commit e77d8fd3732db045eda5efae8144a41b1f6035e5 Author: Atreides Assets Date: Thu Oct 29 11:57:29 2026 +0100 Versie 0.2.0 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0810648 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +data/ +secrets.json +.env +.env.* +node_modules/ +client/vendor/ +dist/ +.git/ +tests/ +*.sqlite +*.db +*.log diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6925141 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf +*.bat text eol=crlf +*.png binary +*.jpg binary +*.woff2 binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8310e97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +/client/vendor/ +/data/ +/dist/ +secrets.json +*.sqlite +*.sqlite-journal +*.sqlite-wal +*.sqlite-shm +*.db +*.log +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fe38935 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM node:22-bookworm-slim AS build + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY scripts/ ./scripts/ +COPY client/ ./client/ +RUN node scripts/vendor.mjs + +FROM node:22-bookworm-slim AS runtime + +WORKDIR /app + +RUN node -e "require('node:sqlite')" + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY client/ ./client/ +COPY server/ ./server/ +COPY shared/ ./shared/ +COPY --from=build /app/client/vendor/ ./client/vendor/ + +RUN mkdir -p /data && chown node:node /data +ENV ASSETAPP_DATA_DIR=/data +VOLUME ["/data"] + +ENV PORT=8080 +EXPOSE 8080 + +USER node + +CMD ["node", "server/supervisor.mjs"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..43c6e23 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Atreides Assets + +What you own, on your own machine: a self-hosted bitcoin portfolio tracker. + +## Run + +Requires Node.js 22.5 or later. + +``` +npm install +npm start +``` + +The app listens on http://localhost:8080 (set `PORT` to change it) and keeps its data in `data/` +(set `ASSETAPP_DATA_DIR` to put it elsewhere). + +## Docker + +``` +docker build -t atreides-assets . +docker run -p 8080:8080 -e ASSETAPP_HOST=0.0.0.0 -v atreides-data:/data atreides-assets +``` + +Without TLS and a password the app only binds to loopback, which inside a container reaches nothing; +`ASSETAPP_HOST` opens it, so keep that port off untrusted networks. + +(c) Atreides Assets and Atreides Services. All rights reserved. diff --git a/client/actions/ai-proposals-widget.js b/client/actions/ai-proposals-widget.js new file mode 100644 index 0000000..2c917ef --- /dev/null +++ b/client/actions/ai-proposals-widget.js @@ -0,0 +1,241 @@ +// Generated, do not edit. sha256:7498a98f290e59aca597eb4a57338d467525bf6c2933429d69f5e8474930a0ac +import { aiProposalsStatus, acceptAiProposal, acceptAiProposals, rejectAiProposal, rejectAiProposals } + from '../data/ai-proposals.js'; +import { CONFIG, PREFERENCES } from '../../shared/config.js'; +import { appSetting, timerSetting, loadAppSettings } from '../app-settings.js'; +import { t } from '../i18n/index.js'; +import { assetLabelHtml } from '../ui/asset-label.js'; +import * as dialog from '../ui/dialog.js'; +import { esc } from '../ui/escape.js'; +import { fmtBase, fmtBasePrice, fmtUnit, priceFromBase, val, currencyName } from '../ui/format.js'; +import { taskCardHtml, nothingPendingHtml } from '../ui/task-card.js'; +import { priceUnitLabel } from '../../shared/units.js'; +import { localDateNl } from '../../shared/time.js'; + +const ONOMKEERBAAR = new Set(CONFIG.AI.DESTRUCTIVE_PROPOSAL_KINDS); + +const assetVan = p => ({ asset_name: p.asset_name, asset_symbol: p.asset_symbol }); + +function detailTekst(detail) { + if (typeof detail !== 'string' || !detail.startsWith('{')) return detail; + try { + const o = JSON.parse(detail); + return o && o.key ? t(o.key, o.params) : detail; + } catch { return detail; } +} + +const meta = p => ({ + kind: p.asset_kind, decimals: p.asset_decimals, unit: p.asset_unit, + subunit: p.asset_subunit, displayUnit: p.asset_display_unit, +}); + +const eenheidIsSymbool = p => { + const asset = meta(p); + const getoond = asset.displayUnit === 'subunit' && asset.subunit ? asset.subunit : asset.unit; + return String(getoond || '').toUpperCase() === String(p.asset_symbol || '').toUpperCase(); +}; + +const koers = p => { + const asset = meta(p); + return `${fmtBasePrice(priceFromBase(p.fiatPrice, asset))} ${t('aiProposals.perUnit')} ${priceUnitLabel(asset)}`; +}; + +const leesbareWaarde = (p, json) => { + try { return toon(p, JSON.parse(json)); } + catch { return String(json); } +}; + +const toon = (p, v) => { + const spec = PREFERENCES[p.scope]?.[p.key]; + if (spec?.type === 'bool') return t(Number(v) ? 'aiProposals.preferenceOn' : 'aiProposals.preferenceOff'); + return String(v); +}; + +const huidigeVoorkeur = (p) => { + try { + const v = p.scope === 'timers' ? timerSetting(p.key) : appSetting(p.key); + return v == null ? null : toon(p, v); + } catch { return null; } +}; + +const OMSCHRIJVING = { + price_point: p => + `${t('aiProposals.kindPricePoint')}: ${assetLabelHtml(assetVan(p))} · ${val(koers(p))} ` + + `${esc(localDateNl(p.observedAt))}`, + + account: p => + `${t('aiProposals.kindAccount')}: ${esc(p.name)} ` + + `${esc(t('enums.account_kind.' + p.accountKind))} · ` + + `${esc(p.isOwnCustody ? t('aiProposals.ownCustody') : t('aiProposals.thirdParty'))}`, + + account_delete: p => + `${t('aiProposals.kindAccountDelete')}: ${esc(p.account_name)} ` + + `${esc(t('enums.account_kind.' + p.account_kind))}`, + + portfolio: p => + `${t('aiProposals.kindPortfolio')}: ${esc(p.name)} ` + + `${esc(t('aiProposals.portfolioIn'))} ${esc(currencyName(p.currency))} (${esc(p.currency)})
` + + `${esc(t('aiProposals.portfolioNote'))}`, + + preference: p => { + const nu = huidigeVoorkeur(p); + return `${t('aiProposals.kindPreference')}: ${esc(p.scope)}.${esc(p.key)} ` + + `→ ${val(leesbareWaarde(p, p.value))}` + + (nu === null ? '' : ` (${esc(t('aiProposals.preferenceCurrent'))} ${esc(nu)})`) + + `
${esc(t('aiProposals.preferenceNote'))}`; + }, + + source_choice: p => + `${t('aiProposals.kindSourceChoice')}: ${assetLabelHtml(assetVan(p))} · ` + + `${esc(t('integrations.sourceCap.' + p.scope))} · ${esc(p.provider_name || p.key)} ` + + `${esc(t(p.value === 'first' ? 'aiProposals.sourceFirst' : 'aiProposals.sourceRemove'))}
` + + `${esc(t('aiProposals.sourceChoiceNote'))}`, + + valuation: p => { + const asset = meta(p); + const grootboek = p.ledger_balance == null ? null : Number(p.ledger_balance); + const verschil = grootboek == null ? null : Number(p.balance) - grootboek; + const naast = grootboek == null ? '' : + ` · ${t('aiProposals.ledger')} ${val(fmtUnit(grootboek, asset))}` + + (verschil === 0 ? '' : ` · ${t('aiProposals.diff')} ${verschil > 0 ? '+' : '−'}${val(fmtUnit(Math.abs(verschil), asset))}`); + return `${t('aiProposals.kindValuation')}: ${val(fmtUnit(p.balance, asset))} ` + + `${assetLabelHtml(assetVan(p), { withoutSymbol: eenheidIsSymbool(p) })} ` + + `· ${esc(p.account_name || '')}${naast}
` + + `${esc(localDateNl(p.observedAt))}`; + }, + + event: p => { + const waar = [p.from_name, p.to_name].filter(Boolean).map(esc).join(' → '); + const prijs = p.fiatPrice == null ? '' : ` · ${val(koers(p))}`; + const bedragen = [ + p.fiatFee == null ? null : val(fmtBase(p.fiatFee)), + p.unitFee == null ? null : val(fmtUnit(p.unitFee, meta(p))), + ].filter(Boolean); + const kosten = bedragen.length ? ` · ${t('aiProposals.fee')} ${bedragen.join(' + ')}` : ''; + return `${esc(t('enums.event_type.' + p.eventType))}: ${val(fmtUnit(p.amount, meta(p)))} ` + + `${assetLabelHtml(assetVan(p), { withoutSymbol: eenheidIsSymbool(p) })}${prijs}${kosten}
` + + `${waar} · ${esc(localDateNl(p.observedAt))}`; + }, +}; + +function regel(p) { + const maak = OMSCHRIJVING[p.kind]; + const tekst = maak ? maak(p) : esc(t('aiProposals.unknownKind') + ` (${p.kind})`); + const notitie = p.note ? `
${esc(p.note)}
` : ''; + const mislukt = p.detail ? `
${esc(t('aiProposals.failed'))}: ${esc(detailTekst(p.detail))}
` : ''; + const weg = ONOMKEERBAAR.has(p.kind); + const waarschuwing = weg + ? `
${esc(t('aiProposals.irreversible'))}
` : ''; + const opening = p.kind === 'valuation' && p.events_before === 0 + ? `
${esc(t('aiProposals.openingBalance'))}
` : ''; + return `
+
+
${tekst}${notitie}${waarschuwing}${opening}${mislukt}
+
+ + +
+
+
`; +} + +function redenTekst(u) { + if (!u.key) return u.reden; + try { return t(u.key, u.params); } catch { return u.reden; } +} + +async function meldMislukt(uitkomsten) { + const stuk = uitkomsten.filter(u => !u.ok); + if (!stuk.length) return; + await dialog.alert({ + title: t('aiProposals.partialTitle'), + message: `${t('aiProposals.partialBody')}\n\n${stuk.map(u => `- ${redenTekst(u)}`).join('\n')}`, + }); +} + +let live = null; +let liveGebonden = false; + +function bindLive() { + if (liveGebonden) return; + liveGebonden = true; + document.addEventListener('ai-proposals-changed', async () => { + if (!live || !live.host || !live.host.isConnected) { live = null; return; } + try { await renderAiProposals(live.host, live.refresh); } catch { } + }); +} + +export async function renderAiProposals(host, refresh) { + live = { host, refresh }; + bindLive(); + const { enabled, pending } = await aiProposalsStatus(); + + if (!enabled) { host.innerHTML = ''; return null; } + + if (!pending.length) { + host.innerHTML = taskCardHtml({ title: t('aiProposals.title'), body: nothingPendingHtml() }); + return 0; + } + + const teVerwijderen = pending.filter(p => ONOMKEERBAAR.has(p.kind)).length; + const bulkUitleg = teVerwijderen + ? `
${esc(t('aiProposals.bulkSkips').replace('{n}', teVerwijderen))}
` + : ''; + + host.innerHTML = taskCardHtml({ + title: t('aiProposals.title'), + actions: `
+ + +
`, + body: `
${t('aiProposals.intro')}
+ ${bulkUitleg} + ${pending.map(regel).join('')}`, + }); + + const ids = pending.filter(p => !ONOMKEERBAAR.has(p.kind)).map(p => p.id); + const alleIds = pending.map(p => p.id); + + host.querySelector('#aiprop-accept-all').onclick = async () => { + if (!ids.length) { + await dialog.alert({ title: t('aiProposals.title'), message: t('aiProposals.bulkNothing') }); + return; + } + if (!(await dialog.confirm({ message: t('aiProposals.acceptAllConfirm').replace('{n}', ids.length) }))) return; + await meldMislukt(await acceptAiProposals(ids)); + if (pending.some(p => ids.includes(p.id) && p.kind === 'preference')) { + await loadAppSettings(); + location.reload(); + return; + } + refresh(); + }; + + host.querySelector('#aiprop-reject-all').onclick = async () => { + if (!(await dialog.confirm({ message: t('aiProposals.rejectAllConfirm').replace('{n}', alleIds.length), danger: true }))) return; + await meldMislukt(await rejectAiProposals(alleIds, t('aiProposals.rejectedByUser'))); + refresh(); + }; + + host.querySelectorAll('[data-aiprop]').forEach(rij => { + const id = Number(rij.dataset.aiprop); + rij.querySelectorAll('[data-aiact]').forEach(knop => knop.onclick = async () => { + try { + if (knop.dataset.aiact === 'accept') { + if (rij.dataset.aidanger === '1' + && !(await dialog.confirm({ message: t('aiProposals.acceptDeleteConfirm'), danger: true }))) return; + await acceptAiProposal(id); + if (rij.dataset.aikind === 'preference') { + await loadAppSettings(); + location.reload(); + return; + } + } else await rejectAiProposal(id, t('aiProposals.rejectedByUser')); + } catch (e) { + await dialog.alert({ title: t('dialog.error'), message: e.message }); + } + refresh(); + }); + }); + return pending.length; +} diff --git a/client/actions/interest-compounding.js b/client/actions/interest-compounding.js new file mode 100644 index 0000000..0028bd4 --- /dev/null +++ b/client/actions/interest-compounding.js @@ -0,0 +1,11 @@ +// Generated, do not edit. sha256:030fb9043ec0bb393242bec50f13de9146004cd70907c5230c0bfc59d06b39b7 +const empty = v => v == null || String(v).trim() === ''; + +export function compoundsViaBalance(s) { + return s.rateKind === 'fixed' && empty(s.principalAmount) + && !empty(s.principalAssetId) && Number(s.principalAssetId) === Number(s.payoutAssetId); +} + +export function looksLikeDepositWithoutPrincipal(s) { + return compoundsViaBalance(s) && !empty(s.endsOn); +} diff --git a/client/actions/interest-order.js b/client/actions/interest-order.js new file mode 100644 index 0000000..cb9a435 --- /dev/null +++ b/client/actions/interest-order.js @@ -0,0 +1,20 @@ +// Generated, do not edit. sha256:dee49c9ff6e746770628ebfbeb55d9732c23407998d26306fb4c07dd8cc4e1ec +export const dueKey = d => `${d.scheduleId}|${d.periodEnd}`; + +export function byPeriod(due) { + return [...due].sort((a, b) => + a.periodEnd.localeCompare(b.periodEnd) + || a.periodStart.localeCompare(b.periodStart) + || a.scheduleId - b.scheduleId); +} + +export function bookableDues(due) { + const oudstePerAccount = new Map(); + for (const d of due) { + const huidig = oudstePerAccount.get(d.accountId); + if (huidig == null || d.periodEnd < huidig) oudstePerAccount.set(d.accountId, d.periodEnd); + } + return new Set(due + .filter(d => d.periodEnd === oudstePerAccount.get(d.accountId)) + .map(dueKey)); +} diff --git a/client/actions/interest-widget.js b/client/actions/interest-widget.js new file mode 100644 index 0000000..dab355a --- /dev/null +++ b/client/actions/interest-widget.js @@ -0,0 +1,110 @@ +// Generated, do not edit. sha256:6eef7930e5d81c104d37fffaf0d8f5c9109485ed27fb20ffaaecdf946a306931 +import { scheduleForecast, dueCreditsForAccount, dueCreditsAll, bookDueCredit, dismissDueCredit } from '../data/interest-credits.js'; +import { byPeriod, bookableDues, dueKey } from './interest-order.js'; +import { listSchedulesForAccount } from '../data/interest-schedules.js'; +import { t } from '../i18n/index.js'; +import * as dialog from '../ui/dialog.js'; +import { fmtUnit, val, pct } from '../ui/format.js'; +import { taskCardHtml, nothingPendingHtml } from '../ui/task-card.js'; +import { esc } from '../ui/escape.js'; +import { CHEVRON } from '../ui/icons.js'; +import { expanders } from '../ui/expand.js'; + +const dueMeta = d => ({ decimals: d.payout_decimals, unit: d.payout_unit, subunit: d.payout_subunit, displayUnit: d.payout_display_unit }); + +const dueKnoppen = (i, kan) => + `` + + ``; + +const dueUitleg = (due, boekbaar) => due.some(d => !boekbaar.has(dueKey(d))) + ? `
${t('accounts.bookOldestFirst')}
` : ''; + +function wireDue(scopeEl, due, refresh) { + scopeEl.querySelectorAll('[data-book]').forEach(b => b.onclick = async () => { + const d = due[Number(b.dataset.book)]; + const amt = `${fmtUnit(d.projected_sub, dueMeta(d))}`; + if (!(await dialog.confirm({ message: `${t('accounts.confirmBook')} ${amt} (${d.periodStart} → ${d.periodEnd})?` }))) return; + try { await bookDueCredit(d, d.projected_sub); refresh(); } + catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); } + }); + scopeEl.querySelectorAll('[data-dismiss]').forEach(b => b.onclick = async () => { + const d = due[Number(b.dataset.dismiss)]; + try { await dismissDueCredit(d); refresh(); } + catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); } + }); +} + +const fig = (label, value, sub = '') => `
${label}
` + + `
${value}
${sub ? `
${sub}
` : ''}
`; + +export async function renderScheduleStatus(statusHost, accountId, refresh) { + if (!statusHost) return false; + const today = new Date().toISOString().slice(0, 10); + const scheds = (await listSchedulesForAccount(accountId)).filter(s => s.rateKind === 'fixed' && (!s.endsOn || s.endsOn >= today)); + const due = byPeriod(await dueCreditsForAccount(accountId)); + const boekbaar = bookableDues(due); + + const prognoses = await Promise.all(scheds.map(s => scheduleForecast(s))); + const expand = await expanders('interest', { upcoming: false }); + const projHtml = scheds.map((s, i) => { + const f = prognoses[i]; + const m = { decimals: s.payout_decimals, unit: s.payout_unit, subunit: s.payout_subunit, displayUnit: s.payout_display_unit }; + const pm = { decimals: s.principal_decimals, unit: s.principal_unit, subunit: s.principal_subunit, displayUnit: s.principal_display_unit }; + const asset = s.principal_symbol === s.payout_symbol ? esc(s.payout_symbol) : `${esc(s.principal_symbol)} → ${esc(s.payout_symbol)}`; + const naam = s.note ? esc(s.note) : asset; + const termen = [ + ...(s.note ? [asset] : []), + pct(s.annualRate), + s.principalAmount != null ? t('accounts.projOver', { bedrag: val(fmtUnit(s.principalAmount, pm)) }) : t('interest.principalFollowsBalance'), + t('enums.interest_cadence.' + s.creditCadence), + s.endsOn ? t('accounts.projUntil', { datum: esc(s.endsOn) }) : t('interest.endsOnOpen'), + ].join(' · '); + const volgende = f.upcoming[0]; + const cijfers = [ + fig(t('accounts.projReceived'), val(fmtUnit(f.realizedSub, m))), + ...(f.termTotalSub != null ? [ + fig(t('accounts.projExpectRemain'), val(fmtUnit(f.remainingSub, m)), t('accounts.projCount', { n: f.remainingCount })), + fig(t('accounts.projTermTotal'), val(fmtUnit(f.termTotalSub, m))), + ] : []), + ...(f.termTotalSub != null && f.sameAsset ? [ + fig(t('accounts.projEndBalance', { datum: esc(s.endsOn) }), val(fmtUnit(s.principalAmount + f.termTotalSub, m)), t('accounts.projEndBalanceSub')), + ] : []), + ...(volgende ? [fig(t('accounts.projNext'), val(fmtUnit(volgende.projectedSub, m)), esc(volgende.periodEnd))] : []), + ].join(''); + const komend = f.upcoming.length > 1 + ? `
${t('accounts.projAllUpcoming', { n: f.upcoming.length })}` + + `` + + `
${f.upcoming.map(p => `
${esc(p.periodEnd)}` + + `${val(fmtUnit(p.projectedSub, m))}
`).join('')}
` + : ''; + return `
${naam}` + + `${termen}
${cijfers}
${komend}
`; + }).join(''); + + const dueHtml = due.length ? `
${t('accounts.dueTitle')}
` + + due.map((d, i) => `
` + + `${d.periodStart} → ${d.periodEnd}${val(fmtUnit(d.projected_sub, dueMeta(d)))}` + + `${dueKnoppen(i, boekbaar.has(dueKey(d)))}
`).join('') + + dueUitleg(due, boekbaar) + '
' : ''; + + statusHost.innerHTML = projHtml + dueHtml; + expand.bind(statusHost); + statusHost.querySelectorAll('.ip-upcoming').forEach(d => d.addEventListener('toggle', () => { + document.querySelectorAll('.ip-upcoming').forEach(o => { if (o.open !== d.open) o.open = d.open; }); + })); + wireDue(statusHost, due, refresh); + return Boolean(projHtml || dueHtml); +} + +export async function renderInterestWidget(host, refresh) { + const due = byPeriod(await dueCreditsAll()); + const boekbaar = bookableDues(due); + const body = due.length === 0 + ? nothingPendingHtml() + : due.map((d, i) => `
` + + `${esc(d.account_name)} · ${d.periodStart} → ${d.periodEnd}: ${val(fmtUnit(d.projected_sub, dueMeta(d)))}` + + `${dueKnoppen(i, boekbaar.has(dueKey(d)))}
`).join('') + dueUitleg(due, boekbaar); + host.innerHTML = taskCardHtml({ title: t('actions.interestTitle'), body }); + wireDue(host, due, refresh); + return due.length; +} diff --git a/client/actions/notifications-widget.js b/client/actions/notifications-widget.js new file mode 100644 index 0000000..ffb77ec --- /dev/null +++ b/client/actions/notifications-widget.js @@ -0,0 +1,62 @@ +// Generated, do not edit. sha256:ce839cfe04c94b47ff548f20a8992356bcbc456e5e878e1829f94a38a663c5f1 +import { currentNotifications, notificationDetail, dismissNotification } from '../data/notifications.js'; +import { localDateNl } from '../../shared/time.js'; +import { t } from '../i18n/index.js'; +import { TARGETS, NOTIFICATION_TARGETS, NOTIFICATION_PRIORITY, targetHash } from '../../shared/config.js'; +import { CHECK, CHEVRON_RIGHT } from '../ui/icons.js'; +import { esc } from '../ui/escape.js'; +import { taskCardHtml, nothingPendingHtml } from '../ui/task-card.js'; + +const targetFor = kind => targetHash(NOTIFICATION_TARGETS[kind] || TARGETS.ACTIONS); + +const rank = kind => NOTIFICATION_PRIORITY.indexOf(kind); + +async function detailFor(n) { + if (n.kind === 'schedule_ended') { + const d = await notificationDetail(n); + if (!d) return ''; + return `${d.account} · ${d.asset}${d.extra ? ` +${d.extra}` : ''}`; + } + return t('notify.hint.' + n.kind); +} + +async function rowHtml(n) { + const target = targetFor(n.kind); + const detail = await detailFor(n); + const date = localDateNl(n.createdAt); + const info = n.kind === 'schedule_ended' ? ' is-info' : ''; + return `
+ +
${t('notify.' + n.kind)}
+ ${detail ? `
${esc(detail)}
` : ''} +
+ ${date || ''} + +
+
`; +} + +export async function renderNotifications(host, onChanged) { + const rerender = () => renderNotifications(host, onChanged); + const items = await currentNotifications(); + const sorted = [...items].sort((a, b) => rank(a.kind) - rank(b.kind)); + + const body = sorted.length === 0 + ? nothingPendingHtml() + : `
${(await Promise.all(sorted.map(rowHtml))).join('')}
`; + + host.innerHTML = taskCardHtml({ title: t('notify.title'), body }); + + host.querySelectorAll('[data-dismiss]').forEach(b => b.onclick = async e => { + e.stopPropagation(); + await dismissNotification(Number(b.dataset.dismiss)); + (onChanged || rerender)(); + }); + + host.querySelectorAll('.notify-row').forEach(row => { + const go = () => { location.hash = row.dataset.target; }; + row.onclick = e => { if (e.target.closest('[data-dismiss]') || e.target.closest('a')) return; go(); }; + row.onkeydown = e => { if (e.key === 'Enter' && e.target === row) go(); }; + }); +} diff --git a/client/actions/reconcile-bulk.js b/client/actions/reconcile-bulk.js new file mode 100644 index 0000000..a6a6f53 --- /dev/null +++ b/client/actions/reconcile-bulk.js @@ -0,0 +1,21 @@ +// Generated, do not edit. sha256:7e67d01e5ccf6ce7a103e0d0647ba030da33d566237ec5c657ed27cb9e070f30 +export const isForward = p => p.tx_layer === 'lightning' && typeof p.transactionHash === 'string' + && p.transactionHash.startsWith('fwd:'); + +export function bulkPlan(pending) { + return (pending || []).map(p => { + if (p.tx_kind) return { id: p.id, action: 'internal', proposal: p }; + if (p.tx_layer !== 'lightning') return { id: p.id, action: 'skip', reason: 'onchain', proposal: p }; + if (isForward(p)) return { id: p.id, action: 'interest', proposal: p }; + return { id: p.id, action: 'trade', proposal: p }; + }); +} + +export function bulkCounts(plan) { + const n = { internal: 0, interest: 0, trade: 0, onchain: 0 }; + for (const s of plan || []) { + if (s.action === 'skip') n[s.reason]++; + else n[s.action]++; + } + return n; +} diff --git a/client/actions/reconcile-widget.js b/client/actions/reconcile-widget.js new file mode 100644 index 0000000..66909f9 --- /dev/null +++ b/client/actions/reconcile-widget.js @@ -0,0 +1,363 @@ +// Generated, do not edit. sha256:405d455ffaf6423b7f59a7abc882b3d6e788cead63eb4b179c23c015dd404720 +import { listSelectableAccounts } from '../data/accounts.js'; +import { dayNl, localDate, localDateTimeNl } from '../../shared/time.js'; +import { + listPendingProposals, resolveProposalAsTrade, resolveProposalAsTransfer, resolveProposalAsInternal, + resolveProposalAsInterest, ignoreProposal, + deleteProposal, ignoreAllPendingProposals, deleteAllPendingProposals, revertProposal, + listHandledProposals +} from '../data/proposals.js'; +import { syncAddressLinks } from '../data/tx-links.js'; +import { listAllWalletSources } from '../data/wallet-sources.js'; +import { t } from '../i18n/index.js'; +import { assetLabelHtml } from '../ui/asset-label.js'; +import * as dialog from '../ui/dialog.js'; +import { fmtInt, val } from '../ui/format.js'; +import { baseCurrency } from '../app-settings.js'; +import { esc } from '../ui/escape.js'; +import { taskCardHtml, nothingPendingHtml } from '../ui/task-card.js'; +import { isOnchainSource, scanSource } from '../onchain.js'; +import { openMatchPopover } from '../ui/match-popover.js'; +import { onchainAsset } from '../data/tx-links.js'; +import { spotOnDate, spotAtMoment } from '../ui/fiat.js'; +import { priceUnitLabel, totalFromPrice, priceFromTotal, priceToBase, priceFromBase } from '../../shared/units.js'; +import { expanders } from '../ui/expand.js'; +import { bulkPlan, bulkCounts, isForward } from './reconcile-bulk.js'; +import { memoLine } from '../lightning/memo-line.js'; + +let wizIdx = 0; + +export async function renderReconcile(host, onChanged) { + const rerender = () => renderReconcile(host, onChanged); + const refresh = () => (onChanged ? onChanged() : rerender()); + + await syncAddressLinks(); + const pending = await listPendingProposals(); + const handled = await listHandledProposals(); + const expand = await expanders('reconcile', { drawer: false }); + + if (wizIdx >= pending.length) wizIdx = Math.max(0, pending.length - 1); + const current = pending[wizIdx] || null; + + const actions = ``; + + const wizard = pending.length === 0 + ? nothingPendingHtml() + : `
+ + ${wizIdx + 1} / ${pending.length} + +
+ ${recRow(current, true, current.id)}`; + + host.innerHTML = taskCardHtml({ + title: t('home.reconcile'), + actions, + body: `
+ ${wizard} + ${drawerSection(pending, handled, current, expand)}`, + }); + + const prev = host.querySelector('#rec-prev'); + if (prev) prev.onclick = () => { wizIdx = Math.max(0, wizIdx - 1); rerender(); }; + const next = host.querySelector('#rec-next'); + if (next) next.onclick = () => { wizIdx = Math.min(pending.length - 1, wizIdx + 1); rerender(); }; + expand.bind(host); + + const igAll = host.querySelector('#rec-ignore-all'); + if (igAll) igAll.onclick = async () => { + if (!(await dialog.confirm({ message: t('home.recIgnoreAllConfirm') }))) return; + await ignoreAllPendingProposals(); refresh(); + }; + const bookAll = host.querySelector('#rec-book-all'); + if (bookAll) bookAll.onclick = () => bookAllLightning(host, pending, refresh); + const delAll = host.querySelector('#rec-delete-all'); + if (delAll) delAll.onclick = async () => { + if (!(await dialog.confirm({ message: t('home.recDeleteAllConfirm'), danger: true }))) return; + await deleteAllPendingProposals(); refresh(); + }; + + host.querySelectorAll('[data-prop]').forEach(row => { + const id = Number(row.dataset.prop); + const form = row.querySelector('.prop-form'); + row.querySelectorAll('[data-act]').forEach(btn => btn.onclick = async () => { + const act = btn.dataset.act; + if (act === 'ignore') { await ignoreProposal(id); refresh(); return; } + if (act === 'internal' || act === 'interest') { + try { await (act === 'internal' ? resolveProposalAsInternal(id) : resolveProposalAsInterest(id)); refresh(); } + catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); } + return; + } + if (act === 'link') { + openMatchPopover(btn, { kind: 'tx', id: Number(row.dataset.wtx), assetId: Number(row.dataset.asset), onChange: refresh }); + return; + } + if (act === 'trade') { showTradeForm(refresh, pending.find(x => x.id === id), form); return; } + await showTransferForm(refresh, id, form); + }); + }); + + host.querySelectorAll('[data-del-prop]').forEach(btn => btn.onclick = async () => { + if (!(await dialog.confirm({ message: t('home.recDeleteConfirm'), danger: true }))) return; + await deleteProposal(Number(btn.dataset.delProp)); refresh(); + }); + + host.querySelectorAll('[data-revert]').forEach(btn => btn.onclick = async () => { + if (!(await dialog.confirm({ message: t('home.recRevertConfirm') }))) return; + try { await revertProposal(Number(btn.dataset.revert)); refresh(); } + catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); } + }); + + const rematch = host.querySelector('#rec-rematch'); + if (rematch) rematch.onclick = () => runUpdate(host, refresh); + return pending.length; +} + +function shortTx(h) { return h ? `${h.slice(0, 8)}…${h.slice(-4)}` : ''; } + +function momentCel(p) { + const bevestigd = !!p.tx_block_time; + const wanneer = bevestigd ? p.tx_block_time : p.detectedAt; + const uitleg = bevestigd ? t('home.recTxAt') : t('home.recDetectedAt'); + return `(${esc(localDateTimeNl(wanneer))})`; +} + +function recRow(p, wiz, currentId) { + const dir = p.direction === 'in' ? t('home.recIn') : t('home.recOut'); + const wtxAttrs = p.walletTransactionId ? ` data-wtx="${p.walletTransactionId}" data-asset="${p.assetId}"` : ''; + const linkBtn = p.walletTransactionId ? `` : ''; + const cls = wiz ? 'rec-item rec-wiz-item' : `rec-item${p.id === currentId ? ' is-current' : ''}`; + const laag = p.tx_layer === 'lightning' + ? ` · ${t('home.recLightning')}${p.via_wallet != null + ? ` ${t('home.recVia', { wallet: p.via_wallet || t('home.recViaNwc') })}` : ''}` + : ''; + const intern = p.tx_kind ? ` · ${t(`home.recInternal_${p.tx_kind}`)}` : ''; + const internLabel = p.tx_node_account + ? t('home.recAsChannelTransfer', { account: p.tx_node_account }) : t('home.recAsInternal'); + const internBtn = p.tx_kind + ? `` : ''; + const forward = isForward(p); + const renteBtn = forward + ? `` : ''; + const eerste = p.tx_kind || forward; + const memo = memoLine(p.tx_memo, p.direction, 120); + const memoHtml = memo ? `
${esc(memo.text)}
` : ''; + return `
+
+
${val(`${fmtInt(p.amount)} sat`)} ${assetLabelHtml(p)} ${dir}${esc(laag)}${esc(intern)} · ${esc(p.account_name)} + ${momentCel(p)}${p.transactionHash ? ` · ${esc(shortTx(p.transactionHash))}` : ''}${memoHtml}
+
+ ${linkBtn} + ${internBtn}${renteBtn} + + + + +
+
+ +
`; +} + +function drawerSection(pending, handled, current, expand) { + const total = pending.length + handled.length; + const teBoeken = bulkPlan(pending).filter(s => s.action !== 'skip').length; + const bulk = `
+ ${teBoeken ? `` : ''} + + +
`; + const rows = total === 0 + ? `
${t('home.recHandledNone')}
` + : pending.map(p => recRow(p, false, current ? current.id : 0)).join('') + handled.map(handledRow).join(''); + return `
+ ${t('home.recAll')} (${total}) + ${bulk} +
${rows}
+
`; +} + +const STATUS_BADGE = { + accepted_trade: 'badge-pos', + accepted_transfer: 'badge-pos', + accepted_interest: 'badge-pos', + ignored: 'badge-neutral', + auto_linked: 'badge-outline', +}; +const REVERTIBLE = new Set(['accepted_trade', 'accepted_transfer', 'accepted_interest', 'ignored']); + +function handledRow(p) { + const dir = p.direction === 'in' ? t('home.recIn') : t('home.recOut'); + const badge = `${t('enums.reconcile_status.' + p.status)}`; + const revert = REVERTIBLE.has(p.status) + ? `` : ''; + const del = p.status !== 'auto_linked' + ? `` : ''; + return `
+
+
${val(`${fmtInt(p.amount)} sat`)} ${assetLabelHtml(p)} ${dir} · ${esc(p.account_name)} + ${momentCel(p)}${p.transactionHash ? ` · ${esc(shortTx(p.transactionHash))}` : ''}
+
${revert}${del}${badge}
+
+
`; +} + +let updating = false; +async function runUpdate(host, refresh) { + if (updating) return; + updating = true; + const btn = host.querySelector('#rec-rematch'); + const prog = host.querySelector('#rec-progress'); + if (btn) btn.disabled = true; + try { + for (const s of (await listAllWalletSources()).filter(isOnchainSource)) { + const label = s.name ? `${s.account_name} · ${s.name}` : s.account_name; + if (prog) prog.textContent = `${t('home.updating')} ${label}`; + try { await scanSource(s, txt => { if (prog) prog.textContent = `${label}: ${txt}`; }); } + catch (e) { console.error('[reconcile-update]', e); } + } + await syncAddressLinks(); + } finally { + updating = false; + } + refresh(); +} + +async function bookAllLightning(host, pending, refresh) { + const plan = bulkPlan(pending); + const n = bulkCounts(plan); + const ok = await dialog.confirm({ + message: t('home.recBookAllConfirm', { trade: n.trade, internal: n.internal, interest: n.interest, skipped: n.onchain }), + }); + if (!ok) return; + const prog = host.querySelector('#rec-progress'); + const teller = { booked: 0, noPrice: 0, failed: 0 }; + const werk = plan.filter(s => s.action !== 'skip'); + for (const [i, s] of werk.entries()) { + if (prog) prog.textContent = t('home.recBookAllProgress', { i: i + 1, n: werk.length }); + try { + if (s.action === 'internal') { + await resolveProposalAsInternal(s.id); + } else if (s.action === 'interest') { + await resolveProposalAsInterest(s.id); + } else { + const k = await koersVoor(s.proposal); + if (!k) { teller.noPrice++; continue; } + await resolveProposalAsTrade(s.id, { fiatPrice: k.price, priceOrigin: k.origin }); + } + teller.booked++; + } catch (e) { + teller.failed++; + console.error('[reconcile-book-all]', e); + } + } + await dialog.alert({ title: t('home.recBookAllDoneTitle'), message: t('home.recBookAllDone', teller) }); + refresh(); +} + +async function koersVoor(p) { + const asset = await onchainAsset(); + if (!asset || asset.id !== p.assetId) return null; + const dag = localDate(p.tx_block_time || p.detectedAt); + const rond = n => Math.round(n * 100) / 100; + const moment = p.tx_block_time ? await spotAtMoment(asset, p.tx_block_time) : null; + if (moment) return { price: rond(moment.price), origin: 'market_moment', at: moment.at, dag, asset }; + const koers = await spotOnDate(asset, dag); + return koers == null ? null : { price: rond(koers), origin: 'market_day', dag, asset }; +} + +async function vulKoers(p, form) { + const input = form.querySelector('[data-price]'); + const hint = form.querySelector('[data-spot]'); + hint.textContent = t('home.recPriceLoading'); + try { + const k = await koersVoor(p); + if (!k) { hint.textContent = t('home.recPriceUnknown'); return null; } + input.value = String(k.price); + input.dataset.filled = input.value; + input.dataset.origin = k.origin; + input.dispatchEvent(new Event('input', { bubbles: true })); + hint.textContent = k.origin === 'market_moment' + ? t('home.recPriceAt', { time: localDateTimeNl(new Date(k.at).toISOString()), unit: priceUnitLabel(k.asset) }) + : t('home.recPriceFrom', { date: dayNl(k.dag), unit: priceUnitLabel(k.asset) }); + return k.price; + } catch { + hint.textContent = t('home.recPriceUnknown'); + return null; + } +} + +function koppelBedragEnPrijs(p, form) { + const prijsEl = form.querySelector('[data-price]'); + const totaalEl = form.querySelector('[data-total]'); + onchainAsset().then(asset => { + if (!asset || asset.id !== p.assetId) return; + const dec = asset.decimals; + const rond = n => (n == null ? '' : String(Math.round(n * 100) / 100)); + const uitPrijs = () => { + const basis = totalFromPrice(p.amount, dec, priceToBase(prijsEl.value, asset)); + totaalEl.value = rond(basis); + }; + const uitTotaal = () => { + const prijs = priceFromTotal(totaalEl.value, p.amount, dec); + prijsEl.value = prijs === null ? '' : rond(priceFromBase(prijs, asset)); + }; + prijsEl.addEventListener('input', uitPrijs); + totaalEl.addEventListener('input', uitTotaal); + if (prijsEl.value !== '') uitPrijs(); + }).catch(() => { }); +} + +function showTradeForm(refresh, p, form) { + if (!p) return; + form.classList.remove('hidden'); + form.innerHTML = ` +
+
+ + +
+
+ + +
+
+ +
+
`; + + vulKoers(p, form); + koppelBedragEnPrijs(p, form); + + form.querySelector('[data-confirm]').onclick = async () => { + const err = form.querySelector('[data-err]'); err.textContent = ''; + const prijsEl = form.querySelector('[data-price]'); + const veld = prijsEl.value; + const price = veld === '' ? await vulKoers(p, form) : parseFloat(veld); + if (!Number.isFinite(price)) { err.textContent = t('home.recPriceRequired'); return; } + const gevuld = prijsEl.dataset.filled; + const priceOrigin = gevuld !== undefined && Number(gevuld) === price ? (prijsEl.dataset.origin || 'market_day') : 'paid'; + try { await resolveProposalAsTrade(p.id, { fiatPrice: price, priceOrigin }); refresh(); } + catch (e) { err.textContent = e.message; } + }; +} + +async function showTransferForm(refresh, id, form) { + form.classList.remove('hidden'); + const opts = (await listSelectableAccounts()).map(a => ``).join(''); + form.innerHTML = ` +
+
+ + +
+ +
+
`; + form.querySelector('[data-confirm]').onclick = async () => { + const err = form.querySelector('[data-err]'); err.textContent = ''; + const other = Number(form.querySelector('[data-counter]').value); + try { await resolveProposalAsTransfer(id, { other_account_id: other }); refresh(); } + catch (e) { err.textContent = e.message; } + }; +} diff --git a/client/actions/reservation-book.js b/client/actions/reservation-book.js new file mode 100644 index 0000000..39c912a --- /dev/null +++ b/client/actions/reservation-book.js @@ -0,0 +1,58 @@ +// Generated, do not edit. sha256:a607e071a277540b2c65e9bb3a8807791f7326ce16b2766440a5c537f44ad532 +import { bookReservation, deleteReservation } from '../data/reservations.js'; +import { listSelectableAccounts } from '../data/accounts.js'; +import { t } from '../i18n/index.js'; +import * as dialog from '../ui/dialog.js'; +import { fromSubunit, toSubunit } from '../ui/format.js'; +import { localDate, toUtcIso } from '../../shared/time.js'; + +export const reservationMeta = r => ({ id: r.assetId, kind: r.asset_kind, decimals: r.asset_decimals, + unit: r.asset_unit, subunit: r.asset_subunit, displayUnit: r.asset_display_unit }); +export const reservationDir = r => (r.fromAccountId != null ? 'out' : 'in'); +export const reservationAccount = r => (r.fromAccountId != null + ? { id: r.fromAccountId, name: r.from_name } : { id: r.toAccountId, name: r.to_name }); + +export async function bookReservationFlow(r) { + const own = reservationAccount(r).id; + const accounts = (await listSelectableAccounts({ ookId: null })).filter(a => a.id !== own); + const res = await dialog.form({ + title: t('reservations.bookTitle'), + fields: [ + { key: 'date', label: t('reservations.colDate'), type: 'date', value: localDate(r.occurredAt) }, + { key: 'amount', label: `${t('reservations.colAmount')} (${r.asset_symbol})`, type: 'number', + value: String(fromSubunit(r.amount, reservationMeta(r))) }, + { key: 'other', label: t(reservationDir(r) === 'out' ? 'reservations.otherTo' : 'reservations.otherFrom'), + type: 'select', value: '', + options: [{ value: '', label: t('reservations.outside') }, ...accounts.map(a => ({ value: a.id, label: a.name }))] }, + ], + confirmLabel: t('reservations.book'), + }); + if (!res) return false; + const qty = parseFloat(res.amount); + if (!res.date || !Number.isFinite(qty) || qty <= 0) { + await dialog.alert({ title: t('dialog.error'), message: t('reservations.fill') }); + return false; + } + try { + await bookReservation(r.id, { + occurredAt: toUtcIso(`${res.date}T12:00:00`), + amount: toSubunit(qty, reservationMeta(r)), + otherAccountId: res.other ? Number(res.other) : null, + }); + return true; + } catch (err) { + await dialog.alert({ title: t('dialog.error'), message: err.message }); + return false; + } +} + +export async function settleReservationFlow(r) { + if (!(await dialog.confirm({ message: t('reservations.confirmSettle') }))) return false; + try { + await deleteReservation(r.id); + return true; + } catch (err) { + await dialog.alert({ title: t('dialog.error'), message: err.message }); + return false; + } +} diff --git a/client/actions/reservations-widget.js b/client/actions/reservations-widget.js new file mode 100644 index 0000000..b90c809 --- /dev/null +++ b/client/actions/reservations-widget.js @@ -0,0 +1,28 @@ +// Generated, do not edit. sha256:d20de9a177bb432b6c8f10236c35149637d208ed9ad948a3457ff3eea687978a +import { listDueReservations } from '../data/reservations.js'; +import { t } from '../i18n/index.js'; +import { fmtUnit, val } from '../ui/format.js'; +import { taskCardHtml, nothingPendingHtml } from '../ui/task-card.js'; +import { esc } from '../ui/escape.js'; +import { localDateNl } from '../../shared/time.js'; +import { bookReservationFlow, reservationAccount, reservationDir, reservationMeta, settleReservationFlow } from './reservation-book.js'; + +export async function renderReservationsWidget(host, refresh) { + const due = await listDueReservations(); + const body = due.length === 0 + ? nothingPendingHtml() + : due.map((r, i) => `
` + + `${esc(reservationAccount(r).name)} · ${esc(localDateNl(r.occurredAt))} · ` + + `${t(reservationDir(r) === 'out' ? 'reservations.dirOut' : 'reservations.dirIn')} ` + + `${val(fmtUnit(r.amount, reservationMeta(r)))}${r.note ? ` · ${esc(r.note)}` : ''}` + + `` + + `
`).join(''); + host.innerHTML = taskCardHtml({ title: t('reservations.title'), body }); + host.querySelectorAll('[data-res-book]').forEach(b => b.onclick = async () => { + if (await bookReservationFlow(due[Number(b.dataset.resBook)])) refresh(); + }); + host.querySelectorAll('[data-res-settle]').forEach(b => b.onclick = async () => { + if (await settleReservationFlow(due[Number(b.dataset.resSettle)])) refresh(); + }); + return due.length; +} diff --git a/client/actions/valuation-fee.js b/client/actions/valuation-fee.js new file mode 100644 index 0000000..d15d6c2 --- /dev/null +++ b/client/actions/valuation-fee.js @@ -0,0 +1,23 @@ +// Generated, do not edit. sha256:8cf51d1c864566621979207e78f8179f742e2fdc07a34188bb58149450665be4 +export function feeCandidates({ events = [], valuations = [], v }) { + if (!v || v.accountId == null) return []; + const tot = Date.parse(v.rateAt); + const vorige = valuations + .filter(x => x.id !== v.id && x.accountId === v.accountId && x.assetId === v.assetId + && Date.parse(x.rateAt) < tot) + .reduce((max, x) => Math.max(max, Date.parse(x.rateAt)), -Infinity); + return events + .filter(e => e.assetId === v.assetId && e.fromAccountId === v.accountId + && (e.type === 'sell' || e.type === 'transfer')) + .filter(e => { const op = Date.parse(e.occurredAt); return op > vorige && op <= tot; }) + .sort((a, b) => Date.parse(b.occurredAt) - Date.parse(a.occurredAt) || b.id - a.id); +} + +export function withFee(event, units, note) { + const fee = Math.abs(Number(units) || 0); + return { + ...event, + unitFee: (Number(event.unitFee) || 0) + fee, + note: note ? (event.note ? `${event.note} · ${note}` : note) : event.note, + }; +} diff --git a/client/actions/valuation-widget.js b/client/actions/valuation-widget.js new file mode 100644 index 0000000..cb0f8f4 --- /dev/null +++ b/client/actions/valuation-widget.js @@ -0,0 +1,144 @@ +// Generated, do not edit. sha256:d42fa49545ab26fee8488ce3ebd3d684d5394b406ae802e38e6b624c87d49f71 +import { listSelectableAccounts } from '../data/accounts.js'; +import { createEvent, findEventByRef, deleteEvent, listEvents, updateEvent } from '../data/events.js'; +import { valuationGap, listOpenValuationDiffs, acceptValuationGap, listValuations } from '../data/valuations.js'; +import { feeCandidates, withFee } from './valuation-fee.js'; +import { t } from '../i18n/index.js'; +import { assetLabelHtml } from '../ui/asset-label.js'; +import * as dialog from '../ui/dialog.js'; +import { esc } from '../ui/escape.js'; +import { fmtUnit, val } from '../ui/format.js'; +import { taskCardHtml, nothingPendingHtml } from '../ui/task-card.js'; +import { lightningBalanceDiffs, recordLightningBalance } from '../data/positions.js'; +import { onchainAsset } from '../data/tx-links.js'; +import { spotOnDate } from '../ui/fiat.js'; +import { priceToBase } from '../../shared/units.js'; +import { dayNl, localDate, localDateNl } from '../../shared/time.js'; + +const meta = v => ({ decimals: v.asset_decimals, unit: v.asset_unit, subunit: v.asset_subunit, displayUnit: v.asset_display_unit }); + +const ADJUST_REF_PREFIX = 'val:'; + +export async function bookDifference(v, refresh) { + if (!v || !v.accountId) return; + const gap = await valuationGap(v); + if (!gap) return; + + const refVan = (tegen) => (tegen ? `${ADJUST_REF_PREFIX}${v.id}:${tegen}` : `${ADJUST_REF_PREFIX}${v.id}`); + const netVan = (e) => (e ? (e.toAccountId === v.accountId ? e.amount : -e.amount) : 0); + const anderen = (await listSelectableAccounts({ ookId: null })).filter(a => a.id !== v.accountId); + + const kandidaten = [{ tegen: null, naam: null }, ...anderen.map(a => ({ tegen: a.id, naam: a.name }))]; + const priors = new Map(); + for (const k of kandidaten) { + const e = await findEventByRef(refVan(k.tegen)); + if (e) priors.set(k.tegen, e); + } + const bedragVan = (k) => gap + netVan(priors.get(k.tegen)); + const keuzes = kandidaten.map(k => { + const bedrag = bedragVan(k); + const uit = bedrag < 0; + const basis = k.tegen === null + ? (uit ? t('valuations.bookAsSell') : t('valuations.bookAsBuy')) + : `${uit ? t('valuations.bookAsTransferTo') : t('valuations.bookAsTransferFrom')} ${k.naam}`; + const extra = !priors.has(k.tegen) ? '' + : bedrag === 0 ? ` (${t('valuations.bookRemoves')})` + : ` (${t('valuations.bookReplaces')} ${fmtUnit(Math.abs(bedrag), meta(v))})`; + return { value: k.tegen === null ? 'trade' : `acc:${k.tegen}`, label: basis + extra }; + }); + const kandidatenFee = gap < 0 + ? feeCandidates({ events: await listEvents(), valuations: await listValuations(), v }).slice(0, 10) + : []; + for (const e of kandidatenFee) { + keuzes.push({ value: `fee:${e.id}`, label: t('valuations.bookAsFeeOn', + { soort: t(`enums.event_type.${e.type}`), datum: localDateNl(e.occurredAt) }) }); + } + + const res = await dialog.form({ + title: t('valuations.bookTitle'), + fields: [{ key: 'hoe', type: 'select', value: 'trade', options: keuzes, + label: `${fmtUnit(Math.abs(gap), meta(v))} ${t('valuations.bookAsSuffix')}` }], + confirmLabel: t('valuations.book'), + }); + if (!res) return; + + if (res.hoe.startsWith('fee:')) { + const e = kandidatenFee.find(x => x.id === Number(res.hoe.slice(4))); + if (e) await updateEvent(e.id, withFee(e, gap, t('valuations.bookFeeNote'))); + refresh(); + return; + } + + const tegen = res.hoe.startsWith('acc:') ? Number(res.hoe.slice(4)) : null; + const prior = priors.get(tegen) || null; + const bedrag = gap + netVan(prior); + const uit = bedrag < 0; + if (prior) await deleteEvent(prior.id); + if (!bedrag) { refresh(); return; } + const gemeen = { + assetId: v.assetId, occurredAt: v.rateAt, amount: Math.abs(bedrag), + fiatFee: null, unitFee: null, source: 'reconcile', + note: t('valuations.bookNote'), importReference: refVan(tegen), + }; + await createEvent(tegen + ? { ...gemeen, type: 'transfer', fiatPrice: null, + fromAccountId: uit ? v.accountId : tegen, toAccountId: uit ? tegen : v.accountId } + : { ...gemeen, type: uit ? 'sell' : 'buy', fiatPrice: v.fiatRate, + fromAccountId: uit ? v.accountId : null, toAccountId: uit ? null : v.accountId }); + refresh(); +} + +export async function acceptDifference(id, refresh) { + if (!(await dialog.confirm({ message: t('valuations.confirmAccept') }))) return; + await acceptValuationGap(Number(id)); + refresh(); +} + +async function recordMeasured(d, refresh) { + const asset = await onchainAsset(); + const dag = localDate(d.scannedAt); + let koers = null; + try { koers = asset ? await spotOnDate(asset, dag) : null; } catch { koers = null; } + const res = await dialog.form({ + title: t('valuations.recordTitle'), + fields: [{ key: 'rate', type: 'number', value: koers != null ? String(Math.round(koers * 100) / 100) : '', + label: t('valuations.recordRate', { date: dayNl(dag) }), attrs: { min: 0, step: 'any' } }], + confirmLabel: t('valuations.recordBalance'), + }); + if (!res) return; + const rate = parseFloat(String(res.rate).replace(',', '.')); + if (!(rate > 0)) return; + try { + await recordLightningBalance(d.accountId, { valuedOn: dag, fiatRate: priceToBase(rate, asset) }); + } catch (err) { + await dialog.alert({ message: err.message }); + return; + } + refresh(); +} + +export async function renderValuationWidget(host, refresh) { + const [rows, gemeten] = await Promise.all([listOpenValuationDiffs(), lightningBalanceDiffs()]); + const btc = gemeten.length ? await onchainAsset() : null; + const btcMeta = btc ? { decimals: btc.decimals, unit: btc.unit, subunit: btc.subunit, displayUnit: btc.displayUnit } : null; + const nodeRijen = gemeten.map((d, i) => `
+ ${esc(localDateNl(d.scannedAt))} · ${esc(d.account_name)} · ${esc(t('valuations.lightningRow'))}: + ${val(fmtUnit(d.gap, btcMeta))} + +
`).join(''); + const balansRijen = rows.map((v, i) => `
+ ${esc(v.valuedOn)} · ${esc(v.account_name)} · ${assetLabelHtml(v)}: + ${val(fmtUnit(v.gap, meta(v)))} + + +
`).join(''); + const body = rows.length + gemeten.length === 0 ? nothingPendingHtml() : nodeRijen + balansRijen; + host.innerHTML = taskCardHtml({ title: t('actions.valuationTitle'), body }); + host.querySelectorAll('[data-vrecord]').forEach(b => + b.onclick = () => recordMeasured(gemeten[Number(b.dataset.vrecord)], refresh)); + host.querySelectorAll('[data-vbook]').forEach(b => + b.onclick = () => bookDifference(rows[Number(b.dataset.vbook)], refresh)); + host.querySelectorAll('[data-vaccept]').forEach(b => + b.onclick = () => acceptDifference(rows[Number(b.dataset.vaccept)].id, refresh)); + return rows.length + gemeten.length; +} diff --git a/client/app-settings.js b/client/app-settings.js new file mode 100644 index 0000000..57ae169 --- /dev/null +++ b/client/app-settings.js @@ -0,0 +1,44 @@ +// Generated, do not edit. sha256:06a40e9a9b07fffcb17863260da0fc8eb04737e1ff80b20eaa9403c734f9d87d +import { PREFERENCES } from '../shared/config.js'; +import { getPref, getSettings, setPref } from './data/preferences.js'; + +let app = null; +const timers = new Map(); + +export async function loadAppSettings() { + app = await getSettings(); + for (const key of Object.keys(PREFERENCES.timers)) timers.set(key, await getPref('timers', key)); + return app; +} + +function geladen() { + if (app === null) { + throw new Error('app-settings: not loaded yet. loadAppSettings() belongs to the boot, before the first render.'); + } + return app; +} + +export function appSetting(key) { + const s = geladen(); + if (!Object.hasOwn(PREFERENCES.app, key)) throw new Error(`Unknown app preference: ${key}.`); + return s[key]; +} + +export function baseCurrency() { return geladen().baseCurrency; } + +export function appSettings() { return { ...geladen() }; } + +export function timerSetting(key) { + geladen(); + return timers.get(key); +} + +export async function setAppSetting(key, value) { + await setPref('app', key, value); + if (app) app[key] = value; +} + +export async function setTimerSetting(key, value) { + await setPref('timers', key, value); + timers.set(key, value); +} diff --git a/client/app.js b/client/app.js new file mode 100644 index 0000000..fc18295 --- /dev/null +++ b/client/app.js @@ -0,0 +1,272 @@ +// Generated, do not edit. sha256:8874091aa075a54dd49f5d1aad9244d40ef61ac47ce7c83d7d4e346d8133207a +import { configureSharedUi } from './ui/shared-setup.js'; +import './edition.js'; +import { assertConfig, ROUTE_KEYS, hashFromLocation } from '../shared/config.js'; +import { listAssets } from './data/assets.js'; +import { refreshNotifications } from './data/notifications.js'; +import { initCommands } from './commands.js'; +import { loadAppSettings, appSetting } from './app-settings.js'; +import { loadLicense } from './license.js'; +import { t, setLocale } from './i18n/index.js'; +import { initStatusBar } from './ui/status-bar.js'; +import { renderErrorPanel } from './ui/error-panel.js'; +import { createRenderTurns } from './ui/render-turn.js'; +import { finishSplash, initSplashReplay } from './ui/splash.js'; +import { refreshActivePortfolio, watchPortfolioChanges, cacheSleutels } from './portfolio.js'; +import { applyAurora } from './ui/aurora.js'; +import { initScheduler } from './ui/scheduler.js'; +import { refreshAttention, attentionForRoute, attentionDotHtml } from './ui/attention.js'; +import { registerAttentionSources } from './ui/attention-sources.js'; +import { clearPageHint } from './ui/page-hint.js'; +import { refreshRecentCloses } from './price/providers.js'; +import * as home from './pages/home.js'; +import * as conversionBitcoin from './pages/conversion-bitcoin.js'; +import * as priceHistory from './pages/price-history.js'; +import * as wealth from './pages/wealth.js'; +import * as events from './pages/events.js'; +import * as tracking from './pages/tracking.js'; +import * as system from './pages/system.js'; +import * as actions from './pages/actions.js'; +import * as guide from './pages/guide.js'; +import { makeStub } from './pages/stub.js'; +import { ADJUST } from './ui/icons.js'; +import { esc } from './ui/escape.js'; +import * as dialog from './ui/dialog.js'; +import { maybeStartSetup } from './onboarding/setup.js'; +import { registerRoute, routePage } from './routes.js'; + +registerRoute(ROUTE_KEYS.HOME, home); +registerRoute(ROUTE_KEYS.WEALTH, wealth); +registerRoute(ROUTE_KEYS.EVENTS, events); +registerRoute(ROUTE_KEYS.VALIDATION, makeStub('validation')); +registerRoute(ROUTE_KEYS.TRACKING, tracking); +registerRoute(ROUTE_KEYS.CONVERSION_BITCOIN, conversionBitcoin); +registerRoute(ROUTE_KEYS.PRICE_HISTORY, priceHistory); +registerRoute(ROUTE_KEYS.SYSTEM, system); +registerRoute(ROUTE_KEYS.ACTIONS, actions); +registerRoute(ROUTE_KEYS.GUIDE, guide); + +const NAV = [ + { items: ['home', 'actions', 'events'] }, + { section: 'onchain', items: ['tracking'] }, + { section: 'tax', items: ['wealth', 'taxes'] }, + { section: 'observe', items: ['conversion/bitcoin', 'price-history', 'charts'] }, + { items: ['guide', 'system'], bottom: true }, +]; + +function currentRoute() { + const key = hashFromLocation(location.hash).slice(2); + if (routePage(key)) return key; + const head = key.split('/')[0]; + return routePage(head) ? head : 'home'; +} + +const LOOK_ATTRS = Object.freeze({ profile: 'profile', glow: 'glow', glowshape: 'glowshape', motion: 'motion', rowsel: 'rowsel', glass_render: 'render' }); +const LOOK_TOKENS = Object.freeze({ + glass_transparency: ['--glass-transparency', '%'], + glass_frost: ['--glass-frost', '%'], + glass_blur: ['--dialog-transparency', ''], +}); + +function applyTheme(s) { + const { theme, accent, depth } = s; + document.documentElement.dataset.theme = theme; + document.documentElement.dataset.accent = accent; + document.documentElement.dataset.depth = depth; + const look = {}; + for (const [key, attr] of Object.entries(LOOK_ATTRS)) { + document.documentElement.dataset[attr] = s[key]; + look[attr] = s[key]; + } + for (const [key, [token, unit]] of Object.entries(LOOK_TOKENS)) { + document.documentElement.style.setProperty(token, `${s[key]}${unit}`); + look[token] = `${s[key]}${unit}`; + } + applyAurora(s); + try { + const per = cacheSleutels(); + localStorage.setItem(per.theme, theme); + localStorage.setItem(per.accent, accent); + localStorage.setItem(per.depth, depth); + localStorage.setItem(per.look, JSON.stringify(look)); + } catch { } +} + +function applyPageTitle(show) { + document.documentElement.dataset.pagetitle = show ? 'on' : 'off'; +} + +function applyPageHint(show) { + document.documentElement.dataset.pagehint = show ? 'on' : 'off'; +} + +const hasBitcoin = async () => (await listAssets()).some(a => a.symbol === 'BTC'); +const NAV_WHEN = { + [ROUTE_KEYS.WEALTH]: async () => (await listAssets()).length > 0, + [ROUTE_KEYS.CHARTS]: async () => appSetting('persona') !== 'broad', + [ROUTE_KEYS.CONVERSION_BITCOIN]: hasBitcoin, + [ROUTE_KEYS.TRACKING]: hasBitcoin, +}; + +const NAV_CUSTOMIZABLE = new Set(['home']); +const editModeRoutes = new Set(); + +function toggleEdit(key) { + if (editModeRoutes.has(key)) editModeRoutes.delete(key); else editModeRoutes.add(key); + if (currentRoute() === key) renderPage(); + else location.hash = `#/${key}`; +} + +function setEditMode(key, on) { + if (editModeRoutes.has(key) === !!on) return; + toggleEdit(key); +} + +let kanSluiten = false; + +async function onNavCloseClick(e) { + if (!e.target.closest('[data-app-close]')) return; + e.preventDefault(); + if (!await dialog.confirm({ title: t('appWindow.confirmTitle'), message: t('appWindow.confirmClose'), + confirmLabel: t('appWindow.close') })) return; + const r = await fetch('/api/app/close', { method: 'POST' }).catch(() => null); + if (r && !r.ok) dialog.alert({ title: t('appWindow.confirmTitle'), message: t('appWindow.closeUnavailable') }); +} + +function onNavEditClick(e) { + const tog = e.target.closest('[data-edit]'); + if (!tog) return; + e.preventDefault(); + e.stopPropagation(); + toggleEdit(tog.dataset.edit); +} + +async function renderSidebar() { + const nav = document.getElementById('nav'); + const active = currentRoute(); + const navToolsSlot = !appSetting('show_page_title'); + const zichtbaar = new Set(); + for (const [route, voorwaarde] of Object.entries(NAV_WHEN)) { + if (await voorwaarde()) zichtbaar.add(route); + } + const keepTools = nav.querySelector('#nav-tools'); + nav.innerHTML = NAV.map(group => { + const title = group.section ? `` : ''; + const links = group.items.filter(k => routePage(k) && (!NAV_WHEN[k] || zichtbaar.has(k))).map(k => { + const dot = attentionDotHtml(attentionForRoute(k)); + const editTog = NAV_CUSTOMIZABLE.has(k) + ? `${ADJUST}` + : ''; + const tools = navToolsSlot && k === active && editModeRoutes.has(k) ? '' : ''; + const link = `${t(`nav.${k}`)}${dot}${editTog}${tools}`; + return tools ? `` : link; + }).join(''); + const sluiten = group.bottom && kanSluiten + ? `${esc(t('appWindow.close'))}` : ''; + return links ? (group.bottom ? '' : '') + title + links + sluiten : ''; + }).join(''); + const slot = nav.querySelector('#nav-tools'); + if (slot && keepTools && keepTools.firstChild) slot.replaceWith(keepTools); +} + +function renderPageHeader(key) { + const host = document.getElementById('page-header'); + if (!host) return; + const mod = routePage(key); + const naam = mod?.headerInColumn || typeof mod?.title !== 'function' ? '' : mod.title(); + if (!naam) { host.innerHTML = ''; return; } + const icoon = typeof mod.titleIcon === 'function' ? mod.titleIcon() : ''; + host.innerHTML = ``; +} + +const renderTurns = createRenderTurns(); + +async function renderPage() { + const turn = renderTurns.take(); + const key = currentRoute(); + for (const k of [...editModeRoutes]) if (k !== key) editModeRoutes.delete(k); + document.getElementById('sidebar').classList.remove('open'); + const view = document.getElementById('view'); + renderPageHeader(key); + clearPageHint(); + try { + await renderSidebar(); + await routePage(key).render(view, { edit: editModeRoutes.has(key), setEdit: on => setEditMode(key, on) }); + } catch (err) { + if (!renderTurns.isCurrent(turn)) { console.warn(`[render] ${key} overtaken:`, err); return; } + renderErrorPanel(view, err, key); + } +} + +async function boot() { + try { + assertConfig(); + const s = await loadAppSettings(); + setLocale(s.locale); + configureSharedUi(); + await loadLicense(); + await refreshNotifications(); + applyTheme(s); + applyPageTitle(s.show_page_title); + applyPageHint(s.show_page_hint); + + document.getElementById('logo-text').textContent = t('app.name'); + document.getElementById('mobile-logo-text').textContent = t('app.name'); + initSplashReplay(); + refreshActivePortfolio(); + watchPortfolioChanges(); + document.getElementById('menu-btn').onclick = + () => document.getElementById('sidebar').classList.toggle('open'); + + document.addEventListener('settings-changed', async () => { + const s2 = await loadAppSettings(); + setLocale(s2.locale); + configureSharedUi(); + applyTheme(s2); + applyPageTitle(s2.show_page_title); + applyPageHint(s2.show_page_hint); + renderPage(); + }); + window.addEventListener('hashchange', renderPage); + document.getElementById('nav').addEventListener('click', onNavEditClick); + document.getElementById('nav').addEventListener('click', onNavCloseClick); + kanSluiten = await fetch('/api/app/window').then(r => (r.ok ? r.json() : {})) + .then(j => j.canClose === true).catch(() => false); + + registerAttentionSources(); + document.addEventListener('attention-changed', + () => renderSidebar().catch(e => console.error('[app] refreshing the sidebar failed:', e))); + for (const signaal of ['providers-changed', 'connections-changed', 'ai-activity-changed', + 'ai-proposals-changed', 'price-gaps-changed']) { + document.addEventListener(signaal, () => refreshAttention()); + } + window.addEventListener('hashchange', () => refreshAttention()); + + document.addEventListener('app-refresh', () => { + refreshAttention(); + renderPage(); + }); + + initStatusBar(); + initScheduler(); + initCommands(); + await renderPage(); + document.documentElement.dataset.app = 'ready'; + finishSplash(); + maybeStartSetup(); + + refreshAttention(); + listAssets() + .then(assets => refreshRecentCloses(assets)) + .then(({ written }) => { if (written) document.dispatchEvent(new CustomEvent('price-gaps-changed')); }) + .catch(e => console.error('[app] refreshing the recent closes failed:', e)); + } catch (err) { + document.body.innerHTML = + `
+

Start afgebroken

${esc(err.message)}
`; + throw err; + } +} + +boot(); diff --git a/client/boot-gate.js b/client/boot-gate.js new file mode 100644 index 0000000..4210ec1 --- /dev/null +++ b/client/boot-gate.js @@ -0,0 +1,52 @@ +// Generated, do not edit. sha256:2c49affa1ec432e4d1ea7c40091c250afc875d5da6a7e1f18da77761d64a9168 +(function () { + var show = false; + var gewisseld = false; + try { + gewisseld = localStorage.getItem('portfolio_switching') === '1'; + if (gewisseld) localStorage.removeItem('portfolio_switching'); + } catch (e0) { } + try { + var KEY = 'splash_last_shown'; + var d = new Date(); + var today = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); + show = gewisseld + || location.search.indexOf('splash') !== -1 + || location.hash.indexOf('splash') !== -1 + || localStorage.getItem(KEY) !== today; + if (show) localStorage.setItem(KEY, today); + } catch (e) { show = true; } + if (show) document.documentElement.dataset.splash = 'on'; + + try { + var naam = localStorage.getItem('portfolio_active'); + if (naam) { + document.documentElement.dataset.portfolio = naam; + document.documentElement.style.setProperty('--portfolio-name', JSON.stringify(naam)); + } + } catch (e1) { } + + try { + var achtervoegsel = document.documentElement.dataset.portfolio + ? ':' + document.documentElement.dataset.portfolio : ''; + var theme = localStorage.getItem('ui_theme' + achtervoegsel); + var accent = localStorage.getItem('ui_accent' + achtervoegsel); + var depth = localStorage.getItem('ui_depth' + achtervoegsel); + if (theme === 'dark' || theme === 'light') document.documentElement.dataset.theme = theme; + if (accent) document.documentElement.dataset.accent = accent; + if (depth === 'flat' || depth === 'deep') document.documentElement.dataset.depth = depth; + } catch (e2) { } + try { + var look = JSON.parse(localStorage.getItem('ui_look' + achtervoegsel) || '{}'); + var LOOK_ATTRS = ['profile', 'glow', 'glowshape', 'motion', 'rowsel', 'render']; + for (var i = 0; i < LOOK_ATTRS.length; i++) { + var v = look && look[LOOK_ATTRS[i]]; + if (typeof v === 'string' && /^[a-z]+$/.test(v)) document.documentElement.dataset[LOOK_ATTRS[i]] = v; + } + var LOOK_TOKENS = ['--glass-transparency', '--glass-frost', '--dialog-transparency']; + for (var j = 0; j < LOOK_TOKENS.length; j++) { + var w = look && look[LOOK_TOKENS[j]]; + if (typeof w === 'string' && /^\d{1,3}(%|px)?$/.test(w)) document.documentElement.style.setProperty(LOOK_TOKENS[j], w); + } + } catch (e3) { } +})(); diff --git a/client/changes.js b/client/changes.js new file mode 100644 index 0000000..8e4557a --- /dev/null +++ b/client/changes.js @@ -0,0 +1,18 @@ +// Generated, do not edit. sha256:671ad1d61da792d482cd1cb3577e28a415b868ce12182a75eeb1a59b5fd96682 +import { changesSince } from './data/bus.js'; +import { dispatchChanges } from './data/_rpc.js'; + +let cursor = null; + +export async function pollChanges() { + let uitslag; + try { + uitslag = await changesSince(cursor); + } catch { + return []; + } + if (!uitslag || typeof uitslag !== 'object') return []; + cursor = Number.isInteger(uitslag.cursor) ? uitslag.cursor : cursor; + if (uitslag.reset) return dispatchChanges(['app-refresh']); + return dispatchChanges(uitslag.names); +} diff --git a/client/commands.js b/client/commands.js new file mode 100644 index 0000000..1b4d404 --- /dev/null +++ b/client/commands.js @@ -0,0 +1,123 @@ +// Generated, do not edit. sha256:a469a46a90e6bcc2ab27a831978cd788d57e02abb6e3347f99750cb9fb53c286 +import { claimNextCommand, resolveCommand } from './data/commands.js'; +import { CONFIG, PLACE_LABELS, hashFromLocation, commandTooLate } from '../shared/config.js'; +import { t } from './i18n/index.js'; +import { esc } from './ui/escape.js'; + +const TAB = (globalThis.crypto?.randomUUID?.() || `tab-${Date.now()}-${Math.random()}`).slice(0, 40); + +const KAN = Object.freeze(['navigate', 'locate']); + +let bezig = false; + +export function initCommands() { + document.addEventListener('commands-changed', () => { pakOp(); }); + pakOp(); +} + +export async function pakOp() { + if (bezig) return; + bezig = true; + try { + const opdracht = await claimNextCommand(TAB, KAN); + if (opdracht) await voerUit(opdracht); + } catch { } + finally { bezig = false; } +} + +async function voerUit(opdracht) { + if (opdracht.kind === 'locate') { + await resolveCommand(opdracht.id, TAB, 'done', hashFromLocation(location.hash)); + return; + } + if (opdracht.kind !== 'navigate') { + await resolveCommand(opdracht.id, TAB, 'rejected', t('commands.unknownKind')); + return; + } + const hash = opdracht.payload && typeof opdracht.payload.hash === 'string' ? opdracht.payload.hash : ''; + if (!hash.startsWith('#/')) { + await resolveCommand(opdracht.id, TAB, 'rejected', t('commands.badTarget')); + return; + } + + if (commandTooLate(opdracht.expiresAt)) { + await resolveCommand(opdracht.id, TAB, 'expired', t('commands.tooLate')); + return; + } + + const doorgaan = await aftellen(plekNaam(opdracht.payload)); + if (!doorgaan) { + await resolveCommand(opdracht.id, TAB, 'rejected', t('commands.stopped')); + return; + } + location.hash = hash; + await resolveCommand(opdracht.id, TAB, 'done'); +} + +function plekNaam({ route, tab, sub }) { + const label = (id) => { try { return t(PLACE_LABELS[id] || `nav.${id}`); } catch { return id; } }; + return [label(route), tab && label(tab), sub && label(sub)].filter(Boolean).join(' → '); +} + +let directVoortaan = false; + +function toonBalk(inhoud) { + const bar = document.createElement('div'); + bar.className = 'cmd-bar'; + bar.setAttribute('role', 'alert'); + bar.innerHTML = inhoud; + document.body.appendChild(bar); + return bar; +} + +const knop = (data, tekst, soort = 'secondary') => + ``; + +const knoppen = (...items) => `
${items.join('')}
`; + +function meldDirect(plek) { + const bar = toonBalk(`${t('commands.movedTo')} ${esc(plek)}` + + knoppen(knop('data-countdown-back', t('commands.countdownBack')))); + const weg = setTimeout(() => bar.remove(), CONFIG.COMMANDS.NOTICE_SECONDS * 1000); + bar.querySelector('[data-countdown-back]').addEventListener('click', () => { + directVoortaan = false; + clearTimeout(weg); + bar.remove(); + }); +} + +function aftellen(plek) { + if (directVoortaan) { meldDirect(plek); return Promise.resolve(true); } + + const seconden = CONFIG.COMMANDS.COUNTDOWN_SECONDS; + return new Promise((klaar) => { + let over = seconden; + const tekst = () => `${t('commands.goingTo')} ${esc(plek)} (${over})`; + const bar = toonBalk(`${tekst()}` + + knoppen( + knop('data-now', t('commands.goNow'), 'primary'), + knop('data-always', t('commands.goAlways')), + knop('data-stop', t('commands.stop')), + )); + + const op = (doorgaan) => { + clearInterval(tik); + document.removeEventListener('keydown', opEscape); + bar.remove(); + klaar(doorgaan); + }; + const opEscape = (e) => { if (e.key === 'Escape') op(false); }; + bar.querySelector('[data-stop]').addEventListener('click', () => op(false)); + const nu = bar.querySelector('[data-now]'); + nu.addEventListener('click', () => op(true)); + nu.focus(); + bar.querySelector('[data-always]').addEventListener('click', () => { directVoortaan = true; op(true); }); + document.addEventListener('keydown', opEscape); + + const tik = setInterval(() => { + over -= 1; + if (over <= 0) { op(true); return; } + bar.querySelector('.cmd-bar-text').innerHTML = tekst(); + }, 1000); + }); +} diff --git a/client/css/styles.css b/client/css/styles.css new file mode 100644 index 0000000..bca118b --- /dev/null +++ b/client/css/styles.css @@ -0,0 +1,2577 @@ +/* Generated, do not edit. sha256:36518f0a81f251fc91cc7a50cccd80f8e26c117fb0b6017e85098cc76621857c */ +:root { + --logo-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'%3E%3Cpath d='M 46.108 37.099 L 79.826 68.032 L 113.068 37.288 L 142.067 37.244 L 92.564 85.773 L 92.367 123.544 L 80.04 135.851 L 67.262 123.517 L 67.425 85.773 L 17.176 37.092 L 46.108 37.099 Z'/%3E%3Cpath d='M 49.901 91.273 L 0 43.722 L 0 24.666 L 64.78 87.731 L 64.689 118.25 L 50.234 105.711 L 49.901 91.273 Z'/%3E%3Cpath d='M 95.221 87.779 L 160 24.149 L 160 43.383 L 109.554 93.918 L 109.584 105.756 L 94.924 118.584 L 95.221 87.779 Z'/%3E%3C/svg%3E"); + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-xl: 28px; + --radius-full: 999px; + --font-base: 'Inter', -apple-system, sans-serif; + --font-mono: 'SF Mono', 'Fira Code', monospace; + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --table-vh-offset: 9.5rem; + --table-row-lines: 2; + --table-line-h: 1.35em; + --table-cell-py: 0.85rem; + --table-actions-w: 6.8rem; + --table-exp-w: 2.2rem; + --table-exp-pad: 0.55rem; + --sig-chrome: 7.7rem; + --main-pad-y: 1.7rem; + --main-pad-x: var(--sidebar-pad); + --page-head-h: 4.25rem; + --aside-vh-offset: 5.9rem; + --year-box-w: 4.4rem; + --year-box-h: 2.6rem; + --page-aside-w: calc(var(--sidebar-w) * 1.65); + --hint-line-x: var(--sidebar-w); + --sidebar-w: 240px; + --sidebar-pad: 1.5rem; + --nav-line-cap: 10px; + --nav-line-top: 1.7rem; + --nav-line-bottom: 1.7rem; + --sig-green:var(--pos); --sig-yellow:#ffd93d; --sig-red:var(--neg); --sig-blue:#5b9bff; + --sig-green-zone:rgba(57,255,138,0.12); --sig-yellow-zone:rgba(255,217,61,0.10); --sig-red-zone:rgba(255,69,69,0.10); +} + +[data-theme="light"] { + --sig-yellow:#c98a00; + --sig-green-zone:rgba(0,122,72,0.12); --sig-yellow-zone:rgba(201,138,0,0.10); --sig-red-zone:rgba(204,34,34,0.10); +} + +[data-theme="dark"][data-accent="neutral"] { + --bg:#080808; --bg-elevated:#181818; --bg-raised:#242424; --bg-overlay:#303030; + --border:rgba(255,255,255,0.18); --border-mid:rgba(255,255,255,0.28); + --text-primary:#f2f2f2; --text-sec:#a3a3a3; --text-ter:#6a6a6a; + --accent:#a3a3a3; --accent-soft:rgba(255,255,255,0.08); --accent-mid:rgba(255,255,255,0.14); + --accent-glow:rgba(255,255,255,0.10); --accent-text:#a3a3a3; + --pos:#39FF8A; --pos-bg:rgba(57,255,138,0.16); --neg:#ff5a5a; --neg-bg:rgba(255,69,69,0.18); + --neutral-bg:rgba(255,255,255,0.08); --input-bg:#1c1c1c; --scrollbar:#2c2c2c; +} +[data-theme="light"][data-accent="neutral"] { + --bg:#fff; --bg-elevated:#f7f7f7; --bg-raised:#f0f0f0; --bg-overlay:#e8e8e8; + --border:rgba(0,0,0,0.12); --border-mid:rgba(0,0,0,0.18); + --text-primary:#0a0a0a; --text-sec:#888; --text-ter:#bbb; + --accent:#888; --accent-soft:rgba(0,0,0,0.05); --accent-mid:rgba(0,0,0,0.10); + --accent-glow:rgba(0,0,0,0.08); --accent-text:#888; + --pos:#007a48; --pos-bg:rgba(0,149,90,0.08); --neg:#cc2222; --neg-bg:rgba(204,34,34,0.08); + --neutral-bg:rgba(0,0,0,0.04); --input-bg:#f5f5f5; --scrollbar:#ddd; +} +[data-theme="dark"][data-accent="orange"] { + --bg:#080808; --bg-elevated:#181818; --bg-raised:#242424; --bg-overlay:#303030; + --border:rgba(255,255,255,0.18); --border-mid:rgba(255,255,255,0.28); + --text-primary:#f2f2f2; --text-sec:#a3a3a3; --text-ter:#6a6a6a; + --accent:#FF6B00; --accent-soft:rgba(255,107,0,0.16); --accent-mid:rgba(255,107,0,0.26); + --accent-glow:rgba(255,107,0,0.30); --accent-text:#FF8124; + --pos:#39FF8A; --pos-bg:rgba(57,255,138,0.16); --neg:#ff5a5a; --neg-bg:rgba(255,69,69,0.18); + --neutral-bg:rgba(255,255,255,0.08); --input-bg:#1c1c1c; --scrollbar:#2c2c2c; +} +[data-theme="dark"][data-accent="blue"] { + --bg:#080808; --bg-elevated:#181818; --bg-raised:#242424; --bg-overlay:#303030; + --border:rgba(255,255,255,0.18); --border-mid:rgba(255,255,255,0.28); + --text-primary:#f2f2f2; --text-sec:#a3a3a3; --text-ter:#6a6a6a; + --accent:#3B82F6; --accent-soft:rgba(59,130,246,0.16); --accent-mid:rgba(59,130,246,0.26); + --accent-glow:rgba(59,130,246,0.30); --accent-text:#6AA1FF; + --pos:#39FF8A; --pos-bg:rgba(57,255,138,0.16); --neg:#ff5a5a; --neg-bg:rgba(255,69,69,0.18); + --neutral-bg:rgba(255,255,255,0.08); --input-bg:#1c1c1c; --scrollbar:#2c2c2c; +} +[data-theme="light"][data-accent="orange"] { + --bg:#fff; --bg-elevated:#f7f7f7; --bg-raised:#f0f0f0; --bg-overlay:#e8e8e8; + --border:rgba(0,0,0,0.12); --border-mid:rgba(0,0,0,0.18); + --text-primary:#0a0a0a; --text-sec:#888; --text-ter:#bbb; + --accent:#E05500; --accent-soft:rgba(224,85,0,0.08); --accent-mid:rgba(224,85,0,0.16); + --accent-glow:rgba(224,85,0,0.22); --accent-text:#E05500; + --pos:#007a48; --pos-bg:rgba(0,149,90,0.08); --neg:#cc2222; --neg-bg:rgba(204,34,34,0.08); + --neutral-bg:rgba(0,0,0,0.04); --input-bg:#f5f5f5; --scrollbar:#ddd; +} +[data-theme="light"][data-accent="blue"] { + --bg:#fff; --bg-elevated:#f7f7f7; --bg-raised:#f0f0f0; --bg-overlay:#e8e8e8; + --border:rgba(0,0,0,0.12); --border-mid:rgba(0,0,0,0.18); + --text-primary:#0a0a0a; --text-sec:#888; --text-ter:#bbb; + --accent:#2563EB; --accent-soft:rgba(37,99,235,0.08); --accent-mid:rgba(37,99,235,0.16); + --accent-glow:rgba(37,99,235,0.22); --accent-text:#2563EB; + --pos:#007a48; --pos-bg:rgba(0,149,90,0.08); --neg:#cc2222; --neg-bg:rgba(204,34,34,0.08); + --neutral-bg:rgba(0,0,0,0.04); --input-bg:#f5f5f5; --scrollbar:#ddd; +} +[data-theme="dark"][data-accent="lila"] { + --bg:#080808; --bg-elevated:#181818; --bg-raised:#242424; --bg-overlay:#303030; + --border:rgba(255,255,255,0.18); --border-mid:rgba(255,255,255,0.28); + --text-primary:#f2f2f2; --text-sec:#a3a3a3; --text-ter:#6a6a6a; + --accent:#B388FF; --accent-soft:rgba(179,136,255,0.16); --accent-mid:rgba(179,136,255,0.26); + --accent-glow:rgba(179,136,255,0.30); --accent-text:#C7A6FF; + --pos:#39FF8A; --pos-bg:rgba(57,255,138,0.16); --neg:#ff5a5a; --neg-bg:rgba(255,69,69,0.18); + --neutral-bg:rgba(255,255,255,0.08); --input-bg:#1c1c1c; --scrollbar:#2c2c2c; +} +[data-theme="light"][data-accent="lila"] { + --bg:#fff; --bg-elevated:#f7f7f7; --bg-raised:#f0f0f0; --bg-overlay:#e8e8e8; + --border:rgba(0,0,0,0.12); --border-mid:rgba(0,0,0,0.18); + --text-primary:#0a0a0a; --text-sec:#888; --text-ter:#bbb; + --accent:#7C3AED; --accent-soft:rgba(124,58,237,0.08); --accent-mid:rgba(124,58,237,0.16); + --accent-glow:rgba(124,58,237,0.22); --accent-text:#7C3AED; + --pos:#007a48; --pos-bg:rgba(0,149,90,0.08); --neg:#cc2222; --neg-bg:rgba(204,34,34,0.08); + --neutral-bg:rgba(0,0,0,0.04); --input-bg:#f5f5f5; --scrollbar:#ddd; +} + +[data-theme="dark"][data-accent="teal"] { + --bg:#080808; --bg-elevated:#181818; --bg-raised:#242424; --bg-overlay:#303030; + --border:rgba(255,255,255,0.18); --border-mid:rgba(255,255,255,0.28); + --text-primary:#f2f2f2; --text-sec:#a3a3a3; --text-ter:#6a6a6a; + --accent:#14B8A6; --accent-soft:rgba(20,184,166,0.16); --accent-mid:rgba(20,184,166,0.26); + --accent-glow:rgba(20,184,166,0.30); --accent-text:#2DD4BF; + --pos:#39FF8A; --pos-bg:rgba(57,255,138,0.16); --neg:#ff5a5a; --neg-bg:rgba(255,69,69,0.18); + --neutral-bg:rgba(255,255,255,0.08); --input-bg:#1c1c1c; --scrollbar:#2c2c2c; +} +[data-theme="light"][data-accent="teal"] { + --bg:#fff; --bg-elevated:#f7f7f7; --bg-raised:#f0f0f0; --bg-overlay:#e8e8e8; + --border:rgba(0,0,0,0.12); --border-mid:rgba(0,0,0,0.18); + --text-primary:#0a0a0a; --text-sec:#888; --text-ter:#bbb; + --accent:#0D9488; --accent-soft:rgba(13,148,136,0.08); --accent-mid:rgba(13,148,136,0.16); + --accent-glow:rgba(13,148,136,0.22); --accent-text:#0D9488; + --pos:#007a48; --pos-bg:rgba(0,149,90,0.08); --neg:#cc2222; --neg-bg:rgba(204,34,34,0.08); + --neutral-bg:rgba(0,0,0,0.04); --input-bg:#f5f5f5; --scrollbar:#ddd; +} +[data-theme="dark"][data-accent="magenta"] { + --bg:#080808; --bg-elevated:#181818; --bg-raised:#242424; --bg-overlay:#303030; + --border:rgba(255,255,255,0.18); --border-mid:rgba(255,255,255,0.28); + --text-primary:#f2f2f2; --text-sec:#a3a3a3; --text-ter:#6a6a6a; + --accent:#E24DAE; --accent-soft:rgba(226,77,174,0.16); --accent-mid:rgba(226,77,174,0.26); + --accent-glow:rgba(226,77,174,0.30); --accent-text:#F072C4; + --pos:#39FF8A; --pos-bg:rgba(57,255,138,0.16); --neg:#ff5a5a; --neg-bg:rgba(255,69,69,0.18); + --neutral-bg:rgba(255,255,255,0.08); --input-bg:#1c1c1c; --scrollbar:#2c2c2c; +} +[data-theme="light"][data-accent="magenta"] { + --bg:#fff; --bg-elevated:#f7f7f7; --bg-raised:#f0f0f0; --bg-overlay:#e8e8e8; + --border:rgba(0,0,0,0.12); --border-mid:rgba(0,0,0,0.18); + --text-primary:#0a0a0a; --text-sec:#888; --text-ter:#bbb; + --accent:#BE2A86; --accent-soft:rgba(190,42,134,0.08); --accent-mid:rgba(190,42,134,0.16); + --accent-glow:rgba(190,42,134,0.22); --accent-text:#BE2A86; + --pos:#007a48; --pos-bg:rgba(0,149,90,0.08); --neg:#cc2222; --neg-bg:rgba(204,34,34,0.08); + --neutral-bg:rgba(0,0,0,0.04); --input-bg:#f5f5f5; --scrollbar:#ddd; +} +[data-theme="dark"][data-accent="indigo"] { + --bg:#080808; --bg-elevated:#181818; --bg-raised:#242424; --bg-overlay:#303030; + --border:rgba(255,255,255,0.18); --border-mid:rgba(255,255,255,0.28); + --text-primary:#f2f2f2; --text-sec:#a3a3a3; --text-ter:#6a6a6a; + --accent:#818CF8; --accent-soft:rgba(129,140,248,0.16); --accent-mid:rgba(129,140,248,0.26); + --accent-glow:rgba(129,140,248,0.30); --accent-text:#A5B4FC; + --pos:#39FF8A; --pos-bg:rgba(57,255,138,0.16); --neg:#ff5a5a; --neg-bg:rgba(255,69,69,0.18); + --neutral-bg:rgba(255,255,255,0.08); --input-bg:#1c1c1c; --scrollbar:#2c2c2c; +} +[data-theme="light"][data-accent="indigo"] { + --bg:#fff; --bg-elevated:#f7f7f7; --bg-raised:#f0f0f0; --bg-overlay:#e8e8e8; + --border:rgba(0,0,0,0.12); --border-mid:rgba(0,0,0,0.18); + --text-primary:#0a0a0a; --text-sec:#888; --text-ter:#bbb; + --accent:#4F46E5; --accent-soft:rgba(79,70,229,0.08); --accent-mid:rgba(79,70,229,0.16); + --accent-glow:rgba(79,70,229,0.22); --accent-text:#4F46E5; + --pos:#007a48; --pos-bg:rgba(0,149,90,0.08); --neg:#cc2222; --neg-bg:rgba(204,34,34,0.08); + --neutral-bg:rgba(0,0,0,0.04); --input-bg:#f5f5f5; --scrollbar:#ddd; +} +[data-theme="dark"][data-accent="cyan"] { + --bg:#080808; --bg-elevated:#181818; --bg-raised:#242424; --bg-overlay:#303030; + --border:rgba(255,255,255,0.18); --border-mid:rgba(255,255,255,0.28); + --text-primary:#f2f2f2; --text-sec:#a3a3a3; --text-ter:#6a6a6a; + --accent:#22D3EE; --accent-soft:rgba(34,211,238,0.16); --accent-mid:rgba(34,211,238,0.26); + --accent-glow:rgba(34,211,238,0.30); --accent-text:#67E8F9; + --pos:#39FF8A; --pos-bg:rgba(57,255,138,0.16); --neg:#ff5a5a; --neg-bg:rgba(255,69,69,0.18); + --neutral-bg:rgba(255,255,255,0.08); --input-bg:#1c1c1c; --scrollbar:#2c2c2c; +} +[data-theme="light"][data-accent="cyan"] { + --bg:#fff; --bg-elevated:#f7f7f7; --bg-raised:#f0f0f0; --bg-overlay:#e8e8e8; + --border:rgba(0,0,0,0.12); --border-mid:rgba(0,0,0,0.18); + --text-primary:#0a0a0a; --text-sec:#888; --text-ter:#bbb; + --accent:#0E7490; --accent-soft:rgba(14,116,144,0.08); --accent-mid:rgba(14,116,144,0.16); + --accent-glow:rgba(14,116,144,0.22); --accent-text:#0E7490; + --pos:#007a48; --pos-bg:rgba(0,149,90,0.08); --neg:#cc2222; --neg-bg:rgba(204,34,34,0.08); + --neutral-bg:rgba(0,0,0,0.04); --input-bg:#f5f5f5; --scrollbar:#ddd; +} +[data-theme="dark"][data-accent="slate"] { + --bg:#080808; --bg-elevated:#181818; --bg-raised:#242424; --bg-overlay:#303030; + --border:rgba(255,255,255,0.18); --border-mid:rgba(255,255,255,0.28); + --text-primary:#f2f2f2; --text-sec:#a3a3a3; --text-ter:#6a6a6a; + --accent:#94A3B8; --accent-soft:rgba(148,163,184,0.16); --accent-mid:rgba(148,163,184,0.26); + --accent-glow:rgba(148,163,184,0.30); --accent-text:#B4C0D0; + --pos:#39FF8A; --pos-bg:rgba(57,255,138,0.16); --neg:#ff5a5a; --neg-bg:rgba(255,69,69,0.18); + --neutral-bg:rgba(255,255,255,0.08); --input-bg:#1c1c1c; --scrollbar:#2c2c2c; +} +[data-theme="light"][data-accent="slate"] { + --bg:#fff; --bg-elevated:#f7f7f7; --bg-raised:#f0f0f0; --bg-overlay:#e8e8e8; + --border:rgba(0,0,0,0.12); --border-mid:rgba(0,0,0,0.18); + --text-primary:#0a0a0a; --text-sec:#888; --text-ter:#bbb; + --accent:#475569; --accent-soft:rgba(71,85,105,0.08); --accent-mid:rgba(71,85,105,0.16); + --accent-glow:rgba(71,85,105,0.22); --accent-text:#475569; + --pos:#007a48; --pos-bg:rgba(0,149,90,0.08); --neg:#cc2222; --neg-bg:rgba(204,34,34,0.08); + --neutral-bg:rgba(0,0,0,0.04); --input-bg:#f5f5f5; --scrollbar:#ddd; +} +[data-theme="dark"][data-accent="sand"] { + --bg:#080808; --bg-elevated:#181818; --bg-raised:#242424; --bg-overlay:#303030; + --border:rgba(255,255,255,0.18); --border-mid:rgba(255,255,255,0.28); + --text-primary:#f2f2f2; --text-sec:#a3a3a3; --text-ter:#6a6a6a; + --accent:#C8B89E; --accent-soft:rgba(200,184,158,0.16); --accent-mid:rgba(200,184,158,0.26); + --accent-glow:rgba(200,184,158,0.30); --accent-text:#DDD0BA; + --pos:#39FF8A; --pos-bg:rgba(57,255,138,0.16); --neg:#ff5a5a; --neg-bg:rgba(255,69,69,0.18); + --neutral-bg:rgba(255,255,255,0.08); --input-bg:#1c1c1c; --scrollbar:#2c2c2c; +} +[data-theme="light"][data-accent="sand"] { + --bg:#fff; --bg-elevated:#f7f7f7; --bg-raised:#f0f0f0; --bg-overlay:#e8e8e8; + --border:rgba(0,0,0,0.12); --border-mid:rgba(0,0,0,0.18); + --text-primary:#0a0a0a; --text-sec:#888; --text-ter:#bbb; + --accent:#7A6650; --accent-soft:rgba(122,102,80,0.08); --accent-mid:rgba(122,102,80,0.16); + --accent-glow:rgba(122,102,80,0.22); --accent-text:#7A6650; + --pos:#007a48; --pos-bg:rgba(0,149,90,0.08); --neg:#cc2222; --neg-bg:rgba(204,34,34,0.08); + --neutral-bg:rgba(0,0,0,0.04); --input-bg:#f5f5f5; --scrollbar:#ddd; +} + +:root { + --surface: var(--bg-elevated); + --surface-inset: var(--bg-raised); + --surface-blur: 0px; + --surface-sat: 100%; + --edge: none; + --ambient: none; + --shadow-1: 0 1px 2px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.05); + --shadow-2: 0 12px 44px rgba(0,0,0,0.35); + --shadow-3: 0 24px 60px rgba(0,0,0,0.40); + --lift-shadow: var(--shadow-2); + --hover-shadow: var(--lift-shadow); +} +[data-theme="light"] { --lift-shadow:0 8px 22px -14px rgba(0,0,0,0.14); --hover-shadow:0 10px 26px -12px rgba(0,0,0,0.20); } +[data-theme="dark"] { --shadow-1:0 1px 2px rgba(0,0,0,0.30); } + +[data-depth="deep"] { --surface-blur:16px; --surface-sat:135%; } +[data-depth="deep"][data-theme="dark"] { + --surface: color-mix(in srgb, #1c1c1c 78%, transparent); + --surface-inset: color-mix(in srgb, #262626 76%, transparent); + --edge: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0) 42%); + --shadow-1: 0 1px 2px rgba(0,0,0,0.35), 0 10px 30px -18px rgba(0,0,0,0.9); +} +[data-depth="deep"][data-theme="light"] { + --surface: color-mix(in srgb, #ffffff 72%, transparent); + --surface-inset: color-mix(in srgb, #f2f2f2 74%, transparent); + --edge: linear-gradient(180deg, rgba(255,255,255,0.85), rgba(255,255,255,0) 46%); +} +[data-depth="deep"]:not([data-glow="off"]) { + --ambient: radial-gradient(58rem 34rem at 8% -8%, var(--accent-glow), transparent 62%), + radial-gradient(46rem 30rem at 102% 4%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 66%); +} + +@media (prefers-reduced-transparency: reduce) { + [data-depth="deep"] { + --surface:var(--bg-elevated); --surface-inset:var(--bg-raised); + --surface-blur:0px; --surface-sat:100%; + } +} +@media (prefers-reduced-motion: reduce) { + body::before, .card, .table-frame { transition:none; } +} + +:root { + --frost: transparent; + --surface-tint: var(--surface-inset); + --row-line: color-mix(in srgb, var(--border) 50%, transparent); + --row-hover: var(--neutral-bg); + --lit: none; + --lift: none; + --slider-track: var(--bg-raised); + --slider-track-edge: inset 0 0 0 1px var(--border); + --slider-fill: linear-gradient(var(--accent), var(--accent)); + --slider-thumb: var(--text-primary); + --slider-thumb-edge: 0 0 0 2px var(--bg), 0 1px 3px rgba(0,0,0,0.5); +} +[data-theme="light"] { + --slider-thumb: #ffffff; + --slider-thumb-edge: 0 0 0 1px rgba(0,0,0,0.14), 0 1px 3px rgba(0,0,0,0.2); +} +:root[data-profile="glass"] { --surface-blur:36px; --float-blur:calc((100 - var(--dialog-transparency, 30)) * 0.4px); } +:root { --ring-seam:0; --ring-sheen:0; } +:root[data-profile="glass"] { --ring-seam:2; --ring-sheen:1; } +:root[data-profile="glass"][data-theme="dark"] { + --surface: color-mix(in srgb, #161616 calc(100% - var(--glass-transparency, 65%)), transparent); + --surface-inset: color-mix(in srgb, #262626 calc(100% - var(--glass-transparency, 65%)), transparent); + --surface-tint: rgba(255,255,255,0.035); + --surface-sat: 165%; + --frost: color-mix(in srgb, #ffffff var(--glass-frost, 3%), transparent); + --edge: linear-gradient(155deg, rgba(255,255,255,0.07), rgba(255,255,255,0.015) 55%, rgba(255,255,255,0)); + --shadow-1: 0 1px 2px rgba(0,0,0,0.4), 0 24px 60px -28px rgba(0,0,0,0.85); + --glans: inset 0 0 0 transparent; + --glans-on: inset 0 0 0 transparent; + --hover-shadow: 0 18px 40px -22px rgba(0,0,0,0.7); + --select-shadow: 0 14px 44px -18px var(--accent-glow); + --frame-border: rgba(255,255,255,0.10); + --frame-top: rgba(255,255,255,0.12); + --row-line: rgba(255,255,255,0.07); + --row-hover: rgba(255,255,255,0.05); + --row-sel: rgba(255,255,255,0.09); + --row-sel-hover: rgba(255,255,255,0.13); + --rim-top: rgba(255,255,255,0.18); + --rim-bottom: rgba(255,255,255,0.05); + --rim-hover: rgba(255,255,255,0.55); + --rim-hover-bottom: rgba(255,255,255,0.16); + --hairline: rgba(255,255,255,0.60); + --hairline-hover: rgba(255,255,255,0.95); + --hover-fill: rgba(255,255,255,0.04); + --chip-on: rgba(255,255,255,0.12); + --chip-on-edge: inset 0 1px 0 rgba(255,255,255,0.16); + --float-bg: color-mix(in srgb, var(--bg-raised) calc(100% - var(--dialog-transparency, 30) * 1%), transparent); + --slider-track: rgba(255,255,255,0.07); + --slider-track-edge: inset 0 1px 2px rgba(0,0,0,0.45), 0 1px 0 rgba(255,255,255,0.06); + --slider-fill: linear-gradient(90deg, color-mix(in srgb, var(--accent) 40%, transparent), var(--accent)); + --slider-thumb: radial-gradient(circle at 35% 30%, #ffffff, rgba(255,255,255,0.8) 50%, rgba(255,255,255,0.55)); + --slider-thumb-edge: inset 0 0 0 1px rgba(255,255,255,0.7), 0 2px 10px rgba(0,0,0,0.55), 0 0 14px -2px var(--accent-glow); +} +:root[data-profile="glass"][data-theme="light"] { + --surface: color-mix(in srgb, #ffffff calc(100% - var(--glass-transparency, 65%)), transparent); + --surface-inset: color-mix(in srgb, #f4f4f4 calc(100% - var(--glass-transparency, 65%)), transparent); + --surface-tint: rgba(0,0,0,0.025); + --surface-sat: 160%; + --frost: color-mix(in srgb, #ffffff calc(var(--glass-frost, 3%) * 2), transparent); + --edge: linear-gradient(155deg, rgba(255,255,255,0.7), rgba(255,255,255,0.12) 60%); + --shadow-1: 0 1px 2px rgba(0,0,0,0.04), 0 10px 28px -20px rgba(0,0,0,0.10); + --glans: inset 0 1px 0 rgba(255,255,255,0.9); + --glans-on: inset 0 0 0 transparent; + --hover-shadow: 0 10px 26px -12px rgba(0,0,0,0.20); + --select-shadow: 0 8px 24px -14px color-mix(in srgb, var(--accent) 22%, transparent); + --frame-border: rgba(0,0,0,0.12); + --frame-top: rgba(255,255,255,0.9); + --row-line: rgba(0,0,0,0.06); + --row-hover: rgba(0,0,0,0.035); + --row-sel: rgba(255,255,255,0.6); + --row-sel-hover: rgba(255,255,255,0.75); + --rim-top: rgba(0,0,0,0.14); + --rim-bottom: rgba(0,0,0,0.07); + --rim-hover: rgba(0,0,0,0.28); + --rim-hover-bottom: rgba(0,0,0,0.14); + --hairline: rgba(0,0,0,0.22); + --hairline-hover: rgba(0,0,0,0.42); + --hover-fill: rgba(255,255,255,0.35); + --chip-on: rgba(255,255,255,0.75); + --chip-on-edge: inset 0 1px 0 #ffffff, 0 1px 2px rgba(0,0,0,0.08); + --float-bg: color-mix(in srgb, #ffffff calc(100% - var(--dialog-transparency, 30) * 1%), transparent); + --slider-track: rgba(0,0,0,0.06); + --slider-track-edge: inset 0 1px 2px rgba(0,0,0,0.12), 0 1px 0 rgba(255,255,255,0.9); + --slider-fill: linear-gradient(90deg, color-mix(in srgb, var(--accent) 40%, transparent), var(--accent)); + --slider-thumb: radial-gradient(circle at 35% 30%, #ffffff, #f5f5f5 55%, #e6e6e6); + --slider-thumb-edge: inset 0 0 0 1px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.18), 0 0 14px -2px var(--accent-glow); +} +:root[data-profile="glass"]:not([data-glow="off"]) { + --ambient: radial-gradient(58rem 34rem at 8% -8%, var(--accent-glow), transparent 62%), + radial-gradient(46rem 30rem at 102% 4%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 66%); +} +:root[data-profile="glass"]:not([data-render="real"]) :is(.card, .table-frame) { + backdrop-filter:none; -webkit-backdrop-filter:none; +} +@media (prefers-reduced-transparency: reduce) { + :root[data-profile="glass"][data-theme] { + --surface:var(--bg-elevated); --surface-inset:var(--bg-raised); --surface-tint:var(--bg-raised); + --surface-blur:0px; --surface-sat:100%; --frost:transparent; + } +} + +*, *::before, *::after { box-sizing:border-box; margin:0; padding:0; } +html { scroll-behavior:smooth; scrollbar-width:thin; scrollbar-color:var(--scrollbar) transparent; } +body { + font-family:var(--font-base); background:var(--bg); color:var(--text-primary); + font-size:15px; line-height:1.6; -webkit-font-smoothing:antialiased; + transition:background 0.25s, color 0.25s; +} + +a { color:var(--accent); } +a:visited { color:var(--accent); } + +body::before { + content:''; position:fixed; inset:0; z-index:0; pointer-events:none; + background:var(--ambient); transition:background 0.3s; +} +.page { position:relative; z-index:1; display:flex; min-height:100vh; } +.page, .mobile-topbar { opacity:0; } +html[data-app="ready"] .page, html[data-app="ready"] .mobile-topbar { opacity:1; transition:opacity 0.3s var(--ease-out); } +.sidebar { + width:var(--sidebar-w); flex-shrink:0; position:sticky; top:0; height:100vh; overflow-y:auto; + padding:var(--nav-line-top) var(--sidebar-pad) 2rem; scrollbar-width:none; + display:flex; flex-direction:column; transition:border-color 0.25s, background 0.25s; +} +.sidebar::-webkit-scrollbar { display:none; } +#splash { position:fixed; inset:0; z-index:2000; display:none; align-items:center; justify-content:center; background:var(--bg); transition:opacity 0ms ease; } +html[data-splash="on"] #splash { display:flex; } +#splash.is-done { opacity:0; pointer-events:none; } +.splash-stage { display:flex; flex-direction:column; align-items:center; gap:2.4rem; padding:1rem; } +.splash-band { display:flex; align-items:center; gap:clamp(1.2rem, 4vmin, 2.6rem); } +.splash-logo { width:clamp(9rem, 38vmin, 17rem); aspect-ratio:1; background:var(--text-primary); -webkit-mask:var(--logo-mask) center / contain no-repeat; mask:var(--logo-mask) center / contain no-repeat; animation:splashLogo 1.3s cubic-bezier(0.2, 0.7, 0.2, 1) both; } +.splash-wing { display:flex; flex-direction:column; gap:0.55rem; } +.splash-wing--l { align-items:flex-end; } +.splash-wing--r { align-items:flex-start; } +.splash-wing span { position:relative; display:block; height:1px; width:clamp(4rem, 17vw, 15rem); background:var(--border-mid); transform:scaleX(0); animation:splashDraw 0.8s cubic-bezier(0.25, 0.8, 0.3, 1) 0.55s both; } +.splash-wing span:nth-child(2) { height:3px; } +.splash-wing span:nth-child(1), .splash-wing span:nth-child(3) { width:clamp(2.4rem, 10.5vw, 9.5rem); } +.splash-wing--l span { transform-origin:right center; } +.splash-wing--r span { transform-origin:left center; } +.splash-wing span::after { content:""; position:absolute; inset:0; background:var(--accent); opacity:0; animation:splashIgnite 0.9s linear 1.35s both; } +.splash-title { white-space:nowrap; font-size:clamp(1rem, 3.2vmin, 1.5rem); font-weight:300; letter-spacing:0.42em; padding-left:0.42em; text-transform:uppercase; color:var(--text-primary); animation:splashTitle 0.9s ease 0.9s both; } +@keyframes splashLogo { from { opacity:0; transform:scale(0.8); } to { opacity:1; transform:scale(1); } } +@keyframes splashDraw { to { transform:scaleX(1); } } +@keyframes splashIgnite { 0% { opacity:0; } 8% { opacity:0.9; } 14% { opacity:0.1; } 26% { opacity:0.75; } 34% { opacity:0.2; } 48% { opacity:1; } 100% { opacity:1; } } +@keyframes splashTitle { from { opacity:0; letter-spacing:0.6em; } to { opacity:1; letter-spacing:0.42em; } } +.splash-portfolio { margin-top:0.9rem; font-size:clamp(0.8rem, 2vmin, 0.95rem); font-weight:300; letter-spacing:0.18em; text-transform:uppercase; color:var(--text-sec); animation:splashTitle 0.9s ease 1.4s both; } +.splash-portfolio::after { content:var(--portfolio-name, ""); } +html:not([data-portfolio]) .splash-portfolio { display:none; } + +.splash-quote { display:none; max-width:min(34rem, 82vw); min-height:7.7em; text-align:center; font-size:clamp(0.85rem, 2.2vmin, 1rem); font-weight:300; font-style:italic; letter-spacing:0.06em; line-height:1.9; color:var(--text-sec); } +#splash.is-manual .splash-quote { display:block; } +.splash-quote-caret { display:inline-block; width:1px; height:1em; margin-left:2px; background:currentColor; vertical-align:-0.15em; animation:splashCaret 1s steps(1) infinite; } +#splash.is-typed .splash-quote-caret { display:none; } +@keyframes splashCaret { 50% { opacity:0; } } +@media (prefers-reduced-motion: reduce) { + #splash { transition:none; } + #splash .splash-logo, #splash .splash-wing span, #splash .splash-title, + #splash .splash-portfolio { animation:none; transform:none; opacity:1; } + #splash .splash-wing span::after { animation:none; opacity:1; } + #splash .splash-quote-caret { animation:none; } +} + +.login-page { min-height:100vh; display:flex; align-items:center; justify-content:center; padding:1.5rem; background:var(--bg); } +.login-form { display:flex; flex-direction:column; align-items:center; gap:0.7rem; width:min(22rem, 88vw); animation:loginRise 0.7s ease 1.5s both; } +.login-row { display:flex; align-items:stretch; justify-content:center; gap:0.5rem; width:100%; } +.login-row .btn { flex:0 0 auto; } +@media (max-width:768px) { + .login-row { flex-direction:column; } + .login-row .btn { width:100%; } +} +.login-input { flex:1 1 auto; } +.login-error { min-height:1.1em; margin:0; text-align:center; } +@keyframes loginRise { from { opacity:0; transform:translateY(0.6rem); } to { opacity:1; transform:none; } } +@media (prefers-reduced-motion: reduce) { + .login-page .splash-logo, .login-page .splash-wing span, .login-page .splash-title, + .login-page .splash-portfolio, .login-form { animation:none; transform:none; opacity:1; } + .login-page .splash-wing span::after { animation:none; opacity:1; } +} + +.sidebar-logo { font-size:1rem; font-weight:700; letter-spacing:-0.01em; margin-bottom:2rem; display:flex; flex-direction:column; align-items:center; gap:0.6rem; text-align:center; cursor:pointer; } +.sidebar-portfolio { margin-top:-1.4rem; margin-bottom:2rem; text-align:center; font-size:0.7rem; font-weight:600; letter-spacing:0.12em; text-transform:uppercase; color:var(--text-sec); } +.sidebar-portfolio::after { content:var(--portfolio-name, ""); } +html:not([data-portfolio]) .sidebar-portfolio { display:none; } +.mobile-topbar-portfolio { font-size:0.6rem; font-weight:600; line-height:1.2; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-sec); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.mobile-topbar-portfolio::after { content:var(--portfolio-name, ""); } +html:not([data-portfolio]) .mobile-topbar-portfolio { display:none; } + +.sidebar-logo-icon { width:40px; height:40px; background:var(--accent); border-radius:10px; display:grid; align-content: end; place-items:center; flex-shrink:0; transition:background 0.45s var(--ease-out); } +.sidebar-logo-icon::before { content:""; width:30px; height:30px; background:#fff; -webkit-mask:var(--logo-mask) center / contain no-repeat; mask:var(--logo-mask) center / contain no-repeat; transform:translateY(2px); } +[data-theme="dark"] .sidebar-logo-icon::before { background:#000; } +.sidebar-logo .sidebar-logo-icon { width:56px; height:45px; border-radius:8px; } +.sidebar-logo .sidebar-logo-icon::before { width:46px; height:46px; } +.mobile-topbar-logo .sidebar-logo-icon { width:40px; height:32px; border-radius:6px; } +.mobile-topbar-logo .sidebar-logo-icon::before { width:33px; height:33px; transform:translateY(1px); } +.sidebar-section-title { text-align:right; padding:1.4rem 0.6rem 0.5rem 0; } +.sidebar-section-title:first-child { padding-top:0; } +#nav { display:flex; flex-direction:column; flex:1; padding-bottom:1rem; } +.nav-spacer { flex:1 1 auto; min-height:1.8rem; } +.sidebar a { display:block; font-size:0.92rem; font-weight:600; letter-spacing:0.01em; color:var(--text-sec); text-decoration:none; padding:0.32rem 0.6rem; text-align:right; transition:color 0.15s; margin:1px 0; cursor:pointer; } +.sidebar a:hover { color:var(--text-primary); } +.sidebar a.active { color:var(--accent-text); } + +.sidebar a.has-attention { display:flex; align-items:center; justify-content:flex-end; gap:0.4rem; } +.sidebar a.is-customizable { display:flex; align-items:center; justify-content:flex-end; gap:0.4rem; } +.sidebar a .nav-attention { order:-1; } +.sidebar a .nav-edit-toggle { order:-2; } +.nav-edit-toggle { flex:none; display:inline-flex; align-items:center; color:var(--text-sec); opacity:0; cursor:pointer; padding:0.12rem; border-radius:var(--radius-sm); transition:opacity 0.12s, color 0.12s, background 0.12s; } +.sidebar a.is-customizable:hover .nav-edit-toggle { opacity:0.5; } +.sidebar a.is-customizable .nav-edit-toggle.is-on { opacity:1; color:var(--accent); } +.sidebar a.is-customizable .nav-edit-toggle:hover { opacity:1; color:var(--accent); background:var(--accent-soft); } +.sidebar a .nav-label { min-width:0; overflow:hidden; text-overflow:ellipsis; } +.nav-attention { --attn-c:var(--accent); display:inline-block; flex:none; width:7px; height:7px; border-radius:50%; background:var(--attn-c); vertical-align:middle; } +.nav-attention.is-problem { --attn-c:var(--sig-red); } +.nav-attention.is-dim { opacity:0.4; box-shadow:none; animation:none; } +@media (prefers-reduced-motion: no-preference) { .nav-attention { animation:attn-pulse 2s ease-in-out infinite; } } + +.controls { margin-top:auto; padding-top:1.5rem; border-top:1px solid var(--border); display:flex; flex-direction:column; gap:0.75rem; } +.pill-toggle { display:flex; background:var(--pill-track, var(--bg-raised)); border-radius:var(--radius-full); padding:3px; gap:2px; } +.slider { -webkit-appearance:none; appearance:none; width:100%; height:22px; margin:0; background:transparent; cursor:pointer; --pct:50%; } +.slider::-webkit-slider-runnable-track { height:6px; border-radius:var(--radius-full); + background:var(--slider-fill) 0 0 / var(--pct) 100% no-repeat, var(--slider-track); box-shadow:var(--slider-track-edge); } +.slider::-moz-range-track { height:6px; border-radius:var(--radius-full); background:var(--slider-track); box-shadow:var(--slider-track-edge); } +.slider::-moz-range-progress { height:6px; border-radius:var(--radius-full); background:var(--slider-fill); } +.slider::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:18px; height:18px; margin-top:-6px; border:0; border-radius:50%; + background:var(--slider-thumb); box-shadow:var(--slider-thumb-edge); transition:transform 0.15s var(--ease-out), box-shadow 0.15s; } +.slider::-moz-range-thumb { width:18px; height:18px; border:0; border-radius:50%; + background:var(--slider-thumb); box-shadow:var(--slider-thumb-edge); transition:transform 0.15s var(--ease-out), box-shadow 0.15s; } +.slider:hover::-webkit-slider-thumb { transform:scale(1.1); } +.slider:hover::-moz-range-thumb { transform:scale(1.1); } +.slider:active::-webkit-slider-thumb { transform:scale(1.18); box-shadow:var(--slider-thumb-edge), 0 0 0 6px var(--accent-soft); } +.slider:active::-moz-range-thumb { transform:scale(1.18); box-shadow:var(--slider-thumb-edge), 0 0 0 6px var(--accent-soft); } +.slider:focus-visible { outline:none; } +.slider:focus-visible::-webkit-slider-thumb { box-shadow:var(--slider-thumb-edge), 0 0 0 3px var(--bg), 0 0 0 5px var(--accent); } +.slider:focus-visible::-moz-range-thumb { box-shadow:var(--slider-thumb-edge), 0 0 0 3px var(--bg), 0 0 0 5px var(--accent); } +.slider-row { display:grid; grid-template-columns:7.5rem minmax(0, 1fr) 3.2rem; align-items:center; gap:0.75rem; font-size:0.8rem; color:var(--text-sec); } +.slider-row output { text-align:right; font-variant-numeric:tabular-nums; color:var(--text-primary); } +.slider-row--wide { grid-template-columns:10rem minmax(0, 1fr) 3.2rem; } +[data-theme="dark"] :is(.dialog, .card) { --pill-track:rgba(0,0,0,0.30); } +[data-theme="light"] :is(.dialog, .card) { --pill-track:rgba(0,0,0,0.06); } +.accent-dots { display:flex; flex-wrap:wrap; gap:0.5rem; } +.accent-dots button { + width:22px; height:22px; padding:0; border-radius:50%; border:2px solid transparent; + background:var(--accent); background-clip:padding-box; box-shadow:0 0 0 1px var(--border); + cursor:pointer; transition:border-color 0.15s, transform 0.15s; +} +.accent-dots button:hover { transform:scale(1.12); } +.accent-dots button.on { border-color:var(--text-primary); } +.accent-dots button:focus-visible { outline:2px solid var(--accent); outline-offset:2px; } +.pill-toggle button { flex:1; border:none; background:transparent; color:var(--text-sec); font-family:var(--font-base); font-size:0.75rem; font-weight:500; padding:0.3rem 0.5rem; border-radius:var(--radius-full); cursor:pointer; transition:all 0.2s; white-space:nowrap; } +.pill-toggle button.on { background:var(--accent); color:#fff; } +[data-theme="dark"] .pill-toggle button.on { color:#000; } +.controls { gap:0.5rem; } +.controls .pill-toggle { padding:2px; } +.controls .pill-toggle button { font-size:0.66rem; padding:0.2rem 0.4rem; } + +.main { flex:1; padding:var(--main-pad-y) var(--main-pad-x); max-width:none; min-width:0; } +.main::before, .page-aside::after { + content:""; position:fixed; pointer-events:none; + top:var(--nav-line-top); bottom:var(--nav-line-bottom); + width:var(--nav-line-cap); + border:1px solid var(--border-mid); border-left:none; + border-top-right-radius:calc(var(--nav-line-cap) - 1px); + border-bottom-right-radius:calc(var(--nav-line-cap) - 1px); + transition:border-color 0.25s; +} +.main::before { left:calc(var(--sidebar-w) - var(--nav-line-cap)); } +.sidebar a.active { position:relative; } +.sidebar a.active::after { + content:""; position:absolute; z-index:1; top:0; bottom:0; + right:calc(-1 * var(--sidebar-pad) - 1px); width:3px; + border-radius:2px; background:var(--accent); +} +.page-layout { display:flex; align-items:flex-start; gap:2rem; } +html:has(.page-layout--narrow) { --page-aside-w:var(--sidebar-w); } +html:has(.page-layout > .page-aside) { --hint-line-x:calc(var(--sidebar-w) + var(--page-aside-w)); } +html:has(.page-layout > .page-aside) { --aside-vh-offset:1.7rem; } +.page-aside { + flex:0 0 var(--page-aside-w); width:var(--page-aside-w); + margin-left:calc(-1 * var(--main-pad-x)); + padding:0 var(--sidebar-pad) 2rem; + position:sticky; top:var(--aside-vh-offset); height:calc(100vh - var(--aside-vh-offset) - var(--main-pad-y)); overflow-y:auto; scrollbar-width:none; + display:flex; flex-direction:column; gap:1.6rem; + z-index:20; +} +.page-aside::-webkit-scrollbar { display:none; } +.page-aside .tabset { flex-direction:column; gap:0.1rem; padding:0; margin:0 0 0.2rem; border-bottom:none; } +.page-aside .tabset::before { display:none; } +.page-aside .tabset-tab { position:relative; text-align:right; padding:0.5rem 0.2rem; margin:0; border-bottom:none; border-radius:var(--radius-sm); } +.page-aside .tabset-tab.is-on::after { + content:""; position:absolute; z-index:1; top:0; bottom:0; + right:calc(-1 * var(--sidebar-pad) - 1px); width:3px; + border-radius:2px; background:var(--accent); +} +.page-aside::after { left:calc(var(--sidebar-w) + var(--page-aside-w) - var(--nav-line-cap)); } +.page-body { flex:1; min-width:0; } +@media (max-width:1280px) { + html:has(.page-layout > .page-aside) { --hint-line-x:var(--sidebar-w); } + .page-layout { --page-aside-w:auto; display:block; } + .page-aside { position:relative; top:auto; width:auto; height:auto; overflow:visible; margin:0 0 1.8rem; padding:0; } + .page-aside::after { display:none; } + .page-aside .tabset { flex-direction:row; flex-wrap:wrap; gap:0.5rem; } + .page-aside .tabset-tab { text-align:left; border-bottom:2px solid transparent; } + .page-aside .tabset-tab.is-on { border-bottom-color:var(--accent); } + .page-aside .tabset-tab.is-on::after { display:none; } +} + +.page-header { display:flex; align-items:baseline; gap:1rem; margin-bottom:2rem; } +.page-title { font-size:1.4rem; font-weight:700; letter-spacing:-0.02em; } +.page-title svg { width:1.15em; height:1.15em; vertical-align:-0.18em; margin-right:0.35em; } + +html[data-pagetitle="off"] .page-header:has(> h1.page-title) { display:none; } +html[data-pagetitle="off"] { --table-vh-offset:5.3rem; --sig-chrome:3.5rem; --aside-vh-offset:1.7rem; --page-head-h:0rem; } +html[data-pagetitle="off"] .dashboard-grid { margin-top:0; } + +.t-h3 { font-size:1.1rem; font-weight:600; letter-spacing:-0.01em; line-height:1.3; } +.t-h4 { font-size:0.9rem; font-weight:600; line-height:1.4; } +.t-body { font-size:0.93rem; font-weight:400; line-height:1.65; color:var(--text-primary); } +.t-body-sm { font-size:0.82rem; line-height:1.6; color:var(--text-sec); } +.t-caption { font-size:0.72rem; line-height:1.5; color:var(--text-sec); } +.t-label { font-size:0.68rem; font-weight:600; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-sec); } +.t-label--ter { color:var(--text-ter); } +.t-mono { font-size:0.82rem; font-family:var(--font-mono); color:var(--accent); } +.t-num-lg { font-size:2.4rem; font-weight:700; letter-spacing:-0.03em; font-variant-numeric:tabular-nums; } +.t-num-md { font-size:1.4rem; font-weight:700; letter-spacing:-0.02em; font-variant-numeric:tabular-nums; } +.t-pos { color:var(--pos); } .t-neg { color:var(--neg); } .t-muted { color:var(--text-sec); } .t-accent { color:var(--accent); } +.delta { display:inline-block; width:max-content; padding:0.05rem 0.45rem; border-radius:var(--radius-full); font-weight:600; font-variant-numeric:tabular-nums; } +.delta.t-pos { background:var(--pos-bg); } +.delta.t-neg { background:var(--neg-bg); } + +.btn { display:inline-flex; align-items:center; justify-content:center; gap:0.4rem; border:none; cursor:pointer; font-family:var(--font-base); font-weight:600; letter-spacing:-0.01em; transition:opacity 0.15s, transform 0.1s, box-shadow 0.2s; white-space:nowrap; text-decoration:none; } +.btn:active { transform:scale(0.97); } +.btn:disabled { opacity:0.35; cursor:not-allowed; } +.btn:disabled:active { transform:none; } +.btn-lg { font-size:0.9rem; padding:0.85rem 1.8rem; border-radius:var(--radius-md); } +.btn-md { font-size:0.82rem; padding:0.65rem 1.3rem; border-radius:var(--radius-md); } +.btn-sm { font-size:0.75rem; padding:0.45rem 0.9rem; border-radius:var(--radius-sm); } +.btn-xs { font-size:0.68rem; padding:0.3rem 0.7rem; border-radius:var(--radius-sm); } +.btn--field { padding-block:0.65rem; font-size:0.88rem; } +.btn-new { width:var(--tctl-h); height:var(--tctl-h); padding:0; align-self:center; display:inline-flex; align-items:center; justify-content:center; border-radius:var(--radius-md); color:var(--accent); background:var(--neutral-bg); border:1px solid var(--border); transition:color 0.15s, background 0.15s, border-color 0.15s; } +.btn-new:hover { background:var(--accent); border-color:var(--accent); color:#fff; } +[data-theme="dark"] .btn-new:hover { color:#000; } +.tx-link { position:absolute; left:0.4rem; top:50%; transform:translateY(-50%); width:3px; height:3px; border-radius:50%; background:var(--text-ter); opacity:0; cursor:pointer; transition:opacity 0.12s var(--ease-out), transform 0.12s var(--ease-out), background 0.15s; } +.tx-link::before { content:''; position:absolute; inset:-7px; border-radius:var(--radius-full); } +.tx-link.is-matched { opacity:1; background:var(--accent); } +tbody tr:hover .tx-link, .mv-row:hover .tx-link, .tx-link.is-open, .tx-link:focus-visible { opacity:1; transform:translateY(-50%) scale(1.9); outline:none; } + +.match-pop { position:fixed; z-index:1000; min-width:280px; max-width:340px; background:var(--bg-elevated); border:1px solid var(--border-mid); border-radius:var(--radius-md); box-shadow:var(--shadow-2); padding:0.5rem; font-size:0.82rem; } +[data-theme="dark"] .match-pop { background:var(--bg-raised); } + +.conv-pop { position:fixed; z-index:1000; min-width:210px; background:var(--bg-elevated); border:1px solid var(--border-mid); border-radius:var(--radius-md); box-shadow:var(--shadow-2); padding:0.7rem; font-size:0.82rem; } +[data-theme="dark"] .conv-pop { background:var(--bg-raised); } +.conv-head { margin-bottom:0.55rem; } +.conv-row { display:flex; align-items:center; gap:0.5rem; margin-bottom:0.45rem; } +.conv-label { flex:0 0 4.5rem; color:var(--text-sec); } +.conv-row .input { flex:1; min-width:0; } +.conv-apply { width:100%; margin-top:0.25rem; } + +.new-menu { position:fixed; z-index:1000; min-width:190px; max-width:92vw; background:var(--bg-elevated); border:1px solid var(--border-mid); border-radius:var(--radius-md); box-shadow:var(--shadow-2); padding:0.4rem; display:flex; flex-direction:column; gap:0.2rem; } +[data-theme="dark"] .new-menu { background:var(--bg-raised); } +.new-menu .btn { margin:0; padding:0.55rem 0.9rem; border-radius:var(--radius-sm); transition:color 0.15s, background 0.15s, border-color 0.15s; } +.new-menu .btn:hover { background:var(--accent); border-color:var(--accent); color:#fff; } +[data-theme="dark"] .new-menu .btn:hover { color:#000; } +.new-menu .new-menu-item { display:block; width:100%; text-align:left; white-space:nowrap; } +.merge-menu { width:260px; max-width:92vw; padding:0.75rem; gap:0.5rem; } +.merge-menu .t-label { margin:0; } +.merge-menu .input { width:100%; } +.merge-menu [data-merge-status] { white-space:normal; line-height:1.35; } +.conv-field { position:relative; } +.conv-field .input { width:100%; padding-right:1.7rem; } +.conv-trigger { position:absolute; top:50%; right:0.3rem; transform:translateY(-50%); display:inline-flex; align-items:center; justify-content:center; width:1.45rem; height:1.45rem; padding:0; border:none; border-radius:var(--radius-sm); background:transparent; color:var(--text-sec); cursor:pointer; opacity:0; transition:opacity .12s, color .15s, background .15s; } +.conv-field:hover .conv-trigger, .conv-field:focus-within .conv-trigger { opacity:1; } +.conv-trigger:hover { color:var(--accent); background:var(--bg-overlay); } +.table-wrap input[type="number"] { appearance:textfield; -moz-appearance:textfield; } +.table-wrap input[type="number"]::-webkit-outer-spin-button, +.table-wrap input[type="number"]::-webkit-inner-spin-button { -webkit-appearance:none; margin:0; } +.input[type="number"] { appearance:textfield; -moz-appearance:textfield; } +.input[type="number"]::-webkit-outer-spin-button, +.input[type="number"]::-webkit-inner-spin-button { -webkit-appearance:none; margin:0; } +.match-pop-head { padding:0.3rem 0.35rem 0.45rem; } +.match-pop-link { display:flex; align-items:flex-start; gap:0.5rem; padding:0.5rem 0.4rem; } +.match-pop-cand { display:flex; align-items:flex-start; gap:0.5rem; padding:0.55rem 0.4rem; border-radius:var(--radius-sm); cursor:pointer; } +.match-pop-cand:hover { background:var(--neutral-bg); } +.match-pop-main { flex:1; min-width:0; } +.match-pop-main .l1 { font-weight:600; line-height:1.3; } +.match-pop-main .l2 { color:var(--text-sec); font-size:0.74rem; line-height:1.35; margin-top:0.1rem; } +.match-pop-method { display:inline-block; margin-top:0.3rem; font-size:0.6rem; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-ter); } +.match-pop-x { flex-shrink:0; border:none; background:none; color:var(--text-ter); cursor:pointer; font-size:1.05rem; line-height:1; padding:0.1rem 0.35rem; border-radius:var(--radius-sm); } +.match-pop-x:hover { color:var(--neg); background:var(--neg-bg); } +.match-pop-sep { border-top:1px solid var(--border-mid); margin:0.4rem 0; } +.match-pop-filter { margin:0.15rem 0 0.5rem; } +.match-pop-list { max-height:240px; overflow-y:auto; } +.match-pop-empty { color:var(--text-ter); padding:0.5rem 0.35rem; } +.btn-primary { background:var(--accent); color:#fff; } +[data-theme="dark"] .btn-primary { color:#000; } +.btn-primary:hover { opacity:0.88; } +[data-theme="dark"][data-accent="green"] .btn-primary { box-shadow:0 0 20px var(--accent-glow); } +[data-theme="dark"][data-accent="lila"] .btn-primary { box-shadow:0 0 20px var(--accent-glow); } +.btn-secondary { background:var(--bg-raised); color:var(--text-primary); border:1px solid var(--border-mid); } +.btn-secondary:hover { background:var(--bg-overlay); } +.btn-ghost { background:transparent; color:var(--text-primary); border:1px solid var(--border-mid); } +.btn-ghost:hover { background:var(--neutral-bg); } +.btn-danger { background:var(--neg-bg); color:var(--neg); } +.btn-danger:hover { opacity:0.8; } + +.badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.7rem; font-weight:600; padding:0.25rem 0.65rem; border-radius:var(--radius-full); letter-spacing:0.02em; border:1px solid color-mix(in srgb, currentColor 30%, transparent); } +.badge svg { width:0.95em; height:0.95em; flex-shrink:0; } +.badge-icon { width:28px; height:28px; padding:0; justify-content:center; } +.badge-icon svg { width:15px; height:15px; } +.badge-accent { background:var(--accent-soft); color:var(--accent-text); } +.badge-pos { background:var(--pos-bg); color:var(--pos); } +.badge-neg { background:var(--neg-bg); color:var(--neg); } +.badge-neutral { background:var(--neutral-bg); color:var(--text-sec); } +.badge-outline { background:transparent; color:var(--text-sec); border:1px solid var(--border-mid); } + +.conn-frame { margin-bottom:1.4rem; } +.conn-usedby { margin:0.2rem 0 1rem; } +.connp-row { display:grid; grid-template-columns:repeat(auto-fill, minmax(250px, 1fr)); gap:0.8rem; margin-bottom:1.1rem; } +.connp { position:relative; min-width:0; padding:0.95rem 1.15rem 2.7rem; border:1px solid var(--border-mid); border-radius:var(--radius-md); background:var(--bg-raised); cursor:pointer; transition:border-color 0.15s, background 0.15s; } +.connp:hover { border-color:var(--text-ter); } +.connp.is-active { border-color:var(--accent); background:var(--accent-soft); } +.connp-dot { position:absolute; top:0.75rem; right:0.75rem; width:8px; height:8px; border-radius:50%; background:var(--border-mid); } +.connp-dot.dot-ok { background:var(--pos); } +.connp-dot.dot-fail { background:var(--neg); } +.connp-name { font-weight:600; padding-right:1.4rem; } +.connp-url { font-family:var(--font-mono); font-size:0.75rem; color:var(--text-ter); margin-top:0.25rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.connp-hover { position:absolute; right:0.55rem; bottom:0.55rem; display:flex; gap:0.3rem; opacity:0; transition:opacity 0.15s; } +.connp:hover .connp-hover, .connp:focus-within .connp-hover { opacity:1; } +.connp-mini { border:1px solid var(--border-mid); background:var(--bg-elevated); color:var(--text-sec); border-radius:var(--radius-sm); width:1.7rem; height:1.7rem; display:grid; place-items:center; cursor:pointer; font-size:0.78rem; } +.connp-mini:hover { color:var(--text-primary); } +.conn-foot { display:flex; align-items:center; gap:0.6rem; flex-wrap:wrap; } +.conn-result { font-size:0.82rem; color:var(--text-sec); opacity:1; transition:opacity 0ms ease; } +.conn-result.is-fading { opacity:0; } +.conn-result.is-ok { color:var(--pos); } +.conn-result.is-fail { color:var(--neg); } + +.card { position:relative; background:var(--surface); --radius:var(--radius-lg); --padding:1.8rem; --padding-x:var(--padding); border-radius:var(--radius); padding:var(--padding); box-shadow:var(--shadow-1); backdrop-filter:blur(var(--surface-blur)) saturate(var(--surface-sat)); -webkit-backdrop-filter:blur(var(--surface-blur)) saturate(var(--surface-sat)); transition:background 0.25s, box-shadow 0.25s; } +[data-theme="light"] .card { border:1px solid var(--border); } +[data-theme="dark"] .card { border:1px solid var(--border); } +.card::before { content:''; position:absolute; inset:0; border-radius:inherit; background:var(--edge); pointer-events:none; } +.card > * { position:relative; } +.card-flat { background:var(--surface); border:1px solid var(--border); --radius:var(--radius-lg); --padding:1.4rem; border-radius:var(--radius); padding:var(--padding); } +.card > .card, .card > .card-flat, .card > .table-wrap, +.card-flat > .card, .card-flat > .table-wrap, +.card .alert, .card .input, .card-flat .input, +.card .btn, .card-flat .btn, .field .btn, +.card .label-combo-btn, .card-flat .label-combo-btn, +.field .field-input { + border-radius: max(4px, calc(var(--radius) - var(--padding))); +} +.t-value { font-weight:700; font-variant-numeric:tabular-nums; color:var(--text-primary); letter-spacing:-0.025em; } + +.card-cols { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:1.5rem; align-items:start; } +.card-stack { display:flex; flex-direction:column; gap:1.5rem; align-items:stretch; } +@media (max-width:1040px) { .card-cols { grid-template-columns:repeat(2, minmax(0, 1fr)); } } +@media (max-width:700px) { .card-cols { grid-template-columns:1fr; } } + +.dashboard-grid { --dash-gap:1.5rem; display:grid; grid-template-columns:repeat(12, 1fr); column-gap:var(--dash-gap); row-gap:var(--dash-gap); grid-auto-rows:3rem; align-items:start; margin-top:0.5rem; } +.dashboard-cell { min-width:0; min-height:0; display:flex; flex-direction:column; } +.dashboard-grid > .dashboard-cell.is-sized { align-self:stretch; } +.dashboard-body { flex:1 1 auto; min-height:0; display:flex; flex-direction:column; overflow:hidden; } +.dashboard-body > * { flex:1 1 auto; } +.net-card, .card.clockw, .metrics-card, .standing-card { display:flex; flex-direction:column; } +.net-card > #net-body { margin-top:auto; margin-bottom:auto; } +.standing-card > #standing-body { margin-top:auto; margin-bottom:auto; } +.card.clockw > .clockw-world { margin-top:auto; } +.card.clockw > .clockw-list { margin-bottom:auto; } +.card.clockw.clockw--no-ex > .clockw-world { margin-bottom:auto; } +.metrics-card > .metrics-strip { margin-top:auto; } +.metrics-card > :last-child { margin-bottom:auto; } +.metrics-strip { display:flex; flex-wrap:wrap; gap:0.6rem 1.25rem; } +.metric-tile { display:flex; align-items:center; gap:0.5rem; min-width:5rem; } +.metric-dot { width:9px; height:9px; border-radius:50%; flex:0 0 auto; background:var(--text-ter); } +.metric-dot.is-ok { background:var(--sig-green); } +.metric-dot.is-warn { background:var(--sig-yellow); } +.metric-dot.is-bad { background:var(--sig-red); } +.metric-meta { line-height:1.15; } + +.clockw { display:flex; flex-direction:column; } +.clockw-world { display:flex; justify-content:center; align-items:flex-start; gap:0.6rem; flex-wrap:nowrap; margin:0.3rem 0 0.7rem; } +@media (min-width:769px) { + .dashboard-cell[data-w="1"] .clockw-world, + .dashboard-cell[data-w="2"] .clockw-world { flex-direction:column; align-items:center; gap:0.5rem; } + .dashboard-cell[data-w="1"] .clockw-world .clockw-clock, + .dashboard-cell[data-w="2"] .clockw-world .clockw-clock { flex:0 0 auto; width:100%; max-width:84px; } +} +.clockw-clock { flex:1 1 0; min-width:0; max-width:104px; display:flex; flex-direction:column; align-items:center; gap:0.3rem; } +.clockw-clock .clockw-face { width:100%; height:auto; } +.clockw-clock-cap { text-align:center; line-height:1.15; min-width:0; max-width:100%; } +.clockw-clock-name { display:block; font-size:0.68rem; color:var(--text-sec); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.clockw-clock.is-local .clockw-clock-name { color:var(--text-primary); font-weight:600; } +.clockw-clock-time { display:block; font-family:var(--font-mono); font-size:0.78rem; font-weight:600; color:var(--text-primary); font-variant-numeric:tabular-nums; } +.clockw-clock-date { display:block; font-size:0.62rem; color:var(--text-ter); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.clockw-face { display:block; } +.clockw-dial { fill:var(--bg-elevated); stroke:var(--border); stroke-width:1.5; transition:stroke 0.3s, filter 0.3s; } +.clockw-tick { stroke:var(--text-ter); stroke-width:0.8; } +.clockw-tick.is-major { stroke:var(--text-sec); stroke-width:1.6; } +.clockw-hand { stroke-linecap:round; } +.clockw-hour { stroke:var(--text-primary); stroke-width:3; } +.clockw-min { stroke:var(--text-primary); stroke-width:2; } +.clockw-sec { stroke:var(--accent); stroke-width:1; } +.clockw-cap { fill:var(--text-primary); } +.clockw-face.is-open .clockw-dial { stroke:var(--sig-green); stroke-width:2; } +.clockw-list { display:flex; flex-direction:column; } +.clockw-list-head { margin:0.9rem 0 0.4rem; padding:0 0.3rem; } +.clockw-row { display:flex; align-items:center; gap:0.7rem; padding:0.5rem 0.3rem; border-radius:var(--radius-sm); } +.clockw-mini { flex:none; display:inline-flex; } +.clockw-mini .clockw-face { width:32px; height:32px; } +.clockw-ex-text { flex:1; min-width:0; display:flex; flex-direction:column; gap:0.05rem; } +.clockw-ex-name { min-width:0; font-weight:600; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.clockw-ex-sub { font-size:0.74rem; color:var(--text-ter); font-variant-numeric:tabular-nums; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.clockw-open { flex:none; font-size:0.8rem; font-variant-numeric:tabular-nums; color:var(--text-ter); white-space:nowrap; } +@media (prefers-reduced-motion: no-preference) { .clockw-row.is-flash { animation:clockw-open-flash 1.5s ease-out 1; } } +@keyframes clockw-open-flash { 0% { background:var(--sig-green-zone); } 100% { background:transparent; } } +.dash-settings { position:absolute; top:2.5rem; left:50%; transform:translateX(-50%); z-index:4; min-width:210px; max-width:88%; background:var(--bg-elevated); border:1px solid var(--border-mid); border-radius:var(--radius-md); box-shadow:0 8px 24px rgba(0,0,0,0.35); padding:0.55rem 0.6rem; max-height:280px; overflow:auto; } +[data-theme="dark"] .dash-settings { background:var(--bg-raised); } +.dash-settings[hidden] { display:none; } +.amount-second { margin-left:0.6em; color:var(--text-sec); } +.dash-settings.dash-settings--wide { width:max-content; max-width:calc(100vw - 2rem); } +.dash-set-row { display:flex; flex-direction:column; gap:0.3rem; } +.dash-set-row + .dash-set-row { margin-top:0.6rem; padding-top:0.55rem; border-top:1px solid var(--border); } +.dash-set-select { font-size:0.8rem; padding:0.28rem 0.45rem; } +.dashboard-cell.is-settings-open .dash-edit-grip, +.dashboard-cell.is-settings-open .dash-edit-wsel-wrap, +.dashboard-cell.is-settings-open .dash-edit-x { opacity:0.35; pointer-events:none; } +.dashboard-cell.is-settings-open .dash-edit-cfg { color:var(--accent); } +.clockw-settings-title { margin-bottom:0.4rem; } +.clockw-check { display:flex; align-items:center; gap:0.6rem; padding:0.25rem 0.15rem; font-size:0.85rem; color:var(--text-primary); cursor:pointer; } +.clockw-check-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.clockw-settings-sep { border-top:1px solid var(--border); margin:0.45rem 0; } +.net-ladder { display:flex; align-items:center; justify-content:flex-end; gap:0.4rem; font-weight:600; color:var(--text-primary); } +.net-quote { margin-top:0.5rem; font-style:italic; color:var(--text-sec); line-height:1.5; } +.net-quote strong { font-style:normal; font-weight:600; color:var(--text-primary); font-variant-numeric:tabular-nums; } +.net-card { position:relative; overflow:hidden; } +.net-watermark { position:absolute; top:0.4rem; right:-0.7rem; z-index:0; font-size:8rem; line-height:1; opacity:0.09; filter:grayscale(1); pointer-events:none; user-select:none; } +.net-card .t-label, .net-card #net-body { position:relative; z-index:1; } +@media (max-width:768px) { + .dashboard-grid { grid-auto-rows:auto !important; } + .dashboard-grid > .dashboard-cell { grid-column:1 / -1 !important; grid-row:auto !important; } + .dashboard-grid > .dashboard-cell .dashboard-body { overflow:visible; } +} +#dashboard-root { position:relative; } +.dashboard-grid { transition:opacity 0.2s var(--ease-out); } +.dashboard-grid.is-loading { opacity:0; } +.dashboard-skeleton { position:absolute; inset:0; z-index:1; } +.dashboard-skel-grid { --dash-gap:1.5rem; display:grid; grid-template-columns:repeat(12, 1fr); gap:var(--dash-gap); align-items:start; } +@media (max-width:768px) { .dashboard-skel-grid > .dashboard-skel-cell { grid-column:1 / -1 !important; } } +.dashboard-cell { position:relative; } +.page-tools { display:flex; align-items:center; gap:0.5rem; margin-left:auto; align-self:center; } +.page-tools:empty { display:none; } +.dashboard-grid.is-editing > .dashboard-cell { border-radius:var(--radius-lg); isolation:isolate; } +.dashboard-grid.is-editing > .dashboard-cell::before { + content:""; position:absolute; inset:-6px; z-index:-1; pointer-events:none; + border:1.5px solid var(--accent-mid); border-radius:calc(var(--radius-lg) + 6px); background:transparent; + transition:border-color 0.18s var(--ease-out), box-shadow 0.18s var(--ease-out), background 0.18s var(--ease-out); +} +.dashboard-grid.is-editing > .dashboard-drop-preview { + z-index:2; pointer-events:none; border-radius:var(--radius-lg); + border:2px dashed var(--accent); background:var(--accent-soft); box-shadow:0 0 0 1px var(--accent), 0 0 18px var(--accent-glow); +} +.dashboard-grid.is-editing > .dashboard-cell.is-drop-swap::before { border-color:var(--accent); border-width:2px; box-shadow:0 0 0 1px var(--accent), 0 8px 26px var(--accent-glow); background:var(--accent-soft); } +.dashboard-grid.is-editing > .dashboard-cell.is-dragging { opacity:0.4; } +.dashboard-grid.is-editing > .dashboard-cell.is-dragging::before { border-color:var(--border); box-shadow:none; background:transparent; } +.dashboard-grid.is-dragging-active { user-select:none; -webkit-user-select:none; } +.dash-edit-grip { cursor:grab; touch-action:none; color:var(--text-ter); display:inline-flex; align-items:center; padding:0.15rem; border-radius:3px; line-height:0; } +.dash-edit-grip:hover { color:var(--accent); background:var(--neutral-bg); } +.dashboard-cell.is-dragging .dash-edit-grip { cursor:grabbing; } +.dashboard-drag-ghost { position:fixed; z-index:1000; pointer-events:none; opacity:0.92; transform:rotate(-1.5deg); filter:drop-shadow(0 14px 30px rgba(0,0,0,0.38)); } +.dashboard-drag-ghost > .card { margin:0; outline:1.5px solid var(--accent); outline-offset:2px; } +.dash-edit-bar { position:absolute; top:0.6rem; left:50%; right:auto; transform:translateX(-50%); z-index:3; display:flex; align-items:center; gap:0.1rem; background:var(--bg-elevated); border:1px solid var(--border-mid); border-radius:var(--radius-md); padding:0.15rem 0.3rem; box-shadow:0 2px 10px rgba(0,0,0,0.2); } +[data-theme="dark"] .dash-edit-bar { background:var(--bg-raised); } +.dash-edit-bar button { background:none; border:0; cursor:pointer; color:var(--text-sec); display:inline-flex; align-items:center; padding:0.15rem; border-radius:3px; line-height:0; } +.dash-edit-bar button:hover { color:var(--accent); background:var(--neutral-bg); } +.dash-edit-bar .chev-btn { padding:0.15rem; } +.dash-edit-wsel-wrap { display:inline-flex; align-items:center; gap:1px; } +.dash-edit-wsel { appearance:none; -webkit-appearance:none; background:var(--bg-raised); border:1px solid var(--border-mid); border-radius:3px; color:var(--text-primary); font:inherit; font-size:0.7rem; font-weight:600; font-variant-numeric:tabular-nums; line-height:1; padding:0.12rem 0.3rem; cursor:pointer; text-align:right; } +.dash-edit-wsel option { text-align:right; } +.dash-edit-wsel:hover { border-color:var(--accent); } +.dash-edit-wsel:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-soft); } +.dash-edit-x:hover { color:var(--neg, #e5484d) !important; background:none !important; } +.dash-profiles { display:flex; flex-direction:column; gap:0.25rem; min-width:9rem; max-width:14rem; } +.dash-profile-list { display:flex; flex-direction:column; background:var(--bg-raised); border:1px solid var(--border-mid); border-radius:var(--radius-sm); padding:0.15rem; max-height:9rem; overflow:auto; } +.dash-profile-item { display:block; width:100%; text-align:left; background:none; border:0; border-radius:3px; cursor:pointer; color:var(--text-primary); font:inherit; font-size:0.78rem; padding:0.28rem 0.45rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.dash-profile-item:hover { background:var(--neutral-bg); } +.dash-profile-item.is-active { background:var(--accent-soft); color:var(--accent); font-weight:600; } +.dash-profile-actions { display:flex; justify-content:center; gap:0.25rem; } +.dash-profile-btn { display:inline-flex; align-items:center; justify-content:center; background:none; border:1px solid var(--border-mid); border-radius:var(--radius-sm); color:var(--text-sec); cursor:pointer; padding:0.3rem 0.6rem; line-height:0; } +.dash-profile-btn:hover:not(:disabled) { color:var(--accent); border-color:var(--accent); } +.dash-profile-btn[data-act="profile-delete"]:hover { color:var(--neg, #e5484d); border-color:var(--neg, #e5484d); } +.dash-profile-btn:disabled { opacity:0.4; cursor:default; } +.nav-tools .dash-profiles { max-width:none; } +.nav-tools .dash-profile-actions { justify-content:stretch; } +.nav-tools .dash-profile-btn { flex:1 1 0; } +.dash-add-bar { display:flex; align-items:center; justify-content:center; z-index:2; color:var(--accent); background:var(--accent-soft); border:1.5px dashed var(--accent); border-radius:var(--radius-lg); cursor:pointer; } +.dash-add-bar[hidden] { display:none; } +.dash-add-bar-plus { display:inline-flex; line-height:0; padding:0.3rem; border-radius:50%; } +.dash-add-bar:hover .dash-add-bar-plus { background:var(--accent-mid); } +.dash-add-bar-plus svg { width:26px; height:26px; } +.dash-add-room { visibility:hidden; pointer-events:none; } +.dash-add-pop { position:fixed; top:auto; right:auto; margin-top:0; z-index:60; width:max-content; max-width:min(20rem, calc(100vw - 1rem)); } +.dash-add-mobile { display:none; } +@media (max-width:768px) { + .dash-add-bar, .dash-add-room { display:none !important; } + .dash-add-mobile { display:block; margin-top:1rem; padding:0.6rem; border:1.5px dashed var(--accent); border-radius:var(--radius-lg); } + .dash-add-mobile > .t-label { margin:0 0 0.3rem 0.5rem; } +} +.dashboard-add-menu { position:absolute; top:100%; right:0; margin-top:0.35rem; min-width:220px; background:var(--bg-elevated); border:1px solid var(--border-mid); border-radius:var(--radius-sm); box-shadow:0 8px 24px rgba(0,0,0,0.35); padding:0.3rem; z-index:20; display:none; max-height:340px; overflow:auto; } +[data-theme="dark"] .dashboard-add-menu { background:var(--bg-raised); } +.dashboard-add-menu.is-open { display:block; } +.dashboard-add-item { display:block; width:100%; text-align:left; background:none; border:0; cursor:pointer; color:var(--text-primary); font-size:0.82rem; padding:0.4rem 0.5rem; border-radius:3px; } +.dashboard-add-item:hover { background:var(--accent-soft); color:var(--accent); } +.dashboard-add-empty { font-size:0.78rem; color:var(--text-sec); padding:0.4rem 0.5rem; font-style:italic; } +.nav-tools { display:flex; flex-direction:column; gap:0.3rem; margin:0.35rem 0 0.7rem; padding:0 0.6rem; } +.nav-tools:empty { display:none; } +.nav-tools .btn { width:100%; } +.nav-edit-group { position:relative; padding-bottom:0.5rem; } +.nav-edit-group > .nav-tools { margin:0.3rem 0 0; } +.nav-edit-group > a.active::after { display:none; } +.nav-edit-group::after { + content:""; position:absolute; z-index:1; top:0; bottom:0; + right:calc(-1 * var(--sidebar-pad) - 1px); width:3px; + border-radius:2px; background:var(--accent); +} +.spot-card { display:flex; flex-direction:column; justify-content:center; padding:1rem 1.1rem; --padding:1rem; --padding-x:1.1rem; } +.spot-head-row { align-items:baseline; } +.spot-head { display:flex; align-items:center; gap:0.35rem; } +.spot-price { font-size:1.15rem; font-weight:700; font-variant-numeric:tabular-nums; color:var(--text-primary); } +.spot-sub { align-items:center; margin-top:0.2rem; min-height:1.1rem; } +.spark-change { font-size:0.8rem; font-weight:600; font-variant-numeric:tabular-nums; } +.spark-change.delta { padding:0.1rem 0.45rem 0; } +.momentum-dot { position:relative; display:inline-block; width:5px; height:5px; margin-left:0.35em; border-radius:50%; vertical-align:0.12em; } +.momentum-dot::after { content:''; position:absolute; inset:-6px; } +.momentum-dot.is-up { background:var(--pos); } +.momentum-dot.is-down { background:var(--neg); } +.range-cycle { display:flex; justify-content:flex-end; gap:0.1rem; } +.range-cycle .range-cycle-btn { border-radius:var(--radius-md); } +.spot-spark-wrap { margin-top:0.5rem; height:38px; } +.spot-spark-empty { height:38px; display:flex; align-items:center; justify-content:center; font-size:0.72rem; color:var(--text-sec); font-style:italic; } + +.spark-mini { position:relative; width:100%; height:100%; } +.spark-mini.is-up { color:var(--pos); } +.spark-mini.is-down { color:var(--neg); } +.spark-mini.is-accent { color:var(--accent); } +.spark-mini-svg { display:block; width:100%; height:100%; } +.spark-mini-svg .spark-grad-top { stop-color:currentColor; stop-opacity:0.28; } +.spark-mini-svg .spark-grad-bot { stop-color:currentColor; stop-opacity:0; } +.spark-mini-clip { position:absolute; inset:0; overflow:hidden; pointer-events:none; } +.spark-mini-vline { position:absolute; top:0; bottom:0; width:1px; transform:translateX(-0.5px); background:var(--text-primary); opacity:0.5; pointer-events:none; } +.spark-mini-dot { position:absolute; left:var(--spark-x, 50%); width:8px; height:8px; border-radius:50%; background:currentColor; border:2px solid var(--bg-elevated); transform:translate(-50%, -50%); pointer-events:none; } +.spark-mini-mark { position:absolute; left:var(--spark-x, 50%); width:6px; height:6px; border-radius:50%; background:currentColor; box-shadow:0 0 0 1.5px var(--bg-elevated); transform:translate(-50%, -50%); pointer-events:none; } +.spark-mini-mark.is-sell { background:var(--bg-elevated); box-shadow:0 0 0 1.5px currentColor; } +.spark-mini-cmp { opacity:0.4; } +.spot-spark-wrap, .sigw-spark, .wealth-spark { + width:calc(100% + 2 * var(--padding-x, 0px)); + margin-left:calc(-1 * var(--padding-x, 0px)); +} +.spark-mini-label { position:absolute; z-index:2; transform:translate(-50%, calc(-100% - 9px)); background:var(--bg-elevated); color:var(--text-sec); border:1px solid var(--border-mid); border-radius:var(--radius-sm); padding:0.2rem 0.45rem; font-size:0.7rem; font-weight:500; line-height:1.35; font-variant-numeric:tabular-nums; white-space:pre-line; width:max-content; max-width:calc(100% - 8px); pointer-events:none; box-shadow:0 2px 8px rgba(0,0,0,0.25); } +.spark-mini-label::first-line { color:var(--text-primary); font-weight:700; } +.spark-mini-label.is-below { transform:translate(-50%, 9px); } +.spark-mini-vline[hidden], .spark-mini-dot[hidden], .spark-mini-label[hidden] { display:none; } +.dash-tables { display:flex; flex-direction:column; margin-top:0.6rem; } +.dash-trow { display:flex; justify-content:space-between; align-items:baseline; gap:0.8rem; padding:0.32rem 0; border-bottom:1px solid var(--border); font-size:0.86rem; } +.dash-trow:last-child { border-bottom:0; } +.dash-trow .tname { color:var(--text-sec); } +.dash-trow .n { font-variant-numeric:tabular-nums; font-weight:600; color:var(--text-primary); text-align:right; } +.stw-dot { display:inline-block; width:5px; height:5px; border-radius:50%; background:var(--accent); margin-right:0.5rem; vertical-align:middle; flex:none; } +.stw-today { font-size:0.8rem; color:var(--text-sec); font-variant-numeric:tabular-nums; margin-bottom:0.15rem; } +.stw-today-row { font-size:0.75rem; margin-right:1.2rem; font-variant-numeric:tabular-nums; } +.stw-grid { display:grid; grid-template-columns:minmax(0, 1fr) auto auto; } +.stw-grid > .dash-trow { display:grid; grid-template-columns:subgrid; grid-column:1 / -1; gap:0; } +.standing-card .dash-trow { border-bottom:0; } +.stw-grid .tname { min-width:0; padding-right:0.8rem; } +.stw-today-cell { text-align:right; } +.stw-grid .n.po-host:has(.po-btn)::before { content:''; display:inline-block; width:1rem; transition:width 0.12s; } +.stw-grid .n.po-host:hover::before, .stw-grid .n.po-host:has(.po-btn:focus-visible)::before { width:0; } + +.notify-list { margin:0.35rem calc(-1 * var(--padding-x, 0.5rem)) 0; container-type:inline-size; } +.notify-row { display:grid; grid-template-columns:auto minmax(0, 1fr) auto; grid-template-areas:"count kind side" "count detail side"; align-items:baseline; column-gap:0.75rem; row-gap:0.1rem; padding:0.55rem var(--padding-x, 0.5rem); cursor:pointer; transition:background 0.15s; } +.notify-count { grid-area:count; align-self:center; } +.notify-kind { grid-area:kind; } +.notify-detail { grid-area:detail; } +.notify-side { grid-area:side; align-self:center; } +.notify-row:hover, .notify-row:focus-visible { background:var(--row-hover); outline:none; } +.notify-row:focus-visible { box-shadow:inset 2px 0 0 var(--accent); } +.notify-count { + display:grid; place-items:center; min-width:1.75rem; height:1.75rem; padding:0 0.3rem; border:1px solid var(--accent); border-radius:999px; + background:transparent; color:var(--accent-text); font:inherit; font-size:0.76rem; font-weight:600; font-variant-numeric:tabular-nums; + cursor:pointer; transition:background 0.15s, color 0.15s, transform 0.1s; +} +.notify-count > * { grid-area:1 / 1; display:inline-flex; transition:opacity 0.15s; } +.notify-check { opacity:0; } +.notify-check svg { width:14px; height:14px; } +.notify-count:is(:hover, :focus-visible) { background:var(--accent); color:#fff; outline:none; } +[data-theme="dark"] .notify-count:is(:hover, :focus-visible) { color:#000; } +.notify-count:is(:hover, :focus-visible) .notify-n { opacity:0; } +.notify-count:is(:hover, :focus-visible) .notify-check { opacity:1; } +.notify-count:active { transform:scale(0.94); } +.notify-kind { font-size:0.82rem; font-weight:600; color:var(--text-primary); line-height:1.3; overflow-wrap:break-word; } +.notify-detail { font-size:0.74rem; color:var(--text-sec); line-height:1.35; display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden; } +.notify-side { display:flex; align-items:center; gap:0.35rem; } +.notify-date { font-size:0.7rem; color:var(--text-ter); font-variant-numeric:tabular-nums; white-space:nowrap; } +.notify-go { display:inline-flex; color:var(--text-ter); transition:color 0.15s; } +.notify-go svg { width:14px; height:14px; } +.notify-row:is(:hover, :focus-visible) .notify-go { color:var(--accent-text); } +@container (max-width: 18rem) { + .notify-row { grid-template-areas:"count kind go" "count detail go" "count date go"; } + .notify-side { display:contents; } + .notify-date { grid-area:date; } + .notify-go { grid-area:go; align-self:center; } +} + +.imp-conflict { margin-bottom:0.8rem; } +.imp-switch { display:inline-flex; align-items:center; gap:0.55rem; cursor:pointer; } +.imp-switch-label { font-size:0.72rem; font-weight:600; color:var(--text-sec); min-width:5.5rem; text-align:right; } +.imp-diff { width:100%; border-collapse:collapse; margin-top:0.6rem; font-size:0.8rem; } +.imp-diff th { color:var(--text-ter); padding:0.2rem 0.55rem; border-bottom:1px solid var(--border); } +.imp-diff td { padding:0.32rem 0.55rem; border-bottom:1px solid var(--border); vertical-align:top; } +.imp-diff tr:last-child td { border-bottom:0; } +.imp-diff-field { color:var(--text-sec); } +.imp-diff-new { color:var(--accent-text); font-weight:600; } +.imp-multi { margin-bottom:0.8rem; } +.imp-cands { margin-top:0.4rem; } +.imp-cand { padding:0.55rem 0; border-top:1px solid var(--border); } +.imp-cand:first-child { border-top:0; } +.empty-state { font-size:0.93rem; color:var(--text-sec); line-height:1.6; padding:0.9rem 0; } +.empty-state-why { color:var(--text-ter); } + +.task-card-head { display:flex; align-items:center; justify-content:space-between; gap:0.6rem; margin-bottom:0.6rem; } +.task-card-empty { display:flex; align-items:center; margin-top:0.7rem; } +.task-card-empty-icon { display:inline-flex; color:var(--accent); opacity:0.85; } +.task-card-empty-icon svg { width:28px; height:28px; } +.rec-wiz-nav { display:flex; align-items:center; justify-content:space-between; gap:0.6rem; margin-bottom:0.6rem; } +.rec-item { padding:0.55rem 0.6rem; border-bottom:1px solid var(--border); border-radius:var(--radius-sm); } +.rec-item:last-child { border-bottom:0; } +.rec-item-row { display:flex; align-items:center; gap:0.7rem; flex-wrap:wrap; justify-content:space-between; } +.rec-item-meta { min-width:0; font-size:0.86rem; } +.rec-wiz-item { border:1px solid var(--border-mid); background:var(--bg-elevated); padding:0.9rem 1rem; } +.rec-item.is-current { border-left:3px solid var(--accent); background:var(--bg-elevated); } +.rec-hover { opacity:0; transition:opacity 0.15s; } +.rec-item:hover .rec-hover, .rec-item:focus-within .rec-hover, +.rec-item:has(.prop-form:not(.hidden)) .rec-hover { opacity:1; } +.rec-bulk { display:flex; gap:0.5rem; margin-top:0.6rem; } + +.sigw-price { font-size:2rem; font-weight:700; letter-spacing:-0.025em; font-variant-numeric:tabular-nums; margin:0.35rem 0 0.5rem; } +.sigw-sub { margin-bottom:0.35rem; } +.sigw-spark { position:relative; height:120px; margin-bottom:0.6rem; } +.sigw-lights { display:grid; grid-template-columns:repeat(3, 1fr); gap:0.55rem; } +.sigw-lights .sig-card { text-decoration:none; color:inherit; flex-direction:column; align-items:flex-start; gap:0.45rem; padding:0.7rem 0.7rem; border-radius:var(--radius-md); } +.sigw-lights .sig-card-val { white-space:normal; font-size:0.78rem; } +.sigw-card { display:flex; flex-direction:column; } +.sigw-card > .sigw-lights { margin-top:auto; } + +.tax-card { padding:1.1rem 1.2rem; overflow-x:auto; } +.tax-group-switch { margin-bottom:0.8rem; } +.tax-card-title { margin-bottom:0.8rem; } +.tax-card-wrap + .tax-card-wrap { margin-top:1.2rem; } +.tax-main-head { display:flex; align-items:flex-start; justify-content:space-between; gap:0.8rem; flex-wrap:wrap; } +.tax-all { display:inline-flex; align-items:center; cursor:pointer; } +.tax-card-wrap { position:relative; } +.ai-sec > summary { cursor:pointer; list-style:none; display:flex; align-items:center; gap:0.6rem; flex-wrap:wrap; } +.ai-sec > summary::-webkit-details-marker { display:none; } +.ai-sec[open] > summary .chev-btn { transform:rotate(180deg); } +.ai-stats { display:flex; align-items:flex-start; gap:1.6rem; margin-left:auto; flex-wrap:wrap; } +.ai-stat { display:flex; flex-direction:column; line-height:1.3; } +.ai-schuiven { display:flex; align-items:flex-start; gap:1.6rem; flex-wrap:wrap; } +.ai-schuiven > .ai-stat { min-width:5.2rem; align-items:center; text-align:center; } +.ai-stats > .ai-tellers { display:flex; align-items:flex-start; gap:1.6rem; } +.ai-stat > .toggle-switch { margin-top:0.55rem; } + +.tax-sec { margin-top:1.4rem; } +.tax-sec > summary { cursor:pointer; list-style:none; padding:0.4rem 0.2rem; display:flex; align-items:center; gap:0.45rem; } +.tax-sec > summary::-webkit-details-marker { display:none; } +.tax-sec[open] > summary .chev-btn { transform:rotate(180deg); } +.tax-sec > .card { margin-top:0.6rem; } +.tax-table { width:100%; border-collapse:collapse; table-layout:fixed; font-size:0.86rem; font-variant-numeric:tabular-nums; } +.tax-table th { padding:0.6rem 0.7rem; border-bottom:1px solid var(--border-mid); overflow:hidden; text-overflow:ellipsis; } +.tax-table th.num, .tax-table td.num { text-align:right; } +.tax-table td { padding:0.55rem 0.7rem; border-bottom:1px solid var(--border); vertical-align:middle; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.tax-table tr:last-child td { border-bottom:none; } +.tax-table td.cap { color:var(--text-sec); } +.tax-label { font-weight:600; } +.tax-expand { margin-right:0.35rem; vertical-align:middle; } +.tax-sub { display:none; } +.tax-sub.visible { display:table-row; } +.tax-sub td { font-size:0.78rem; } +.tax-sub td.cap { padding-left:1.6rem; } +.tax-totals td { font-weight:700; border-top:1px solid var(--border-mid); padding-top:0.75rem; padding-bottom:0.75rem; } +.tax-strong td { font-weight:700; } +.tax-pct { font-size:0.74rem; font-weight:600; margin-right:0.5rem; } +.tax-perf { display:flex; flex-wrap:wrap; gap:0.45rem; justify-content:flex-end; margin-top:0.2rem; } +.tax-perf .tax-pct { margin-right:0; } +.tax-table td[title] { cursor:pointer; } +.tax-pct-lbl { color:var(--text-sec); font-weight:700; font-size:0.66rem; letter-spacing:0.03em; margin-right:0.25rem; } +.tax-colsub { text-transform:none; letter-spacing:0; font-weight:400; margin-top:0.1rem; } + +.form-group { display:flex; flex-direction:column; gap:0.5rem; margin-bottom:1.2rem; } +.form-label { font-size:0.78rem; font-weight:600; color:var(--text-primary); } +.form-hint { font-size:0.72rem; color:var(--text-sec); margin-top:0.3rem; } +.form-error { font-size:0.72rem; color:var(--neg); margin-top:0.3rem; } +.input { background:var(--input-bg); border:1px solid var(--border-mid); border-radius:var(--radius-md); padding:0.65rem 0.9rem; font-family:var(--font-base); font-size:0.88rem; color:var(--text-primary); outline:none; transition:border-color 0.15s, box-shadow 0.15s; width:100%; } +.input::placeholder { color:var(--text-ter); } +.input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-soft); } +.input.error { border-color:var(--neg); } +.input.error:focus { box-shadow:0 0 0 3px var(--neg-bg); } +select.input { appearance:none; cursor:pointer; } +[data-theme="dark"] select { color-scheme:dark; } +[data-theme="light"] select { color-scheme:light; } +select option, select optgroup { background-color:var(--bg-elevated); color:var(--text-primary); } +.select-label { position:relative; display:inline-flex; align-items:center; min-width:0; color:var(--text-sec); } +.select-label select { appearance:none; -webkit-appearance:none; border:0; background:transparent; box-shadow:none; + padding:0.1rem 1.3rem 0.1rem 0; margin:0; font:inherit; font-size:0.9rem; font-weight:500; color:inherit; + cursor:pointer; outline:none; text-overflow:ellipsis; min-width:0; } +.select-label svg { position:absolute; right:0; width:1rem; height:1rem; pointer-events:none; } +.select-label:hover, .select-label:focus-within { color:var(--accent-text); } +.select-label select:focus-visible { box-shadow:0 0 0 2px var(--accent-soft); border-radius:var(--radius-sm, 4px); } +textarea.input { resize:vertical; min-height:5.5rem; } +.toggle-switch { position:relative; width:40px; height:22px; flex-shrink:0; display:inline-block; } +.toggle-switch input { opacity:0; width:0; height:0; position:absolute; } +.toggle-track { position:absolute; inset:0; background:var(--bg-overlay); border-radius:var(--radius-full); cursor:pointer; transition:background 0.2s; border:1px solid var(--border-mid); } +.toggle-switch input:checked ~ .toggle-track { background:color-mix(in srgb, var(--accent) 45%, var(--bg-overlay)); + border-color:color-mix(in srgb, var(--accent) 55%, var(--bg-overlay)); } +.toggle-track::after { content:''; position:absolute; left:2px; top:2px; width:16px; height:16px; border-radius:50%; background:#fff; transition:transform 0.2s var(--ease-out); } +.toggle-switch input:checked ~ .toggle-track::after { transform:translateX(18px); } + +.table-frame { border:1px solid var(--border); border-radius:var(--radius-lg); overflow:hidden; background:var(--edge), var(--surface); box-shadow:var(--shadow-1); backdrop-filter:blur(var(--surface-blur)) saturate(var(--surface-sat)); -webkit-backdrop-filter:blur(var(--surface-blur)) saturate(var(--surface-sat)); transition:background 0.25s, box-shadow 0.25s; } +.table-frame--plain { --surface-blur:0px; } +[data-theme="light"] .table-frame { box-shadow:0 1px 3px rgba(0,0,0,0.04); } +.table-wrap { width:100%; max-height:calc(100vh - var(--table-vh-offset)); overflow:auto; } +@supports selector(::-webkit-scrollbar) { + .table-wrap { --head-fill:var(--bg-elevated); --head-line:var(--border-mid); scrollbar-color:auto; scrollbar-width:auto; } + .table-wrap::-webkit-scrollbar { width:8px; height:8px; } + .table-wrap::-webkit-scrollbar:vertical { background:linear-gradient(var(--head-fill) calc(var(--sticky-row-top, 0px) - 1px), var(--head-line) 0 var(--sticky-row-top, 0px), transparent 0); } + .table-wrap::-webkit-scrollbar-thumb { background:var(--scrollbar); border-radius:var(--radius-full); } + .table-wrap::-webkit-scrollbar-track, .table-wrap::-webkit-scrollbar-corner { background:transparent; } + .table-wrap::-webkit-scrollbar-track:vertical { margin-top:var(--sticky-row-top, 0px); } +} +table { width:100%; border-collapse:collapse; font-size:0.85rem; font-variant-numeric:tabular-nums; } +thead { position:sticky; top:0; z-index:3; } +thead tr:last-child > th { box-shadow:inset 0 -1px 0 var(--border-mid); } +th { padding:0.75rem 1.1rem; text-align:left; font-size:0.67rem; font-weight:600; letter-spacing:0.09em; text-transform:uppercase; color:var(--text-sec); white-space:nowrap; background:var(--bg-elevated); transition:background 0.25s, color 0.25s; } +th.num, td.num { text-align:right; } +th.center, td.center { text-align:center; } +.table-wrap :is(th, td):nth-last-child(1 of :not(.actions-cell, [colspan])) { text-align:right; } +tbody tr { position:relative; border-bottom:1px solid color-mix(in srgb, var(--border) 50%, transparent); transition:background 0.1s; } +tbody tr:last-child { border-bottom:none; } +tbody tr:hover { background:var(--neutral-bg); } +td { padding:var(--table-cell-py) 1.1rem; vertical-align:middle; color:var(--text-primary); white-space:nowrap; transition:color 0.25s, background 0.25s; } +.t-muted-cell { color:var(--text-sec); font-size:0.8rem; } +.t-subline { display:block; min-height:1.2em; } +.asset-name { color:var(--text-primary); } +.table-wrap td.text, .table-wrap th.text { white-space:normal; overflow-wrap:anywhere; min-width:7rem; } + +.table-wrap td { height:calc(var(--table-row-lines) * var(--table-line-h) + 2 * var(--table-cell-py)); } +.table-wrap td.text > .cell-clamp { + display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:var(--table-row-lines); overflow:hidden; +} +.table-controls-bar { padding:0.6rem 0.7rem; background:var(--bg-elevated); } +.tbl-title { padding:0.15rem 0.15rem 0.6rem; } +.table-controls { display:flex; align-items:center; gap:0.6rem; flex-wrap:wrap; --tctl-h:2.5rem; } +.table-controls .input { height:var(--tctl-h); } +.tbl-search { width:auto; min-width:150px; max-width:240px; } +.table-controls .tbl-group-select { width:auto; min-width:140px; } +.row-editing .tbl-textarea, .edit-expand-row .tbl-textarea { resize:vertical; width:100%; } +.edit-expand-row .edit-expand-fields { flex-wrap:wrap; align-items:flex-end; } +.edit-expand-row .edit-expand-field { margin-bottom:0; min-width:200px; flex:1; } +.edit-expand-row .edit-expand-field.is-full { min-width:auto; flex:1 0 100%; } +.edit-expand-row .edit-expand-error { margin-top:0.6rem; } +.edit-expand-row .edit-actions { justify-content:flex-start; } +.edit-expand-row :is(.edit-expand-fields, .edit-expand-error) + .edit-actions { margin-top:0.8rem; } +.t-caption.mt-sm { text-align:center; } +.table-controls label { font-size:0.72rem; font-weight:600; color:var(--text-sec); } +.tbl-filter { justify-content:center; flex:1 1 auto; min-width:0; } +.tbl-empty-spacer td { height:18px; padding:0; } +.tbl-count { flex:0 0 auto; margin-left:auto; display:inline-flex; align-items:center; justify-content:center; height:var(--tctl-h); min-width:2.6rem; padding:0 0.7rem; border-radius:var(--radius-md); border:1px solid var(--border-mid); background:var(--bg-elevated); color:var(--text-sec); font-size:0.8rem; font-weight:600; font-variant-numeric:tabular-nums; } +th.th-sort { cursor:pointer; user-select:none; } +th.th-sort:hover { color:var(--text-primary); } +.sort-arrow { font-size:0.55rem; margin-left:0.3rem; opacity:0.7; } +.group-row > td { background:var(--bg-elevated); padding:0.5rem 1.1rem; box-shadow:inset 0 1px 0 var(--border), inset 0 -1px 0 var(--border); } +.group-header { display:flex; align-items:center; gap:0.6rem; font-size:0.86rem; font-weight:700; color:var(--accent); margin:0; } +.group-header .group-count { color:var(--text-sec); font-weight:500; background:var(--neutral-bg); padding:0.05rem 0.5rem; border-radius:var(--radius-full); font-size:0.72rem; } +.group-row td { padding:0; border:none; background:transparent; } +.group-row:hover td { background:transparent; } +.group-row .group-header { margin:1.2rem 1.1rem 0.4rem; } +.table-wrap.has-exp .group-row .group-header { margin-left:var(--table-exp-pad); } +.group-header[data-gcollapse] { cursor:pointer; user-select:none; } +.group-header .chev-btn { flex:0 0 auto; } +.group-header .group-summary { color:var(--text-sec); font-weight:500; font-variant-numeric:tabular-nums; font-size:0.78rem; margin-left:0.2rem; } +.fbar-item.is-collapsed { opacity:0.6; font-style:italic; } +tbody tr[data-row-id] { cursor:pointer; } +tbody tr.row-selected td { background:var(--accent-soft); } +tbody tr.row-selected:hover td { background:var(--accent-mid); } +tbody tr.row-selected td:first-child { box-shadow:inset 3px 0 0 var(--accent); } +.actions-cell { text-align:right; width:var(--table-actions-w); white-space:nowrap; } + +.exp-cell { width:var(--table-exp-w); text-align:left; padding-left:var(--table-exp-pad); padding-right:0; } +.exp-cell .chev-btn { vertical-align:middle; } +tbody tr[data-row-id]:hover .exp-cell .chev-btn { color:var(--accent); } +.row-actions { display:inline-flex; gap:0.4rem; justify-content:flex-end; } +@media (min-width:1201px) { + .table-frame > .table-wrap:has(> table > thead .actions-cell) { container-type:inline-size; } + .table-frame > .table-wrap :is(th, td).actions-cell { width:0; padding:0; } + .table-frame > .table-wrap tr.row-selected > td.actions-cell { position:sticky; right:0; z-index:2; } + .table-frame > .table-wrap tr.row-selected > td.actions-cell > .row-actions { + position:absolute; top:50%; right:50cqw; transform:translate(50%, -50%); + padding:0.3rem; border:1px solid color-mix(in srgb, var(--accent) 70%, transparent); border-radius:var(--radius-md); + background:var(--bg-raised); box-shadow:0 6px 18px rgba(0,0,0,0.35); + } + .table-frame > .table-wrap tr.row-selected > td.actions-cell > .row-actions:empty { display:none; } + :root[data-profile="glass"] .table-frame > .table-wrap tr.row-selected > td.actions-cell > .row-actions { + background:var(--float-bg); + backdrop-filter:blur(var(--float-blur)); -webkit-backdrop-filter:blur(var(--float-blur)); + } + :root[data-profile="glass"] .table-frame > .table-wrap tbody tr.row-selected > td.actions-cell { backdrop-filter:none; -webkit-backdrop-filter:none; } +} + +.row-expansion > td { padding:0; background:var(--bg-raised); box-shadow:inset 3px 0 0 var(--accent); transition:background 0.25s; } +.row-expansion:hover > td { background:var(--bg-raised); } +.src-drawer { padding:1rem 1.2rem 1.1rem; } +.ai-gesprek { padding:0.5rem 0 0.5rem 1.1rem; } +.src-drawer-title { margin-bottom:0.7rem; } +.row-expansion .table-wrap { border:none; border-radius:0; box-shadow:none; overflow:visible; max-height:none; } +.row-expansion thead { position:static; } +.row-expansion .table-controls { margin-bottom:0.6rem; } +.row-expansion th { background:transparent; } +.row-expansion tbody tr:last-child { border-bottom:none; } + +.ip-box { margin-top:1.5rem; display:flex; flex-direction:column; gap:1.2rem; } +.ip-box-head { display:flex; align-items:baseline; flex-wrap:wrap; gap:0.3rem 1rem; } +.ip-acc + .ip-acc { border-top:1px solid var(--border); padding-top:1.2rem; } +.ip-acc-name { font-weight:600; color:var(--text-primary); margin-bottom:0.7rem; } +.ip-sched + .ip-sched, .ip-sched + .ip-due { margin-top:1.1rem; } +.ip-sched-head { display:flex; align-items:baseline; flex-wrap:wrap; gap:0.2rem 0.8rem; margin-bottom:0.5rem; } +.ip-sched-name { font-weight:600; color:var(--text-primary); } +.ip-figs { display:grid; grid-template-columns:repeat(auto-fill, minmax(13rem, 1fr)); gap:0.6rem; } +.ip-fig { padding:0.65rem 0.85rem; border-radius:var(--radius-sm); background:var(--surface-tint); } +.ip-fig > .t-label { white-space:nowrap; } +.ip-fig-value { font-size:1.05rem; margin:0.2rem 0 0.1rem; } +@media (max-width:700px) { + .ip-figs { grid-template-columns:1fr; gap:0.4rem; } + .ip-fig { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; column-gap:0.8rem; padding:0.5rem 0.75rem; } + .ip-fig-value { margin:0; } + .ip-fig > .t-body-sm { flex-basis:100%; text-align:right; } +} +.ip-num { font-variant-numeric:tabular-nums; text-align:right; } +.ip-upcoming { margin-top:0.55rem; } +.ip-upcoming > summary { display:inline-flex; align-items:center; gap:0.35rem; cursor:pointer; list-style:none; color:var(--text-sec); } +.ip-upcoming > summary::-webkit-details-marker { display:none; } +.ip-upcoming[open] > summary .chev-btn { transform:rotate(180deg); } +.ip-upcoming-list { display:grid; grid-template-columns:repeat(auto-fill, 11rem); gap:0.25rem 1.5rem; margin-top:0.5rem; } +.ip-up { display:flex; justify-content:space-between; gap:0.8rem; font-size:0.82rem; } +.ip-due .t-label { margin-bottom:0.3rem; } +.ip-due-row { display:flex; flex-wrap:wrap; align-items:center; gap:0.3rem 1.2rem; padding:0.25rem 0; } +.ip-due-row > :first-child { min-width:11rem; } +.ip-due-row > .ip-num { min-width:5.5rem; } +.ip-due-btns { display:flex; gap:0.4rem; } + +.table-wrap.sticky-sel tbody tr.row-selected > td { position:sticky; top:var(--sticky-row-top, 2.7rem); z-index:2; background:color-mix(in srgb, var(--accent) 20%, var(--bg-elevated)); } +.table-wrap.sticky-sel tbody tr.row-selected:hover > td { background:color-mix(in srgb, var(--accent) 30%, var(--bg-elevated)); } + +.cmd-bar { + position:fixed; left:50%; bottom:1.8rem; transform:translateX(-50%); z-index:60; + display:flex; align-items:center; gap:0.9rem; + padding:0.75rem 1.1rem; border-radius:var(--radius-md); + background:var(--bg-elevated); border:1px solid var(--accent); + box-shadow:0 6px 24px rgba(0,0,0,0.25); font-size:0.95rem; +} +.cmd-bar-text { white-space:nowrap; } +.cmd-bar-buttons { display:flex; align-items:center; gap:0.5rem; } +.cmd-bar-buttons .btn-primary:focus-visible { outline:2px solid var(--accent); outline-offset:2px; } + +.tabset { position:relative; display:flex; gap:0.3rem; margin-bottom:1.1rem; padding-left:1.3rem; border-bottom:1px solid transparent; } +.tabset::before { content:""; position:absolute; bottom:0; left:0; right:0; height:8px; border-bottom:1px solid var(--border-mid); border-left:1px solid var(--border-mid); border-right:1px solid var(--border-mid); border-bottom-left-radius:7px; border-bottom-right-radius:7px; } +.tabset-tab { appearance:none; border:none; cursor:pointer; background:transparent; color:var(--text-sec); font-family:var(--font-base); font-size:0.78rem; font-weight:600; letter-spacing:0.01em; padding:0.45rem 0.9rem; border-radius:var(--radius-sm) var(--radius-sm) 0 0; margin-bottom:-1px; border-bottom:2px solid transparent; transition:color 0.15s, border-color 0.15s; } +.tabset-tab:hover { color:var(--text-primary); } +.tabset-tab.is-on { color:var(--accent-text); border-bottom-color:var(--accent); } +.tabset--lg { gap:0.5rem; margin-bottom:1.6rem; padding-left:2.2rem; } +.tabset--lg::before { height:12px; border-bottom-left-radius:11px; border-bottom-right-radius:11px; } +.tabset--lg .tabset-tab { font-size:0.92rem; padding:0.7rem 1.4rem; border-bottom-width:3px; } +@media (min-width:769px) { + .tabset--lg.tabset--rows { --tabset-row-gap:0.6rem; display:grid; grid-template-columns:repeat(var(--tabset-cols), minmax(0, 1fr)); grid-auto-rows:1fr; gap:var(--tabset-row-gap) 0; padding-right:2.2rem; } + .tabset--lg.tabset--rows .tabset-tab { padding-inline:0.4rem; } + .tabset--lg.tabset--rows::before { top:calc((100% - var(--tabset-row-gap)) / 2 - 12px); bottom:auto; right:auto; width:12px; border-right:none; border-bottom-right-radius:0; } + .tabset--lg.tabset--rows::after { content:""; position:absolute; top:calc((100% - var(--tabset-row-gap)) / 2 - 1px); bottom:0; left:0; right:0; border:1px solid var(--border-mid); border-radius:11px; pointer-events:none; + mask:linear-gradient(transparent calc(100% - 12px), #000 0) 0 0 / 12px 100% no-repeat, linear-gradient(#000, #000) 12px 0 / calc(100% - 12px) 100% no-repeat; } +} +@media (max-width:768px) { + .tabset { flex-wrap:wrap; gap:0.4rem; padding-left:0; border-bottom:none; } + .tabset::before { display:none; } + .tabset-tab { margin-bottom:0; border:1px solid var(--border-mid); border-radius:999px; } + .tabset-tab.is-on { border-color:var(--accent); background:color-mix(in srgb, var(--accent) 12%, transparent); } + .tabset--lg { gap:0.45rem; padding-left:0; } + .tabset--lg .tabset-tab { font-size:0.85rem; padding:0.5rem 1.1rem; border-bottom-width:1px; } +} + +.fbar { display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center; } +.fbar-item { appearance:none; cursor:pointer; border:1px solid var(--border-mid); background:var(--bg-elevated); color:var(--text-sec); font-family:var(--font-base); transition:border-color .15s, background .15s, color .15s; } +.fbar-item:hover { color:var(--text-primary); border-color:var(--text-ter); } +.fbar-item.is-on { border-color:var(--accent); background:var(--accent-soft); color:var(--accent-text); } +.fbar-item:disabled { opacity:0.5; cursor:default; } +.fbar--chip .fbar-item { padding:0.3rem 0.7rem; border-radius:var(--radius-full); font-size:0.76rem; font-weight:600; letter-spacing:0.02em; } +.fbar-act { color:var(--text-ter); } +.fbar-act:hover { color:var(--text-primary); } +.fbar--tctl .fbar-item { width:5.5rem; height:var(--tctl-h); text-align:center; font-size:0.8rem; font-weight:500; line-height:calc(var(--tctl-h) - 2px); padding:0 0.4rem; border-radius:var(--radius-md); letter-spacing:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } + +.mtr-chart { margin:0.2rem 0 1rem; } +.mtr-chart-h { display:flex; align-items:center; gap:0.4rem; margin-bottom:0.35rem; } +.mtr-dot { width:8px; height:8px; border-radius:2px; display:inline-block; flex-shrink:0; } +.spark { width:100%; height:46px; display:block; background:var(--bg-raised); border-radius:var(--radius-sm); } + +.ilog-last { display:flex; align-items:center; flex-wrap:wrap; gap:0.4rem; margin:0.3rem 0 0.6rem; } +.ilog-filter { max-width:16rem; } +.ilog-scroll { overflow-x:auto; } +.ilog-table { border-collapse:collapse; width:100%; font-size:0.82rem; } +.ilog-table th { text-align:left; font-weight:500; color:var(--text-sec); padding:0.3rem 0.8rem 0.3rem 0; border-bottom:1px solid var(--border); white-space:nowrap; } +.ilog-table td { padding:0.3rem 0.8rem 0.3rem 0; border-bottom:1px solid var(--border); vertical-align:top; } +.ilog-table td.num { text-align:right; } +.ilog-nowrap { white-space:nowrap; } +.ilog-id { font-size:0.75rem; white-space:nowrap; } + +.trk-block { display:flex; flex-direction:column; gap:0.5rem; min-width:0; } +.trk-act { appearance:none; cursor:pointer; flex:0 0 auto; padding:0.32rem 0.75rem; border:1px solid var(--border-mid); border-radius:var(--radius-sm); background:transparent; color:var(--text-ter); font-family:var(--font-base); font-size:0.72rem; font-weight:600; letter-spacing:0.02em; } +.trk-act:hover { color:var(--text-primary); border-color:var(--text-ter); } +.trk-acct-foot { display:flex; align-items:center; justify-content:space-between; gap:0.6rem; } +.trk-selection { min-width:0; font-size:0.72rem; line-height:1.3; } +.trk-scan-status { text-align:right; font-size:0.72rem; line-height:1.35; } +.trk-scan-log { list-style:none; margin:0; padding:0; width:100%; display:flex; flex-direction:column; gap:0.15rem; max-height:14rem; overflow-y:auto; scrollbar-width:none; font-size:0.72rem; line-height:1.35; } +.trk-scan-log:empty { display:none; } +.trk-scan-log::-webkit-scrollbar { display:none; } +.trk-scan-group { display:flex; flex-direction:column; gap:0.1rem; } +.trk-scan-group + .trk-scan-group { margin-top:0.4rem; } +.trk-scan-acct { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.trk-scan-acct:empty { display:none; } +.trk-scan-wallets { list-style:none; margin:0; padding:0 0 0 0.6rem; display:flex; flex-direction:column; gap:0.15rem; } +.trk-scan-row { display:flex; justify-content:space-between; gap:0.6rem; min-width:0; } +.trk-scan-name { color:var(--text-sec); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; } +.trk-scan-out { flex:0 0 auto; text-align:right; white-space:nowrap; } + +.trk-accounts { flex:0 1 auto; min-height:6rem; } +.trk-acct-list { display:grid; grid-template-columns:repeat(auto-fit, minmax(7.5rem, 1fr)); gap:0.4rem; align-content:start; flex:1 1 auto; min-height:0; overflow-y:auto; scrollbar-width:none; } +.trk-acct-list::-webkit-scrollbar { display:none; } +.trk-acct { appearance:none; cursor:pointer; width:100%; text-align:left; display:flex; flex-direction:column; justify-content:space-between; gap:0.35rem; min-height:4rem; padding:0.55rem 0.7rem; border:1px solid var(--border-mid); border-radius:var(--radius-sm); background:var(--bg-elevated); color:var(--text-sec); font-family:var(--font-base); transition:border-color .15s, background .15s, color .15s; } +.trk-acct:hover { color:var(--text-primary); border-color:var(--text-ter); } +.trk-acct.is-on { border-color:var(--accent); background:var(--accent-soft); color:var(--accent-text); } +.trk-acct-name { font-size:0.85rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.trk-acct-line { display:flex; align-items:baseline; justify-content:space-between; gap:0.4rem; } +.trk-acct-sub { font-size:0.66rem; color:var(--text-ter); white-space:nowrap; } +.trk-acct-amt { font-size:0.85rem; font-weight:600; font-variant-numeric:tabular-nums; text-align:right; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + +.trk-total { align-items:center; text-align:center; gap:0.15rem; } +.trk-total-amt { display:flex; flex-direction:column; gap:0.1rem; text-align:right; min-width:0; } +.trk-total .t-num-lg { font-size:1.9rem; line-height:1.15; } +.trk-stats { display:flex; flex-wrap:wrap; justify-content:center; gap:0.7rem 1.4rem; margin-top:0.9rem; } +.trk-stat { min-width:4.5rem; text-align:right; } + +.trk-scan { align-items:flex-end; gap:0.4rem; } +.trk-scan-when { text-align:right; } + +@media (max-width:1280px) { + .trk-accounts { min-height:0; } + .trk-acct-list { display:grid; grid-template-columns:repeat(auto-fill, minmax(13rem, 1fr)); overflow:visible; min-height:0; } + .trk-total, .trk-total-amt { align-items:flex-start; text-align:left; } + .trk-stats { max-width:30rem; } + .trk-stat, .trk-scan-when, .trk-scan-status { text-align:left; } + .trk-scan { align-self:flex-start; align-items:flex-start; } + .trk-scan-log { max-width:30rem; } + .trk-acct-foot { justify-content:flex-start; } +} +.wealth-years { position:relative; z-index:6; } +.wealth-year-boxes { display:flex; flex-wrap:wrap; justify-content:center; gap:0.35rem; padding:0 2rem; } +.wealth-year-menu::before { content:""; position:absolute; left:0; right:0; top:-0.55rem; height:0.55rem; } +.wealth-year-menu { position:absolute; top:calc(100% + 0.55rem); right:0; left:0; z-index:6; max-height:calc(100vh - var(--aside-vh-offset) - var(--year-box-h) - var(--main-pad-y) - 1.1rem); overflow-y:auto; padding:0.5rem; border-radius:var(--radius-md); background:var(--bg-elevated); border:1px solid var(--border); box-shadow:var(--shadow-2); opacity:0; visibility:hidden; transform:translateY(-4px); transition:opacity 0.15s, transform 0.15s, visibility 0.15s; } +.wealth-years:hover .wealth-year-menu, .wealth-years:focus-within .wealth-year-menu { opacity:1; visibility:visible; transform:translateY(0); } +.wealth-year-grid { display:grid; grid-template-columns:repeat(auto-fill, var(--year-box-w)); gap:0.35rem; justify-content:center; } +.wealth-year-chip, .wealth-year { box-sizing:border-box; width:var(--year-box-w); height:var(--year-box-h); display:inline-flex; flex-direction:column; align-items:center; justify-content:center; gap:0.1rem; padding:0 0.3rem; border:1px solid var(--border-mid); border-radius:var(--radius-sm); background:transparent; color:var(--text-sec); font-family:var(--font-base); font-size:0.82rem; font-weight:600; font-variant-numeric:tabular-nums; line-height:1.1; text-align:center; transition:background 0.15s, color 0.15s, border-color 0.15s; } +.wealth-year-pct { font-size:0.72rem; font-weight:500; font-variant-numeric:tabular-nums; } +.wealth-year { cursor:pointer; } +.wealth-year:hover { background:var(--bg-raised); color:var(--text-primary); border-color:var(--text-ter); } +.wealth-year-chip, .wealth-year.is-on { border-color:var(--accent); background:var(--accent-soft); color:var(--accent-text); } +button.wealth-year-chip { cursor:pointer; } +button.wealth-year-chip:hover { background:var(--accent-mid); } +.wealth-year-tip { margin:0.3rem 0.6rem 0.1rem; } +.wealth-header-stats { display:flex; flex-direction:column; align-items:flex-end; gap:0.8rem; margin-top:1.1rem; } +.wealth-header-stats { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:0.8rem 4rem; width:auto; } +.wealth-header-stats .wealth-stat { text-align:right; } +.wealth-header-note { margin-top:0.5rem; max-width:34rem; text-align:left; } +.wealth-header-rings { display:flex; flex-direction:column; gap:1.2rem; min-width:0; flex:1 1 0; min-height:0; } +.wealth-ring { margin:0; min-width:0; display:flex; flex-direction:column; gap:0.4rem; align-items:center; flex:1 1 0; min-height:0; } +.wealth-ring-plot { width:100%; min-width:0; flex:1 1 0; min-height:6rem; } +.wealth-aside-total { display:flex; flex-direction:column; align-items:center; text-align:center; gap:0.15rem; } +.wealth-aside-head { display:flex; align-items:center; gap:0.25rem; } +.wealth-all { margin:0 0.25rem; display:inline-flex; align-items:center; cursor:pointer; } +@media (max-width:1280px) { + .wealth-aside-total, .wealth-header-stats { align-items:flex-start; text-align:left; } + .wealth-header-stats .wealth-stat { text-align:left; } + .wealth-year-boxes { justify-content:flex-start; } + .wealth-header-rings { flex-direction:row; flex-wrap:wrap; flex:0 0 auto; } + .wealth-ring { flex:1 1 16rem; } + .wealth-ring-plot { height:min(11rem, 16vh); flex:0 0 auto; } +} +.pf-blok + .pf-blok { margin-top:1.4rem; padding-top:1.4rem; border-top:1px solid var(--border); } +.pf-blok:first-of-type { margin-top:1rem; } + +.wealth-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(min(22rem, 100%), 1fr)); gap:1.2rem; align-items:stretch; } +.gap-list { max-height:16rem; overflow-y:auto; margin-top:0.4rem; } +.gap-row { display:flex; align-items:center; justify-content:space-between; gap:0.6rem; padding:0.25rem 0; border-top:1px solid color-mix(in srgb, var(--border) 50%, transparent); } +.gap-row .gap-fill { padding:0.25rem 0.4rem; line-height:0; opacity:0; pointer-events:none; transition:opacity 0.15s ease; } +.gap-row:hover .gap-fill, .gap-row:focus-within .gap-fill { opacity:1; pointer-events:auto; } +.gap-row.is-focus { background:var(--accent-soft); box-shadow:0 0 0 0.4rem var(--accent-soft); border-radius:var(--radius-sm); } +.ph-weak-row .gap-fill { padding:0.25rem 0.4rem; line-height:0; opacity:0; pointer-events:none; transition:opacity 0.15s ease; } +.ph-weak-row:hover .gap-fill, .ph-weak-row:focus-within .gap-fill { opacity:1; pointer-events:auto; } +.ph-ignored { margin-top:1.2rem; } +.ph-ignored > summary { display:flex; align-items:center; gap:0.35rem; cursor:pointer; list-style:none; color:var(--text-sec); font-weight:500; } +.ph-ignored > summary::-webkit-details-marker { display:none; } +.ph-ignored > summary svg { transition:transform 0.15s ease; } +.ph-ignored[open] > summary svg { transform:rotate(90deg); } +.ph-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(min(22rem, 100%), 1fr)); grid-auto-rows:1fr; gap:1rem; margin-bottom:1.2rem; } +.ph-card { display:flex; flex-direction:column; } +.ph-counts { display:flex; flex-wrap:wrap; gap:0.2rem 0.8rem; margin-top:0.3rem; } +.ph-count { display:inline-flex; align-items:center; gap:0.3rem; } +.ph-dot { display:inline-block; width:0.55rem; height:0.55rem; border-radius:2px; } +.ph-notices { display:flex; flex-wrap:wrap; gap:0.1rem 0.9rem; min-height:1.2rem; margin-top:0.2rem; } +.ph-notice { display:inline-flex; align-items:center; gap:0.35rem; } +.ph-notice .nav-attention { margin:0; animation:none; } +.ph-finding { padding:0.5rem 0; border-top:1px solid var(--border); } +.ph-finding:first-child { border-top:0; } +.ph-proposal { margin-top:0.4rem; } +.ph-gaps-head { display:flex; align-items:center; justify-content:space-between; gap:0.6rem; } +.ph-filter { margin-bottom:0.6rem; } +.ph-confirm, .ph-outcome { margin:0.6rem 0; padding:0.6rem 0.8rem; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg-raised); } +.ph-confirm-actions { display:flex; justify-content:flex-end; gap:0.5rem; margin-top:0.5rem; } +.ph-outcome { margin-top:0; } +.ph-spark-wrap { margin-top:auto; } +.ph-detail-chart { padding:0; } +.ph-detail-chart .ph-strip { height:1rem; margin-top:0.4rem; } +.ph-detail-plot { position:relative; height:min(16rem, 30vh); } +.dialog:has(.ph-detail) { max-width:min(90rem, 100%); } +.wealth-detail-tabs > [role="tabpanel"]:has(> .ph-prices) { display:flex; flex-direction:column; overflow:hidden; } +.ph-prices { display:flex; flex-direction:column; flex:1 1 auto; min-height:0; } +.wealth-detail-tabs > [role="tabpanel"]:has(> .notes) { display:flex; flex-direction:column; overflow:hidden; } +.notes { display:flex; flex-direction:column; gap:0.6rem; flex:1 1 auto; min-height:0; padding-bottom:0.2rem; } +.notes-add { display:flex; gap:0.6rem; } +.notes-when { flex:0 0 auto; width:auto; } +.notes-line { flex:1 1 auto; min-width:0; } +textarea.notes-body { flex:1 1 auto; min-height:8rem; resize:none; line-height:1.55; font-family:inherit; } +.notes-status { min-height:1.2em; } +.ph-prices > .table-frame { + display:flex; flex-direction:column; min-height:0; + border:0; border-radius:0; background:none; box-shadow:none; backdrop-filter:none; -webkit-backdrop-filter:none; +} +.ph-prices .table-controls-bar { padding:0 0 0.6rem; background:none; } +.ph-detail .ph-prices .table-wrap { flex:0 1 auto; min-height:0; overflow:auto; } +.wealth-detail-tabs > [role="tabpanel"]:has(> .ph-prov) { display:flex; flex-direction:column; overflow:hidden; } +.ph-prov { display:flex; flex-direction:column; flex:1 1 auto; min-height:0; } +.ph-detail .ph-prov .table-wrap { flex:0 1 auto; min-height:0; overflow:auto; margin-top:0.8rem; } +.ph-spark { height:3rem; } +.ph-strip { height:0.35rem; margin-top:0.2rem; } +.ph-strip svg { display:block; width:100%; height:100%; } +.ph-card .ph-strip { + width:calc(100% + 2 * var(--padding-x, 0px)); + margin-left:calc(-1 * var(--padding-x, 0px)); +} +.wealth-detail .gap-list { max-height:none; overflow:visible; } +.ph-k-close { fill:var(--accent); background:var(--accent); opacity:0.35; } +.ph-k-manual { fill:var(--sig-blue); background:var(--sig-blue); } +.ph-k-live { fill:var(--accent); background:var(--accent); } +.ph-k-spot { fill:var(--sig-yellow); background:var(--sig-yellow); } +.ph-k-carried { fill:var(--text-sec); background:var(--text-sec); opacity:0.6; } +.ph-k-none { fill:var(--sig-red); background:var(--sig-red); } +.wealth-card { display:flex; flex-direction:column; } +.wealth-card .wealth-spark-wrap { margin-top:auto; } +.wealth-stat.is-leeg { visibility:hidden; } +.wealth-card { width:100%; cursor:pointer; border:0; } +.wealth-card:hover { box-shadow:var(--hover-shadow); } +.wealth-detail-btn { display:inline-grid; place-items:center; width:1.7rem; height:1.7rem; padding:0; border:0; border-radius:50%; background:none; color:var(--text-ter); cursor:pointer; } +.wealth-detail-btn:hover, .wealth-detail-btn:focus-visible { background:var(--accent-soft); color:var(--accent-text); } +.wealth-detail-btn svg { display:block; } +.wealth-detail-btn--inline { width:1.2rem; height:1.2rem; margin-left:0.15rem; vertical-align:middle; } +.wealth-detail-btn--inline svg { width:0.85rem; height:0.85rem; } +.return-view { display:flex; flex-direction:column; gap:0.9rem; min-width:0; } +.dialog-title-aside [data-return-data] { max-width:16rem; } +.dialog:has(.return-view--dialog) { max-width:min(84rem, 100%); overflow:hidden; } +.dialog-body--rich:has(> .return-view--dialog) { overflow:visible; max-height:none; } +.return-view--dialog .return-plot { height:min(26rem, 46vh); + margin:0 calc(-1 * var(--dialog-pad-x)) calc(-1 * var(--dialog-pad-y)); } +.return-periods { display:grid; grid-template-columns:repeat(auto-fit, minmax(7rem, 1fr)); gap:0.4rem; } +.return-period { display:flex; flex-direction:column; align-items:center; gap:0.15rem; padding:0.45rem 0.4rem; + border:1px solid var(--border-mid); border-radius:var(--radius-sm); background:transparent; color:var(--text-sec); + font-family:var(--font-base); font-variant-numeric:tabular-nums; cursor:pointer; + transition:background 0.15s, border-color 0.15s; } +.return-period:not(.is-on):not(:disabled):hover { background:var(--bg-raised); border-color:var(--text-ter); } +.return-period.is-on { border-color:var(--accent); background:var(--accent-soft); } +.return-period.is-on:hover { background:var(--accent-mid); } +.return-period:disabled { opacity:0.5; cursor:default; } +.return-period-label { font-size:0.78rem; font-weight:600; } +.return-period-amount { font-size:0.95rem; font-weight:700; } +.return-period-pct { font-size:0.72rem; min-height:1em; } +.return-plot { position:relative; height:16rem; } +.wealth-detail.is-return .wealth-detail-spark { display:none; } +.wealth-detail.is-return .wealth-detail-tabs > [role="tabpanel"] { + overflow:hidden; margin-left:calc(-1 * var(--dialog-pad-x)); margin-right:calc(-1 * var(--dialog-pad-x)); } +.wealth-return-tab { display:flex; flex-direction:column; gap:0.9rem; height:100%; } +.wealth-return-years { flex:1 1 auto; min-height:0; overflow-y:auto; padding:0 var(--dialog-pad-x); } +.wealth-return-tab .return-view { flex:0 0 auto; } +.wealth-detail .return-periods { padding:0 var(--dialog-pad-x); } +.return-w-amount { margin:0.4rem 0 0.1rem; color:var(--text-sec); } +.return-w-pct { font-size:0.85rem; font-variant-numeric:tabular-nums; } +.return-view--widget { margin-top:0.6rem; } +.return-view--widget .return-plot { height:8rem; margin:0 calc(-1 * var(--padding-x)) calc(-1 * var(--padding)); + overflow:hidden; border-radius:0 0 var(--radius) var(--radius); } +.wealth-detail .return-plot { height:min(12rem, 22vh); } +.wealth-card .wealth-detail-btn { + position:absolute; margin:0; + top:calc(0.8rem - var(--padding)); right:calc(0.8rem - var(--padding-x)); +} +.wealth-head .wealth-detail-btn { opacity:0; pointer-events:none; transition:opacity 0.15s ease, background 0.15s ease; } +.wealth-card:hover .wealth-detail-btn, +.wealth-card:focus-within .wealth-detail-btn { opacity:1; pointer-events:auto; } +.card-go-btn, .card-go-btn:visited { color:var(--text-ter); text-decoration:none; } +.card-go-btn:hover, .card-go-btn:focus-visible { color:var(--accent-text); } +.card-go-btn { + position:absolute; margin:0; + top:calc(0.8rem - var(--padding)); right:calc(0.8rem - var(--padding-x)); + opacity:0; pointer-events:none; transition:opacity 0.15s ease, background 0.15s ease; +} +.card:hover .card-go-btn, +.card:focus-within .card-go-btn { opacity:1; pointer-events:auto; } +.wealth-card { transition:box-shadow 0.15s ease; } +.wealth-card:hover, .wealth-card:focus-within { z-index:3; } +.wealth-card.is-linked { box-shadow:inset 0 0 0 2px var(--accent), var(--lift-shadow); } +.wealth-card.is-selected { box-shadow:inset 0 0 0 2px var(--accent), var(--lift-shadow); background:var(--accent-soft); } +[data-theme="light"] .wealth-card:is(.is-linked, .is-selected)::before { opacity:0; } +.wealth-stats { display:grid; grid-template-columns:1fr 1fr; gap:1.1rem 1.6rem; margin-top:1rem; } +.wealth-spark-wrap { margin-top:1.1rem; } +.wealth-noprice { margin-top:0.9rem; } + +#page-hint { position:fixed; z-index:120; top:var(--nav-line-top); left:calc(var(--hint-line-x) - 1.85rem); } +html[data-pagehint="off"] #page-hint { display:none; } +.hint { position:relative; } +.hint-btn { display:flex; align-items:center; justify-content:center; width:1.6rem; height:1.6rem; padding:0; border:none; border-radius:50%; background:transparent; color:var(--text-sec); opacity:0.55; cursor:pointer; transition:opacity 0.15s, color 0.15s; } +.hint-btn:hover, .hint-btn:focus-visible { opacity:1; color:var(--accent-text); } +html:has(.po-btn.is-warn) #page-hint .hint-btn { opacity:1; color:var(--sig-yellow); } +#page-hint .hint-warn { display:none; } +html:has(.po-btn.is-warn) #page-hint .hint-warn { display:flex; } +#page-hint .hint.is-fallback { display:none; } +html:has(.po-btn.is-warn) #page-hint .hint.is-fallback { display:block; } +.hint-warn .hint-sample { color:var(--sig-yellow); } +.hint-warn .hint-sample svg { width:0.8rem; height:0.8rem; } +.hint-panel { + position:fixed; top:var(--nav-line-top); + right:calc(100% - var(--hint-line-x) + 1.4rem); left:auto; + width:min(44rem, calc(var(--hint-line-x) - 2.8rem)); + max-height:calc(100vh - var(--nav-line-top) - 2rem); overflow-y:auto; + padding:0.9rem 1rem; border-radius:var(--radius-md); background:var(--bg-elevated); + border:1px solid var(--border); box-shadow:var(--shadow-2); + opacity:0; visibility:hidden; transform:translateY(4px); + transition:opacity 0.15s, transform 0.15s, visibility 0.15s; pointer-events:none; +} +html:not(:has(.page-layout > .page-aside)) .hint-panel { + right:auto; left:calc(var(--hint-line-x) + 1.4rem); + width:min(44rem, calc(100vw - var(--hint-line-x) - 2.8rem)); +} +.hint:hover .hint-panel, .hint-btn:focus-visible + .hint-panel { opacity:1; visibility:visible; transform:translateY(0); } +@media (min-width:769px) { + #page-hint .hint-panel.is-wide { + right:auto; left:calc(var(--hint-line-x) + 1.4rem); + width:min(64rem, calc(100vw - var(--hint-line-x) - 2.8rem)); + column-count:2; column-gap:1.8rem; + } + #page-hint .hint-panel.is-wide > .t-label:first-child { column-span:all; } + #page-hint .hint-panel.is-wide :is(.hint-row, .hint-heading) { break-inside:avoid; } +} +.hint-heading { margin-top:1rem; } +.hint-heading:first-of-type { margin-top:0.8rem; } +.hint-row { display:flex; align-items:baseline; gap:0.6rem; margin-top:0.55rem; font-size:0.78rem; color:var(--text-sec); line-height:1.45; } +.hint-sample { flex:0 0 1.4rem; } +.hint-row > span:last-child > div { margin-top:0.2rem; } +.hint-line { display:inline-block; width:1.4rem; height:0; border-top:2px solid var(--accent); transform:translateY(-0.25rem); } +.hint-line.is-dashed { border-top-style:dashed; opacity:0.5; } +.hint-dot { display:inline-block; width:0.4rem; height:0.4rem; margin-left:0.5rem; border-radius:50%; background:var(--accent); transform:translateY(-0.1rem); } +.hint-dot.is-open { background:var(--bg-elevated); box-shadow:0 0 0 1.5px var(--accent); } +.hint-ring { display:inline-block; width:0.75rem; height:0.75rem; margin-left:0.3rem; border-radius:50%; border:3px solid var(--accent); transform:translateY(0.1rem); } +.wealth-spark { height:4.5rem; margin-top:0.3rem; color:var(--accent); } +.wealth-stat { min-width:0; } +.wealth-stats .wealth-stat:nth-child(even) { text-align:right; } +.wealth-head { display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap; } +.wealth-head .wealth-detail-btn { margin-left:auto; } +.acc-total { font-size:2rem; font-weight:700; letter-spacing:-0.025em; line-height:1.15; font-variant-numeric:tabular-nums; + margin-top:0.5rem; } +.acc-card .acc-lines { margin-top:auto; padding-top:1rem; } +.acc-interest { margin-top:0.3rem; font-size:0.8rem; } +.acc-interest > span { white-space:nowrap; } +.acc-card .acc-spark-wrap { margin-top:0.8rem; } +.acc-pulse { height:4rem; margin:0.2rem 0 1rem; color:var(--accent); } +.acc-pulse .spark-mini-area { display:none; } +.acc-detail-plot { height:5rem; color:var(--accent); } +.spark-mini.is-pickable { cursor:pointer; } +.spark-mini-mark.is-agreement { background:var(--bg-elevated); box-shadow:0 0 0 1.5px currentColor; } +.spark-mini-mark.is-account { width:4px; height:4px; opacity:0.6; } +.spark-mini-mark.is-reservation { background:var(--bg-elevated); box-shadow:0 0 0 1.5px currentColor; border-radius:1px; } +.spark-mini-mark.is-now { width:8px; height:8px; } +.spark-mini-mark.is-now::before, .spark-mini-mark.is-now::after { + content:''; position:absolute; inset:0; border-radius:50%; border:1.5px solid currentColor; opacity:0; + animation:spark-now-ring 3s ease-out infinite; +} +.spark-mini-mark.is-now::after { animation-delay:1.5s; } +@keyframes spark-now-ring { 0% { transform:scale(1); opacity:0.75; } 100% { transform:scale(3.4); opacity:0; } } +@media (prefers-reduced-motion: reduce) { .spark-mini-mark.is-now::before, .spark-mini-mark.is-now::after { animation:none; } } +.acc-lines { display:flex; flex-direction:column; gap:0.6rem; margin-top:0.8rem; } +.acc-line-main, .acc-line-sub { display:flex; justify-content:space-between; gap:0.8rem; } +.acc-card .acc-line-main > span, .acc-card .acc-line-sub > span { min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.acc-line-sub { font-size:0.8rem; } +.acc-filter { display:flex; flex-wrap:wrap; align-items:center; gap:0.4rem 1.2rem; } +.acc-grid { grid-auto-rows:1fr; } +.dialog-body--rich:has(> .wealth-detail.acc-detail) { height:calc(100vh - 9rem); } +.acc-detail { --table-row-lines:2; } +.wealth-detail.acc-detail .group-header { justify-content:flex-start; } +.wealth-detail.acc-detail .group-header .group-summary { margin-left:0.2rem; } +.acc-summary { flex:0 0 auto; display:grid; grid-template-columns:repeat(auto-fill, minmax(16rem, 1fr)); gap:0.6rem; + padding:0 var(--dialog-pad-x); margin-bottom:0.9rem; } +.acc-summary > .acc-interest { grid-column:1 / -1; margin-top:0; } +.acc-detail [role="tabpanel"]:not(:has(> #sched)) { display:flex; flex-direction:column; overflow:hidden; } +.acc-detail [role="tabpanel"] > #tbl { display:flex; flex-direction:column; flex:1 1 auto; min-height:0; } +.acc-detail .table-frame { + display:flex; flex-direction:column; min-height:0; + border:0; border-radius:0; background:none; box-shadow:none; backdrop-filter:none; -webkit-backdrop-filter:none; +} +.acc-detail .table-controls-bar { padding:0 0 0.6rem; background:none; } +.acc-detail [role="tabpanel"]:not(:has(> #sched)) > #tbl .table-wrap { flex:0 1 auto; min-height:0; overflow:auto; } +.wealth-detail.acc-detail .table-wrap { overflow-x:auto; } +@media (min-width:1201px) { + .acc-detail td.actions-cell:has(> .row-actions) { position:sticky; right:0; z-index:1; } + :root:not([data-profile="glass"]) .acc-detail td.actions-cell:has(> .row-actions) { background:var(--bg-elevated); } + :root[data-profile="glass"] .acc-detail td.actions-cell:has(> .row-actions) { backdrop-filter:blur(var(--float-blur)); -webkit-backdrop-filter:blur(var(--float-blur)); } +} +.dialog-title-aside:has(.acc-total) { display:inline-flex; align-items:center; gap:0.6rem; } +.dialog-title-aside .acc-total { margin:0; font-size:inherit; } +.wealth-group, .wealth-filter { flex:none; justify-content:center; } +.acc-pick { display:flex; flex-direction:column; gap:0.3rem; } +.acc-single { grid-column:1 / -1; display:flex; align-items:center; gap:0.8rem; } +.acc-anchor-btn { align-self:flex-end; } +.acc-line--pick { display:block; width:100%; text-align:left; font:inherit; color:inherit; cursor:pointer; + background:transparent; border:1px solid var(--border); border-radius:var(--radius-md); padding:0.5rem 0.7rem; } +.acc-line--pick:hover { border-color:var(--border-mid); } +.acc-line--pick.is-on { border-color:var(--accent); background:var(--accent-soft); } +.dialog:has(.wealth-detail) { padding:var(--dialog-pad-y) 0 0; overflow:hidden; } +.dialog:has(.wealth-detail) .dialog-title { padding:0 var(--dialog-pad-x); } +.dialog-body--rich:has(> .wealth-detail) { + display:flex; overflow:hidden; padding:0; + height:min(78vh, calc(100vh - 12rem)); max-height:none; +} +.wealth-detail { display:flex; flex-direction:column; flex:1 1 auto; min-width:0; min-height:0; } +.wealth-detail-tabs { display:flex; flex-direction:column; flex:1 1 auto; min-height:0; padding:0 var(--dialog-pad-x); } +.wealth-detail-cijfers { padding:0 0 0.9rem; } +.wealth-detail-stats { display:flex; flex-wrap:wrap; gap:0.6rem 2.4rem; } +.wealth-detail-beheerd { margin-bottom:0.9rem; } +.wealth-detail-beheerd table { width:100%; } +.wealth-detail-tabs > [role="tabpanel"] { flex:1 1 auto; min-height:0; overflow-y:auto; margin-top:0.9rem; } +.wealth-detail-spark { flex:0 0 auto; margin-top:0.6rem; position:relative; z-index:0; } +.dialog:has(.wealth-detail) { position:relative; } +.wealth-detail-plot { height:min(18rem, 34vh); color:var(--accent); } +.wealth-detail-leeg { display:flex; align-items:center; justify-content:center; height:100%; } +.wealth-detail { --table-row-lines:1; --table-cell-py:0.45rem; } +.wealth-detail .table-wrap { max-height:none; overflow:visible; } +.dialog-body--rich:has(> .wealth-overview) { display:flex; overflow:hidden; } +.wealth-overview { display:flex; flex-direction:column; flex:1 1 auto; min-width:0; min-height:0; --table-row-lines:1; --table-cell-py:0.45rem; } +.wealth-overview .table-wrap { max-height:none; flex:1 1 auto; min-height:0; overflow:auto; } +.wealth-overview-plot { flex:0 0 auto; height:min(16rem, 30vh); margin-bottom:0.8rem; } +.wealth-detail-periode { position:absolute; top:calc(var(--dialog-pad-y) + 0.95rem); left:50%; transform:translate(-50%, -50%); + z-index:2; pointer-events:none; border-color:var(--border-mid); background:transparent; color:var(--text-sec); } +.wealth-detail-spark .wealth-detail-plot { position:relative; z-index:1; } +.wealth-detail .group-header { justify-content:space-between; } +.wealth-detail .group-header .group-summary { margin-left:auto; } +.wealth-trade-leeg > td { color:var(--text-ter); font-style:italic; } +tr[data-trade].is-active > td { background:var(--accent-soft); } +.spark-mini-mark.is-active { width:11px; height:11px; box-shadow:0 0 0 2px var(--bg-elevated), 0 0 0 3.5px currentColor; z-index:1; } + +.oca-grid { display:flex; align-items:flex-start; gap:1.5rem; --cols:1; } +@media (min-width:1000px) { .oca-grid { --cols:2; } } +@media (min-width:2100px) { .oca-grid { --cols:3; } } +@media (min-width:2900px) { .oca-grid { --cols:4; } } +.masonry-col { display:flex; flex-direction:column; gap:1.5rem; flex:1 1 0; min-width:0; } +.oca-source { position:relative; min-width:0; --wallet-pad:1.3rem; --padding:var(--wallet-pad); } +.oca-source-head { display:flex; align-items:center; gap:0.9rem; flex-wrap:wrap; } +.oca-source-meta { flex:1; min-width:0; } +.oca-source-acct { font-size:0.66rem; font-weight:700; letter-spacing:0.06em; text-transform:uppercase; color:var(--text-ter); margin-top:0.15rem; } +.oca-source-name { font-size:1.25rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.oca-source-total { flex:1; text-align:right; } +.oca-wallet-tags { flex:0 0 auto; display:flex; justify-content:center; gap:0.4rem; } +.oca-movements { + --padding:0.4rem; margin:0.9rem calc(-1 * var(--wallet-pad)) calc(-1 * var(--wallet-pad)); + border-top:1px solid var(--row-line); border-radius:0 0 var(--radius) var(--radius); overflow:hidden; +} +.mv-group { display:flex; flex-direction:column; } +.mv-group + .mv-group { border-top:1px solid var(--row-line); } +.mv-date { font-size:0.72rem; font-weight:600; letter-spacing:0.04em; color:var(--text-sec); padding:0.5rem var(--wallet-pad) 0; } +.mv-empty { padding:0.7rem var(--wallet-pad); } +.oca-source { --mv-linked:color-mix(in srgb, var(--accent) 3.5%, transparent); } +[data-theme="dark"] .oca-source { --mv-linked:color-mix(in srgb, var(--accent) 5.5%, transparent); } +.mv-row:has(> .mv-mid > .tx-link.is-matched) { background:var(--mv-linked); } +.mv-card:not(:has(.tx-link:not(.is-matched))) > .mv-date { background:var(--mv-linked); } +.mv-row:not(:hover) .tx-link.is-matched:not(.is-open, :focus-visible) { opacity:0; } +.mv-row { position:relative; display:flex; align-items:center; gap:0.9rem; padding:0.5rem var(--wallet-pad); } +.mv-dir { flex-shrink:0; width:38px; height:38px; display:grid; place-items:center; border:1px solid var(--border-mid); border-radius:50%; color:var(--text-sec); } +.mv-mid { flex:1; min-width:0; } +.mv-label { font-weight:600; cursor:pointer; display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.mv-label:hover { color:var(--accent); } +.mv-addr { font-family:var(--font-mono); font-size:0.8rem; color:var(--text-ter); cursor:pointer; display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.mv-mid { display:flex; align-items:center; gap:0.45rem; } +.mv-mid > .mv-label, .mv-mid > .mv-addr { min-width:0; } +.copy-btn { flex-shrink:0; border:none; background:none; padding:0 0.15rem; color:var(--text-ter); cursor:pointer; font-size:0.9rem; line-height:1; opacity:0; transition:opacity 0.12s, color 0.15s; vertical-align:middle; } +tbody tr:hover .copy-btn, .mv-row:hover .copy-btn, .card-flat:hover .copy-btn { opacity:1; } +.copy-btn:hover, .copy-btn:focus-visible { opacity:1; color:var(--accent); outline:none; } +.copy-btn.is-copied { opacity:1; color:var(--pos); } +.mv-grp > summary { list-style:none; } +.mv-grp > summary::-webkit-details-marker { display:none; } +.mv-grp > summary.mv-row { cursor:pointer; } +.mv-chev { flex-shrink:0; color:var(--accent); font-size:1rem; line-height:1; transition:transform 0.15s; } +.mv-grp[open] .mv-chev { transform:rotate(90deg); } +.mv-sub .mv-row { padding-left:calc(var(--wallet-pad) + 1.2rem); } +.mv-addr:hover { color:var(--accent); } +.mv-mid .label-combo { width:100%; } +.mv-amounts { display:flex; align-items:flex-start; gap:0.9rem; flex-shrink:0; } +.mv-amt { width:8rem; text-align:right; flex-shrink:0; } +.mv-fee { width:6.5rem; text-align:right; flex-shrink:0; opacity:0.75; } +.mv-amt .num, .mv-fee .num { font-weight:600; font-variant-numeric:tabular-nums; } +.mv-feecap { font-size:0.7em; font-weight:600; color:var(--text-ter); margin-right:0.15rem; } + +.alert { display:flex; gap:0.8rem; align-items:flex-start; padding:1rem 1.2rem; border-radius:var(--radius-md); font-size:0.84rem; margin-bottom:0.75rem; } +.alert-icon { font-size:1rem; flex-shrink:0; margin-top:0.05rem; } +.alert-content { flex:1; min-width:0; } +.alert-title { font-weight:600; margin-bottom:0.1rem; } +.alert-body { color:var(--text-sec); font-size:0.8rem; } +.alert-info { background:var(--accent-soft); color:var(--accent-text); } +.alert-success { background:var(--pos-bg); color:var(--pos); } +.alert-error { background:var(--neg-bg); color:var(--neg); } +.alert-neutral { background:var(--neutral-bg); color:var(--text-sec); border:1px solid var(--border); } + +.divider { height:1px; background:var(--border); margin:2rem 0; } +.code { background:var(--bg-raised); border:1px solid var(--border); border-radius:var(--radius-md); padding:1.2rem 1.4rem; font-family:var(--font-mono); font-size:0.8rem; color:var(--accent); overflow-x:auto; line-height:1.7; } + +.codeblock { position:relative; width:fit-content; max-width:100%; margin:0.8rem 0; background:var(--bg-raised); border:1px solid var(--border); border-radius:var(--radius-md); overflow:hidden; } +.codeblock > pre { margin:0; padding:1rem 1.2rem; padding-right:6.5rem; overflow-x:auto; font-family:var(--font-mono); font-size:0.8rem; line-height:1.6; } +.codeblock-tools { position:absolute; top:0.45rem; right:0.6rem; display:flex; align-items:stretch; gap:0.35rem; } +.codeblock-tag { display:inline-flex; align-items:center; font-size:0.62rem; font-weight:700; letter-spacing:0.09em; text-transform:uppercase; color:var(--text-ter); background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-sm); padding:0 0.4rem; pointer-events:none; } +.codeblock-copy { display:inline-flex; align-items:center; justify-content:center; padding:0.2rem 0.3rem; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg-elevated); color:var(--text-sec); cursor:pointer; opacity:0; transition:opacity 0.15s, color 0.15s, border-color 0.15s; } +.codeblock:hover .codeblock-copy, +.codeblock-copy:focus-visible { opacity:1; } +.codeblock-copy:hover { color:var(--text-primary); border-color:var(--border-mid); } +.codeblock-copy.is-done { color:var(--pos); border-color:var(--pos); opacity:1; } +@media (hover: none) { .codeblock-copy { opacity:1; } } +.skeleton { background:linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%); background-size:200% 100%; animation:shimmer 1.6s infinite; border-radius:var(--radius-sm); } +@keyframes shimmer { to { background-position:-200% 0; } } +.mt-sm { margin-top:0.5rem; } .mt-md { margin-top:1rem; } .mt-lg { margin-top:1.5rem; } +.gap-sm { gap:0.5rem; } .gap-md { gap:1rem; } +.flex { display:flex; align-items:center; } .flex-between { display:flex; align-items:center; justify-content:space-between; } +.hidden { display:none !important; } + +.price-hero { display:flex; align-items:center; justify-content:space-between; gap:1.5rem; flex-wrap:wrap; margin-bottom:1.2rem; } +.price-label { margin-bottom:0.35rem; display:flex; align-items:center; gap:0.5rem; } +.price-val { font-size:2.1rem; letter-spacing:-0.03em; line-height:1.1; } +.price-meta { font-size:0.72rem; color:var(--text-ter); margin-top:0.35rem; } +.conv-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:0.9rem; } +.field { display:flex; flex-direction:column; gap:0.6rem; background:var(--bg-elevated); border:1px solid var(--border); --radius:var(--radius-lg); --padding:1.1rem; border-radius:var(--radius); padding:1.1rem 1.2rem; transition:border-color 0.15s, background 0.25s; } +.field:focus-within { border-color:var(--accent); } +.field-head { display:flex; align-items:center; justify-content:space-between; gap:0.5rem; } +.field-name { font-size:0.9rem; font-weight:600; } +.field-input { background:var(--input-bg); border:1px solid var(--border-mid); border-radius:var(--radius-md); padding:0.6rem 0.85rem; font-family:var(--font-base); font-size:1.05rem; font-weight:600; color:var(--text-primary); outline:none; width:100%; text-align:right; font-variant-numeric:tabular-nums; transition:border-color 0.15s, box-shadow 0.15s; } +.field-input::placeholder { color:var(--text-ter); font-weight:400; } +.field-input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-soft); } +.field-hint { font-size:0.7rem; color:var(--text-sec); font-variant-numeric:tabular-nums; } +.info { background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-lg); padding:1.3rem 1.4rem; margin-top:1.2rem; } +.info-title { margin-bottom:0.8rem; } +.info-body { font-size:0.82rem; color:var(--text-sec); line-height:1.8; font-variant-numeric:tabular-nums; } +.info-body strong { color:var(--text-primary); font-weight:600; } +.info-note { margin-top:0.7rem; font-size:0.8rem; color:var(--text-sec); } +.conv-footer { margin-top:1.5rem; text-align:center; font-size:0.72rem; color:var(--text-ter); } +@media (max-width:560px) { + .price-hero { flex-direction:column; align-items:stretch; } + .conv-grid { grid-template-columns:1fr; } + .price-val { font-size:1.8rem; } +} + +.lock-panel { max-width:44rem; padding:1.4rem 1.6rem; border:1px solid var(--border); border-radius:var(--radius-md); background:var(--bg-raised); } +.lock-panel-head { display:flex; align-items:center; gap:0.5rem; font-weight:600; margin-bottom:0.6rem; } +.lock-panel-what { margin:0 0 0.9rem; color:var(--text-sec); line-height:1.55; } +.lock-panel-more { margin:0; } +.lock-icon { font-size:0.9em; opacity:0.75; } +.settings-cols { + display:grid; gap:1.5rem; align-items:start; + grid-template-columns:repeat(auto-fit, minmax(min(100%, 26rem), 32.5rem)); +} + +.lic-head { display:flex; align-items:baseline; justify-content:space-between; gap:1rem; margin-bottom:1.2rem; } +.lic-card p { margin:0 0 0.8rem; overflow-wrap:break-word; } + +.lic-drop { display:grid; gap:0.2rem; place-items:center; padding:1.6rem; border:1px dashed var(--border-mid); border-radius:var(--radius-md); cursor:pointer; transition:border-color 0.15s, background 0.15s; } +.lic-drop:hover, .lic-drop:focus-visible { border-color:var(--accent); } +.lic-drop.is-over { border-color:var(--accent); background:var(--accent-soft); } + +.lic-layout { display:grid; gap:1.5rem; align-items:start; grid-template-columns:minmax(20rem, 28rem) minmax(0, 1fr); } +.lic-side, .lic-main { display:flex; flex-direction:column; gap:1.5rem; min-width:0; } + +.lic-hero { display:flex; flex-direction:column; align-items:center; gap:1rem; margin-bottom:1.6rem; } +.lic-feat { margin-bottom:0.8rem; } +.lic-feat-row { display:grid; grid-template-columns:minmax(0, 1fr) 5rem 5rem; align-items:center; justify-items:center; + padding:0.4rem 0.5rem; border-radius:var(--radius-sm); cursor:default; } +.lic-feat-row > :first-child { justify-self:start; } +.lic-feat-row[data-feature]:hover, .lic-feat-row[data-feature]:focus-visible, .lic-feat-row.is-on { background:var(--neutral-bg); outline:none; } +.lic-feat-cols { font-weight:500; color:var(--text-sec); font-size:0.8rem; padding-top:0; } +.lic-group + .lic-group { border-top:1px solid var(--border); } +.lic-group > summary { list-style:none; cursor:pointer; padding:0.7rem 0.5rem; font-size:1.05rem; font-weight:500; } +.lic-group > summary::-webkit-details-marker { display:none; } +.lic-group-name { display:inline-flex; align-items:center; gap:0.5rem; } +.lic-group[open] > summary .chev-btn { transform:rotate(180deg); } +.lic-feat-sub { list-style:none; margin:0 0 0.5rem; padding:0; font-size:0.88rem; color:var(--text-sec); } +.lic-feat-sub .lic-feat-row > :first-child { padding-left:1.6rem; } +.lic-feat-yes { color:var(--pos); display:inline-flex; } +.lic-feat-yes svg { width:1.4rem; height:1.4rem; } +.lic-feat-sub .lic-feat-yes svg { width:1.1rem; height:1.1rem; } +.lic-feat-no, .lic-feat-open { color:var(--text-sec); font-size:1.2rem; line-height:1; } +.lic-feat-part { color:var(--text-sec); font-size:0.8rem; font-weight:400; } +.lic-feat-shot[hidden] { display:none; } +.lic-feat-shot { position:fixed; z-index:1000; width:min(34rem, calc(100vw - 1rem)); aspect-ratio:16 / 10; display:grid; place-items:center; + padding:0.6rem; text-align:center; overflow:hidden; pointer-events:none; + background:var(--bg-raised); border:1px solid var(--border-mid); border-radius:var(--radius-md); box-shadow:0 12px 40px rgba(0,0,0,0.35); } +.lic-feat-img { max-width:100%; max-height:100%; object-fit:contain; border-radius:var(--radius-sm); }.lic-section { border-top:1px solid var(--border); margin-top:1.2rem; padding-top:1.2rem; } +.lic-card .lic-drop { padding:0.8rem; margin-bottom:0.6rem; } +.lic-apps { margin:0.2rem 0 0.8rem; } +.lic-apps .t-caption { margin-bottom:0.3rem; } +.lic-card .lic-apps p { margin:0 0 0.4rem; } +.lic-more summary { cursor:pointer; color:var(--text-sec); margin-top:0.4rem; } +.lic-more[open] summary { margin-bottom:0.8rem; } + +.lic-chat-card { position:sticky; top:0; display:flex; flex-direction:column; height:calc(100vh - 11rem); min-height:30rem; } +.lic-chat-card[hidden] { display:none; } +.lic-chat { flex:1; display:flex; flex-direction:column; gap:0.8rem; min-height:8rem; overflow-y:auto; padding:0.25rem 0.25rem 0.5rem; margin-bottom:1rem; } +@media (max-width:1100px) { + .lic-layout { grid-template-columns:minmax(0, 1fr); } + .lic-chat-card { position:static; height:auto; } + .lic-chat { flex:none; max-height:28rem; } +} +.lic-msg { display:flex; flex-direction:column; gap:0.2rem; max-width:75%; } +.lic-msg.is-mine { align-self:flex-end; align-items:flex-end; } +.lic-msg.is-theirs { align-self:flex-start; } +.lic-bubble { display:flex; flex-direction:column; gap:0.5rem; padding:0.6rem 0.85rem; border-radius:var(--radius-md); background:var(--neutral-bg); } +.lic-msg.is-mine .lic-bubble { background:var(--accent-soft); } +.lic-card .lic-bubble p { margin:0; white-space:pre-wrap; } +.lic-shot { display:block; max-width:100%; max-height:16rem; border-radius:var(--radius-sm); border:1px solid var(--border); } +.lic-pending { display:flex; flex-wrap:wrap; align-items:flex-end; gap:0.75rem; margin-top:0.5rem; } +.lic-pending .lic-shot { max-height:7rem; } +@media (max-width:768px) { .lic-msg { max-width:90%; } } + +.lock-note { display:flex; align-items:center; gap:0.4rem; flex-wrap:wrap; margin:0 0 0.8rem; font-size:0.82rem; color:var(--text-sec); } +.lock-counter { margin-left:0.5rem; font-size:0.78rem; color:var(--text-ter); } +.tbl-note { align-self:center; font-size:0.78rem; color:var(--text-ter); } + +.mobile-topbar { display:none; position:sticky; top:0; z-index:300; background:var(--bg); border-bottom:1px solid var(--border); padding:0.9rem 1.2rem; align-items:center; justify-content:space-between; gap:0.5rem; transition:background 0.25s, border-color 0.25s; } +.mobile-topbar-logo { font-weight:700; font-size:0.75rem; display:flex; align-items:center; gap:0.5rem; margin-right:auto; min-width:0; cursor:pointer; } +.mobile-topbar-names { display:flex; flex-direction:column; justify-content:center; gap:0.1rem; min-width:0; } +.mobile-topbar-logo #mobile-logo-text { line-height:1.15; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.mobile-menu-btn { background:var(--bg-raised); border:1px solid var(--border); color:var(--text-primary); border-radius:var(--radius-sm); padding:0.4rem 0.7rem; font-size:1rem; cursor:pointer; } +@media (max-width:768px) { + .sidebar { display:none; } + .sidebar.open { display:flex; position:fixed; inset:0; width:100%; z-index:200; height:100vh; overflow-y:auto; padding-top:4.5rem; background:var(--bg); } + .sidebar-logo { display:none; } + .mobile-topbar { display:flex; } + #sb-slot-desktop { display:none; } + .main::before { display:none; } + .sidebar a.active::after, .nav-edit-group::after { display:none; } + #page-hint { top:4.4rem; left:auto; right:1rem; } + #page-hint .hint-panel { top:4.4rem; left:auto; right:1rem; width:min(44rem, calc(100vw - 2rem)); } + .sidebar a.active { background:var(--accent-soft); border-radius:var(--radius-sm); } + .nav-edit-group { background:var(--accent-soft); border-radius:var(--radius-md); } + .nav-edit-group > a.active { background:transparent; } + .sidebar a { text-align:left; } + .sidebar a.has-attention, .sidebar a.is-customizable { justify-content:flex-start; } + .sidebar a .nav-attention, .sidebar a .nav-edit-toggle { order:0; } + .sidebar a .nav-edit-toggle { margin-left:auto; } + .sidebar-section-title { text-align:left; padding-right:0; } + .main { padding:2rem 1.2rem 4rem; max-width:100%; } + :root { --table-vh-offset: 13rem; } + .page { flex-direction:column; } + .t-num-lg { font-size:1.8rem; } + .oca-header { flex-direction:column; align-items:stretch; } + .oca-header-right { align-items:stretch; } + .oca-total { text-align:left; } + .oca-header-right .btn { width:100%; } + .page-actions { flex-wrap:wrap; } +} + +@media (max-width:520px) { + .main { padding:1.5rem 1rem 4rem; } + .mv-row { gap:0.5rem; padding:0.4rem var(--wallet-pad); } + .mv-dir { width:32px; height:32px; } + .mv-amounts { gap:0.55rem; } + .mv-amt { width:6.5rem; } + .mv-fee { width:5.4rem; } + .mv-amt .num, .mv-fee .num { font-size:0.78rem; } + .mv-date { font-size:0.68rem; } + .oca-source-name { font-size:1.1rem; } +} + +.act-block { display:flex; flex-direction:column; gap:0.15rem; align-items:flex-end; text-align:right; } +.act-block .t-num-lg { font-size:2.2rem; line-height:1.15; } +.act-jump { display:flex; flex-direction:column; gap:0.3rem; } +.act-jump-item { appearance:none; cursor:pointer; width:100%; display:flex; align-items:center; justify-content:space-between; gap:0.6rem; padding:0.55rem 0.7rem; border:1px solid var(--border-mid); border-radius:var(--radius-sm); background:var(--bg-elevated); color:var(--text-sec); font-family:var(--font-base); font-size:0.85rem; font-weight:600; text-align:left; transition:border-color .15s, background .15s, color .15s; } +.act-jump-item:hover { color:var(--text-primary); border-color:var(--text-ter); } +.act-jump-label { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.act-jump-count { flex:0 0 auto; font-variant-numeric:tabular-nums; color:var(--accent-text); } +.act-jump-item.is-done { color:var(--text-ter); } +.act-jump-item.is-done .act-jump-count { color:var(--pos); } +.act-jump-count svg { display:block; } + +@media (max-width:1280px) { + .act-block { align-items:flex-start; text-align:left; } +} + +.guide-section { max-width:46rem; padding-top:var(--guide-top); } +html { --guide-top:0rem; } +html[data-pagetitle="off"] { --guide-top:1.2rem; } +.guide-body { margin-top:0.8rem; } +.guide-body p, .guide-body ul { margin:0 0 0.8rem; } +.guide-body ul { padding-left:1.2rem; } +.guide-body li { margin-bottom:0.4rem; } +.guide-body > p:first-child { font-size:1rem; color:var(--text-sec); } +.guide-body .guide-h { margin:1.6rem 0 0.6rem; font-size:0.72rem; font-weight:600; letter-spacing:0.08em; + text-transform:uppercase; color:var(--text-ter); } +.guide-terms { display:grid; grid-template-columns:minmax(7rem, 11rem) 1fr; gap:0.55rem 1.2rem; margin:0 0 1rem; } +.guide-terms dt { font-weight:600; color:var(--text-primary); text-align:right; padding-right:1.2rem; + border-right:1px solid var(--border-mid); } +.guide-terms dd { margin:0; } +@media (max-width:700px) { + .guide-terms { grid-template-columns:1fr; gap:0.15rem; } + .guide-terms dt { text-align:left; padding-right:0; border-right:0; margin-top:0.5rem; } +} +.guide-note { margin:0 0 1rem; padding:0.65rem 0.9rem; border-left:3px solid var(--accent); + border-radius:0 var(--radius-sm) var(--radius-sm) 0; background:var(--accent-soft); color:var(--text-primary); } +.guide-body a, .guide-related a { color:var(--accent-text); } +.guide-code { margin:0 0 0.8rem; padding:0.6rem 0.8rem; border:1px solid var(--border-mid); border-radius:var(--radius-sm); + background:var(--bg-overlay); font-family:var(--font-mono); font-size:0.82rem; + white-space:pre-wrap; word-break:break-word; user-select:all; } +.guide-foot { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:0.8rem; margin-top:1.8rem; padding-top:1rem; border-top:1px solid var(--border-mid); font-size:0.85rem; } +.guide-goto { margin-left:auto; gap:0.25rem; padding-right:0.55rem; transition:color 0.15s, border-color 0.15s, background 0.15s; } +.guide-goto svg { width:14px; height:14px; } +.guide-goto:hover, .guide-goto:focus-visible { color:var(--accent-text); border-color:var(--accent); background:var(--accent-soft); } +.guide-aside .guide-sections .tabset-tab { font-size:0.85rem; padding:0.42rem 0.2rem; } +.guide-aside .guide-groups { margin-top:calc(var(--page-head-h) + var(--guide-top)); } +.guide-body .guide-icon { display:inline-flex; vertical-align:-0.2em; color:var(--accent-text); } +.guide-body .guide-icon svg { width:1em; height:1em; } +.guide-aside .guide-groups .tabset { flex-direction:row; justify-content:flex-end; gap:1.6rem; margin:0; + padding-bottom:0.15rem; border-bottom:1px solid var(--border-mid); } +.guide-aside .guide-groups .tabset-tab { font-size:0.85rem; padding:0.3rem 0.1rem; } +.guide-aside .guide-groups .tabset-tab.is-on::after { top:auto; bottom:calc(-0.15rem - 2px); left:0; right:0; + width:auto; height:2px; } +.guide-aside .guide-sections { margin-top:0.5rem; } +@media (max-width:1280px) { + .guide-aside .guide-groups { margin-top:0; } + .guide-aside .guide-groups .tabset { justify-content:flex-start; } +} + +.sig-aside .sig-bar { display:flex; flex-direction:column; gap:0.35rem; margin:auto 0 0; } +.sig-aside .sig-card { padding:0.6rem 0.8rem; gap:0.7rem; } +.sig-aside .sig-card-val { white-space:normal; } +.sig-aside .tabset--lg .tabset-tab { font-size:0.85rem; padding:0.42rem 0.2rem; } +.sig-aside .tabset { margin-top:calc(var(--page-head-h) + 5.2rem); } +.sig-panel-head { display:flex; align-items:flex-start; justify-content:space-between; flex-wrap:wrap; gap:0.8rem; margin-bottom:1rem; } +.sig-panel-btns { display:flex; flex-wrap:wrap; gap:0.4rem; } +@media (max-width:1280px) { + .sig-aside .tabset { margin-top:0; } +} +.sig-card { display:flex; align-items:center; gap:0.9rem; text-align:left; width:100%; background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-lg); padding:0.85rem 1.1rem; cursor:pointer; transition:border-color 0.2s, background 0.2s; font-family:var(--font-base); } +.sig-card:hover { border-color:var(--border-mid); background:var(--bg-raised); } +.sig-light { width:8px; height:8px; border-radius:50%; flex-shrink:0; background:var(--bg-overlay); transition:all 0.4s; } +.sig-bar .sig-light { width:14px; height:14px; } +.sig-light.green { background:var(--sig-green); } +.sig-light.yellow { background:var(--sig-yellow); } +.sig-light.red { background:var(--sig-red); } +.sig-card-text { display:flex; flex-direction:column; min-width:0; } +.sig-card-val { font-family:var(--font-mono); font-size:0.82rem; margin-top:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } + +.sig-panel > .card { display:flex; flex-direction:column; height:calc(100vh - var(--sig-chrome)); min-height:22rem; } +.sig-canvas { position:relative; flex:1 1 auto; min-height:280px; } +.sig-canvas canvas { width:100% !important; } +.sig-canvas:fullscreen { height:100vh; background:var(--bg); padding:1rem; } +.sig-loading { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:1rem; background:var(--bg-elevated); z-index:5; transition:opacity 0.3s; } +.sig-loading.hidden { display:none; } +.sig-spinner { width:38px; height:38px; border:3px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.8s linear infinite; } +@keyframes spin { to { transform:rotate(360deg); } } +.sig-error { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:var(--neg); font-family:var(--font-mono); font-size:0.85rem; text-align:center; padding:1rem; } + +.skel { display:inline-block; background:var(--bg-raised); border-radius:var(--radius-sm); position:relative; overflow:hidden; } +.skel::after { content:""; position:absolute; inset:0; transform:translateX(-100%); + background:linear-gradient(90deg, transparent, var(--accent-soft), transparent); animation:skel-shimmer 1.4s ease-in-out infinite; } +@keyframes skel-shimmer { 100% { transform:translateX(100%); } } +.skel-line { height:0.7rem; border-radius:999px; } +.skel-block { display:block; } +.skel-cell { display:inline-block; height:0.8rem; border-radius:999px; } + +.skel-wrap { display:block; } +.skel-row { display:flex; align-items:center; justify-content:space-between; gap:0.8rem; margin-bottom:0.55rem; } +.skel-row:last-child { margin-bottom:0; } +.skel-row-l { display:flex; align-items:center; gap:0.6rem; flex:1 1 auto; min-width:0; } + +.skel-card { background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-md); padding:1rem 1.1rem; } +.skel-card-body { margin-top:0.9rem; } + +.skel-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(240px, 1fr)); gap:1rem; } + +.skel-table { display:flex; flex-direction:column; gap:0.55rem; } +.skel-trow { display:grid; grid-auto-flow:column; grid-auto-columns:1fr; gap:1rem; align-items:center; } +.skel-td { display:flex; } + +.skel-spin-wrap { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:0.7rem; padding:2.5rem 1rem; } +.skel-spinner { width:30px; height:30px; border:3px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.8s linear infinite; } + +.skel-error { padding:1.5rem 0; } + +@media (prefers-reduced-motion: reduce) { + .skel::after { animation:none; } + .skel-spinner { animation:none; } +} + +.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } + +@media (max-width:900px) { + .sig-bar { grid-template-columns:1fr; } + .sig-info-grid { grid-template-columns:1fr; } + :root { --sig-chrome:23.6rem; } +} + +.label-combo { display:inline-flex; align-items:center; gap:4px; } +.label-combo-input { width:auto; min-width:140px; padding:0.4rem 0.6rem; font-size:0.82rem; } +.label-combo-btn { + display:none; width:30px; height:30px; flex-shrink:0; + align-items:center; justify-content:center; + border:1px solid var(--border-mid); border-radius:var(--radius-sm); + background:var(--bg-raised); color:var(--text-sec); + font-size:1rem; line-height:1; cursor:pointer; transition:color .15s, background .15s; +} +.label-combo-btn:hover { color:var(--text-primary); background:var(--bg-overlay); } +.label-combo-btn:disabled { opacity:0.35; cursor:not-allowed; } +.label-combo:focus-within .label-combo-btn { display:inline-flex; } + +.dialog-overlay { position:fixed; inset:0; z-index:2000; display:flex; align-items:center; justify-content:center; padding:1.5rem; background:rgba(0,0,0,0.55); animation:dlg-fade 0.12s ease-out; } +.dialog-overlay--stacked { z-index:2001; background:rgba(0,0,0,0.3); } +.dialog { --dialog-pad-y:1.5rem; --dialog-pad-x:1.6rem; width:100%; max-width:420px; background:var(--bg-elevated); border:1px solid var(--border-mid); border-radius:var(--radius-lg); padding:var(--dialog-pad-y) var(--dialog-pad-x); box-shadow:var(--shadow-3); } +[data-theme="dark"] .dialog { background:var(--bg-raised); } +.dialog-title { font-size:1.05rem; font-weight:700; color:var(--text-primary); margin-bottom:0.6rem; } +.dialog-title:has(.dialog-title-aside) { display:flex; align-items:center; gap:0.7rem; } +.dialog-title:has(.dialog-title-aside) .dialog-title-text { min-width:0; } +.dialog:has(.dialog-close) .dialog-title { padding-right:2.2rem; } +.dialog-eye { display:inline-flex; align-items:center; justify-content:center; flex:none; width:1.7rem; height:1.7rem; padding:0; + border:0; border-radius:50%; background:transparent; color:var(--text-ter); cursor:pointer; } +.dialog-eye:hover, .dialog-eye:focus-visible { background:var(--accent-soft); color:var(--accent-text); } +.dialog-eye svg { width:1rem; height:1rem; } +.dialog:has(.dialog-close) { position:relative; } +.dialog-close { + position:absolute; top:0.65rem; right:0.65rem; z-index:3; + display:inline-flex; align-items:center; justify-content:center; + width:1.7rem; height:1.7rem; padding:0; + border:0; border-radius:50%; background:transparent; color:var(--text-ter); + cursor:pointer; opacity:0; pointer-events:none; + transition:opacity 0.15s, background 0.15s, color 0.15s; +} +.dialog:hover .dialog-close, .dialog-close:focus-visible { opacity:1; pointer-events:auto; } +.dialog-close:hover { background:var(--accent-soft); color:var(--accent-text); } +.dialog-close:focus-visible { outline:2px solid var(--accent); outline-offset:1px; } +.dialog-body { font-size:0.9rem; color:var(--text-sec); line-height:1.5; white-space:pre-line; } +.dialog--wide { max-width:min(72rem, 100%); } +.dialog--wide.dialog--xl { max-width:min(110rem, 100%); } +.dialog-body--rich { white-space:normal; max-height:min(78vh, calc(100vh - 12rem)); overflow-y:auto; } +.dialog-actions { display:flex; justify-content:flex-end; gap:0.6rem; margin-top:1.4rem; } +@keyframes dlg-fade { from { opacity:0; } to { opacity:1; } } + +.dialog-overlay--focus { background:rgba(0,0,0,0.78); backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px); } +.dialog--wide.dialog-stepper { width:min(56rem, 100%); max-width:min(56rem, 100%); height:min(34rem, calc(100vh - 3rem)); + display:flex; flex-direction:column; --dialog-pad-y:2.2rem; --dialog-pad-x:2.4rem; } +.dialog-stepper .dialog-body { flex:1; min-height:0; max-height:none; } +.stepper-title { font-size:1.5rem; font-weight:600; letter-spacing:-0.01em; color:var(--text-primary); margin-bottom:1.6rem; } +.stepper-actions .stepper-skip { margin-right:auto; } +.setup-choices { display:grid; grid-template-columns:repeat(auto-fill, minmax(11rem, 1fr)); gap:0.8rem; } +.setup-choice { display:flex; flex-direction:column; gap:0.25rem; text-align:left; padding:1.1rem 1.2rem; + border:1px solid var(--border-mid); border-radius:var(--radius-md); background:var(--bg-raised); + color:var(--text-sec); font:inherit; cursor:pointer; transition:border-color 0.15s, background 0.15s; } +.setup-choice:hover { border-color:var(--text-ter); } +.setup-choice.is-on { border-color:var(--accent); background:var(--accent-soft); } +.setup-choice:focus-visible { outline:2px solid var(--accent); outline-offset:1px; } +.setup-choice-name { font-weight:600; color:var(--text-primary); } +.setup-choice-sub { font-size:0.75rem; color:var(--text-ter); font-family:var(--font-mono); } +.dialog--wide.dialog-stepper--hero { width:min(60rem, 100%); max-width:min(60rem, 100%); height:min(44rem, calc(100vh - 3rem)); } +.stepper-hero { padding-bottom:2rem; } +.dialog-stepper--hero .stepper-title { text-align:center; } +.dialog-stepper--hero .setup-choices { max-width:40rem; margin-inline:auto; } +.splash-mini { display:flex; flex-direction:column; align-items:center; gap:1.2rem; } +.splash-mini .splash-logo { width:clamp(4.5rem, 13vmin, 7rem); } +.splash-mini .splash-wing span { width:clamp(2.5rem, 9vw, 8rem); } +.splash-mini .splash-wing span:nth-child(1), .splash-mini .splash-wing span:nth-child(3) { width:clamp(1.5rem, 5.5vw, 5rem); } +.splash-mini .splash-title { font-size:clamp(0.85rem, 2.4vmin, 1.1rem); } +@media (prefers-reduced-motion: reduce) { + .splash-mini .splash-logo, .splash-mini .splash-wing span, .splash-mini .splash-title { animation:none; transform:none; opacity:1; } + .splash-mini .splash-wing span::after { animation:none; opacity:1; } +} + +.chev-btn { display:inline-flex; align-items:center; justify-content:center; padding:0; border:none; background:none; color:var(--text-ter); cursor:pointer; transition:opacity 0.15s, color 0.15s, transform 0.15s; transform-origin:center; } +.chev-btn svg { display:block; } +.chev-btn:hover { color:var(--accent); } +.chev-btn.is-open { transform:rotate(180deg); } +.chev-btn.is-accent { color:var(--accent); } + +.oca-collapse { position:absolute; top:0.4rem; left:0; right:0; margin:0 auto; width:1.6rem; height:1.6rem; z-index:2; opacity:0; } +.oca-source:hover .oca-collapse, .oca-collapse:focus-visible { opacity:1; } +.oca-source.is-collapsed .oca-movements { display:none; } + +.val { transition:filter 0.12s; } +[data-blur="on"] .val { filter:blur(max(10px, 0.55em)); } +[data-blur="on"] .val:hover { filter:none; } + +.statusbar-eye { display:inline-flex; align-items:center; justify-content:center; width:100%; padding:0.25rem; border:none; background:none; color:var(--text-ter); cursor:pointer; border-radius:var(--radius-sm); transition:color 0.15s; } +.statusbar-eye:hover { color:var(--text-primary); } +.statusbar-eye.is-on { color:var(--accent); } +.statusbar-eye svg { display:block; } + +.po-btn { display:inline-flex; align-items:center; justify-content:center; vertical-align:middle; border:0; background:none; + color:var(--text-sec); cursor:default; opacity:0; max-width:0; padding:0; margin:0; overflow:hidden; pointer-events:none; + transition:max-width 0.12s, opacity 0.12s, margin 0.12s; } +.po-btn svg { width:0.8rem; height:0.8rem; flex:none; } +td:hover .po-btn, .po-host:hover .po-btn, .po-btn:focus-visible { opacity:1; max-width:0.8rem; margin-left:0.2rem; pointer-events:auto; } +@media (min-width:1201px) { + td:not(.num):has(> .po-btn)::after, td:not(.num) :has(> .po-btn)::after, + td.num:has(> .po-btn)::before, td.num :has(> .po-btn)::before { + content:''; display:inline-block; width:1rem; transition:width 0.12s; + } + td:hover:has(> .po-btn)::after, td:hover :has(> .po-btn)::after, + td:hover:has(> .po-btn)::before, td:hover :has(> .po-btn)::before { width:0; } +} +.po-btn:hover, .po-btn:focus-visible { color:var(--accent-text); outline:none; } +td:hover .po-btn, .po-host:hover .po-btn { color:var(--accent-text); } +.po-btn.is-warn, td:hover .po-btn.is-warn, .po-host:hover .po-btn.is-warn, .po-btn.is-warn:hover { color:var(--sig-yellow); } +.po-pop { position:fixed; z-index:1000; max-width:22rem; padding:0.55rem 0.75rem; font-size:0.78rem; line-height:1.45; + background:var(--bg-elevated); border:1px solid var(--border-mid); border-radius:var(--radius-md); box-shadow:var(--shadow-2); + pointer-events:none; } +[data-theme="dark"] .po-pop { background:var(--bg-raised); } +.ring-tip { position:fixed; z-index:2100; display:flex; flex-direction:column; gap:0.05rem; padding:0.3rem 0.55rem; line-height:1.3; + font-size:0.72rem; font-variant-numeric:tabular-nums; white-space:nowrap; color:var(--text-primary); + background:var(--bg-elevated); border:1px solid var(--border-mid); border-radius:var(--radius-md); box-shadow:var(--shadow-2); + transform:translate(-50%, calc(-100% - 10px)); pointer-events:none; opacity:0; transition:opacity 0.12s ease; } +[data-theme="dark"] .ring-tip { background:var(--bg-raised); } +.ring-tip.is-visible { opacity:1; } +.ring-tip-label { font-weight:600; } +.ring-tip-row { display:flex; gap:0.35rem; } +.ring-tip-pct { color:var(--text-sec); } +.ring-tip-row--sub { padding-left:0.7rem; font-size:0.92em; } +.po-pop .po-head { font-weight:600; font-variant-numeric:tabular-nums; } +.po-pop .po-line { color:var(--text-sec); } + +.tax-table td.num .copy-btn { opacity:0; max-width:0; padding:0; overflow:hidden; transition:max-width 0.12s, opacity 0.12s, padding 0.12s; } +.tax-table td.num:hover .copy-btn { opacity:1; max-width:1.5rem; padding:0 0.15rem; margin-left:0.2rem; } + +.statusbar { + display:grid; grid-template-columns:var(--sb-vak,3rem) 1px var(--sb-vak,3rem) 1px var(--sb-vak,3rem); align-items:center; gap:0.5rem; + height:2.4rem; padding:0 0.55rem; background:color-mix(in srgb, var(--neutral-bg) 90%, transparent); border-radius:var(--radius-sm); +} +.statusbar-slot { display:flex; align-items:center; min-width:0; } +#sb-slot-desktop { margin:0 0 1.5rem; } +#sb-slot-desktop .statusbar { width:100%; grid-template-columns:1fr 1px 1fr 1px 1fr; } +#sb-slot-mobile .statusbar { gap:0.35rem; padding:0 0.35rem; max-width:100%; grid-template-columns:var(--sb-vak,3rem) 1px minmax(0.6rem, var(--sb-vak,3rem)) 1px var(--sb-vak,3rem); } +.statusbar-div { width:1px; height:1.5rem; background:var(--bg); justify-self:center; } + +.statusbar-dots { display:grid; grid-auto-flow:column; grid-template-rows:repeat(var(--sb-rows,2),1fr); gap:5px; align-content:center; justify-content:center; } +.statusbar-dot { width:7px; height:7px; padding:0; border:none; border-radius:50%; background:var(--bg); opacity:1; cursor:pointer; transition:background 0.2s, opacity 0.3s, box-shadow 0.3s, transform 0.2s; } +.statusbar-dot:hover { background:var(--text-ter); opacity:0.7; } +.statusbar-dot.is-active.is-ok { background:var(--sig-green); opacity:1; } +.statusbar-dot.is-active.is-fail { background:var(--sig-red); opacity:1; } +.statusbar-dot.is-active { animation:sb-blip 0.6s ease-out; } + +.statusbar-labels { display:flex; flex-direction:column; justify-content:center; gap:1px; min-width:0; } +.statusbar-label { font-family:var(--font-mono); font-size:0.62rem; line-height:1.25; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; opacity:0; } +.statusbar-label.is-flash { animation:sb-label 2.6s var(--ease-out) forwards; } + +.statusbar-blur { display:flex; align-items:center; justify-content:center; } + +@keyframes sb-blip { 0% { transform:scale(0.6); } 50% { transform:scale(1.35); } 100% { transform:scale(1); } } +@keyframes sb-label { 0% { opacity:1; } 70% { opacity:1; } 100% { opacity:0; } } +@keyframes attn-pulse { 0%,100% { opacity:1; } 50% { opacity:0.5; } } +@media (prefers-reduced-motion: reduce) { + .statusbar-dot.is-active { animation:none; } + .statusbar-label.is-flash { animation:none; opacity:1; } + .nav-attention { animation:none; } + .statusbar-attn.is-shown { animation:none; } +} + +.statusbar-labels { position:relative; } +.statusbar-attn { + position:absolute; inset:0; margin:auto; width:14px; height:14px; padding:0; border:none; border-radius:50%; + display:grid; place-items:center; --attn-c:var(--accent); background:var(--attn-c); color:#000; font-size:0.62rem; font-weight:800; line-height:1; + cursor:pointer; opacity:0; transform:scale(0.6); pointer-events:none; transition:opacity 0.25s, transform 0.25s; +} +.statusbar-attn.is-shown { opacity:1; transform:scale(1); pointer-events:auto; animation:attn-pulse 2s ease-in-out infinite; } +.statusbar-attn.is-shown.is-dim { opacity:0.45; animation:none; } +.statusbar-attn.is-problem { --attn-c:var(--sig-red); } +.statusbar-attn:hover { filter:brightness(1.1); } +.statusbar-attn.is-count { width:auto; min-width:16px; height:16px; padding:0 3px; border-radius:8px; } + +.attn-menu { position:fixed; min-width:15rem; max-width:22rem; padding:0.35rem; background:var(--bg-elevated); border:1px solid var(--border-mid); border-radius:var(--radius-md); box-shadow:0 10px 30px rgba(0,0,0,0.3); z-index:1000; } +[data-theme="dark"] .attn-menu { background:var(--bg-raised); } +.attn-menu-title { padding:0.3rem 0.5rem 0.4rem; } +.attn-menu-item { display:block; width:100%; text-align:left; background:none; border:none; border-radius:var(--radius-sm); padding:0.45rem 0.5rem; font-size:0.8rem; color:var(--text-primary); cursor:pointer; } +.attn-menu-item:hover { background:var(--neutral-bg); } + +.int-section + .int-section { margin-top:1.6rem; } +.mx-card { display:flex; flex-direction:column; gap:0.9rem; padding:1.1rem 1.2rem 1.2rem; } +.mx-card-head { display:flex; flex-direction:column; gap:0.2rem; } +.mx-stand { color:var(--text-ter); } +.mx-wrap { overflow:hidden; } +.mx-table { --mx-rank:1.5rem; border-collapse:separate; border-spacing:0 0.3rem; width:100%; table-layout:fixed; font-size:0.8rem; } +.mx-table thead { position:static; } +.mx-table th, .mx-table td { padding:0.6rem 0.4rem; border:0; background:none; box-shadow:none; text-align:center; vertical-align:middle; white-space:nowrap; color:var(--text-primary); } +.mx-table tr, .mx-table tbody tr, .mx-table tr:hover, .mx-table tbody tr:hover, +.mx-table tr:hover > td, .mx-table tbody tr:hover > td { border:0; background:none; box-shadow:none; } +.mx-table thead th { font-size:0.7rem; font-weight:500; color:var(--text-sec); line-height:1.2; padding-bottom:0.5rem; } +.mx-table thead tr:last-child th { border-bottom:1px solid var(--border); } +.mx-table .mx-cur-row th { padding-top:0; height:1.4rem; line-height:0.9rem; } +.mx-name { display:block; overflow:hidden; text-overflow:ellipsis; } +.mx-table .mx-lab { width:5.5rem; text-align:left; padding-left:0; font-weight:500; color:var(--text-sec); } +.mx-cur { font-size:0.65rem; color:var(--text-ter); } +.mx-cell { cursor:pointer; border-radius:var(--radius-sm); transition:background 0.12s; } +.mx-table tbody tr > td.mx-cell:hover { background:var(--accent-soft); } +.mx-pick { display:inline-grid; place-items:center; width:var(--mx-rank); height:var(--mx-rank); vertical-align:middle; color:var(--text-ter); font-weight:700; opacity:0; transition:opacity 0.12s, color 0.12s; } +.mx-table tbody td { height:calc(var(--mx-rank) + 1.2rem); } +.mx-rank { vertical-align:middle; } +.mx-card:hover .mx-pick { opacity:0.45; } +.mx-card .mx-cell:hover .mx-pick { opacity:1; color:var(--accent-text); } +.mx-empty { font-size:0.78rem; color:var(--text-ter); } +.mx-rank { display:inline-grid; place-items:center; width:var(--mx-rank); height:var(--mx-rank); border-radius:50%; font-size:calc(var(--mx-rank) * 0.48); font-weight:700; font-variant-numeric:tabular-nums; background:var(--accent-soft); color:var(--accent-text); border:1px solid color-mix(in srgb, var(--accent) 35%, transparent); } +.mx-rank-1 { background:var(--accent); color:#fff; border-color:var(--accent); } +[data-theme="dark"] .mx-rank-1 { color:#000; } +.mx-rank.is-proposed { box-shadow:0 0 0 2px var(--sig-yellow); } +.mx-svc { cursor:default; } +.mx-rank.is-service { border-style:dashed; } +.mx-rank.is-off, [data-theme="dark"] .mx-rank.is-off { background:var(--bg-elevated); color:var(--text-ter); border-color:var(--border); } +.mx-col.is-off { color:var(--text-ter); } +.cov-lines { --cov-line:1.6rem; display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); column-gap:1rem; align-content:center; } +.cov-line { display:flex; align-items:center; min-height:var(--cov-line); min-width:0; } +.cov-line:last-child:nth-child(odd) { grid-column:1 / -1; } +.mx-rank.is-empty { background:transparent; color:var(--text-ter); border:1px dashed var(--border-mid); } +.mx-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(min(24rem, 100%), 1fr)); grid-auto-rows:1fr; gap:1.25rem; align-items:stretch; margin-top:1rem; } +.mx-filter { margin:0.2rem 0 1.3rem; } +@media (max-width:520px) { + .mx-table { --mx-rank:1.25rem; font-size:0.72rem; } +} + +@media (max-width:1200px) { + .table-wrap { max-height:none; overflow:visible; } + .table-wrap table, .table-wrap tbody, .table-wrap tr, .table-wrap td { display:block; width:auto; } + .table-wrap thead { display:none; } + .table-wrap tbody tr { border:none; } + + .table-wrap tbody tr[data-row-id] { + border:none; border-top:1px solid var(--border-mid); background:transparent; margin:0 0 0.4rem; + } + .table-wrap tbody tr[data-row-id]:hover { background:transparent; } + .table-wrap tbody tr[data-row-id].row-alt, + .table-wrap tbody tr[data-row-id].row-alt:hover { background:color-mix(in srgb, var(--text-primary) 6%, transparent); } + .table-wrap tbody tr[data-row-id]:has(+ tr.row-expansion) { margin-bottom:0; } + .table-wrap tbody tr.row-expansion { margin:0 0 1.1rem; } + .table-wrap tbody tr.group-row + tr[data-row-id] { border-top:none; } + .table-controls .tbl-filter { justify-content:flex-start; } + .table-wrap tbody tr.row-selected { box-shadow:none; } + .table-wrap tbody tr.row-selected > td { background:transparent; box-shadow:inset 3px 0 0 var(--accent); } + + .table-wrap td[data-label] { + display:flow-root; text-align:right; white-space:normal; overflow-wrap:anywhere; + padding:0.5rem 0.9rem; border:none; border-top:1px solid var(--border); + } + .table-wrap td { height:auto; } + .table-wrap td.text > .cell-clamp { display:block; -webkit-line-clamp:none; overflow:visible; } + .table-wrap tr > td[data-label]:first-child, + .table-wrap tr > td.exp-cell + td[data-label] { border-top:none; } + .table-wrap td[data-label]::before { + content:attr(data-label); float:left; margin-right:1rem; text-align:left; line-height:1.5; + font-size:0.66rem; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; color:var(--text-sec); + } + .table-wrap td:not([data-label]):not(.actions-cell):empty { display:none; } + .table-wrap td.exp-cell, .table-wrap th.exp-cell { display:none; } + + .table-wrap .group-row > td, + .table-wrap .row-expansion > td, + .table-wrap .edit-expand-row > td { display:block; } + .table-wrap .actions-cell { display:flex; align-items:center; justify-content:flex-end; width:auto; min-height:2.6rem; padding:0.35rem 0.9rem; } + .table-wrap tr.row-editing > .actions-cell:empty { display:none; } + + .table-wrap tr.row-editing td[data-label] { display:block; text-align:left; } + .table-wrap tr.row-editing td[data-label]::before { float:none; display:block; margin:0 0 0.25rem; } + .table-wrap tr.row-editing td[data-label] .input, + .table-wrap tr.row-editing td[data-label] .conv-field { width:100%; } + + .table-wrap.sticky-sel tbody tr.row-selected > td { position:static; } +} + +.health-start { display:flex; align-items:center; gap:0.8rem; flex-wrap:wrap; margin-bottom:0.6rem; } +.health-rows { list-style:none; margin:0.4rem 0 0; padding:0; } +.health-rows li { display:flex; gap:0.45rem; align-items:baseline; padding:0.15rem 0; font-size:0.86rem; overflow-wrap:anywhere; } +.health-rows li > span { min-width:0; } +.health-rows li .btn { flex-shrink:0; } +.health-findings { display:flex; flex-direction:column; gap:0.9rem; } +.health-accepted:not(:empty) { margin-top:1.6rem; } +.card.health-warn { background:var(--neg-bg); border-color:color-mix(in srgb, var(--neg) 35%, transparent); } +.card.health-info { background:var(--accent-soft); border-color:color-mix(in srgb, var(--accent) 35%, transparent); } +.health-finding .task-card-head { justify-content:flex-start; gap:0.7rem; } + +@property --rim-top { syntax:''; inherits:true; initial-value:transparent; } +@property --rim-bottom { syntax:''; inherits:true; initial-value:transparent; } +@property --hairline { syntax:''; inherits:true; initial-value:transparent; } + +:root[data-profile="glass"] :is(.card, .card-flat) { + position:relative; + background:var(--lift), var(--lit), linear-gradient(var(--frost), var(--frost)), var(--card-fill, var(--surface)); + border-color:transparent; + box-shadow:var(--shadow-1), var(--glans); + transition:background-color 0.25s, box-shadow 0.25s, --rim-top 0.25s, --rim-bottom 0.25s, --hairline 0.25s; +} +:root[data-profile="glass"] :is(.card, .card-flat)::after { + content:''; position:absolute; inset:-1px; border-radius:inherit; padding:1px; pointer-events:none; + background: + linear-gradient(90deg, transparent 14%, var(--hairline) 50%, transparent 86%) top / 100% 1px no-repeat, + linear-gradient(180deg, var(--rim-top), var(--rim-bottom)); + -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite:xor; + mask:linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); +} +:root[data-profile="glass"] .tax-card { border-color:var(--frame-border); box-shadow:var(--shadow-1), inset 0 1px 0 var(--frame-top); } +:root[data-profile="glass"] .tax-card::after { content:none; } +:root[data-profile="glass"] .net-card::after, .net-card > .attn { inset:0; } +:root[data-profile="glass"] :is(.card, .card-flat) :is(.card, .card-flat) { + --card-fill:var(--surface-tint); --frost:transparent; box-shadow:none; backdrop-filter:none; -webkit-backdrop-filter:none; +} +:root[data-profile="glass"] .card.health-warn { --card-fill:var(--neg-bg); } +:root[data-profile="glass"] .card.health-info { --card-fill:var(--accent-soft); } + +:root[data-profile="glass"] .wealth-card:hover { + --rim-top:var(--rim-hover); --rim-bottom:var(--rim-hover-bottom); --hairline:var(--hairline-hover); + --lift:linear-gradient(var(--hover-fill), var(--hover-fill)); + box-shadow:var(--shadow-1), var(--glans), var(--hover-shadow); +} +:root[data-profile="glass"] .wealth-card.is-linked { + --rim-top:color-mix(in srgb, var(--accent) 65%, transparent); + --rim-bottom:color-mix(in srgb, var(--accent) 18%, transparent); + --hairline:var(--accent-text); + --glans:var(--glans-on); + box-shadow:var(--shadow-1), var(--glans); +} +:root[data-profile="glass"] .wealth-card.is-selected { + --rim-top:color-mix(in srgb, var(--accent) 85%, transparent); + --rim-bottom:color-mix(in srgb, var(--accent) 30%, transparent); + --hairline:var(--accent-text); + --glans:var(--glans-on); + --lit:radial-gradient(120% 85% at 50% -12%, color-mix(in srgb, var(--accent) 17%, transparent), transparent 68%); + box-shadow:var(--shadow-1), var(--glans), var(--select-shadow); +} +:root[data-profile="glass"] :is(.sig-card, .connp, .setup-choice) { + background:linear-gradient(var(--frost), var(--frost)), var(--surface-tint); + border-color:var(--frame-border); +} +:root[data-profile="glass"] :is(.sig-card, .connp, .setup-choice):hover { + background:linear-gradient(var(--hover-fill), var(--hover-fill)), var(--surface-tint); + border-color:var(--rim-hover); +} +:root[data-profile="glass"] .setup-choice.is-on { background:var(--accent-soft); border-color:var(--accent); } +:root[data-profile="glass"] .connp.is-active { background:var(--accent-soft); border-color:var(--accent); } + +:root[data-profile="glass"] .table-frame { + background:var(--edge), linear-gradient(var(--frost), var(--frost)), var(--surface); + border-color:var(--frame-border); + box-shadow:var(--shadow-1), inset 0 1px 0 var(--frame-top); +} +:root[data-profile="glass"] thead th { background:var(--surface-tint); } +:root[data-profile="glass"] tbody th { background:transparent; } +:root[data-profile="glass"] .tax-table thead { position:static; } +:root[data-profile="glass"] .tax-table thead th { background:transparent; backdrop-filter:none; -webkit-backdrop-filter:none; box-shadow:none; } +:root[data-profile="glass"] table.mx-table thead th { background:transparent; backdrop-filter:none; -webkit-backdrop-filter:none; box-shadow:none; } +:root[data-profile="glass"] thead tr:last-child > th { box-shadow:inset 0 -1px 0 var(--frame-border); } +:root[data-profile="glass"] .table-wrap { --head-fill:var(--surface-tint); --head-line:var(--frame-border); } +:root[data-profile="glass"] tbody tr { border-bottom-color:var(--row-line); } +:root[data-profile="glass"] tbody tr:hover { background:var(--row-hover); } +:root[data-profile="glass"] :is(.row-expansion > td, .row-expansion:hover > td) { background:var(--surface-tint); } + +:root[data-profile="glass"] tbody tr.row-selected td { background:transparent; } +:root[data-profile="glass"] tbody tr.row-selected:hover td { background:var(--row-hover); } +:root[data-profile="glass"] tbody tr.row-selected td:first-child { box-shadow:inset 2px 0 0 var(--accent), inset 14px 0 18px -14px var(--accent-glow); } +:root[data-profile="glass"] tbody tr.row-selected { + background:linear-gradient(192deg, color-mix(in srgb, var(--accent) 20%, transparent), color-mix(in srgb, var(--accent) 5%, transparent) 45%, transparent 85%); +} +:root[data-profile="glass"][data-rowsel="tint"] tbody tr.row-selected { background:var(--row-sel); } +:root[data-profile="glass"][data-rowsel="tint"] tbody tr.row-selected:hover { background:var(--row-sel-hover); } +:root[data-profile="glass"][data-rowsel="tint"] tbody tr.row-selected:hover td { background:transparent; } +:root[data-profile="glass"][data-rowsel="tint"] tbody tr.row-selected td:first-child { box-shadow:inset 3px 0 0 var(--accent); } +:root[data-profile="glass"][data-rowsel="pill"] .table-wrap table { border-collapse:separate; border-spacing:0; } +:root[data-profile="glass"][data-rowsel="pill"] .table-wrap tbody td { border-bottom:1px solid var(--row-line); } +:root[data-profile="glass"][data-rowsel="pill"] .table-wrap tbody tr:last-child > td, +:root[data-profile="glass"][data-rowsel="pill"] .table-wrap tbody tr.row-selected > td, +:root[data-profile="glass"][data-rowsel="pill"] .table-wrap tbody tr:has(+ tr.row-selected) > td { border-bottom-color:transparent; } +:root[data-profile="glass"][data-rowsel="pill"] tbody tr.row-selected { background:none; } +:root[data-profile="glass"][data-rowsel="pill"] tbody tr.row-selected td { + background:color-mix(in srgb, var(--accent) 8%, transparent); + box-shadow:inset 0 1px 0 color-mix(in srgb, var(--accent) 55%, transparent), inset 0 -1px 0 color-mix(in srgb, var(--accent) 18%, transparent); +} +:root[data-profile="glass"][data-rowsel="pill"] tbody tr.row-selected td:first-child { + border-radius:10px 0 0 10px; + box-shadow:inset 1px 1px 0 color-mix(in srgb, var(--accent) 55%, transparent), inset 0 -1px 0 color-mix(in srgb, var(--accent) 18%, transparent); +} +:root[data-profile="glass"][data-rowsel="pill"] tbody tr.row-selected td:nth-last-child(1 of :not(.actions-cell)) { + border-radius:0 10px 10px 0; + box-shadow:inset -1px 1px 0 color-mix(in srgb, var(--accent) 55%, transparent), inset 0 -1px 0 color-mix(in srgb, var(--accent) 18%, transparent); +} +:root[data-profile="glass"] .table-wrap.sticky-sel { + --sel-fill:color-mix(in srgb, var(--accent) 10%, transparent); --sel-fill-hover:color-mix(in srgb, var(--accent) 16%, transparent); +} +:root[data-profile="glass"][data-rowsel="tint"] .table-wrap.sticky-sel { + --sel-fill:var(--row-sel); --sel-fill-hover:var(--row-sel-hover); +} +:root[data-profile="glass"][data-rowsel="pill"] .table-wrap.sticky-sel { + --sel-fill:color-mix(in srgb, var(--accent) 8%, transparent); --sel-fill-hover:color-mix(in srgb, var(--accent) 13%, transparent); +} +:root[data-profile="glass"] .table-wrap.sticky-sel tbody tr.row-selected { background:none; } +:root[data-profile="glass"] .table-wrap.sticky-sel tbody tr.row-selected > td { + background:linear-gradient(var(--sel-fill), var(--sel-fill)); + backdrop-filter:blur(var(--float-blur)); -webkit-backdrop-filter:blur(var(--float-blur)); +} +:root[data-profile="glass"] .table-wrap.sticky-sel tbody tr.row-selected:hover > td { + background:linear-gradient(var(--sel-fill-hover), var(--sel-fill-hover)); +} +:root[data-profile="glass"]:not([data-rowsel="tint"], [data-rowsel="pill"]) .table-wrap.sticky-sel tbody tr.row-selected > td { + background-image:linear-gradient(192deg, color-mix(in srgb, var(--accent) 20%, transparent), color-mix(in srgb, var(--accent) 5%, transparent) 45%, transparent 85%); + background-size:var(--row-w, 100%) 100%; + background-position:calc(-1 * var(--cell-x, 0px)) 0; + background-repeat:no-repeat; + transition:background-color 0.25s; +} +:root[data-profile="glass"]:not([data-rowsel="tint"], [data-rowsel="pill"]) .table-wrap.sticky-sel tbody tr.row-selected:hover > td { + background-image:linear-gradient(var(--row-hover), var(--row-hover)), linear-gradient(192deg, color-mix(in srgb, var(--accent) 20%, transparent), color-mix(in srgb, var(--accent) 5%, transparent) 45%, transparent 85%); + background-size:100% 100%, var(--row-w, 100%) 100%; + background-position:0 0, calc(-1 * var(--cell-x, 0px)) 0; +} + +:root[data-profile="glass"] .pill-toggle { background:var(--surface-tint); box-shadow:inset 0 0 0 1px var(--row-line); } +:root[data-profile="glass"] .btn-secondary { background:var(--surface-tint); border-color:var(--frame-border); } +:root[data-profile="glass"] .btn-secondary:hover { background:var(--row-hover); border-color:var(--rim-hover); } +:root[data-profile="glass"] :is(.input, .field-input, .dash-profile-list) { background:var(--surface-tint); border-color:var(--frame-border); } +:root[data-profile="glass"] :is(.input, .field-input):focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-soft), 0 0 18px -4px var(--accent-glow); } +:root[data-profile="glass"] .field { background:linear-gradient(var(--frost), var(--frost)), var(--surface); border-color:var(--frame-border); } +:root[data-profile="glass"] .field:focus-within { border-color:var(--accent); } +:root[data-profile="glass"] .label-combo-btn { background:var(--surface-tint); border-color:var(--frame-border); } +:root[data-profile="glass"] :is(.label-combo-btn, .conv-trigger):hover { background:var(--row-hover); } +:root[data-profile="glass"] .wealth-year:hover { background:var(--row-hover); border-color:var(--rim-hover); } +:root[data-profile="glass"] .toggle-switch input:not(:checked) ~ .toggle-track { background:var(--surface-tint); border-color:var(--frame-border); } +:root[data-profile="glass"] :is(.fbar-item, .trk-acct, .act-jump-item, .tbl-count, .guide-code) { background:var(--surface-tint); border-color:var(--frame-border); } +:root[data-profile="glass"] :is(.fbar-item, .trk-acct, .act-jump-item):hover { background:var(--row-hover); border-color:var(--rim-hover); } +:root[data-profile="glass"] :is(.fbar-item, .trk-acct).is-on { background:var(--accent-soft); border-color:var(--accent); } +:root[data-profile="glass"] :is(.trk-act, .btn-new) { border-color:var(--frame-border); } +:root[data-profile="glass"] :is(.trk-act, .btn-new):hover { border-color:var(--rim-hover); } +:root[data-profile="glass"] .btn-new:hover { border-color:var(--accent); } +:root[data-profile="glass"] .table-controls-bar { background:var(--surface-tint); } +:root[data-profile="glass"] :is(.connp-mini, .dash-edit-wsel, .rec-wiz-item, .spark, .code, .codeblock, .codeblock-tag, .codeblock-copy, .info, .lock-panel, .mobile-menu-btn, .skel) { + background:var(--surface-tint); border-color:var(--frame-border); +} +:root[data-profile="glass"] .skel-card { + background:linear-gradient(var(--frost), var(--frost)), var(--surface); border-color:var(--frame-border); +} +:root[data-profile="glass"] .btn-primary { box-shadow:inset 0 1px 0 rgba(255,255,255,0.25); } +:root[data-profile="glass"][data-theme="dark"] .btn-primary { box-shadow:0 0 20px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.25); } + +:root[data-profile="glass"] :is(.hint-panel, .match-pop, .conv-pop, .new-menu, .attn-menu, .dash-settings, .dashboard-add-menu, .wealth-year-menu, .po-pop, .ring-tip, .cmd-bar) { + background:linear-gradient(var(--frost), var(--frost)), var(--float-bg); + border-color:var(--frame-border); + box-shadow:var(--shadow-2), inset 0 1px 0 var(--frame-top); + backdrop-filter:blur(var(--float-blur)) saturate(150%); -webkit-backdrop-filter:blur(var(--float-blur)) saturate(150%); +} +:root[data-profile="glass"] .cmd-bar { border-color:var(--accent); } +:root[data-profile="glass"] :is(.dash-edit-bar, .spark-mini-label) { + background:var(--float-bg); border-color:var(--frame-border); + backdrop-filter:blur(var(--float-blur)); -webkit-backdrop-filter:blur(var(--float-blur)); +} +:root[data-profile="glass"] .mobile-topbar { + background:color-mix(in srgb, var(--bg) 60%, transparent); + border-bottom-color:var(--frame-border); + backdrop-filter:blur(var(--float-blur)) saturate(150%); -webkit-backdrop-filter:blur(var(--float-blur)) saturate(150%); +} +:root[data-profile="glass"] .dialog-overlay { background:transparent; } +:root[data-profile="glass"] .dialog-overlay::before { + content:''; position:absolute; inset:0; pointer-events:none; + background:rgba(0,0,0,0.32); + backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px); +} +:root[data-profile="glass"][data-theme="light"] .dialog-overlay::before { background:rgba(0,0,0,0.18); } +:root[data-profile="glass"] .dialog { + position:relative; isolation:isolate; + background:none; + border-color:var(--frame-border); + box-shadow:var(--shadow-3); +} +:root[data-profile="glass"] .dialog::before { + content:''; position:absolute; inset:0; z-index:-1; pointer-events:none; border-radius:inherit; + background:linear-gradient(var(--frost), var(--frost)), var(--float-bg); + box-shadow:inset 0 1px 0 var(--frame-top); + backdrop-filter:blur(var(--float-blur)) saturate(150%); -webkit-backdrop-filter:blur(var(--float-blur)) saturate(150%); +} +:root[data-profile="glass"] .lic-feat-shot { + background:linear-gradient(var(--frost), var(--frost)), var(--float-bg); border-color:var(--frame-border); + backdrop-filter:blur(var(--float-blur)) saturate(150%); -webkit-backdrop-filter:blur(var(--float-blur)) saturate(150%); +} +:root[data-profile="glass"] .dialog .table-frame { background:none; border:0; box-shadow:none; backdrop-filter:none; -webkit-backdrop-filter:none; } +:root[data-profile="glass"] .dialog .table-controls-bar { background:none; } +.aurora { display:none; position:fixed; inset:0; z-index:0; pointer-events:none; overflow:hidden; } +:root[data-profile="glass"]:not([data-glow="off"]) .aurora { display:block; } +:root[data-profile="glass"] body:has(> .aurora)::before { display:none; } +.blob { + position:absolute; border-radius:50%; translate:-50% -50%; will-change:transform; + transition:transform var(--drift-dur, 24s) cubic-bezier(0.45, 0.05, 0.25, 1), opacity 2.5s; +} +.blob::before, .blob::after { + content:''; position:absolute; border-radius:50%; pointer-events:none; + transition:opacity var(--drift-dur, 24s) ease-in-out; +} +.blob::before { inset:0; background:var(--spot); } +.blob::after { + left:50%; top:50%; width:var(--core-size); height:var(--core-size); translate:-50% -50%; + background:radial-gradient(closest-side, color-mix(in srgb, var(--accent) var(--core-hi), transparent), color-mix(in srgb, var(--accent) var(--core-lo), transparent) 30%, transparent 70%); + opacity:0; +} +.blob.is-core::before { opacity:0.35; } +.blob.is-core::after { opacity:1; } +.blob-a { left:8%; top:-8%; width:116rem; height:68rem; --spot:radial-gradient(closest-side, var(--accent-glow), transparent 62%); --core-size:74rem; --core-hi:40%; --core-lo:13%; } +.blob-b { left:102%; top:4%; width:92rem; height:60rem; --spot:radial-gradient(closest-side, color-mix(in srgb, var(--accent) 14%, transparent), transparent 66%); --core-size:62rem; --core-hi:24%; --core-lo:8%; } +.blob-c { left:50%; top:100%; width:124rem; height:84rem; --spot:radial-gradient(closest-side, color-mix(in srgb, var(--accent) 14%, transparent), transparent 64%); --core-size:66rem; --core-hi:24%; --core-lo:8%; opacity:0; } +[data-theme="light"] .blob-a { --core-hi:26%; --core-lo:9%; } +[data-theme="light"] :is(.blob-b, .blob-c) { --core-hi:16%; --core-lo:5%; } +:is([data-motion="step"], [data-motion="always"]) .blob-c { opacity:1; } +[data-motion="always"] .blob { transition:opacity 2.5s; } +[data-motion="always"] .blob-a { animation:drift-a calc(24s * var(--drift-mult, 2)) ease-in-out infinite alternate; } +[data-motion="always"] .blob-b { animation:drift-b calc(30s * var(--drift-mult, 2)) ease-in-out infinite alternate; } +[data-motion="always"] .blob-c { animation:drift-c calc(36s * var(--drift-mult, 2)) ease-in-out infinite alternate; } +@keyframes drift-a { 50% { transform:translate(20vw, 6vh) rotate(10deg) scale(1.15, 0.9); } 100% { transform:translate(42vw, 14vh) rotate(-6deg) scale(0.95, 1.15); } } +@keyframes drift-b { 50% { transform:translate(-24vw, 12vh) rotate(-12deg) scale(0.9, 1.15); } 100% { transform:translate(-52vw, 18vh) rotate(8deg) scale(1.1, 0.95); } } +@keyframes drift-c { 40% { transform:translate(-32vw, -30vh) rotate(8deg) scale(1.15, 0.9); } 75% { transform:translate(30vw, -18vh) rotate(-10deg) scale(0.95, 1.12); } 100% { transform:translate(10vw, 80vh) rotate(-4deg) scale(1, 1); } } +@media (prefers-reduced-motion: reduce) { + .blob { transition:opacity 2.5s; animation:none; transform:none !important; } + .blob.is-core::before { opacity:1; } + .blob.is-core::after { opacity:0; } +} +.bands { + display:none; position:absolute; inset:0; opacity:0; transition:opacity 5s ease-in-out; + -webkit-mask-image:radial-gradient(farthest-corner at 100% 0%, #000 15%, rgba(0,0,0,0.45) 55%, transparent 95%); + mask-image:radial-gradient(farthest-corner at 100% 0%, #000 15%, rgba(0,0,0,0.45) 55%, transparent 95%); +} +:root[data-glowshape="bands"] .bands { display:block; } +.bands.is-shown { opacity:var(--band-alpha); } +[data-theme="dark"] { --band-ink:color-mix(in srgb, var(--accent) 60%, #ffffff); --band-alpha:0.55; } +[data-theme="light"] { --band-ink:color-mix(in srgb, var(--accent) 85%, #000000); --band-alpha:0.32; } +.band { + position:absolute; left:50%; top:50%; + width:calc(100vw + 30vh + 60px); height:calc(100vh + 30vw + var(--tile-h, 0px) + 60px); + background:var(--band-ink); + -webkit-mask-image:linear-gradient(transparent, transparent); mask-image:linear-gradient(transparent, transparent); + -webkit-mask-repeat:repeat; mask-repeat:repeat; + will-change:transform; + animation:band-fall calc(var(--band-dur, 20s) * var(--drift-mult, 2) / 2) linear infinite paused; +} +.bands.is-falling .band { animation-play-state:running; } +@keyframes band-fall { + from { transform:translate(-50%, -50%) rotate(var(--tilt)) translateY(calc(var(--tile-h) * -0.5)); } + to { transform:translate(-50%, -50%) rotate(var(--tilt)) translateY(calc(var(--tile-h) * 0.5)); } +} +:root[data-glowshape="bands"] .blob { animation:none; transform:none !important; } +:root[data-glowshape="bands"] .blob-c { opacity:0; } +@media (prefers-reduced-motion: reduce) { + .band { animation-play-state:paused !important; } +} + +.attn { + position:absolute; inset:-1px; border-radius:inherit; padding:1.5px; pointer-events:none; + overflow:hidden; opacity:0; transition:opacity 0.8s; + -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite:xor; + mask:linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); +} +.card > .attn { position:absolute; } +.attn-spin { + position:absolute; left:50%; top:50%; + width:var(--attn-d, 800px); height:var(--attn-d, 800px); + margin:calc(var(--attn-d, 800px) / -2) 0 0 calc(var(--attn-d, 800px) / -2); + background:conic-gradient(from 0deg, transparent 0 52%, color-mix(in srgb, var(--accent) 45%, transparent) 70%, var(--accent-text) 82%, transparent 94%); + animation:attn-spin 3.5s linear infinite; animation-play-state:paused; +} +.is-attention > .attn { opacity:1; } +.is-attention > .attn .attn-spin { animation-play-state:running; will-change:transform; } +.is-attention { box-shadow:var(--shadow-1), 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent), 0 16px 48px -20px var(--accent-glow); } +@keyframes attn-spin { to { transform:rotate(1turn); } } +@media (prefers-reduced-motion: reduce) { + .attn-spin { animation:none; background:var(--accent); } +} + +@media (prefers-reduced-transparency: reduce) { + :root[data-profile="glass"] :is(thead th, .hint-panel, .match-pop, .conv-pop, .new-menu, .attn-menu, .dash-settings, .dashboard-add-menu, .wealth-year-menu, .po-pop, .ring-tip, .cmd-bar, .dash-edit-bar, .spark-mini-label, .mobile-topbar, .dialog) { + background:var(--bg-elevated); backdrop-filter:none; -webkit-backdrop-filter:none; + } + :root[data-profile="glass"] .dialog::before { background:var(--bg-elevated); backdrop-filter:none; -webkit-backdrop-filter:none; } + :root[data-profile="glass"] .table-wrap { --head-fill:var(--bg-elevated); } + :root[data-profile="glass"] .table-wrap.sticky-sel tbody tr.row-selected > td { + background:color-mix(in srgb, var(--accent) 20%, var(--bg-elevated)); backdrop-filter:none; -webkit-backdrop-filter:none; + } +} diff --git a/client/dashboard/dashboard.js b/client/dashboard/dashboard.js new file mode 100644 index 0000000..a357efc --- /dev/null +++ b/client/dashboard/dashboard.js @@ -0,0 +1,805 @@ +// Generated, do not edit. sha256:890b0e46a8d4907e79564933d64186ce26573ba305bedb6fd4fa2f911700fadc +import { getPref, setPref } from '../data/preferences.js'; +import { + activateProfile, activeProfile, createProfile, deleteProfile, listProfiles, listWidgets, + renameProfile, saveWidgets, widgetParams, +} from '../data/dashboard.js'; +import * as dialog from '../ui/dialog.js'; +import { DASHBOARD } from '../../shared/config.js'; +import { t } from '../i18n/index.js'; +import { CLOSE, PLUS, GRIP, ADJUST, PENCIL, TRASH } from '../ui/icons.js'; +import { esc } from '../ui/escape.js'; +import * as loading from '../ui/loading.js'; +import { freeRunAt, fittingCatalog, placeInRun } from './free-space.js'; + +const LAYOUT_SCOPE = 'dashboard'; +const MODEL_KEY = 'heightModel'; +const LAYOUT_MODEL_KEY = 'layoutModel'; + +let masonryRO = null; +let masonryResizeBound = false; +let masonrySettleTimer = null; +let masonryRevealTimer = null; +let masonryMO = null; +let growCheckTimer = null; + +function clampSpan(w) { + const n = Number(w); + if (!Number.isFinite(n)) return DASHBOARD.MIN_SPAN; + return Math.max(DASHBOARD.MIN_SPAN, Math.min(DASHBOARD.MAX_SPAN, Math.round(n))); +} + +function clampH(h) { + const n = Number(h); + if (!Number.isFinite(n)) return DASHBOARD.MIN_H; + return Math.max(DASHBOARD.MIN_H, Math.min(DASHBOARD.MAX_H, Math.round(n))); +} + +function stepPx() { + const rootPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + return DASHBOARD.HEIGHT_STEP_REM * rootPx; +} + +function pxForSteps(n, step, gap) { return n > 0 ? n * step + (n - 1) * gap : 0; } +function stepsForPx(px, step, gap) { return Math.max(1, Math.ceil((px + gap) / (step + gap))); } + +function newInstance(c) { + const def = DASHBOARD.WIDGETS[c.type]; + return { instId: c.instId, type: c.type, params: c.params || null, w: def.defaultSpan, h: 0, x: 0, y: 0 }; +} + +function cellSteps(it) { + if (it.h > 0) return clampH(it.h); + const def = DASHBOARD.WIDGETS[it.type]; + return def && def.defaultH != null ? clampH(def.defaultH) : DASHBOARD.SKELETON_STEPS; +} + +function spanSteps(it) { return Math.max(DASHBOARD.MIN_H, cellSteps(it)); } + +function packLayout(list) { + const cols = DASHBOARD.GRID_COLUMNS; + const bottom = new Array(cols).fill(0); + for (const it of list) { + const w = clampSpan(it.w); + const h = Math.max(DASHBOARD.MIN_H, cellSteps(it)); + const own = Math.max(0, Math.min(cols - w, Math.round(Number(it.x) || 0))); + let bestX = 0, bestY = Infinity; + for (let x = 0; x + w <= cols; x++) { + let y = 0; + for (let c = x; c < x + w; c++) if (bottom[c] > y) y = bottom[c]; + if (y < bestY || (y === bestY && x === own)) { bestY = y; bestX = x; } + } + it.x = bestX; it.y = bestY; + for (let c = bestX; c < bestX + w; c++) bottom[c] = bestY + h; + } + return list; +} + +function rectsOverlap(a, b) { + return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + spanSteps(b) && b.y < a.y + spanSteps(a); +} + +function makeRoom(list, anchors) { + const anchorSet = new Set(anchors); + const queue = anchors.slice(); + let guard = 0; + while (queue.length && guard++ < 2000) { + const b = list[queue.shift()]; + const bBottom = b.y + spanSteps(b); + for (let i = 0; i < list.length; i++) { + if (anchorSet.has(i)) continue; + const a = list[i]; + if (a === b) continue; + if (rectsOverlap(a, b) && bBottom > a.y) { a.y = bBottom; queue.push(i); } + } + } +} + +function layoutBottom(list) { + let bottom = 0; + for (const it of list) { + const b = (Math.max(0, Math.round(Number(it.y) || 0))) + Math.max(DASHBOARD.MIN_H, cellSteps(it)); + if (b > bottom) bottom = b; + } + return bottom; +} + +export async function readLayout() { + const list = await listWidgets((await activeProfile()).id); + const model = Number(await getPref(LAYOUT_SCOPE, MODEL_KEY, 0)) || 0; + const hinted = model >= DASHBOARD.HEIGHT_MODEL; + const layoutModel = Number(await getPref(LAYOUT_SCOPE, LAYOUT_MODEL_KEY, 0)) || 0; + const placed = layoutModel >= DASHBOARD.LAYOUT_MODEL; + const out = list + .filter(r => DASHBOARD.WIDGETS[r.type]) + .map(r => { + const def = DASHBOARD.WIDGETS[r.type]; + const h = (hinted && r.spanHeight > 0) ? clampH(r.spanHeight) : 0; + const w = clampSpan(r.spanWidth != null ? r.spanWidth : def.defaultSpan); + return { + instId: String(r.instanceKey || r.type), + type: r.type, + params: widgetParams(r), + w, + h, + x: Math.max(0, Math.min(DASHBOARD.GRID_COLUMNS - w, Math.round(Number(r.gridX) || 0))), + y: placed ? Math.max(0, Math.round(Number(r.gridY) || 0)) : 0, + }; + }); + if (!placed) packLayout(out); + return out; +} + +async function writeLayout(list) { + try { + const profileId = (await activeProfile()).id; + await saveWidgets(profileId, list.map(it => ({ + instanceKey: it.instId, type: it.type, params: it.params || null, + gridX: it.x, gridY: it.y, spanWidth: it.w, + spanHeight: it.h > 0 ? it.h : null, + }))); + await setPref(LAYOUT_SCOPE, LAYOUT_MODEL_KEY, DASHBOARD.LAYOUT_MODEL); + } catch { } +} + +function errCard(e) { + return `
\u26A0 ${esc((e && e.message) || e)}
`; +} + +function editBarHtml(it, i, hasSettings) { + let opts = ''; + for (let n = DASHBOARD.MIN_SPAN; n <= DASHBOARD.MAX_SPAN; n++) opts += ``; + const widthSel = ``; + const cfgBtn = hasSettings + ? `` + : ''; + return `
+ ${GRIP} + ${widthSel} + ${cfgBtn} + +
`; +} + +function cellHtml(it, i, edit, hasSettings) { + const x = Math.max(0, Math.round(Number(it.x) || 0)); + const y = Math.max(0, Math.round(Number(it.y) || 0)); + const span = spanSteps(it); + const cls = `dashboard-cell${it.h > 0 ? ' is-sized' : ''}`; + const place = `grid-column: ${x + 1} / span ${it.w}; grid-row: ${y + 1} / span ${span};`; + const settingsPanel = edit && hasSettings ? '' : ''; + return `
${edit ? editBarHtml(it, i, hasSettings) : ''}${settingsPanel}
`; +} + +async function profileListHtml() { + const profielen = await listProfiles(); + const actief = profielen.find(p => p.isActive); + const systeem = !!(actief && actief.isSystem); + const items = profielen.map(p => ``).join(''); + const knop = (act, icoon, label, uit = false) => + ``; + return `
+
${items}
+
+ ${knop('profile-new', PLUS, t('dashboard.profileNew'))} + ${knop('profile-rename', PENCIL, systeem ? t('dashboard.profileSystemHint') : t('dashboard.profileRename'), systeem)} + ${knop('profile-delete', TRASH, systeem ? t('dashboard.profileRestart') : t('dashboard.profileDelete'))} +
+
`; +} + +const itemLabelHtml = c => c.labelHtml || esc(c.label); + +async function toolsHtml() { + return profileListHtml(); +} + +function mobileAddHtml(catalog) { + const items = catalog.length + ? catalog.map((c, i) => ``).join('') + : `
${t('dashboard.addNone')}
`; + return `
${t('dashboard.add')}
${items}
`; +} + +export async function renderDashboard(host, opts = {}) { + if (!host) return; + latestOpts.set(host, opts); + return draw(host, opts); +} + +const latestOpts = new WeakMap(); +const drawGen = new WeakMap(); +const rerender = host => draw(host, latestOpts.get(host) || {}); + +async function draw(host, opts) { + const gen = (drawGen.get(host) || 0) + 1; + drawGen.set(host, gen); + const stale = () => drawGen.get(host) !== gen; + const renderers = opts.renderers || {}; + const edit = !!opts.edit; + const toolbarHost = opts.toolbarHost || null; + const layout = await readLayout(); + const catalog = edit && typeof opts.catalog === 'function' ? await opts.catalog(layout) : []; + if (stale()) return; + const hasSettings = type => !!(opts.settings && typeof opts.settings[type] === 'function'); + teardownSettingsOutside(); + + const prevBodies = new Map(); + host.querySelectorAll('.dashboard-grid > .dashboard-cell').forEach(c => { + const b = c.querySelector('.dashboard-body'); + if (b && c.dataset.inst) prevBodies.set(c.dataset.inst, b); + }); + const freshMount = prevBodies.size === 0 && layout.length > 0; + + const holdHeight = host.getBoundingClientRect().height; + if (holdHeight > 0) host.style.minHeight = holdHeight + 'px'; + + const order = layout.map((_, i) => i).sort((a, b) => (layout[a].y - layout[b].y) || (layout[a].x - layout[b].x)); + const room = edit ? `` : ''; + closeAddPop(); + host.innerHTML = `
${order.map(i => cellHtml(layout[i], i, edit, hasSettings(layout[i].type))).join('')}${room}
${edit ? mobileAddHtml(catalog) : ''}`; + + const clickHandler = edit ? editClickHandler(host, layout, catalog, opts) : null; + if (toolbarHost) { + const tools = edit ? await toolsHtml() : ''; + if (stale()) return; + toolbarHost.innerHTML = tools; + toolbarHost.onclick = edit ? clickHandler : null; + } + + host.querySelectorAll('.dashboard-grid > .dashboard-cell').forEach(cell => { + const it = layout[Number(cell.dataset.cell)]; + if (!it) return; + const render = renderers[it.type]; + if (typeof render !== 'function') { cell.remove(); return; } + const prev = prevBodies.get(it.instId); + if (prev) { cell.querySelector('.dashboard-body').replaceWith(prev); prevBodies.delete(it.instId); return; } + const body = cell.querySelector('.dashboard-body'); + try { render(body, it); } + catch (e) { body.innerHTML = errCard(e); } + }); + + host.onclick = clickHandler; + host.onchange = edit ? editChangeHandler(host, layout, opts) : null; + + if (edit) setupDrag(host, layout, opts); + if (edit) setupAddBar(host, layout, catalog, opts); + teardownEditExit(); + if (typeof opts.onEdit === 'function') { + if (edit) setupEditExit(host, opts); + else setupLongPress(host, opts); + } + + if (masonryRevealTimer) { clearTimeout(masonryRevealTimer); masonryRevealTimer = null; } + if (freshMount) { + const g = host.querySelector('.dashboard-grid'); + if (g) { + g.__freshSettle = true; + g.classList.add('is-loading'); + g.insertAdjacentHTML('beforebegin', skeletonHtml(layout)); + loading.sizeSkeleton(g.parentElement); + masonryRevealTimer = setTimeout(() => revealGrid(g), DASHBOARD.REVEAL_MAX_MS); + } + } + + setupMasonry(host, layout, opts); + + if (holdHeight > 0) host.style.minHeight = ''; +} + +let settingsOutsideHandler = null; +function teardownSettingsOutside() { + if (settingsOutsideHandler) { document.removeEventListener('mousedown', settingsOutsideHandler, true); settingsOutsideHandler = null; } +} +function closeSettingsPanel(panel) { + if (!panel) return; + panel.setAttribute('hidden', ''); + panel.innerHTML = ''; + const cell = panel.closest('.dashboard-cell'); + if (cell) cell.classList.remove('is-settings-open'); + teardownSettingsOutside(); +} + +function editClickHandler(host, layout, catalog, opts) { + return async e => { + const btn = e.target.closest('[data-act]'); + if (!btn) return; + const act = btn.dataset.act; + + if (act === 'settings') { + const cell = btn.closest('.dashboard-cell'); + const panel = cell && cell.querySelector('.dash-settings'); + if (!panel) return; + const wasOpen = !panel.hasAttribute('hidden'); + host.querySelectorAll('.dash-settings:not([hidden])').forEach(p => { if (p !== panel) closeSettingsPanel(p); }); + if (wasOpen) { closeSettingsPanel(panel); return; } + const it = layout[Number(btn.dataset.i)]; + const fn = it && opts.settings && opts.settings[it.type]; + if (typeof fn !== 'function') return; + const commit = (o = {}) => { + if (o && o.resize) it.h = 0; + writeLayout(layout); + const body = cell.querySelector('.dashboard-body'); + const render = (opts.renderers || {})[it.type]; + if (body && typeof render === 'function') { try { render(body, it); } catch (e) { body.innerHTML = errCard(e); } } + if (o && o.resize) { + cell.classList.remove('is-sized'); + const grid = host.querySelector('.dashboard-grid'); + if (grid) armPersist(host, grid, layout, opts); + } + }; + panel.innerHTML = ''; + fn(panel, it, commit); + panel.removeAttribute('hidden'); + cell.classList.add('is-settings-open'); + teardownSettingsOutside(); + settingsOutsideHandler = ev => { + if (panel.contains(ev.target)) return; + if (ev.target.closest('[data-act="settings"]')) return; + closeSettingsPanel(panel); + }; + document.addEventListener('mousedown', settingsOutsideHandler, true); + return; + } + + if (act === 'add') { + const c = catalog[Number(btn.dataset.idx)]; + if (!c || !DASHBOARD.WIDGETS[c.type]) return; + const inst = newInstance(c); inst.x = 0; inst.y = layoutBottom(layout); + layout.push(inst); + await writeLayout(layout); await rerender(host); return; + } + + if (act.startsWith('profile-')) { await profileAction(host, opts, act, btn); return; } + + const i = Number(btn.dataset.i); + const it = layout[i]; + if (!it) return; + if (act === 'remove') layout.splice(i, 1); + else return; + + await writeLayout(layout); + await rerender(host); + }; +} + +async function profileAction(host, opts, act, btn) { + const huidig = await activeProfile(); + try { + if (act === 'profile-pick') { + const id = Number(btn.dataset.id); + if (!Number.isInteger(id) || id === huidig.id) return; + await activateProfile(id); + } else if (act === 'profile-new') { + const res = await dialog.form({ + title: t('dashboard.profileNew'), + fields: [{ key: 'name', label: t('dashboard.profileNewPrompt'), value: '' }], + }); + if (!res || !String(res.name || '').trim()) return; + await activateProfile(await createProfile({ name: res.name })); + } else if (act === 'profile-rename') { + if (huidig.isSystem) return; + const res = await dialog.form({ + title: t('dashboard.profileRename'), + fields: [{ key: 'name', label: t('dashboard.profileRenamePrompt'), value: huidig.name }], + }); + if (!res || !String(res.name || '').trim()) return; + await renameProfile(huidig.id, { name: res.name }); + } else if (act === 'profile-delete') { + const vraag = huidig.isSystem ? t('dashboard.profileRestartConfirm') : t('dashboard.profileDeleteConfirm'); + if (!(await dialog.confirm({ message: vraag, danger: true }))) return; + await deleteProfile(huidig.id); + } else return; + } catch (err) { + dialog.alert({ title: t('dialog.error'), message: err.message }); + } + host.innerHTML = ''; + await rerender(host); +} + +function editChangeHandler(host, layout, opts) { + return async e => { + const sel = e.target.closest('select[data-act="width"]'); + if (!sel || !host.contains(sel)) return; + const it = layout[Number(sel.dataset.i)]; + if (!it) return; + it.w = clampSpan(sel.value); + it.h = 0; + await writeLayout(layout); + await rerender(host); + }; +} + +const MOBILE_QUERY = '(max-width:768px)'; +let addPop = null; +function addPopOutside(e) { + if (addPop && !addPop.contains(e.target) && !e.target.closest('.dash-add-bar')) closeAddPop(); +} +function closeAddPop() { + if (!addPop) return; + addPop.remove(); + addPop = null; + document.removeEventListener('mousedown', addPopOutside, true); +} + +function setupAddBar(host, layout, catalog, opts) { + const grid = host.querySelector('.dashboard-grid'); + if (!grid) return; + const bar = document.createElement('div'); + bar.className = 'dash-add-bar'; + bar.hidden = true; + bar.innerHTML = `${PLUS}`; + grid.appendChild(bar); + let run = null; + + const cellAt = (clientX, clientY) => { + const gr = grid.getBoundingClientRect(); + const cs = getComputedStyle(grid); + const colGap = parseFloat(cs.columnGap) || 0; + const rowGap = parseFloat(cs.rowGap) || 0; + const colW = (gr.width - (DASHBOARD.GRID_COLUMNS - 1) * colGap) / DASHBOARD.GRID_COLUMNS; + return { + col: Math.floor((clientX - gr.left) / (colW + colGap)), + row: Math.floor((clientY - gr.top) / (stepPx() + rowGap)), + }; + }; + const hide = () => { if (!addPop) { bar.hidden = true; run = null; } }; + const follow = (target, clientX, clientY) => { + if (addPop || grid.classList.contains('is-dragging-active') || matchMedia(MOBILE_QUERY).matches) return; + if (target.closest('.dash-add-bar')) return; + if (target.closest('.dashboard-cell')) { hide(); return; } + const { col, row } = cellAt(clientX, clientY); + const r = freeRunAt(layout, col, row, spanSteps); + if (!r) { hide(); return; } + run = r; + bar.style.gridColumn = `${r.x + 1} / span ${r.w}`; + bar.style.gridRow = `${r.y + 1} / span 1`; + bar.hidden = false; + }; + grid.addEventListener('pointermove', e => follow(e.target, e.clientX, e.clientY)); + grid.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') follow(e.target, e.clientX, e.clientY); }); + grid.addEventListener('pointerleave', hide); + bar.addEventListener('click', () => { + if (run) openAddPop(host, layout, catalog, opts, run, bar); + }); +} + +function openAddPop(host, layout, catalog, opts, run, bar) { + closeAddPop(); + const items = fittingCatalog(catalog, run); + const pop = document.createElement('div'); + pop.className = 'dashboard-add-menu dash-add-pop is-open'; + pop.innerHTML = items.length + ? items.map((c, i) => ``).join('') + : `
${esc(catalog.length ? t('dashboard.addNoFit') : t('dashboard.addNone'))}
`; + document.body.appendChild(pop); + addPop = pop; + const r = bar.getBoundingClientRect(); + const w = pop.offsetWidth, h = pop.offsetHeight; + const left = Math.max(8, Math.min(window.innerWidth - w - 8, r.left + r.width / 2 - w / 2)); + const top = r.bottom + 6 + h > window.innerHeight ? Math.max(8, r.top - 6 - h) : r.bottom + 6; + pop.style.left = left + 'px'; + pop.style.top = top + 'px'; + document.addEventListener('mousedown', addPopOutside, true); + pop.addEventListener('click', async e => { + const btn = e.target.closest('[data-idx]'); + const c = btn && items[Number(btn.dataset.idx)]; + if (!c) return; + closeAddPop(); + const inst = newInstance(c); + Object.assign(inst, placeInRun(run, inst.w)); + layout.push(inst); + makeRoom(layout, [layout.length - 1]); + await writeLayout(layout); + await rerender(host); + }); +} + +let swallowClick = false; +let swallowBound = false; +function setupLongPress(host, opts) { + const grid = host.querySelector('.dashboard-grid'); + if (!grid) return; + if (!swallowBound) { + swallowBound = true; + window.addEventListener('click', e => { + if (!swallowClick) return; + swallowClick = false; + e.preventDefault(); + e.stopImmediatePropagation(); + }, true); + window.addEventListener('pointerdown', () => { swallowClick = false; }, true); + } + grid.addEventListener('pointerdown', e => { + swallowClick = false; + if (e.pointerType !== 'mouse' || e.button !== 0) return; + if (!e.target.closest('.dashboard-cell')) return; + const x0 = e.clientX, y0 = e.clientY; + const stop = () => { + clearTimeout(timer); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', stop); + window.removeEventListener('pointercancel', stop); + }; + const onMove = ev => { + if (Math.hypot(ev.clientX - x0, ev.clientY - y0) > DASHBOARD.LONG_PRESS_SLOP_PX) stop(); + }; + const timer = setTimeout(() => { + stop(); + swallowClick = true; + opts.onEdit(true); + }, DASHBOARD.LONG_PRESS_MS); + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', stop); + window.addEventListener('pointercancel', stop); + }); +} + +let editExitHandlers = null; +function teardownEditExit() { + if (!editExitHandlers) return; + document.removeEventListener('keydown', editExitHandlers.key); + document.removeEventListener('click', editExitHandlers.click); + editExitHandlers = null; +} +const EDIT_INSIDE = '.dashboard-cell, .dash-add-bar,.dashboard-add-menu, .dash-add-mobile, #page-tools, #nav-tools, #sidebar, .dialog-overlay'; +function setupEditExit(host, opts) { + const done = () => { + teardownEditExit(); + if (host.isConnected) opts.onEdit(false); + }; + const key = e => { + if (e.key !== 'Escape') return; + if (!host.isConnected) { teardownEditExit(); return; } + if (e.defaultPrevented || !e.target.isConnected || document.querySelector('.dialog-overlay')) return; + const panel = host.querySelector('.dash-settings:not([hidden])'); + if (panel) { closeSettingsPanel(panel); return; } + if (addPop) { closeAddPop(); return; } + done(); + }; + const click = e => { + if (!e.target.isConnected) return; + if (e.target.closest(EDIT_INSIDE)) return; + done(); + }; + editExitHandlers = { key, click }; + document.addEventListener('keydown', key); + document.addEventListener('click', click); +} + +function setupDrag(host, layout, opts) { + const grid = host.querySelector('.dashboard-grid'); + if (!grid) return; + + grid.addEventListener('pointerdown', e => { + if (e.button != null && e.button !== 0) return; + const handle = e.target.closest('.dash-edit-grip'); + if (!handle || !grid.contains(handle)) return; + const fromCell = handle.closest('.dashboard-cell'); + if (!fromCell) return; + e.preventDefault(); + startDrag({ host, grid, layout, opts, fromCell, fromIdx: Number(fromCell.dataset.cell), startX: e.clientX, startY: e.clientY }); + }); +} + +function swallowNextClick() { + const eat = e => { e.preventDefault(); e.stopImmediatePropagation(); off(); }; + const off = () => { + clearTimeout(timer); + window.removeEventListener('click', eat, true); + window.removeEventListener('pointerdown', off, true); + }; + const timer = setTimeout(off, 300); + window.addEventListener('click', eat, true); + window.addEventListener('pointerdown', off, true); +} + +function startDrag({ host, grid, layout, opts, fromCell, fromIdx, startX, startY }) { + const it = layout[fromIdx]; + if (!it) return; + const w = clampSpan(it.w); + const h = spanSteps(it); + + grid.classList.add('is-dragging-active'); + fromCell.classList.add('is-dragging'); + + const srcRect = fromCell.getBoundingClientRect(); + const grabX = startX - srcRect.left, grabY = startY - srcRect.top; + const srcInner = fromCell.querySelector('.dashboard-body > *'); + const ghost = document.createElement('div'); + ghost.className = 'dashboard-drag-ghost'; + ghost.style.width = srcRect.width + 'px'; + ghost.style.left = srcRect.left + 'px'; + ghost.style.top = srcRect.top + 'px'; + if (srcInner) ghost.appendChild(srcInner.cloneNode(true)); + document.body.appendChild(ghost); + const moveGhost = (x, y) => { ghost.style.left = (x - grabX) + 'px'; ghost.style.top = (y - grabY) + 'px'; }; + + const preview = document.createElement('div'); + preview.className = 'dashboard-drop-preview'; + grid.appendChild(preview); + + let target = null; + let swapCell = null; + + const cellByIdx = idx => grid.querySelector('.dashboard-cell[data-cell="' + idx + '"]'); + const clearSwap = () => { if (swapCell) { swapCell.classList.remove('is-drop-swap'); swapCell = null; } }; + + const snapCell = (clientX, clientY) => { + const gr = grid.getBoundingClientRect(); + const cs = getComputedStyle(grid); + const colGap = parseFloat(cs.columnGap) || 0; + const rowGap = parseFloat(cs.rowGap) || 0; + const step = stepPx(); + const colW = (gr.width - (DASHBOARD.GRID_COLUMNS - 1) * colGap) / DASHBOARD.GRID_COLUMNS; + let x = Math.round(((clientX - grabX) - gr.left) / (colW + colGap)); + x = Math.max(0, Math.min(DASHBOARD.GRID_COLUMNS - w, x)); + const y = Math.max(0, Math.round(((clientY - grabY) - gr.top) / (step + rowGap))); + return { x, y }; + }; + + const computeTarget = (clientX, clientY) => { + let over = document.elementFromPoint(clientX, clientY); + over = over && over.closest ? over.closest('.dashboard-cell') : null; + if (over && grid.contains(over) && Number(over.dataset.cell) !== fromIdx) { + return { mode: 'swap', overIdx: Number(over.dataset.cell) }; + } + const s = snapCell(clientX, clientY); + return { mode: 'insert', x: s.x, y: s.y }; + }; + + const showTarget = tg => { + if (tg.mode === 'swap') { + preview.style.display = 'none'; + const cell = cellByIdx(tg.overIdx); + if (swapCell && swapCell !== cell) clearSwap(); + if (cell) { swapCell = cell; cell.classList.add('is-drop-swap'); } + } else { + clearSwap(); + preview.style.display = ''; + preview.style.gridColumn = (tg.x + 1) + ' / span ' + w; + preview.style.gridRow = (tg.y + 1) + ' / span ' + h; + } + }; + + const onMove = ev => { moveGhost(ev.clientX, ev.clientY); target = computeTarget(ev.clientX, ev.clientY); showTarget(target); }; + moveGhost(startX, startY); + target = computeTarget(startX, startY); + showTarget(target); + + const finish = async commit => { + if (commit) swallowNextClick(); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onCancel); + clearSwap(); + preview.remove(); + ghost.remove(); + grid.classList.remove('is-dragging-active'); + fromCell.classList.remove('is-dragging'); + if (!commit || !target) return; + + if (target.mode === 'swap') { + const b = layout[target.overIdx]; + if (!b || target.overIdx === fromIdx) return; + const ax = it.x, ay = it.y; + it.x = b.x; it.y = b.y; + b.x = ax; b.y = ay; + if (rectsOverlap(it, b)) makeRoom(layout, [fromIdx]); + else makeRoom(layout, [fromIdx, target.overIdx]); + } else { + if (target.x === it.x && target.y === it.y) return; + it.x = target.x; it.y = target.y; + makeRoom(layout, [fromIdx]); + } + await writeLayout(layout); + await rerender(host); + }; + + const onUp = () => finish(true); + const onCancel = () => finish(false); + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onCancel); +} + +function packGrid(grid) { + if (!grid) return; + const gap = parseFloat(getComputedStyle(grid).columnGap) || 0; + const rows = Math.round(stepPx()) + 'px'; + const rowGap = Math.round(gap) + 'px'; + if (grid.style.gridAutoRows !== rows) grid.style.gridAutoRows = rows; + if (grid.style.rowGap !== rowGap) grid.style.rowGap = rowGap; +} + +function scheduleMasonry(grid) { + if (!grid || grid.__packQueued) return; + grid.__packQueued = true; + requestAnimationFrame(() => { grid.__packQueued = false; packGrid(grid); }); +} + +function setupMasonry(host, layout, opts) { + const grid = host.querySelector('.dashboard-grid'); + if (!grid) return; + packGrid(grid); + if (typeof ResizeObserver !== 'undefined') { + if (masonryRO) masonryRO.disconnect(); + masonryRO = new ResizeObserver(() => { scheduleMasonry(grid); armPersist(host, grid, layout, opts); }); + grid.querySelectorAll(':scope > .dashboard-cell').forEach(c => masonryRO.observe(c)); + } + if (typeof MutationObserver !== 'undefined') { + if (masonryMO) masonryMO.disconnect(); + if (growCheckTimer) { clearTimeout(growCheckTimer); growCheckTimer = null; } + masonryMO = new MutationObserver(() => scheduleGrowCheck(host, grid, layout, opts)); + grid.querySelectorAll(':scope > .dashboard-cell > .dashboard-body') + .forEach(b => masonryMO.observe(b, { childList: true, subtree: true, characterData: true })); + } + if (!masonryResizeBound) { + masonryResizeBound = true; + window.addEventListener('resize', () => { + const g = document.querySelector('.dashboard-grid'); + if (g) scheduleMasonry(g); + }); + } + armPersist(host, grid, layout, opts); +} + +function armPersist(host, grid, layout, opts) { + if (masonrySettleTimer) clearTimeout(masonrySettleTimer); + masonrySettleTimer = setTimeout(() => persistSettled(host, grid, layout, opts), DASHBOARD.SETTLE_MS); +} + +function scheduleGrowCheck(host, grid, layout, opts) { + if (masonrySettleTimer || growCheckTimer) return; + growCheckTimer = setTimeout(() => { growCheckTimer = null; persistSettled(host, grid, layout, opts); }, DASHBOARD.SETTLE_MS); +} + +async function persistSettled(host, grid, layout, opts) { + masonrySettleTimer = null; + if (!grid.isConnected) return; + if (!grid.getBoundingClientRect().height) { + if (grid.querySelector(':scope > .dashboard-cell')) armPersist(host, grid, layout, opts); + return; + } + const step = stepPx(); + const gap = parseFloat(getComputedStyle(grid).columnGap) || 0; + const minSteps = DASHBOARD.MIN_H; + const GROW_TOL = 4; + const grown = []; + grid.querySelectorAll(':scope > .dashboard-cell').forEach(cell => { + const idx = Number(cell.dataset.cell); + const it = layout[idx]; + if (!it) return; + const bodyEl = cell.querySelector('.dashboard-body'); + const content = Math.round((bodyEl || cell).scrollHeight); + const wanted = Math.max(minSteps, stepsForPx(content, step, gap)); + if (it.h <= 0) { if (wanted !== it.h) { it.h = wanted; grown.push(idx); } } + else if (content > pxForSteps(it.h, step, gap) + GROW_TOL && wanted > it.h) { it.h = wanted; grown.push(idx); } + }); + if (!grown.length) { revealGrid(grid); return; } + const fresh = grid.__freshSettle; grid.__freshSettle = false; + if (fresh) packLayout(layout); else makeRoom(layout, grown); + await writeLayout(layout); + try { await setPref(LAYOUT_SCOPE, MODEL_KEY, DASHBOARD.HEIGHT_MODEL); } catch { } + await rerender(host); +} + +function revealGrid(grid) { + if (masonryRevealTimer) { clearTimeout(masonryRevealTimer); masonryRevealTimer = null; } + if (!grid || !grid.classList.contains('is-loading')) return; + const host = grid.parentElement; + const skel = host && host.querySelector(':scope > .dashboard-skeleton'); + if (skel) skel.remove(); + grid.classList.remove('is-loading'); +} + +function skeletonHtml(layout) { + const step = stepPx(); + const cells = layout.map(it => { + const steps = it.h > 0 ? it.h : DASHBOARD.SKELETON_STEPS; + const h = Math.round(steps * step); + return `
${loading.card()}
`; + }).join(''); + return ``; +} diff --git a/client/dashboard/free-space.js b/client/dashboard/free-space.js new file mode 100644 index 0000000..3a301c7 --- /dev/null +++ b/client/dashboard/free-space.js @@ -0,0 +1,24 @@ +// Generated, do not edit. sha256:0de0d7d8274e0483bbf9a739f8fbd821c59be413f9db532018d5a830258c7352 +import { DASHBOARD } from '../../shared/config.js'; + +export function freeRunAt(layout, col, row, stepsOf) { + const cols = DASHBOARD.GRID_COLUMNS; + if (!Number.isInteger(col) || !Number.isInteger(row) || col < 0 || col >= cols || row < 0) return null; + const taken = x => layout.some(it => x >= it.x && x < it.x + it.w && row >= it.y && row < it.y + stepsOf(it)); + if (taken(col)) return null; + let x0 = col, x1 = col + 1; + while (x0 > 0 && !taken(x0 - 1)) x0--; + while (x1 < cols && !taken(x1)) x1++; + return { x: x0, y: row, w: x1 - x0, col }; +} + +export function fittingCatalog(catalog, run) { + return catalog.filter(c => { + const def = DASHBOARD.WIDGETS[c.type]; + return def && run && def.defaultSpan <= run.w; + }); +} + +export function placeInRun(run, w) { + return { x: Math.max(run.x, Math.min(run.col, run.x + run.w - w)), y: run.y }; +} diff --git a/client/data/_rpc.js b/client/data/_rpc.js new file mode 100644 index 0000000..50a6cc8 --- /dev/null +++ b/client/data/_rpc.js @@ -0,0 +1,87 @@ +// Generated, do not edit. sha256:f8b7d4cfde53f5e36967f7e1ae667dea2e048f4cbedb5088c2d2e8af98709896 +import { CONFIG } from '../../shared/config.js'; +import { t } from '../i18n/index.js'; + +const DOORLATEN = new Set(['then', 'toJSON', 'constructor', 'hasOwnProperty', 'nodeType', 'length', + 'inspect', 'valueOf', 'toString']); + +function bewaakteRij(rij) { + return new Proxy(rij, { + get(doel, naam) { + if (typeof naam === 'symbol' || naam in doel || DOORLATEN.has(naam)) return Reflect.get(doel, naam); + throw new Error( + `Onbekende kolom '${String(naam)}' op een rij met: ${Object.keys(doel).join(', ')}. ` + + `Waarschijnlijk een naam van vóór de baseline.`); + }, + }); +} + +function bewaak(waarde) { + if (waarde instanceof Map) return new Map([...waarde].map(([k, v]) => [k, bewaak(v)])); + if (Array.isArray(waarde)) return waarde.map(bewaak); + if (waarde && typeof waarde === 'object') return bewaakteRij(waarde); + return waarde; +} + +export function decodeer(waarde) { + if (Array.isArray(waarde)) return waarde.map(decodeer); + if (waarde && typeof waarde === 'object') { + if (waarde.__t === 'Map') return new Map(waarde.v.map(([k, v]) => [k, decodeer(v)])); + if (waarde.__t === 'Set') return new Set(waarde.v.map(decodeer)); + return Object.fromEntries(Object.entries(waarde).map(([k, v]) => [k, decodeer(v)])); + } + return waarde; +} + +let guardAan = CONFIG.ROW_GUARD; +export function setRowGuard(aan) { guardAan = !!aan; } +export function rowGuardAan() { return guardAan; } + +const DISPATCH_VENSTER_MS = 500; +const laatstGedispatcht = new Map(); + +export function dispatchChanges(namen) { + if (!Array.isArray(namen) || !namen.length) return []; + const nu = Date.now(); + const uit = []; + for (const naam of namen) { + if (typeof naam !== 'string' || !naam) continue; + const vorige = laatstGedispatcht.get(naam); + if (vorige != null && nu - vorige < DISPATCH_VENSTER_MS) continue; + laatstGedispatcht.set(naam, nu); + uit.push(naam); + document.dispatchEvent(new CustomEvent(naam)); + } + return uit; +} + +export function serverFoutTekst(json) { + if (!json) return ''; + if (json.key) { + try { return t(json.key, json.params); } catch { } + } + return json.error || ''; +} + +export async function rpc(module, functie, ...args) { + let res; + try { + res = await fetch(`/api/data/${module}/${functie}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ args }), + }); + } catch (e) { + throw new Error(`No connection to the server (${module}/${functie}): ${e.message}`); + } + if (res.status === 401) { + location.replace(CONFIG.AUTH.LOGIN_PATH); + throw new Error(t('common.sessionExpired')); + } + const json = await res.json().catch(() => null); + if (!res.ok) throw Object.assign(new Error(serverFoutTekst(json) || `HTTP ${res.status} bij ${module}/${functie}`), { key: json?.key ?? null }); + + if (json && json.changes) dispatchChanges(json.changes); + const result = decodeer(json ? json.result : null); + return guardAan ? bewaak(result) : result; +} diff --git a/client/data/accounts-merge.js b/client/data/accounts-merge.js new file mode 100644 index 0000000..01d2b7a --- /dev/null +++ b/client/data/accounts-merge.js @@ -0,0 +1,5 @@ +// Generated, do not edit. sha256:1b478fd1c705f49efb007e47d687e8d3c3e16944ad6127dd9fb3f6aff7cd2f9e +import { rpc } from './_rpc.js'; + +export const mergeAccounts = (...args) => rpc('accounts-merge', 'mergeAccounts', ...args); +export const mergeCheck = (...args) => rpc('accounts-merge', 'mergeCheck', ...args); diff --git a/client/data/accounts.js b/client/data/accounts.js new file mode 100644 index 0000000..b392912 --- /dev/null +++ b/client/data/accounts.js @@ -0,0 +1,11 @@ +// Generated, do not edit. sha256:a76e42b4b17445ed23c0a5b34d484c1fa3c93c95b182f816b35b9e874d53a6c1 +import { rpc } from './_rpc.js'; + +export const createAccount = (...args) => rpc('accounts', 'createAccount', ...args); +export const deactivateAccount = (...args) => rpc('accounts', 'deactivateAccount', ...args); +export const deactivateCheck = (...args) => rpc('accounts', 'deactivateCheck', ...args); +export const deleteAccount = (...args) => rpc('accounts', 'deleteAccount', ...args); +export const listAccounts = (...args) => rpc('accounts', 'listAccounts', ...args); +export const listSelectableAccounts = (...args) => rpc('accounts', 'listSelectableAccounts', ...args); +export const reactivateAccount = (...args) => rpc('accounts', 'reactivateAccount', ...args); +export const updateAccount = (...args) => rpc('accounts', 'updateAccount', ...args); diff --git a/client/data/ai-audit.js b/client/data/ai-audit.js new file mode 100644 index 0000000..73abc67 --- /dev/null +++ b/client/data/ai-audit.js @@ -0,0 +1,9 @@ +// Generated, do not edit. sha256:80b585a42433b6d90a3e5c2a9924d92c130d30f48331c7ff7a52c9498a9688c3 +import { rpc } from './_rpc.js'; + +export const aiSessions = (...args) => rpc('ai-audit', 'aiSessions', ...args); +export const listAiToolCalls = (...args) => rpc('ai-audit', 'listAiToolCalls', ...args); +export const countAiToolCalls = (...args) => rpc('ai-audit', 'countAiToolCalls', ...args); +export const lastAiToolCallAt = (...args) => rpc('ai-audit', 'lastAiToolCallAt', ...args); +export const aiToolCallMessages = (...args) => rpc('ai-audit', 'aiToolCallMessages', ...args); +export const countAiToolCallsSince = (...args) => rpc('ai-audit', 'countAiToolCallsSince', ...args); diff --git a/client/data/ai-proposals.js b/client/data/ai-proposals.js new file mode 100644 index 0000000..9d8656a --- /dev/null +++ b/client/data/ai-proposals.js @@ -0,0 +1,10 @@ +// Generated, do not edit. sha256:c54529dca3707ad988dd577ff2218f700b511b00652dc60ab704cd18c7e5d14b +import { rpc } from './_rpc.js'; + +export const aiProposalsStatus = (...args) => rpc('ai-proposals', 'aiProposalsStatus', ...args); +export const listPendingAiProposals = (...args) => rpc('ai-proposals', 'listPendingAiProposals', ...args); +export const getAiProposal = (...args) => rpc('ai-proposals', 'getAiProposal', ...args); +export const acceptAiProposal = (...args) => rpc('ai-proposals', 'acceptAiProposal', ...args); +export const acceptAiProposals = (...args) => rpc('ai-proposals', 'acceptAiProposals', ...args); +export const rejectAiProposal = (...args) => rpc('ai-proposals', 'rejectAiProposal', ...args); +export const rejectAiProposals = (...args) => rpc('ai-proposals', 'rejectAiProposals', ...args); diff --git a/client/data/api-metrics.js b/client/data/api-metrics.js new file mode 100644 index 0000000..dada2d6 --- /dev/null +++ b/client/data/api-metrics.js @@ -0,0 +1,9 @@ +// Generated, do not edit. sha256:0774745d5baf5d1ac279b3fd841bf393edb924718cb10edb23e5f72d43b20b77 +import { rpc } from './_rpc.js'; + +export const apiCallsByProvider = (...args) => rpc('api-metrics', 'apiCallsByProvider', ...args); +export const apiMetricsByCapability = (...args) => rpc('api-metrics', 'apiMetricsByCapability', ...args); +export const apiMetricsGlobal = (...args) => rpc('api-metrics', 'apiMetricsGlobal', ...args); +export const apiMetricsSeries = (...args) => rpc('api-metrics', 'apiMetricsSeries', ...args); +export const apiMetricsTotals = (...args) => rpc('api-metrics', 'apiMetricsTotals', ...args); +export const recordApiCall = (...args) => rpc('api-metrics', 'recordApiCall', ...args); diff --git a/client/data/asset-notes.js b/client/data/asset-notes.js new file mode 100644 index 0000000..d6b4fd4 --- /dev/null +++ b/client/data/asset-notes.js @@ -0,0 +1,5 @@ +// Generated, do not edit. sha256:9c2f2feef6a443ca4b9b2a030dfe347691b73b07fee08484ac2bb9a1afdbc90c +import { rpc } from './_rpc.js'; + +export const getAssetNote = (...args) => rpc('asset-notes', 'getAssetNote', ...args); +export const setAssetNote = (...args) => rpc('asset-notes', 'setAssetNote', ...args); diff --git a/client/data/assets.js b/client/data/assets.js new file mode 100644 index 0000000..c19e275 --- /dev/null +++ b/client/data/assets.js @@ -0,0 +1,8 @@ +// Generated, do not edit. sha256:b6febc102fa1a8f3cb1bd92d3bd9c51308fdd07a2b8841c36c26aee2e0d2a986 +import { rpc } from './_rpc.js'; + +export const createAsset = (...args) => rpc('assets', 'createAsset', ...args); +export const deleteAsset = (...args) => rpc('assets', 'deleteAsset', ...args); +export const listAssets = (...args) => rpc('assets', 'listAssets', ...args); +export const listBookableAssets = (...args) => rpc('assets', 'listBookableAssets', ...args); +export const updateAsset = (...args) => rpc('assets', 'updateAsset', ...args); diff --git a/client/data/bus.js b/client/data/bus.js new file mode 100644 index 0000000..a20e3cd --- /dev/null +++ b/client/data/bus.js @@ -0,0 +1,4 @@ +// Generated, do not edit. sha256:7a9b757076981126008fa611b32466b0c4ea84c9740dc8db3223b7668aa909c2 +import { rpc } from './_rpc.js'; + +export const changesSince = (...args) => rpc('bus', 'changesSince', ...args); diff --git a/client/data/commands.js b/client/data/commands.js new file mode 100644 index 0000000..c0ec49b --- /dev/null +++ b/client/data/commands.js @@ -0,0 +1,5 @@ +// Generated, do not edit. sha256:b4a6d5eaaa00e5f6844a5d032a45b92244fec713cce2d7b6906b9bbf12a9a0ec +import { rpc } from './_rpc.js'; + +export const claimNextCommand = (...args) => rpc('commands', 'claimNextCommand', ...args); +export const resolveCommand = (...args) => rpc('commands', 'resolveCommand', ...args); diff --git a/client/data/connections.js b/client/data/connections.js new file mode 100644 index 0000000..0b40672 --- /dev/null +++ b/client/data/connections.js @@ -0,0 +1,9 @@ +// Generated, do not edit. sha256:fcdff1a53c7e1919f381d8176d33f6f88b3620b864306002867bca5768bececc +import { rpc } from './_rpc.js'; + +export const activeConnection = (...args) => rpc('connections', 'activeConnection', ...args); +export const deleteConnection = (...args) => rpc('connections', 'deleteConnection', ...args); +export const listConnections = (...args) => rpc('connections', 'listConnections', ...args); +export const recordConnectionStatus = (...args) => rpc('connections', 'recordConnectionStatus', ...args); +export const saveConnection = (...args) => rpc('connections', 'saveConnection', ...args); +export const setActiveConnection = (...args) => rpc('connections', 'setActiveConnection', ...args); diff --git a/client/data/dashboard.js b/client/data/dashboard.js new file mode 100644 index 0000000..050fd79 --- /dev/null +++ b/client/data/dashboard.js @@ -0,0 +1,24 @@ +// Generated, do not edit. sha256:defb7eb8725729bc4363e332e3b27f668e5593fc5ad9d4cdb73dde97b1c913b3 +import { rpc } from './_rpc.js'; + +export const activateProfile = (...args) => rpc('dashboard', 'activateProfile', ...args); +export const activeProfile = (...args) => rpc('dashboard', 'activeProfile', ...args); +export const createProfile = (...args) => rpc('dashboard', 'createProfile', ...args); +export const deleteProfile = (...args) => rpc('dashboard', 'deleteProfile', ...args); +export const listProfiles = (...args) => rpc('dashboard', 'listProfiles', ...args); +export const listWidgets = (...args) => rpc('dashboard', 'listWidgets', ...args); +export const renameProfile = (...args) => rpc('dashboard', 'renameProfile', ...args); +export const saveWidgets = (...args) => rpc('dashboard', 'saveWidgets', ...args); + +export function widgetParams(rij) { + if (!rij) return null; + let rest = null; + if (rij.settings != null && rij.settings !== '') { + try { + const v = JSON.parse(rij.settings); + if (v && typeof v === 'object' && !Array.isArray(v)) rest = v; + } catch { } + } + if (rij.assetId == null && !rest) return null; + return { ...(rest || {}), ...(rij.assetId == null ? {} : { assetId: rij.assetId }) }; +} diff --git a/client/data/events.js b/client/data/events.js new file mode 100644 index 0000000..6ec4f6a --- /dev/null +++ b/client/data/events.js @@ -0,0 +1,17 @@ +// Generated, do not edit. sha256:7ddb93aead45334f8213e5120543e2ce9c2fdbc49c3493dc936e72a7e07fb3b7 +import { rpc } from './_rpc.js'; + +export const assetPositions = (...args) => rpc('events', 'assetPositions', ...args); +export const createEvent = (...args) => rpc('events', 'createEvent', ...args); +export const deleteEvent = (...args) => rpc('events', 'deleteEvent', ...args); +export const eventsNetByAccount = (...args) => rpc('events', 'eventsNetByAccount', ...args); +export const eventsWithoutImportRef = (...args) => rpc('events', 'eventsWithoutImportRef', ...args); +export const findEventByRef = (...args) => rpc('events', 'findEventByRef', ...args); +export const firstTaxEventDate = (...args) => rpc('events', 'firstTaxEventDate', ...args); +export const listAssetTrades = (...args) => rpc('events', 'listAssetTrades', ...args); +export const listEvents = (...args) => rpc('events', 'listEvents', ...args); +export const listFlowLegs = (...args) => rpc('events', 'listFlowLegs', ...args); +export const listTradeEvents = (...args) => rpc('events', 'listTradeEvents', ...args); +export const listTrades = (...args) => rpc('events', 'listTrades', ...args); +export const listTransfers = (...args) => rpc('events', 'listTransfers', ...args); +export const updateEvent = (...args) => rpc('events', 'updateEvent', ...args); diff --git a/client/data/hygiene.js b/client/data/hygiene.js new file mode 100644 index 0000000..7df2985 --- /dev/null +++ b/client/data/hygiene.js @@ -0,0 +1,10 @@ +// Generated, do not edit. sha256:3d2479ecc3a9b127fd96f6f5e0ff246e37273a0096ff4f6b4225e400506ea204 +import { rpc } from './_rpc.js'; + +export const acceptHygieneFinding = (...args) => rpc('hygiene', 'acceptHygieneFinding', ...args); +export const fillDerivablePriceOrigins = (...args) => rpc('hygiene', 'fillDerivablePriceOrigins', ...args); +export const hygieneFingerprint = (...args) => rpc('hygiene', 'hygieneFingerprint', ...args); +export const hygieneKinds = (...args) => rpc('hygiene', 'hygieneKinds', ...args); +export const hygieneReport = (...args) => rpc('hygiene', 'hygieneReport', ...args); +export const listHygieneAcceptances = (...args) => rpc('hygiene', 'listHygieneAcceptances', ...args); +export const unacceptHygieneFinding = (...args) => rpc('hygiene', 'unacceptHygieneFinding', ...args); diff --git a/client/data/integration-log.js b/client/data/integration-log.js new file mode 100644 index 0000000..a86e244 --- /dev/null +++ b/client/data/integration-log.js @@ -0,0 +1,6 @@ +// Generated, do not edit. sha256:9e3f0b7599208ec0351dd06de329a368ee8b0aec5e374f17900cb84afb65c3ca +import { rpc } from './_rpc.js'; + +export const lastIntegrationFailures = (...args) => rpc('integration-log', 'lastIntegrationFailures', ...args); +export const listIntegrationCalls = (...args) => rpc('integration-log', 'listIntegrationCalls', ...args); +export const serviceState = (...args) => rpc('integration-log', 'serviceState', ...args); diff --git a/client/data/interest-credits.js b/client/data/interest-credits.js new file mode 100644 index 0000000..14a817f --- /dev/null +++ b/client/data/interest-credits.js @@ -0,0 +1,9 @@ +// Generated, do not edit. sha256:abab16d475f5e1157c1883f4362f2317dbe5f56059e56c4a310f27132a6f26ee +import { rpc } from './_rpc.js'; + +export const bookDueCredit = (...args) => rpc('interest-credits', 'bookDueCredit', ...args); +export const dismissDueCredit = (...args) => rpc('interest-credits', 'dismissDueCredit', ...args); +export const dueCreditsAll = (...args) => rpc('interest-credits', 'dueCreditsAll', ...args); +export const dueCreditsForAccount = (...args) => rpc('interest-credits', 'dueCreditsForAccount', ...args); +export const realizedInterestSub = (...args) => rpc('interest-credits', 'realizedInterestSub', ...args); +export const scheduleForecast = (...args) => rpc('interest-credits', 'scheduleForecast', ...args); diff --git a/client/data/interest-schedules.js b/client/data/interest-schedules.js new file mode 100644 index 0000000..7b1c33c --- /dev/null +++ b/client/data/interest-schedules.js @@ -0,0 +1,15 @@ +// Generated, do not edit. sha256:49d76c7bdfff8e21aacf25ba4615ebc3e4c44908244991363de89f4b5302883d +import { rpc } from './_rpc.js'; + +export const createSchedule = (...args) => rpc('interest-schedules', 'createSchedule', ...args); +export const deleteSchedule = (...args) => rpc('interest-schedules', 'deleteSchedule', ...args); +export const listAllSchedules = (...args) => rpc('interest-schedules', 'listAllSchedules', ...args); +export const createPeriod = (...args) => rpc('interest-schedules', 'createPeriod', ...args); +export const deletePeriod = (...args) => rpc('interest-schedules', 'deletePeriod', ...args); +export const listInterest = (...args) => rpc('interest-schedules', 'listInterest', ...args); +export const listPeriods = (...args) => rpc('interest-schedules', 'listPeriods', ...args); +export const listInterestForAccount = (...args) => rpc('interest-schedules', 'listInterestForAccount', ...args); +export const listSchedulesForAccount = (...args) => rpc('interest-schedules', 'listSchedulesForAccount', ...args); +export const scheduleLabel = (...args) => rpc('interest-schedules', 'scheduleLabel', ...args); +export const updateSchedule = (...args) => rpc('interest-schedules', 'updateSchedule', ...args); +export const updatePeriod = (...args) => rpc('interest-schedules', 'updatePeriod', ...args); diff --git a/client/data/labels.js b/client/data/labels.js new file mode 100644 index 0000000..b82d6bf --- /dev/null +++ b/client/data/labels.js @@ -0,0 +1,6 @@ +// Generated, do not edit. sha256:957d597a4bfb509e38384b0a56f933177ad98da3654a8091e1d096d93809f347 +import { rpc } from './_rpc.js'; + +export const createLabel = (...args) => rpc('labels', 'createLabel', ...args); +export const deleteLabel = (...args) => rpc('labels', 'deleteLabel', ...args); +export const listLabels = (...args) => rpc('labels', 'listLabels', ...args); diff --git a/client/data/license-buyer.js b/client/data/license-buyer.js new file mode 100644 index 0000000..0c21955 --- /dev/null +++ b/client/data/license-buyer.js @@ -0,0 +1,9 @@ +// Generated, do not edit. sha256:a456f8dc9c28fdd063403c4c2eef8d6e41c477c6c093ce7ffcb31be14c5e6235 +import { rpc } from './_rpc.js'; + +export const licenseShop = (...args) => rpc('license-buyer', 'licenseShop', ...args); +export const buyerLicenses = (...args) => rpc('license-buyer', 'buyerLicenses', ...args); +export const forgetBuyer = (...args) => rpc('license-buyer', 'forgetBuyer', ...args); +export const saleInfo = (...args) => rpc('license-buyer', 'saleInfo', ...args); +export const placeOrder = (...args) => rpc('license-buyer', 'placeOrder', ...args); +export const orderStatus = (...args) => rpc('license-buyer', 'orderStatus', ...args); diff --git a/client/data/license-install.js b/client/data/license-install.js new file mode 100644 index 0000000..36ac392 --- /dev/null +++ b/client/data/license-install.js @@ -0,0 +1,4 @@ +// Generated, do not edit. sha256:ab6b0bca1cf0d6f7ad3bf3740b3ce12c351a567f2d0e027243b33a65ff31aa10 +import { rpc } from './_rpc.js'; + +export const installLicense = (...args) => rpc('license-install', 'installLicense', ...args); diff --git a/client/data/license-status.js b/client/data/license-status.js new file mode 100644 index 0000000..ae69404 --- /dev/null +++ b/client/data/license-status.js @@ -0,0 +1,4 @@ +// Generated, do not edit. sha256:d90a48e296cc770018512015bf31cae94c3aae0aec94589114777077dee41473 +import { rpc } from './_rpc.js'; + +export const licenseStatus = (...args) => rpc('license-status', 'licenseStatus', ...args); diff --git a/client/data/market.js b/client/data/market.js new file mode 100644 index 0000000..d22337c --- /dev/null +++ b/client/data/market.js @@ -0,0 +1,8 @@ +// Generated, do not edit. sha256:c8ee3ea7594922b4374e1a96cd8ac1eb601542bf346bd3e985122a2cbe72d1f9 +import { rpc } from './_rpc.js'; + +export const intradaySeries = (...args) => rpc('market', 'intradaySeries', ...args); +export const priceAtMoment = (...args) => rpc('market', 'priceAtMoment', ...args); +export const serviceSpot = (...args) => rpc('market', 'serviceSpot', ...args); +export const serviceCoverage = (...args) => rpc('market', 'serviceCoverage', ...args); +export const serviceDayHistory = (...args) => rpc('market', 'serviceDayHistory', ...args); diff --git a/client/data/metals.js b/client/data/metals.js new file mode 100644 index 0000000..e403b03 --- /dev/null +++ b/client/data/metals.js @@ -0,0 +1,11 @@ +// Generated, do not edit. sha256:32f05b529e2424e1bc6f485748255e6c9c0bc3181418d5b3715c4e68840342e4 +import { rpc } from './_rpc.js'; + +export const createMetalProduct = (...args) => rpc('metals', 'createMetalProduct', ...args); +export const deleteMetalProduct = (...args) => rpc('metals', 'deleteMetalProduct', ...args); +export const hasMetalAssets = (...args) => rpc('metals', 'hasMetalAssets', ...args); +export const listMetalAssets = (...args) => rpc('metals', 'listMetalAssets', ...args); +export const listMetalProducts = (...args) => rpc('metals', 'listMetalProducts', ...args); +export const listMetalProductsForAsset = (...args) => rpc('metals', 'listMetalProductsForAsset', ...args); +export const metalHoldings = (...args) => rpc('metals', 'metalHoldings', ...args); +export const updateMetalProduct = (...args) => rpc('metals', 'updateMetalProduct', ...args); diff --git a/client/data/notifications.js b/client/data/notifications.js new file mode 100644 index 0000000..355ce41 --- /dev/null +++ b/client/data/notifications.js @@ -0,0 +1,8 @@ +// Generated, do not edit. sha256:05a6b3f4ff4f47c46307c9ac80ccc27088deb87eaaf1db6f2988deb654616faa +import { rpc } from './_rpc.js'; + +export const dismissNotification = (...args) => rpc('notifications', 'dismissNotification', ...args); +export const listActiveNotifications = (...args) => rpc('notifications', 'listActiveNotifications', ...args); +export const notificationDetail = (...args) => rpc('notifications', 'notificationDetail', ...args); +export const currentNotifications = (...args) => rpc('notifications', 'currentNotifications', ...args); +export const refreshNotifications = (...args) => rpc('notifications', 'refreshNotifications', ...args); diff --git a/client/data/onboarding.js b/client/data/onboarding.js new file mode 100644 index 0000000..fedd6a1 --- /dev/null +++ b/client/data/onboarding.js @@ -0,0 +1,5 @@ +// Generated, do not edit. sha256:191f1aef7a5233f2e6d621a68c738dece7c6834a3df68adccdd082cdcc7521b7 +import { rpc } from './_rpc.js'; + +export const completeSetup = (...args) => rpc('onboarding', 'completeSetup', ...args); +export const setupStatus = (...args) => rpc('onboarding', 'setupStatus', ...args); diff --git a/client/data/positions.js b/client/data/positions.js new file mode 100644 index 0000000..72f9490 --- /dev/null +++ b/client/data/positions.js @@ -0,0 +1,13 @@ +// Generated, do not edit. sha256:aadd4a27f8975ab5f4228e6f75932f3b6c20c61c52e3836e29c9ace64bade8ce +import { rpc } from './_rpc.js'; + +export const accountHoldings = (...args) => rpc('positions', 'accountHoldings', ...args); +export const accountPositions =(...args) => rpc('positions', 'accountPositions', ...args); +export const assetPositionsByAccount = (...args) => rpc('positions', 'assetPositionsByAccount', ...args); +export const firstPositionDate = (...args) => rpc('positions', 'firstPositionDate', ...args); +export const lightningBalanceDiffs = (...args) => rpc('positions', 'lightningBalanceDiffs', ...args); +export const positionAt = (...args) => rpc('positions', 'positionAt', ...args); +export const positionsByAsset = (...args) => rpc('positions', 'positionsByAsset', ...args); +export const recordLightningBalance = (...args) => rpc('positions', 'recordLightningBalance', ...args); +export const unexplainedByPair = (...args) => rpc('positions', 'unexplainedByPair', ...args); +export const unexplainedChange = (...args) => rpc('positions', 'unexplainedChange', ...args); diff --git a/client/data/preferences.js b/client/data/preferences.js new file mode 100644 index 0000000..9d62523 --- /dev/null +++ b/client/data/preferences.js @@ -0,0 +1,35 @@ +// Generated, do not edit. sha256:b4ad8c5f7165b15620e02825794b0dddb66d0170a5147e3133d866b2a23f0721 +import { rpc } from './_rpc.js'; + +export const deletePref = (...args) => rpc('preferences', 'deletePref', ...args); +export const getPref = (...args) => rpc('preferences', 'getPref', ...args); +export const getSettings = (...args) => rpc('preferences', 'getSettings', ...args); +export const setPref = (...args) => rpc('preferences', 'setPref', ...args); +export const updateSettings = (...args) => rpc('preferences', 'updateSettings', ...args); + +export async function tableViewCfg(scope, defaultSort = null, fixedGroup = null) { + const k = `table.${scope}`; + if (fixedGroup) { + const sticky = await getPref(k, 'sticky', {}); + return { + sticky, + onStickyChange: v => setPref(k, 'sticky', v).catch(() => { }), + sort: defaultSort, defaultSort, group: fixedGroup, groupChoice: false, + }; + } + const view = await getPref(k, 'view', + { sort: defaultSort, group: null, group2: null, group3: null, groupFilter: {}, groupCollapsed: {} }); + const sticky = await getPref(k, 'sticky', {}); + return { + sticky, + onStickyChange: v => setPref(k, 'sticky', v).catch(() => { }), + sort: view.sort, + group: view.group, + group2: view.group2 ?? null, + group3: view.group3 ?? null, + groupFilter: view.groupFilter ?? {}, + groupCollapsed: view.groupCollapsed ?? {}, + defaultSort, + onStateChange: v => setPref(k, 'view', v).catch(() => { }), + }; +} diff --git a/client/data/price-history.js b/client/data/price-history.js new file mode 100644 index 0000000..dc444d3 --- /dev/null +++ b/client/data/price-history.js @@ -0,0 +1,21 @@ +// Generated, do not edit. sha256:9c96669397f06ce2f157ce7eee34dbe23fdd2da353c005dbfe8d12b9dd299240 +import { rpc } from './_rpc.js'; + +export const addManualPricePoint = (...args) => rpc('price-history', 'addManualPricePoint', ...args); +export const getPriceAt = (...args) => rpc('price-history', 'getPriceAt', ...args); +export const getPricePoint = (...args) => rpc('price-history', 'getPricePoint', ...args); +export const listAllPriceHistory = (...args) => rpc('price-history', 'listAllPriceHistory', ...args); +export const listPriceGaps = (...args) => rpc('price-history', 'listPriceGaps', ...args); +export const ignorePriceGap = (...args) => rpc('price-history', 'ignorePriceGap', ...args); +export const listIgnoredPriceGaps = (...args) => rpc('price-history', 'listIgnoredPriceGaps', ...args); +export const unignorePriceGap = (...args) => rpc('price-history', 'unignorePriceGap', ...args); +export const latestPricePoint = (...args) => rpc('price-history', 'latestPricePoint', ...args); +export const latestSpotPoint = (...args) => rpc('price-history', 'latestSpotPoint', ...args); +export const listPriceHistory = (...args) => rpc('price-history', 'listPriceHistory', ...args); +export const priceHistoryCount = (...args) => rpc('price-history', 'priceHistoryCount', ...args); +export const recordCurrentSpot = (...args) => rpc('price-history', 'recordCurrentSpot', ...args); +export const upsertPriceHistory = (...args) => rpc('price-history', 'upsertPriceHistory', ...args); +export const recordNoPriceDays = (...args) => rpc('price-history', 'recordNoPriceDays', ...args); +export const listNoPriceDays = (...args) => rpc('price-history', 'listNoPriceDays', ...args); +export const listPriceAbsences = (...args) => rpc('price-history', 'listPriceAbsences', ...args); +export const deletePriceAbsences = (...args) => rpc('price-history', 'deletePriceAbsences', ...args); diff --git a/client/data/price-source.js b/client/data/price-source.js new file mode 100644 index 0000000..5473140 --- /dev/null +++ b/client/data/price-source.js @@ -0,0 +1,9 @@ +// Generated, do not edit. sha256:97443fa6467b58882d3c847667f7ec5feefc100a996ce271a6573e15a4cf3910 +import { rpc } from './_rpc.js'; + +export const clearPriceSourceCell = (...args) => rpc('price-source', 'clearPriceSourceCell', ...args); +export const confirmPriceSourceCell = (...args) => rpc('price-source', 'confirmPriceSourceCell', ...args); +export const listPriceSource = (...args) => rpc('price-source', 'listPriceSource', ...args); +export const ownPriceSourceChain = (...args) => rpc('price-source', 'ownPriceSourceChain', ...args); +export const priceSourceChain = (...args) => rpc('price-source', 'priceSourceChain', ...args); +export const setPriceSourceRank = (...args) => rpc('price-source', 'setPriceSourceRank', ...args); diff --git a/client/data/proposals.js b/client/data/proposals.js new file mode 100644 index 0000000..65341d5 --- /dev/null +++ b/client/data/proposals.js @@ -0,0 +1,15 @@ +// Generated, do not edit. sha256:5e4ef4bc2a2565b1c329aabff8462d3a4406176dfc8e63fc7080facce90cb72f +import { rpc } from './_rpc.js'; + +export const deleteAllPendingProposals = (...args) => rpc('proposals', 'deleteAllPendingProposals', ...args); +export const deleteProposal = (...args) => rpc('proposals', 'deleteProposal', ...args); +export const ignoreAllPendingProposals = (...args) => rpc('proposals', 'ignoreAllPendingProposals', ...args); +export const ignoreProposal = (...args) => rpc('proposals', 'ignoreProposal', ...args); +export const listHandledProposals = (...args) => rpc('proposals', 'listHandledProposals', ...args); +export const listPendingProposals = (...args) => rpc('proposals', 'listPendingProposals', ...args); +export const resolveProposalAsInterest = (...args) => rpc('proposals', 'resolveProposalAsInterest', ...args); +export const resolveProposalAsInternal =(...args) => rpc('proposals', 'resolveProposalAsInternal', ...args); +export const resolveProposalAsTrade = (...args) => rpc('proposals', 'resolveProposalAsTrade', ...args); +export const resolveProposalAsTransfer = (...args) => rpc('proposals', 'resolveProposalAsTransfer', ...args); +export const revertProposal = (...args) => rpc('proposals', 'revertProposal', ...args); +export const syncTxProposals = (...args) => rpc('proposals', 'syncTxProposals', ...args); diff --git a/client/data/providers.js b/client/data/providers.js new file mode 100644 index 0000000..c96f65a --- /dev/null +++ b/client/data/providers.js @@ -0,0 +1,22 @@ +// Generated, do not edit. sha256:72e387af4ed3d4acd30a530057d9d83c411b2f51d88b3b96f79d51dce9ab149c +import { rpc } from './_rpc.js'; + +export const clearProviderHmac = (...args) => rpc('providers', 'clearProviderHmac', ...args); +export const createProvider = (...args) => rpc('providers', 'createProvider', ...args); +export const createProviderEndpoint = (...args) => rpc('providers', 'createProviderEndpoint', ...args); +export const createProviderOutput = (...args) => rpc('providers', 'createProviderOutput', ...args); +export const createProviderSymbol = (...args) => rpc('providers', 'createProviderSymbol', ...args); +export const deleteProvider = (...args) => rpc('providers', 'deleteProvider', ...args); +export const deleteProviderEndpoint = (...args) => rpc('providers', 'deleteProviderEndpoint', ...args); +export const deleteProviderOutput = (...args) => rpc('providers', 'deleteProviderOutput', ...args); +export const deleteProviderSymbol = (...args) => rpc('providers', 'deleteProviderSymbol', ...args); +export const getProvider = (...args) => rpc('providers', 'getProvider', ...args); +export const listProviders = (...args) => rpc('providers', 'listProviders', ...args); +export const providerCapabilityMap = (...args) => rpc('providers', 'providerCapabilityMap', ...args); +export const setProviderActive = (...args) => rpc('providers', 'setProviderActive', ...args); +export const setProviderCallLimit = (...args) => rpc('providers', 'setProviderCallLimit', ...args); +export const setProviderHmac = (...args) => rpc('providers', 'setProviderHmac', ...args); +export const updateProvider = (...args) => rpc('providers', 'updateProvider', ...args); +export const updateProviderEndpoint = (...args) => rpc('providers', 'updateProviderEndpoint', ...args); +export const updateProviderOutput = (...args) => rpc('providers', 'updateProviderOutput', ...args); +export const updateProviderSymbol = (...args) => rpc('providers', 'updateProviderSymbol', ...args); diff --git a/client/data/reservations.js b/client/data/reservations.js new file mode 100644 index 0000000..3898c8b --- /dev/null +++ b/client/data/reservations.js @@ -0,0 +1,10 @@ +// Generated, do not edit. sha256:d4dd5293de13bcb4d450a08d8edd7724a2cb3ee1a5ec520a57a9d5a196dc7d80 +import { rpc } from './_rpc.js'; + +export const bookReservation = (...args) => rpc('reservations', 'bookReservation', ...args); +export const createReservation = (...args) => rpc('reservations', 'createReservation', ...args); +export const deleteReservation = (...args) => rpc('reservations', 'deleteReservation', ...args); +export const listDueReservations = (...args) => rpc('reservations', 'listDueReservations', ...args); +export const listReservations = (...args) => rpc('reservations', 'listReservations', ...args); +export const listReservationsForAccount = (...args) => rpc('reservations', 'listReservationsForAccount', ...args); +export const updateReservation = (...args) => rpc('reservations', 'updateReservation', ...args); diff --git a/client/data/scan-logs.js b/client/data/scan-logs.js new file mode 100644 index 0000000..5b9a433 --- /dev/null +++ b/client/data/scan-logs.js @@ -0,0 +1,6 @@ +// Generated, do not edit. sha256:2f7e0226173eb1df58f29ec0390b99a4181cee3c0805900be698ecb645960d1a +import { rpc } from './_rpc.js'; + +export const createScanLog = (...args) => rpc('scan-logs', 'createScanLog', ...args); +export const latestScanLog = (...args) => rpc('scan-logs', 'latestScanLog', ...args); +export const listScanLogs = (...args) => rpc('scan-logs', 'listScanLogs', ...args); diff --git a/client/data/support.js b/client/data/support.js new file mode 100644 index 0000000..9363e8c --- /dev/null +++ b/client/data/support.js @@ -0,0 +1,7 @@ +// Generated, do not edit. sha256:53e150ac657ad243c1234b99306c5a7b09f6eb4fcb4a6de8c2d3d374da23ed45 +import { rpc } from './_rpc.js'; + +export const supportInfo = (...args) => rpc('support', 'supportInfo', ...args); +export const supportMessages = (...args) => rpc('support', 'supportMessages', ...args); +export const sendSupport = (...args) => rpc('support', 'sendSupport', ...args); +export const supportAttachment = (...args) => rpc('support', 'supportAttachment', ...args); diff --git a/client/data/table-viewer.js b/client/data/table-viewer.js new file mode 100644 index 0000000..02621e8 --- /dev/null +++ b/client/data/table-viewer.js @@ -0,0 +1,7 @@ +// Generated, do not edit. sha256:c0d23ea37ddc6695c69eb1b6531c8b951887f5c30452dd318610fcfcb868477c +import { rpc } from './_rpc.js'; + +export const clearTable = (...args) => rpc('table-viewer', 'clearTable', ...args); +export const deleteTableRow = (...args) => rpc('table-viewer', 'deleteTableRow', ...args); +export const listTableRaw = (...args) => rpc('table-viewer', 'listTableRaw', ...args); +export const tableColumns = (...args) => rpc('table-viewer', 'tableColumns', ...args); diff --git a/client/data/tx-links.js b/client/data/tx-links.js new file mode 100644 index 0000000..70d0af6 --- /dev/null +++ b/client/data/tx-links.js @@ -0,0 +1,13 @@ +// Generated, do not edit. sha256:2be827f5b7855361ec06bd60ca60c5d976f355c3f8bb57e1356ee04e368af34b +import { rpc } from './_rpc.js'; + +export const eventCandidatesForTx = (...args) => rpc('tx-links', 'eventCandidatesForTx', ...args); +export const eventLinkMap = (...args) => rpc('tx-links', 'eventLinkMap', ...args); +export const linkTxEvent = (...args) => rpc('tx-links', 'linkTxEvent', ...args); +export const linksForEvent = (...args) => rpc('tx-links', 'linksForEvent', ...args); +export const linksForTx = (...args) => rpc('tx-links', 'linksForTx', ...args); +export const onchainAsset = (...args) => rpc('tx-links', 'onchainAsset', ...args); +export const syncAddressLinks = (...args) => rpc('tx-links', 'syncAddressLinks', ...args); +export const txCandidatesForEvent = (...args) => rpc('tx-links', 'txCandidatesForEvent', ...args); +export const unlinkTxEvent = (...args) => rpc('tx-links', 'unlinkTxEvent', ...args); +export const walletTxEventMap = (...args) => rpc('tx-links', 'walletTxEventMap', ...args); diff --git a/client/data/valuations.js b/client/data/valuations.js new file mode 100644 index 0000000..ea1b3b5 --- /dev/null +++ b/client/data/valuations.js @@ -0,0 +1,13 @@ +// Generated, do not edit. sha256:b8cbf600ab36d03c83b239c9ff1dd49bbb558bd66a0c9ffc89f3e59150f41929 +import { rpc } from './_rpc.js'; + +export const acceptValuationGap = (...args) => rpc('valuations', 'acceptValuationGap', ...args); +export const createValuation = (...args) => rpc('valuations', 'createValuation', ...args); +export const deleteValuation = (...args) => rpc('valuations', 'deleteValuation', ...args); +export const listOpenValuationDiffs = (...args) => rpc('valuations', 'listOpenValuationDiffs', ...args); +export const listValuations = (...args) => rpc('valuations', 'listValuations', ...args); +export const updateValuation = (...args) => rpc('valuations', 'updateValuation', ...args); +export const valuationDiff = (...args) => rpc('valuations', 'valuationDiff', ...args); +export const valuationGap = (...args) => rpc('valuations', 'valuationGap', ...args); +export const valuationDiffs = (...args) => rpc('valuations', 'valuationDiffs', ...args); +export const latestAnchors = (...args) => rpc('valuations', 'latestAnchors', ...args); diff --git a/client/data/wallet-addresses.js b/client/data/wallet-addresses.js new file mode 100644 index 0000000..03f9bbc --- /dev/null +++ b/client/data/wallet-addresses.js @@ -0,0 +1,7 @@ +// Generated, do not edit. sha256:4729896ea697d903a66a55b2e3c47015d5d91f3d507871983d51e85d1dfe5bc2 +import { rpc } from './_rpc.js'; + +export const accountOnchainBalance = (...args) => rpc('wallet-addresses', 'accountOnchainBalance', ...args); +export const listWalletAddresses = (...args) => rpc('wallet-addresses', 'listWalletAddresses', ...args); +export const sourceBalance = (...args) => rpc('wallet-addresses', 'sourceBalance', ...args); +export const upsertWalletAddresses = (...args) => rpc('wallet-addresses', 'upsertWalletAddresses', ...args); diff --git a/client/data/wallet-sources.js b/client/data/wallet-sources.js new file mode 100644 index 0000000..c158c8e --- /dev/null +++ b/client/data/wallet-sources.js @@ -0,0 +1,9 @@ +// Generated, do not edit. sha256:10c2762ed4edc728c9bb085ae65408b05698da3cb759b6e0d83ec573a6f31da5 +import { rpc } from './_rpc.js'; + +export const createWalletSource = (...args) => rpc('wallet-sources', 'createWalletSource', ...args); +export const deleteWalletSource = (...args) => rpc('wallet-sources', 'deleteWalletSource', ...args); +export const listAllWalletSources = (...args) => rpc('wallet-sources', 'listAllWalletSources', ...args); +export const listWalletSources = (...args) => rpc('wallet-sources', 'listWalletSources', ...args); +export const recordSourceBalance = (...args) => rpc('wallet-sources', 'recordSourceBalance', ...args); +export const updateWalletSource = (...args) => rpc('wallet-sources', 'updateWalletSource', ...args); diff --git a/client/data/wallet-txs.js b/client/data/wallet-txs.js new file mode 100644 index 0000000..e54b458 --- /dev/null +++ b/client/data/wallet-txs.js @@ -0,0 +1,7 @@ +// Generated, do not edit. sha256:0e229496c38eb1f378e94fa6b93fdbc8268a8edc7a91fbc647e628f80ad40ad5 +import { rpc } from './_rpc.js'; + +export const labelChannelTxs = (...args) => rpc('wallet-txs', 'labelChannelTxs', ...args); +export const listSourceTxs = (...args) => rpc('wallet-txs', 'listSourceTxs', ...args); +export const setTxLabels = (...args) => rpc('wallet-txs', 'setTxLabels', ...args); +export const upsertWalletTxs = (...args) => rpc('wallet-txs', 'upsertWalletTxs', ...args); diff --git a/client/edition.js b/client/edition.js new file mode 100644 index 0000000..5e8c66f --- /dev/null +++ b/client/edition.js @@ -0,0 +1 @@ +// Generated, do not edit. sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 diff --git a/client/i18n/en.js b/client/i18n/en.js new file mode 100644 index 0000000..d8f35de --- /dev/null +++ b/client/i18n/en.js @@ -0,0 +1,2466 @@ +// Generated, do not edit. sha256:13240cf3e3de47963beb3e5b97f3a5cb83beb4a9a635ce540f88a745df4d753d +import { nl } from './nl.js'; + +export const en = { + app: { name: 'Atreides Assets' }, + + common: { + loading: 'Loading…', + loadError: 'Cannot load data', + manageAssets: 'Manage assets', + sessionExpired: 'The session has expired. You are being sent to the sign-in screen.', + backendSilent: 'The backend does not respond within {s} seconds. Is the server process running?', + requestTooLarge: 'The request is too large.', + fileTooLarge: 'The file is too large.', + invalidAmount: 'Invalid amount.', + }, + + splash: { + quote: nl.splash.quote, + logoHint: 'Show the splash screen', + }, + + attention: { + title: 'Needs attention', + none: 'Nothing needs attention', + integrationNoKeySuffix: ': API key required, not yet entered', + priceGaps: 'Price history: {n} gaps to fill', + usageNear: '{naam}: {calls} of {limiet} calls this month', + usageOver: '{naam}: monthly limit reached ({calls} of {limiet})', + actionsReconcilePrefix: 'Reconcile: ', + actionsReconcileSuffix: ' open', + actionsInterestPrefix: 'Interest to record: ', + actionsValuationPrefix: 'Valuation differences: ', + actionsAiPrefix: 'AI proposals: ', + actionsReservationPrefix: 'Reservations to settle: ', + healthPrefix: 'Findings about your records: ', + backupNever: 'No backup made yet', + backupStalePrefix: 'Last backup ', + backupStaleSuffix: ' days ago', + healthNever: 'Records never checked', + healthStalePrefix: 'Last check ', + healthStaleSuffix: ' days ago', + aiActivityPrefix: 'AI activity: ', + aiActivitySuffix: ' new call(s)', + licenseChanged: 'The license has changed', + }, + + commands: { + goingTo: 'An AI is taking you to', + stop: 'Stop', + goNow: 'Go now', + goAlways: 'Always go now', + movedTo: 'An AI took you to', + countdownBack: 'Count down anyway', + stopped: 'Stopped by the user.', + badTarget: 'The command did not point to a valid place in the app.', + tooLate: 'The command had almost expired when this tab picked it up; no navigation took place.', + unknownKind: 'This tab does not know this kind of command.', + }, + + license: { + lockedTitle: 'Part of the paid version', + counter: '{n} of {max}', + whatIsPaid: 'What does the paid version include?', + why_develop: 'Development version: nothing is restricted below 1.0.0', + why_forced: 'Forced via ASSETAPP_LICENSE_STATE (development only)', + 'why_forced-develop': 'Forced via ASSETAPP_LICENSE_STATE (development only)', + why_none: 'No license file found', + why_invalid: 'The license file is not valid', + why_unreadable: 'The license file cannot be read', + 'why_out-of-range': 'The license does not cover this major version', + 'why_unknown-version': 'The app version cannot be determined', + why_licensed: 'Valid license', + cardTitle: 'License', + cardFree: 'This installation runs the free version: no time limit, with a small set of records and ' + + 'without the connections, the source choice and the taxes page. Reading and exporting always ' + + 'keep working; nothing is ever removed.', + cardBuy: 'The paid version lifts those limits. If you have a license file, drop it below.', + cardFull: 'This installation runs the full version. All limits are lifted and every feature is ' + + 'available.', + cardDevelop: 'This is a development version: below 1.0.0 the app restricts nothing.', + cardFacts: 'The license is for {buyer}, issued on {issued} and valid for major versions {from} ' + + 'through {to}.', + cardOutOfRange: 'This app is version {version} and falls outside that range.', + cardProblem: 'There is a license file, but the app cannot use it.', + cardVersion: 'Version {version}', + signInTitle: 'Sign in', + signInIntro: 'Your license belongs to a nostr key. Sign in to fetch the licenses for that key.', + signInLast: 'Last signed in as {npub}.', + signInExtension: 'With extension', + signInExtensionNone: 'No nostr extension found in this browser (such as Alby or nos2x).', + signInKey: 'With key', + signInNew: 'New key', + signInKeyLabel: 'Secret key (nsec)', + signInKeyHint: 'The key stays in this tab and is not stored anywhere. After a reload you sign in ' + + 'again.', + signInGo: 'Sign in', + signInBadKey: 'This is not a valid secret key. An nsec starts with nsec1.', + signInNpub: 'This is the public key (npub). Signing in requires the secret key (nsec).', + newKeyTitle: 'Your new key', + newKeyIntro: 'Keep this secret key safe. Whoever has it can fetch your license; if you lose it, you ' + + 'can no longer do so. The app does not store it.', + newKeyPublic: 'Public key: {npub}', + newKeyCopy: 'Copy', + newKeyCopied: 'Copied', + newKeyDownload: 'Download', + newKeySaved: 'I have saved this key', + newKeyGo: 'Continue', + newKeyCancel: 'Cancel', + newKeyFileName: 'nostr-key.txt', + signedIn: 'Signed in as {npub}.', + signedInExtension: 'Signed in as {npub}, with extension.', + signOut: 'Sign out', + otherBuyer: 'The installed license belongs to a different key.', + licensesLoading: 'Fetching licenses…', + licensesNone: 'There are no licenses for this key yet.', + licenseLine: '{level}, major versions {from} through {to}, issued on {issued}.', + level_full: 'Full version', + level_free: 'Free version', + licenseInstalled: 'Installed', + licenseInstall: 'Install', + licenseNotValid: 'This app does not accept this license.', + licenseNotCovered: 'Does not cover this version.', + buyTitle: 'Buy', + buyPrice: 'A full license for major version {major} costs {sats} sats.', + buyClosed: 'Sales are closed at the moment.', + buyAway: 'Atreides Services cannot be reached right now, so buying is not possible at the moment. ' + + 'Try again later.', + buySignInFirst: 'Sign in first: the license is issued to your key.', + buyButton: 'Buy', + buyInvoice: 'Pay this invoice with a Lightning wallet. It is valid until {time}.', + buyOpenWallet: 'Open in wallet', + buyQr: 'QR code of the invoice, to scan with a wallet', + buyCopy: 'Copy invoice', + buyCopied: 'Copied', + buyWaiting: 'Waiting for payment…', + buyPaid: 'Paid. The license is being issued…', + buyWaived: 'Waived. The license is being issued…', + buyExpired: 'The invoice expired without payment.', + buyCanceled: 'The invoice was canceled.', + buyUnderpaid: 'Less than the amount was paid; contact the seller.', + buyAgain: 'New order', + buyNotValid: 'The license was issued, but this app does not accept it. Contact the seller.', + supportTitle: 'Support', + supportIntro: 'A conversation with the seller, via Atreides Services. Your messages are sent from your ' + + 'signed-in key, and you read the reply here. You can attach a screenshot.', + supportAway: 'Atreides Services cannot be reached right now, so support is not available at the ' + + 'moment. Try again later.', + supportNostr: 'Prefer nostr? Send a direct message to {name}.', + supportLabel: 'Your message', + supportPlaceholder: 'Type a message…', + supportSend: 'Send', + supportSending: 'Sending…', + supportEmpty: 'Type a message or choose a screenshot first.', + supportTooLong: 'A message is at most {max} characters.', + supportAttach: 'Screenshot', + supportAttachHint: 'PNG, JPEG or WebP up to {mb} MB; pasting also works.', + supportAttachType: 'A screenshot is a PNG, JPEG or WebP image.', + supportAttachTooLarge: 'A screenshot is at most {mb} MB.', + supportRemoveAttachment: 'Remove screenshot', + supportScreenshot: 'Screenshot', + supportShowScreenshot: 'Show screenshot', + supportRefresh: 'Refresh', + supportThreadLoading: 'Fetching conversation…', + supportThreadEmpty: 'No messages yet. Ask your question below.', + supportYou: 'You', + supportSeller: 'Support', + supportAi: 'Support, drafted with the help of AI', + payTitle: 'Pay with Lightning', + payIntro: "You pay with a Lightning invoice created by the seller's service; the app does not see your " + + 'wallet. For that you need a wallet with sats.', + payGroup_easy: 'The easiest way, for small amounts', + payGroup_other: 'Other apps', + payApp_phoenix: 'is a Lightning wallet on your phone that runs its own node; the app manages the ' + + 'channel for you. You use it to scan the QR code of the invoice.', + payApp_pocket: 'converts a transfer from your bank account into sats and sends them via Lightning to ' + + 'Phoenix. One-time ID check.', + payApp_strike: 'pays an invoice directly from the app, with money from your bank account. Strike holds ' + + 'the balance and asks for ID at sign-up.', + payApp_zeus: 'runs LND on your phone or connects to your own node, such as one on an Umbrel; you manage ' + + 'channels and funding yourself.', + payApp_bringin: 'is a Lightning wallet with your own keys, and gives you your own IBAN to buy sats (with ' + + 'ID check).', + payNote: 'As of October 2026: check the terms with the provider.', + nostrMore: 'About nostr and your key', + signInKnown: 'Your license is on {npub}. Sign with that key to fetch licenses, buy or ask for ' + + 'support; the npub alone does not prove it is you.', + infoNostrHead: 'What is nostr?', + infoNostr: 'Nostr is an open protocol in which your identity is a key pair. The public key (npub) is ' + + 'like an address: anyone may see it. The secret key (nsec) is like a password that no one can ' + + 'recover for you: whoever has it is you.', + infoWhyHead: 'Why is the license tied to a key?', + infoWhy: 'No account and no email address are needed. The license is signed to your npub and the app ' + + 'verifies it without internet. With the same key you get it back on any installation, even after ' + + 'a reinstall.', + infoSignInHead: 'How do you sign in?', + infoSignIn: 'With a browser extension such as Alby or nos2x, the key stays in the extension and the app ' + + 'never sees it. With the key itself, it stays in this tab and is not stored anywhere. If you do ' + + 'not have a key yet, create one here and keep it safe.', + infoZapHead: 'And a zap?', + infoZap: 'A zap (NIP-57) is a Lightning payment with a nostr receipt attached, meant as a tip under a ' + + 'post. The license does not need one: a regular invoice is enough, and the license is already ' + + 'on your key.', + link_nostrHow: 'nostr.how: what nostr is, for beginners', + link_nostrOrg: 'nostr.org: introduction and apps (by LNbits)', + link_alby: 'Alby: browser extension with nostr and Lightning', + link_nos2x: 'nos2x: browser extension that only signs', + link_lightning: 'lightning.network: the Lightning Network', + infoPrivacyHead: 'What does the seller know about you?', + infoPrivacy: 'Your npub and the orders with it. An email address only if you provide one, and then ' + + 'made unrecognizable, so it can only be matched if you come forward with it yourself.', + name_fiatAccounts: 'Fiat accounts', + featTitle: 'What the editions include', + featFull: 'Full', + featFree: 'Free', + featFreeOpen: 'What the free edition includes is not settled yet.', + featNoShot: 'No screenshot of this feature yet.', + featIncluded: 'Included', + featExcluded: 'Not included', + featUndecided: 'Not decided yet', + featPartial: 'partly', + featPartialTitle: 'Part of it is included', + featGroup_overview: 'Overview and reports', + featGroup_bitcoin: 'Bitcoin and Lightning', + featGroup_records: 'Records', + featGroup_prices: 'Prices', + featGroup_app: 'The app', + feat_dashboard: 'Dashboard with your own widgets', + feat_wealth: 'Net worth per asset and account', + feat_returns: 'Return per period', + feat_taxes: 'Taxes per reference date', + feat_tracking: 'On-chain wallet tracking', + feat_lightning: 'Lightning via LND and NWC', + feat_interest: 'Interest and rewards', + feat_balances: 'Balances and reconcile', + feat_ownMempool: 'Your own mempool node', + feat_priceHistory: 'Price history', + feat_sourceChoice: 'Source choice per asset', + feat_providerKeys: 'Your own price sources and API keys', + feat_signals: 'Bitcoin signals, charts and widget', + feat_netshare: 'Network share widget', + feat_portfolios: 'Multiple portfolios', + feat_ai: 'AI connection (MCP)', + feat_import: 'Import and export', + feat_data: 'Data: the raw tables', + feat_health: 'Health: checks on your records', + feat_kiosk: 'The app in its own window', + feat_metals: 'Precious metals, coins and bars', + unlimited: 'unlimited', + dropHere: 'Drag the license file here', + dropOr: 'or click to choose one', + dropTooBig: 'That file is far too large for a license. The license file is a small JSON file of a ' + + 'few lines at most.', + installOk: 'License saved. The app is now the full version.', + installOkFuture: 'License saved, but it does not cover this major version. The app stays in the free ' + + 'version for now.', + installUnreadable: 'This is not a valid license file: {reason}', + installBadSignature: 'The signature does not match this license, or it was not issued for this app. ' + + 'Nothing was saved.', + accountsWhat: 'The free version is limited to one fiat account. With a license you enter all your ' + + 'bank and savings accounts, interest included.', + matrixWhat: 'The source choice matrix is part of the paid version. With it you decide per asset ' + + 'which source takes precedence and what the app falls back on; without that choice it uses the ' + + 'default sources.', + aiWhat: 'The AI integration is part of the paid version. With it an AI assistant can read your records ' + + 'and make proposals, with a log of everything that was shared.', + keysWhat: 'Your own provider API keys are part of the paid version. The default sources work without ' + + 'an API key; your own subscription with a price source is entered here.', + taxWhat: 'The taxes page shows your net worth on each reference date and the gain/loss per year, with ' + + 'bank and other assets separate, and shows per item where the number comes from.', + }, + + connections: { + lightningPaid: 'Lightning connections are part of the paid version.', + ownExplorerPaid: 'Your own explorer is part of the paid version; the default source keeps working.', + kindBtcExplorer: 'Bitcoin Blockchain Explorer', + usedByBtcExplorer: 'Used by: wallet tracking (Tracking), and the price source "mempool" (Providers) if there is one.', + newProfile: 'New profile', + editProfile: 'Edit profile', + name: 'Name', + url: 'Base URL', + save: 'Save', + test: 'Test', + edit: 'Edit', + delete: 'Delete', + active: 'Active', + notTested: 'Not tested', + testing: 'Testing…', + statusOk: 'Connected', + statusFail: 'No connection', + blockHeight: 'block height', + lastChecked: 'Last tested:', + badResponse: 'Unexpected response from the server.', + testUnsupported: 'Testing this kind of connection is not supported yet.', + cspBlocked: 'This address was added after the page loaded, and the browser only allows it after a ' + + 'reload. Reload the page and test again.', + confirmDelete: 'Delete this profile?', + kindLnd: 'Lightning node (LND)', + usedByLnd: 'Used by: the Lightning tracker (coming in B3). The app talks to the node through its own backend: the node is not public; macaroon and tls.cert stay server-side.', + lndUrl: 'LND REST URL', + lndMacaroon: 'Macaroon (hex or base64; read-only baked recommended)', + lndTlsCert: 'tls.cert (PEM; optional: with cert pinned and verified, without cert unverified https)', + secretKeepHint: 'leave empty = unchanged', + lndSynced: 'synced', + lndNotSynced: 'NOT synced', + noActive: 'No active {kind} connection. Choose one at System → Integrations → Connections.', + nameRequired: 'Name is required.', + urlScheme: 'A URL starts with http(s):// or ws(s)://.', + nameInUse: 'A {kind} connection named "{naam}" already exists.', + unknownConnection: 'Connection #{id} does not exist.', + linkedToSources: 'This connection is still linked to {n} tracking source(s); unlink them first.', + macaroonUnknown: 'Macaroon not recognized: paste hex (lncli bakemacaroon) or base64(url), for example the macaroon parameter from an lndconnect URL.', + lndUrlScheme: 'The LND REST URL starts with http:// or https:// (the dialog completes a bare ip:port itself).', + tlsCertPem: 'tls.cert must be a PEM certificate (-----BEGIN CERTIFICATE-----).', + lndUrlMissing: 'The LND REST URL is missing.', + lndNoConfig: 'No LND configuration for this connection: save the URL and the macaroon first.', + lndNoMacaroon: 'No macaroon saved for this connection: edit the profile and paste the macaroon.', + nwcUriInvalid: 'Invalid NWC URI (expected nostr+walletconnect://…?relay=…&secret=…).', + nwcPubkeyInvalid: 'Invalid wallet pubkey in the NWC URI.', + nwcSecretInvalid: 'Invalid secret in the NWC URI.', + nwcRelayInvalid: 'Invalid or missing relay (wss://…) in the NWC URI.', + nwcBadFormat: 'Unexpected NIP-04 format in the response.', + nwcNoUri: 'No NWC URI saved for this source. Edit the source and paste the connection URI.', + lndWantsHttps: 'The node expects https on this port: paste the LND tls.cert into the profile (the app then chooses https with pinning), or type https:// before the address.', + lndTimeout: 'LND timeout ({s}s): the node cannot be reached. Is the (WireGuard) tunnel on?', + lndUnreachable: 'LND cannot be reached: {reden}.', + lndUnreachableCert: 'LND cannot be reached: {reden}. The pinned tls.cert does not match this node: paste the current tls.cert into the profile.', + nwcRelayRefused: 'The relay refuses the request: {reden}.', + nwcRelayRefusedNoReason: 'The relay refuses the request and does not say why.', + nwcDecryptFailed: 'Decrypting the response failed: {reden}', + kindMarket: 'Atreides Services', + usedByMarket: "The app's own price service, for prices, the license and support. Address and token apply to the whole installation, so to every portfolio. The app talks to the service through its own server; the token stays server-side and never comes back to the browser.", + marketConfigure: 'Configure', + marketDialogTitle: 'Configure Atreides Services', + marketTokenUnset: 'no token', + marketTokenLicense: 'token from the license ({tier})', + marketTokenManual: 'custom token', + marketTokenPending: 'the app requests a token with the license', + marketTokenNoLicense: 'no license to request a token with', + marketOverrideUrl: 'development address', + marketTestOk: '{app} {version} · {n} instruments', + marketSpot: 'What the app fetches from the service', + marketSpot_off: 'Only the intraday price line; prices and daily prices from your own sources', + marketSpot_fallback: 'Also prices and daily prices, if your own sources return nothing', + marketSpot_first: 'Also prices and daily prices, before your own sources; a source you chose yourself takes precedence', + marketSpotShort_off: 'service: intraday line only', + marketSpotShort_fallback: 'service: as fallback', + marketSpotShort_first: 'service: first', + }, + + marketService: { + urlScheme: 'The service address starts with http:// or https://.', + noToken: 'There is no token for Atreides Services, and no license to request one with.', + unreachable: 'Atreides Services cannot be reached at {url}: {reason}. The app continues with the prices it already has.', + timeout: 'Atreides Services does not respond within {s}s. Try again later.', + unauthorized: 'Atreides Services rejects the token, even after the app requested a new one with the license.', + signatureRefused: 'Atreides Services rejects the signature: {reason}', + salesClosed: 'Nothing is for sale at the moment.', + checkoutUnavailable: 'The Atreides Services checkout cannot create an invoice right now. Try again later.', + tooManyOrders: 'Atreides Services is not accepting more orders for this key right now: {reason}', + unknownOrder: 'Atreides Services does not know this order (anymore).', + licenseRevoked: 'Atreides Services has revoked this license and no longer issues a token for it.', + badLicense: 'Atreides Services does not accept this license: {reason}', + supportEmpty: 'A message has text or a screenshot.', + supportTooLong: 'A message is at most {max} characters.', + supportAttachmentType: 'Atreides Services does not accept this screenshot: only a PNG, JPEG or WebP image.', + supportAttachmentTooLarge: 'A screenshot is at most {mb} MB.', + supportTooMany: 'Too many messages came from this key today. Try again tomorrow.', + unknownAttachment: 'Atreides Services does not know this screenshot.', + tooManyRequests: 'Too many requests to Atreides Services. Try again in {s}s.', + monthUnreachable: 'Atreides Services cannot read month {month} right now (the storage cannot be reached). That is not a gap in the prices: try again later.', + spotUnreachable: 'Atreides Services has the latest price, but cannot read it right now (the storage cannot be reached). Try again later.', + sourceLive: 'Atreides Services ({source})', + sourceCached: 'Atreides Services, last known {when} UTC (the service is not responding)', + unavailable: 'Atreides Services is not ready yet, it may still be starting. Try again later.', + badAnswer: 'The response from {url} is not the Atreides Services API. Is this the API port (3855 on the Umbrel, 8091 locally) and not that of the admin app?', + rejected: 'Atreides Services rejects the request (HTTP {status}, {key}): {reason}', + }, + + nav: { + overview: 'Overview', + manage: 'Manage', + stam: 'Master data', + events: 'Events', + observe: 'Tools', + onchain: 'On-chain', + tax: 'Reports', + tools: 'Bitcoin', + system: 'System', + home: 'Dashboard', + settings: 'Settings', + portfolios: 'Portfolios', + import: 'Import and export', + data: 'Data', + health: 'Health', + ai: 'AI activity', + license: 'License', + integrations: 'Integrations', + connections: 'Connections', + assets: 'Assets', + accounts: 'Accounts', + wealth: 'Wealth', + locations: 'Locations', + labels: 'Labels', + trades: 'Buy & Sell', + transfers: 'Transfers', + valuations: 'Balances', + interest: 'Interest & Rewards', + reservations: 'Reservations', + taxes: 'Taxes', + validation: 'Validation', + tracking: 'Tracking', + conversion: 'Conversion', + 'conversion/bitcoin': 'Conversions', + 'price-history': 'Price history', + charts: 'Signals & Charts', + actions: 'To do', + guide: 'Help', + customize: 'Customize layout', + }, + + dashboard: { + width: 'Width', + remove: 'Remove', + settings: 'Settings', + reorder: 'Drag to reorder', + add: 'Add widget', + secondCurrency: 'Second currency', + secondNone: 'None', + momentum: 'Short trend as dot', + momentumUp: '{pct}% of the last {n} price moves up', + momentumDown: '{pct}% of the last {n} price moves down', + addNoFit: 'Not enough space here for the remaining widgets', + addNone: 'Everything has been added', + profile: 'Layout', + profileNew: 'New layout…', + profileNewPrompt: 'Name of the new layout', + profileRename: 'Rename…', + profileRenamePrompt: 'New name for this layout', + profileDelete: 'Delete this layout…', + profileDeleteConfirm: 'Delete this layout and all its widgets? The other layouts remain.', + profileSystemHint: 'The default layout cannot be renamed.', + profileRestart: 'Start default layout over…', + profileRestartConfirm: 'Start the default layout over? What you placed yourself is removed and the default layout takes its place.', + nameRequired: 'A layout needs a name.', + unknownProfile: 'Layout #{id} does not exist.', + lastNotDeletable: 'The last layout cannot be deleted.', + widget: { + signal: 'Bitcoin signals', + notifications: 'To do', + standing: 'Current position', + spot: 'Price', + metrics: 'Status', + netshare: 'Network share', + clocks: 'World clocks', + return: 'Return', + }, + }, + + clocks: { + showExchanges: 'Show exchanges', + chooseClocks: 'Show clocks', + localLabel: 'My time zone', + hoverLocal: 'Your time zone', + hoverSame: 'Same as your time zone', + listHead: 'Exchanges · in your time', + opensAt: 'Opens at {tijd}', + opensOn: 'Opens {dag} at {tijd}', + openUntil: 'Open until {tijd}', + opensIn: 'in {duur}', + closesIn: 'closes in {duur}', + durMin: '{n} min', + durHourMin: '{n} h {m} min', + durHours: '{n} h', + durDay: '1 day', + durDays: '{n} days', + }, + + netshare: { + noOnchain: 'no on-chain asset', + zero: 'No bitcoin in view yet, every sat is a start.', + tier: { shrimp: 'Shrimp', crab: 'Crab', octopus: 'Octopus', fish: 'Fish', dolphin: 'Dolphin', shark: 'Shark', whale: 'Whale', humpback: 'Humpback' }, + quotesUnder: [ + 'I officially own **{X} billionths** of all the Bitcoin that will ever exist. My savings account can pack up and go.', + '**{X} billionths** of the Bitcoin universe are mine. Scientifically a speck of dust, emotionally a fortune.', + 'With my share of **{X} billionths** I own a tiny piece of the future of money. Give it a while and it buys me a cheese sandwich.', + 'Share of the Bitcoin network: **{X} billionths**. Share of my ego about it: 100%.', + 'Technically I am now a Bitcoin stakeholder. Practically I own **{X} billionths** of a pizza purchase from 2010.', + 'I own **{X} billionths** of all Bitcoin. A small start, and a big story to brag about.', + 'Statistically negligible, emotionally priceless: **{X} billionths** of 21 million coins.', + 'I own a fraction so small my calculator rounds it away, but my wallet app does not. **{X} billionths**, and nobody takes that from me.', + 'Split all Bitcoin into a billion pieces and I hold **{X}** of them. Split it further and I am still proud.', + 'My share of the network is **{X} billionths**. My share of the hype around it: considerably bigger.', + '**{X} billionths** of Bitcoin are mine. Enough to brag about, too little to buy anything with.', + 'I co-own **{X} billionths** of digital gold. The rest of the gold, sadly, belongs to someone else.', + 'Nano investor reporting: **{X} billionths** of the total supply, and proud as a peacock.', + 'Small share, big story: **{X} billionths** of all Bitcoin ever.', + 'I have less Bitcoin than there are stars visible to the naked eye, but I do own **{X} billionths** of the network. Progress is progress.', + ], + quotesOver: [ + 'I own a **whole number** of Bitcoin. In a world of decimals I am a whole coin. And people may know it.', + 'Holding **{Y}** of the network, I can officially say: I have a piece of the 21 million. Not a crumb, a bite.', + '1 BTC or more: the moment your wallet app starts taking you seriously.', + 'My share: **{Y}**. Small on the scale of the universe, big on the scale of my friends without Bitcoin.', + 'I am past the magic line of 1 BTC. I can keep saying so for the rest of my life.', + '**{Y}** of all the Bitcoin that will ever exist is mine. That number never grows again. My pride does.', + 'From "billionths" to "a whole coin", an upgrade no smartphone can match.', + 'Share of the network: **{Y}**. Share of telling this fact at parties: 100%.', + 'I no longer have to say "not even 1 BTC". I just say "1 BTC". A short sentence, a big feeling.', + '**{Y}** of Bitcoin is mine. Scarce, finite, and forever a good conversation starter.', + 'From shrimp to something bigger on the Bitcoin ladder. My wallet is evolving.', + 'I own **{Y}** of an asset that will never exceed 21 million. Rarer than my patience for explaining this at birthday parties.', + ], + }, + + sysmetrics: { + db: 'Database', + calls: 'Calls', + errors: 'Errors', + window: 'window', + }, + + range: { + cycle: 'Other period', + pointCarried: '{bron}, price of {dag}', + pointVia: '{name} ({source})', + day: 'D', + week: 'W', + month: 'M', + year: 'Y', + all: 'A', + }, + + spot: { + noHistory: 'no history', + unknownAsset: 'unknown asset', + }, + + filter: { + all: 'All', + none: 'None', + }, + + actions: { + title: 'To do', + interestTitle: 'Interest to record', + valuationTitle: 'Valuation differences', + openTitle: 'Still to do', + openOne: 'item open', + openMany: 'items open', + openNone: 'Nothing open', + }, + + appWindow: { + close: 'Close', + confirmTitle: 'Close app', + confirmClose: 'Close the app and this window? Reopen it with start-app.', + closeUnavailable: 'Closing is only possible on this computer, in the window the app opened itself.', + }, + guide: { + title: 'Help', + goTo: 'Go to {plek}', + related: 'See also', + newTab: 'Opens in a new tab', + figures: { + period: 'Selected period', + sinceBuy: 'Unrealized and Invested: since the purchase', + inPeriod: 'Return, Gain/loss, Realized', + purchase: 'Purchase', + yearStart: 'January 1', + now: 'Now', + cost: 'Cost basis 191.20', + broughtIn: 'Contributed (net) 154.92', + value: 'Now: 186.43', + unrealized: 'Unrealized -4.77', + result: 'Gain/loss', + resultAmount: '31.51', + purchaseExchange: 'Purchase (exchange)', + transfer: 'Transfer', + legendValue: 'Value', + legendBroughtIn: 'Contributed (net)', + legendCost: 'Cost basis of the asset', + roughly: 'is roughly', + unrealizedPart: 'Unrealized', + realized: 'Realized', + interest: 'Interest', + }, + }, + aiProposals: { + title: 'Proposals from the AI connection', + intro: 'The AI connection proposed this. Nothing has changed yet; the app carries it out only when ' + + 'you approve.', + kindPricePoint: 'Record price', + kindAccount: 'New account', + kindAccountDelete: 'Delete account', + kindValuation: 'Balance', + kindPortfolio: 'New portfolio', + portfolioIn: 'in', + portfolioNote: 'Empty, and without API keys or data from these records. It is not opened: ' + + 'you switch yourself on System → Portfolios, and that restarts the app.', + kindPreference: 'Change setting', + preferenceNote: 'This setting applies to this portfolio and only changes the display or ' + + 'an interval, not the records themselves.', + preferenceCurrent: 'now', + preferenceOn: 'on', + preferenceOff: 'off', + kindSourceChoice: 'Source choice', + sourceFirst: 'first', + sourceRemove: 'remove', + sourceChoiceNote: 'You can see the chain as it stands now on Integrations, Source choice, or on the Source tab of Price history.', + unknownKind: 'Unknown kind of proposal', + ledger: 'ledger', + diff: 'difference', + openingBalance: 'This becomes an OPENING BALANCE: there is no entry before this date, so the app ' + + 'will show no difference after it. Check the balance extra carefully.', + irreversible: 'Irreversible. Not included in "Approve all".', + acceptDelete: 'Delete', + acceptDeleteConfirm: 'Permanently delete this account? This cannot be undone. ' + + 'If it has entries, balances or scans, the app refuses.', + bulkSkips: '{n} proposal(s) delete something and are skipped by "Approve all". ' + + 'You approve those separately.', + bulkNothing: 'Nothing open can be approved in one go. What is there deletes ' + + 'something and needs a separate confirmation.', + ownCustody: 'self-custody', + thirdParty: 'with a third party', + fee: 'fees', + perUnit: 'per', + accept: 'Approve', + reject: 'Reject', + acceptAll: 'Approve all', + rejectAll: 'Reject all', + acceptAllConfirm: 'Approve and carry out {n} proposals?', + rejectAllConfirm: 'Reject {n} proposals? They disappear from this list and are not carried out.', + rejectedByUser: 'Rejected on To do', + failed: 'Previous attempt failed', + partialTitle: 'Not everything succeeded', + partialBody: 'The rest was handled. These remain:', + unknownProposal: 'Proposal {id} no longer exists.', + handled_accepted: 'Proposal {id} has already been approved.', + handled_rejected: 'Proposal {id} has already been rejected.', + unreadableMoment: 'Unreadable moment in proposal {id}: {moment}.', + prefScopeNotAllowed: 'This proposal wants to change the settings under "{scope}", and that is not allowed.', + duplicateValuation: 'There is already a balance for this account and this asset on {dag}.', + noPriceForBalance: 'No price in the history for {dag}, and a balance cannot do without one. First ' + + 'propose a price point on that date (propose_price_point), then this balance can be recorded after it.', + }, + reservations: { + title: 'Reservations', + hint: 'A reservation is an amount that may be debited or credited later. It counts nowhere ' + + 'until you record it; from its date it appears on To do. You can add one on the Reservations tab and in the account window.', + none: 'No reservations on this account.', + noneAll: 'No reservations yet.', + colDate: 'Date', + colAccount: 'Account', + colDirection: 'Direction', + colAsset: 'Asset', + colAmount: 'Amount', + colNote: 'Description', + dirOut: 'Debit', + dirIn: 'Credit', + due: 'Date reached', + book: 'Record', + settle: 'Without entry', + bookTitle: 'Record reservation', + otherTo: 'To', + otherFrom: 'From', + outside: 'Outside the portfolio', + fill: 'Enter a date, an asset and an amount greater than zero.', + confirmSettle: 'Settle this reservation without an entry? It then disappears.', + confirmDelete: 'Delete this reservation?', + notOpen: 'This reservation is no longer open.', + cardOne: '1 reservation', + cardMany: '{n} reservations', + cardNext: 'next {datum}', + }, + notify: { + title: 'Notifications', + none: 'Nothing to do.', + dismiss: 'Hide', + reconcile: 'Movements to reconcile', + interest: 'Interest to record', + valuation: 'Valuation differences', + schedule_ended: 'Interest schedule ended', + source_proposal: 'Confirm price source', + ai_proposal: 'AI proposal', + reservation: 'Reservation to settle', + view: 'View', + hint: { + reconcile: 'Unlinked on-chain movements', + interest: 'Credits ready to record', + valuation: 'Balance differs from the valuation', + source_proposal: 'Proposed price source for a new asset', + ai_proposal: 'Proposed by the AI connection, awaiting your approval', + reservation: 'The date of a reservation has been reached: record it or settle it without an entry', + }, + }, + integrationLog: { + title: 'Error log', + open: 'Error log', + openHint: 'The failed calls of the past {n} days', + lastFailure: 'Last error', + noFailures: 'No errors in the past {n} days.', + allSources: 'All sources', + service: 'Atreides Services', + colTime: 'Time', + colSource: 'Source', + colOperation: 'Call', + colSubject: 'Subject', + colOutcome: 'Outcome', + colStatus: 'Status', + colKey: 'Key', + colDetail: 'Details', + colRequestId: 'Request ID', + requestIdHint: 'The same ID is in the Atreides Services log.', + outcome_fail: 'Failed', + outcome_empty: 'Empty response', + outcome_refused: 'Not sent', + }, + integrations: { + matrixPaid: 'The source choice matrix is part of the paid version. The app keeps using the default sources.', + keysPaid: 'Your own provider API keys are part of the paid version; the default sources keep working.', + title: 'Integrations', + intro: 'External integrations and their endpoints. For each integration you manage the API keys, see the status and test the connection.', + indicatorHint: 'API activity, click for integrations and metrics', + baseUrlOrProfile: 'Base URL / profile', + endpoints: 'Endpoints', + coverage: 'Coverage', + noCoverage: 'No covering source: record the coverage on an integration.', + systemSource: 'System source: {naam}. There is nothing to choose here.', + convertedVia: 'converted to {munt} via {naam}, the source choice of {valuta}', + sourcesHintTitle: 'Source choice', + coverageUsage: 'Coverage and source choice', + coverageUnchosen: 'covered, but not chosen anywhere', + inactiveNoEffect: 'This integration is off: the source choice has no effect until you turn it on.', + inactiveHint: 'gray: the integration is off, so this choice does nothing until you turn it on', + noRateSource: 'no exchange rate source for {valuta}', + noRateSourceHint: 'This source quotes in {valuta}. First choose a source for the exchange rate of {valuta}, then you can choose it here.', + method: 'Method', + path: 'Path', + auth: 'Authentication type', + authYes: 'Requires API key', + authNo: 'Public', + authKind: { + none: 'Public', + header_key: 'API key in a header', + query_key: 'API key in the URL', + bearer: 'Bearer token', + basic: 'Username and password', + hmac: 'Signed request (HMAC)', + }, + authHeaderName: 'Header name', + authParameterName: 'URL parameter name', + keys: 'API keys', + keysSet: 'Set', + keysMissing: 'Not set', + keysNone: 'No API keys needed', + testFail: 'Failed', + noBackend: 'backend unreachable', + saved: 'Saved', + keysTitle: 'API keys', + keysHint: 'API keys are stored server-side; the browser never sees them. Leave a field empty to keep it unchanged.', + keysEmpty: 'Not set', + saveKeys: 'Save API keys', + api_key: 'API key', + api_secret: 'API secret', + capability: 'Type', + query: 'Query', + symbol: 'Symbol', + date: 'Date', + active: 'Active', + authRequired: 'API key required', + authOptional: 'API key optional', + authKeyStored: 'API key stored', + autoActivated: 'Integration activated (test passed)', + symbolsTitle: 'Input mapping', + outputsTitle: 'Output mapping', + hmacTitle: 'HMAC signing', + authTitle: 'Authentication', + keyRequiredCol: 'API key required', + kinds: 'Types', + yes: 'Yes', + no: 'No', + saveAuth: 'Save authentication', + readonlyNote: 'Configuration is read-only in this version.', + testMapTitle: 'Test & map', + runTest: 'Test & map', + testRunning: 'Working…', + mappedResult: 'Mapping result', + rawResponse: 'Raw response', + metricsTitle: 'Metrics', + calls: 'calls', + okCount: 'succeeded', + failCount: 'failed', + avgLatency: 'avg.', + maxLatency: 'max', + noMetrics: 'no calls', + period_uur: 'Last hour', + period_dag: 'Today', + period_maand: 'This month', + subtabMapping: 'Fields & mapping', + subtabAuth: 'Auth & API keys', + subtabTest: 'Test', + subtabMetrics: 'Metrics', + subtabUsage: 'Source choice', + usageNoCoverage: 'This source does not cover any asset yet: record the coverage in the row.', + quoteCurrency: 'Currency', + chartCalls: 'Calls (succeeded / failed)', + chartLatency: 'Latency (max, ms)', + deleteProvider: 'Delete', + cap: { spot: 'Spot', daily_history: 'Daily history', history_point: 'Historical point' }, + tabProviders: 'Providers', + tabSources: 'Source choice', + tabConnections: 'Connections', + sourcesSoon: 'The source choice matrix (which integration supplies which data, per asset and capability) will have its place here. We build it as the next step; until then you manage the source per asset on the Assets page.', + newProvider: 'New integration', + name: 'Name', + field: 'Field', + transform: 'Transform', + externalId: 'External ID', + addEndpoint: 'Add endpoint', + addSymbol: 'Add symbol', + addOutput: 'Add output', + confirmDeleteProvider: 'Delete this integration and all its configuration?', + confirmDeleteRow: 'Delete this row?', + hmacMessageParts: 'Message parts', + hmacHashAlgo: 'Hash algorithm', + hmacEncoding: 'Encoding', + hmacKeyHeader: 'Key header', + hmacSignatureHeader: 'Signature header', + hmacTimestampHeader: 'Timestamp header', + hmacWindowHeader: 'Window header', + hmacWindowMs: 'Window (ms)', + saveHmac: 'Save HMAC', + clearHmac: 'Clear HMAC', + hmacNone: 'No HMAC signing configured.', + systemNote: 'System provider, not editable.', + asset: 'Asset', + nameRequired: 'An integration needs a name.', + keyInUse: 'An integration with the key "{key}" already exists.', + unknownProvider: 'Integration "{key}" does not exist.', + providerInMatrix: 'Integration "{key}" is still in the source matrix ({n} choice(s)); remove those first under Source choice.', + providerHasPrices: 'Integration "{key}" still has {n} price row(s) in the history; they still need it as their source.', + fieldRequired: '{veld} is required.', + priceCallFailed: 'Price call failed ({status}).', + price: { + externalIdMissing: 'The output path uses {external_id}, but there is no external ID.', + listPathWithoutBrackets: 'List path without []: "{pad}".', + expectedArray: 'Expected an array at "{pad}".', + expectedObject: 'Expected an object at "{pad}".', + unknownTransform: 'Unknown transform: "{transform}".', + noOutputMapping: 'Provider {provider}: no output mapping for "{capability}".', + noValidPrice: 'Provider {provider}: no valid price in the response.', + limitReached: '{provider}: the monthly limit of {limiet} calls has been reached; no more requests go out this month.', + noExchangeRate: 'No exchange rate for {valuta} on or shortly before {datum}; without an exchange rate there is no price in {munt}.', + invalidDate: 'Provider {provider}: invalid date "{datum}".', + columnsUneven: 'Provider {provider}: the output columns differ in length.', + capabilityNotConfigured: 'Provider {provider}: capability "{capability}" is not configured.', + symbolMissing: 'Provider {provider}: {external_id} is used but no symbol was given.', + templateDateInvalid: 'Provider {provider}: the template uses {date}, but date "{datum}" is invalid.', + unknownHmacToken: 'Unknown hmac message token: "{token}".', + unknownCapability: 'Unknown capability: "{capability}".', + onlyGet: 'Provider {provider}: only GET is supported.', + unknownAuthKind: 'Unknown authenticationKind: "{soort}".', + keyMissing: 'Provider {provider}: an API key is required but is missing or incomplete.', + parameterNameMissing: 'Provider {provider}: the URL parameter name is missing (API key in the URL).', + headerNameMissing: 'Provider {provider}: the header name is missing (API key in a header).', + hmacConfigMissing: 'Provider {provider}: the hmac configuration is missing.', + networkError: 'Provider {provider} {capability}: network error ({reden}).', + httpFailed: 'Provider {provider} {capability} failed ({status}){detail}.', + notJson: 'Provider {provider} {capability}: the response is not JSON ({detail}).', + notFromFixedRate: 'Provider {provider}: "{capability}" cannot come from a fixed rate.', + callFailed: '{label} failed ({status}).', + callTimeout: '{label} timed out (timeout {s}s).', + labelAddress: 'Fetching address {adres}', + labelAddressTxs: 'Fetching transactions for {adres}', + labelAddressTxsPage: 'Transactions (page)', + }, + scriptLoadFailed: 'Failed to load script: {src}', + unknownEndpoint: 'Endpoint {id} does not exist.', + quoteCurrencyInvalid: 'Quote currency "{waarde}" is not a three-letter currency code.', + minIntervalInvalid: 'Interval "{waarde}" is not a whole number of seconds above zero.', + quoteUnit: 'Unit', + quoteUnitNone: 'as stored', + quoteUnitOpt: { ozt: 'per troy ounce' }, + quoteUnitInvalid: 'Unknown unit "{waarde}".', + minInterval: 'Interval (s)', + minIntervalApp: 'default', + intervalHours: '{n} h', + intervalMinutes: '{n} min', + intervalSeconds: '{n} s', + callLimitInvalid: 'A monthly limit is a whole number above zero, not "{waarde}".', + usageCol: 'Usage (month)', + callLimitCol: 'Monthly limit', + usageHint: 'Calls this calendar month, succeeded and failed together, against the monthly limit of the subscription. The test button does not count.', + unknownSymbolMap: 'Input mapping {id} does not exist.', + catchAll: 'All others', + catchAllHint: 'Applies to every symbol without its own rule. The Symbol field then stays empty.', + catchAllSymbol: 'all others', + catchAllExists: 'This source already has a rule for all other symbols.', + unknownOutputMap: 'Output mapping {id} does not exist.', + matrixHint: 'For each asset you choose a source for the spot price and one for the history; that choice applies everywhere in the app. Clicking an empty cell makes it the next choice (1, then 2 as backup); clicking a chosen cell removes it. A yellow ring is a proposal that a click confirms; an empty cell without a plus is something the source does not supply. History covers a series and a price per date: the app fetches the series if there is one, and per date whatever is missing. By default only assets with more than one source are shown; you turn on the rest in the filter.', + sourceCap: { spot: 'Spot', history: 'History' }, + mxToSetUp: '{wat} still to set up', + mxOpenOne: '1 choice still open', + mxOpenMany: '{n} choices still open', + mxAllChosen: 'all chosen', + mxServiceHint_first: 'Atreides Services: after a source you selected yourself and before the sources the app chose. Select a source to put it before the service. The setting is on System → Integrations → Connections.', + mxServiceHint_fallback: 'Atreides Services: as a fallback, after all chosen sources. The setting is on System → Integrations → Connections.', + mxProposalsOne: '1 proposal', + mxProposalsMany: '{n} proposals', + }, + table: { none: 'Ungrouped', new: 'New', save: 'Save', cancel: 'Cancel', search: 'Search…', count: 'Number of records', edit: 'Edit' }, + action: { copy: 'Copy' }, + dialog: { ok: 'OK', cancel: 'Cancel', close: 'Close', error: 'Something went wrong', + next: 'Next', back: 'Back', finish: 'Finish', skip: 'Skip' }, + setup: { + currencyTitle: 'Which currency is your bank account in?', + accountsTitle: 'Where is it held?', + account_wallet: 'Own wallet', + account_exchange: 'Exchange', + account_bank: 'Bank', + dashboardTitle: 'On the dashboard', + widget_standing: 'Current position', + widget_spot: 'Prices', + finish: 'Set up', + persona_bitcoin: 'Bitcoin only', + persona_broad: 'Broader', + rerun: 'Set up…', + }, + notes: { + tab: 'Notes', + when: 'Date and time of the line', + line: 'New line, Enter adds it at the top', + body: 'Notes on this asset. Freely editable; they also appear in the other detail window of this asset.', + saving: 'Saving…', + saved: 'Saved', + }, + error: { panelTitle: 'Something went wrong loading this section', panelHint: 'The rest of the app keeps working. Details below (also in the console).' }, + blur: { toggle: 'Hide/show amounts', showAmounts: 'Show amounts' }, + controls: { theme: 'Theme', accent: 'Accent', currency: 'Currency', dark: 'Dark', light: 'Light', orange: 'Orange', lila: 'Lilac', blue: 'Blue', teal: 'Teal', magenta: 'Magenta', indigo: 'Indigo', cyan: 'Cyan', slate: 'Slate', sand: 'Sand', flat: 'Flat', deep: 'Relief', business: 'Business', glass: 'Glass' }, + settings: { + prefBadValue: 'Invalid value for {scope}.{key}: {waarde}.', + prefWantsInteger: 'Invalid value for {scope}.{key}: {waarde} (an integer was expected).', + prefBelowMin: '{scope}.{key} cannot be less than {min}.', + prefAboveMax: '{scope}.{key} cannot be greater than {max}.', + unknownSetting: 'Unknown setting: {key}.', + title: 'Settings', + appearance: 'Appearance', + screen: 'Screen', + region: 'Region', + profile: 'Profile', + theme: 'Theme', + accent: 'Accent color', + depth: 'Depth', + depthHint: 'Relief gives the page an accent glow and frames with shadow and edge light; Flat keeps everything plain. Business only.', + glowBg: 'Background glow', + glowBgMotion: 'Background glow and animation', + glowOff: 'Off', + glowOn: 'On', + glowStill: 'Still', + glowStep: 'Occasionally', + glowAlways: 'Always', + glowShape: 'Glow shape', + glowShapeBlobs: 'Orbs', + glowShapeBands: 'Beams', + rowsel: 'Selected table row', + rowselLight: 'Edge light', + rowselTint: 'Tint', + rowselPill: 'Pill', + glass: 'Glass', + glassTransparency: 'Transparency', + glassFrost: 'Frost', + glassDialog: 'Dialog transparency', + glassDefault: 'Default', + glassRender: 'Glass rendering', + glassRenderMimic: 'Simulated', + glassRenderReal: 'Real blur', + motionBlocked: 'Your system asks for reduced motion, so the glow stands still.', + transparencyBlocked: 'Your system asks for reduced transparency, so the glass is opaque.', + hintTitle: 'Settings', + hintProfile: 'Profile: Business is flat and fast, Glass adds a glow behind and translucent frames over it.', + hintTheme: 'Theme and accent color apply in both profiles; the accent color also tints the glow.', + hintGlow: 'Background glow and animation: in Glass, three orbs in the accent color. With Occasionally one shifts now and then; Always moves continuously and takes the most processing.', + hintGlowShape: 'Glow shape, only in Glass with the glow on: Orbs (default) are three spots that shift; Beams are broad light beams in three layers that fall slowly from the top right corner, over the still glow. With Occasionally that comes in showers: light for a while, then nothing for a while.', + hintRowsel: 'Selected table row, only in Glass: Edge light comes from the stripe on the left, Tint colors the whole row, Pill turns it into a rounded strip.', + hintAiHeading: 'Only through the AI', + hintAiIntro: 'This is not on the page. Ask the AI assistant to change it; the proposal appears on To do.', + hintAiTempo: 'Glow tempo: Brisk, Calm (default) or Very calm.', + hintGlassRender: 'Glass rendering, only in Glass: Simulated (default) is faster, Real blur blurs what lies behind the cards and takes more processing.', + hintGlass: 'Glass, only in Glass: Transparency applies to cards and frames (default 65%), Frost to all glass (3%), Dialog transparency to what floats above the content, such as a dialog, menu or tooltip (30%): 0 is opaque, 100 fully transparent, and the more transparent, the less blurred. A table header does not blur. Default resets them, and Glass rendering to Simulated.', + hintScreenHeading: 'Screen', + autoBlur: 'Hide amounts after', + autoBlurHint: 'Time without activity before the amounts blur. With "Manual" you toggle it yourself with the eye icon.', + autoBlurManual: 'Manual', + minutesShort: 'min', + assetDisplay: 'Asset display', + assetDisplayHint: 'Show the name, the symbol, or both. At least one stays on.', + pageTitle: 'Page title', + pageTitleShow: 'Show', + pageTitleHide: 'Hide', + pageTitleHint: 'The title above each page. When hidden, the content starts higher; the page name is also in the menu.', + pageHint: 'Page legend', + pageHintHint: 'The i icon in the margin, with the explanation of what you see on that page. When hidden, the icon disappears entirely; a page without a legend never shows it.', + resultSigns: 'Sign on gains and losses', + resultSignsNone: 'None', + resultSignsNegative: 'Negative only', + resultSignsBoth: 'Negative and positive', + resultSignsHint: 'Applies to gains/losses and returns in green or red. With "None" only the color says whether it is a gain or a loss. Without color the sign is always shown.', + persona: 'Portfolio covers', + personaHint: 'Broader hides Signals & Charts and the bitcoin widgets from the dashboard choice. Per portfolio; entries and assets do not change.', + kiosk: 'Open in', + kioskWindow: 'Browser', + kioskFull: 'Own window', + kioskHint: 'Own window opens the app at the next start with start-app in its own window without address bar and tabs, maximized (Vivaldi, Edge or Chrome). Without one of those browsers the app opens in a regular tab. F11 makes it full screen; moving it to another screen works as with any window. Under System you then find Close, which closes the app and the window. On a server or the Umbrel this choice has no effect.', + language: 'Language', + nl: 'Nederlands', + en: 'English', + }, + ai: { + notConnected: 'No AI assistant has been connected yet. As soon as that happens, this shows ' + + 'per call what was done and which data left the app.', + howTo: 'You connect with your own AI program on this computer. That program starts the ' + + 'connection itself; no web address and no password are needed.', + total: 'Calls', + lastAt: 'Last call', + ok: 'Completed', + error: 'Failed', + refused: 'Refused', + when: 'When', + what: 'What', + who: 'By', + shared: 'Data shared', + characters: 'Characters', + result: 'Outcome', + sharedNothing: 'nothing', + sharedUnknown: 'not recorded', + sharedSome: 'some data', + unknownClient: 'unknown program', + detailNone: '', + noRows: 'No calls match the filters.', + conversation: 'The conversation', + msgRequest: 'Received', + msgResponse: 'Answered', + noConversation: 'The text of this call was not saved. It was handled by a version ' + + 'of the app that did not yet record the conversation.', + conversationFail: 'The conversation could not be loaded.', + switchOn: 'On: a connected AI program may make calls.', + switchOff: 'Off: every call is refused, including from an AI program that is already running.', + switchFail: 'Saving the setting failed.', + activeHead: 'Active', + switchHead: 'Read', + writeHead: 'Write', + controlHead: 'Navigation', + rawHead: 'Raw', + activeOn: 'On: the connection works. What an AI may do is set by the switches next to it.', + activeOff: 'Off: no connection is running. The connection test does nothing either and a running ' + + 'AI program stops itself.', + writeOn: 'On: an AI may add or change data, always through a proposal that you approve.', + writeOff: 'Off: every request to change something is refused.', + rawLabel: 'Share raw', + rawOn: 'On: an AI sees the real names and amounts of this portfolio.', + rawOff: 'Off: an AI sees references and ranges instead of names and amounts.', + rawHint: 'Only turn this on if this portfolio contains no real data. The app cannot ' + + 'determine that; only you know. Note: a copy of a portfolio contains the data of ' + + 'the original.', + rawConfirm: 'Turn on raw sharing for "{naam}"? A connected AI then sees the real names ' + + 'and amounts of this portfolio instead of references and ranges. Only do this if there is ' + + 'no real data in it.', + precisionTitle: 'How precisely an AI sees the numbers', + precisionIntro: 'On means ranges instead of exact numbers, and that is how the ' + + 'app has always worked. Turn one off and that kind comes through exactly. If "Raw" is on, ' + + 'these four do nothing: there is nothing to make a range of.', + sharesHead: 'Percentages', + sharesOn: 'On: the distribution across your assets comes in ranges, for example "20 to 25%".', + sharesOff: 'Off: the distribution comes exactly, for example "23.4%".', + totalsHead: 'Totals', + totalsOn: 'On: the total of your net worth comes as a range.', + totalsOff: 'Off: the total comes as an exact amount.', + amountsHead: 'Amounts', + amountsOn: 'On: an amount in a row comes as a range.', + amountsOff: 'Off: an amount in a row comes exactly.', + quantitiesHead: 'Quantities', + quantitiesOn: 'On: a quantity (0.05 BTC) comes as a range.', + quantitiesOff: 'Off: a quantity comes exactly.', + precisionCombo: 'Note: if "Percentages" and "Totals" are both off, an AI answer leaves out the ' + + 'distribution across your assets. Together those two pin down the value per asset, while the ' + + 'amounts per row are in ranges.', + scope: 'These settings apply only to the portfolio that is open now: {naam}.', + revoke: 'A right you grant can always be revoked here: turn the switch off and it applies ' + + 'from the next call, also for an AI program that is already running. No restart is needed. ' + + 'What has already been approved stays; revoking is about what an AI may do from now on.', + controlLabel: 'The app may be controlled', + controlHint: 'This lets an AI take you to a page. Your data is not ' + + 'read or changed by it. You see every jump coming and can stop it.', + controlOn: 'On: an AI may take you to a page.', + controlOff: 'Off: a request to navigate is refused.', + connectTitle: 'Connect to your AI program', + connectIntro: 'Put the lines below in the configuration file of your AI program. The path is already ' + + 'filled in for this computer. Hover over the box and click the copy button at the top right; then paste ' + + 'the block unchanged into that file.', + connectFile: 'In Claude Desktop that file is called claude_desktop_config.json. If there is already a ' + + '"mcpServers" block, add only the lines inside it.', + connectRestart: 'The AI program must then be restarted; only then does it read the ' + + 'new configuration.', + connectCopy: 'Copy', + connectCopied: 'Copied', + connectCopyFail: 'Copying failed. Select the text above and copy with Ctrl+C.', + connectFail: 'The connection details could not be fetched.', + dryTitle: 'Preview what would be shared', + dryIntro: 'Choose an action and see exactly what an AI program would get to see. The ' + + 'action really runs on your data, but the answer goes to this screen and ' + + 'not outside. Nothing is changed and no row is added to the log below, ' + + 'because nothing was sent.', + dryPick: 'Action', + dryRun: 'Preview', + dryBusy: 'Working…', + dryFail: 'The preview failed.', + dryResult: 'This is what the AI program would get to see', + dryScope: { + none: 'does not touch your records', + read: 'reads your records', + write: 'changes data', + control: 'controls the app, reads no data', + }, + dryArgs: 'Values for this action', + dryArgRequired: 'required', + dryArgPick: 'Choose…', + dryNeedsArg: 'Fill in the required values first.', + dryArgHandleNone: 'You get a reference from another action. First preview "Overzicht van de ' + + 'inrichting"; its output contains references such as account:7f3a9c that you can enter here.', + dryArgHandleSome: 'Choose a reference you saw earlier, or type one in from a conversation.', + }, + home: { + title: 'Dashboard', + assets: 'Assets', + locations: 'Locations', + events: 'Events', + valuations: 'Reference date values', + accounts: 'Accounts', + reconcile: 'Reconcile', + rematch: 'Update', + updating: 'Updating', + rematchDone: 'new links made', + rematchNone: 'No new links: everything is already linked.', + recAsTrade: 'Buy/Sell', + recAsTransfer: 'Transfer', + recIgnore: 'Ignore', + recTotal: 'Amount paid ({munt})', + recPrice: 'Fill price per unit ({munt})', + recPriceLoading: 'Fetching price…', + recPriceFrom: 'Daily price of {date} from the price history, per {unit}. You can change it.', + recPriceAt: 'Price at {time} from Atreides Services, per {unit}. You can change it.', + recPriceUnknown: 'No price known for that date. Enter the price yourself, or 0 if you paid nothing ' + + 'for it.', + recPriceRequired: 'Enter a price.', + recTxAt: 'Time of the transaction', + recDetectedAt: 'The transaction is not confirmed yet; this is the time the app saw it', + recCounter: 'Counter account', + recConfirm: 'Confirm', + recIn: 'received', + recOut: 'sent', + recLightning: 'Lightning', + recVia: 'via {wallet}', + recViaNwc: 'NWC wallet', + recDone: 'Processed.', + records: 'records', + recPending: 'Open differences', + recHandled: 'Handled', + recHandledNone: 'Nothing handled yet.', + recRevert: 'Revert', + recRevertConfirm: 'Revert this proposal? Any event created for it will be deleted.', + recLink: 'Link to event', + recDelete: 'Delete', + recDeleteConfirm: 'Delete this proposal from the list? Any event or link created for it stays.', + recUnknownProposal: 'Proposal {id} no longer exists.', + recHandled_accepted_trade: 'Proposal {id} is already recorded as a purchase or sale.', + recHandled_accepted_transfer: 'Proposal {id} is already recorded as a transfer.', + recHandled_accepted_interest: 'Proposal {id} is already recorded as interest.', + recHandled_ignored: 'Proposal {id} is already ignored.', + recHandled_auto_linked: 'Proposal {id} is already linked automatically.', + recNoCounter: 'There is no counter account to record to. Set a second account to active first.', + recAsInternal: 'Within the account', + recAsChannelTransfer: 'To the channel on {account}', + recInternal_channel: 'channel opened or closed', + recInternal_self: 'payment to yourself', + recInternalHint: 'Stays within the account: only the fee is deducted.', + recNotInternal: 'Proposal {id} is not a movement within the account.', + recBookAll: 'Record Lightning ({n})', + recBookAllConfirm: 'Record {trade} lightning payments as a purchase or sale, at the price of the ' + + 'moment or else the daily price, {internal} movements within the account and {interest} forwards as ' + + 'interest. {skipped} regular on-chain movements stay; you record those one by one.', + recAsInterest: 'As interest', + recInterestHint: 'Routing income from your node: counts as income, not as a deposit.', + recNotForward: 'Proposal {id} is not a lightning forward.', + recBookAllProgress: 'Recording {i} of {n}', + recBookAllDoneTitle: 'Lightning recorded', + recBookAllDone: '{booked} recorded. Left open without a price: {noPrice}. Failed: {failed}.', + recAutoLinked: 'Proposal {id} was linked automatically; to unlink it, use "Link to event".', + recStillOpen: 'Proposal {id} is still open; there is nothing to revert.', + recPrev: 'Previous', + recNext: 'Next', + recAll: 'All proposals', + recIgnoreAll: 'Ignore all', + recDeleteAll: 'Delete all', + recIgnoreAllConfirm: 'Ignore all open proposals? Ignored proposals do not come back on a rescan.', + recDeleteAllConfirm: 'Delete all open proposals? The handled log stays; unlinked movements reappear on a new scan.', + btcWidget: 'Bitcoin', + toSignals: 'To Signals & Charts', + hintTitle: 'Arranging the dashboard', + hintEdit: 'Press and hold a widget to customize the layout.', + hintDone: 'Done: Escape, a click beside the widgets or another page.', + hintAdd: 'Add: point at an empty spot and click the +. On a phone the list is below the widgets.', + hintWidget: 'Move a widget by its handle; the button next to it opens its settings.', + hintProfiles: 'Choose a layout in the menu. Deleting Default starts over with the default layout.', + groupAccount: 'By account', + groupAccountKind: 'By account type', + groupAsset: 'By asset', + groupAssetKind: 'By asset type', + groupBy: 'Group', + standing: 'Current position', + toWealth: 'To Wealth', + standingNone: 'No tax-relevant positions yet.', + showToday: 'Show return', + }, + + conversion: { + title: 'Bitcoin Conversions', + btcRate: 'Bitcoin price', + loading: 'Fetching price…', + updatedAt: 'Updated at', + rateUnavailable: 'Price not available', + fiat: 'Euro', + fiatHint: 'Traditional currency', + btcHint: 'Base unit · max 21 million', + hierarchy: 'Unit hierarchy', + satNote: 'The satoshi is the smallest on-chain unit. The millisatoshi exists only on the Lightning Network.', + footer: 'Live price from the selected price source', + }, + + labels: { + title: 'Labels', + inUse: 'Label is used by {n} item(s); unlink those first.', + new: 'New label', + edit: 'Edit', + cancel: 'Cancel', + save: 'Save', + name: 'Name', + color: 'Color', + useColor: 'Use color', + none: 'No labels yet.', + delete: 'Delete', + confirmDelete: 'Delete this label?', + colColor: 'Color', + colName: 'Name', + badHex: 'Invalid color. Use e.g. #39FF8A.', + }, + + accounts: { + maxFreeBank: 'The free version is limited to {max} fiat bank account. A license removes that limit; ' + + 'nothing is removed in the meantime.', + title: 'Accounts', + nameRequired: 'Enter a name.', + new: 'New account', + edit: 'Edit', + cancel: 'Cancel', + save: 'Save', + name: 'Name', + kind: 'Type', + ownCustody: 'Self-custody', + scanEnabled: 'Scan automatically', + taxCategory: 'Category', + taxCategoryAuto: 'follows asset', + taxScope: 'Taxes', + taxScopeAuto: 'default (follows asset)', + wealthScope: 'Wealth', + wealthScopeAuto: 'default (own wealth)', + color: 'Color', + useColor: 'Use color', + label: 'Label', + none: 'No accounts yet.', + confirmDelete: 'Delete this account?', + yes: 'Yes', + no: 'No', + sources: 'Tracking sources', + sourcesFor: 'Tracking sources of', + addSource: 'Add tracking source', + sourceKind: 'Type', + sourceValue: 'Value', + xpub: 'xpub / ypub / zpub', + address: 'Single address', + nwc: 'NWC (Nostr Wallet Connect)', + lnd: 'LND (Lightning node)', + noSources: 'No tracking sources for this account yet.', + noSourceKind: 'This account type has no tracking source.', + interestManageHint: 'Add and change them on', + interestNone: 'No interest schedules for this account yet.', + interestMovedLink: 'Interest & Rewards', + merge: 'Merge', + mergeTitle: 'Merge two accounts into one', + mergeNeedTwo: 'You need at least two accounts to merge.', + mergePick: '(choose an account)', + mergeKeep: 'Account to keep', + mergeDrop: 'Account to remove', + mergeSummary: '"{drop}" → "{keep}". Moves: {sources} source(s), {events} event(s), {schedules} schedule(s), {valuations} valuation(s).', + mergeConfirm: 'This merge cannot be undone. Continue? (Run a rescan afterwards.)', + mergeDone: 'Accounts merged. Run a rescan to refresh the tracking cache.', + mergeReason_pickTwo: 'Choose two accounts.', + mergeReason_sameAccount: 'Choose two different accounts.', + mergeReason_notFound: 'Account not found.', + mergeReason_sourceMismatch: 'Not possible: one type has tracking sources and the other does not (e.g. exchange × wallet).', + mergeReason_kindCannotCarry: 'Not possible: the account to keep cannot carry the source types of the other. Choose the Lightning account as the account to keep, or set it to type "Lightning" first.', + sourceValueRequired: 'Enter an xpub, address or NWC URI.', + sourceNameRequired: 'Give the tracking source a name.', + inUse: 'This account is used by {n} entry(ies); delete or relink those first.', + unknownAccount: 'Account #{id} does not exist.', + sourceUriMissing: 'The NWC URI is missing.', + sourceNotLnd: 'The selected connection is not an LND connection.', + labelNoTxs: 'No movements were given for the label.', + sourceNamePh: 'e.g. Ledger receiving', + colSourceName: 'Name', + confirmDeleteSource: 'Delete this tracking source and its scanned addresses?', + testSource: 'Test', + testing: 'Testing…', + testValid: 'valid', + testTxs: 'transactions', + testFail: 'Test failed', + nwcPubkey: 'wallet pubkey', + nwcKeepPh: 'URI saved: leave empty to keep, paste a new one to replace', + colSourceKind: 'Type', + colSourceValue: 'Source', + colConnection: 'Connection', + colScriptType: 'Type', + lndVia: 'via', + lndHeight: 'block height', + lndNoConnection: 'Choose an LND connection (create one first under Connections).', + noNode: 'None', + onchainOfNode: 'on-chain of', + onchainOfNodeHint: 'This wallet is the on-chain side of that node. A channel it opens is recorded by the app as a transfer to the node account.', + 'script_p2tr': 'Taproot (bc1p…)', + 'script_p2wpkh': 'Native SegWit (zpub · bc1q…)', + 'script_p2sh-p2wpkh': 'Nested SegWit (ypub · 3…)', + 'script_p2pkh': 'Legacy (xpub · 1…)', + interest: 'Interest received', + intDate: 'Date', + intAsset: 'Asset', + intAmount: 'Amount', + intValue: 'Value', + noInterest: 'No interest received on this account yet.', + schedules: 'Interest schedules', + schPrincipalAsset: 'Locked', + schPrincipal: 'Principal', + schRate: 'Interest', + schPayoutAsset: 'Payout', + schStart: 'Start', + schEnd: 'End', + schCadence: 'Credit', + schWeekday: 'Weekday', + schDayOfMonth: 'Day of month', + wd0: 'Sunday', wd1: 'Monday', wd2: 'Tuesday', wd3: 'Wednesday', wd4: 'Thursday', wd5: 'Friday', wd6: 'Saturday', + schRateType: 'Type', + schNote: 'Note', + noSchedules: 'No interest schedules for this account yet.', + confirmDeleteSchedule: 'Delete this interest schedule?', + schAssetRequired: 'Choose the locked asset.', + schPayoutRequired: 'Choose a payout asset.', + schRateInvalid: 'Enter a valid interest rate.', + schStartRequired: 'Enter a start date.', + schEndBeforeStart: 'The end date is before the start date.', + projection: 'Forecast', + projectionHint: 'Remaining credits, for information only, not counted in balance or taxes.', + projReceived: 'Received', + projExpectRemain: 'Still expected', + projCount: '{n} credits', + projTermTotal: 'Total for the term', + projEndBalance: 'Final balance {datum}', + projEndBalanceSub: 'deposit plus interest', + projNext: 'Next', + projAllUpcoming: 'All {n} upcoming credits', + projOver: 'on {bedrag}', + projUntil: 'through {datum}', + dueTitle: 'Interest to record', + book: 'Record', + dismiss: 'Ignore', + bookOldestFirst: 'Record from oldest to newest: each credit raises the balance the next one is calculated on. Ignoring is allowed in any order and releases the next one.', + confirmBook: 'Credit interest:', + }, + + interest: { + title: 'Interest & Rewards', + none: 'No interest events yet.', + needAsset: 'Add an asset first.', + colDate: 'Date', + time: 'Time', + colAccount: 'Account', + colAsset: 'Asset', + colAmount: 'Amount', + colSource: 'Source', + colNote: 'Note', + colSchedule: 'Interest schedule', + noSchedule: 'No schedule', + new: 'New interest', + save: 'Save', + cancel: 'Cancel', + confirmDelete: 'Delete this interest event?', + confirmDeletePeriod: 'Delete this rate period?', + invalid: 'Enter date, account, asset and amount (> 0).', + importNote: 'Imported records are authoritative and cannot be edited.', + schedulesTitle: 'Interest schedules', + recordsTitle: 'Interest received', + principalFollowsBalance: 'follows the balance', + endsOnOpen: 'ongoing', + cadenceNone: 'No credit cycle', + colCompounding: 'Compound interest', + compoundingViaBalance: 'via the balance', + depositTitle: 'Principal empty', + depositWarning: 'This schedule has an end date and follows the balance, and the interest is paid to this account. Then each credit is also calculated on interest recorded earlier. For a term deposit the bank usually calculates on the starting amount: enter that as the principal.', + depositSaveAnyway: 'Save anyway', + depositBack: 'Enter principal', + depositNotSaved: 'Not saved: enter the starting amount as the principal, or save and choose Save anyway.', + periods: 'Rate periods', + periodsBase: 'From the start of the schedule on', + periodsVariable: 'This schedule is set to "manual or import": the app calculates nothing here, so there are no rate periods.', + periodStartsOn: 'Starts on', + periodRateRequired: 'Enter an interest rate.', + noPeriods: 'No changes yet; the rate of the schedule applies for the whole term.', + noSchedules: 'No interest schedules yet. Add one to see forecasts and interest to record.', + schAccountRequired: 'Choose an account for the schedule.', + principalPositive: 'The principal must be greater than 0. Leave the field empty to follow the account balance.', + rateNotNegative: 'The annual interest rate cannot be negative.', + compoundingNeedsPrincipal: 'Compound interest requires a fixed principal. A principal that follows the balance already grows with each recorded credit.', + compoundingNeedsSameAsset: 'Compound interest is only possible if the interest is paid in the same asset as the principal.', + weekdayRequired: 'Choose a weekday for the weekly credit.', + dayOfMonthRequired: 'Choose a day of the month for the monthly credit.', + unknownSchedule: 'This interest schedule does not exist.', + unknownPeriod: 'This rate period does not exist.', + periodStartRequired: 'Enter an effective date.', + periodStartBeforeSchedule: 'The effective date must be after the start of the schedule ({start}); the rate from that day is in the schedule itself.', + bookOlderFirst: 'First record the credit for {start} → {eind}. Each credit raises the ' + + 'balance the next one is calculated on, so the order determines the amount.', + periodStartAfterEnd: 'The effective date is after the end of the schedule ({eind}).', + }, + + taxes: { + title: 'Taxes', + loading: 'Calculating…', + empty: 'Nothing to calculate yet.', + emptyWhy: 'Add an event (a purchase or a transfer) or set a balance; from then on this page calculates the reference dates.', + colAccount: 'Account', + colAsset: 'Asset', + groupAccount: 'By account', + groupAsset: 'By asset', + groupKind: 'By type', + colKind: 'Type', + catTitle: 'Category', + catBank: 'Bank balances', + catOverig: 'Other assets', + catUnknown: 'No category', + catNote: 'Category: the amounts split into bank balances and other assets. Appears as soon as a category is chosen on an account or asset, or "Listed separately only" under Taxes (System, tabs Assets and Accounts). Without a choice an asset follows its type: currency is a bank balance, the rest is another asset. What appears only here does not count in the table and the gain/loss; money to and from such an account counts there as a deposit or withdrawal.', + showAll: 'Show all', + showAllTip: 'Show the page as if Category and Taxes on Assets and Accounts are set to the default everywhere.', + showAllNote: 'Show all: if an asset or account has something other than the default under Category or Taxes, this switch shows the page as if everything is set to the default. That way you see what those choices change. The switch setting is remembered.', + colCurrent: 'Current value', + total: 'Total', + untilNow: '· to date', + hintTitle: 'What you see here', + hintMainTitle: 'Value per reference date', + mainNote: 'Value per reference date (January 1, 00:00) in the base currency: the position at that moment × the price of that day. The position is the last recorded balance plus the events after it, or Σ events if there is no balance.', + pctNote: 'Hover over an amount to see the return for the period ending at that column; the January 1 column therefore covers the year before it. The total row does show the percentage, as that is the figure for the whole.', + pctMwr: 'MWR', + pctIrr: 'IRR', + pctMwrTip: 'Money-weighted return using Modified Dietz: what YOUR money did, divided by the average invested capital (each deposit counts in proportion to how long it was in). End value minus start value, minus what you bought or transferred to this account, plus what went out, and minus what came from a balance instead of an entry. Buying more therefore does not count as profit. Empty if there was no start value to measure against.', + pctIrrTip: 'Money-weighted return using the internal rate of return (IRR), annualized: the rate that exactly explains your deposits and withdrawals. Where it does not exist (a position that drops to zero in between) the figure falls back to Modified Dietz. Adjustable in the display settings.', + aangifteTitle: 'Gain/loss', + colPeriod: 'Jan 1 {van} → Jan 1 {tot}', + colPeriodNow: 'Jan 1 {van} → now', + cellReturn: '{label} {pct}, gain/loss {bedrag}', + cellResult: 'Gain/loss {bedrag}', + buitenBox3: 'Kept out of the overview (does count as net worth):', + emptyColsNote: 'Reference dates left out because nothing was there: {jaren}. In the gain/loss per year they ' + + 'remain the start value of that year.', + startYearNote: 'These records start in {eerste} and are shown from reference date January 1 {start}. The positions include everything before that; only columns are missing. The start year is set under System → Portfolios.', + aangifteNote: 'Key figures per year for the net worth in this app: crypto, precious metals and the accounts you track here. Return = end value - start value - purchases + sales - what came from balances. "Current value" is the current year to date; a year gets its own column once it is complete. The header of a year column is the reference date on which that year ends, so the columns line up with the table above: the End value here is the same reference date column there, and the Start value is the column to its right. Click an amount to copy it (rounded to whole units). This app does not calculate tax: these are the key figures a tax return usually asks for; rates and exemptions differ per country.', + kgRendement: 'Return', + kgUitBalansen: 'From balances (not recorded)', + kgGiften: 'Gifts (nothing paid)', + kgUnexplained: 'Of which unexplained', + kgUnexplainedTip: 'Part of the change comes from a measured balance and not from entries. If that was a deposit you did not record, the return is that much lower; if it was income, this amount is correct.', + zonderKoersTip: 'Left out of this return because the price is missing on one of the two reference dates: {namen}. Counting it as zero would read that missing price as a loss or gain. Add the price under Price history, or fill in the value with a balance.', + zonderKoersWhere: 'Left out of the return because the price is missing on one of the two reference dates:', + unexplainedWhere: 'Of which unexplained, per account (a negative amount means the entries explain more than the position changed, usually a jump from an earlier year that you have now recorded):', + unexplainedOpeningAll: 'an opening balance with no entries before it, so there is nothing to record here', + unexplainedOpening: 'of which from an opening balance with no entries before it:', + owrBegin: 'Start value', + owrEind: 'End value', + owrDeposits: 'Purchases (deposits)', + owrWithdrawals: 'Sales (withdrawals)', + }, + eventAccounts: { + none: 'No accounts yet.', + noneWhy: 'A balance belongs to an account. Create one first.', + manage: 'Manage accounts', + empty: 'No balance', + closed: 'Closed', + openGaps: '{n} difference(s) to check', + open: 'Open account', + pickAsset: 'Show only this asset in the tables; click again for all', + showEmpty: 'Without balance', + recordBalance: 'Record balance', + recordBalanceHint: 'Record the current balance as a balance entry. Change the amount if your statement says otherwise; the difference then goes to To do.', + recordBalanceTitle: 'Record the {asset} balance as a balance entry', + lastBalance: 'Balance {datum}', + fromBookings: 'From entries', + noPrice: 'no price', + hintTitle: 'Accounts', + hintBalance: 'The balance is the latest recorded balance plus the entries after it, the same number as on Wealth. "Balance" is the date of that latest recorded balance; "From entries" means no balance has been recorded for this asset yet.', + hintDetail: 'Click a card to see and edit the balances, buys and sells, transfers and interest of that account. Click an asset there to filter the tables on that asset.', + interestAgreement: 'Interest schedule', + interestAgreements: '{n} interest schedules', + interestBooked: '{n}× interest recorded, last {datum}', + interestNext: 'expected {datum}: {bedrag}', + nowTotal: 'now {bedrag} on the accounts shown', + nowTotalPartial: 'now {bedrag} on the accounts shown, excluding assets without a price', + act: { + buy: 'buy', + sell: 'sell', + transferIn: 'transfer in', + transferOut: 'transfer out', + interest: 'interest', + balance: 'balance recorded', + agreementStart: 'interest schedule starts', + agreementEnd: 'interest schedule ends', + accountOpened: 'account created', + accountClosed: 'account closed', + accountChanged: 'account last changed', + reservation: 'reservation', + interestDue: 'interest expected', + }, + hintActivity: 'The line at the top (all accounts shown), at the bottom of a card and in the account window is the activity from the start year of the portfolio: a peak up is a day on which something came in, a dip a day on which something went out; the height follows the number of entries. Ticks are balances, interest schedules, expected interest payments, reservations (a small square) and changes to the account; the line runs to the last of these, even if it lies in the future. In the account window, clicking a day opens that entry or reservation.', + hintInterest: 'Below the balance: the current interest schedules of the account and how often interest or rewards were recorded.', + hintCapture: 'The "Record balance" button is shown by default only for money. Turn it on or off per asset under System, tab Assets, column "Record balance".', + }, + + valuations: { + maxFree: 'The free version is limited to {max} balance. A license removes that limit; nothing is removed in the meantime.', + title: 'Balances', + needAsset: 'Create an asset first or migrate the data on the overview.', + colDate: 'Reference date', + colTime: 'Time', + colUpdated: 'Updated', + colAccount: 'Account', + colAsset: 'Asset', + colBalance: 'Balance', + colRate: 'Price ({munt})', + colSource: 'Source', + colDiff: 'Difference', + ratePlaceholder: 'empty = look up', + rateNote: 'Leave the price empty = looked up automatically at the reference date and time, via the source chosen for this asset. A balance is the anchor for the position of that account: the position is this balance plus the events after it. If there are also events before the reference moment, Difference shows whether they match, and "Record" writes the difference as one correction at the price of that moment.', + anchorHint: 'This is the anchor for the position of this account: there are no events before this reference moment, so there is nothing to reconcile.', + futureHint: 'This reference date is still to come. The balance does not count in your position yet and creates no work; once the reference moment has passed, it becomes the anchor for this account.', + book: 'Record', + accept: 'Accept', + acceptHint: 'This difference is not income but money that went in or out. No entry, and it no longer counts as unexplained.', + confirmAccept: 'Accept this difference? It then counts as money going in or out of your records, not as return. You can undo this later.', + lightningRow: 'Measured at Update', + lightningHint: 'The balance on Tracking (on-chain plus lightning) against what your records said at that moment.', + recordBalance: 'Record as balance', + recordBalanceHint: 'The measured balance becomes a balance on that day. After that it is listed here as a regular balance, with Record and Accept.', + recordTitle: 'Record measured balance', + recordRate: 'Price on {date}', + lightningSameDay: 'There is already a balance on {date} for this account; it will not be overwritten.', + acceptedMark: '✓ accepted', + acceptedHint: 'Accepted difference, click to undo:', + bookTitle: 'Record difference', + bookAsSuffix: 'record as', + bookAsSell: 'Sell (left your portfolio)', + bookAsBuy: 'Buy (entered your portfolio)', + bookAsTransferTo: 'Transfer to', + bookAsTransferFrom: 'Transfer from', + bookAsFeeOn: 'Fees on {soort} of {datum}', + bookReplaces: 'existing line becomes', + bookRemoves: 'removes the existing line', + bookNote: 'valuation correction', + bookFeeNote: 'fees added via Record difference', + invalid: 'Fill in reference date, asset and balance (≥ 0).', + noRate: 'No price found for this time.', + none: 'No balances yet.', + confirmDelete: 'Delete this balance?', + }, + + assets: { + title: 'Assets', + decimalsRange: 'Decimals must be a whole number from 0 to {max} (received: "{waarde}"). Above that, a quantity can no longer be read back.', + inUse: 'Asset is in use by {n} record(s); delete those first.', + maxFree: 'The free version is limited to {max} asset. A license removes that limit; nothing is removed in the meantime.', + freeOnly: 'The free version tracks {list} and currencies only. A license removes that limit.', + fillSymbolNameUnit: 'Fill in symbol, name and main unit.', + metalUnitUnknown: 'Metal main unit must be a known unit: {eenheden}.', + fillDecimals: 'Fill in decimals (≥ 0).', + new: 'New asset', + edit: 'Edit', + cancel: 'Cancel', + save: 'Save', + symbol: 'Symbol', + name: 'Name', + kind: 'Type', + unit: 'Main unit', + subunit: 'Subunit', + displayUnit: 'Display', + decimals: 'Decimals', + taxCategory: 'Category', + taxCategoryAuto: 'follows type', + taxCategoryAutoIs: 'follows type ({waarde})', + taxScope: 'Taxes', + taxScopeAuto: 'default (counts)', + balanceCapture: 'Record balance', + balanceCaptureOn: 'On', + balanceCaptureOff: 'Off', + balanceCaptureAutoOn: 'follows type (on)', + balanceCaptureAutoOff: 'follows type (off)', + priceSource: 'Price source', + none: 'No assets yet.', + delete: 'Delete', + confirmDelete: 'Delete this asset?', + }, + + metalProducts: { + title: 'Coins & bars', + assetRequired: 'Choose a valid metal asset.', + nameRequired: 'The name is required.', + fineGramsPositive: 'The fine weight must be greater than 0.', + pickMetalAndName: 'Choose a metal and fill in a name.', + fillFineGrams: 'Fill in a valid fine weight (> 0).', + notMetal: 'The selected asset is not a metal.', + inUse: 'This product is in use by {n} event(s); delete those first.', + asset: 'Metal', + name: 'Name', + form: 'Form', + fineWeight: 'Fine weight', + weight: 'Weight', + unit: 'Unit', + save: 'Save', + cancel: 'Cancel', + none: 'No coins or bars yet.', + needMetal: 'Add a metal asset first (Master data → Assets).', + confirmDelete: 'Delete this product?', + }, + + wealth: { + title: 'Wealth', + none: 'No assets with a position yet.', + quantity: 'Quantity', + value: 'Current value', + avgSpot: 'Avg. purchase price', + avgPremium: 'Avg. premium/event', + unrealized: 'Unrealized', + realized: 'Realized', + share: 'Share of total', + result: 'Gain/loss', + twr: 'Return', + twrTotal: 'Return', + unexplained: 'Unexplained', + allYears: 'All', + noPrice: 'No price known for this period: no value, share or return.', + yearPick: 'Choose a year or a range of years', + yearRangeTip: 'Shift+click for a range of years.', + yearOff: 'Click to leave out this year and everything before it', + legendTitle: 'What you see here', + legendSparkValue: 'Value of the position per day.', + legendSparkInvested: 'Invested. The difference with the solid line is the gain/loss.', + legendBuy: 'Buy.', + legendSell: 'Sell. A jump without a dot comes from the market.', + legendRings: 'Distribution over types (left) and assets (right) as a share of total net worth; below 2% goes into "Other".', + legendUnexplained: 'Unexplained: a balance differs from the entries on that account, so an entry is missing. Accounts tracked by balance only do not count. Does not count as return.', + legendTwr: 'Return is time-weighted: only the price counts, not deposits or timing ("Unrealized" does count those). Interest, staking and cashback count as holdings on receipt and are included in "Gain/loss". The base currency has no return. The money-weighted return is on Taxes.', + legendRange: 'Year selection: calendar years, the current year up to today. Shift-click selects a range; deselecting a selected year also drops the years before it. Everything on the page follows the selection, with amounts at the end of the period (the following January 1). Every asset that moved in the period gets a card, even if it has since been sold. The detail window always shows "All".', + legendInvested: 'Invested: price paid plus premium and fees. "Avg. purchase price", "Avg. premium/event" and "Invested" per asset are in the detail window; the purchase price measures only the spot price.', + legendResult: 'Gain/loss: what the position yielded in the selected period, the change in value minus the net amount invested. Price gains, sales and interest count. Unrealized applies since purchase and only to what you still hold; over all years, gain/loss is roughly unrealized plus realized plus interest.', + legendDetail: '"Show details": the chart at full size, with accounts and entries. Pointing at a row highlights that day in the chart.', + legendChain: 'Return over multiple years is chained, not added: two years of +10% is +21% combined. A year box shows that year on its own.', + legendNoPrice: '"No price known": the quantity comes from the entries; value, share, gain/loss and return stay empty (not zero). Return counts from the first known price. You can add price points via a source on Price history or via the AI connection; one per year is enough.', + legendUnrealized: 'Unrealized: against the price paid including fees and premium. Realized: proceeds of sales in the period minus fees and cost basis. A dash: nothing sold or cost basis unknown. A coin stands at minus the premium until the spot price earns it back; loose weight has no product.', + invested: 'Invested', + total: 'Total value', + product: 'Product', + form: 'Form', + count: 'Count', + weight: 'Weight', + unallocated: 'Unallocated / loose weight', + openDetail: 'Show details', + tradesTitle: 'Buys and sells', + tradeDate: 'Date', + tradeType: 'Type', + tradeBuy: 'Buy', + tradeSell: 'Sell', + tradePrice: 'Price', + tradeAmount: 'Amount', + noTrades: 'No buys or sells on this account.', + noChart: 'No history over the selected period.', + ringKinds: 'By type', + ringAssets: 'By asset', + ringKindsLabel: 'Distribution of your net worth over the asset types', + ringAssetsLabel: 'Distribution of your net worth over the individual assets', + ringOther: 'Other', + perAccount: 'By account', + sinceStart: 'Since start', + openYearOverview: 'Open annual net worth overview', + openReturn: 'Open return', + returnTitle: 'Return', + returnDay: 'This day', + returnSoFar: 'Since start of period', + returnCarried: 'No price on this day: the previous one is carried forward, so the movement only shows at the next price.', + periodDay: '1 day', + periodDays: '{n} days', + periodYear: '1 year', + returnData: 'Based on', + returnTotal: 'Total net worth', + returnSelection: 'Selection ({n} assets)', + returnKinds: 'Type', + returnAssets: 'Asset', + perAsset: 'By asset', + groupBy: 'Group the cards', + asset: 'Asset', + shareOfAccount: 'Share of account', + broughtInNet: 'Contributed (net)', + ownWealth: 'Own wealth', + othersWealth: 'Third-party wealth', + costOnArrival: 'Cost basis on arrival', + account: 'Account', + noAccountAssets: 'This account holds nothing right now.', + legendAccounts: 'By account: the same prices as by asset. Unrealized in proportion to the units, with the average cost basis of own wealth. A third-party account has its own cost basis: the daily price at which it came in, plus what it bought itself. A transfer between your own accounts counts for the return as a withdrawal and a deposit at the daily price, so only the price and the fees count. The second line is what was brought in, transfers included, and the gain/loss is the difference with the value. Whatever is on no account gets the card "No account".', + legendFilter: 'Filter: turning off a card only hides the card. The total, the rings and the return still include it. By account you filter by type, such as exchange or wallet.', + showAllTip: 'Also show the wealth you manage for someone else and what does not count on Wealth.', + legendShowAll: 'Show all (the switch at the total): if an account is set to "Third-party wealth" or "Not counted" under Wealth (System, tab Accounts), that page does not count the account. Money to and from such an account counts as a withdrawal or deposit, not as gain/loss. The switch shows everything, and its setting is remembered.', + legendManaged: 'Third-party wealth, not counted: {bedrag}.', + legendNotCounted: 'Not counted, excluded: {bedrag}.', + legendShowAllOn: 'Show all is on: third-party wealth and what does not count are included in the figures. In the window of an asset, third-party wealth is on its own tab, with its own key figures.', + yearOverviewTitle: 'Annual net worth overview', + valueEnd: 'Value at end', + investedNet: 'Invested (net)', + noLongerHeld: 'No longer held', + noLongerHeldSince: 'Since {dag} the quantity is zero. The card is shown because you still held this asset in this period; return and gain/loss cover that time.', + year: 'Year', + noYears: 'No years to show yet.', + perProduct: 'By product', + noAccount: 'No account', + noAccountRows: 'No position per account to show.', + }, + transfers: { + title: 'Transfers', + fillDateAsset: 'Enter date and asset.', + pickValidProduct: 'Choose a valid product.', + fillCount: 'Enter a number of pieces (> 0).', + fillDateAssetQuantity: 'Enter date, asset and quantity (> 0).', + fillOneAccount: 'Enter at least one from/to account.', + fillThisAccount: 'Choose this account as the from or to account.', + new: 'New transfer', + edit: 'Edit', + cancel: 'Cancel', + save: 'Save', + colOrigin: 'Origin', + originPick: 'Regular transfer', + originNone: 'Nothing in return', + originNonePick: 'Nothing in return: received or a bonus', + asset: 'Asset', + date: 'Date', + time: 'Time', + amount: 'Quantity (sats)', + from: 'From', + to: 'To', + unitFee: 'Fee (sats, on-chain)', + note: 'Note', + none: 'No transfers yet.', + delete: 'Delete', + confirmDelete: 'Delete this transfer?', + needAsset: 'Create an asset first or migrate the data on the overview.', + colDate: 'Date', + colAsset: 'Asset', + colAmount: 'Quantity', + colFrom: 'From', + colTo: 'To', + colDirection: 'Direction', + colOther: 'From / to', + dirIn: 'In', + dirOut: 'Out', + colFee: 'Fee', + colNote: 'Note', + colAddress: 'On-chain address', + colSource: 'Source', + addressPlaceholder: 'On-chain receiving address (for auto-match)', + colKind: 'Type', + colProduct: 'Product', + addCrypto: 'Transfer crypto', + addMetal: 'Transfer precious metal', + addFiat: 'Transfer money', + looseWeight: '(loose weight)', + internal: 'internal', + fiatNote: 'Fiat value = market price (nearest) on the transfer date, in the base currency.', + matched: 'Also found in on-chain tracking', + }, + + trades: { + title: 'Buy & Sell', + noSpotForDate: 'No price found for this date. Enter the price yourself, or 0 if you paid nothing for it.', + saveCancelled: 'Save canceled: adjust the price.', + fillDateAssetPrice: 'Enter date, asset and price.', + pickValidProduct: 'Choose a valid product.', + fillCount: 'Enter a number of pieces (> 0).', + fillQuantity: 'Enter a quantity (> 0).', + new: 'New trade', + edit: 'Edit', + cancel: 'Cancel', + save: 'Save', + type: 'Type', + asset: 'Asset', + date: 'Date', + time: 'Time', + amount: 'Quantity (sats)', + location: 'Location', + note: 'Note', + none: 'No trades yet.', + delete: 'Delete', + confirmDelete: 'Delete this trade?', + needAsset: 'Create an asset first or migrate the data on the overview.', + colDate: 'Date', + colType: 'Type', + colAsset: 'Asset', + colAmount: 'Quantity', + colTotal: 'Amount', + colPrice: 'Price/unit', + colOrigin: 'Origin', + originUnknown: 'Unknown', + originPaidHint: 'The price comes from you: an amount or a price per unit that you entered.', + originMarketDay: 'Daily price, looked up by the app. The time of the entry is not taken into account.', + originMarketMoment: 'Price at the time of the entry, looked up by the app.', + originNoneHint: 'Received without anything in return, so a gift. The price is 0, and that is the correct answer here.', + originUnknownHint: 'This entry predates the app recording this; whether the price was paid or looked up is unknown.', + originPick: 'Unknown, or let the app choose', + originPickPaid: 'Paid: this number comes from me', + originPickGift: 'Gift: nothing paid', + colSource: 'Source', + colFee: 'Fee', + feeOnInterest: 'Fees on interest are not counted anywhere, so this entry is not saved. Remove the fees or lower the amount.', + unitFeeOnBuy: 'Fees in the asset itself on a purchase are not counted anywhere. Record the amount you received, without those fees, or enter the fees in fiat.', + colAccount: 'Account', + colNote: 'Note', + colKind: 'Type', + colProduct: 'Product', + addCrypto: 'Add crypto', + addMetal: 'Add precious metal', + addFiat: 'Deposit or withdraw money', + looseWeight: '(loose weight)', + fiatNote: 'Amount = quantity × price per unit, including the fee on a buy and after deducting the fee on a sell.', + matched: 'Also found in on-chain tracking', + }, + + priceOrigin: { + price: 'Price {prijs} on {dag}', + pricePer: 'Price {prijs} per {eenheid} on {dag}', + someSource: 'a source', + close: 'Closing price from {bron}', + manual: 'Entered manually', + live: 'Current price, still moving', + liveFrom: 'Current price from {bron}, still moving', + spot: 'Derived from a spot price from {bron}, not a closing price', + spotAt: 'Derived from the {tijd} spot price at {bron}, not a closing price', + devBefore: '{pct} off the previous closing price', + devAfter: '{pct} off the next one', + carried: 'Carried forward from {dag} ({bron}), {n} days old', + strength: 'Accuracy: {sterkte}', + sumHead: 'Based on {n} prices', + sumWeakest: 'Weakest: {asset}, {zin}', + sumMissing: 'No price for {assets}: counts as zero', + pageWarnTitle: 'Missing prices', + pageWarn: 'On this page, at least one amount is missing a price: that item counts as zero. Hover over the amounts; where it applies, a yellow i shows which item it is.', + bookingPrice: 'Price {prijs}', + bookingPricePer: 'Price {prijs} per {eenheid}', + bookingPaid: 'Paid: the price you entered yourself', + bookingMoment: 'The price at the time of the entry', + bookingDay: 'Daily price of {dag}, looked up when recording', + bookingDayFrom: 'Daily price of {dag}: {zin}', + bookingDayNote: 'A daily price is not the price of the exact moment', + bookingUnknown: 'Not recorded whether this price was paid or looked up', + strength_unknown: 'unknown', + strength_high: 'high', + strength_lower: 'lower', + strength_low: 'low', + strength_missing: 'incomplete', + }, + + priceHistory: { + title: 'Price history', + confirmDeletePoint: 'Delete this price?', + gapsMore: 'And {n} more gaps; fill these first.', + gapLine: '{van} to {tot}, {n} days', + gapFill: 'Fill', + gapNoSource: 'No source is selected for {asset} that provides a series or a price per date. Choose one under Integrations, Source choice.', + gapConfirmPoint: 'This requests up to {n} prices from {bron}, one request per day. A source with a monthly limit can run through that quickly.', + gapConfirmSeries: 'This fetches the series from {reeks} (at most once a day).', + gapConfirmBoth: 'This first fetches the series from {reeks}, then requests what is still missing from {bron} per day: up to {n} requests.', + gapFillAll: 'Fill all', + gapIgnore: 'Ignore', + gapUnignore: 'Count again', + gapIgnoreAll: 'Ignore all', + gapIgnoreAllConfirm: 'These {n} gaps will no longer be shown and no longer count toward the indicator dot. You can restore them under Ignored, at the bottom of this tab.', + gapNone: 'No open gaps.', + gapBusy: 'Filling…', + gapIgnoreConfirm: 'This gap will no longer be shown and no longer counts toward the indicator dot. Use this for days that no source has. You can restore it under Ignored.', + gapCountOne: '{n} gap', + gapCountMany: '{n} gaps', + dayCountOne: '{n} day', + dayCountMany: '{n} days', + suspectOne: '{n} suspicious price', + suspectMany: '{n} suspicious prices', + gapCardSummary: '{gaten}, {dagen}', + gapStop: 'Stop', + gapStopProgress: 'Stop ({i} of {n})', + gapStopped: 'Filling stopped; the rest was not requested.', + gapLimit: 'The monthly limit of the source has been reached, so filling stopped. What is still missing can be filled next month, or through another source for this type.', + gapFilled: '{asset}: the gaps are filled.', + gapRemaining: '{asset}: {n} days still without a price.', + gapWhySeries: '{reeks} provides a series that only starts on {begin}, and no active source fetches a price per date (such as GoldAPI). For the days before that, you can ignore the gap or enter prices manually; enabling a per-date source costs one request per day.', + gapWhySeriesHole: 'These days fall within the series from {reeks}, but that source has no prices for them itself: a gap in its data. A source that fetches a price per date (such as GoldAPI) can fill them, for {n} requests; otherwise ignore them or enter them manually.', + gapWhySeriesEmpty: '{reeks} returned nothing for these days, and no active source fetches a price per date (such as GoldAPI). Ignore the gap or enter prices manually.', + gapWhyPoint: '{bron} returned no price for these days: a weekend or holiday, a date before its history, or the monthly limit is used up.', + kind_close: 'closing price', + kind_manual: 'manual', + kind_live: 'today', + kind_spot: 'derived', + kind_carried: 'carried forward', + kind_none: 'no price', + kindCount: '{n} {soort}', + kindCount_closeOne: 'closing price', + kindCount_closeMany: 'closing prices', + kindCount_manualOne: 'manual', + kindCount_manualMany: 'manual', + kindCount_liveOne: 'today', + kindCount_liveMany: 'today', + kindCount_spotOne: 'derived', + kindCount_spotMany: 'derived', + kindCount_carriedOne: 'carried forward', + kindCount_carriedMany: 'carried forward', + kindCount_noneOne: 'without a price', + kindCount_noneMany: 'without a price', + cardPeriod: '{dagen}:', + cardNoPrices: 'No prices yet.', + stripDay: '{dag}: {soort}', + stripRange: '{van} to {tot}: {soort}', + cardGaps: '{gaten}, {dagen} without a price', + tabProvenance: 'Origin', + tabGaps: 'Gaps', + tabIgnored: 'Ignored', + tabAbsent: 'No price at source', + absentExplain: 'The source returned no price for these days, so the app does not request them again. Remove a line to have those days requested again at the next fill.', + absentLine: '{bron}: {van} to {tot}, {dagen}', + absentLineOne: '{bron}: {dag}', + absentForget: 'Request again', + tabPrices: 'Prices', + tabCheck: 'Check', + checkFactor: 'factor {n}', + checkShare: '{n}%', + checkHint: 'Delete or correct a wrong row under Prices. If a price is correct after all, you can accept the finding at System, Health.', + checkShowKind: 'Show in Prices', + checkOpenRow: 'Show this price in Prices', + checkFiltered: 'Filtered on Check: {soort} ({n}).', + checkFilteredDay: 'Filtered on {dag}, from Check.', + checkClearFilter: 'All prices', + tipMoment: 'price at {tijd}', + tipDeviation: '{voor} off the previous closing price, {na} off the next one', + provNoWeak: 'Every day in this period has a closing price.', + provRange: '{van} to {tot}', + provColPeriod: 'Period', + provColKind: 'Kind', + provColDays: 'Days', + provColNote: 'Details', + provColSource: 'Source', + provToGaps: 'Go to this gap under Gaps', + provSpotNote: 'up to {pct} off the closing price', + provCarriedNote: 'up to {n} days old', + tabSource: 'Source', + diagCard_noCoverage: 'No integration provides prices', + diagCard_allInactive: '{namen} is turned off', + diagCard_noChoice: 'No source selected', + diagCard_choiceUnusable: 'Source choice does not work', + diagCard_limitReached: 'Monthly limit reached: {namen}', + diagCard_choiceInactive: 'Selected but turned off: {namen}', + diag_noCoverage: 'No integration provides a series or a price per date for {symbool}. Add one under Integrations, or enter prices manually.', + diag_allInactive: '{namen} provides prices for {symbool}, but is turned off. Turn it on under Integrations.', + diag_noChoice: 'No source is selected for the price history of {symbool}, so no prices come in.', + diag_choiceUnusable: 'The selected source ({namen}) is turned off or provides no prices for {symbool}, so no prices come in.', + diag_limitReached: 'The monthly limit of {namen} has been reached. No new prices for {symbool} will come in this month.', + diag_limitReachedFallback: 'The monthly limit of {namen} has been reached. The next source in the chain takes over.', + diag_choiceInactive: 'Selected but turned off: {namen}. The next source in the chain takes over; choose another one under Integrations, Source choice, or turn it on.', + diagProposal: 'Proposal: {naam} for the price history.', + momentTitle: 'Price at a specific moment', + date: 'Date', + time: 'Time', + useTime: 'Use time', + peildatum: 'Reference date', + lookup: 'Fetch price', + loading: 'Fetching price…', + requested: 'Requested', + actual: 'Actual price point', + diff: 'difference', + histTitle: 'Stored price history (database)', + startDate: 'From date', + histLookup: 'Show', + sync: 'Sync with mempool', + syncing: 'Syncing…', + synced: 'price points stored', + storedEmpty: 'No stored history yet. Click "Sync with mempool" to fetch and store the price history.', + points: 'stored points', + noPoints: 'No stored price points on or after this date.', + colUnit: 'Per', + manualUnknownAsset: 'That asset does not exist (anymore).', + manualBadMoment: 'Enter a valid date.', + manualFutureMoment: 'A price cannot be in the future.', + manualBadPrice: 'A price must be a number above zero.', + colDateTime: 'Date & time', + colDate: 'Date', + colTime: 'Time', + colCombined: 'Integration · Asset · Price', + search: 'Search…', + assetLabel: 'Asset', + compare: 'Compare integrations', + comparing: 'Querying integrations…', + cmpDayTitle: 'Day resolution (daily figures)', + cmpDayNote: 'Daily figures for the selected date, comparable with each other.', + cmpTsTitle: 'Timestamp resolution (spot now)', + cmpTsNote: 'Live spot price per integration (current moment): not comparable with daily figures.', + cmpNone: 'No integration returned a price for this asset.', + colDiff: 'Difference', + colResolution: 'Resolution', + colIntegration: 'Integration', + colAsset: 'Asset', + resDay: 'day', + resTimestamp: 'timestamp', + tableTitle: 'All stored price points', + colUpdated: 'Updated', + allAssets: 'All assets', + refresh: 'Refresh', + delete: 'Delete', + noAsset: 'Choose an asset.', + noDate: 'Enter a date.', + lookupNone: 'No price point found, and fetching from the source returned nothing.', + dayCovers: 'Daily figure: applies to the whole day (no exact time).', + peilTitle: 'Reference date prices', + peilBody: 'Fetch the price for the configured reference date', + peilFetch: 'Fetch reference date prices (all assets)', + peilNoneForAsset: 'no price found', + resTitle: 'About the resolution', + resBody: 'This page uses the mempool API. The historical-price endpoint returns the nearest stored price point, not the exact requested time. For older data, that can differ by hours up to a day; the actual time of the returned point is therefore shown with the result. An exact price to the minute requires an exchange OHLC API (spot_api phase).', + }, + + tracking: { + title: 'Tracking', + zapFrom: 'Zap from {who}', + zapTo: 'Zap to {who}', + zapIn: 'Zap received', + zapOut: 'Zap sent', + noAccountsWithSources: 'No accounts with a tracking source yet.', + manageAccounts: 'Manage accounts', + selectAccount: 'Account', + accountTotal: 'Total balance', + sourceFallback: 'Tracking source', + badgeNwc: 'Wallet pubkey', + badgeNwcHint: 'An NWC wallet, identified by its wallet pubkey (Nostr Wallet Connect)', + badgeLnd: 'Lightning node', + badgeLndHint: 'An LND node, connected on the Connections tab', + walletOne: 'wallet', + walletMany: 'wallets', + statWallets: 'Wallets', + statTxs: 'Transactions', + statLinked: 'Linked', + selectedSum: 'Selected:', + selectedOf: '{n} of {m} accounts', + scanLast: 'Last updated on', + scanProgress: 'Wallet {i} of {n}', + scanUpdated: 'updated', + scanFailedN: 'failed', + proposalOne: 'proposal', + proposalMany: 'proposals', + scanNever: 'Not updated yet', + scanAll: 'Update', + scanning: 'Scanning…', + scanningTx: 'Fetching transactions', + scanError: 'Update failed', + scanNotOnchain: 'Cannot be scanned on-chain', + lightningMovements: 'Lightning transactions updated', + addressesScanned: 'addresses scanned', + addressesFailed: 'failed', + dirIn: 'Received', + dirOut: 'Sent', + fee: 'fee', + unconfirmed: 'Unconfirmed', + addLabel: 'Add label', + txCount: 'transactions', + copyAddr: 'Copy address', + collapseToggle: 'Expand/collapse', + noTxs: 'No transactions for this source yet.', + matched: 'Also recorded as an event', + }, + link: { + exact: 'Automatic match (address)', + soft: 'Suggested match (confirmed)', + manual: 'Linked manually', + }, + match: { + add: 'Link to records', + linked: 'Linked', + toEvent: 'Link to event', + toTx: 'Link to on-chain transaction', + unlink: 'Unlink', + filter: 'Filter candidates…', + noCandidates: 'No candidates found', + unconfirmed: 'unconfirmed', + }, + convert: { + title: 'Convert', + main: 'Main unit', + sub: 'Subunit', + apply: 'Apply', + pieces: 'Per piece', + product: 'Product', + count: 'Count', + }, + portfolios: { + maxFree: 'The free version is limited to {max} portfolio. A license removes that limit; nothing is removed in the meantime.', + title: 'Portfolios', + switchFailed: 'Switching failed.', + nameRequired: 'A portfolio must have a name.', + nameTooLong: 'A portfolio name is at most {max} characters long.', + nameIsDirRef: '"{naam}" is not a name but a reference to a folder.', + nameForbiddenChars: 'A portfolio name cannot contain any of these characters: < > : " / \\ | ? *', + nameTrailing: 'A portfolio name cannot end with a space or a period.', + nameLeadingSpace: 'A portfolio name cannot start with a space.', + nameReserved: '"{naam}" is a reserved name in Windows and cannot become a folder.', + nameTaken: '"{naam}" is a name the app uses itself; choose another one.', + activeCannotRename: '"{naam}" is the portfolio that is open now and cannot be renamed. Switch to another portfolio first; the app restarts when you do.', + activeCannotArchive: '"{naam}" is the portfolio that is open now and cannot be deleted. Switch to another portfolio first; the app restarts when you do.', + unknownPortfolio: 'There is no portfolio "{naam}".', + nameInUse: 'There is already a portfolio "{naam}"; names that differ only in capitals or spelling count as the same.', + archiveFull: 'The archive is full of folders named "{naam}"; clean up a few first.', + notInArchive: 'There is no "{naam}" in the archive.', + passwordRequired: 'Enter a password.', + intro: 'Each portfolio is a separate set of records: its own database and its own secrets, in its own folder. Exactly one is always open.', + openNow: 'Open now', + activate: 'Open…', + activateConfirm: 'Switch to "{naam}"? The app restarts to do this.', + activateWaiting: 'The app is restarting…', + activateNoSupervisor: 'The choice is saved, but this app was started without the supervisor (npm run start:server). Close the app and start it again to work in "{naam}".', + create: 'New portfolio…', + createName: 'Name', + createNameHint: 'The name becomes the folder name on disk.', + createCurrency: 'Base currency', + createDone: 'Portfolio "{naam}" created.', + copy: 'Copy…', + copyTitle: 'Copy of "{naam}"', + copyName: 'Name of the copy', + copyConfirm: 'Copy', + copyDone: 'Portfolio "{naam}" created as a copy of "{bron}".', + createHint: 'New portfolio gives fresh records without connections. You copy a portfolio from the list; a copy takes the database and the secrets along together, because a secret belongs to a row and only fits that database.', + createCurrencyHint: 'The base currency is the currency the portfolio calculates in and shows all amounts in. You choose it for a new portfolio and after that it is fixed; a copy keeps the currency of the original.', + rename: 'Rename…', + renameTo: 'New name', + renameDone: 'Renamed to "{naam}".', + renameActive: 'The portfolio that is open now cannot be renamed: its database is open. Switch to another one first.', + remove: 'Delete…', + removeConfirm: 'Delete portfolio "{naam}"? The folder moves to the archive; nothing is erased. Type the name to confirm.', + removeTypeLabel: 'Type the name', + removeMismatch: 'That is not the name; nothing was changed.', + removeDone: '"{naam}" is now in the archive.', + empty: 'There is no other portfolio yet.', + noDatabase: 'no database yet', + lastUsed: 'last used', + never: 'not used yet', + archiveTitle: 'Archive', + archiveIntro: 'Deleted portfolios are kept here. They no longer count anywhere, but the files still exist.', + archiveEmpty: 'The archive is empty.', + purge: 'Delete permanently…', + purgeConfirm: 'Permanently delete the folder of "{naam}" and everything in it? The database, secrets and history will be gone. This cannot be undone. Type the name to confirm.', + purgeDone: '"{naam}" has been permanently deleted.', + settingsTitle: 'Settings of this portfolio', + hintTitle: 'What you see here', + authTitle: 'Access to this portfolio', + authNone: 'No password set. The app can therefore only be reached from this machine.', + authSet: 'A password is set.', + authEnv: 'Set through the environment variable ASSETAPP_PASSWORD. As long as that is set, it overrides what you enter here.', + authChange: 'Set or change…', + authClear: 'Clear…', + authPassword: 'Password', + authClearConfirm: 'Remove the password of this portfolio? After that the app can only be reached from this machine.', + authDone: 'Saved. It applies from the next time the app starts.', + authClearedDone: 'Removed. It applies from the next time the app starts.', + authLogout: 'Sign out', + authLogoutConfirm: 'Sign out? You need to enter the password again to continue.', + authHint: 'Access from another device requires a certificate and sign-in credentials. The certificate belongs to this installation; the credentials to this portfolio.', + startYearTitle: 'Start year of these records', + startYearLabel: 'Start reports at', + startYearAuto: 'Automatic (from the first entry)', + startYearAutoState: 'Automatic: reports start at the first reference date after the first entry, {jaar}.', + startYearState: 'Reports start at reference date January 1, {jaar}. The first entry is in {eerste}.', + startYearEmpty: 'Nothing has been recorded yet, so there is nothing to limit. This becomes visible as soon as there is an entry or a balance.', + startYearHint: 'Only needed if your records go far back: with a first entry in 1984 the taxes page creates 42 reference date columns and asks the price source for as many historical points. Nothing is lost: the positions still include everything before the start year, only fewer columns are shown.', + mwrTitle: 'Return method', + mwrLabel: 'Calculate the money-weighted return with', + mwr_dietz: 'Modified Dietz (default)', + mwr_irr: 'Internal rate of return (IRR)', + mwrState_dietz: 'The return is divided by the average invested capital: each deposit counts in proportion to how long it was there.', + mwrState_irr: 'The return is the annual rate that exactly explains your deposits and withdrawals. Where none exists, the figure falls back to Modified Dietz.', + mwrHint: 'This changes the percentages on the taxes page. Modified Dietz always gives a figure; IRR is more accurate but does not always have a solution, for example with a position that drops to zero in between. The chosen method is always shown with the figure.', + }, + dbfile: { + title: 'All records', + passTooShort: 'Choose a password of at least 8 characters for the backup.', + notABackup: 'This file is not an AssetApp backup (too short).', + notABackupUseRestore: 'This file is not an AssetApp backup. You restore a separate .sqlite file with "Restore".', + unknownBackupVersion: 'Unknown backup version ({versie}); this app knows version {kent}.', + decryptFailed: 'Decryption failed: wrong password, or the file is damaged.', + noManifest: 'Backup damaged: no manifest found.', + manifestUnreadable: 'Backup damaged: the manifest is unreadable.', + manifestLengths: 'Backup damaged: the lengths in the manifest do not match the content.', + secretsUnreadable: 'Backup damaged: the secrets file is not readable JSON.', + notReadableSqlite: 'This is not a readable SQLite file ({reden}).', + noSchemaTable: 'This file has no Schema table. A database from before the baseline can no longer be converted: the conversion script only exists in the git history (git log --all -- scripts/baseline-convert.mjs).', + noReadableVersion: 'The Schema table contains no readable version.', + versionBeforeBaseline: 'This database is version {versie}, from before the new setup (version {baseline}). Converting is no longer possible; the conversion script only exists in the git history (git log --all -- scripts/baseline-convert.mjs).', + versionNewerThanApp: 'This database is version {versie} and therefore newer than the app ({app}). Update the app before you restore it.', + emptyFile: 'Empty file received.', + notSqlite: 'This is not an SQLite file (the file header does not match).', + backup: 'Back up…', + backupPass: 'Password for the backup', + backupPassAgain: 'Repeat password', + backupPassMismatch: 'The two passwords do not match.', + backupDone: 'Backup created. Keep the password safe: without the password the backup cannot be restored.', + export: 'Export database', + import: 'Restore…', + confirmRestoreBackup: 'Restore this backup? This replaces the database AND the secrets (secrets.json in the folder of this portfolio). The previous version of both is kept as .previous.', + restoredSecrets: 'Secrets restored as well:', + confirmImport: 'Replace the current database with this file? The previous one is kept as .previous next to the database file.', + importDone: 'Database restored', + importOrphans: 'Note, orphaned references found:', + hint: 'The backup contains the database and the secrets, encrypted. The export is only the database: enough to read, not enough to restore everything.', + fresh: 'Start empty…', + confirmFresh: 'Start with empty records? A fresh database is put into use; the current one is kept as .previous next to the database file. Make a backup first if you might ever need it again.', + freshWord: 'empty', + freshTypeLabel: 'Type the word to confirm', + freshWordMismatch: 'That is not the requested word; nothing was changed.', + freshDone: 'Empty records in use', + }, + import: { + title: 'Import', + fileLabel: 'CSV file', + read: 'Read', + template: 'Blank template', + formatHint: 'Default format = the Bitvavo export (tab- or comma-separated). Your own files with the same headers also work; optional column "Account".', + noFile: 'Choose a file first.', + readError: 'Reading the file failed.', + origin: 'Origin', + originPh: 'e.g. Bitvavo', + summary: 'Summary', + nEvents: 'as event', + nSkipped: 'skipped', + account: 'Account (exchange) for this import', + chooseAccount: '(choose account)', + newAccount: '+ new exchange account…', + newAccountPh: 'Name of new account', + assetsTitle: 'Assets', + assetOk: 'linked', + assetMissing: 'no asset: create it under System → Assets (rows are skipped)', + addresses: 'Address destinations (withdrawals)', + destAccount: '(destination account)', + noSource: '(no specific tracking source)', + alreadyLinked: 'already linked', + process: 'Create events', + needAccount: 'Choose or create the exchange account first.', + needDest: 'Choose a destination account for each address.', + done: 'Import processed', + processFailed: 'Processing failed', + resCreated: 'created', + resUpdated: 'overwritten', + resKept: 'kept', + resUnchanged: 'unchanged', + resSkipped: 'skipped', + rows: 'rows', + conflictsTitle: 'Existing records', + conflictsIntro: 'These records already exist. By default they are overwritten; set one to "Keep" to leave the existing version in place.', + colField: 'Field', + colExisting: 'Existing', + colImported: 'Imported', + keep: 'Keep', + overwrite: 'Overwrite', + allKeep: 'Keep all', + allOverwrite: 'Overwrite all', + continue: 'Continue', + fType: 'Type', + fDate: 'Date', + fAmount: 'Amount', + fPrice: 'Price', + fFee: 'Fee ({munt})', + fFeeUnit: 'Fee', + notBookedTitle: 'These lines are not recorded', + feeNotBooked: 'Line {regel}: fees on interest, or fees in the asset itself on a purchase, are not counted anywhere. Record this line manually.', + fFrom: 'From', + fTo: 'To', + fAddress: 'Address', + resDeleted: 'deleted', + multiTitle: 'Multiple matches', + multiIntro: 'One import line matches several existing records. The import line is created; for each existing record, choose whether to leave it (Ignore) or delete it.', + importRow: 'Import line', + ignore: 'Ignore', + delete: 'Delete', + linkedNoDelete: 'linked, cannot be deleted', + identical: 'Identical', + }, + health: { + summary: '{n} findings.', + start: 'Run check', + again: 'Check again', + lastRun: 'Last check: {wanneer}.', + neverRun: 'This check has never been run.', + checking: 'Check {i} of {n}: {naam}…', + checkFailed: 'This check could not be run:', + slowest: 'The slowest check was "{naam}" ({s} seconds).', + more: 'And {n} more. The full list is in the table itself.', + accept: 'This is correct', + acceptConfirm: 'This finding disappears from the report as long as the situation stays the same. If something changes (a case is added, or a count changes), it appears again.', + accepted: 'Accepted. This finding is now listed under the accepted findings at the bottom.', + severityWarn: 'Probably unintended', + severityInfo: 'For information', + acceptedTitle: 'Accepted findings', + acceptedNone: 'No findings accepted yet.', + unaccept: 'Show again', + acceptUnknownKind: 'That kind of finding does not exist.', + acceptNeedsSummary: 'An accepted finding needs a readable summary.', + allClear: 'No findings.', + allClearWhy: 'This report looks at duplicate entries, balances that are not reconciled, gaps and delays in the price history, suspicious prices, transfers without a counterpart and a few technical references.', + kind: { + duplicate_events: 'Duplicate entries', + assets_without_balance: 'Assets without a balance', + unreconciled_valuations: 'Balances that are not reconciled', + stale_prices: 'Outdated prices', + late_price_history: 'Price history starts too late', + price_gaps: 'Gaps in the price history', + price_stopped: 'Holding without a current price', + transfer_without_counterpart: 'Transfer without a counterpart', + unlinked_transactions: 'Unlinked transactions', + coverage_without_asset: 'Coverage without an asset', + price_unit_swap: 'Price in the wrong unit', + price_outlier: 'Outlier in the price history', + price_sources_disagree: 'Sources disagree', + price_not_positive: 'Price of zero or less', + price_in_future: 'Price in the future', + fee_without_effect: 'Fees that count nowhere', + booking_price_deviates: 'Entry price far from the daily price', + price_origin_derivable: 'Price origin can be derived', + }, + why: { + duplicate_events: 'The same entry appears twice: same asset, type, moment and quantity. Almost always one purchase that was entered twice. For a product entry the product also counts, because different coins of the same weight are not the same.', + assets_without_balance: 'You hold this asset, but there is not a single balance against it. That is fine: records that run on the ledger are complete without balances. It only means there is nothing to check that ledger against.', + unreconciled_valuations: 'A balance differs from what the entries say, and that difference has not yet been recorded or accepted. You can resolve it on To do.', + stale_prices: 'The latest price of this asset is old. The current value then falls back to that point, however old it is, and that is not shown anywhere.', + late_price_history: 'You have held this asset longer than the price history goes back. Everything before the first price point therefore cannot be valued; you can add a price point manually on Price history.', + price_gaps: 'There is a gap between two price points, counted in days without a price since your first entry, as on Price history. The daily grid carries the previous price forward up to the age limit and gives nothing after that.', + price_stopped: 'You hold this asset now, but the latest price is older than the app carries it forward. On a reference date after that price it therefore cannot be valued. Find a source, or add a price manually on Price history.', + transfer_without_counterpart: 'A transfer names only one account. That can be correct (to or from outside), but between your own accounts there should be a counterpart.', + unlinked_transactions: 'A transaction on the chain is not linked to any entry. It then counts in your balance but not in your ledger.', + coverage_without_asset: 'A source claims to provide a symbol that is not an asset here. Not wrong, but good to know before you rely on it.', + price_unit_swap: 'This price is a factor 31 off the price before it, and that is the number of grams in a troy ounce. Almost always a price per ounce stored as a price per gram, or the other way around. Correct or delete it on Price history, under Prices.', + price_outlier: 'This price is a factor three or more above or below both its neighbors. No market does that in a day; a zero too many or too few does.', + price_sources_disagree: 'Two sources give prices for the same day that are far apart. That is not an error of either one, but a choice: the app uses the most recently written row.', + price_not_positive: 'A price of zero or less is not a price. The app does not count this row, so it is there but does nothing.', + price_in_future: 'This price has a moment that has not happened yet. That can only be a typo in the date.', + fee_without_effect: 'This entry carries fees in a place no calculation reads: fees in money on interest, or fees in the asset itself on a purchase or interest. They are on it but do nothing.', + booking_price_deviates: 'The price of this purchase or sale is more than 20% off the price of that day, or a factor 31 off (gram versus troy ounce). A spread of a few percent is normal; this is usually a typo, a wrong date or the wrong unit.', + price_origin_derivable: 'For these entries it is not recorded where the price came from, but it is exactly the daily price: the form filled it in itself. The button gives them the origin Daily price. Anything that differs stays unknown, because then it cannot be told whether it was paid or the price at the time.', + }, + fillOrigins: 'Fill in origin', + fillOriginsConfirm: 'These entries get the origin Daily price. The change is written to the change log.', + fillOriginsDone: 'The origin was filled in for {n} entries.', + }, + data: { + title: 'Data', + table: 'Table', + rows: 'rows', + empty: 'No rows.', + delete: 'Delete', + confirmDelete: 'Permanently delete this row?', + clear: 'Clear table', + confirmClear: 'Permanently delete all records from this table? This cannot be undone.', + }, + signals: { + title: 'Bitcoin Signals & Charts', + noHistory: 'No price history in storage.', + noAssetForHistory: 'No {symbool} asset found for price history.', + live: 'LIVE DATA', + price: 'Price', + tabPrice: 'Bitcoin price', + tabPl: 'Power Law', + tabWma: 'Weekly averages', + tabMayer: 'Mayer Multiple', + sigPlLabel: 'Power Law', + sigWmaLabel: '200-week MA', + sigMayerLabel: 'Mayer Multiple', + priceTitle: 'Bitcoin price', + priceSub: 'Historical price', + plTitle: 'Power Law Corridor', + plSub: 'Live price vs. long-term trend · log-log', + wmaTitle: '50- and 200-week average', + wmaSub: '200-week: historical bottom zone · 50-week: dividing line between the regimes', + mayerTitle: 'Mayer Multiple', + mayerSub: 'Price ÷ 200-day average · over-/undervaluation', + log: 'Logarithmic', + lin: 'Linear', + zoomRecent: 'Zoom recent', + resetZoom: 'Reset zoom', + fullscreen: 'Full screen', + loading: 'Loading…', + error: 'Cannot load data', + mayerInfo1Title: 'Mayer < 1.0:', + mayerInfo1: 'price is below the 200-day average. Historically a strong but rare zone (local bottoms).', + mayerInfo2Title: 'Mayer 1.0 to 2.4:', + mayerInfo2: 'normal zone. Price above the 200-day average, but not yet stretched.', + mayerInfo3Title: 'Mayer ≥ 2.4:', + mayerInfo3: "historically overheated (Mayer's own threshold). Often near cycle tops.", + hintTitle: 'What you see here', + hintLightsTitle: 'The three signals', + hintLights: 'The lights summarize the three charts and stay in place while you move through them. Click one to go to that chart. Green, yellow and red are a judgment here, and the only place on this page where color means that.', + hintColorsTitle: 'The colors in the charts', + hintColors: 'Each line is a shade of the accent color: solid is the price, one shade lighter is the signal line next to it, and the lightest is the reference or the band behind it. So not a color code, but a ranking.', + hintPl: 'The Power Law is a straight line in log-log: the price grows as a power of the time since the genesis block. The line is the fit, not a prediction.', + hintPlBands: 'The bands are a fixed factor on that fit, set by the app to what has roughly held historically. They are a drawing and never the data.', + hintPlProjection: 'The lines continue one year beyond the latest price. Short on purpose: drawing further ahead suggests a predictive value this fit does not have.', + hintWma200: 'The 200-week average is the slow bottom zone of the cycle.', + hintWma50: 'The 50-week average is the dividing line between the two regimes: support in a rising market, and losing it has historically coincided with the start of the decline.', + leg: { + btc: 'Bitcoin', fit: 'Power Law fit', lower: 'Lower band', upper: 'Upper band', + wma: '200-week MA', wma50: '50-week MA', mayer: 'Mayer Multiple', + }, + sig: { + plStrong: 'STRONG BUY ZONE', plBuy: 'BUY ZONE', plAbove: 'ABOVE TREND', plFar: 'FAR ABOVE TREND', + wmaBuy: 'BUY ZONE', wmaAbove: 'ABOVE 200WMA × 1.2', wmaFar: 'FAR ABOVE 200WMA', + mayerLow: 'BUY ZONE', mayerMid: 'NEUTRAL', mayerHigh: 'OVERHEATED', + }, + }, + stub: { + title: 'Not built yet', + body: 'This page comes in a later phase. The skeleton and the database already work.', + }, + enums: { + asset_kind: { crypto: 'Crypto', metal: 'Metals', fiat: 'Currencies' }, + tax_category: { bank: 'Bank balance', overig: 'Other asset' }, + tax_scope: { all: 'Counts', category: 'Listed separately only', none: 'Does not count' }, + wealth_scope: { managed: 'Third-party wealth', none: 'Does not count' }, + metal_form: { coin: 'Coin', bar: 'Bar', other: 'Other' }, + displayUnit: { unit: 'Main unit', subunit: 'Subunit' }, + location_kind: { exchange: 'Exchange', wallet: 'Wallet', lightning: 'Lightning', card: 'Card', bank: 'Bank', vault: 'Vault' }, + account_kind: { exchange: 'Exchange', wallet: 'Wallet', lightning: 'Lightning', card: 'Card', bank: 'Bank', vault: 'Vault' }, + event_source: { manual: 'Manual', import: 'Import', reconcile: 'Reconcile', lightning: 'Lightning' }, + event_type: { buy: 'Buy', sell: 'Sell', transfer: 'Transfer', interest: 'Interest', reservation: 'Reservation' }, + event_price_origin: { paid: 'Paid', market_day: 'Daily price', market_moment: 'Price at the time', none: 'Gift' }, + valuation_source: { onchain: 'On-chain', manual: 'Manual', statement: 'Statement' }, + interest_cadence: { term_end: 'End of term', annual: 'Annually', quarterly: 'Quarterly', monthly: 'Monthly', weekly: 'Weekly' }, + interest_rate_kind: { fixed: 'Calculated', variable: 'Manual or import' }, + reconcile_status: { pending: 'Open', accepted_trade: 'Buy/Sell', accepted_transfer: 'Transfer', accepted_interest: 'Interest', ignored: 'Ignored', auto_linked: 'Auto-linked' }, + }, +}; diff --git a/client/i18n/index.js b/client/i18n/index.js new file mode 100644 index 0000000..f332af2 --- /dev/null +++ b/client/i18n/index.js @@ -0,0 +1,28 @@ +// Generated, do not edit. sha256:ce8fbbb506834f134cab273f778af5fb72846a7d201d4112d3740ac9125ead32 +import { CONFIG } from '../../shared/config.js'; +import { setDateLocale } from '../../shared/time.js'; +import { nl } from './nl.js'; +import { en } from './en.js'; + +export const LOCALES = Object.freeze({ nl, en }); + +let code = 'nl'; +let active = nl; + +export function setLocale(next) { + if (!LOCALES[next] || !CONFIG.LOCALE_TAGS[next]) throw new Error(`Unknown locale: '${next}'.`); + code = next; + active = LOCALES[next]; + setDateLocale(CONFIG.LOCALE_TAGS[next]); +} + +export const locale = () => code; + +export const localeTag = () => CONFIG.LOCALE_TAGS[code]; + +export function t(path, params) { + const val = path.split('.').reduce((o, k) => (o == null ? o : o[k]), active); + if (val === undefined) throw new Error(`Missing translation key: '${path}'.`); + if (!params || typeof val !== 'string') return val; + return Object.entries(params).reduce((s, [k, v]) => s.replaceAll(`{${k}}`, String(v)), val); +} diff --git a/client/i18n/nl.js b/client/i18n/nl.js new file mode 100644 index 0000000..d711667 --- /dev/null +++ b/client/i18n/nl.js @@ -0,0 +1,2538 @@ +// Generated, do not edit. sha256:5337809d822cab071fd28371d6976e564034e1f27296fd058188237da0027ce9 +export const nl = { + app: { name: 'Atreides Assets' }, + + common: { + loading: 'Laden…', + loadError: 'Kan gegevens niet laden', + manageAssets: 'Assets beheren', + sessionExpired: 'De sessie is verlopen. Je wordt naar het inlogscherm gestuurd.', + backendSilent: 'De backend antwoordt niet binnen {s} seconden. Draait het server-proces?', + requestTooLarge: 'Het verzoek is te groot.', + fileTooLarge: 'Het bestand is te groot.', + invalidAmount: 'Ongeldige hoeveelheid.', + }, + + splash: { + quote: '"I must not fear. Fear is the mind-killer."', + logoHint: 'Toon het openingsscherm', + }, + + attention: { + title: 'Vraagt aandacht', + none: 'Niets dat aandacht vraagt', + integrationNoKeySuffix: ': sleutel vereist, nog niet ingevoerd', + priceGaps: 'Koershistorie: {n} gaten om bij te werken', + usageNear: '{naam}: {calls} van {limiet} aanroepen deze maand', + usageOver: '{naam}: maandlimiet bereikt ({calls} van {limiet})', + actionsReconcilePrefix: 'Reconcile: ', + actionsReconcileSuffix: ' openstaand', + actionsInterestPrefix: 'Rente te boeken: ', + actionsValuationPrefix: 'Waarderingsverschillen: ', + actionsAiPrefix: 'AI-voorstellen: ', + actionsReservationPrefix: 'Reserveringen af te handelen: ', + healthPrefix: 'Bevindingen over uw administratie: ', + backupNever: 'Nog geen back-up gemaakt', + backupStalePrefix: 'Laatste back-up ', + backupStaleSuffix: ' dagen geleden', + healthNever: 'Administratie nog nooit gecontroleerd', + healthStalePrefix: 'Laatste controle ', + healthStaleSuffix: ' dagen geleden', + aiActivityPrefix: 'AI-activiteit: ', + aiActivitySuffix: ' nieuwe aanroep(en)', + licenseChanged: 'De licentie is gewijzigd', + }, + + commands: { + goingTo: 'Een AI brengt u naar', + stop: 'Stop', + goNow: 'Ga direct', + goAlways: 'Ga direct voortaan', + movedTo: 'Een AI bracht u naar', + countdownBack: 'Toch aftellen', + stopped: 'Tegengehouden door de gebruiker.', + badTarget: 'De opdracht wees niet naar een geldige plek in de app.', + tooLate: 'De opdracht was bijna verlopen toen dit tabblad hem oppakte; er is niet genavigeerd.', + unknownKind: 'Dit tabblad kent deze soort opdracht niet.', + }, + + license: { + lockedTitle: 'Hoort bij de betaalde versie', + counter: '{n} van {max}', + whatIsPaid: 'Wat zit er in de betaalde versie?', + why_develop: 'Ontwikkelversie: er wordt niets beperkt onder 1.0.0', + why_forced: 'Geforceerd via ASSETAPP_LICENSE_STATE (alleen bij ontwikkelen)', + 'why_forced-develop': 'Geforceerd via ASSETAPP_LICENSE_STATE (alleen bij ontwikkelen)', + why_none: 'Geen licentiebestand gevonden', + why_invalid: 'Het licentiebestand is niet geldig', + why_unreadable: 'Het licentiebestand is niet te lezen', + 'why_out-of-range': 'De licentie geldt niet voor deze hoofdversie', + 'why_unknown-version': 'De versie van de app is niet te bepalen', + why_licensed: 'Geldige licentie', + cardTitle: 'Licentie', + cardFree: 'Deze installatie draait de gratis versie: onbeperkt in tijd, met een kleine ' + + 'administratie en zonder de koppelingen, de bronkeuze en de belastingpagina. Lezen en ' + + 'exporteren blijven altijd werken; er wordt nooit iets weggehaald.', + cardBuy: 'De betaalde versie heft die grenzen op. Hebt u een licentiebestand, zet het dan hieronder ' + + 'neer.', + cardFull: 'Deze installatie draait de volledige versie. Alle grenzen zijn opgeheven en elk ' + + 'onderdeel staat open.', + cardDevelop: 'Dit is een ontwikkelversie: onder 1.0.0 beperkt de app niets.', + cardFacts: 'De licentie is voor {buyer}, afgegeven op {issued} en geldig voor hoofdversie {from} ' + + 'tot en met {to}.', + cardOutOfRange: 'Deze app is versie {version} en valt buiten dat bereik.', + cardProblem: 'Er staat een licentiebestand, maar de app kan er niets mee.', + cardVersion: 'Versie {version}', + signInTitle: 'Aanmelden', + signInIntro: 'Uw licentie hoort bij een nostr-sleutel. Meld u aan om de licenties bij die sleutel op ' + + 'te halen.', + signInLast: 'Laatst aangemeld als {npub}.', + signInExtension: 'Met extensie', + signInExtensionNone: 'Geen nostr-extensie gevonden in deze browser (zoals Alby of nos2x).', + signInKey: 'Met sleutel', + signInNew: 'Nieuwe sleutel', + signInKeyLabel: 'Geheime sleutel (nsec)', + signInKeyHint: 'De sleutel blijft in dit tabblad en wordt nergens bewaard. Na herladen meldt u zich ' + + 'opnieuw aan.', + signInGo: 'Aanmelden', + signInBadKey: 'Dit is geen geldige geheime sleutel. Een nsec begint met nsec1.', + signInNpub: 'Dit is de publieke sleutel (npub). Aanmelden gaat met de geheime sleutel (nsec).', + newKeyTitle: 'Uw nieuwe sleutel', + newKeyIntro: 'Bewaar deze geheime sleutel goed. Wie hem heeft, kan uw licentie ophalen; wie hem ' + + 'kwijt is, kan dat niet meer. De app bewaart hem niet.', + newKeyPublic: 'Publieke sleutel: {npub}', + newKeyCopy: 'Kopiëren', + newKeyCopied: 'Gekopieerd', + newKeyDownload: 'Downloaden', + newKeySaved: 'Ik heb deze sleutel bewaard', + newKeyGo: 'Doorgaan', + newKeyCancel: 'Annuleren', + newKeyFileName: 'nostr-sleutel.txt', + signedIn: 'Aangemeld als {npub}.', + signedInExtension: 'Aangemeld als {npub}, met extensie.', + signOut: 'Afmelden', + otherBuyer: 'De geïnstalleerde licentie hoort bij een andere sleutel.', + licensesLoading: 'Licenties ophalen…', + licensesNone: 'Bij deze sleutel staan nog geen licenties.', + licenseLine: '{level}, hoofdversie {from} tot en met {to}, afgegeven op {issued}.', + level_full: 'Volledige versie', + level_free: 'Gratis versie', + licenseInstalled: 'Geïnstalleerd', + licenseInstall: 'Installeren', + licenseNotValid: 'Deze app aanvaardt deze licentie niet.', + licenseNotCovered: 'Geldt niet voor deze versie.', + buyTitle: 'Kopen', + buyPrice: 'Een volledige licentie voor hoofdversie {major} kost {sats} sats.', + buyClosed: 'De verkoop is op dit moment gesloten.', + buyAway: 'Atreides Services is nu niet bereikbaar, dus kopen kan op dit moment niet. Probeer het later ' + + 'opnieuw.', + buySignInFirst: 'Meld u eerst aan: de licentie komt op uw sleutel te staan.', + buyButton: 'Kopen', + buyInvoice: 'Betaal deze invoice met een Lightning-wallet. Hij is geldig tot {time}.', + buyOpenWallet: 'Openen in wallet', + buyQr: 'QR-code van de invoice, om met een wallet te scannen', + buyCopy: 'Invoice kopiëren', + buyCopied: 'Gekopieerd', + buyWaiting: 'Wachten op de betaling…', + buyPaid: 'Betaald. De licentie wordt uitgegeven…', + buyWaived: 'Kwijtgescholden. De licentie wordt uitgegeven…', + buyExpired: 'De invoice is verlopen zonder betaling.', + buyCanceled: 'De invoice is geannuleerd.', + buyUnderpaid: 'Er is minder betaald dan het bedrag; neem contact op met de verkoper.', + buyAgain: 'Nieuwe order', + buyNotValid: 'De licentie is uitgegeven, maar deze app aanvaardt hem niet. Neem contact op met de verkoper.', + supportTitle: 'Support', + supportIntro: 'Een gesprek met de verkoper, via Atreides Services. Uw berichten gaan van uw aangemelde ' + + 'sleutel uit, en het antwoord leest u hier. Een screenshot kan mee.', + supportAway: 'Atreides Services is nu niet bereikbaar, dus support kan op dit moment niet. Probeer het ' + + 'later opnieuw.', + supportNostr: 'Liever via nostr? Stuur een privébericht aan {name}.', + supportLabel: 'Uw bericht', + supportPlaceholder: 'Typ een bericht…', + supportSend: 'Versturen', + supportSending: 'Versturen…', + supportEmpty: 'Typ eerst een bericht of kies een screenshot.', + supportTooLong: 'Een bericht is hooguit {max} tekens.', + supportAttach: 'Screenshot', + supportAttachHint: 'PNG, JPEG of WebP tot {mb} MB; plakken kan ook.', + supportAttachType: 'Een screenshot is een PNG-, JPEG- of WebP-afbeelding.', + supportAttachTooLarge: 'Een screenshot is hooguit {mb} MB.', + supportRemoveAttachment: 'Screenshot weghalen', + supportScreenshot: 'Screenshot', + supportShowScreenshot: 'Screenshot tonen', + supportRefresh: 'Vernieuwen', + supportThreadLoading: 'Gesprek ophalen…', + supportThreadEmpty: 'Nog geen berichten. Stel hieronder uw vraag.', + supportYou: 'U', + supportSeller: 'Support', + supportAi: 'Support, opgesteld met hulp van AI', + payTitle: 'Betalen met Lightning', + payIntro: 'U betaalt met een Lightning-invoice die de dienst van de verkoper maakt; de app ziet uw wallet ' + + 'niet. Daarvoor hebt u een wallet met sats nodig.', + payGroup_easy: 'De makkelijkste weg, voor kleine bedragen', + payGroup_other: 'Andere apps', + payApp_phoenix: 'is een Lightning-wallet op uw telefoon die zelf een node draait; het kanaal regelt de app ' + + 'voor u. Daarmee scant u de QR-code van de invoice.', + payApp_pocket: 'zet een overboeking van uw bankrekening om in sats en stuurt die via Lightning naar Phoenix. ' + + 'Eenmalig een ID-controle.', + payApp_strike: 'betaalt een invoice direct uit de app, met geld van uw bankrekening. Strike bewaart het ' + + 'saldo en vraagt een ID bij het aanmelden.', + payApp_zeus: 'draait LND op uw telefoon of koppelt aan uw eigen node, zoals die op een Umbrel; kanalen en ' + + 'het vullen regelt u zelf.', + payApp_bringin: 'is een Lightning-wallet met eigen sleutels, en geeft een eigen IBAN om sats te kopen (met ' + + 'ID-controle).', + payNote: 'Stand van oktober 2026: controleer de voorwaarden bij de aanbieder.', + nostrMore: 'Over nostr en uw sleutel', + signInKnown: 'Uw licentie staat op {npub}. Onderteken met die sleutel om licenties op te halen, te kopen ' + + 'of support te vragen; de npub alleen bewijst niet dat u het bent.', + infoNostrHead: 'Wat is nostr?', + infoNostr: 'Nostr is een open protocol waarin uw identiteit een sleutelpaar is. De publieke sleutel ' + + '(npub) is als een adres: die mag iedereen zien. De geheime sleutel (nsec) is als een wachtwoord dat ' + + 'niemand voor u kan herstellen: wie hem heeft, is u.', + infoWhyHead: 'Waarom hangt de licentie aan een sleutel?', + infoWhy: 'Er is geen account en geen e-mailadres nodig. De licentie is ondertekend op uw npub en de ' + + 'app controleert hem zonder internet. Met dezelfde sleutel haalt u hem op elke installatie terug, ook ' + + 'na een herinstallatie.', + infoSignInHead: 'Hoe meldt u zich aan?', + infoSignIn: 'Met een browserextensie zoals Alby of nos2x blijft de sleutel in de extensie en ziet de app ' + + 'hem nooit. Met de sleutel zelf blijft hij in dit tabblad en wordt hij nergens bewaard. Hebt u nog ' + + 'geen sleutel, maak er dan hier een en bewaar hem goed.', + infoZapHead: 'En een zap?', + infoZap: 'Een zap (NIP-57) is een Lightning-betaling met een nostr-bewijs erbij, bedoeld als fooi onder een ' + + 'bericht. Voor de licentie is dat niet nodig: een gewone invoice volstaat, en de licentie staat toch al ' + + 'op uw sleutel.', + link_nostrHow: 'nostr.how: wat nostr is, voor beginners', + link_nostrOrg: 'nostr.org: introductie en apps (van LNbits)', + link_alby: 'Alby: browserextensie met nostr en Lightning', + link_nos2x: 'nos2x: browserextensie die alleen ondertekent', + link_lightning: 'lightning.network: het Lightning Network', + infoPrivacyHead: 'Wat weet de verkoper van u?', + infoPrivacy: 'Uw npub en de orders erbij. Een e-mailadres alleen als u dat opgeeft, en dan onherkenbaar ' + + 'gemaakt, zodat het alleen terug te vinden is als u zich er zelf mee meldt.', + name_fiatAccounts: 'Fiatrekeningen', + featTitle: 'Wat de edities bieden', + featFull: 'Volledig', + featFree: 'Gratis', + featFreeOpen: 'Wat de gratis editie krijgt, ligt nog niet vast.', + featNoShot: 'Nog geen schermafdruk van dit onderdeel.', + featIncluded: 'Inbegrepen', + featExcluded: 'Niet inbegrepen', + featUndecided: 'Nog niet bepaald', + featPartial: 'deels', + featPartialTitle: 'Een deel is inbegrepen', + featGroup_overview: 'Overzicht en rapportage', + featGroup_bitcoin: 'Bitcoin en Lightning', + featGroup_records: 'Administratie', + featGroup_prices: 'Koersen', + featGroup_app: 'De app', + feat_dashboard: 'Dashboard met eigen widgets', + feat_wealth: 'Vermogen per asset en account', + feat_returns: 'Rendement per periode', + feat_taxes: 'Belastingen per peildatum', + feat_tracking: 'On-chain tracking van wallets', + feat_lightning: 'Lightning via LND en NWC', + feat_interest: 'Rente en beloningen', + feat_balances: 'Balansen en reconcile', + feat_ownMempool: 'Eigen mempool-node', + feat_priceHistory: 'Prijsgeschiedenis', + feat_sourceChoice: 'Bronkeuze per asset', + feat_providerKeys: 'Eigen koersbronnen en API-sleutels', + feat_signals: 'Bitcoin-signalen, grafieken en widget', + feat_netshare: 'Widget netwerkaandeel', + feat_portfolios: 'Meerdere portefeuilles', + feat_ai: 'AI-koppeling (MCP)', + feat_import: 'Importeren en exporteren', + feat_data: 'Data: de ruwe tabellen', + feat_health: 'Gezondheid: controles op de administratie', + feat_kiosk: 'De app in een eigen venster', + feat_metals: 'Edelmetaal, munten en baren', + unlimited: 'onbeperkt', + dropHere: 'Sleep het licentiebestand hierheen', + dropOr: 'of klik om er een te kiezen', + dropTooBig: 'Dat bestand is veel te groot voor een licentie. Het licentiebestand is een klein ' + + 'JSON-bestand van hooguit een paar regels.', + installOk: 'Licentie opgeslagen. De app is nu de volledige versie.', + installOkFuture: 'Licentie opgeslagen, maar hij geldt niet voor deze hoofdversie. De app blijft ' + + 'voorlopig in de gratis stand.', + installUnreadable: 'Dit is geen geldig licentiebestand: {reason}', + installBadSignature: 'De handtekening hoort niet bij deze licentie, of hij is niet voor deze app ' + + 'afgegeven. Er is niets opgeslagen.', + accountsWhat: 'De gratis versie houdt het op één fiatrekening. Met een licentie voert u al uw ' + + 'bank- en spaarrekeningen in, met rente en al.', + matrixWhat: 'De bronkeuze-matrix hoort bij de betaalde versie. Daarmee bepaalt u per asset welke ' + + 'bron voorgaat en waarop de app terugvalt; zonder die keuze gebruikt hij de standaardbronnen.', + aiWhat: 'De AI-koppeling hoort bij de betaalde versie. Daarmee kan een AI-assistent uw administratie ' + + 'lezen en voorstellen doen, met een logboek van alles wat er gedeeld is.', + keysWhat: 'Eigen provider-sleutels horen bij de betaalde versie. De standaardbronnen werken zonder ' + + 'sleutel; een eigen abonnement bij een koersbron voert u hier in.', + taxWhat: 'De belastingpagina geeft uw vermogen op elke peildatum en het resultaat per jaar, met de ' + + 'bank- en overige bezittingen apart, en laat per post zien waar het getal vandaan komt.', + }, + + connections: { + lightningPaid: 'Lightning-verbindingen horen bij de betaalde versie.', + ownExplorerPaid: 'Een eigen explorer hoort bij de betaalde versie; de standaardbron blijft werken.', + kindBtcExplorer: 'Bitcoin Blockchain Explorer', + usedByBtcExplorer: 'Gebruikt door: wallet-tracking (Tracking), en koersbron "mempool" (Providers) als die er is.', + newProfile: 'Nieuw profiel', + editProfile: 'Profiel wijzigen', + name: 'Naam', + url: 'Basis-URL', + save: 'Opslaan', + test: 'Test', + edit: 'Wijzig', + delete: 'Verwijder', + active: 'Actief', + notTested: 'Niet getest', + testing: 'Testen…', + statusOk: 'Verbonden', + statusFail: 'Geen verbinding', + blockHeight: 'blokhoogte', + lastChecked: 'Laatst getest:', + badResponse: 'Onverwacht antwoord van de server.', + testUnsupported: 'Testen voor dit soort verbinding wordt nog niet ondersteund.', + cspBlocked: 'Dit adres is toegevoegd nadat de pagina geladen werd, en de browser laat het pas toe na ' + + 'herladen. Herlaad de pagina en test opnieuw.', + confirmDelete: 'Dit profiel verwijderen?', + kindLnd: 'Lightning-node (LND)', + usedByLnd: 'Gebruikt door: de lightning-tracker (volgt in B3). De app praat via de eigen backend met de node: de node is niet publiek; macaroon en tls.cert blijven server-side.', + lndUrl: 'LND-REST-URL', + lndMacaroon: 'Macaroon (hex of base64; read-only gebakken aanbevolen)', + lndTlsCert: 'tls.cert (PEM; optioneel: mét cert gepind en geverifieerd, zónder cert onverifieerd https)', + secretKeepHint: 'leeg laten = ongewijzigd', + lndSynced: 'gesynchroniseerd', + lndNotSynced: 'NIET gesynchroniseerd', + noActive: 'Geen actieve {kind}-verbinding. Kies er één op Systeem → Integraties → Verbindingen.', + nameRequired: 'Naam is verplicht.', + urlScheme: 'Een URL begint met http(s):// of ws(s)://.', + nameInUse: 'Er bestaat al een {kind}-verbinding met de naam "{naam}".', + unknownConnection: 'Verbinding #{id} bestaat niet.', + linkedToSources: 'Deze verbinding is nog gekoppeld aan {n} tracking-bron(nen); ontkoppel die eerst.', + macaroonUnknown: 'Macaroon niet herkend: plak hex (lncli bakemacaroon) of base64(url), bijvoorbeeld de macaroon-parameter uit een lndconnect-URL.', + lndUrlScheme: 'De LND-REST-URL begint met http:// of https:// (een kale ip:poort vult de dialoog zelf aan).', + tlsCertPem: 'tls.cert moet een PEM-certificaat zijn (-----BEGIN CERTIFICATE-----).', + lndUrlMissing: 'De LND-REST-URL ontbreekt.', + lndNoConfig: 'Geen LND-configuratie voor deze verbinding: sla eerst de URL en de macaroon op.', + lndNoMacaroon: 'Geen macaroon voor deze verbinding opgeslagen: wijzig het profiel en plak de macaroon.', + nwcUriInvalid: 'Ongeldige NWC-URI (verwacht nostr+walletconnect://…?relay=…&secret=…).', + nwcPubkeyInvalid: 'Ongeldige wallet-pubkey in de NWC-URI.', + nwcSecretInvalid: 'Ongeldig secret in de NWC-URI.', + nwcRelayInvalid: 'Ongeldige of ontbrekende relay (wss://…) in de NWC-URI.', + nwcBadFormat: 'Onverwacht NIP-04-formaat in het antwoord.', + nwcNoUri: 'Geen NWC-URI voor deze bron opgeslagen. Wijzig de bron en plak de connection-URI.', + lndWantsHttps: 'De node verwacht https op deze poort: plak de tls.cert van LND bij het profiel (de app kiest dan zelf https met pinning), of typ https:// vóór het adres.', + lndTimeout: 'LND-timeout ({s}s): de node is niet bereikbaar. Staat de (WireGuard-)tunnel aan?', + lndUnreachable: 'LND is niet bereikbaar: {reden}.', + lndUnreachableCert: 'LND is niet bereikbaar: {reden}. Het gepinde tls.cert past niet bij deze node: plak het actuele tls.cert bij het profiel.', + nwcRelayRefused: 'De relay weigert het verzoek: {reden}.', + nwcRelayRefusedNoReason: 'De relay weigert het verzoek en zegt niet waarom.', + nwcDecryptFailed: 'Antwoord ontsleutelen mislukt: {reden}', + kindMarket: 'Atreides Services', + usedByMarket: 'De eigen koersdienst, voor koersen, de licentie en support. Adres en token gelden voor de hele installatie, dus voor elke portefeuille. De app praat via de eigen server met de dienst; het token blijft server-side en komt nooit terug in de browser.', + marketConfigure: 'Instellen', + marketDialogTitle: 'Atreides Services instellen', + marketTokenUnset: 'geen token', + marketTokenLicense: 'token van de licentie ({tier})', + marketTokenManual: 'eigen token', + marketTokenPending: 'de app vraagt een token aan met de licentie', + marketTokenNoLicense: 'geen licentie om een token mee aan te vragen', + marketOverrideUrl: 'ontwikkeladres', + marketTestOk: '{app} {version} · {n} instrumenten', + marketSpot: 'Wat de app uit de dienst haalt', + marketSpot_off: 'Alleen de koerslijn binnen de dag; koersen en dagkoersen uit de eigen bronnen', + marketSpot_fallback: 'Ook koersen en dagkoersen, als de eigen bronnen niets geven', + marketSpot_first: 'Ook koersen en dagkoersen, vóór de eigen bronnen; een bron die je zelf koos gaat voor', + marketSpotShort_off: 'dienst: alleen daglijn', + marketSpotShort_fallback: 'dienst: als terugval', + marketSpotShort_first: 'dienst: eerst', + }, + + marketService: { + urlScheme: 'Het adres van de dienst begint met http:// of https://.', + noToken: 'Er is geen token voor Atreides Services, en geen licentie om er een mee aan te vragen.', + unreachable: 'Atreides Services is niet bereikbaar op {url}: {reason}. De app rekent door op de koersen die hij al heeft.', + timeout: 'Atreides Services antwoordt niet binnen {s}s. Later opnieuw.', + unauthorized: 'Atreides Services weigert het token, ook nadat de app er met de licentie een nieuw voor vroeg.', + signatureRefused: 'Atreides Services weigert de handtekening: {reason}', + salesClosed: 'Er is op dit moment niets te koop.', + checkoutUnavailable: 'De kassa van Atreides Services kan nu geen invoice maken. Probeer het later opnieuw.', + tooManyOrders: 'Atreides Services neemt voor deze sleutel nu geen orders meer aan: {reason}', + unknownOrder: 'Atreides Services kent deze order niet (meer).', + licenseRevoked: 'Atreides Services heeft deze licentie ingetrokken en geeft er geen token meer voor.', + badLicense: 'Atreides Services neemt deze licentie niet aan: {reason}', + supportEmpty: 'Een bericht heeft tekst of een screenshot.', + supportTooLong: 'Een bericht is hooguit {max} tekens.', + supportAttachmentType: 'Atreides Services neemt deze screenshot niet aan: alleen een PNG-, JPEG- of WebP-afbeelding.', + supportAttachmentTooLarge: 'Een screenshot is hooguit {mb} MB.', + supportTooMany: 'Van deze sleutel kwamen vandaag al te veel berichten. Probeer het morgen opnieuw.', + unknownAttachment: 'Atreides Services kent deze screenshot niet.', + tooManyRequests: 'Te veel verzoeken aan Atreides Services. Over {s}s opnieuw.', + monthUnreachable: 'Atreides Services kan maand {month} nu niet lezen (de opslag is niet bereikbaar). Dat is geen gat in de koersen: later opnieuw.', + spotUnreachable: 'Atreides Services heeft de laatste koers wel, maar kan hem nu niet lezen (de opslag is niet bereikbaar). Later opnieuw.', + sourceLive: 'Atreides Services ({source})', + sourceCached: 'Atreides Services, laatst bekend {when} UTC (de dienst antwoordt nu niet)', + unavailable: 'Atreides Services is nog niet klaar, misschien start hij nog. Later opnieuw.', + badAnswer: 'Het antwoord van {url} is niet de API van Atreides Services. Is dit de poort van de API (op de Umbrel 3855, lokaal 8091) en niet die van de beheer-app?', + rejected: 'Atreides Services weigert het verzoek (HTTP {status}, {key}): {reason}', + }, + + nav: { + overview: 'Overzicht', + manage: 'Beheer', + stam: 'Stamgegevens', + events: 'Gebeurtenissen', + observe: 'Hulpmiddelen', + onchain: 'On-chain', + tax: 'Overzichten', + tools: 'Bitcoin', + system: 'Systeem', + home: 'Dashboard', + settings: 'Instellingen', + portfolios: 'Portefeuilles', + import: 'Importeren en exporteren', + data: 'Data', + health: 'Gezondheid', + ai: 'AI-activiteit', + license: 'Licentie', + integrations: 'Integraties', + connections: 'Verbindingen', + assets: 'Assets', + accounts: 'Accounts', + wealth: 'Vermogen', + locations: 'Locaties', + labels: 'Labels', + trades: 'Aankoop & Verkoop', + transfers: 'Verplaatsingen', + valuations: 'Balansen', + interest: 'Rente & Beloning', + reservations: 'Reserveringen', + taxes: 'Belastingen', + validation: 'Validatie', + tracking: 'Tracking', + conversion: 'Conversie', + 'conversion/bitcoin': 'Conversies', + 'price-history': 'Prijsgeschiedenis', + charts: 'Signalen & Grafieken', + actions: 'Af te handelen', + guide: 'Hulp', + customize: 'Indeling aanpassen', + }, + + dashboard: { + width: 'Breedte', + remove: 'Verwijderen', + settings: 'Instellingen', + reorder: 'Versleep om te herschikken', + add: 'Widget toevoegen', + secondCurrency: 'Tweede valuta', + secondNone: 'Geen', + momentum: 'Korte trend als stip', + momentumUp: '{pct}% van de laatste {n} koersbewegingen omhoog', + momentumDown: '{pct}% van de laatste {n} koersbewegingen omlaag', + addNoFit: 'Hier is te weinig ruimte voor de widgets die nog kunnen', + addNone: 'Alles is al toegevoegd', + profile: 'Indeling', + profileNew: 'Nieuwe indeling…', + profileNewPrompt: 'Naam van de nieuwe indeling', + profileRename: 'Naam wijzigen…', + profileRenamePrompt: 'Nieuwe naam voor deze indeling', + profileDelete: 'Deze indeling verwijderen…', + profileDeleteConfirm: 'Deze indeling en al zijn widgets verwijderen? De andere indelingen blijven staan.', + profileSystemHint: 'Standaard kan niet hernoemd worden.', + profileRestart: 'Standaard opnieuw beginnen…', + profileRestartConfirm: 'Standaard opnieuw beginnen? Wat je zelf hebt neergezet gaat weg en de standaardindeling komt ervoor in de plaats.', + nameRequired: 'Een indeling heeft een naam nodig.', + unknownProfile: 'Indeling #{id} bestaat niet.', + lastNotDeletable: 'De laatste indeling kan niet verwijderd worden.', + widget: { + signal: 'Bitcoin-signalen', + notifications: 'Af te handelen', + standing: 'Huidige stand', + spot: 'Koers', + metrics: 'Status', + netshare: 'Netwerk-aandeel', + clocks: 'Wereldklokken', + return: 'Rendement', + }, + }, + + clocks: { + showExchanges: 'Beurzen tonen', + chooseClocks: 'Klokken tonen', + localLabel: 'Mijn tijdzone', + hoverLocal: 'Jouw tijdzone', + hoverSame: 'Gelijk met jouw tijdzone', + listHead: 'Beurzen · in jouw tijd', + opensAt: 'Opent om {tijd}', + opensOn: 'Opent {dag} om {tijd}', + openUntil: 'Open tot {tijd}', + opensIn: 'over {duur}', + closesIn: 'sluit over {duur}', + durMin: '{n} min', + durHourMin: '{n} uur {m} min', + durHours: '{n} uur', + durDay: '1 dag', + durDays: '{n} dagen', + }, + + netshare: { + noOnchain: 'geen on-chain asset', + zero: 'Nog geen bitcoin in beeld, elke sat is een begin.', + tier: { shrimp: 'Garnaal', crab: 'Krab', octopus: 'Octopus', fish: 'Vis', dolphin: 'Dolfijn', shark: 'Haai', whale: 'Walvis', humpback: 'Bultrug' }, + quotesUnder: [ + 'Ik bezit officieel **{X} miljardste deel** van alle Bitcoin die ooit zal bestaan. Mijn spaarrekening kan inpakken.', + '**{X} miljardste deel** van het Bitcoin-universum is van mij. Wetenschappelijk gezien een stofdeeltje, emotioneel gezien een fortuin.', + 'Met mijn aandeel van **{X} miljardste deel** ben ik voor een piepklein deel eigenaar van de toekomst van geld. Nog even en ik koop er een broodje kaas van.', + 'Aandeel in het Bitcoin-netwerk: **{X} miljardste deel**. Aandeel in mijn ego erover: 100%.', + 'Technisch gezien ben ik nu een Bitcoin-stakeholder. Praktisch gezien heb ik **{X} miljardste deel** van een pizza-aankoop uit 2010.', + 'Ik heb **{X} miljardste deel** van alle Bitcoin. Klein begin, groot verhaal om over op te scheppen.', + 'Statistisch verwaarloosbaar, emotioneel onbetaalbaar: **{X} miljardste deel** van 21 miljoen munten.', + 'Ik bezit een fractie zo klein dat mijn rekenmachine hem afrondt, maar mijn wallet-app niet. **{X} miljardste deel**, en dat laat ik me niet afpakken.', + 'Als je alle Bitcoin verdeelt in een miljard stukjes, heb ik er **{X}**. Verdeel het nog verder en ik ben nog steeds trots.', + 'Mijn aandeel in het netwerk is **{X} miljardste deel**. Mijn aandeel in de hype eromheen: aanzienlijk groter.', + '**{X} miljardste deel** van Bitcoin is van mij. Genoeg om over op te scheppen, te klein om iets van te kopen.', + 'Ik ben voor **{X} miljardste deel** mede-eigenaar van digitaal goud. De rest van het goud is helaas van iemand anders.', + 'Nano-investeerder gemeld: **{X} miljardste deel** van het totale aanbod, en trots als een pauw.', + 'Klein aandeel, groot verhaal: **{X} miljardste deel** van alle Bitcoin ooit.', + 'Ik heb minder Bitcoin dan er sterren zichtbaar zijn met het blote oog, maar wel **{X} miljardste deel** van het netwerk. Vooruitgang is vooruitgang.', + ], + quotesOver: [ + 'Ik bezit een **heel getal** aan Bitcoin. In een wereld van decimalen ben ik een hele munt. En dat mag geweten worden.', + 'Met **{Y}** van het netwerk in bezit, mag ik officieel zeggen: ik heb een stukje van de 21 miljoen. Geen kruimel, maar een hap.', + '1 BTC of meer: het moment dat je wallet-app je serieus begint te nemen.', + "Mijn aandeel: **{Y}**. Klein op de schaal van het universum, groot op de schaal van m'n vrienden die geen Bitcoin hebben.", + "Ik zit boven de magische grens van 1 BTC. De rest van m'n leven kan ik dit rustig blijven noemen.", + '**{Y}** van alle Bitcoin die ooit zal bestaan is van mij. Dat getal wordt nooit meer groter. Mijn trots des te meer.', + 'Van "miljardste deel" naar "hele munt", een upgrade die geen enkele smartphone kan evenaren.', + 'Aandeel in het netwerk: **{Y}**. Aandeel in het vertellen van dit feitje op feestjes: 100%.', + 'Ik hoef niet meer te zeggen "nog geen 1 BTC". Ik zeg gewoon "1 BTC". Kleine zin, groot gevoel.', + '**{Y}** van Bitcoin is van mij. Schaars, eindig, en voor altijd een leuk gespreksonderwerp.', + 'Van garnaal naar iets groters op de Bitcoin-ladder. Mijn wallet is aan het evolueren.', + 'Ik bezit **{Y}** van een asset waar er nooit meer dan 21 miljoen van komen. Zeldzamer dan mijn geduld om dit uit te leggen op verjaardagen.', + ], + }, + + sysmetrics: { + db: 'Database', + calls: 'Calls', + errors: 'Fouten', + window: 'venster', + }, + + range: { + cycle: 'Ander tijdvak', + pointCarried: '{bron}, koers van {dag}', + pointVia: '{name} ({source})', + day: 'D', + week: 'W', + month: 'M', + year: 'J', + all: 'A', + }, + + spot: { + noHistory: 'geen historie', + unknownAsset: 'onbekende asset', + }, + + filter: { + all: 'Alles', + none: 'Niets', + }, + + actions: { + title: 'Af te handelen', + interestTitle: 'Rente te boeken', + valuationTitle: 'Waarderingsverschillen', + openTitle: 'Nog te doen', + openOne: 'punt open', + openMany: 'punten open', + openNone: 'Niets open', + }, + + appWindow: { + close: 'Sluiten', + confirmTitle: 'App sluiten', + confirmClose: 'De app en dit venster sluiten? Opnieuw openen gaat met start-app.', + closeUnavailable: 'Sluiten kan alleen op deze computer, in het eigen venster dat de app zelf heeft geopend.', + }, + + guide: { + title: 'Hulp', + goTo: 'Naar {plek}', + related: 'Zie ook', + newTab: 'Opent in een nieuw tabblad', + figures: { + period: 'Gekozen periode', + sinceBuy: 'Ongerealiseerd en Ingelegd: sinds de aankoop', + inPeriod: 'Rendement, Resultaat, Gerealiseerd', + purchase: 'Aankoop', + yearStart: '1 januari', + now: 'Nu', + cost: 'Kostprijs 191,20', + broughtIn: 'Ingebracht (netto) 154,92', + value: 'Nu: 186,43', + unrealized: 'Ongerealiseerd -4,77', + result: 'Resultaat', + resultAmount: '31,51', + purchaseExchange: 'Aankoop (exchange)', + transfer: 'Verplaatsing', + legendValue: 'Waarde', + legendBroughtIn: 'Ingebracht (netto)', + legendCost: 'Kostprijs van het asset', + roughly: 'is ongeveer', + unrealizedPart: 'Ongerealiseerd', + realized: 'Gerealiseerd', + interest: 'Rente', + }, + }, + + aiProposals: { + title: 'Voorstellen van de AI-koppeling', + intro: 'De AI-koppeling heeft dit voorgesteld. Er is nog niets gewijzigd; pas bij goedkeuren voert ' + + 'de app het uit.', + kindPricePoint: 'Koers vastleggen', + kindAccount: 'Nieuw account', + kindAccountDelete: 'Account verwijderen', + kindValuation: 'Balans', + kindPortfolio: 'Nieuwe portefeuille', + portfolioIn: 'in', + portfolioNote: 'Leeg, en zonder sleutels of data uit deze administratie. Hij wordt niet geopend: ' + + 'wisselen doe je zelf op Systeem → Portefeuilles, en dat is een herstart van de app.', + kindPreference: 'Instelling wijzigen', + preferenceNote: 'Deze instelling geldt voor deze portefeuille en verandert alleen de weergave of ' + + 'een interval, niet de administratie zelf.', + preferenceCurrent: 'nu', + preferenceOn: 'aan', + preferenceOff: 'uit', + kindSourceChoice: 'Bronkeuze', + sourceFirst: 'als eerste', + sourceRemove: 'eruit halen', + sourceChoiceNote: 'De keten zoals hij nu staat zie je op Integraties, Bronkeuze, of op het tabblad Bron van Prijsgeschiedenis.', + unknownKind: 'Onbekend soort voorstel', + ledger: 'grootboek', + diff: 'verschil', + openingBalance: 'Dit wordt een OPENINGSBALANS: er staat geen boeking vóór deze datum, dus de app ' + + 'toont hierna géén verschil. Controleer de stand extra goed.', + irreversible: 'Onomkeerbaar. Wordt niet meegenomen in "Alles goedkeuren".', + acceptDelete: 'Verwijderen', + acceptDeleteConfirm: 'Dit account definitief verwijderen? Dit kan niet ongedaan gemaakt worden. ' + + 'Staan er boekingen, balansen of scans op, dan weigert de app het.', + bulkSkips: '{n} voorstel(len) verwijderen iets en worden overgeslagen door "Alles goedkeuren". ' + + 'Die keur je apart goed.', + bulkNothing: 'Er staat niets open dat in één keer goedgekeurd kan worden. Wat er staat, verwijdert ' + + 'iets en vraagt een aparte bevestiging.', + ownCustody: 'zelf in bewaring', + thirdParty: 'bij een derde partij', + fee: 'kosten', + perUnit: 'per', + accept: 'Goedkeuren', + reject: 'Afwijzen', + acceptAll: 'Alles goedkeuren', + rejectAll: 'Alles afwijzen', + acceptAllConfirm: '{n} voorstellen goedkeuren en uitvoeren?', + rejectAllConfirm: '{n} voorstellen afwijzen? Ze verdwijnen van deze lijst en worden niet uitgevoerd.', + rejectedByUser: 'Afgewezen op Af te handelen', + failed: 'Vorige poging mislukt', + partialTitle: 'Niet alles is gelukt', + partialBody: 'De rest is wel afgehandeld. Deze bleven staan:', + unknownProposal: 'Voorstel {id} bestaat niet meer.', + handled_accepted: 'Voorstel {id} is al goedgekeurd.', + handled_rejected: 'Voorstel {id} is al afgewezen.', + unreadableMoment: 'Onleesbaar moment in voorstel {id}: {moment}.', + prefScopeNotAllowed: 'Dit voorstel wil de instellingen onder "{scope}" zetten, en dat mag niet.', + duplicateValuation: 'Er staat al een balans voor dit account en dit asset op {dag}.', + noPriceForBalance: 'Geen koers in de historie voor {dag}, en een balans kan niet zonder. Stel eerst ' + + 'een koerspunt voor op die datum (propose_price_point), dan kan deze balans erna geboekt worden.', + }, + + reservations: { + title: 'Reserveringen', + hint: 'Een reservering is een bedrag dat later mogelijk wordt afgeschreven of bijgeboekt. Hij telt nergens ' + + 'mee tot je hem boekt; vanaf zijn datum staat hij op Af te handelen. Vastleggen kan op de tab Reserveringen en in het accountvenster.', + none: 'Geen reserveringen op dit account.', + noneAll: 'Nog geen reserveringen.', + colDate: 'Datum', + colAccount: 'Account', + colDirection: 'Richting', + colAsset: 'Asset', + colAmount: 'Bedrag', + colNote: 'Omschrijving', + dirOut: 'Afschrijving', + dirIn: 'Bijboeking', + due: 'Datum bereikt', + book: 'Boeken', + settle: 'Zonder boeking', + bookTitle: 'Reservering boeken', + otherTo: 'Naar', + otherFrom: 'Van', + outside: 'Buiten de portefeuille', + fill: 'Vul een datum, een asset en een bedrag groter dan nul in.', + confirmSettle: 'Deze reservering afhandelen zonder boeking? Hij verdwijnt dan.', + confirmDelete: 'Deze reservering verwijderen?', + notOpen: 'Deze reservering staat niet meer open.', + cardOne: '1 reservering', + cardMany: '{n} reserveringen', + cardNext: 'eerstvolgende {datum}', + }, + + notify: { + title: 'Meldingen', + none: 'Niets te doen.', + dismiss: 'Verbergen', + reconcile: 'Bewegingen te reconciliëren', + interest: 'Rente te boeken', + valuation: 'Waarderingsverschillen', + schedule_ended: 'Rente-afspraak afgelopen', + source_proposal: 'Koersbron bevestigen', + ai_proposal: 'Voorstel van de AI', + reservation: 'Reservering af te handelen', + view: 'Bekijken', + hint: { + reconcile: 'Niet-gekoppelde on-chain bewegingen', + interest: 'Bijschrijvingen klaar om te boeken', + valuation: 'Saldo wijkt af van de waardering', + source_proposal: 'Voorgestelde koersbron voor een nieuw asset', + ai_proposal: 'Voorgesteld door de AI-koppeling, wacht op jouw akkoord', + reservation: 'De datum van een reservering is bereikt: boeken of zonder boeking afhandelen', + }, + }, + + integrationLog: { + title: 'Foutenlog', + open: 'Foutenlog', + openHint: 'De mislukte aanroepen van de afgelopen {n} dagen', + lastFailure: 'Laatste fout', + noFailures: 'Geen fouten in de afgelopen {n} dagen.', + allSources: 'Alle bronnen', + service: 'Atreides Services', + colTime: 'Tijdstip', + colSource: 'Bron', + colOperation: 'Aanroep', + colSubject: 'Onderwerp', + colOutcome: 'Uitkomst', + colStatus: 'Status', + colKey: 'Sleutel', + colDetail: 'Toelichting', + colRequestId: 'Verzoek-id', + requestIdHint: 'Dezelfde id staat in de log van Atreides Services.', + outcome_fail: 'Mislukt', + outcome_empty: 'Leeg antwoord', + outcome_refused: 'Niet verstuurd', + }, + + integrations: { + matrixPaid: 'De bronkeuze-matrix hoort bij de betaalde versie. De app blijft de standaardbronnen gebruiken.', + keysPaid: 'Eigen provider-sleutels horen bij de betaalde versie; de standaardbronnen blijven werken.', + title: 'Integraties', + intro: 'Externe koppelingen en hun endpoints. Per koppeling beheer je de sleutels, zie je de status en test je de verbinding.', + indicatorHint: 'API-activiteit, klik voor integraties en metrics', + baseUrlOrProfile: 'Basis-URL / profiel', + endpoints: 'Endpoints', + coverage: 'Dekking', + noCoverage: 'Geen dekkende bron: leg de dekking vast bij een integratie.', + systemSource: 'Systeembron: {naam}. Hier valt niets te kiezen.', + convertedVia: 'omgerekend naar {munt} via {naam}, de bronkeuze van {valuta}', + sourcesHintTitle: 'Bronkeuze', + coverageUsage: 'Dekking en bronkeuze', + coverageUnchosen: 'gedekt, maar nergens gekozen', + inactiveNoEffect: 'Deze integratie staat uit: de bronkeuze heeft geen effect tot je hem aanzet.', + inactiveHint: 'grijs: de integratie staat uit, dus deze keuze doet niets tot je hem aanzet', + noRateSource: 'geen wisselkoersbron voor {valuta}', + noRateSourceHint: 'Deze bron levert in {valuta}. Kies eerst een bron voor de wisselkoers van {valuta}, dan is hij hier te kiezen.', + method: 'Methode', + path: 'Pad', + auth: 'Authenticatietype', + authYes: 'Vereist sleutel', + authNo: 'Publiek', + authKind: { + none: 'Publiek', + header_key: 'Sleutel in een header', + query_key: 'Sleutel in de URL', + bearer: 'Bearer-token', + basic: 'Gebruikersnaam en wachtwoord', + hmac: 'Ondertekend verzoek (HMAC)', + }, + authHeaderName: 'Naam van de header', + authParameterName: 'Naam van de URL-parameter', + keys: 'Sleutels', + keysSet: 'Ingesteld', + keysMissing: 'Niet ingesteld', + keysNone: 'Geen sleutels nodig', + testFail: 'Mislukt', + noBackend: 'backend niet bereikbaar', + saved: 'Opgeslagen', + keysTitle: 'API-sleutels', + keysHint: 'Sleutels worden server-side bewaard; de browser ziet ze nooit. Laat een veld leeg om het ongewijzigd te laten.', + keysEmpty: 'Niet ingesteld', + saveKeys: 'Sleutels opslaan', + api_key: 'API-sleutel', + api_secret: 'API-secret', + capability: 'Soort', + query: 'Query', + symbol: 'Symbool', + date: 'Datum', + active: 'Actief', + authRequired: 'sleutel vereist', + authOptional: 'sleutel optioneel', + authKeyStored: 'sleutel opgeslagen', + autoActivated: 'Integratie geactiveerd (test geslaagd)', + symbolsTitle: 'Input-mapping', + outputsTitle: 'Output-mapping', + hmacTitle: 'HMAC-ondertekening', + authTitle: 'Authenticatie', + keyRequiredCol: 'Sleutel vereist', + kinds: 'Soorten', + yes: 'Ja', + no: 'Nee', + saveAuth: 'Authenticatie opslaan', + readonlyNote: 'Configuratie is in deze versie alleen-lezen.', + testMapTitle: 'Test & map', + runTest: 'Test & map', + testRunning: 'Bezig…', + mappedResult: 'Mapping-resultaat', + rawResponse: 'Ruwe respons', + metricsTitle: 'Metrics', + calls: 'calls', + okCount: 'gelukt', + failCount: 'mislukt', + avgLatency: 'gem.', + maxLatency: 'max', + noMetrics: 'geen calls', + period_uur: 'Laatste uur', + period_dag: 'Vandaag', + period_maand: 'Deze maand', + subtabMapping: 'Velden & mapping', + subtabAuth: 'Auth & sleutels', + subtabTest: 'Test', + subtabMetrics: 'Metrics', + subtabUsage: 'Bronkeuze', + usageNoCoverage: 'Deze bron dekt nog geen asset: leg de dekking vast in de rij.', + quoteCurrency: 'Munt', + chartCalls: 'Calls (gelukt / mislukt)', + chartLatency: 'Latency (max, ms)', + deleteProvider: 'Verwijderen', + cap: { spot: 'Spot', daily_history: 'Daghistorie', history_point: 'Historisch punt' }, + tabProviders: 'Providers', + tabSources: 'Bronkeuze', + tabConnections: 'Verbindingen', + sourcesSoon: 'De bronkeuze-matrix (welke integratie levert welke data, per asset en capability) krijgt hier zijn plek. Die bouwen we als volgende stap; tot dan beheer je de bron per asset nog op de Assets-pagina.', + newProvider: 'Nieuwe integratie', + name: 'Naam', + field: 'Veld', + transform: 'Transform', + externalId: 'Externe id', + addEndpoint: 'Endpoint toevoegen', + addSymbol: 'Symbool toevoegen', + addOutput: 'Output toevoegen', + confirmDeleteProvider: 'Deze integratie en al haar configuratie verwijderen?', + confirmDeleteRow: 'Deze regel verwijderen?', + hmacMessageParts: 'Message parts', + hmacHashAlgo: 'Hash-algoritme', + hmacEncoding: 'Encoding', + hmacKeyHeader: 'Key-header', + hmacSignatureHeader: 'Signature-header', + hmacTimestampHeader: 'Timestamp-header', + hmacWindowHeader: 'Window-header', + hmacWindowMs: 'Window (ms)', + saveHmac: 'HMAC opslaan', + clearHmac: 'HMAC wissen', + hmacNone: 'Geen HMAC-ondertekening ingesteld.', + systemNote: 'Systeemprovider, niet bewerkbaar.', + asset: 'Asset', + nameRequired: 'Een integratie heeft een naam nodig.', + keyInUse: 'Er bestaat al een integratie met de sleutel "{key}".', + unknownProvider: 'Integratie "{key}" bestaat niet.', + providerInMatrix: 'Integratie "{key}" staat nog in de bron-matrix ({n} keuze(s)); haal die eerst weg bij Bronkeuze.', + providerHasPrices: 'Integratie "{key}" heeft nog {n} koersrij(en) in de historie; die blijven als bron nodig.', + fieldRequired: '{veld} is verplicht.', + priceCallFailed: 'Koers-call mislukt ({status}).', + price: { + externalIdMissing: 'Het output-pad gebruikt {external_id}, maar er is geen extern id.', + listPathWithoutBrackets: 'Lijst-pad zonder []: "{pad}".', + expectedArray: 'Verwachtte een array op "{pad}".', + expectedObject: 'Verwachtte een object op "{pad}".', + unknownTransform: 'Onbekende transform: "{transform}".', + noOutputMapping: 'Provider {provider}: geen output-mapping voor "{capability}".', + noValidPrice: 'Provider {provider}: geen geldige prijs in de respons.', + limitReached: '{provider}: de maandlimiet van {limiet} aanroepen is bereikt; deze maand gaat er geen verzoek meer uit.', + noExchangeRate: 'Geen wisselkoers voor {valuta} op of kort vóór {datum}; zonder koers is er geen prijs in {munt}.', + invalidDate: 'Provider {provider}: ongeldige datum "{datum}".', + columnsUneven: 'Provider {provider}: de output-kolommen zijn ongelijk van lengte.', + capabilityNotConfigured: 'Provider {provider}: capability "{capability}" is niet geconfigureerd.', + symbolMissing: 'Provider {provider}: {external_id} wordt gebruikt maar er is geen symbool opgegeven.', + templateDateInvalid: 'Provider {provider}: het template gebruikt {date}, maar datum "{datum}" is ongeldig.', + unknownHmacToken: 'Onbekend hmac message-token: "{token}".', + unknownCapability: 'Onbekende capability: "{capability}".', + onlyGet: 'Provider {provider}: alleen GET wordt ondersteund.', + unknownAuthKind: 'Onbekende authenticationKind: "{soort}".', + keyMissing: 'Provider {provider}: een sleutel is verplicht maar ontbreekt of is incompleet.', + parameterNameMissing: 'Provider {provider}: de naam van de URL-parameter ontbreekt (sleutel in de URL).', + headerNameMissing: 'Provider {provider}: de naam van de header ontbreekt (sleutel in een header).', + hmacConfigMissing: 'Provider {provider}: de hmac-configuratie ontbreekt.', + networkError: 'Provider {provider} {capability}: netwerkfout ({reden}).', + httpFailed: 'Provider {provider} {capability} mislukt ({status}){detail}.', + notJson: 'Provider {provider} {capability}: het antwoord is geen JSON ({detail}).', + notFromFixedRate: 'Provider {provider}: "{capability}" kan niet uit een vaste koers komen.', + callFailed: '{label} mislukt ({status}).', + callTimeout: '{label} verlopen (timeout {s}s).', + labelAddress: 'Adres ophalen voor {adres}', + labelAddressTxs: 'Transacties ophalen voor {adres}', + labelAddressTxsPage: 'Transacties (pagina)', + }, + scriptLoadFailed: 'Script laden mislukt: {src}', + unknownEndpoint: 'Endpoint {id} bestaat niet.', + quoteCurrencyInvalid: 'Leveringsvaluta "{waarde}" is geen valutacode van drie letters.', + minIntervalInvalid: 'Interval "{waarde}" is geen heel aantal seconden boven nul.', + quoteUnit: 'Eenheid', + quoteUnitNone: 'zoals opgeslagen', + quoteUnitOpt: { ozt: 'per troy ounce' }, + quoteUnitInvalid: 'Onbekende eenheid "{waarde}".', + minInterval: 'Interval (s)', + minIntervalApp: 'standaard', + intervalHours: '{n} uur', + intervalMinutes: '{n} min', + intervalSeconds: '{n} s', + callLimitInvalid: 'Een maandlimiet is een heel getal boven nul, niet "{waarde}".', + usageCol: 'Verbruik (maand)', + callLimitCol: 'Maandlimiet', + usageHint: 'Aanroepen deze kalendermaand, gelukt en mislukt samen, tegen de maandlimiet van het abonnement. De testknop telt niet mee.', + unknownSymbolMap: 'Input-mapping {id} bestaat niet.', + catchAll: 'Alle overige', + catchAllHint: 'Geldt voor elk symbool zonder eigen regel. Het veld Symbool blijft dan leeg.', + catchAllSymbol: 'alle overige', + catchAllExists: 'Deze bron heeft al een regel voor alle overige symbolen.', + unknownOutputMap: 'Output-mapping {id} bestaat niet.', + matrixHint: 'Per asset kies je een bron voor de spot en een voor de historie; die keuze geldt overal in de app. Een klik op een lege cel maakt hem de volgende keuze (1, dan 2 als reserve); een klik op een gekozen cel haalt hem weg. Een gele ring is een voorstel dat een klik bevestigt; een lege cel zonder plus is iets wat de bron niet levert. Onder historie vallen een reeks en een koers per datum: de app haalt de reeks als die er is, en per datum wat ontbreekt. Standaard staan alleen assets met meer dan één bron aan; de rest zet je aan in de filter.', + sourceCap: { spot: 'Spot', history: 'Historie' }, + mxToSetUp: '{wat} nog in te richten', + mxOpenOne: 'nog 1 keuze open', + mxOpenMany: 'nog {n} keuzes open', + mxAllChosen: 'alles gekozen', + mxServiceHint_first: 'Atreides Services: na een bron die je zelf aanklikte en vóór de bronnen die de app koos. Klik een bron aan om hem vóór de dienst te zetten. De stand staat op Systeem → Integraties → Verbindingen.', + mxServiceHint_fallback: 'Atreides Services: als terugval, na alle gekozen bronnen. De stand staat op Systeem → Integraties → Verbindingen.', + mxProposalsOne: '1 voorstel', + mxProposalsMany: '{n} voorstellen', + }, + + table: { none: 'Ongegroepeerd', new: 'Nieuw', save: 'Opslaan', cancel: 'Annuleren', search: 'Zoeken…', count: 'Aantal records', edit: 'Bewerken' }, + + action: { copy: 'Kopiëren' }, + + dialog: { ok: 'OK', cancel: 'Annuleren', close: 'Sluiten', error: 'Er ging iets mis', + next: 'Volgende', back: 'Terug', finish: 'Afronden', skip: 'Overslaan' }, + + setup: { + currencyTitle: 'In welke munt is je bankrekening?', + accountsTitle: 'Waar staat het?', + account_wallet: 'Eigen wallet', + account_exchange: 'Exchange', + account_bank: 'Bank', + dashboardTitle: 'Op het dashboard', + widget_standing: 'Stand', + widget_spot: 'Koersen', + finish: 'Inrichten', + persona_bitcoin: 'Alleen bitcoin', + persona_broad: 'Breder', + rerun: 'Inrichten…', + }, + + notes: { + tab: 'Notities', + when: 'Datum en tijd van de regel', + line: 'Nieuwe regel, Enter voegt hem bovenaan toe', + body: 'Notities bij dit asset. Vrij te bewerken; ze staan ook in het andere detailvenster van dit asset.', + saving: 'Opslaan…', + saved: 'Opgeslagen', + }, + + error: { panelTitle: 'Er ging iets mis bij het laden van deze sectie', panelHint: 'De rest van de app blijft werken. Details hieronder (ook in de console).' }, + + blur: { toggle: 'Bedragen verbergen/tonen', showAmounts: 'Bedragen tonen' }, + + controls: { theme: 'Thema', accent: 'Accent', currency: 'Valuta', dark: 'Donker', light: 'Licht', orange: 'Oranje', lila: 'Lila', blue: 'Blauw', teal: 'Teal', magenta: 'Magenta', indigo: 'Indigo', cyan: 'Cyaan', slate: 'Leisteen', sand: 'Zand', flat: 'Vlak', deep: 'Reliëf', business: 'Zakelijk', glass: 'Glas' }, + + settings: { + prefBadValue: 'Ongeldige waarde voor {scope}.{key}: {waarde}.', + prefWantsInteger: 'Ongeldige waarde voor {scope}.{key}: {waarde} (een geheel getal verwacht).', + prefBelowMin: '{scope}.{key} mag niet kleiner zijn dan {min}.', + prefAboveMax: '{scope}.{key} mag niet groter zijn dan {max}.', + unknownSetting: 'Onbekende instelling: {key}.', + title: 'Instellingen', + appearance: 'Weergave', + screen: 'Scherm', + region: 'Regio', + profile: 'Profiel', + theme: 'Thema', + accent: 'Accentkleur', + depth: 'Diepte', + depthHint: 'Reliëf geeft de pagina een accentgloed en kaders met schaduw en randlicht; Vlak laat alles egaal. Alleen bij Zakelijk.', + glowBg: 'Achtergrondgloed', + glowBgMotion: 'Achtergrondgloed en animatie', + glowOff: 'Uit', + glowOn: 'Aan', + glowStill: 'Stil', + glowStep: 'Af en toe', + glowAlways: 'Altijd', + glowShape: 'Vorm van de gloed', + glowShapeBlobs: 'Bollen', + glowShapeBands: 'Banen', + rowsel: 'Geselecteerde tabelrij', + rowselLight: 'Lichtval', + rowselTint: 'Tint', + rowselPill: 'Pil', + glass: 'Glas', + glassTransparency: 'Doorzichtigheid', + glassFrost: 'Matheid', + glassDialog: 'Dialoog doorzichtigheid', + glassDefault: 'Standaard', + glassRender: 'Glas tekenen', + glassRenderMimic: 'Nagebootst', + glassRenderReal: 'Echte blur', + motionBlocked: 'Je systeem vraagt om minder beweging, dus de gloed staat stil.', + transparencyBlocked: 'Je systeem vraagt om minder doorzichtigheid, dus het glas is dicht.', + hintTitle: 'Instellingen', + hintProfile: 'Profiel: Zakelijk is vlak en snel, Glas legt er een gloed achter en doorschijnende kaders over.', + hintTheme: 'Thema en accentkleur gelden in beide profielen; de accentkleur kleurt ook de gloed.', + hintGlow: 'Achtergrondgloed en animatie: in Glas drie bollen in de accentkleur. Bij Af en toe verschuift er zo nu en dan één; Altijd beweegt doorlopend en kost het meest rekenwerk.', + hintGlowShape: 'Vorm van de gloed, alleen bij Glas met de gloed aan: Bollen (standaard) zijn drie vlekken die verschuiven; Banen zijn brede lichtbanen in drie lagen die traag uit de rechterbovenhoek vallen, over de stille gloed heen. Bij Af en toe is dat een bui: een tijd licht, dan een tijd niets.', + hintRowsel: 'Geselecteerde tabelrij, alleen bij Glas: Lichtval komt uit de streep links, Tint kleurt de hele rij, Pil maakt er een afgeronde strook van.', + hintAiHeading: 'Alleen via de AI', + hintAiIntro: 'Dit staat niet op de pagina. Vraag de AI-assistent het aan te passen; het voorstel verschijnt op Af te handelen.', + hintAiTempo: 'Tempo van de gloed: Vlot, Rustig (standaard) of Heel rustig.', + hintGlassRender: 'Glas tekenen, alleen bij Glas: Nagebootst (standaard) is sneller, Echte blur vervaagt wat achter de kaarten ligt en kost meer rekenwerk.', + hintGlass: 'Glas, alleen bij Glas: Doorzichtigheid geldt voor kaarten en kaders (standaard 65%), Matheid voor al het glas (3%), Dialoog doorzichtigheid voor wat boven de inhoud zweeft, zoals een dialoog, menu of tooltip (30%): 0 is dicht, 100 helemaal doorzichtig, en hoe doorzichtiger, hoe minder wazig. Een tabelkop vervaagt niet. Standaard zet ze terug, en Glas tekenen op Nagebootst.', + hintScreenHeading: 'Scherm', + autoBlur: 'Bedragen verbergen na', + autoBlurHint: 'Tijd zonder activiteit voordat de bedragen vervagen. Bij "Handmatig" schakel je zelf met het oogje.', + autoBlurManual: 'Handmatig', + minutesShort: 'min', + assetDisplay: 'Asset-weergave', + assetDisplayHint: 'Toon de naam, het symbool, of beide. Minstens één blijft aan.', + pageTitle: 'Paginatitel', + pageTitleShow: 'Tonen', + pageTitleHide: 'Verbergen', + pageTitleHint: 'De titel boven elke pagina. Verborgen begint de inhoud hoger; de naam van de pagina staat ook in het menu.', + pageHint: 'Pagina-legenda', + pageHintHint: 'Het i-icoon op de kantlijn, met de uitleg van wat je op die pagina ziet. Verborgen verdwijnt het icoon helemaal; een pagina zonder legenda toont het sowieso niet.', + resultSigns: 'Teken bij winst en verlies', + resultSignsNone: 'Geen', + resultSignsNegative: 'Alleen negatief', + resultSignsBoth: 'Negatief en positief', + resultSignsHint: 'Geldt voor resultaten en rendementen in groen of rood. Bij "Geen" zegt alleen de kleur of het winst of verlies is. Zonder kleur staat het teken er altijd.', + persona: 'Portefeuille gaat over', + personaHint: 'Breder verbergt Signalen & Grafieken en de bitcoinwidgets uit de keuze voor het dashboard. Per portefeuille; boekingen en assets veranderen niet.', + kiosk: 'Openen in', + kioskWindow: 'Browser', + kioskFull: 'Eigen venster', + kioskHint: 'Eigen venster opent de app bij de volgende start met start-app in een eigen venster zonder adresbalk en tabbladen, gemaximaliseerd (Vivaldi, Edge of Chrome). Zonder een van die browsers opent de app in een gewoon tabblad. F11 maakt het volledig scherm; naar een ander scherm verplaatsen gaat zoals bij elk venster. Onder Systeem staat dan Sluiten, dat de app en het venster afsluit. Op een server of de Umbrel heeft deze keuze geen effect.', + language: 'Taal', + nl: 'Nederlands', + en: 'English', + }, + + ai: { + notConnected: 'Er is nog geen AI-assistent verbonden geweest. Zodra dat gebeurt, verschijnt hier ' + + 'per aanroep wat er is gedaan en welke gegevens de app daarbij heeft verlaten.', + howTo: 'Koppelen gaat met een eigen AI-programma op deze computer. Dat programma start de ' + + 'koppeling zelf; er is geen webadres en geen wachtwoord nodig.', + total: 'Aanroepen', + lastAt: 'Laatste aanroep', + ok: 'Uitgevoerd', + error: 'Mislukt', + refused: 'Geweigerd', + when: 'Wanneer', + what: 'Wat', + who: 'Door', + shared: 'Gegevens gedeeld', + characters: 'Tekens', + result: 'Uitkomst', + sharedNothing: 'niets', + sharedUnknown: 'niet vastgelegd', + sharedSome: 'wel gegevens', + unknownClient: 'onbekend programma', + detailNone: '', + noRows: 'Geen aanroepen die aan de filters voldoen.', + conversation: 'Het gesprek', + msgRequest: 'Binnengekomen', + msgResponse: 'Beantwoord', + noConversation: 'Van deze aanroep is de tekst niet bewaard. Hij is afgehandeld door een versie ' + + 'van de app die het gesprek nog niet vastlegde.', + conversationFail: 'Het gesprek kon niet geladen worden.', + switchOn: 'Aan: een gekoppeld AI-programma mag aanroepen doen.', + switchOff: 'Uit: elke aanroep wordt geweigerd, ook van een al gestart AI-programma.', + switchFail: 'Instelling opslaan is niet gelukt.', + activeHead: 'Actief', + switchHead: 'Lezen', + writeHead: 'Schrijven', + controlHead: 'Navigatie', + rawHead: 'Onbewerkt', + activeOn: 'Aan: de koppeling werkt. Wat een AI mag, bepalen de schuiven hiernaast.', + activeOff: 'Uit: er draait geen koppeling. Ook de verbindingstest doet niets en een gestart ' + + 'AI-programma stopt zichzelf.', + writeOn: 'Aan: een AI mag gegevens toevoegen of wijzigen, altijd via een voorstel dat u accordeert.', + writeOff: 'Uit: elk verzoek om iets te wijzigen wordt geweigerd.', + rawLabel: 'Onbewerkt delen', + rawOn: 'Aan: een AI ziet de echte namen en bedragen van deze portefeuille.', + rawOff: 'Uit: een AI ziet verwijzingen en marges in plaats van namen en bedragen.', + rawHint: 'Alleen aanzetten als deze portefeuille geen echte gegevens bevat. De app kan dat niet ' + + 'vaststellen; dat weet alleen u. Let op: een kopie van een portefeuille bevat de gegevens van ' + + 'het origineel.', + rawConfirm: 'Onbewerkt delen aanzetten voor "{naam}"? Een gekoppelde AI ziet daarna de echte namen ' + + 'en bedragen van deze portefeuille in plaats van verwijzingen en marges. Doe dit alleen als hier ' + + 'geen echte gegevens in staan.', + precisionTitle: 'Hoe precies een AI de cijfers ziet', + precisionIntro: 'Aan betekent marges in plaats van exacte getallen, en dat is de stand waarin de ' + + 'app altijd heeft gewerkt. Zet er een uit en die soort komt exact mee. Staat "Onbewerkt" aan, ' + + 'dan doen deze vier niets: dan is er niets om een marge van te maken.', + sharesHead: 'Percentages', + sharesOn: 'Aan: de verdeling over uw assets komt in marges, bijvoorbeeld "20 tot 25%".', + sharesOff: 'Uit: de verdeling komt exact, bijvoorbeeld "23,4%".', + totalsHead: 'Totalen', + totalsOn: 'Aan: het totaal van uw vermogen komt als marge.', + totalsOff: 'Uit: het totaal komt als exact bedrag.', + amountsHead: 'Bedragen', + amountsOn: 'Aan: een bedrag in een regel komt als marge.', + amountsOff: 'Uit: een bedrag in een regel komt exact.', + quantitiesHead: 'Hoeveelheden', + quantitiesOn: 'Aan: een aantal (0,05 BTC) komt als marge.', + quantitiesOff: 'Uit: een aantal komt exact.', + precisionCombo: 'Let op: staan "Percentages" en "Totalen" allebei uit, dan laat een AI-antwoord de ' + + 'verdeling over uw assets wég. Samen leggen die twee de waarde per asset vast, terwijl de ' + + 'bedragen per regel juist in marges staan.', + scope: 'Deze instellingen gelden alleen voor de portefeuille die nu open is: {naam}.', + revoke: 'Een recht dat u geeft, kunt u hier altijd weer intrekken: zet de schuif uit en het geldt ' + + 'vanaf de volgende aanroep, ook voor een AI-programma dat al draait. Herstarten is niet nodig. ' + + 'Wat al is goedgekeurd, blijft staan; intrekken gaat over wat een AI hierna nog mag.', + controlLabel: 'De app mag bestuurd worden', + controlHint: 'Hiermee mag een AI u naar een pagina brengen. Uw gegevens worden er niet door ' + + 'gelezen of gewijzigd. U ziet elke sprong aankomen en kunt hem tegenhouden.', + controlOn: 'Aan: een AI mag u naar een pagina brengen.', + controlOff: 'Uit: een verzoek om te navigeren wordt geweigerd.', + connectTitle: 'Koppelen aan uw AI-programma', + connectIntro: 'Zet onderstaande regels in het configuratiebestand van uw AI-programma. Het pad is al ' + + 'ingevuld voor deze computer. Beweeg over het kader en klik het kopieerknopje rechtsboven; plak ' + + 'het blok daarna ongewijzigd in dat bestand.', + connectFile: 'Bij Claude Desktop heet dat bestand claude_desktop_config.json. Staat er al een ' + + 'blok "mcpServers", voeg dan alleen de regels daarbinnen toe.', + connectRestart: 'Het AI-programma moet daarna opnieuw gestart worden; pas dan leest het de ' + + 'nieuwe configuratie.', + connectCopy: 'Kopiëren', + connectCopied: 'Gekopieerd', + connectCopyFail: 'Kopiëren is niet gelukt. Selecteer de tekst hierboven en kopieer met Ctrl+C.', + connectFail: 'De koppelgegevens konden niet worden opgehaald.', + dryTitle: 'Vooraf bekijken wat er gedeeld zou worden', + dryIntro: 'Kies een handeling en bekijk precies wat een AI-programma te zien zou krijgen. De ' + + 'handeling wordt echt uitgevoerd op uw gegevens, maar het antwoord gaat naar dit scherm en ' + + 'niet naar buiten. Er wordt niets gewijzigd en er komt geen regel in het logboek hieronder, ' + + 'want er is niets verstuurd.', + dryPick: 'Handeling', + dryRun: 'Bekijken', + dryBusy: 'Bezig…', + dryFail: 'Het bekijken is niet gelukt.', + dryResult: 'Dit zou het AI-programma te zien krijgen', + dryScope: { + none: 'raakt de administratie niet', + read: 'leest de administratie', + write: 'wijzigt gegevens', + control: 'bestuurt de app, leest geen gegevens', + }, + dryArgs: 'Waarden voor deze handeling', + dryArgRequired: 'verplicht', + dryArgPick: 'Kies…', + dryNeedsArg: 'Vul eerst de verplichte waarden in.', + dryArgHandleNone: 'Een verwijzing krijgt u van een andere handeling. Bekijk eerst "Overzicht van de ' + + 'inrichting"; in die uitvoer staan verwijzingen zoals account:7f3a9c die u hier kunt invullen.', + dryArgHandleSome: 'Kies een verwijzing die u eerder zag, of typ er een over uit een gesprek.', + }, + + home: { + title: 'Dashboard', + assets: 'Assets', + locations: 'Locaties', + events: 'Events', + valuations: 'Peildatumwaarden', + accounts: 'Accounts', + reconcile: 'Reconcile', + rematch: 'Bijwerken', + updating: 'Bijwerken', + rematchDone: 'nieuwe koppelingen gelegd', + rematchNone: 'Geen nieuwe koppelingen: alles is al gekoppeld.', + recAsTrade: 'Aankoop/Verkoop', + recAsTransfer: 'Verplaatsing', + recIgnore: 'Negeer', + recTotal: 'Betaald bedrag ({munt})', + recPrice: 'Fill-prijs per eenheid ({munt})', + recPriceLoading: 'Koers ophalen…', + recPriceFrom: 'Dagkoers van {date} uit de prijsgeschiedenis, per {unit}. Aanpassen mag.', + recPriceAt: 'Koers van {time} uit Atreides Services, per {unit}. Aanpassen mag.', + recPriceUnknown: 'Geen koers bekend bij die datum. Vul de prijs zelf in, of 0 als u er niets voor ' + + 'betaald hebt.', + recPriceRequired: 'Vul een prijs in.', + recTxAt: 'Moment van de transactie', + recDetectedAt: 'De transactie is nog niet bevestigd; dit is het moment waarop de app hem zag', + recCounter: 'Tegenaccount', + recConfirm: 'Bevestig', + recIn: 'ontvangen', + recOut: 'verstuurd', + recLightning: 'Lightning', + recVia: 'via {wallet}', + recViaNwc: 'NWC-wallet', + recDone: 'Verwerkt.', + records: 'records', + recPending: 'Openstaande afwijkingen', + recHandled: 'Afgehandeld', + recHandledNone: 'Nog niets afgehandeld.', + recRevert: 'Terugdraaien', + recRevertConfirm: 'Voorstel terugdraaien? Een eventueel aangemaakte gebeurtenis wordt verwijderd.', + recLink: 'Koppel aan gebeurtenis', + recDelete: 'Verwijderen', + recDeleteConfirm: 'Dit voorstel verwijderen uit de lijst? Een eventueel aangemaakte gebeurtenis of koppeling blijft staan.', + recUnknownProposal: 'Voorstel {id} bestaat niet meer.', + recHandled_accepted_trade: 'Voorstel {id} is al als aankoop of verkoop geboekt.', + recHandled_accepted_transfer: 'Voorstel {id} is al als verplaatsing geboekt.', + recHandled_accepted_interest: 'Voorstel {id} is al als rente geboekt.', + recHandled_ignored: 'Voorstel {id} is al genegeerd.', + recHandled_auto_linked: 'Voorstel {id} is al automatisch gekoppeld.', + recNoCounter: 'Er is geen tegenaccount om naartoe te boeken. Zet eerst een tweede account op actief.', + recAsInternal: 'Binnen het account', + recAsChannelTransfer: 'Naar het kanaal op {account}', + recInternal_channel: 'kanaal geopend of gesloten', + recInternal_self: 'betaling aan uzelf', + recInternalHint: 'Blijft binnen het account: alleen de fee gaat eraf.', + recNotInternal: 'Voorstel {id} is geen beweging binnen het account.', + recBookAll: 'Lightning boeken ({n})', + recBookAllConfirm: '{trade} lightning-betalingen boeken als aankoop of verkoop, tegen de koers van het ' + + 'moment of anders de dagkoers, {internal} bewegingen binnen het account en {interest} forwards als ' + + 'rente. {skipped} gewone on-chain bewegingen blijven staan; die boekt u per stuk.', + recAsInterest: 'Als rente', + recInterestHint: 'Routing-inkomen van uw node: telt als opbrengst, niet als inleg.', + recNotForward: 'Voorstel {id} is geen lightning-forward.', + recBookAllProgress: 'Boeken {i} van {n}', + recBookAllDoneTitle: 'Lightning geboekt', + recBookAllDone: '{booked} geboekt. Zonder koers blijven staan: {noPrice}. Mislukt: {failed}.', + recAutoLinked: 'Voorstel {id} is automatisch gekoppeld; ontkoppelen doe je met "Koppel aan gebeurtenis".', + recStillOpen: 'Voorstel {id} staat nog open; er is niets om terug te draaien.', + recPrev: 'Vorige', + recNext: 'Volgende', + recAll: 'Alle voorstellen', + recIgnoreAll: 'Alles negeren', + recDeleteAll: 'Alles verwijderen', + recIgnoreAllConfirm: 'Alle openstaande voorstellen negeren? Genegeerde voorstellen komen bij een herscan niet terug.', + recDeleteAllConfirm: 'Alle openstaande voorstellen verwijderen? De afgehandelde log blijft staan; niet-gekoppelde bewegingen verschijnen bij een nieuwe scan opnieuw.', + btcWidget: 'Bitcoin', + toSignals: 'Naar Signalen & Grafieken', + hintTitle: 'Het dashboard indelen', + hintEdit: 'Houd een widget even ingedrukt om de indeling aan te passen.', + hintDone: 'Klaar: Escape, een klik naast de widgets of een andere pagina.', + hintAdd: 'Toevoegen: wijs een lege plek aan en klik op de +. Op een telefoon staat de lijst onder de widgets.', + hintWidget: 'Een widget verplaats je aan het greepje; het knopje ernaast opent zijn instellingen.', + hintProfiles: 'Een indeling kies je in het menu. Standaard verwijderen begint opnieuw met de standaardindeling.', + groupAccount: 'Per account', + groupAccountKind: 'Per accountsoort', + groupAsset: 'Per asset', + groupAssetKind: 'Per assetsoort', + groupBy: 'Groeperen', + standing: 'Huidige stand', + toWealth: 'Naar Vermogen', + standingNone: 'Nog geen tax-relevante posities.', + showToday: 'Rendement tonen', + }, + + conversion: { + title: 'Bitcoin Conversies', + btcRate: 'Bitcoin koers', + loading: 'Koers ophalen…', + updatedAt: 'Bijgewerkt om', + rateUnavailable: 'Koers niet beschikbaar', + fiat: 'Euro', + fiatHint: 'Traditionele munteenheid', + btcHint: 'Basiseenheid · max 21 mln', + hierarchy: 'Eenheden hiërarchie', + satNote: 'De satoshi is de kleinste on-chain eenheid. De millisatoshi bestaat alleen op het Lightning Network.', + footer: 'Live koers via de gekozen koersbron', + }, + + labels: { + title: 'Labels', + inUse: 'Label is in gebruik door {n} item(s); ontkoppel die eerst.', + new: 'Nieuw label', + edit: 'Bewerken', + cancel: 'Annuleren', + save: 'Opslaan', + name: 'Naam', + color: 'Kleur', + useColor: 'Kleur gebruiken', + none: 'Nog geen labels.', + delete: 'Verwijderen', + confirmDelete: 'Dit label verwijderen?', + colColor: 'Kleur', + colName: 'Naam', + badHex: 'Ongeldige kleur. Gebruik bv. #39FF8A.', + }, + + accounts: { + maxFreeBank: 'De gratis versie houdt het op {max} fiatrekening. Met een licentie vervalt die grens; ' + + 'er wordt intussen niets weggehaald.', + title: 'Accounts', + nameRequired: 'Vul een naam in.', + new: 'Nieuw account', + edit: 'Bewerken', + cancel: 'Annuleren', + save: 'Opslaan', + name: 'Naam', + kind: 'Soort', + ownCustody: 'Zelf in bewaring', + scanEnabled: 'Automatisch scannen', + taxCategory: 'Categorie', + taxCategoryAuto: 'volgt asset', + taxScope: 'Belastingen', + taxScopeAuto: 'standaard (volgt asset)', + wealthScope: 'Vermogen', + wealthScopeAuto: 'standaard (eigen vermogen)', + color: 'Kleur', + useColor: 'Kleur gebruiken', + label: 'Label', + none: 'Nog geen accounts.', + confirmDelete: 'Dit account verwijderen?', + yes: 'Ja', + no: 'Nee', + sources: 'Tracking-bronnen', + sourcesFor: 'Tracking-bronnen van', + addSource: 'Tracking-bron toevoegen', + sourceKind: 'Soort', + sourceValue: 'Waarde', + xpub: 'xpub / ypub / zpub', + address: 'Los adres', + nwc: 'NWC (Nostr Wallet Connect)', + lnd: 'LND (Lightning-node)', + noSources: 'Nog geen tracking-bronnen voor dit account.', + noSourceKind: 'Dit accounttype heeft geen tracking-bron.', + interestManageHint: 'Opvoeren en wijzigen op', + interestNone: 'Nog geen rente-afspraken voor dit account.', + interestMovedLink: 'Rente & Beloning', + merge: 'Samenvoegen', + mergeTitle: 'Twee accounts samenvoegen tot één', + mergeNeedTwo: 'Je hebt minstens twee accounts nodig om samen te voegen.', + mergePick: '(kies een account)', + mergeKeep: 'Behouden account', + mergeDrop: 'Opheffen account', + mergeSummary: '"{drop}" → "{keep}". Verhuist: {sources} bron(nen), {events} event(s), {schedules} afspra(a)k(en), {valuations} waardering(en).', + mergeConfirm: 'Dit samenvoegen is onomkeerbaar. Doorgaan? (Doe daarna een herscan.)', + mergeDone: 'Accounts samengevoegd. Doe een herscan om de tracking-cache te verversen.', + mergeReason_pickTwo: 'Kies twee accounts.', + mergeReason_sameAccount: 'Kies twee verschillende accounts.', + mergeReason_notFound: 'Account niet gevonden.', + mergeReason_sourceMismatch: 'Kan niet: het ene soort heeft tracking-bronnen en het andere niet (bijv. exchange × wallet).', + mergeReason_kindCannotCarry: 'Kan niet: het behouden account kan de bron-soorten van het andere niet dragen. Kies het Lightning-account als behouden account, of zet het eerst op soort "Lightning".', + sourceValueRequired: 'Vul een xpub, adres of NWC-URI in.', + sourceNameRequired: 'Geef de tracking-bron een naam.', + inUse: 'Dit account is in gebruik door {n} boeking(en); verwijder of herkoppel die eerst.', + unknownAccount: 'Account #{id} bestaat niet.', + sourceUriMissing: 'De NWC-URI ontbreekt.', + sourceNotLnd: 'De gekozen verbinding is geen LND-verbinding.', + labelNoTxs: 'Er zijn geen bewegingen opgegeven voor het label.', + sourceNamePh: 'bijv. Ledger ontvangst', + colSourceName: 'Naam', + confirmDeleteSource: 'Deze tracking-bron en de gescande adressen verwijderen?', + testSource: 'Test', + testing: 'Testen…', + testValid: 'geldig', + testTxs: 'transacties', + testFail: 'Test mislukt', + nwcPubkey: 'wallet-pubkey', + nwcKeepPh: 'URI opgeslagen: leeg laten = behouden, nieuwe plakken = vervangen', + colSourceKind: 'Soort', + colSourceValue: 'Bron', + colConnection: 'Verbinding', + colScriptType: 'Type', + lndVia: 'via', + lndHeight: 'blokhoogte', + lndNoConnection: 'Kies een LND-verbinding (maak er eerst één aan bij Verbindingen).', + noNode: 'Geen', + onchainOfNode: 'on-chain van', + onchainOfNodeHint: 'Deze wallet is de on-chain kant van die node. Een kanaal dat hij opent, boekt de app als verplaatsing naar het account van de node.', + 'script_p2tr': 'Taproot (bc1p…)', + 'script_p2wpkh': 'Native SegWit (zpub · bc1q…)', + 'script_p2sh-p2wpkh': 'Nested SegWit (ypub · 3…)', + 'script_p2pkh': 'Legacy (xpub · 1…)', + interest: 'Ontvangen rente', + intDate: 'Datum', + intAsset: 'Asset', + intAmount: 'Bedrag', + intValue: 'Waarde', + noInterest: 'Nog geen rente ontvangen op dit account.', + schedules: 'Rente-afspraken', + schPrincipalAsset: 'Vastgezet', + schPrincipal: 'Hoofdsom', + schRate: 'Rente', + schPayoutAsset: 'Uitbetaling', + schStart: 'Start', + schEnd: 'Eind', + schCadence: 'Bijschrijven', + schWeekday: 'Weekdag', + schDayOfMonth: 'Dag v/d maand', + wd0: 'Zondag', wd1: 'Maandag', wd2: 'Dinsdag', wd3: 'Woensdag', wd4: 'Donderdag', wd5: 'Vrijdag', wd6: 'Zaterdag', + schRateType: 'Soort', + schNote: 'Notitie', + noSchedules: 'Nog geen rente-afspraken voor dit account.', + confirmDeleteSchedule: 'Deze rente-afspraak verwijderen?', + schAssetRequired: 'Kies het vastgezette asset.', + schPayoutRequired: 'Kies een uitkerings-asset.', + schRateInvalid: 'Vul een geldige rente in.', + schStartRequired: 'Vul een startdatum in.', + schEndBeforeStart: 'De einddatum ligt vóór de startdatum.', + projection: 'Prognose', + projectionHint: 'Resterende bijschrijvingen, informatief, telt niet mee in saldo of belasting.', + projReceived: 'Ontvangen', + projExpectRemain: 'Nog te verwachten', + projCount: '{n} bijschrijvingen', + projTermTotal: 'Totaal looptijd', + projEndBalance: 'Eindstand {datum}', + projEndBalanceSub: 'inleg plus rente', + projNext: 'Volgende', + projAllUpcoming: 'Alle {n} komende bijschrijvingen', + projOver: 'over {bedrag}', + projUntil: 't/m {datum}', + dueTitle: 'Te boeken rente', + book: 'Boeken', + dismiss: 'Negeren', + bookOldestFirst: 'Boek van oud naar nieuw: elke bijschrijving verhoogt het saldo waarover de volgende rekent. Negeren mag in elke volgorde en geeft de volgende vrij.', + confirmBook: 'Rente bijschrijven:', + }, + + interest: { + title: 'Rente & Beloning', + none: 'Nog geen rente-events.', + needAsset: 'Voeg eerst een asset toe.', + colDate: 'Datum', + time: 'Tijd', + colAccount: 'Account', + colAsset: 'Asset', + colAmount: 'Bedrag', + colSource: 'Bron', + colNote: 'Notitie', + colSchedule: 'Rente-afspraak', + noSchedule: 'Geen afspraak', + new: 'Nieuwe rente', + save: 'Opslaan', + cancel: 'Annuleren', + confirmDelete: 'Dit rente-event verwijderen?', + confirmDeletePeriod: 'Deze renteperiode verwijderen?', + invalid: 'Vul datum, account, asset en bedrag (> 0) in.', + importNote: 'Geïmporteerde records zijn gezaghebbend en niet bewerkbaar.', + schedulesTitle: 'Rente-afspraken', + recordsTitle: 'Ontvangen rente', + principalFollowsBalance: 'volgt het saldo', + endsOnOpen: 'doorlopend', + cadenceNone: 'Zonder ritme', + colCompounding: 'Rente op rente', + compoundingViaBalance: 'via het saldo', + depositTitle: 'Hoofdsom leeg', + depositWarning: 'Deze afspraak heeft een einddatum en volgt het saldo, en de rente komt op dit account. Dan rekent elke bijschrijving ook over de eerder geboekte rente. Bij een deposito rekent de bank meestal over het startbedrag: vul dat dan in als hoofdsom.', + depositSaveAnyway: 'Toch opslaan', + depositBack: 'Hoofdsom invullen', + depositNotSaved: 'Niet opgeslagen: vul het startbedrag in als hoofdsom, of sla op en kies Toch opslaan.', + periods: 'Renteperioden', + periodsBase: 'Vanaf de start van de afspraak op', + periodsVariable: 'Deze afspraak staat op "handmatig of import": de app rekent hier niets uit, dus er zijn geen renteperioden.', + periodStartsOn: 'Gaat in op', + periodRateRequired: 'Vul een rentepercentage in.', + noPeriods: 'Nog geen wijzigingen; de rente van de afspraak geldt de hele looptijd.', + noSchedules: 'Nog geen rente-afspraken. Voeg er een toe om prognoses en te-boeken rente te zien.', + schAccountRequired: 'Kies een account voor de afspraak.', + principalPositive: 'De hoofdsom moet groter dan 0 zijn. Laat het veld leeg om het saldo van het account te volgen.', + rateNotNegative: 'De jaarrente mag niet negatief zijn.', + compoundingNeedsPrincipal: 'Rente op rente vraagt een vaste hoofdsom. Een hoofdsom die het saldo volgt, groeit al met elke geboekte bijschrijving.', + compoundingNeedsSameAsset: 'Rente op rente kan alleen als de rente in hetzelfde asset wordt uitgekeerd als de hoofdsom.', + weekdayRequired: 'Kies een weekdag voor de wekelijkse bijschrijving.', + dayOfMonthRequired: 'Kies een dag-van-de-maand voor de maandelijkse bijschrijving.', + unknownSchedule: 'Deze rente-afspraak bestaat niet.', + unknownPeriod: 'Deze renteperiode bestaat niet.', + periodStartRequired: 'Vul een ingangsdatum in.', + periodStartBeforeSchedule: 'De ingangsdatum moet ná de start van de afspraak ({start}) liggen; de rente vanaf die dag staat in de afspraak zelf.', + bookOlderFirst: 'Boek eerst de bijschrijving van {start} → {eind}. Elke bijschrijving verhoogt het ' + + 'saldo waarover de volgende rekent, dus de volgorde bepaalt het bedrag.', + periodStartAfterEnd: 'De ingangsdatum ligt ná het einde van de afspraak ({eind}).', + }, + + taxes: { + title: 'Belastingen', + loading: 'Berekenen…', + empty: 'Nog niets om te berekenen.', + emptyWhy: 'Voer een gebeurtenis op (een aankoop of een verplaatsing) of zet een balans; vanaf dat moment rekent deze pagina de peildata uit.', + colAccount: 'Account', + colAsset: 'Asset', + groupAccount: 'Per account', + groupAsset: 'Per asset', + groupKind: 'Per soort', + colKind: 'Soort', + catTitle: 'Categorie', + catBank: 'Banktegoeden', + catOverig: 'Overige bezittingen', + catUnknown: 'Zonder categorie', + catNote: 'Categorie: de bedragen gesplitst naar banktegoeden en overige bezittingen. Verschijnt zodra op een account of asset een categorie is gekozen, of "Alleen apart vermeld" bij Belastingen (Systeem, tabs Assets en Accounts). Zonder keuze volgt een asset zijn soort: valuta is banktegoed, de rest overige bezitting. Wat alleen hier staat, telt niet in de tabel en het resultaat; geld van en naar zo\'n account telt daar als storting of onttrekking.', + showAll: 'Alles tonen', + showAllTip: 'Toon de pagina alsof Categorie en Belastingen op Assets en Accounts overal op de standaard staan.', + showAllNote: 'Alles tonen: staat ergens op een asset of account bij Categorie of Belastingen iets anders dan de standaard, dan toont deze schakelaar de pagina alsof alles op de standaard staat. Zo zie je wat die keuzes uitmaken. De stand van de schakelaar wordt onthouden.', + colCurrent: 'Huidige waarde', + total: 'Totaal', + untilNow: '· t/m nu', + hintTitle: 'Wat zie je hier', + hintMainTitle: 'Waarde per peildatum', + mainNote: 'Waarde per peildatum (1 januari, 00:00) in de rekenvaluta: de stand op dat moment × de koers van die dag. De stand is de laatst vastgelegde balans plus de gebeurtenissen daarna, of Σ gebeurtenissen als er geen balans is.', + pctNote: 'Hou de muis boven een bedrag om het rendement te zien over de periode die op die kolom eindigt; de kolom 1 januari gaat dus over het jaar ervóór. Op de totaalregel staat het percentage er wél bij, want dat is het getal over het geheel.', + pctMwr: 'MWR', + pctIrr: 'IRR', + pctMwrTip: 'Geldgewogen rendement volgens Modified Dietz: wat JOUW geld deed, gedeeld door het gemiddeld werkzame vermogen (elke storting telt mee naar rato van hoe lang hij er stond). Eindwaarde min beginwaarde, min wat je erbij kocht of naar dit account verplaatste, plus wat eruit ging, en min wat uit een balans kwam in plaats van uit een boeking. Bijkopen telt dus niet als winst. Leeg als er geen beginwaarde was om tegen af te meten.', + pctIrrTip: 'Geldgewogen rendement volgens de interne rentevoet (IRR), op jaarbasis: de rente die jouw stortingen en opnames precies verklaart. Waar die niet bestaat (een positie die tussendoor op nul komt) valt het getal terug op Modified Dietz. Instelbaar bij de weergave-instellingen.', + aangifteTitle: 'Resultaat', + colPeriod: '1 jan {van} → 1 jan {tot}', + colPeriodNow: '1 jan {van} → nu', + cellReturn: '{label} {pct}, resultaat {bedrag}', + cellResult: 'Resultaat {bedrag}', + buitenBox3: 'Buiten het overzicht gehouden (telt wél als vermogen):', + emptyColsNote: 'Weggelaten peildata omdat er niets stond: {jaren}. In het resultaat per jaar blijven ' + + 'ze de beginwaarde van dat jaar.', + startYearNote: 'Deze administratie begint in {eerste} en wordt getoond vanaf peildatum 1 januari {start}. De standen tellen alles daarvóór gewoon mee; er ontbreken alleen kolommen. Het startjaar staat op Systeem → Portefeuilles.', + aangifteNote: 'Kengetallen per jaar over het vermogen dat in deze app staat: crypto, edelmetaal en de accounts die je hier bijhoudt. Rendement = eindwaarde - beginwaarde - aankopen + verkopen - wat uit balansen kwam. "Huidige waarde" is het lopende jaar t/m nu; een jaar krijgt een eigen kolom zodra het is afgerond. De kop van een jaarkolom is de peildatum waarop dat jaar eindigt, zodat de kolommen uitlijnen met de tabel hierboven: de Eindwaarde hier is dezelfde peildatum-kolom daar, en de Beginwaarde is de kolom rechts ernaast. Klik een bedrag om het (afgerond op hele eenheden) te kopiëren. Deze app rekent geen belasting uit: dit zijn de kengetallen die een aangifte doorgaans vraagt; tarief en vrijstellingen verschillen per land.', + kgRendement: 'Rendement', + kgUitBalansen: 'Uit balansen (niet geboekt)', + kgGiften: 'Giften (niets betaald)', + kgUnexplained: 'Waarvan niet verklaard', + kgUnexplainedTip: 'Een deel van de wijziging komt uit een gemeten balans en niet uit boekingen. Was dat een storting die je niet hebt geboekt, dan is het rendement zoveel lager; was het opbrengst, dan klopt dit bedrag.', + zonderKoersTip: 'Buiten dit rendement gelaten, omdat de koers op één van beide peildata ontbreekt: {namen}. Als nul tellen zou die ontbrekende koers als verlies of winst lezen. Zet de koers erbij op Prijsgeschiedenis, of vul de waarde met een balans.', + zonderKoersWhere: 'Buiten het rendement gelaten, omdat de koers op één van beide peildata ontbreekt:', + unexplainedWhere: 'Waarvan niet verklaard, per account (een negatief bedrag betekent dat de boekingen méér verklaren dan de stand veranderde, meestal een sprong uit een eerder jaar die je nu geboekt hebt):', + unexplainedOpeningAll: 'een beginbalans zonder boekingen ervóór, dus hier valt niets te boeken', + unexplainedOpening: 'waarvan uit een beginbalans zonder boekingen ervóór:', + owrBegin: 'Beginwaarde', + owrEind: 'Eindwaarde', + owrDeposits: 'Aankopen (stortingen)', + owrWithdrawals: 'Verkopen (onttrekkingen)', + }, + + eventAccounts: { + none: 'Nog geen accounts.', + noneWhy: 'Een saldo hoort bij een account. Maak er eerst een aan.', + manage: 'Accounts beheren', + empty: 'Geen saldo', + closed: 'Gesloten', + openGaps: '{n} verschil te controleren', + open: 'Account openen', + pickAsset: 'Toon alleen dit asset in de tabellen; klik nog eens voor alles', + showEmpty: 'Zonder saldo', + recordBalance: 'Saldo vastleggen', + recordBalanceHint: 'Leg het saldo van nu vast als balans. Pas het bedrag aan als je afschrift iets anders zegt; het verschil komt dan op Af te handelen.', + recordBalanceTitle: 'Saldo van {asset} vastleggen als balans', + lastBalance: 'Balans {datum}', + fromBookings: 'Uit boekingen', + noPrice: 'geen koers', + hintTitle: 'Accounts', + hintBalance: 'Het saldo is de nieuwste balans plus de boekingen erna, hetzelfde getal als op Vermogen. "Balans" is de datum van die laatste balans; "Uit boekingen" betekent dat er voor dit asset nog geen balans is vastgelegd.', + hintDetail: 'Klik een kaart om de balansen, aan- en verkopen, verplaatsingen en rente van dat account te zien en te bewerken. Klik daar een asset aan om de tabellen op dat asset te filteren.', + interestAgreement: 'Rente-afspraak', + interestAgreements: '{n} rente-afspraken', + interestBooked: '{n}× rente geboekt, laatst {datum}', + interestNext: 'verwacht {datum}: {bedrag}', + nowTotal: 'nu {bedrag} op de getoonde accounts', + nowTotalPartial: 'nu {bedrag} op de getoonde accounts, zonder de assets zonder koers', + act: { + buy: 'aankoop', + sell: 'verkoop', + transferIn: 'verplaatsing erin', + transferOut: 'verplaatsing eruit', + interest: 'rente', + balance: 'balans vastgelegd', + agreementStart: 'rente-afspraak begint', + agreementEnd: 'rente-afspraak eindigt', + accountOpened: 'account aangemaakt', + accountClosed: 'account gesloten', + accountChanged: 'account laatst gewijzigd', + reservation: 'reservering', + interestDue: 'rente verwacht', + }, + hintActivity: 'De lijn bovenaan (alle getoonde accounts), onderaan een kaart en in het accountvenster is de activiteit vanaf het startjaar van de portefeuille: een piek omhoog is een dag waarop er iets binnenkwam, een dal een dag waarop er iets uitging; de hoogte volgt het aantal boekingen. Streepjes zijn balansen, rente-afspraken, verwachte rentebijschrijvingen, reserveringen (een vierkantje) en wijzigingen aan het account; de lijn loopt door tot de laatste daarvan, ook als die in de toekomst ligt. In het accountvenster opent een klik op een dag die boeking of reservering.', + hintInterest: 'Onder het saldo: de lopende rente-afspraken van het account en hoe vaak er rente of beloning is geboekt.', + hintCapture: 'De knop "Saldo vastleggen" staat standaard alleen bij geld. Per asset zet je hem aan of uit op Systeem, tab Assets, kolom "Saldo vastleggen".', + }, + + valuations: { + maxFree: 'De gratis versie houdt het op {max} balans. Met een licentie vervalt die grens; ' + + 'er wordt intussen niets weggehaald.', + title: 'Balansen', + needAsset: 'Maak eerst een asset aan of migreer de data op het overzicht.', + colDate: 'Peildatum', + colTime: 'Tijd', + colUpdated: 'Bijgewerkt', + colAccount: 'Account', + colAsset: 'Asset', + colBalance: 'Saldo', + colRate: 'Koers ({munt})', + colSource: 'Bron', + colDiff: 'Verschil', + ratePlaceholder: 'leeg = opzoeken', + rateNote: 'Koers leeg laten = automatisch opgezocht op peildatum en tijd, via de bron die voor dit asset gekozen is. Een balans is het anker voor de stand van dat account: de stand is deze balans plus de gebeurtenissen erna. Staan er ook gebeurtenissen vóór het peilmoment, dan toont Verschil of die daarmee kloppen, en schrijft "Boek" het verschil als één correctie tegen de koers van dat moment.', + anchorHint: 'Dit is het anker voor de stand van dit account: er staan geen gebeurtenissen van vóór dit peilmoment, dus er is niets te verzoenen.', + futureHint: 'Deze peildatum moet nog komen. De balans telt nog niet mee in je stand en levert geen werk op; zodra het peilmoment voorbij is, wordt hij het anker voor dit account.', + book: 'Boek', + accept: 'Aanvaard', + acceptHint: 'Dit verschil is geen opbrengst maar geld dat erin of eruit ging. Geen boeking, en het telt niet meer als niet verklaard.', + confirmAccept: 'Dit verschil aanvaarden? Het telt dan als geld dat de administratie in- of uitgaat en niet als rendement. Je kunt het later terugdraaien.', + lightningRow: 'Gemeten bij Bijwerken', + lightningHint: 'Het saldo op Tracking (on-chain plus lightning) tegen wat je administratie op dat moment zegt.', + recordBalance: 'Leg vast als balans', + recordBalanceHint: 'Het gemeten saldo wordt een balans op die dag. Daarna staat hij hier als gewone balans, met Boek en Aanvaard.', + recordTitle: 'Gemeten saldo vastleggen', + recordRate: 'Koers op {date}', + lightningSameDay: 'Er staat al een balans op {date} voor dit account; die wordt niet overschreven.', + acceptedMark: '✓ aanvaard', + acceptedHint: 'Aanvaard verschil, klik om terug te draaien:', + bookTitle: 'Verschil boeken', + bookAsSuffix: 'boeken als', + bookAsSell: 'Verkoop (ging uit je portefeuille)', + bookAsBuy: 'Aankoop (kwam je portefeuille in)', + bookAsTransferTo: 'Verplaatsing naar', + bookAsTransferFrom: 'Verplaatsing van', + bookAsFeeOn: 'Kosten bij {soort} van {datum}', + bookReplaces: 'bestaande regel wordt', + bookRemoves: 'haalt de bestaande regel weg', + bookNote: 'waardering-correctie', + bookFeeNote: 'kosten aangevuld via Verschil boeken', + invalid: 'Vul peildatum, asset en saldo (≥ 0) in.', + noRate: 'Geen koers gevonden voor dit tijdstip.', + none: 'Nog geen balansen.', + confirmDelete: 'Deze balans verwijderen?', + }, + + assets: { + title: 'Assets', + decimalsRange: 'Decimalen moet een heel getal van 0 tot en met {max} zijn (gekregen: "{waarde}"). ' + + 'Daarboven kan een hoeveelheid niet meer teruggelezen worden.', + inUse: 'Asset is in gebruik door {n} record(s); verwijder die eerst.', + maxFree: 'De gratis versie houdt het op {max} asset. Met een licentie vervalt die grens; ' + + 'er wordt intussen niets weggehaald.', + freeOnly: 'De gratis versie houdt alleen {list} en munten bij. Met een licentie vervalt die grens.', + fillSymbolNameUnit: 'Vul symbool, naam en hoofdeenheid in.', + metalUnitUnknown: 'Metaal-hoofdeenheid moet een bekende eenheid zijn: {eenheden}.', + fillDecimals: 'Vul decimalen (≥ 0) in.', + new: 'Nieuwe asset', + edit: 'Bewerken', + cancel: 'Annuleren', + save: 'Opslaan', + symbol: 'Symbool', + name: 'Naam', + kind: 'Soort', + unit: 'Hoofdeenheid', + subunit: 'Ondereenheid', + displayUnit: 'Weergave', + decimals: 'Decimalen', + taxCategory: 'Categorie', + taxCategoryAuto: 'volgt soort', + taxCategoryAutoIs: 'volgt soort ({waarde})', + taxScope: 'Belastingen', + taxScopeAuto: 'standaard (telt mee)', + balanceCapture: 'Saldo vastleggen', + balanceCaptureOn: 'Aan', + balanceCaptureOff: 'Uit', + balanceCaptureAutoOn: 'volgt soort (aan)', + balanceCaptureAutoOff: 'volgt soort (uit)', + priceSource: 'Koersbron', + none: 'Nog geen assets.', + delete: 'Verwijderen', + confirmDelete: 'Deze asset verwijderen?', + }, + + metalProducts: { + title: 'Munten & baren', + assetRequired: 'Kies een geldig metaal-asset.', + nameRequired: 'De naam is verplicht.', + fineGramsPositive: 'Het fijn gewicht moet groter dan 0 zijn.', + pickMetalAndName: 'Kies een metaal en vul een naam in.', + fillFineGrams: 'Vul een geldig fijn gewicht (> 0) in.', + notMetal: 'Het gekozen asset is geen metaal.', + inUse: 'Dit product is in gebruik door {n} gebeurtenis(sen); verwijder die eerst.', + asset: 'Metaal', + name: 'Naam', + form: 'Vorm', + fineWeight: 'Fijn gewicht', + weight: 'Gewicht', + unit: 'Eenheid', + save: 'Opslaan', + cancel: 'Annuleren', + none: 'Nog geen munten of baren.', + needMetal: 'Voeg eerst een metaal-asset toe (Stamgegevens → Assets).', + confirmDelete: 'Dit product verwijderen?', + }, + + wealth: { + title: 'Vermogen', + none: 'Nog geen assets met een positie.', + quantity: 'Hoeveelheid', + value: 'Actuele waarde', + avgSpot: 'Gem. aankoopkoers', + avgPremium: 'Gem. premie/event', + unrealized: 'Ongerealiseerd', + realized: 'Gerealiseerd', + share: 'Aandeel in totaal', + result: 'Resultaat', + twr: 'Rendement', + twrTotal: 'Rendement', + unexplained: 'Niet verklaard', + allYears: 'Alles', + noPrice: 'Geen koers bekend voor deze periode: geen waarde, aandeel of rendement.', + yearPick: 'Kies een jaar of een reeks jaren', + yearRangeTip: 'Shift+klik voor een reeks jaren.', + yearOff: 'Klik om dit jaar en alles ervóór weg te laten', + legendTitle: 'Wat zie je hier', + legendSparkValue: 'Waarde van de positie per dag.', + legendSparkInvested: 'Ingelegd. Het verschil met de volle lijn is het resultaat.', + legendBuy: 'Aankoop.', + legendSell: 'Verkoop. Een sprong zonder stip komt van de markt.', + legendRings: 'Verdeling over soorten (links) en assets (rechts) als deel van het totale vermogen; onder 2% valt onder "Overig".', + legendUnexplained: 'Niet verklaard: een balans wijkt af van de boekingen op dat account, dus er ontbreekt een boeking. Accounts die alleen op saldo worden bijgehouden tellen niet mee. Telt niet als rendement.', + legendTwr: 'Rendement is tijdgewogen: alleen de koers telt, stortingen en timing niet ("Ongerealiseerd" telt die wel). Rente, staking en cashback tellen als bezit bij ontvangst en staan in "Resultaat". De rekenvaluta heeft geen rendement. Het geldgewogen rendement staat op Belastingen.', + legendRange: 'Jaarkeuze: kalenderjaren, het lopende jaar tot vandaag. Shift-klik kiest een reeks; een gekozen jaar wegklikken laat ook de jaren ervóór vallen. Alles op de pagina volgt de keuze, met bedragen per einde periode (1 januari erna). Elk asset dat in de periode bewoog krijgt een kaart, ook als het inmiddels verkocht is. Het detailvenster toont altijd "Alles".', + legendInvested: 'Ingelegd: betaalde koers plus premie en kosten. "Gem. aankoopkoers", "Gem. premie/event" en "Ingelegd" per asset staan in het detailvenster; de aankoopkoers meet alleen de spot.', + legendResult: 'Resultaat: wat de positie in de gekozen periode opleverde, de waardeverandering min wat er netto is ingelegd. Koerswinst, verkopen en rente tellen mee. Ongerealiseerd geldt sinds aankoop en alleen voor wat je nog hebt; over alle jaren is resultaat ongeveer ongerealiseerd plus gerealiseerd plus rente.', + legendDetail: '"Toon details": de grafiek groot, met accounts en boekingen. Een regel aanwijzen markeert die dag in de grafiek.', + legendChain: 'Rendement over meerdere jaren is geketend, niet opgeteld: twee jaar +10% is samen +21%. Een jaarkader toont dat jaar apart.', + legendNoPrice: '"Geen koers bekend": de hoeveelheid komt uit de boekingen; waarde, aandeel, resultaat en rendement blijven leeg (geen nul). Rendement telt vanaf de eerste bekende koers. Koerspunten toevoegen kan via een bron op Prijsgeschiedenis of via de AI-koppeling; één per jaar volstaat.', + legendUnrealized: 'Ongerealiseerd: tegen de betaalde prijs inclusief kosten en premie. Gerealiseerd: opbrengst van verkopen in de periode min kosten en kostprijs. Een streepje: niets verkocht of kostprijs onbekend. Een munt staat op min de premie tot de spot die terugverdient; los gewicht heeft geen product.', + invested: 'Ingelegd', + total: 'Totale waarde', + product: 'Product', + form: 'Vorm', + count: 'Aantal', + weight: 'Gewicht', + unallocated: 'Niet-toegewezen / los gewicht', + openDetail: 'Toon details', + tradesTitle: 'Aan- en verkopen', + tradeDate: 'Datum', + tradeType: 'Soort', + tradeBuy: 'Aankoop', + tradeSell: 'Verkoop', + tradePrice: 'Koers', + tradeAmount: 'Bedrag', + noTrades: 'Geen aan- of verkopen op dit account.', + noChart: 'Geen verloop over de gekozen periode.', + ringKinds: 'Naar soort', + ringAssets: 'Per asset', + ringKindsLabel: 'Verdeling van je vermogen over de assetsoorten', + ringAssetsLabel: 'Verdeling van je vermogen over de afzonderlijke assets', + ringOther: 'Overig', + perAccount: 'Per account', + sinceStart: 'Sinds begin', + openYearOverview: 'Jaaroverzicht van het vermogen openen', + openReturn: 'Rendement openen', + returnTitle: 'Rendement', + returnDay: 'Deze dag', + returnSoFar: 'Sinds begin periode', + returnCarried: 'Geen koers deze dag: de vorige is doorgetrokken, dus de beweging komt pas op de volgende koers.', + periodDay: '1 dag', + periodDays: '{n} dagen', + periodYear: '1 jaar', + returnData: 'Waarover', + returnTotal: 'Hele vermogen', + returnSelection: 'Selectie ({n} assets)', + returnKinds: 'Soort', + returnAssets: 'Asset', + perAsset: 'Per asset', + groupBy: 'Groepeer de kaarten', + asset: 'Asset', + shareOfAccount: 'Aandeel in account', + broughtInNet: 'Ingebracht (netto)', + ownWealth: 'Eigen vermogen', + othersWealth: 'Vermogen van derden', + costOnArrival: 'Kostprijs bij binnenkomst', + account: 'Account', + noAccountAssets: 'Dit account houdt nu niets aan.', + legendAccounts: 'Per account: dezelfde koersen als per asset. Ongerealiseerd naar rato van de eenheden, met de gemiddelde kostprijs van eigen vermogen. Een account van derden heeft een eigen kostprijs: de dagkoers waartegen het binnenkwam, plus wat het zelf kocht. Een verplaatsing tussen eigen accounts telt bij het rendement als opname en storting tegen de dagkoers, dus alleen de koers en de kosten tellen. De tweede lijn is wat er is ingebracht, verplaatsingen inbegrepen, en het resultaat is het verschil met de waarde. Wat op geen account staat, krijgt de kaart "Zonder account".', + legendFilter: 'Filter: een kaart uitzetten verbergt alleen de kaart. Het totaal, de ringen en het rendement tellen hem mee. Per account filter je op soort, zoals exchange of wallet.', + showAllTip: 'Toon ook het vermogen dat je voor een ander beheert en wat bij Vermogen niet meetelt.', + legendShowAll: 'Alles tonen (de schakelaar bij het totaal): staat bij Vermogen op een account "Vermogen van derden" of "Telt niet mee" (Systeem, tab Accounts), dan telt die pagina dat account niet mee. Geld van en naar zo\'n account telt als onttrekking of storting en niet als resultaat. De schakelaar toont alles, en zijn stand wordt onthouden.', + legendManaged: 'Vermogen van derden, niet meegeteld: {bedrag}.', + legendNotCounted: 'Telt niet mee, niet meegeteld: {bedrag}.', + legendShowAllOn: 'Alles tonen staat aan: vermogen van derden en wat niet meetelt, staan in de cijfers. In het venster van een asset staat het vermogen van derden op een eigen tab, met zijn eigen kengetallen.', + yearOverviewTitle: 'Jaaroverzicht van het vermogen', + valueEnd: 'Waarde eind', + investedNet: 'Ingelegd (netto)', + noLongerHeld: 'Niet meer in bezit', + noLongerHeldSince: 'Sinds {dag} is de hoeveelheid nul. De kaart staat er omdat je dit asset in deze periode nog wel had; rendement en resultaat gaan over die tijd.', + year: 'Jaar', + noYears: 'Nog geen jaren om te tonen.', + perProduct: 'Per product', + noAccount: 'Zonder account', + noAccountRows: 'Geen stand per account om te tonen.', + }, + + transfers: { + title: 'Verplaatsingen', + fillDateAsset: 'Vul datum en asset in.', + pickValidProduct: 'Kies een geldig product.', + fillCount: 'Vul een aantal stuks (> 0) in.', + fillDateAssetQuantity: 'Vul datum, asset en hoeveelheid (> 0) in.', + fillOneAccount: 'Vul ten minste een van/naar-account in.', + fillThisAccount: 'Kies dit account als van- of naar-account.', + new: 'Nieuwe verplaatsing', + edit: 'Bewerken', + cancel: 'Annuleren', + save: 'Opslaan', + colOrigin: 'Herkomst', + originPick: 'Gewone verplaatsing', + originNone: 'Niets tegenover', + originNonePick: 'Niets tegenover: gekregen of een bonus', + asset: 'Asset', + date: 'Datum', + time: 'Tijd', + amount: 'Hoeveelheid (sats)', + from: 'Van', + to: 'Naar', + unitFee: 'Fee (sats, on-chain)', + note: 'Notitie', + none: 'Nog geen verplaatsingen.', + delete: 'Verwijderen', + confirmDelete: 'Deze verplaatsing verwijderen?', + needAsset: 'Maak eerst een asset aan of migreer de data op het overzicht.', + colDate: 'Datum', + colAsset: 'Asset', + colAmount: 'Hoeveelheid', + colFrom: 'Van', + colTo: 'Naar', + colDirection: 'Richting', + colOther: 'Van / naar', + dirIn: 'In', + dirOut: 'Uit', + colFee: 'Fee', + colNote: 'Notitie', + colAddress: 'Ketenadres', + colSource: 'Bron', + addressPlaceholder: 'On-chain ontvangstadres (voor auto-match)', + colKind: 'Soort', + colProduct: 'Product', + addCrypto: 'Crypto verplaatsen', + addMetal: 'Edelmetaal verplaatsen', + addFiat: 'Geld verplaatsen', + looseWeight: '(los gewicht)', + internal: 'intern', + fiatNote: 'Fiat-waarde = marktkoers (dichtstbijzijnde) op de datum van verplaatsing, in de rekenvaluta.', + matched: 'Ook gevonden in on-chain tracking', + }, + + trades: { + title: 'Aankoop & Verkoop', + noSpotForDate: 'Geen koers gevonden voor deze datum. Vul de prijs zelf in, of 0 als u er niets ' + + 'voor betaald hebt.', + saveCancelled: 'Opslaan geannuleerd: pas de prijs aan.', + fillDateAssetPrice: 'Vul datum, asset en prijs in.', + pickValidProduct: 'Kies een geldig product.', + fillCount: 'Vul een aantal stuks (> 0) in.', + fillQuantity: 'Vul een hoeveelheid (> 0) in.', + new: 'Nieuwe trade', + edit: 'Bewerken', + cancel: 'Annuleren', + save: 'Opslaan', + type: 'Type', + asset: 'Asset', + date: 'Datum', + time: 'Tijd', + amount: 'Hoeveelheid (sats)', + location: 'Locatie', + note: 'Notitie', + none: 'Nog geen trades.', + delete: 'Verwijderen', + confirmDelete: 'Deze trade verwijderen?', + needAsset: 'Maak eerst een asset aan of migreer de data op het overzicht.', + colDate: 'Datum', + colType: 'Type', + colAsset: 'Asset', + colAmount: 'Hoeveelheid', + colTotal: 'Bedrag', + colPrice: 'Prijs/eenheid', + colOrigin: 'Herkomst', + originUnknown: 'Onbekend', + originPaidHint: 'De prijs komt van jou: een bedrag of een prijs per eenheid die je hebt ingevoerd.', + originMarketDay: 'Dagkoers, door de app opgezocht. Het tijdstip van de boeking is er niet in verwerkt.', + originMarketMoment: 'Koers op het tijdstip van de boeking, door de app opgezocht.', + originNoneHint: 'Verkregen zonder tegenprestatie, dus een gift. De prijs is 0 en dat is hier het juiste antwoord.', + originUnknownHint: 'Deze boeking komt van voor de app dit vastlegde; of de prijs betaald of opgezocht was, is niet bekend.', + originPick: 'Onbekend, of laat de app kiezen', + originPickPaid: 'Betaald: dit getal komt van mij', + originPickGift: 'Gift: niets betaald', + colSource: 'Bron', + colFee: 'Fee', + feeOnInterest: 'Kosten bij rente tellen nergens mee, dus deze boeking wordt niet opgeslagen. Laat de ' + + 'kosten weg of verlaag het bedrag.', + unitFeeOnBuy: 'Kosten in het asset zelf bij een aankoop tellen nergens mee. Boek het bedrag dat je ' + + 'ontving, zonder die kosten, of zet de kosten in geld.', + colAccount: 'Account', + colNote: 'Notitie', + colKind: 'Soort', + colProduct: 'Product', + addCrypto: 'Crypto toevoegen', + addMetal: 'Edelmetaal toevoegen', + addFiat: 'Geld storten of opnemen', + looseWeight: '(los gewicht)', + fiatNote: 'Bedrag = hoeveelheid × prijs per eenheid, inclusief de fee bij een aankoop en na aftrek van de fee bij een verkoop.', + matched: 'Ook gevonden in on-chain tracking', + }, + + priceOrigin: { + price: 'Koers {prijs} op {dag}', + pricePer: 'Koers {prijs} per {eenheid} op {dag}', + someSource: 'een bron', + close: 'Slotkoers van {bron}', + manual: 'Met de hand ingevoerd', + live: 'Actuele koers, die nog beweegt', + liveFrom: 'Actuele koers van {bron}, die nog beweegt', + spot: 'Afgeleid uit een spot van {bron}, geen slotkoers', + spotAt: 'Afgeleid uit de spot van {tijd} bij {bron}, geen slotkoers', + devBefore: '{pct} naast de slotkoers ervoor', + devAfter: '{pct} naast die erna', + carried: 'Doorgetrokken van {dag} ({bron}), {n} dagen oud', + strength: 'Nauwkeurigheid: {sterkte}', + sumHead: 'Rust op {n} koersen', + sumWeakest: 'Zwakste: {asset}, {zin}', + sumMissing: 'Geen koers voor {assets}: telt als nul mee', + pageWarnTitle: 'Ontbrekende koersen', + pageWarn: 'Op deze pagina ontbreekt bij minstens één bedrag een koers: die post telt als nul mee. Ga met de muis over de bedragen; waar het speelt staat een gele i die zegt welke post het is.', + bookingPrice: 'Prijs {prijs}', + bookingPricePer: 'Prijs {prijs} per {eenheid}', + bookingPaid: 'Betaald: de prijs die je zelf invoerde', + bookingMoment: 'De koers op het moment van de boeking', + bookingDay: 'Dagkoers van {dag}, opgezocht bij het boeken', + bookingDayFrom: 'Dagkoers van {dag}: {zin}', + bookingDayNote: 'Een dagkoers is niet de koers van het moment zelf', + bookingUnknown: 'Niet vastgelegd of deze prijs betaald of opgezocht is', + strength_unknown: 'onbekend', + strength_high: 'hoog', + strength_lower: 'lager', + strength_low: 'laag', + strength_missing: 'onvolledig', + }, + + priceHistory: { + title: 'Prijsgeschiedenis', + confirmDeletePoint: 'Deze koers verwijderen?', + gapsMore: 'En nog {n} gaten; werk eerst deze bij.', + gapLine: '{van} t/m {tot}, {n} dagen', + gapFill: 'Bijwerken', + gapNoSource: 'Voor {asset} is geen bron gekozen die een reeks of een koers per datum levert. Kies er een onder Integraties, Bronkeuze.', + gapConfirmPoint: 'Dit vraagt bij {bron} tot {n} koersen op, één verzoek per dag. Een bron met een maandlimiet kan daar snel doorheen gaan.', + gapConfirmSeries: 'Dit haalt de reeks van {reeks} op (hooguit één keer per dag).', + gapConfirmBoth: 'Dit haalt eerst de reeks van {reeks} op, en vraagt daarna bij {bron} per dag op wat nog ontbreekt: tot {n} verzoeken.', + gapFillAll: 'Alles bijwerken', + gapIgnore: 'Negeren', + gapUnignore: 'Weer meetellen', + gapIgnoreAll: 'Alles negeren', + gapIgnoreAllConfirm: 'Deze {n} gaten worden niet meer getoond en tellen niet meer mee voor het bolletje. Terugzetten kan onder Genegeerd, onderaan dit tabblad.', + gapNone: 'Geen openstaande gaten.', + gapBusy: 'Bezig met bijwerken…', + gapIgnoreConfirm: 'Dit gat wordt niet meer getoond en telt niet meer mee voor het bolletje. Gebruik dit voor dagen die geen enkele bron heeft. Terugzetten kan onder Genegeerd.', + gapCountOne: '{n} gat', + gapCountMany: '{n} gaten', + dayCountOne: '{n} dag', + dayCountMany: '{n} dagen', + suspectOne: '{n} verdachte koers', + suspectMany: '{n} verdachte koersen', + gapCardSummary: '{gaten}, {dagen}', + gapStop: 'Stoppen', + gapStopProgress: 'Stoppen ({i} van {n})', + gapStopped: 'Het bijwerken is gestopt; de rest is niet gevraagd.', + gapLimit: 'De maandlimiet van de bron is bereikt, dus het bijwerken is gestopt. Wat nog ontbreekt kan volgende maand, of via een andere bron voor deze soort.', + gapFilled: '{asset}: de gaten zijn gevuld.', + gapRemaining: '{asset}: nog {n} dagen zonder koers.', + gapWhySeries: '{reeks} levert een reeks die pas op {begin} begint, en er is geen bron actief die een koers per datum ophaalt (zoals GoldAPI). Voor de dagen daarvóór kun je het gat negeren of koersen met de hand invoeren; een bron per datum aanzetten kost een verzoek per dag.', + gapWhySeriesHole: 'Deze dagen vallen binnen de reeks van {reeks}, maar die bron heeft er zelf geen koersen voor: een gat in zijn data. Een bron die een koers per datum ophaalt (zoals GoldAPI) kan ze vullen, voor {n} verzoeken; anders negeren of met de hand invoeren.', + gapWhySeriesEmpty: '{reeks} leverde voor deze dagen niets, en er is geen bron actief die een koers per datum ophaalt (zoals GoldAPI). Negeer het gat of voer koersen met de hand in.', + gapWhyPoint: '{bron} gaf voor deze dagen geen koers: een weekend of feestdag, een datum van vóór zijn historie, of de maandlimiet is op.', + kind_close: 'slotkoers', + kind_manual: 'met de hand', + kind_live: 'vandaag', + kind_spot: 'afgeleid', + kind_carried: 'doorgetrokken', + kind_none: 'geen koers', + kindCount: '{n} {soort}', + kindCount_closeOne: 'slotkoers', + kindCount_closeMany: 'slotkoersen', + kindCount_manualOne: 'met de hand', + kindCount_manualMany: 'met de hand', + kindCount_liveOne: 'vandaag', + kindCount_liveMany: 'vandaag', + kindCount_spotOne: 'afgeleid', + kindCount_spotMany: 'afgeleid', + kindCount_carriedOne: 'doorgetrokken', + kindCount_carriedMany: 'doorgetrokken', + kindCount_noneOne: 'zonder koers', + kindCount_noneMany: 'zonder koers', + cardPeriod: '{dagen}:', + cardNoPrices: 'Nog geen koersen.', + stripDay: '{dag}: {soort}', + stripRange: '{van} t/m {tot}: {soort}', + cardGaps: '{gaten}, {dagen} zonder koers', + tabProvenance: 'Herkomst', + tabGaps: 'Gaten', + tabIgnored: 'Genegeerd', + tabAbsent: 'Geen koers bij de bron', + absentExplain: 'De bron gaf voor deze dagen geen koers, dus de app vraagt ze niet opnieuw op. Verwijder een regel om die dagen bij het volgende bijwerken weer te laten opvragen.', + absentLine: '{bron}: {van} t/m {tot}, {dagen}', + absentLineOne: '{bron}: {dag}', + absentForget: 'Opnieuw laten opvragen', + tabPrices: 'Koersen', + tabCheck: 'Controle', + checkFactor: 'factor {n}', + checkShare: '{n}%', + checkHint: 'Een verkeerde rij verwijder of corrigeer je onder Koersen. Klopt een koers toch, dan kun je de bevinding aanvaarden op Systeem, Gezondheid.', + checkShowKind: 'Toon in Koersen', + checkOpenRow: 'Toon deze koers in Koersen', + checkFiltered: 'Gefilterd op Controle: {soort} ({n}).', + checkFilteredDay: 'Gefilterd op {dag}, uit Controle.', + checkClearFilter: 'Alle koersen', + tipMoment: 'koers van {tijd}', + tipDeviation: '{voor} naast de slotkoers ervoor, {na} naast die erna', + provNoWeak: 'Elke dag in deze periode heeft een slotkoers.', + provRange: '{van} t/m {tot}', + provColPeriod: 'Periode', + provColKind: 'Soort', + provColDays: 'Dagen', + provColNote: 'Toelichting', + provColSource: 'Bron', + provToGaps: 'Naar dit gat onder Gaten', + provSpotNote: 'tot {pct} naast de slotkoers', + provCarriedNote: 'tot {n} dagen oud', + tabSource: 'Bron', + diagCard_noCoverage: 'Geen integratie levert koersen', + diagCard_allInactive: '{namen} staat uit', + diagCard_noChoice: 'Geen bronkeuze', + diagCard_choiceUnusable: 'Bronkeuze werkt niet', + diagCard_limitReached: 'Maandlimiet bereikt: {namen}', + diagCard_choiceInactive: 'Gekozen maar uitgezet: {namen}', + diag_noCoverage: 'Geen enkele integratie levert een reeks of een koers per datum voor {symbool}. Voeg er een toe onder Integraties, of voer koersen met de hand in.', + diag_allInactive: '{namen} levert koersen voor {symbool}, maar staat uit. Zet hem aan onder Integraties.', + diag_noChoice: 'Er is geen bron gekozen voor de koershistorie van {symbool}, dus er komen geen koersen binnen.', + diag_choiceUnusable: 'De gekozen bron ({namen}) staat uit of levert geen koersen voor {symbool}, dus er komen geen koersen binnen.', + diag_limitReached: 'De maandlimiet van {namen} is bereikt. Deze maand komen er geen nieuwe koersen voor {symbool} binnen.', + diag_limitReachedFallback: 'De maandlimiet van {namen} is bereikt. De volgende bron in de keten neemt het over.', + diag_choiceInactive: 'Gekozen maar uitgezet: {namen}. De volgende bron in de keten neemt het over; kies een andere onder Integraties, Bronkeuze, of zet hem aan.', + diagProposal: 'Voorstel: {naam} voor de koershistorie.', + momentTitle: 'Prijs op een specifiek moment', + date: 'Datum', + time: 'Tijd', + useTime: 'Tijd gebruiken', + peildatum: 'Peildatum', + lookup: 'Prijs ophalen', + loading: 'Koers ophalen…', + requested: 'Opgevraagd', + actual: 'Werkelijk prijspunt', + diff: 'verschil', + histTitle: 'Opgeslagen prijsgeschiedenis (database)', + startDate: 'Vanaf datum', + histLookup: 'Tonen', + sync: 'Synchroniseren met mempool', + syncing: 'Synchroniseren…', + synced: 'prijspunten opgeslagen', + storedEmpty: 'Nog geen opgeslagen historie. Klik op "Synchroniseren met mempool" om de koershistorie op te halen en te bewaren.', + points: 'opgeslagen punten', + noPoints: 'Geen opgeslagen prijspunten op of na deze datum.', + colUnit: 'Per', + manualUnknownAsset: 'Dat asset bestaat niet (meer).', + manualBadMoment: 'Vul een geldige datum in.', + manualFutureMoment: 'Een koers kan niet in de toekomst liggen.', + manualBadPrice: 'Een koers moet een getal boven nul zijn.', + colDateTime: 'Datum & tijd', + colDate: 'Datum', + colTime: 'Tijd', + colCombined: 'Integratie · Asset · Prijs', + search: 'Zoeken…', + assetLabel: 'Asset', + compare: 'Vergelijk integraties', + comparing: 'Integraties bevragen…', + cmpDayTitle: 'Dag-resolutie (dagcijfers)', + cmpDayNote: 'Dagcijfers voor de gekozen datum, onderling vergelijkbaar.', + cmpTsTitle: 'Tijdstip-resolutie (spot nu)', + cmpTsNote: 'Live spot per integratie (huidig moment): niet te vergelijken met dagcijfers.', + cmpNone: 'Geen integratie leverde een prijs voor deze asset.', + colDiff: 'Verschil', + colResolution: 'Resolutie', + colIntegration: 'Integratie', + colAsset: 'Asset', + resDay: 'dag', + resTimestamp: 'tijdstip', + tableTitle: 'Alle opgeslagen prijspunten', + colUpdated: 'Bijgewerkt', + allAssets: 'Alle assets', + refresh: 'Verversen', + delete: 'Verwijderen', + noAsset: 'Kies een asset.', + noDate: 'Vul een datum in.', + lookupNone: 'Geen prijspunt gevonden en ophalen bij de bron leverde niets op.', + dayCovers: 'Dagcijfer: geldt voor de hele dag (geen exact tijdstip).', + peilTitle: 'Peildatum-prijzen', + peilBody: 'Haal de prijs op voor de geconfigureerde peildatum', + peilFetch: 'Peildatumprijzen ophalen (alle assets)', + peilNoneForAsset: 'geen prijs gevonden', + resTitle: 'Over de resolutie', + resBody: 'Deze pagina gebruikt de mempool-API. Het endpoint historical-price geeft het dichtstbijzijnde opgeslagen prijspunt terug, niet exact het opgevraagde tijdstip. Voor oudere data kan dat uren tot een dag verschillen; de werkelijke tijd van het teruggegeven punt staat daarom bij het resultaat. Voor exacte prijs op de minuut is een beurs-OHLC-API nodig (spot_api-fase).', + }, + + tracking: { + title: 'Tracking', + zapFrom: 'Zap van {who}', + zapTo: 'Zap aan {who}', + zapIn: 'Zap ontvangen', + zapOut: 'Zap verstuurd', + noAccountsWithSources: 'Nog geen accounts met een tracking-bron.', + manageAccounts: 'Accounts beheren', + selectAccount: 'Account', + accountTotal: 'Totaal saldo', + sourceFallback: 'Tracking-bron', + badgeNwc: 'Wallet-pubkey', + badgeNwcHint: 'Een NWC-wallet, herkend aan zijn wallet-pubkey (Nostr Wallet Connect)', + badgeLnd: 'Lightning-node', + badgeLndHint: 'Een LND-node, gekoppeld op het tabblad Verbindingen', + walletOne: 'wallet', + walletMany: 'wallets', + statWallets: 'Wallets', + statTxs: 'Bewegingen', + statLinked: 'Gekoppeld', + selectedSum: 'Gekozen:', + selectedOf: '{n} van {m} accounts', + scanLast: 'Laatst bijgewerkt op', + scanProgress: 'Wallet {i} van {n}', + scanUpdated: 'bijgewerkt', + scanFailedN: 'mislukt', + proposalOne: 'voorstel', + proposalMany: 'voorstellen', + scanNever: 'Nog niet bijgewerkt', + scanAll: 'Bijwerken', + scanning: 'Scannen…', + scanningTx: 'Transacties ophalen', + scanError: 'Bijwerken mislukt', + scanNotOnchain: 'Niet via de keten te scannen', + lightningMovements: 'lightning-bewegingen bijgewerkt', + addressesScanned: 'adressen gescand', + addressesFailed: 'mislukt', + dirIn: 'Ontvangen', + dirOut: 'Verzonden', + fee: 'fee', + unconfirmed: 'Onbevestigd', + addLabel: 'Label toevoegen', + txCount: 'bewegingen', + copyAddr: 'Adres kopiëren', + collapseToggle: 'In-/uitklappen', + noTxs: 'Nog geen bewegingen voor deze bron.', + matched: 'Ook geregistreerd als gebeurtenis', + }, + + link: { + exact: 'Automatische match (adres)', + soft: 'Voorgestelde match (bevestigd)', + manual: 'Handmatig gekoppeld', + }, + + match: { + add: 'Koppel aan administratie', + linked: 'Gekoppeld', + toEvent: 'Koppel aan gebeurtenis', + toTx: 'Koppel aan on-chain beweging', + unlink: 'Ontkoppelen', + filter: 'Filter kandidaten…', + noCandidates: 'Geen kandidaten gevonden', + unconfirmed: 'onbevestigd', + }, + + convert: { + title: 'Omrekenen', + main: 'Hoofdeenheid', + sub: 'Subeenheid', + apply: 'Invoeren', + pieces: 'Per stuk', + product: 'Product', + count: 'Aantal', + }, + + portfolios: { + maxFree: 'De gratis versie houdt het op {max} portefeuille. Met een licentie vervalt die grens; ' + + 'er wordt intussen niets weggehaald.', + title: 'Portefeuilles', + switchFailed: 'Wisselen is niet gelukt.', + nameRequired: 'Een portefeuille moet een naam hebben.', + nameTooLong: 'Een portefeuillenaam is hoogstens {max} tekens lang.', + nameIsDirRef: '"{naam}" is geen naam maar een verwijzing naar een map.', + nameForbiddenChars: 'Een portefeuillenaam mag geen van deze tekens bevatten: < > : " / \\ | ? *', + nameTrailing: 'Een portefeuillenaam mag niet op een spatie of een punt eindigen.', + nameLeadingSpace: 'Een portefeuillenaam mag niet met een spatie beginnen.', + nameReserved: '"{naam}" is een gereserveerde naam in Windows en kan geen map worden.', + nameTaken: '"{naam}" is een naam die de app zelf gebruikt; kies een andere.', + activeCannotRename: '"{naam}" is de portefeuille die nu open is en kan niet hernoemd worden. Wissel eerst naar een andere portefeuille; de app start daarbij opnieuw.', + activeCannotArchive: '"{naam}" is de portefeuille die nu open is en kan niet weggegooid worden. Wissel eerst naar een andere portefeuille; de app start daarbij opnieuw.', + unknownPortfolio: 'Er is geen portefeuille "{naam}".', + nameInUse: 'Er is al een portefeuille "{naam}"; namen die alleen in hoofdletters of in schrijfwijze verschillen tellen als dezelfde.', + archiveFull: 'Het archief zit vol met mappen die "{naam}" heten; ruim er eerst een paar op.', + notInArchive: 'Er staat geen "{naam}" in het archief.', + passwordRequired: 'Vul een wachtwoord in.', + intro: 'Elke portefeuille is een eigen administratie: een eigen database én eigen geheimen, in een eigen map. Er is er altijd precies één open.', + openNow: 'Nu open', + activate: 'Openen…', + activateConfirm: 'Overschakelen naar "{naam}"? De app start daarvoor opnieuw op.', + activateWaiting: 'De app start opnieuw…', + activateNoSupervisor: 'De keuze is vastgelegd, maar deze app is gestart zonder de supervisor (npm run start:server). Sluit de app en start hem opnieuw om in "{naam}" te werken.', + create: 'Nieuwe portefeuille…', + createName: 'Naam', + createNameHint: 'De naam wordt de mapnaam op schijf.', + createCurrency: 'Rekenvaluta', + createDone: 'Portefeuille "{naam}" aangemaakt.', + copy: 'Kopiëren…', + copyTitle: 'Kopie van "{naam}"', + copyName: 'Naam van de kopie', + copyConfirm: 'Kopiëren', + copyDone: 'Portefeuille "{naam}" aangemaakt als kopie van "{bron}".', + createHint: 'Nieuwe portefeuille geeft een verse administratie zonder verbindingen. Kopiëren doe je per portefeuille in de lijst; een kopie neemt de database én de geheimen samen mee, want een geheim hoort bij een rij en klopt alleen bij die database.', + createCurrencyHint: 'De rekenvaluta is de munt waarin de portefeuille rekent en alle bedragen toont. Je kiest hem bij een nieuwe portefeuille en daarna ligt hij vast; een kopie houdt de munt van het origineel.', + rename: 'Hernoemen…', + renameTo: 'Nieuwe naam', + renameDone: 'Hernoemd naar "{naam}".', + renameActive: 'De portefeuille die nu open is, kan niet hernoemd worden: zijn database staat open. Schakel eerst over naar een andere.', + remove: 'Verwijderen…', + removeConfirm: 'Portefeuille "{naam}" verwijderen? De map verhuist naar het archief; er wordt niets gewist. Typ ter bevestiging de naam over.', + removeTypeLabel: 'Typ de naam', + removeMismatch: 'Dat is niet de naam; er is niets gewijzigd.', + removeDone: '"{naam}" staat nu in het archief.', + empty: 'Er is nog geen andere portefeuille.', + noDatabase: 'nog geen database', + lastUsed: 'laatst gebruikt', + never: 'nog niet gebruikt', + archiveTitle: 'Archief', + archiveIntro: 'Verwijderde portefeuilles staan hier. Ze tellen nergens meer mee, maar de bestanden zijn er nog.', + archiveEmpty: 'Het archief is leeg.', + purge: 'Definitief verwijderen…', + purgeConfirm: 'De map van "{naam}" en alles erin definitief verwijderen? Database, geheimen en historie zijn daarna weg. Dit kan niet ongedaan gemaakt worden. Typ ter bevestiging de naam over.', + purgeDone: '"{naam}" is definitief verwijderd.', + settingsTitle: 'Instellingen van deze portefeuille', + hintTitle: 'Wat zie je hier', + authTitle: 'Toegang tot deze portefeuille', + authNone: 'Geen wachtwoord ingesteld. De app is daardoor alleen vanaf deze machine bereikbaar.', + authSet: 'Er is een wachtwoord ingesteld.', + authEnv: 'Ingesteld via de omgevingsvariabele ASSETAPP_PASSWORD. Zolang die er staat, wint dat van wat je hier invult.', + authChange: 'Instellen of wijzigen…', + authClear: 'Leegmaken…', + authPassword: 'Wachtwoord', + authClearConfirm: 'Het wachtwoord van deze portefeuille weghalen? Daarna is de app alleen nog vanaf deze machine bereikbaar.', + authDone: 'Opgeslagen. Het geldt vanaf de volgende keer dat de app start.', + authClearedDone: 'Weggehaald. Het geldt vanaf de volgende keer dat de app start.', + authLogout: 'Uitloggen', + authLogoutConfirm: 'Uitloggen? Je moet het wachtwoord opnieuw invoeren om verder te kunnen.', + authHint: 'Van een ander apparaat erbij kunnen vergt een certificaat én inloggegevens. Het certificaat hoort bij deze installatie; de inloggegevens bij deze portefeuille.', + startYearTitle: 'Startjaar van deze administratie', + startYearLabel: 'Begin overzichten bij', + startYearAuto: 'Automatisch (vanaf de eerste boeking)', + startYearAutoState: 'Automatisch: overzichten beginnen bij de eerste peildatum na de eerste boeking, {jaar}.', + startYearState: 'Overzichten beginnen bij peildatum 1 januari {jaar}. De eerste boeking staat in {eerste}.', + startYearEmpty: 'Er is nog niets geboekt, dus er valt nog niets te begrenzen. Dit wordt pas zichtbaar zodra er een boeking of een balans staat.', + startYearHint: 'Alleen nodig als je administratie ver terug gaat: bij een eerste boeking in 1984 maakt de belastingpagina 42 peildatum-kolommen en vraagt hij de koersbron om evenzoveel historische punten. Er verdwijnt niets: de standen tellen alles vóór het startjaar gewoon mee, er worden alleen minder kolommen getoond.', + mwrTitle: 'Rendementsmethode', + mwrLabel: 'Bereken het geldgewogen rendement met', + mwr_dietz: 'Modified Dietz (standaard)', + mwr_irr: 'Interne rentevoet (IRR)', + mwrState_dietz: 'Het rendement wordt gedeeld door het gemiddeld werkzame vermogen: elke storting telt mee naar rato van hoe lang hij er stond.', + mwrState_irr: 'Het rendement is de rente op jaarbasis die jouw stortingen en opnames precies verklaart. Waar die niet bestaat, valt het getal terug op Modified Dietz.', + mwrHint: 'Dit verandert de percentages op de belastingpagina. Modified Dietz geeft altijd een getal; IRR is zuiverder maar heeft niet altijd een oplossing, bijvoorbeeld bij een positie die tussendoor op nul komt. De gekozen methode staat altijd bij het getal.', + }, + + dbfile: { + title: 'De hele administratie', + passTooShort: 'Kies een wachtwoord van minstens 8 tekens voor de back-up.', + notABackup: 'Dit bestand is geen AssetApp-back-up (te kort).', + notABackupUseRestore: 'Dit bestand is geen AssetApp-back-up. Een losse .sqlite zet je terug met "Terugzetten".', + unknownBackupVersion: 'Onbekende back-up-versie ({versie}); deze app kent versie {kent}.', + decryptFailed: 'Ontsleutelen mislukt: verkeerd wachtwoord, of het bestand is beschadigd.', + noManifest: 'Back-up beschadigd: geen manifest gevonden.', + manifestUnreadable: 'Back-up beschadigd: het manifest is onleesbaar.', + manifestLengths: 'Back-up beschadigd: de lengtes in het manifest kloppen niet met de inhoud.', + secretsUnreadable: 'Back-up beschadigd: het geheimenbestand is geen leesbare JSON.', + notReadableSqlite: 'Dit is geen leesbaar SQLite-bestand ({reden}).', + noSchemaTable: 'Dit bestand heeft geen Schema-tabel. Een database van vóór de baseline is niet ' + + 'meer om te zetten: het conversiescript staat alleen nog in de git-historie ' + + '(git log --all -- scripts/baseline-convert.mjs).', + noReadableVersion: 'De Schema-tabel bevat geen leesbare versie.', + versionBeforeBaseline: 'Deze database is versie {versie}, van vóór de nieuwe opzet (versie ' + + '{baseline}). Omzetten kan niet meer; het conversiescript staat alleen nog in de git-historie ' + + '(git log --all -- scripts/baseline-convert.mjs).', + versionNewerThanApp: 'Deze database is versie {versie} en dus nieuwer dan de app ({app}). Werk de app bij voordat je hem terugzet.', + emptyFile: 'Leeg bestand ontvangen.', + notSqlite: 'Dit is geen SQLite-bestand (de bestandskop klopt niet).', + backup: 'Back-up maken…', + backupPass: 'Wachtwoord voor de back-up', + backupPassAgain: 'Wachtwoord herhalen', + backupPassMismatch: 'De twee wachtwoorden zijn niet gelijk.', + backupDone: 'Back-up gemaakt. Bewaar het wachtwoord goed: zonder wachtwoord is de back-up niet terug te zetten.', + export: 'Database exporteren', + import: 'Terugzetten…', + confirmRestoreBackup: 'Deze back-up terugzetten? Dat vervangt de database ÉN de geheimen (secrets.json in de map van deze portefeuille). Van allebei blijft de vorige versie als .previous staan.', + restoredSecrets: 'Geheimen meeteruggezet:', + confirmImport: 'De huidige database vervangen door dit bestand? De vorige blijft als .previous naast het databasebestand staan.', + importDone: 'Database teruggezet', + importOrphans: 'Let op, verweesde verwijzingen gevonden:', + hint: 'De back-up bevat de database én de geheimen, versleuteld. De export is alleen de database: genoeg om te lezen, niet genoeg om alles terug te zetten.', + fresh: 'Leeg beginnen…', + confirmFresh: 'Met een lege administratie beginnen? Er komt een verse database in gebruik; de huidige blijft als .previous naast het databasebestand staan. Maak eerst een back-up als je hier ooit nog bij wilt.', + freshWord: 'leeg', + freshTypeLabel: 'Typ ter bevestiging het woord', + freshWordMismatch: 'Dat is niet het gevraagde woord; er is niets gewijzigd.', + freshDone: 'Lege administratie in gebruik', + }, + + import: { + title: 'Importeren', + fileLabel: 'CSV-bestand', + read: 'Inlezen', + template: 'Leeg sjabloon', + formatHint: 'Standaardformaat = de Bitvavo-export (tab- of komma-gescheiden). Eigen bestanden met dezelfde koppen werken ook; optionele kolom "Account".', + noFile: 'Kies eerst een bestand.', + readError: 'Bestand lezen mislukt.', + origin: 'Herkomst', + originPh: 'bijv. Bitvavo', + summary: 'Samenvatting', + nEvents: 'als gebeurtenis', + nSkipped: 'overgeslagen', + account: 'Account (exchange) voor deze import', + chooseAccount: '(kies account)', + newAccount: '+ nieuw exchange-account…', + newAccountPh: 'Naam nieuw account', + assetsTitle: 'Assets', + assetOk: 'gekoppeld', + assetMissing: 'geen asset: maak aan onder Systeem → Assets (rijen worden overgeslagen)', + addresses: 'Bestemming van adressen (withdrawals)', + destAccount: '(doel-account)', + noSource: '(geen specifieke tracking-bron)', + alreadyLinked: 'reeds gekoppeld', + process: 'Verwerken naar gebeurtenissen', + needAccount: 'Kies of maak eerst het exchange-account.', + needDest: 'Kies een doel-account voor elk adres.', + done: 'Import verwerkt', + processFailed: 'Verwerken mislukt', + resCreated: 'aangemaakt', + resUpdated: 'overschreven', + resKept: 'behouden', + resUnchanged: 'ongewijzigd', + resSkipped: 'overgeslagen', + rows: 'rijen', + conflictsTitle: 'Bestaande records', + conflictsIntro: 'Deze records bestaan al. Standaard worden ze overschreven; zet er een op "Behouden" om de bestaande versie te laten staan.', + colField: 'Veld', + colExisting: 'Bestaand', + colImported: 'Geïmporteerd', + keep: 'Behouden', + overwrite: 'Overschrijven', + allKeep: 'Alles behouden', + allOverwrite: 'Alles overschrijven', + continue: 'Doorgaan', + fType: 'Type', + fDate: 'Datum', + fAmount: 'Bedrag', + fPrice: 'Koers', + fFee: 'Fee ({munt})', + fFeeUnit: 'Fee', + notBookedTitle: 'Deze regels worden niet geboekt', + feeNotBooked: 'Regel {regel}: kosten bij rente, of kosten in het asset zelf bij een aankoop, tellen ' + + 'nergens mee. Boek deze regel met de hand.', + fFrom: 'Van', + fTo: 'Naar', + fAddress: 'Adres', + resDeleted: 'verwijderd', + multiTitle: 'Meerdere overeenkomsten', + multiIntro: 'Eén importregel komt overeen met meerdere bestaande records. De importregel wordt aangemaakt; kies per bestaand record of je het laat staan (Negeren) of verwijdert.', + importRow: 'Importregel', + ignore: 'Negeren', + delete: 'Verwijderen', + linkedNoDelete: 'gekoppeld, niet verwijderbaar', + identical: 'Identiek', + }, + + health: { + summary: '{n} bevindingen.', + start: 'Controle uitvoeren', + again: 'Opnieuw controleren', + lastRun: 'Laatste controle: {wanneer}.', + neverRun: 'Deze controle is nog nooit uitgevoerd.', + checking: 'Controle {i} van {n}: {naam}…', + checkFailed: 'Deze controle kon niet worden uitgevoerd:', + slowest: 'De traagste controle was "{naam}" ({s} seconden).', + more: 'En nog {n} meer. De volledige lijst staat in de tabel zelf.', + accept: 'Dit klopt zo', + acceptConfirm: 'Deze bevinding verdwijnt uit het rapport zolang de situatie hetzelfde blijft. ' + + 'Verandert er iets (er komt een geval bij, of een aantal wijzigt), dan staat hij er weer.', + accepted: 'Aanvaard. Deze bevinding staat nu bij de aanvaarde bevindingen onderaan.', + severityWarn: 'Waarschijnlijk niet bedoeld', + severityInfo: 'Ter kennisgeving', + acceptedTitle: 'Aanvaarde bevindingen', + acceptedNone: 'Nog geen bevindingen aanvaard.', + unaccept: 'Toch weer tonen', + acceptUnknownKind: 'Die soort bevinding bestaat niet.', + acceptNeedsSummary: 'Een aanvaarde bevinding heeft een leesbare samenvatting nodig.', + allClear: 'Geen bevindingen.', + allClearWhy: 'Dit rapport kijkt naar ' + + 'dubbele boekingen, balansen die niet verzoend zijn, gaten en achterstand in de koershistorie, ' + + 'verdachte koersen, verplaatsingen zonder tegenkant en een paar technische verwijzingen.', + kind: { + duplicate_events: 'Dubbele boekingen', + assets_without_balance: 'Assets zonder balans', + unreconciled_valuations: 'Balansen die niet verzoend zijn', + stale_prices: 'Achterlopende koersen', + late_price_history: 'Koershistorie begint te laat', + price_gaps: 'Gaten in de koershistorie', + price_stopped: 'Bezit zonder actuele koers', + transfer_without_counterpart: 'Verplaatsing zonder tegenkant', + unlinked_transactions: 'Ongekoppelde bewegingen', + coverage_without_asset: 'Dekking zonder asset', + price_unit_swap: 'Koers in de verkeerde eenheid', + price_outlier: 'Uitschieter in de koershistorie', + price_sources_disagree: 'Bronnen zijn het oneens', + price_not_positive: 'Koers van nul of lager', + price_in_future: 'Koers in de toekomst', + fee_without_effect: 'Kosten die nergens tellen', + booking_price_deviates: 'Boekingsprijs ver van de dagkoers', + price_origin_derivable: 'Herkomst van de prijs af te leiden', + }, + why: { + duplicate_events: 'Dezelfde boeking staat er twee keer: zelfde asset, soort, moment en ' + + 'hoeveelheid. Bijna altijd één aankoop die dubbel is ingevoerd. Bij een productboeking telt ' + + 'ook het product mee, want verschillende munten van gelijk gewicht zijn niet hetzelfde.', + assets_without_balance: 'Je hebt dit asset in bezit, maar er staat geen enkele balans tegenover. ' + + 'Dat mag: een administratie die op het grootboek draait is compleet zonder balansen. Het ' + + 'betekent alleen dat er niets is om dat grootboek tegen af te zetten.', + unreconciled_valuations: 'Een balans wijkt af van wat de boekingen zeggen, en dat verschil is nog ' + + 'niet geboekt of aanvaard. Op Af te handelen kun je dat afhandelen.', + stale_prices: 'De laatste koers van dit asset is oud. De actuele waarde valt dan terug op dat ' + + 'punt, hoe oud het ook is, en dat staat nergens bij.', + late_price_history: 'Je bezit dit asset langer dan de koershistorie teruggaat. Alles vóór het ' + + 'eerste koerspunt is dus niet te waarderen; een koerspunt met de hand toevoegen kan op ' + + 'Prijsgeschiedenis.', + price_gaps: 'Tussen twee koerspunten zit een gat, geteld in dagen zonder koers vanaf je eerste ' + + 'boeking, net als op Prijsgeschiedenis. Het dagraster trekt de vorige koers door tot de ' + + 'ouderdomsgrens en geeft daarna niets meer.', + price_stopped: 'Je hebt dit asset nu in bezit, maar de laatste koers is ouder dan de app hem ' + + 'doortrekt. Op een peildatum na die koers is het dan niet te waarderen. Zoek een bron, of ' + + 'voeg een koers met de hand toe op Prijsgeschiedenis.', + transfer_without_counterpart: 'Een verplaatsing noemt maar één account. Dat kan kloppen (naar ' + + 'buiten of van buiten), maar tussen je eigen accounts hoort er een tegenkant te zijn.', + unlinked_transactions: 'Een beweging op de keten is aan geen enkele boeking gekoppeld. Dan telt ' + + 'hij wel in je saldo en niet in je grootboek.', + coverage_without_asset: 'Een bron zegt een symbool te leveren dat hier geen asset is. Niet fout, ' + + 'maar wel goed om te weten voordat je je erop verlaat.', + price_unit_swap: 'Deze koers ligt een factor 31 van de koers ervoor, en dat is het aantal gram in ' + + 'een troy ounce. Bijna altijd een koers per ounce die als koers per gram is opgeslagen, of ' + + 'andersom. Corrigeer of verwijder hem op Prijsgeschiedenis, onder Koersen.', + price_outlier: 'Deze koers ligt een factor drie of meer boven of onder allebei zijn buren. Dat doet ' + + 'geen markt in een dag; een nul te veel of te weinig wel.', + price_sources_disagree: 'Twee bronnen geven voor dezelfde dag een koers die ver uit elkaar ligt. Dat ' + + 'is geen fout van een van beide, maar een keuze: de app gebruikt de laatst geschreven rij.', + price_not_positive: 'Een koers van nul of lager is geen koers. De app telt deze rij niet mee, dus ' + + 'hij staat er wel maar doet niets.', + price_in_future: 'Deze koers heeft een moment dat nog niet geweest is. Dat kan alleen een tikfout ' + + 'in de datum zijn.', + fee_without_effect: 'Deze boeking draagt kosten op een plek die geen berekening leest: kosten in ' + + 'geld bij rente, of kosten in het asset zelf bij een aankoop of rente. Ze staan erop maar doen ' + + 'niets.', + booking_price_deviates: 'De prijs van deze aan- of verkoop ligt meer dan 20% van de koers van die ' + + 'dag, of een factor 31 (gram tegen troy ounce). Een spread van een paar procent is normaal; dit ' + + 'is meestal een tikfout, een verkeerde datum of de verkeerde eenheid.', + price_origin_derivable: 'Van deze boekingen is niet vastgelegd waar de prijs vandaan komt, maar hij ' + + 'is precies de dagkoers: die vulde het formulier zelf in. Met de knop krijgen ze de herkomst ' + + 'Dagkoers. Wat afwijkt blijft onbekend, want betaald of koers op het moment is dan niet te zien.', + }, + fillOrigins: 'Herkomst invullen', + fillOriginsConfirm: 'Deze boekingen krijgen de herkomst Dagkoers. De wijziging komt in het ' + + 'wijzigingslog.', + fillOriginsDone: 'Bij {n} boekingen is de herkomst ingevuld.', + }, + + data: { + title: 'Data', + table: 'Tabel', + rows: 'rijen', + empty: 'Geen rijen.', + delete: 'Verwijderen', + confirmDelete: 'Deze rij definitief verwijderen?', + clear: 'Tabel leegmaken', + confirmClear: 'Alle records uit deze tabel definitief verwijderen? Dit kan niet ongedaan worden gemaakt.', + }, + + signals: { + title: 'Bitcoin Signalen & Grafieken', + noHistory: 'Geen koershistorie in opslag.', + noAssetForHistory: 'Geen {symbool}-asset gevonden voor koershistorie.', + live: 'LIVE DATA', + price: 'Prijs', + tabPrice: 'Bitcoin prijs', + tabPl: 'Power Law', + tabWma: 'Weekgemiddelden', + tabMayer: 'Mayer Multiple', + sigPlLabel: 'Power Law', + sigWmaLabel: '200-weeks MA', + sigMayerLabel: 'Mayer Multiple', + priceTitle: 'Bitcoin prijs', + priceSub: 'Historische koers', + plTitle: 'Power Law Corridor', + plSub: 'Live koers vs. langetermijntrend · log-log', + wmaTitle: '50- en 200-weeks gemiddelde', + wmaSub: '200-weeks: historische bodemzone · 50-weeks: scheidslijn tussen de regimes', + mayerTitle: 'Mayer Multiple', + mayerSub: 'Koers ÷ 200-daags gemiddelde · over-/onderwaardering', + log: 'Logaritmisch', + lin: 'Lineair', + zoomRecent: 'Zoom recent', + resetZoom: 'Reset zoom', + fullscreen: 'Volledig scherm', + loading: 'Laden…', + error: 'Kan data niet laden', + mayerInfo1Title: 'Mayer < 1,0:', + mayerInfo1: 'koers ligt onder het 200-daags gemiddelde. Historisch een sterke, maar zeldzame zone (lokale bodems).', + mayerInfo2Title: 'Mayer 1,0 tot 2,4:', + mayerInfo2: 'normale zone. Koers boven het 200-daags gemiddelde, maar nog niet uitgerekt.', + mayerInfo3Title: 'Mayer ≥ 2,4:', + mayerInfo3: 'historisch oververhit (de eigen drempel van Mayer). Vaak in de buurt van cyclustoppen.', + hintTitle: 'Wat zie je hier', + hintLightsTitle: 'De drie signalen', + hintLights: 'De lampjes vatten de drie grafieken samen en blijven staan terwijl je erdoorheen loopt. Klik er een aan om naar die grafiek te gaan. Groen, geel en rood zijn hier een oordeel, en de enige plek op deze pagina waar kleur dat betekent.', + hintColorsTitle: 'De kleuren in de grafieken', + hintColors: 'Elke lijn is een tint van de accentkleur: vol is de koers, een tint lichter is de signaallijn ernaast, en de lichtste is de referentie of de band erachter. Dus geen kleurcode, maar een rangorde.', + hintPl: 'De Power Law is een rechte lijn in log-log: de koers groeit als een macht van de tijd sinds het genesisblok. De lijn is de fit, niet een voorspelling.', + hintPlBands: 'De banden zijn een vaste factor op die fit, door de app gezet op wat historisch ongeveer klopt. Ze zijn een tekening en nooit de data.', + hintPlProjection: 'De lijnen lopen één jaar door voorbij de laatste koers. Bewust kort: verder vooruit tekenen suggereert een voorspellende waarde die deze fit niet heeft.', + hintWma200: 'Het 200-weeks gemiddelde is de trage bodemzone van de cyclus.', + hintWma50: 'Het 50-weeks gemiddelde is de scheidslijn tussen de twee regimes: steun in een stijgende markt, en hem verliezen valt historisch samen met het begin van de daling.', + leg: { + btc: 'Bitcoin', fit: 'Power Law fit', lower: 'Onderband', upper: 'Bovenband', + wma: '200-weeks MA', wma50: '50-weeks MA', mayer: 'Mayer Multiple', + }, + sig: { + plStrong: 'STERKE KOOPZONE', plBuy: 'KOOPZONE', plAbove: 'BOVEN TREND', plFar: 'VER BOVEN TREND', + wmaBuy: 'KOOPZONE', wmaAbove: 'BOVEN 200WMA × 1,2', wmaFar: 'VER BOVEN 200WMA', + mayerLow: 'KOOPZONE', mayerMid: 'NEUTRAAL', mayerHigh: 'OVERVERHIT', + }, + }, + + stub: { + title: 'Nog niet gebouwd', + body: 'Deze pagina komt in een latere fase. Het skelet en de database werken al.', + }, + + enums: { + asset_kind: { crypto: 'Crypto', metal: 'Metalen', fiat: 'Valuta' }, + tax_category: { bank: 'Banktegoed', overig: 'Overige bezitting' }, + tax_scope: { all: 'Telt mee', category: 'Alleen apart vermeld', none: 'Telt niet mee' }, + wealth_scope: { managed: 'Vermogen van derden', none: 'Telt niet mee' }, + metal_form: { coin: 'Munt', bar: 'Baar', other: 'Overig' }, + displayUnit: { unit: 'Hoofdeenheid', subunit: 'Onder-eenheid' }, + location_kind: { exchange: 'Exchange', wallet: 'Wallet', lightning: 'Lightning', card: 'Card', bank: 'Bank', vault: 'Kluis' }, + account_kind: { exchange: 'Exchange', wallet: 'Wallet', lightning: 'Lightning', card: 'Card', bank: 'Bank', vault: 'Kluis' }, + event_source: { manual: 'Handmatig', import: 'Import', reconcile: 'Reconcile', lightning: 'Lightning' }, + event_type: { buy: 'Aankoop', sell: 'Verkoop', transfer: 'Verplaatsing', interest: 'Rente', reservation: 'Reservering' }, + event_price_origin: { paid: 'Betaald', market_day: 'Dagkoers', market_moment: 'Koers op moment', none: 'Gift' }, + valuation_source: { onchain: 'On-chain', manual: 'Handmatig', statement: 'Afschrift' }, + interest_cadence: { term_end: 'Einde termijn', annual: 'Jaarlijks', quarterly: 'Per kwartaal', monthly: 'Maandelijks', weekly: 'Wekelijks' }, + interest_rate_kind: { fixed: 'Berekend', variable: 'Handmatig of import' }, + reconcile_status: { pending: 'Open', accepted_trade: 'Aankoop/Verkoop', accepted_transfer: 'Verplaatsing', accepted_interest: 'Rente', ignored: 'Genegeerd', auto_linked: 'Auto-gekoppeld' }, + }, +}; diff --git a/client/img/bull.svg b/client/img/bull.svg new file mode 100644 index 0000000..3448c63 --- /dev/null +++ b/client/img/bull.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/img/favicon.svg b/client/img/favicon.svg new file mode 100644 index 0000000..077940a --- /dev/null +++ b/client/img/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/client/img/hawk.svg b/client/img/hawk.svg new file mode 100644 index 0000000..9372953 --- /dev/null +++ b/client/img/hawk.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..0a185f5 --- /dev/null +++ b/client/index.html @@ -0,0 +1,68 @@ + + + + + + + Atreides Assets + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+ +
+
+ + + + diff --git a/client/integrations/failure-log.js b/client/integrations/failure-log.js new file mode 100644 index 0000000..a10e9f9 --- /dev/null +++ b/client/integrations/failure-log.js @@ -0,0 +1,72 @@ +// Generated, do not edit. sha256:4da5cbe36b669344f62663079e6a6414b09f2f6d56a746c2f3daa2989f1e621a +import { t } from '../i18n/index.js'; +import { listIntegrationCalls, lastIntegrationFailures } from '../data/integration-log.js'; +import { listProviders } from '../data/providers.js'; +import { esc } from '../ui/escape.js'; +import * as dialog from '../ui/dialog.js'; +import { selectLabel } from '../ui/select-label.js'; +import { localDateTimeNl } from '../../shared/time.js'; +import { CONFIG } from '../../shared/config.js'; + +const DAYS = CONFIG.INTEGRATION_LOG.WINDOW_DAYS; + +const sourceOf = row => (row.target === 'service' ? 'service' : row.providerKey); + +const reasonOf = row => row.detail || row.errorKey || t(`integrationLog.outcome_${row.outcome}`); + +export async function lastFailureHtml(source) { + const row = (await lastIntegrationFailures()).find(r => sourceOf(r) === source); + if (!row) return ''; + const status = row.httpStatus ? ` (${row.httpStatus})` : ''; + return `
+ ${esc(t('integrationLog.lastFailure'))} + ${esc(localDateTimeNl(row.occurredAt))} · ${esc(reasonOf(row))}${esc(status)} + +
`; +} + +export function wireFailureLog(host) { + host.querySelectorAll('[data-ilog-open]').forEach(b => { + b.onclick = (e) => { e.stopPropagation(); openFailureLog(b.dataset.ilogOpen); }; + }); +} + +function tableHtml(rows, names) { + if (!rows.length) return `
${esc(t('integrationLog.noFailures', { n: DAYS }))}
`; + const head = ['colTime', 'colSource', 'colOperation', 'colSubject', 'colOutcome', 'colStatus', 'colKey', 'colDetail', 'colRequestId'] + .map(k => `${esc(t(`integrationLog.${k}`))}`).join(''); + const body = rows.map(r => ` + ${esc(localDateTimeNl(r.occurredAt))} + ${esc(names.get(sourceOf(r)) || sourceOf(r))} + ${esc(r.operation)} + ${esc(r.subject ?? '')} + ${esc(t(`integrationLog.outcome_${r.outcome}`))} + ${esc(r.httpStatus ?? '')} + ${esc(r.errorKey ?? '')} + ${esc(r.detail ?? '')} + ${esc(r.requestId ?? '')} + `).join(''); + return `
${head}${body}
`; +} + +export async function openFailureLog(source = '') { + const [rows, providers] = await Promise.all([listIntegrationCalls({ days: DAYS }), listProviders()]); + const names = new Map([['service', t('integrationLog.service')], ...providers.map(p => [p.key, p.name])]); + const sources = [...new Set([...rows.map(sourceOf), ...(source ? [source] : [])])] + .sort((a, b) => (a === 'service' ? -1 : b === 'service' ? 1 : String(names.get(a) || a).localeCompare(String(names.get(b) || b)))); + const options = [``, + ...sources.map(s => ``)].join(''); + dialog.detail({ + title: t('integrationLog.title'), size: 'xl', + asideHtml: selectLabel(``), + html: '
', + }); + const overlays = document.querySelectorAll('.dialog-overlay'); + const d = overlays[overlays.length - 1]; + if (!d) return; + const bodyEl = d.querySelector('[data-ilog-body]'); + const filter = d.querySelector('[data-ilog-filter]'); + const paint = () => { bodyEl.innerHTML = tableHtml(rows.filter(r => !filter.value || sourceOf(r) === filter.value), names); }; + filter.onchange = paint; + paint(); +} diff --git a/client/license.js b/client/license.js new file mode 100644 index 0000000..d7b3226 --- /dev/null +++ b/client/license.js @@ -0,0 +1,50 @@ +// Generated, do not edit. sha256:e2056779c9285b8b0c7aa5d702fc44b067694e358ed2b791b9b58447551ece12 +import { licenseStatus } from './data/license-status.js'; +import { getPref, setPref } from './data/preferences.js'; +import { t } from './i18n/index.js'; + +let snapshot = null; + +export async function loadLicense() { + snapshot = await licenseStatus(); + return snapshot; +} + +export function licenseSnapshot() { return snapshot; } + +export function locked(feature) { + const f = snapshot?.features; + return f ? f[feature] === false : false; +} + +export function limitFor(kind) { + const m = snapshot?.max; + return m && kind in m ? m[kind] : null; +} + +export function counterText(kind, current) { + const max = limitFor(kind); + return max === null ? null : t('license.counter', { n: current, max }); +} + +const PREF_SCOPE = 'license'; +const PREF_SEEN = 'seen'; + +export function licenseFingerprint(s) { + if (!s) return ''; + const i = s.installed; + return [s.level, s.why, s.buyer ?? '', s.issued ?? '', + i ? `${i.majorFrom}-${i.majorTo}` : ''].join('|'); +} + +export async function markLicenseSeen() { + await setPref(PREF_SCOPE, PREF_SEEN, licenseFingerprint(snapshot)); +} + +export async function licenseChangedSinceSeen() { + if (!snapshot) return false; + const now = licenseFingerprint(snapshot); + const seen = await getPref(PREF_SCOPE, PREF_SEEN, null); + if (seen === null || seen === undefined) { await setPref(PREF_SCOPE, PREF_SEEN, now); return false; } + return seen !== now; +} diff --git a/client/lightning/lightning-scan.js b/client/lightning/lightning-scan.js new file mode 100644 index 0000000..27c7f8c --- /dev/null +++ b/client/lightning/lightning-scan.js @@ -0,0 +1,62 @@ +// Generated, do not edit. sha256:437d88ce2721c02e69b9a2fbd8a4429ac52560b575699f0f5ce5e41e7cc24369 +import { normalizeMovements, normalizeNwcMovements, nodeBalance, channelTxids } from './lightning-tracker.js'; +import { upsertWalletTxs, labelChannelTxs } from '../data/wallet-txs.js'; +import { recordSourceBalance } from '../data/wallet-sources.js'; + +export const isLightningSource = s => s?.kind === 'lnd' || s?.kind === 'nwc'; + +export function walletTxRows(movements, source) { + const rows = []; + let skipped = 0; + for (const m of movements || []) { + const txid = m.hash || m.ref || null; + if (!txid || !m.at) { skipped++; continue; } + const direction = m.direction === 'out' ? 'out' : 'in'; + rows.push({ + accountId: source.accountId, sourceId: source.id, layer: 'lightning', txid, direction, + valueSats: Math.max(0, Math.round(Number(m.sats) || 0)), + feeSats: direction === 'out' ? Math.max(0, Math.round(Number(m.fee_sats) || 0)) : null, + blockTime: m.at, blockHeight: null, address: null, kind: m.self ? 'self' : null, memo: m.memo ?? null, + }); + } + return { rows, skipped }; +} + +const msatToSats = msat => Math.max(0, Math.floor(Number(msat || 0) / 1000)); + +export async function scanLightningSource(source, { tracker = null, nwcClient = null } = {}) { + const tr = tracker || { normalizeMovements, normalizeNwcMovements, nodeBalance, channelTxids }; + let movements, balanceSats, kanaalTxids = []; + if (source.kind === 'lnd') { + if (!source.connectionId) throw new Error('A LND source without a connection.'); + movements = await tr.normalizeMovements(source.connectionId); + balanceSats = (await tr.nodeBalance(source.connectionId)).channelLocal; + kanaalTxids = [...await tr.channelTxids(source.connectionId)]; + } else if (source.kind === 'nwc') { + movements = await tr.normalizeNwcMovements(source.id); + const nwc = nwcClient || await import('./nwc.js'); + balanceSats = msatToSats((await nwc.getBalance(source.id))?.balance); + } else { + throw new Error(`Not a lightning source: '${source.kind}'.`); + } + const { rows, skipped } = walletTxRows(movements, source); + if (rows.length) await upsertWalletTxs(rows); + await recordSourceBalance(source.id, balanceSats); + const channelLabels = kanaalTxids.length ? await labelChannelTxs(source.accountId, kanaalTxids) : 0; + return { count: rows.length, skipped, balanceSats, channelLabels, channelTxids: kanaalTxids }; +} + +export function countMovements(txs, isLinked) { + const groups = new Map(); + for (const tx of txs || []) { + const key = tx.layer === 'lightning' ? `ln:${tx.accountId}:${tx.txid}` : `id:${tx.id}`; + groups.set(key, groups.get(key) || isLinked(tx)); + } + return { total: groups.size, linked: [...groups.values()].filter(Boolean).length }; +} + +export function lightningAccountSats(sources) { + const ln = (sources || []).filter(isLightningSource); + const heeftLnd = ln.some(s => s.kind === 'lnd'); + return ln.filter(s => s.kind === 'lnd' || !heeftLnd).reduce((sum, s) => sum + (Number(s.balanceSats) || 0), 0); +} diff --git a/client/lightning/lightning-tracker.js b/client/lightning/lightning-tracker.js new file mode 100644 index 0000000..ab15415 --- /dev/null +++ b/client/lightning/lightning-tracker.js @@ -0,0 +1,198 @@ +// Generated, do not edit. sha256:20b466379c38e65d9cee77881aaa8890de723421f475561a2047de7bd3f03d95 +import * as lnd from './lnd.js'; + +function toHex(v) { + const s = String(v || ''); + if (!s) return ''; + if (/^[0-9a-fA-F]+$/.test(s) && s.length % 2 === 0) return s.toLowerCase(); + const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/')); + let h = ''; + for (let i = 0; i < bin.length; i++) h += bin.charCodeAt(i).toString(16).padStart(2, '0'); + return h; +} + +const toSats = v => Math.round(Number(v || 0)); +const secToIso = s => (Number(s) ? new Date(Number(s) * 1000).toISOString() : null); +const nsToIso = ns => (Number(ns) ? new Date(Math.floor(Number(ns) / 1e6)).toISOString() : null); + +const PAGE = 200; +const MAX_PAGES = 50; + +async function fetchPayments(connId) { + const out = []; + let offset = 0; + for (let p = 0; p < MAX_PAGES; p++) { + const res = await lnd.listPayments(connId, { max_payments: PAGE, index_offset: offset, include_incomplete: false }); + const batch = (res && res.payments) || []; + out.push(...batch); + const next = Number(res && res.last_index_offset) || 0; + if (!batch.length || next <= offset) break; + offset = next; + } + return out; +} + +async function fetchInvoices(connId) { + const out = []; + let offset = 0; + for (let p = 0; p < MAX_PAGES; p++) { + const res = await lnd.listInvoices(connId, { num_max_invoices: PAGE, index_offset: offset }); + const batch = (res && res.invoices) || []; + out.push(...batch); + const next = Number(res && res.last_index_offset) || 0; + if (!batch.length || next <= offset) break; + offset = next; + } + return out; +} + +async function fetchForwarding(connId) { + const out = []; + let offset = 0; + for (let p = 0; p < MAX_PAGES; p++) { + const res = await lnd.forwardingHistory(connId, { num_max_events: PAGE, index_offset: offset }); + const batch = (res && res.forwarding_events) || []; + out.push(...batch); + const next = Number(res && res.last_offset_index) || 0; + if (!batch.length || next <= offset) break; + offset = next; + } + return out; +} + +export async function normalizeMovements(connId) { + const [payments, invoices, forwards] = await Promise.all([ + fetchPayments(connId), fetchInvoices(connId), fetchForwarding(connId), + ]); + + const okPayments = payments.filter(p => p.status === 'SUCCEEDED'); + const okInvoices = invoices.filter(i => i.state === 'SETTLED' || i.settled === true); + + const invoiceHashes = new Set(okInvoices.map(i => toHex(i.r_hash))); + const selfHashes = new Set(okPayments.map(p => toHex(p.payment_hash)).filter(h => invoiceHashes.has(h))); + + const movements = []; + + for (const p of okPayments) { + const hash = toHex(p.payment_hash); + const isSelf = selfHashes.has(hash); + movements.push({ + kind: 'payment', + type: isSelf ? 'transfer' : 'sell', + direction: 'out', + hash, + at: nsToIso(p.creation_time_ns) || secToIso(p.creation_date), + sats: toSats(p.value_sat), + fee_sats: toSats(p.fee_sat), + self: isSelf, + memo: null, + }); + } + + for (const i of okInvoices) { + const hash = toHex(i.r_hash); + if (selfHashes.has(hash)) continue; + movements.push({ + kind: 'invoice', + type: 'buy', + direction: 'in', + hash, + at: secToIso(i.settle_date) || secToIso(i.creation_date), + sats: toSats(i.amt_paid_sat || i.value), + fee_sats: 0, + self: false, + memo: i.memo || null, + }); + } + + for (const f of forwards) { + const fee = toSats(f.fee); + if (!fee) continue; + movements.push({ + kind: 'forward', + type: 'interest', + direction: 'route', + hash: null, + ref: `fwd:${f.timestamp_ns || f.timestamp || ''}:${f.chan_id_in || ''}:${f.chan_id_out || ''}`, + at: nsToIso(f.timestamp_ns) || secToIso(f.timestamp), + sats: fee, + fee_sats: 0, + self: false, + memo: null, + }); + } + + movements.sort((a, b) => (a.at || '').localeCompare(b.at || '')); + return movements; +} + +export function nwcMemo(x) { + const beschrijving = typeof x?.description === 'string' ? x.description : ''; + const zap = x?.metadata && typeof x.metadata.nostr === 'object' ? x.metadata.nostr : null; + if (zap && !beschrijving.trim().startsWith('{')) return { kind: 9734, ...zap }; + return beschrijving || (typeof x?.metadata?.comment === 'string' ? x.metadata.comment : null) || null; +} + +export async function normalizeNwcMovements(sourceId) { + const nwc = await import('./nwc.js'); + const all = []; + let offset = 0; + for (let p = 0; p < MAX_PAGES; p++) { + const res = await nwc.listTransactions(sourceId, { limit: PAGE, offset, unpaid: false }); + const batch = (res && res.transactions) || []; + all.push(...batch); + if (batch.length < PAGE) break; + offset += batch.length; + } + const inHashes = new Set(all.filter(x => x.type === 'incoming').map(x => String(x.payment_hash || '')).filter(Boolean)); + const selfHashes = new Set(all.filter(x => x.type === 'outgoing').map(x => String(x.payment_hash || '')).filter(h => inHashes.has(h))); + const movements = []; + for (const x of all) { + const hash = String(x.payment_hash || ''); + const isSelf = !!hash && selfHashes.has(hash); + if (x.type === 'incoming' && isSelf) continue; + movements.push({ + kind: 'nwc', + type: isSelf ? 'transfer' : (x.type === 'incoming' ? 'buy' : 'sell'), + direction: x.type === 'incoming' ? 'in' : 'out', + hash: hash || null, + at: secToIso(x.settled_at) || secToIso(x.created_at), + sats: Math.round(Number(x.amount || 0) / 1000), + fee_sats: Math.round(Number(x.fees_paid || 0) / 1000), + self: isSelf, + memo: nwcMemo(x), + }); + } + movements.sort((a, b) => (a.at || '').localeCompare(b.at || '')); + return movements; +} + +export async function nodeBalance(connId) { + const [chain, chan, chans] = await Promise.all([ + lnd.blockchainBalance(connId), lnd.channelBalance(connId), lnd.listChannels(connId), + ]); + const amt = a => toSats(a && typeof a === 'object' ? a.sat : a); + const onchainConfirmed = toSats(chain && chain.confirmed_balance); + const onchainUnconfirmed = toSats(chain && chain.unconfirmed_balance); + const channelLocal = (chans && Array.isArray(chans.channels) ? chans.channels : []) + .reduce((s, ch) => s + toSats(ch && ch.local_balance) + (ch && ch.initiator ? toSats(ch.commit_fee) : 0), 0); + const unsettledLocal = amt(chan && chan.unsettled_local_balance); + const pendingOpen = amt(chan && chan.pending_open_local_balance) || toSats(chan && chan.pending_open_balance); + const anchor = onchainConfirmed + channelLocal; + const inflight = onchainUnconfirmed + unsettledLocal + pendingOpen; + return { onchainConfirmed, onchainUnconfirmed, channelLocal, unsettledLocal, pendingOpen, anchor, inflight }; +} + +const ZERO_TXID = /^0+$/; +export async function channelTxids(connId) { + const [open, closed] = await Promise.all([lnd.listChannels(connId), lnd.listClosedChannels(connId)]); + const out = new Set(); + const funding = point => String(point || '').split(':')[0].toLowerCase(); + for (const ch of (open && open.channels) || []) if (funding(ch.channel_point)) out.add(funding(ch.channel_point)); + for (const ch of (closed && closed.channels) || []) { + if (funding(ch.channel_point)) out.add(funding(ch.channel_point)); + const sluit = String(ch.closing_tx_hash || '').toLowerCase(); + if (sluit && !ZERO_TXID.test(sluit)) out.add(sluit); + } + return out; +} diff --git a/client/lightning/lnd.js b/client/lightning/lnd.js new file mode 100644 index 0000000..608e52d --- /dev/null +++ b/client/lightning/lnd.js @@ -0,0 +1,48 @@ +// Generated, do not edit. sha256:bf0605d21f5259254ae5ab85694c9ae241adda242e38f4332cc0db1ae9405d9d +import { CONFIG } from '../../shared/config.js'; +import { t } from '../i18n/index.js'; +import { serverFoutTekst } from '../data/_rpc.js'; + +async function api(path, { method = 'GET', body = null } = {}) { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), CONFIG.LND.CLIENT_TIMEOUT_MS); + try { + const res = await fetch(path, { + method, + signal: ctl.signal, + ...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}), + }); + const json = await res.json().catch(() => null); + if (!res.ok) throw new Error(serverFoutTekst(json) || `HTTP ${res.status}`); + return json; + } catch (e) { + if (e.name === 'AbortError') throw new Error(t('common.backendSilent', { s: CONFIG.LND.CLIENT_TIMEOUT_MS / 1000 })); + throw e; + } finally { clearTimeout(timer); } +} + +export const status = id => api(`/api/lnd/status?connection=${encodeURIComponent(id)}`); +export const setConfig = (id, { url, macaroon, tls_cert }) => + api('/api/lnd/config', { method: 'POST', body: { connection: id, url, macaroon, tls_cert } }); +export const purgeConfig = id => api('/api/lnd/delete', { method: 'POST', body: { connection: id } }); + +export const call = (id, endpoint, { query = null, body = null } = {}) => + api('/api/lnd/call', { method: 'POST', body: { connection: id, endpoint, query, body } }); + +export const getInfo = id => call(id, 'getinfo'); +export const channelBalance = id => call(id, 'balance_channels'); +export const blockchainBalance = id => call(id, 'balance_blockchain'); +export const listChannels = id => call(id, 'channels'); +export const listClosedChannels = id => call(id, 'channels_closed'); +export const listPayments = (id, query = {}) => call(id, 'payments', { query }); +export const listInvoices = (id, query = {}) => call(id, 'invoices', { query }); +export const forwardingHistory = (id, body = {}) => call(id, 'forwarding', { body }); + +export async function testLnd(id) { + const info = await getInfo(id); + return { + alias: info.alias || null, + synced: !!info.synced_to_chain, + blockHeight: Number(info.block_height) || 0, + }; +} diff --git a/client/lightning/memo-line.js b/client/lightning/memo-line.js new file mode 100644 index 0000000..3b4f4d4 --- /dev/null +++ b/client/lightning/memo-line.js @@ -0,0 +1,19 @@ +// Generated, do not edit. sha256:c864b8912c6f9fb6e1f43a0e7802dcf91da8cc548250568950b56366eecfca04 +import { zapContext, npubShort, compactMemo } from '../../shared/zap.js'; +import { t } from '../i18n/index.js'; + +export function memoLine(memo, direction, max = 80) { + const z = zapContext(memo); + let text; + if (z) { + const wie = npubShort(direction === 'in' ? z.sender : z.recipient); + const kop = direction === 'in' + ? (wie ? t('tracking.zapFrom', { who: wie }) : t('tracking.zapIn')) + : (wie ? t('tracking.zapTo', { who: wie }) : t('tracking.zapOut')); + text = z.message ? `${kop}: "${z.message}"` : kop; + } else { + text = compactMemo(memo); + } + if (!text) return null; + return { text: text.length > max ? `${text.slice(0, max - 1)}…` : text, title: text }; +} diff --git a/client/lightning/nwc.js b/client/lightning/nwc.js new file mode 100644 index 0000000..ddfaa7b --- /dev/null +++ b/client/lightning/nwc.js @@ -0,0 +1,36 @@ +// Generated, do not edit. sha256:5e5e2f16e143c15a74105363f7c477f9e5d2936d0860e2793eadda36b591c7e6 +import { CONFIG } from '../../shared/config.js'; +import { t } from '../i18n/index.js'; +import { serverFoutTekst } from '../data/_rpc.js'; + +async function api(path, { method = 'GET', body = null } = {}) { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), CONFIG.NWC.CLIENT_TIMEOUT_MS); + try { + const res = await fetch(path, { + method, + signal: ctl.signal, + ...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}), + }); + const json = await res.json().catch(() => null); + if (!res.ok) throw new Error(serverFoutTekst(json) || `HTTP ${res.status}`); + return json; + } catch (e) { + if (e.name === 'AbortError') throw new Error(t('common.backendSilent', { s: CONFIG.NWC.CLIENT_TIMEOUT_MS / 1000 })); + throw e; + } finally { clearTimeout(timer); } +} + +export const validateUri = uri => api('/api/nwc/validate', { method: 'POST', body: { uri } }); +export const status = sourceId => api(`/api/nwc/status?source=${encodeURIComponent(sourceId)}`); +export const setUri = (sourceId, uri) => api('/api/nwc/config', { method: 'POST', body: { source: sourceId, uri } }); +export const purgeUri = sourceId => api('/api/nwc/delete', { method: 'POST', body: { source: sourceId } }); + +export const call = (sourceId, method, params = {}) => + api('/api/nwc/call', { method: 'POST', body: { source: sourceId, method, params } }); + +export const getInfo = sourceId => call(sourceId, 'get_info'); +export const getBalance = sourceId => call(sourceId, 'get_balance'); +export const listTransactions = (sourceId, params = {}) => call(sourceId, 'list_transactions', params); + +export const testNwc = sourceId => api('/api/nwc/test', { method: 'POST', body: { source: sourceId } }); diff --git a/client/login.html b/client/login.html new file mode 100644 index 0000000..1d576d0 --- /dev/null +++ b/client/login.html @@ -0,0 +1,41 @@ + + + + + + + Atreides Assets + + + + + + + + +
+
+
+
+ +
+
+
Atreides Assets
+ +
+ + +
+
+ + diff --git a/client/login.js b/client/login.js new file mode 100644 index 0000000..c998aac --- /dev/null +++ b/client/login.js @@ -0,0 +1,78 @@ +// Generated, do not edit. sha256:57b15ebfb354db8adff68add2532edc1829f9c3b7ed8450a9de6ff4be9df9286 +(function () { + try { + var naam = localStorage.getItem('portfolio_active'); + if (naam) { + document.documentElement.dataset.portfolio = naam; + document.documentElement.style.setProperty('--portfolio-name', JSON.stringify(naam)); + } + } catch (e0) { } + try { + var achtervoegsel = document.documentElement.dataset.portfolio + ? ':' + document.documentElement.dataset.portfolio : ''; + var theme = localStorage.getItem('ui_theme' + achtervoegsel); + var accent = localStorage.getItem('ui_accent' + achtervoegsel); + var depth = localStorage.getItem('ui_depth' + achtervoegsel); + if (theme === 'dark' || theme === 'light') document.documentElement.dataset.theme = theme; + if (accent) document.documentElement.dataset.accent = accent; + if (depth === 'flat' || depth === 'deep') document.documentElement.dataset.depth = depth; + } catch (e1) { } + try { + var look = JSON.parse(localStorage.getItem('ui_look' + achtervoegsel) || '{}'); + var LOOK_ATTRS = ['profile', 'glow', 'glowshape', 'motion', 'rowsel', 'render']; + for (var i = 0; i < LOOK_ATTRS.length; i++) { + var v = look && look[LOOK_ATTRS[i]]; + if (typeof v === 'string' && /^[a-z]+$/.test(v)) document.documentElement.dataset[LOOK_ATTRS[i]] = v; + } + var LOOK_TOKENS = ['--glass-transparency', '--glass-frost', '--dialog-transparency']; + for (var j = 0; j < LOOK_TOKENS.length; j++) { + var w = look && look[LOOK_TOKENS[j]]; + if (typeof w === 'string' && /^\d{1,3}(%|px)?$/.test(w)) document.documentElement.style.setProperty(LOOK_TOKENS[j], w); + } + } catch (e2) { } + + document.addEventListener('DOMContentLoaded', function () { + var form = document.getElementById('login-form'); + var veld = document.getElementById('login-password'); + var knop = document.getElementById('login-submit'); + var fout = document.getElementById('login-error'); + if (!form || !veld || !knop || !fout) return; + + function melding(antwoord) { + var wacht = antwoord && antwoord.retryAfterMs; + if (wacht > 0) { + var seconden = Math.ceil(wacht / 1000); + return 'Te veel pogingen. Probeer het over ' + seconden + + (seconden === 1 ? ' seconde' : ' seconden') + ' opnieuw.'; + } + return 'Onjuist wachtwoord.'; + } + + form.addEventListener('submit', function (e) { + e.preventDefault(); + fout.textContent = ''; + knop.disabled = true; + + fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: veld.value }), + }).then(function (res) { + return res.json().catch(function () { return null; }).then(function (json) { + return { ok: res.ok, json: json }; + }); + }).then(function (uit) { + if (uit.ok) { + location.replace('/'); + return; + } + knop.disabled = false; + fout.textContent = melding(uit.json); + veld.select(); + }).catch(function () { + knop.disabled = false; + fout.textContent = 'Geen verbinding met de server.'; + }); + }); + }); +})(); diff --git a/client/market-service.js b/client/market-service.js new file mode 100644 index 0000000..98b29bc --- /dev/null +++ b/client/market-service.js @@ -0,0 +1,25 @@ +// Generated, do not edit. sha256:2d07cc9fa8245d2a586ce4b51503f9ca7d40676b66802c6c06d44ad790d35373 +import { CONFIG } from '../shared/config.js'; +import { t } from './i18n/index.js'; +import { serverFoutTekst } from './data/_rpc.js'; + +async function api(path, { method = 'GET', body = null } = {}) { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), CONFIG.MARKET_SERVICE.CLIENT_TIMEOUT_MS); + try { + const res = await fetch(path, { + method, + signal: ctl.signal, + ...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}), + }); + const json = await res.json().catch(() => null); + if (!res.ok) throw new Error(serverFoutTekst(json) || `HTTP ${res.status}`); + return json; + } catch (e) { + if (e.name === 'AbortError') throw new Error(t('common.backendSilent', { s: CONFIG.MARKET_SERVICE.CLIENT_TIMEOUT_MS / 1000 })); + throw e; + } finally { clearTimeout(timer); } +} + +export const status = () => api('/api/market/status'); +export const test = () => api('/api/market/test', { method: 'POST', body: {} }); diff --git a/client/nostr/keys.js b/client/nostr/keys.js new file mode 100644 index 0000000..23a6015 --- /dev/null +++ b/client/nostr/keys.js @@ -0,0 +1,44 @@ +// Generated, do not edit. sha256:48d09dbb5cf2116c2b2c3990d8d1b662451d072fe40458fddcaa483e74bb3100 +import { bech32, hexToBytes, bytesToHex, schnorr } from '../vendor/crypto/noble-scure.js'; + +const HEX64 = /^[0-9a-f]{64}$/; + +export function npubFromHex(hex) { + const v = String(hex ?? '').toLowerCase(); + if (!HEX64.test(v)) return null; + return bech32.encode('npub', bech32.toWords(hexToBytes(v))); +} + +export function shortKey(key, keep = 8) { + const k = String(key ?? ''); + return k.length > keep * 2 + 1 ? `${k.slice(0, keep)}…${k.slice(-keep)}` : k; +} + +function validSecret(bytes) { + try { schnorr.getPublicKey(bytes); return true; } catch { return false; } +} + +export function secretFromText(text) { + const v = String(text ?? '').trim(); + if (v.startsWith('npub1')) return { error: 'npub' }; + let bytes = null; + if (HEX64.test(v.toLowerCase())) bytes = hexToBytes(v.toLowerCase()); + else if (v.startsWith('nsec1')) { + try { + const { prefix, words } = bech32.decode(v); + if (prefix === 'nsec') bytes = Uint8Array.from(bech32.fromWords(words)); + } catch { bytes = null; } + } + return bytes && bytes.length === 32 && validSecret(bytes) ? { secret: bytes } : { error: 'invalid' }; +} + +export const publicKeyOf = (secret) => bytesToHex(schnorr.getPublicKey(secret)); + +export const nsecFromSecret = (secret) => bech32.encode('nsec', bech32.toWords(secret)); + +export function newSecret(random = (n) => crypto.getRandomValues(new Uint8Array(n))) { + for (;;) { + const bytes = random(32); + if (validSecret(bytes)) return bytes; + } +} diff --git a/client/nostr/nip44.js b/client/nostr/nip44.js new file mode 100644 index 0000000..7caff54 --- /dev/null +++ b/client/nostr/nip44.js @@ -0,0 +1,74 @@ +// Generated, do not edit. sha256:c9e44c618374bc6218029ec68095d9d8a62c6e5839ffe1dd3084b46600ccb118 +import { + secp256k1, sha256, chacha20, hkdfExtract, hkdfExpand, hmac, base64, + hexToBytes, utf8ToBytes, concatBytes, +} from '../vendor/crypto/noble-scure.js'; + +const VERSION = 2; +const SALT = utf8ToBytes('nip44-v2'); +const MIN = 1; +const MAX = 65535; + +const asBytes = (x) => (typeof x === 'string' ? hexToBytes(x) : x); + +export function conversationKey(secret, pubkey) { + const shared = secp256k1.getSharedSecret(asBytes(secret), concatBytes(Uint8Array.of(2), asBytes(pubkey))); + return hkdfExtract(sha256, shared.subarray(1, 33), SALT); +} + +export function messageKeys(convKey, nonce) { + const keys = hkdfExpand(sha256, asBytes(convKey), asBytes(nonce), 76); + return { chachaKey: keys.subarray(0, 32), chachaNonce: keys.subarray(32, 44), hmacKey: keys.subarray(44, 76) }; +} + +export function paddedLength(len) { + if (len <= 32) return 32; + const next = 1 << (Math.floor(Math.log2(len - 1)) + 1); + const chunk = next <= 256 ? 32 : next / 8; + return chunk * (Math.floor((len - 1) / chunk) + 1); +} + +function pad(text) { + const bytes = utf8ToBytes(text); + if (bytes.length < MIN || bytes.length > MAX) throw new Error(`A NIP-44 message is ${MIN} to ${MAX} bytes.`); + const out = new Uint8Array(2 + paddedLength(bytes.length)); + out[0] = bytes.length >> 8; + out[1] = bytes.length & 0xff; + out.set(bytes, 2); + return out; +} + +function unpad(padded) { + const len = (padded[0] << 8) | padded[1]; + if (len < MIN || padded.length !== 2 + paddedLength(len)) throw new Error('Invalid padding.'); + return new TextDecoder('utf-8', { fatal: true }).decode(padded.subarray(2, 2 + len)); +} + +function sameBytes(a, b) { + if (a.length !== b.length) return false; + let d = 0; + for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i]; + return d === 0; +} + +export function encrypt(text, convKey, nonce = crypto.getRandomValues(new Uint8Array(32))) { + const n = asBytes(nonce); + const { chachaKey, chachaNonce, hmacKey } = messageKeys(convKey, n); + const ciphertext = chacha20(chachaKey, chachaNonce, pad(text)); + const mac = hmac(sha256, hmacKey, concatBytes(n, ciphertext)); + return base64.encode(concatBytes(Uint8Array.of(VERSION), n, ciphertext, mac)); +} + +export function decrypt(payload, convKey) { + const p = String(payload ?? ''); + if (!p || p[0] === '#') throw new Error('Unknown NIP-44 version.'); + if (p.length < 132 || p.length > 87472) throw new Error('Invalid NIP-44 payload length.'); + const data = base64.decode(p); + if (data[0] !== VERSION) throw new Error(`Unknown NIP-44 version ${data[0]}.`); + const nonce = data.subarray(1, 33); + const ciphertext = data.subarray(33, data.length - 32); + const mac = data.subarray(data.length - 32); + const { chachaKey, chachaNonce, hmacKey } = messageKeys(convKey, nonce); + if (!sameBytes(mac, hmac(sha256, hmacKey, concatBytes(nonce, ciphertext)))) throw new Error('Invalid MAC.'); + return unpad(chacha20(chachaKey, chachaNonce, ciphertext)); +} diff --git a/client/nostr/nip98.js b/client/nostr/nip98.js new file mode 100644 index 0000000..b79fb05 --- /dev/null +++ b/client/nostr/nip98.js @@ -0,0 +1,58 @@ +// Generated, do not edit. sha256:f29f3a90c87550fd7890bda37eec12ad08ada3b62d0dc1ae18995512314d74ce +import { schnorr, sha256, bytesToHex, hexToBytes, utf8ToBytes } from '../vendor/crypto/noble-scure.js'; +import { publicKeyOf } from './keys.js'; +import { encrypt as nip44, decrypt as nip44Open, conversationKey } from './nip44.js'; + +const KIND = 27235; + +export const eventId = (e) => bytesToHex(sha256(utf8ToBytes(JSON.stringify([0, e.pubkey, e.created_at, e.kind, e.tags, e.content])))); + +export function nip98Event({ pubkey, url, method, body = '', now = Date.now() }) { + const tags = [['u', url], ['method', method]]; + if (body) tags.push(['payload', bytesToHex(sha256(utf8ToBytes(body)))]); + return { pubkey, created_at: Math.floor(now / 1000), kind: KIND, tags, content: '' }; +} + +export function keySigner(secret) { + const pubkey = publicKeyOf(secret); + return { + kind: 'key', + pubkey, + async sign(event) { + const id = eventId(event); + return { ...event, id, sig: bytesToHex(schnorr.sign(hexToBytes(id), secret)) }; + }, + async nip44Encrypt(pubkey, text) { + return nip44(text, conversationKey(secret, pubkey)); + }, + async nip44Decrypt(pubkey, payload) { + return nip44Open(payload, conversationKey(secret, pubkey)); + }, + }; +} + +export async function extensionSigner(nostr = globalThis.nostr) { + if (!nostr || typeof nostr.getPublicKey !== 'function' || typeof nostr.signEvent !== 'function') { + throw new Error('No nostr extension (NIP-07) in this browser.'); + } + const pubkey = String(await nostr.getPublicKey()).toLowerCase(); + return { + kind: 'extension', + pubkey, + sign: (event) => nostr.signEvent(event), + nip44Encrypt: (to, text) => { + if (typeof nostr.nip44?.encrypt !== 'function') throw new Error('This nostr extension cannot encrypt with NIP-44.'); + return nostr.nip44.encrypt(to, text); + }, + nip44Decrypt: (from, payload) => { + if (typeof nostr.nip44?.decrypt !== 'function') throw new Error('This nostr extension cannot decrypt with NIP-44.'); + return nostr.nip44.decrypt(from, payload); + }, + }; +} + +export async function nip98Header(signer, { url, method, body = '', now = Date.now() }) { + const signed = await signer.sign(nip98Event({ pubkey: signer.pubkey, url, method, body, now })); + if (signed.pubkey !== signer.pubkey) throw new Error('The signer signed with another key than it gave.'); + return `Nostr ${btoa(JSON.stringify(signed))}`; +} diff --git a/client/onboarding/setup.js b/client/onboarding/setup.js new file mode 100644 index 0000000..0e0fe83 --- /dev/null +++ b/client/onboarding/setup.js @@ -0,0 +1,70 @@ +// Generated, do not edit. sha256:e845407dddf225ff542fbca232d3d1694ac7796e6550456ebc528cb13c3d79b8 +import { t } from '../i18n/index.js'; +import { esc } from '../ui/escape.js'; +import * as dialog from '../ui/dialog.js'; +import { currencyName } from '../ui/format.js'; +import { splashMarkHtml } from '../ui/splash.js'; +import { completeSetup, setupStatus } from '../data/onboarding.js'; +import { serverFoutTekst } from '../data/_rpc.js'; +import { appSetting, baseCurrency } from '../app-settings.js'; + +const ASSETS = ['BTC']; +const CURRENCIES = ['EUR', 'USD']; +const ACCOUNT_KINDS = ['wallet', 'exchange', 'bank']; +const WIDGETS = ['standing', 'spot']; + +const tile = ({ attr, key, value, on, name, sub = '' }) => ` + `; +const has = (s, key, v) => Array.isArray(s[key]) && s[key].includes(v); +const toggles = (s, key, values, label) => `
${values.map(v => tile({ + attr: 'data-step-toggle', key, value: v, on: has(s, key, v), name: label(v) })).join('')}
`; + +const steps = status => [ + { title: t('setup.currencyTitle'), skip: () => !status.currencyOpen, + html: s => `
${CURRENCIES.map(c => tile({ + attr: 'data-step-set', key: 'currency', value: c, on: s.currency === c, name: currencyName(c), sub: c })).join('')}
` }, + { title: t('setup.accountsTitle'), html: s => toggles(s, 'accounts', ACCOUNT_KINDS, k => t(`setup.account_${k}`)) }, + { title: t('setup.dashboardTitle'), html: s => toggles(s, 'widgets', WIDGETS, w => t(`setup.widget_${w}`)) }, +]; + +async function chooseCurrency(code) { + const r = await fetch('/api/setup/currency', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseCurrency: code }), + }); + const uit = await r.json().catch(() => null); + if (!r.ok) throw new Error(serverFoutTekst(uit) || `HTTP ${r.status}`); + return uit.changed; +} + +export async function openSetup({ rerun = false } = {}) { + try { + const status = await setupStatus(); + const state = await dialog.stepper({ + steps: steps(status), hero: splashMarkHtml(), finishLabel: t('setup.finish'), + state: { currency: baseCurrency(), accounts: rerun ? [] : ['bank'], widgets: [...WIDGETS] }, + }); + if (!state && rerun) return; + const reload = !!state && status.currencyOpen && await chooseCurrency(state.currency); + await completeSetup(state ? { + assets: ASSETS, + persona: rerun ? appSetting('persona') : 'broad', + accounts: state.accounts.map(k => ({ kind: k, name: t(`setup.account_${k}`) })), + widgets: state.widgets, + } : {}); + if (reload) location.reload(); + else document.dispatchEvent(new CustomEvent('settings-changed')); + } catch (err) { + console.error('[setup]', err); + dialog.alert({ title: t('dialog.error'), message: err.message }); + } +} + +export async function maybeStartSetup() { + try { + if ((await setupStatus()).needed) await openSetup(); + } catch (err) { + console.error('[setup]', err); + } +} diff --git a/client/onchain.js b/client/onchain.js new file mode 100644 index 0000000..dacac55 --- /dev/null +++ b/client/onchain.js @@ -0,0 +1,73 @@ +// Generated, do not edit. sha256:1acae16b8266effbdb3fb96f020db0a5b6e3d06834851544c79f37653dbed396 +import { eventsNetByAccount } from './data/events.js'; +import { syncTxProposals } from './data/proposals.js'; +import { createScanLog } from './data/scan-logs.js'; +import { syncAddressLinks, onchainAsset } from './data/tx-links.js'; +import { listWalletAddresses, upsertWalletAddresses, accountOnchainBalance } from './data/wallet-addresses.js'; +import { upsertWalletTxs } from './data/wallet-txs.js'; +import { t } from './i18n/index.js'; +import { addressInfo } from './price/btc-explorer.js'; +import { ENUMS } from '../shared/config.js'; + +const nowIso = () => new Date().toISOString(); + +export const isOnchainSource = (source) => ENUMS.ONCHAIN_SOURCE_KIND.includes(source && source.kind); + +export async function scanSource(source, report = () => {}) { + let addrRows; + let failed = 0; + if (!isOnchainSource(source)) { + throw new Error(`A source of kind '${source && source.kind}' is not scanned through the chain; ` + + `only ${ENUMS.ONCHAIN_SOURCE_KIND.join(' and ')} can be.`); + } + if (source.kind === 'address') { + const info = await addressInfo(source.value); + addrRows = [{ + accountId: source.accountId, sourceId: source.id, address: source.value, + chainIndex: null, derivationIndex: null, automaticLabel: null, + balanceSats: info.balanceSats, transactionCount: info.transactionCount, lastScannedAt: nowIso(), + }]; + } else { + const { scanWallet } = await import('./price/wallet-scanner.js?v=8'); + const res = await scanWallet(source.value, source.scriptType, p => { + if (p.fase === 'scan') report(`${t('tracking.scanning')} ${p.chain} #${p.index}`); + }); + addrRows = res.alleAdressen.map(a => ({ + accountId: source.accountId, sourceId: source.id, address: a.adres, + chainIndex: a.chain, derivationIndex: a.index, automaticLabel: a.chainLabel, + balanceSats: a.saldo_satoshi, transactionCount: a.tx_aantal, lastScannedAt: nowIso(), + })); + } + await upsertWalletAddresses(addrRows); + + const actief = addrRows.filter(a => (a.transactionCount || 0) > 0).map(a => a.address); + if (actief.length) { + report(t('tracking.scanningTx')); + const { scanMovements } = await import('./price/wallet-scanner.js?v=8'); + const rekening = (await listWalletAddresses(source.accountId)) + .filter(a => (a.transactionCount || 0) > 0).map(a => a.address); + const res = await scanMovements(actief, source.accountId, source.id, p => { + if (p.fase === 'tx') report(`${t('tracking.scanningTx')} ${p.index + 1}/${p.total}`); + }, { ownAddresses: rekening }); + await upsertWalletTxs(res.rows); + await syncAddressLinks(); + failed = res.failed.length; + } + const proposals = await reconcileAccount(source.accountId); + return { count: addrRows.length, failed, proposals }; +} + +export async function reconcileAccount(accountId) { + const onchain = await accountOnchainBalance(accountId); + const asset = await onchainAsset(); + if (!asset) return 0; + const net = await eventsNetByAccount(accountId, asset.id); + const created = await syncTxProposals(accountId, asset.id); + await createScanLog({ + accountId: accountId, scannedAt: nowIso(), status: 'success', + addressesFound: (await listWalletAddresses(accountId)).length, + balanceFound: onchain, proposalsCreated: created, + rawData: { onchain, events_net: net, gap: onchain - net }, + }); + return created; +} diff --git a/client/pages/accounts.js b/client/pages/accounts.js new file mode 100644 index 0000000..91cbcaa --- /dev/null +++ b/client/pages/accounts.js @@ -0,0 +1,376 @@ +// Generated, do not edit. sha256:51ab737fb6604c8493f238a8893184408226a1a90ddd465fcd92bf0040b5a50e +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: () => `${t('license.name_fiatAccounts')}: ${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)); + + 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' } }, + ], + rows: await listWalletSources(accountId), + ...(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); }, + }); +} diff --git a/client/pages/actions.js b/client/pages/actions.js new file mode 100644 index 0000000..99ba85d --- /dev/null +++ b/client/pages/actions.js @@ -0,0 +1,84 @@ +// Generated, do not edit. sha256:0bce2d8ce656c1145d2eaf8627da39c4fe16f1583c0379c2a0fa2919e295eae7 +import { t } from '../i18n/index.js'; +import { renderAiProposals } from '../actions/ai-proposals-widget.js'; +import { renderReconcile } from '../actions/reconcile-widget.js'; +import { renderInterestWidget } from '../actions/interest-widget.js'; +import { renderValuationWidget } from '../actions/valuation-widget.js'; +import { renderReservationsWidget } from '../actions/reservations-widget.js'; +import { fmtInt } from '../ui/format.js'; +import { esc } from '../ui/escape.js'; +import { CHECK } from '../ui/icons.js'; + +export const title = () => t('actions.title'); +export const headerInColumn = true; + +const BLOKKEN = [ + { id: 'act-ai', label: () => t('aiProposals.title') }, + { id: 'act-reconcile', label: () => t('home.reconcile') }, + { id: 'act-interest', label: () => t('actions.interestTitle') }, + { id: 'act-valuation', label: () => t('actions.valuationTitle') }, + { id: 'act-reservation', label: () => t('reservations.title') }, +]; + +export async function render(el) { + el.innerHTML = ` +
+ +
+ +
+
+
+
+
+
+
`; + + const refresh = () => render(el); + const aantal = {}; + for (const [id, teken] of [['act-ai', renderAiProposals], ['act-reconcile', renderReconcile], + ['act-interest', renderInterestWidget], ['act-valuation', renderValuationWidget], + ['act-reservation', renderReservationsWidget]]) { + const host = el.querySelector('#' + id); + if (!host) return; + aantal[id] = await teken(host, refresh); + } + + vulKolom(el, aantal); +} + +function vulKolom(el, aantal) { + const zichtbaar = BLOKKEN.filter(b => aantal[b.id] != null); + const totaal = zichtbaar.reduce((som, b) => som + aantal[b.id], 0); + + const zet = (sleutel, html) => { + const e = el.querySelector(`[data-act="${sleutel}"]`); + if (e) e.innerHTML = html; + }; + zet('total', totaal ? fmtInt(totaal) : CHECK); + zet('totalSub', totaal + ? (totaal === 1 ? t('actions.openOne') : t('actions.openMany')) + : t('actions.openNone')); + + const nav = el.querySelector('[data-jump]'); + if (!nav) return; + nav.innerHTML = zichtbaar.map(b => ` + `).join(''); + + nav.onclick = e => { + const knop = e.target.closest('[data-to]'); + if (!knop) return; + const doel = el.querySelector('#' + knop.dataset.to); + if (doel) doel.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; +} diff --git a/client/pages/ai.js b/client/pages/ai.js new file mode 100644 index 0000000..22c980a --- /dev/null +++ b/client/pages/ai.js @@ -0,0 +1,607 @@ +// Generated, do not edit. sha256:9d5e374482da80f40653036b5e206e6d6ff98a8d9dbf55c030df645a418ea367 +import { + listAiToolCalls, countAiToolCalls, lastAiToolCallAt, aiToolCallMessages, +} from '../data/ai-audit.js'; +import { tableViewCfg, getPref, setPref } from '../data/preferences.js'; +import { t, localeTag } from '../i18n/index.js'; +import { fmtInt } from '../ui/format.js'; +import { esc } from '../ui/escape.js'; +import { alertHtml } from '../ui/alert.js'; +import { renderTable } from '../ui/table.js'; +import { refreshAttention } from '../ui/attention.js'; +import { CHEVRON, COPY, CHECK } from '../ui/icons.js'; +import { expanders } from '../ui/expand.js'; +import * as dialog from '../ui/dialog.js'; +import { cachedPortfolioName } from '../portfolio.js'; +import { CONFIG } from '../../shared/config.js'; + +function moment(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(localeTag(), { dateStyle: 'short', timeStyle: 'medium' }); +} + +function dag(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString(localeTag(), { dateStyle: 'short' }); +} + +function deelGroep(velden) { + if (velden == null) return t('ai.sharedUnknown'); + return velden.length ? t('ai.sharedSome') : t('ai.sharedNothing'); +} + +function gedeeld(velden) { + if (velden == null) return `${t('ai.sharedUnknown')}`; + if (!velden.length) return `${t('ai.sharedNothing')}`; + return velden.map(v => `${esc(v)}`).join(', '); +} + +function notitie(json) { + if (!json) return ''; + try { + const args = JSON.parse(json); + if (args && typeof args.note === 'string' && args.note) return esc(args.note); + } catch { } + return ''; +} + +async function koppelGegevens() { + try { + const res = await fetch('/api/mcp/config'); + if (!res.ok) return null; + const cfg = await res.json(); + return cfg && typeof cfg.snippet === 'string' ? cfg : null; + } catch { return null; } +} + +async function dryTools() { + try { + const res = await fetch('/api/mcp/tools'); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data.tools) ? data.tools : []; + } catch { return []; } +} + +const scopeTekst = scope => { + try { return t(`ai.dryScope.${scope}`); } catch { return scope; } +}; + +const gezieneVerwijzingen = new Set(); + +const VERWIJZING = /\b[a-z]+:[0-9a-f]{6}\b/g; + +function onthoudVerwijzingen(tekst) { + for (const m of String(tekst || '').matchAll(VERWIJZING)) gezieneVerwijzingen.add(m[0]); +} + +const VERWIJZING_VELDEN = new Set(['handle']); + +function dryArgVelden(tool) { + const schema = tool && tool.inputSchema; + const props = schema && schema.properties ? Object.entries(schema.properties) : []; + if (!props.length) return ''; + const verplicht = new Set(Array.isArray(schema.required) ? schema.required : []); + + const velden = props.map(([naam, spec]) => { + const nodig = verplicht.has(naam); + const label = `${esc(naam)}${nodig ? ` (${t('ai.dryArgRequired')})` : ''}`; + const bediening = Array.isArray(spec.enum) + ? `` + : tekstVeld(naam, spec); + return ``; + }).join(''); + + const hulp = props.some(([naam]) => VERWIJZING_VELDEN.has(naam)) + ? `
${ + gezieneVerwijzingen.size ? t('ai.dryArgHandleSome') : t('ai.dryArgHandleNone')}
` + : ''; + + return `
${t('ai.dryArgs')}
+
${velden}
+ ${hulp}`; +} + +function tekstVeld(naam, spec) { + const suggesties = VERWIJZING_VELDEN.has(naam) ? [...gezieneVerwijzingen].sort() : []; + const lijstId = `dry-lijst-${naam}`; + return ` + ${suggesties.length + ? `${suggesties.map(v => ``).join('')}` + : ''}`; +} + +function dryBlok(tools, expandAttr) { + if (!tools.length) return ''; + const opties = tools.map(x => + ``).join(''); + return ` +
+ ${CHEVRON}${t('ai.dryTitle')} +
+
${t('ai.dryIntro')}
+
+ + + +
+
${dryArgVelden(tools[0])}
+
+
+
`; +} + +function dryUitslag(r) { + const detail = r.detail ? `
${esc(r.detail)}
` : ''; + return ` +
+
${t('ai.result')}
${ + r.outcome === 'error' ? `${t('ai.error')}` : t(`ai.${r.outcome}`)}
+
${t('ai.shared')}
${gedeeld(r.fieldsShared)}
+
${t('ai.characters')}
${ + r.charactersOut == null ? '' : fmtInt(r.charactersOut)}
+
+ ${detail} +
${t('ai.dryResult')}
+ ${tekstBlok(r.text)}`; +} + +function tekstBlok(tekst) { + return `
${esc(tekst)}
`; +} + +const gesprekLade = (inhoud) => `
${inhoud}
`; + +function gesprekBlok(regels) { + if (!regels.length) return `
${t('ai.noConversation')}
`; + const blokken = regels.map(m => { + const label = t(m.kind === 'request' ? 'ai.msgRequest' : 'ai.msgResponse'); + return `
+
${label}, ${esc(moment(m.occurredAt))}
+ ${tekstBlok(m.text)} +
`; + }).join(''); + return blokken; +} + +function bindDry(el, tools) { + const knop = el.querySelector('#dry-run'); + if (!knop) return; + const keuze = el.querySelector('#dry-tool'); + const melding = el.querySelector('#dry-melding'); + const uitslag = el.querySelector('#dry-uitslag'); + const argVak = el.querySelector('#dry-args'); + const toolVan = naam => tools.find(x => x.name === naam) || null; + + keuze.addEventListener('change', () => { + argVak.innerHTML = dryArgVelden(toolVan(keuze.value)); + uitslag.innerHTML = ''; + melding.textContent = ''; + }); + + function werkSuggestiesBij() { + for (const veld of argVak.querySelectorAll('[data-arg]')) { + if (!VERWIJZING_VELDEN.has(veld.dataset.arg)) continue; + const lijstId = `dry-lijst-${veld.dataset.arg}`; + let lijst = argVak.querySelector(`#${lijstId}`); + if (!lijst) { + lijst = document.createElement('datalist'); + lijst.id = lijstId; + veld.after(lijst); + veld.setAttribute('list', lijstId); + } + lijst.innerHTML = [...gezieneVerwijzingen].sort() + .map(v => ``).join(''); + } + } + + function argumenten() { + const uit = {}; + for (const veld of argVak.querySelectorAll('[data-arg]')) { + const w = veld.value.trim(); + if (w) uit[veld.dataset.arg] = w; + } + return uit; + } + + function ontbreekt(tool) { + const nodig = tool?.inputSchema?.required; + if (!Array.isArray(nodig)) return false; + const ingevuld = argumenten(); + return nodig.some(naam => !ingevuld[naam]); + } + + knop.addEventListener('click', async () => { + if (ontbreekt(toolVan(keuze.value))) { melding.textContent = t('ai.dryNeedsArg'); return; } + knop.disabled = true; + melding.textContent = t('ai.dryBusy'); + try { + const res = await fetch('/api/mcp/dry-run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tool: keuze.value, arguments: argumenten() }), + }); + if (!res.ok) throw new Error(t('ai.dryFail')); + const r = await res.json(); + uitslag.innerHTML = dryUitslag(r); + melding.textContent = ''; + const voor = gezieneVerwijzingen.size; + onthoudVerwijzingen(r.text); + if (gezieneVerwijzingen.size !== voor) werkSuggestiesBij(); + } catch (e) { + melding.textContent = e.message || t('ai.dryFail'); + } finally { + knop.disabled = false; + } + }); +} + +const cel = (kop, waarde) => + `${kop}${waarde}`; + +function schuifCel(kop, id, aan, hint) { + return cel(kop, + `` + + `` + + ``); +} + +function tellerCellen(telling, laatste) { + if (!telling) return ''; + return cel(t('ai.total'), fmtInt(telling.total)) + + cel(t('ai.ok'), fmtInt(telling.byOutcome.ok)) + + cel(t('ai.refused'), fmtInt(telling.byOutcome.refused)) + + cel(t('ai.error'), fmtInt(telling.byOutcome.error)) + + cel(t('ai.lastAt'), esc(moment(laatste))); +} + +function bedieningsBlok(standen, telling, laatste) { + const { actief, lezen, schrijven, besturen, onbewerkt } = standen; + return `` + + `` + + schuifCel(t('ai.activeHead'), 'ai-active', actief, actief ? t('ai.activeOn') : t('ai.activeOff')) + + schuifCel(t('ai.switchHead'), 'ai-read', lezen, lezen ? t('ai.switchOn') : t('ai.switchOff')) + + schuifCel(t('ai.writeHead'), 'ai-write', schrijven, schrijven ? t('ai.writeOn') : t('ai.writeOff')) + + schuifCel(t('ai.controlHead'), 'ai-control', besturen, besturen ? t('ai.controlOn') : t('ai.controlOff')) + + schuifCel(t('ai.rawHead'), 'ai-raw', onbewerkt, onbewerkt ? t('ai.rawOn') : t('ai.rawOff')) + + `` + + `${tellerCellen(telling, laatste)}` + + ``; +} + +function precisieBlok(standen) { + const { emmerAandeel, emmerTotaal, emmerBedrag, emmerAantal } = standen; + return ` +
+
${t('ai.precisionTitle')}
+
${t('ai.precisionIntro')}
+ + ${schuifCel(t('ai.sharesHead'), 'ai-bshares', emmerAandeel, + emmerAandeel ? t('ai.sharesOn') : t('ai.sharesOff'))} + ${schuifCel(t('ai.totalsHead'), 'ai-btotals', emmerTotaal, + emmerTotaal ? t('ai.totalsOn') : t('ai.totalsOff'))} + ${schuifCel(t('ai.amountsHead'), 'ai-bamounts', emmerBedrag, + emmerBedrag ? t('ai.amountsOn') : t('ai.amountsOff'))} + ${schuifCel(t('ai.quantitiesHead'), 'ai-bquantities', emmerAantal, + emmerAantal ? t('ai.quantitiesOn') : t('ai.quantitiesOff'))} + + + + + +
${t('ai.precisionCombo')}
+
`; +} + +function koppelBlok(cfg, expandAttr, bediening, standen) { + if (!cfg) return `
${t('ai.connectFail')}
`; + return ` +
+ + ${CHEVRON} + ${t('ai.connectTitle')} + + + + + + ${bediening} + +
+
${esc(portefeuilleRegel())}
+
${t('ai.revoke')}
+
${t('ai.rawHint')}
+ ${precisieBlok(standen)} +
${t('ai.connectIntro')}
+
+
+ + JSON +
+
${esc(cfg.snippet)}
+
+ +
+ ${t('ai.connectFile')} ${t('ai.connectRestart')} ${t('ai.controlHint')} +
+
+
`; +} + +function bindSchuif(el, { wrapId, vakId, standId, scope, key, aanTekst, uitTekst, faalTekst, bevestigAan = null }) { + const wrap = el.querySelector(`#${wrapId}`); + if (!wrap) return; + const vak = el.querySelector(`#${vakId}`); + const stand = el.querySelector(`#${standId}`); + + const toon = (aan, melding = '') => { + if (vak) vak.checked = aan; + wrap.setAttribute('aria-checked', aan ? 'true' : 'false'); + wrap.title = aan ? aanTekst : uitTekst; + if (stand) stand.textContent = melding; + }; + + wrap.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + const aan = wrap.getAttribute('aria-checked') !== 'true'; + if (aan && bevestigAan && !await bevestigAan()) return; + toon(aan); + try { + await setPref(scope, key, aan ? 1 : 0); + } catch { + toon(!aan, faalTekst); + } + }); +} + +function bindSchuiven(el) { + bindSchuif(el, { + wrapId: 'ai-active-wrap', vakId: 'ai-active', standId: 'ai-active-stand', + scope: 'ai', key: 'enabled', + aanTekst: t('ai.activeOn'), uitTekst: t('ai.activeOff'), faalTekst: t('ai.switchFail'), + }); + bindSchuif(el, { + wrapId: 'ai-read-wrap', vakId: 'ai-read', standId: 'ai-read-stand', + scope: 'ai', key: 'allow_read', + aanTekst: t('ai.switchOn'), uitTekst: t('ai.switchOff'), faalTekst: t('ai.switchFail'), + }); + bindSchuif(el, { + wrapId: 'ai-write-wrap', vakId: 'ai-write', standId: 'ai-write-stand', + scope: 'ai', key: 'allow_write', + aanTekst: t('ai.writeOn'), uitTekst: t('ai.writeOff'), faalTekst: t('ai.switchFail'), + }); + bindSchuif(el, { + wrapId: 'ai-control-wrap', vakId: 'ai-control', standId: 'ai-control-stand', + scope: 'ai', key: 'allow_control', + aanTekst: t('ai.controlOn'), uitTekst: t('ai.controlOff'), faalTekst: t('ai.switchFail'), + }); + bindSchuif(el, { + wrapId: 'ai-raw-wrap', vakId: 'ai-raw', standId: 'ai-raw-stand', + scope: 'ai', key: 'allow_raw', + aanTekst: t('ai.rawOn'), uitTekst: t('ai.rawOff'), faalTekst: t('ai.switchFail'), + bevestigAan: () => dialog.confirm({ + message: t('ai.rawConfirm').replace('{naam}', cachedPortfolioName() || '?'), + danger: true, + }), + }); + + for (const [id, key] of [ + ['ai-bshares', 'bucket_shares'], + ['ai-btotals', 'bucket_totals'], + ['ai-bamounts', 'bucket_amounts'], + ['ai-bquantities', 'bucket_quantities'], + ]) { + const stam = key.replace('bucket_', ''); + bindSchuif(el, { + wrapId: `${id}-wrap`, vakId: id, standId: `${id}-stand`, + scope: 'ai', key, + aanTekst: t(`ai.${stam}On`), uitTekst: t(`ai.${stam}Off`), faalTekst: t('ai.switchFail'), + }); + } +} + +function portefeuilleRegel() { + const naam = cachedPortfolioName(); + return naam ? t('ai.scope').replace('{naam}', naam) : t('ai.rawHint'); +} + +const BEVESTIGING_MS = 2000; + +function bindKopieer(el, cfg) { + const knop = el.querySelector('#mcp-config-copy'); + if (!knop || !cfg) return; + const melding = el.querySelector('#mcp-config-melding'); + let terug = null; + const herstel = () => { + knop.classList.remove('is-done'); + knop.innerHTML = COPY; + knop.setAttribute('aria-label', t('ai.connectCopy')); + knop.setAttribute('title', t('ai.connectCopy')); + if (melding) melding.textContent = ''; + }; + knop.addEventListener('click', () => { + navigator.clipboard.writeText(cfg.snippet) + .then(() => { + knop.classList.add('is-done'); + knop.innerHTML = CHECK; + knop.setAttribute('aria-label', t('ai.connectCopied')); + knop.setAttribute('title', t('ai.connectCopied')); + if (melding) melding.textContent = ''; + }) + .catch(() => { + knop.classList.remove('is-done'); + knop.innerHTML = COPY; + if (melding) melding.textContent = t('ai.connectCopyFail'); + }) + .finally(() => { clearTimeout(terug); terug = setTimeout(herstel, BEVESTIGING_MS); }); + }); +} + +let live = null; +let liveGebonden = false; +const gesprekken = new Map(); + +function bindLive() { + if (liveGebonden) return; + liveGebonden = true; + document.addEventListener('ai-activity-changed', async () => { + if (!live || !live.el || !live.el.isConnected) { live = null; return; } + let regels, telling, laatste; + try { + [regels, telling, laatste] = await Promise.all([ + listAiToolCalls(CONFIG.AI.AUDIT_PAGE_SIZE), countAiToolCalls(), lastAiToolCallAt(), + ]); + } catch { return; } + if (!live.el.isConnected) { live = null; return; } + + live.tabel.refresh(regels); + const tellers = live.el.querySelector('.ai-tellers'); + if (tellers) tellers.innerHTML = tellerCellen(telling, laatste); + await markeerGezien(regels); + }); +} + +async function markeerGezien(regels) { + const hoogste = regels.reduce((n, r) => Math.max(n, Number(r.id) || 0), 0); + if (!hoogste) return; + try { + await setPref('ai', 'seen_call_id', hoogste); + await refreshAttention(); + } catch { } +} + +export async function render(el) { + let regels, telling, laatste; + try { + [regels, telling, laatste] = await Promise.all([ + listAiToolCalls(CONFIG.AI.AUDIT_PAGE_SIZE), countAiToolCalls(), lastAiToolCallAt(), + ]); + } catch (err) { + el.innerHTML = alertHtml({ kind: 'error', body: err.message }); + return; + } + + const cfg = await koppelGegevens(); + const tools = await dryTools(); + const lezen = await getPref('ai', 'allow_read', 1).then(v => v !== 0).catch(() => true); + const uitBijTwijfel = (sleutel) => getPref('ai', sleutel, 0).then(v => v === 1).catch(() => false); + const aanBijTwijfel = (sleutel) => getPref('ai', sleutel, 1).then(v => v !== 0).catch(() => true); + const standen = { + actief: await getPref('ai', 'enabled', 1).then(v => v !== 0).catch(() => true), + lezen, + schrijven: await uitBijTwijfel('allow_write'), + besturen: await uitBijTwijfel('allow_control'), + onbewerkt: await uitBijTwijfel('allow_raw'), + emmerAandeel: await aanBijTwijfel('bucket_shares'), + emmerTotaal: await aanBijTwijfel('bucket_totals'), + emmerBedrag: await aanBijTwijfel('bucket_amounts'), + emmerAantal: await aanBijTwijfel('bucket_quantities'), + }; + + const expand = await expanders('ai', { connect: !telling.total, dryRun: false }); + + if (!telling.total) { + el.innerHTML = `${koppelBlok(cfg, expand.attr('connect'), bedieningsBlok(standen, null), standen)}${dryBlok(tools, expand.attr('dryRun'))} + ${alertHtml({ kind: 'neutral', body: t('ai.notConnected'), + extra: `
${t('ai.howTo')}
` })}`; + bindSchuiven(el); + bindKopieer(el, cfg); + bindDry(el, tools); + expand.bind(el); + return; + } + + el.innerHTML = `${koppelBlok(cfg, expand.attr('connect'), bedieningsBlok(standen, telling, laatste), standen)}` + + `${dryBlok(tools, expand.attr('dryRun'))}
`; + bindSchuiven(el); + bindKopieer(el, cfg); + bindDry(el, tools); + expand.bind(el); + gesprekken.clear(); + + const tabel = renderTable(el.querySelector('#tbl'), { + columns: [ + { + key: 'when', label: t('ai.when'), sortable: true, + sortValue: r => r.occurredAt || '', + render: r => esc(moment(r.occurredAt)), + group: { value: r => dag(r.occurredAt), label: v => v }, + }, + { + key: 'what', label: t('ai.what'), sortable: true, text: true, + sortValue: r => r.tool || '', + render: r => { + const note = notitie(r.arguments); + const detail = r.detail ? `
${esc(r.detail)}
` : ''; + const noteRegel = note ? `
"${note}"
` : ''; + return `${esc(r.tool)}${noteRegel}${detail}`; + }, + group: { value: r => r.tool || '', label: v => v }, + }, + { + key: 'who', label: t('ai.who'), sortable: true, + sortValue: r => r.client || '', + render: r => (r.client ? esc(r.client) : `${t('ai.unknownClient')}`), + group: { value: r => r.client || t('ai.unknownClient'), label: v => v }, + }, + { + key: 'shared', label: t('ai.shared'), sortable: true, text: true, + sortValue: r => (r.fieldsShared == null ? -1 : r.fieldsShared.length), + render: r => gedeeld(r.fieldsShared), + group: { value: r => deelGroep(r.fieldsShared), label: v => v }, + }, + { + key: 'characters', label: t('ai.characters'), align: 'num', sortable: true, + sortValue: r => r.charactersOut ?? -1, + render: r => (r.charactersOut == null ? '' : fmtInt(r.charactersOut)), + }, + { + key: 'result', label: t('ai.result'), sortable: true, + sortValue: r => r.outcome || '', + render: r => (r.outcome === 'error' + ? `${t(`ai.${r.outcome}`)}` : t(`ai.${r.outcome}`)), + group: { value: r => t(`ai.${r.outcome}`), label: v => v }, + }, + ], + rows: regels, + idKey: 'id', + rowExpansion: (row) => gesprekLade(gesprekken.get(row.id) ?? `
${t('ai.conversation')}
`), + onExpansionMount: async (row, host) => { + if (gesprekken.has(row.id)) { host.innerHTML = gesprekLade(gesprekken.get(row.id)); return; } + try { + gesprekken.set(row.id, gesprekBlok(await aiToolCallMessages(row.id))); + } catch { + host.innerHTML = gesprekLade(`
${t('ai.conversationFail')}
`); + return; + } + host.innerHTML = gesprekLade(gesprekken.get(row.id)); + }, + ...(await tableViewCfg('ai', { key: 'when', dir: 'desc' })), + noneLabel: t('table.none'), + collapsibleGroups: true, + emptyText: t('ai.noRows'), + }); + + live = { el, tabel }; + bindLive(); + await markeerGezien(regels); +} diff --git a/client/pages/assets.js b/client/pages/assets.js new file mode 100644 index 0000000..f3acd9d --- /dev/null +++ b/client/pages/assets.js @@ -0,0 +1,156 @@ +// Generated, do not edit. sha256:7de3dc53eab20f070a6ac8c7162fee1bd0695f724c6a13e314a994181782906e +import { listAssets, listBookableAssets, createAsset, updateAsset, deleteAsset } from '../data/assets.js'; +import { hasMetalAssets } from '../data/metals.js'; +import { tableViewCfg } from '../data/preferences.js'; +import { t } from '../i18n/index.js'; +import * as dialog from '../ui/dialog.js'; +import { esc } from '../ui/escape.js'; +import { ENUMS, CONFIG, balanceCaptureOn } from '../../shared/config.js'; +import { REIKWIJDTE, categorieVoorAsset } from '../tax/constants.js'; +import { renderTable } from '../ui/table.js'; +import { takeRowFocus } from '../ui/row-focus.js'; +import * as metalProducts from './metal-products.js'; +import { capacityCfg } from '../ui/lock.js'; +import { baseCurrency } from '../app-settings.js'; + +let tableApi = null; + +export async function render(el) { + el.innerHTML = ` +
+
`; + await buildTable(el); + if (await hasMetalAssets()) { + const sec = el.querySelector('#mp-section'); + sec.innerHTML = `
`; + metalProducts.mount(sec.querySelector('#mp-tbl')); + } +} + +function kindOpts() { return ENUMS.ASSET_KIND.map(v => ({ value: v, label: t('enums.asset_kind.' + v) })); } +function displayUnitOpts() { return ENUMS.ASSET_DISPLAY_UNIT.map(v => ({ value: v, label: t('enums.displayUnit.' + v) })); } +function taxCategorieOpts(vals = {}) { + const derived = categorieVoorAsset(vals); + return [{ value: '', label: derived + ? t('assets.taxCategoryAutoIs', { waarde: t('enums.tax_category.' + derived) }) + : t('assets.taxCategoryAuto') }, + ...ENUMS.TAX_CATEGORY.filter(v => v !== derived || v === vals.taxCategory) + .map(v => ({ value: v, label: t('enums.tax_category.' + v) }))]; +} +function taxScopeOpts(vals = {}) { + return [{ value: '', label: t('assets.taxScopeAuto') }, + ...ENUMS.TAX_SCOPE.filter(v => v !== REIKWIJDTE.ALL || v === vals.taxScope) + .map(v => ({ value: v, label: t('enums.tax_scope.' + v) }))]; +} +function balanceCaptureOpts() { + return [{ value: '', label: t('assets.taxCategoryAuto') }, + { value: '1', label: t('assets.balanceCaptureOn') }, { value: '0', label: t('assets.balanceCaptureOff') }]; +} +const captureValue = v => (v === '' || v == null ? null : Number(v)); + +async function buildTable(el) { + const columns = [ + { key: 'name', label: t('assets.name'), sortable: true, text: true, + edit: { type: 'text', placeholder: 'Bitcoin' } }, + { key: 'symbol', label: t('assets.symbol'), sortable: true, + render: a => `${esc(a.symbol)}`, + edit: { type: 'text', placeholder: 'BTC' } }, + { key: 'kind', label: t('assets.kind'), sortable: true, + render: a => t('enums.asset_kind.' + a.kind), + group: { value: a => a.kind, label: v => t('enums.asset_kind.' + v) }, + edit: { type: 'select', options: kindOpts, reactive: true } }, + { key: 'unit', label: t('assets.unit'), sortable: true, + render: a => `${esc(a.unit)}${a.subunit ? ` / ${esc(a.subunit)}` : ''}`, + edit: { type: 'text', placeholder: 'BTC / g / ozt' } }, + { key: 'subunit', label: t('assets.subunit'), + render: a => (a.subunit ? esc(a.subunit) : '-'), + edit: { type: 'text', placeholder: 'sat / mg' } }, + { key: 'displayUnit', label: t('assets.displayUnit'), sortable: true, + render: a => { + if (!a.subunit) return `${esc(a.unit)}`; + const subChosen = a.displayUnit === 'subunit'; + const part = (txt, chosen) => chosen ? `${txt}` : `${txt}`; + return `${part(esc(a.unit), !subChosen)} / ${part(esc(a.subunit), subChosen)}`; + }, + edit: { type: 'select', options: displayUnitOpts } }, + { key: 'decimals', label: t('assets.decimals'), align: 'num', sortable: true, + edit: { type: 'number', attrs: { min: 0, step: 1 } } }, + { key: 'taxCategory', label: t('assets.taxCategory'), sortable: true, + render: a => a.taxCategory + ? t('enums.tax_category.' + a.taxCategory) + : `${categorieVoorAsset(a) + ? t('assets.taxCategoryAutoIs', { waarde: t('enums.tax_category.' + categorieVoorAsset(a)) }) + : t('assets.taxCategoryAuto')}`, + group: { value: a => a.taxCategory || '', label: v => v ? t('enums.tax_category.' + v) : t('assets.taxCategoryAuto') }, + edit: { type: 'select', options: taxCategorieOpts } }, + { key: 'taxScope', label: t('assets.taxScope'), sortable: true, + render: a => a.taxScope + ? t('enums.tax_scope.' + a.taxScope) + : `${t('assets.taxScopeAuto')}`, + group: { value: a => a.taxScope || '', label: v => v ? t('enums.tax_scope.' + v) : t('assets.taxScopeAuto') }, + edit: { type: 'select', options: taxScopeOpts } }, + { key: 'balanceCapture', label: t('assets.balanceCapture'), sortable: true, + render: a => a.balanceCapture == null + ? `${t(balanceCaptureOn(a) ? 'assets.balanceCaptureAutoOn' : 'assets.balanceCaptureAutoOff')}` + : a.balanceCapture === 1 + ? `${t('assets.balanceCaptureOn')}` + : `${t('assets.balanceCaptureOff')}`, + edit: { type: 'select', options: balanceCaptureOpts } }, + ]; + + function toPayload(v) { + const symbol = String(v.symbol || '').trim(); + const name = String(v.name || '').trim(); + const unit = String(v.unit || '').trim(); + if (!symbol || !name || !unit) throw new Error(t('assets.fillSymbolNameUnit')); + + if (v.kind === 'metal') { + const M = CONFIG.METALS; + if (!M.UNITS[unit]) { + throw new Error(t('assets.metalUnitUnknown', { eenheden: Object.keys(M.UNITS).join(', ') })); + } + return { + symbol, name, kind: 'metal', + unit, subunit: M.BASE_UNIT, decimals: M.BASE_DECIMALS, + displayUnit: v.displayUnit === 'subunit' ? 'subunit' : 'unit', + taxCategory: v.taxCategory || null, + taxScope: v.taxScope || null, + balanceCapture: captureValue(v.balanceCapture), + }; + } + + const decimals = parseInt(v.decimals, 10); + if (!Number.isInteger(decimals) || decimals < 0) throw new Error(t('assets.fillDecimals')); + const subunit = (v.subunit || '').trim() || null; + const displayUnit = (subunit && v.displayUnit === 'subunit') ? 'subunit' : 'unit'; + return { + symbol, name, kind: v.kind, + unit, subunit, decimals, displayUnit, + taxCategory: v.taxCategory || null, + taxScope: v.taxScope || null, + balanceCapture: captureValue(v.balanceCapture), + }; + } + + const rijen = await listAssets(); + const bookable = async () => (await listBookableAssets()).filter(r => r.symbol !== baseCurrency()).length; + let used = await bookable(); + tableApi = renderTable(el.querySelector('#tbl'), { + columns, rows: rijen, + ...capacityCfg('assets', () => used), + ...(await tableViewCfg('assets', { key: 'symbol', dir: 'asc' })), + emptyText: t('assets.none'), + noneLabel: t('table.none'), + collapsibleGroups: true, + newLabel: t('table.new'), + newDefaults: { kind: 'crypto', decimals: '', displayUnit: 'unit', taxCategory: '', taxScope: '', balanceCapture: '' }, + saveLabel: t('assets.save'), cancelLabel: t('assets.cancel'), + onCreate: async v => { const id = await createAsset(toPayload(v)); used = await bookable(); tableApi.refresh(await listAssets(), { locate: id }); }, + onSave: async (id, v) => { await updateAsset(id, toPayload(v)); tableApi.refresh(await listAssets()); }, + onDelete: async id => { + if (!(await dialog.confirm({ message: t('assets.confirmDelete'), danger: true }))) return; + try { await deleteAsset(id); used = await bookable(); tableApi.refresh(await listAssets()); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); } + }, + }); + tableApi.focusRow(takeRowFocus('assets')); +} diff --git a/client/pages/connections.js b/client/pages/connections.js new file mode 100644 index 0000000..ced6cdd --- /dev/null +++ b/client/pages/connections.js @@ -0,0 +1,308 @@ +// Generated, do not edit. sha256:1f57286ac82ef2aadfb0cdfa809fcb1f771cf74bba815b7e579bbb106a986dd4 +import { t } from '../i18n/index.js'; +import { listConnections, saveConnection, setActiveConnection, deleteConnection, recordConnectionStatus } from '../data/connections.js'; +import { localDateTimeNl } from '../../shared/time.js'; +import { CONFIG, ENUMS } from '../../shared/config.js'; +import { originAllowedByCsp } from '../../shared/csp.js'; +import * as dialog from '../ui/dialog.js'; +import * as lnd from '../lightning/lnd.js'; +import * as market from '../market-service.js'; +import { appSetting, setAppSetting } from '../app-settings.js'; +import { clearSpotCache } from '../price/providers.js'; +import { fmtInt } from '../ui/format.js'; +import { esc } from '../ui/escape.js'; +import { locked } from '../license.js'; +import { serviceState } from '../data/integration-log.js'; +import { lastFailureHtml, openFailureLog } from '../integrations/failure-log.js'; + +const KINDS = ['btc_explorer', 'lnd']; +const KIND_META = { + btc_explorer: { label: 'connections.kindBtcExplorer', usedBy: 'connections.usedByBtcExplorer' }, + lnd: { label: 'connections.kindLnd', usedBy: 'connections.usedByLnd' }, +}; + +const explorerUrlsAtLoad = Promise.all([listConnections('btc_explorer')]) + .then(([rows]) => rows.map(c => c.url)).catch(() => []); + +const liveResult = new Map(); +const fadeTimers = new Map(); + +function clearFade(kind) { + clearTimeout(fadeTimers.get(kind)); + fadeTimers.delete(kind); +} + +function scheduleFade(el, kind) { + clearFade(kind); + fadeTimers.set(kind, setTimeout(() => { + if (!isOnsPaneel(el)) { liveResult.delete(kind); return; } + const host = el.querySelector(`[data-result="${kind}"]`); + if (host) { + host.style.transitionDuration = `${CONFIG.CONNECTIONS.RESULT_FADE_MS}ms`; + host.classList.add('is-fading'); + } + fadeTimers.set(kind, setTimeout(() => { + liveResult.delete(kind); + if (isOnsPaneel(el)) paintResult(el, kind); + }, CONFIG.CONNECTIONS.RESULT_FADE_MS)); + }, CONFIG.CONNECTIONS.RESULT_HOLD_MS)); +} + +async function testConnection(conn) { + if (conn.kind === 'btc_explorer') { + if (!originAllowedByCsp(conn.url, location.origin, await explorerUrlsAtLoad)) { + throw new Error(t('connections.cspBlocked')); + } + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), CONFIG.CONNECTIONS.TEST_TIMEOUT_MS); + try { + const res = await fetch(`${conn.url}/api/blocks/tip/height`, { signal: ctl.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const height = Number((await res.text()).trim()); + if (!Number.isFinite(height) || height <= 0) throw new Error(t('connections.badResponse')); + return `${t('connections.blockHeight')} ${fmtInt(height)}`; + } finally { clearTimeout(timer); } + } + if (conn.kind === 'lnd') { + const r = await lnd.testLnd(conn.id); + const sync = t(r.synced ? 'connections.lndSynced' : 'connections.lndNotSynced'); + return `${r.alias ? `${esc(r.alias)} · ` : ''}${sync} · ${t('connections.blockHeight')} ${fmtInt(r.blockHeight)}`; + } + throw new Error(t('connections.testUnsupported')); +} + +async function runTest(el, conn, { footer = false } = {}) { + if (footer) { clearFade(conn.kind); liveResult.set(conn.kind, { pending: true }); paintResult(el, conn.kind); } + try { + const detail = await testConnection(conn); + await recordConnectionStatus(conn.id, true); + if (footer) liveResult.set(conn.kind, { ok: true, text: `${esc(conn.name)}: ${t('connections.statusOk')} · ${detail}` }); + } catch (e) { + await recordConnectionStatus(conn.id, false); + if (footer) liveResult.set(conn.kind, { ok: false, text: `${esc(conn.name)}: ${t('connections.statusFail')}: ${esc(e.message)}` }); + } + if (!isOnsPaneel(el)) return; + draw(el); + if (footer) scheduleFade(el, conn.kind); +} + +function paintResult(el, kind) { + const host = el.querySelector(`[data-result="${kind}"]`); + if (!host) return; + host.style.transitionDuration = ''; + const r = liveResult.get(kind); + if (!r) { host.className = 'conn-result'; host.innerHTML = ''; return; } + if (r.pending) { host.className = 'conn-result'; host.textContent = t('connections.testing'); return; } + host.className = `conn-result ${r.ok ? 'is-ok' : 'is-fail'}`; + host.innerHTML = r.text; +} + +function dotHtml(c) { + const cls = c.lastStatus === 'ok' ? ' dot-ok' : c.lastStatus === 'fail' ? ' dot-fail' : ''; + const label = c.lastStatus === 'ok' ? t('connections.statusOk') + : c.lastStatus === 'fail' ? t('connections.statusFail') : t('connections.notTested'); + const when = c.lastCheckedAt ? ` · ${t('connections.lastChecked')} ${localDateTimeNl(c.lastCheckedAt)}` : ''; + return ``; +} + +function tileHtml(c) { + const hover = c.isSystem ? '' : ` + + + `; + return `
+ ${dotHtml(c)} +
${esc(c.name)}
+
${esc(c.url)}
+ ${hover} +
`; +} + +const KIND_FEATURE = Object.freeze({ btc_explorer: 'ownMempool', lnd: 'lightning', nwc: 'lightning' }); + +async function frameHtml(kind) { + const list = await listConnections(kind); + const opSlot = locked(KIND_FEATURE[kind]); + if (opSlot && !list.length) return ''; + const tiles = list.map(tileHtml).join(''); + return `
+
${t(KIND_META[kind].label)}
+
${t(KIND_META[kind].usedBy)}
+
${tiles}
+
+ ${opSlot ? '' : ``} + +
+
+
`; +} + +const MARKET = 'market'; + +let marketStatus = null; + +function tokenLine(st) { + if (st.token_source === 'license') return t('connections.marketTokenLicense', { tier: t(`license.level_${st.tier}`) }); + if (st.token_source === 'manual') return t('connections.marketTokenManual'); + return t(st.can_register ? 'connections.marketTokenPending' : 'connections.marketTokenNoLicense'); +} + +async function marketFrameHtml() { + const st = await market.status(); + let shown = marketStatus; + if (!shown && st.token_set) { + try { + const s = await serviceState(); + if (s.ok != null) shown = { ok: s.ok, at: Date.parse(s.at) }; + } catch { } + } + const cls = !st.token_set || !shown ? '' : shown.ok ? ' dot-ok' : ' dot-fail'; + const label = !st.token_set ? t('connections.marketTokenUnset') + : !shown ? t('connections.notTested') : t(shown.ok ? 'connections.statusOk' : 'connections.statusFail'); + const when = shown ? ` · ${t('connections.lastChecked')} ${localDateTimeNl(new Date(shown.at).toISOString())}` : ''; + const lastFailure = st.token_set ? await lastFailureHtml('service').catch(() => '') : ''; + const url = st.override ? `${st.url} (${t('connections.marketOverrideUrl')})` : ''; + const stand = [tokenLine(st), t('connections.marketSpotShort_' + appSetting('market_spot'))].join(' · '); + return `
+
${t('connections.kindMarket')}
+
${t('connections.usedByMarket')}
+
+
+ +
Atreides Services
+ ${url ? `
${esc(url)}
` : ''} +
+
+
${esc(stand)}
+ ${lastFailure} +
+ +
+
+
`; +} + +async function runMarketTest(el, { footer = false } = {}) { + if (footer) { clearFade(MARKET); liveResult.set(MARKET, { pending: true }); paintResult(el, MARKET); } + try { + const r = await market.test(); + marketStatus = { ok: true, at: Date.now() }; + const detail = t('connections.marketTestOk', { app: r.app, version: r.version || '', n: fmtInt(r.instruments) }); + if (footer) liveResult.set(MARKET, { ok: true, text: `${t('connections.statusOk')} · ${esc(detail)}` }); + } catch (e) { + marketStatus = { ok: false, at: Date.now() }; + if (footer) liveResult.set(MARKET, { ok: false, text: `${t('connections.statusFail')}: ${esc(e.message)}` }); + } + if (!isOnsPaneel(el)) return; + await draw(el); + if (footer) scheduleFade(el, MARKET); +} + +async function marketDialog(el) { + const res = await dialog.form({ + title: t('connections.marketDialogTitle'), + confirmLabel: t('connections.save'), + fields: [ + { key: 'spot', label: t('connections.marketSpot'), type: 'select', value: appSetting('market_spot'), + options: ENUMS.MARKET_SPOT.map(v => ({ value: v, label: t('connections.marketSpot_' + v) })) }, + ], + }); + if (!res) return; + if (res.spot && res.spot !== appSetting('market_spot')) { + await setAppSetting('market_spot', res.spot); + clearSpotCache(); + } + await draw(el); + runMarketTest(el, { footer: true }); +} + +const byId = async id => (await listConnections()).find(c => c.id === Number(id)); +const activeOf = async kind => (await listConnections(kind)).find(c => c.isActive); + +async function profileDialog(el, kind, c = null) { + const fields = [ + { key: 'name', label: t('connections.name'), value: c ? c.name : '' }, + { key: 'url', label: t(kind === 'lnd' ? 'connections.lndUrl' : 'connections.url'), + value: c ? c.url : '', placeholder: kind === 'lnd' ? '10.8.0.1:8080' : 'https://…' }, + ]; + if (kind === 'lnd') { + const keep = c ? t('connections.secretKeepHint') : ''; + fields.push( + { key: 'macaroon', label: t('connections.lndMacaroon'), type: 'password', placeholder: keep || '0201036c6e64…' }, + { key: 'tls_cert', label: t('connections.lndTlsCert'), type: 'textarea', placeholder: keep || '-----BEGIN CERTIFICATE-----' }, + ); + } + const res = await dialog.form({ + title: c ? t('connections.editProfile') : t('connections.newProfile'), + confirmLabel: t('connections.save'), + fields, + }); + if (!res) return; + if (kind === 'lnd' && res.url && !/^[a-z]+:\/\//i.test(res.url.trim())) res.url = `https://${res.url.trim()}`; + const id = await saveConnection({ id: c ? c.id : null, kind, name: res.name, url: res.url }); + if (kind === 'lnd') await lnd.setConfig(id, { url: res.url, macaroon: res.macaroon, tls_cert: res.tls_cert }); + draw(el); +} + +const isOnsPaneel = (el) => !!el && !!el.querySelector('[data-result]'); + +async function draw(el) { + const [marketFrame, frames] = await Promise.all([marketFrameHtml(), Promise.all(KINDS.map(frameHtml))]); + el.innerHTML = marketFrame + frames.join(''); + for (const kind of [MARKET, ...KINDS]) paintResult(el, kind); + + el.onclick = async e => { + try { + if (e.target.closest('[data-market-edit]')) { await marketDialog(el); return; } + if (e.target.closest('[data-market-test]')) { await runMarketTest(el, { footer: true }); return; } + const ilog = e.target.closest('[data-ilog-open]'); + if (ilog) { await openFailureLog(ilog.dataset.ilogOpen); return; } + const edit = e.target.closest('[data-edit]'); + if (edit) { const c = await byId(edit.dataset.edit); if (c) await profileDialog(el, c.kind, c); return; } + const del = e.target.closest('[data-del]'); + if (del) { + const c = await byId(del.dataset.del); + if (!c) return; + if (!(await dialog.confirm({ message: t('connections.confirmDelete'), danger: true }))) return; + if (c.kind === 'lnd') await lnd.purgeConfig(c.id); + await deleteConnection(c.id); + draw(el); + if (c.isActive) { const a = await activeOf(c.kind); if (a) runTest(el, a); } + return; + } + const nieuw = e.target.closest('[data-new]'); + if (nieuw) { + await profileDialog(el, nieuw.dataset.new); + return; + } + const testBtn = e.target.closest('[data-test-active]'); + if (testBtn) { const a = await activeOf(testBtn.dataset.testActive); if (a) await runTest(el, a, { footer: true }); return; } + const tile = e.target.closest('[data-select]'); + if (tile) { + const c = await byId(tile.dataset.select); + if (!c || c.isActive) return; + await setActiveConnection(c.id); + draw(el); + runTest(el, c); + return; + } + } catch (err) { dialog.alert({ title: t('dialog.error'), message: err.message }); } + }; + el.onkeydown = e => { + if ((e.key === 'Enter' || e.key === ' ') && e.target.matches && e.target.matches('[data-select], [data-market-edit]')) { + e.preventDefault(); + e.target.click(); + } + }; +} + +export async function render(el) { + for (const kind of [MARKET, ...KINDS]) clearFade(kind); + liveResult.clear(); + await draw(el); + const st = await market.status(); + if (st.token_set || st.can_register) runMarketTest(el); + for (const kind of KINDS) { + for (const c of await listConnections(kind)) runTest(el, c); + } +} diff --git a/client/pages/conversion-bitcoin.js b/client/pages/conversion-bitcoin.js new file mode 100644 index 0000000..c546412 --- /dev/null +++ b/client/pages/conversion-bitcoin.js @@ -0,0 +1,121 @@ +// Generated, do not edit. sha256:1c9c0ff79637180d3780a7e1dd1e59dbb03533899d9a66468c9cdf709d9ee0fe +import { t, localeTag } from '../i18n/index.js'; +import { listAssets } from '../data/assets.js'; +import { liveSpotFor, basePriceOf } from '../price/providers.js'; +import { fmtMoney, fmtInt, currencySymbol, currencyName, parseLocaleNumber } from '../ui/format.js'; +import { baseCurrency } from '../app-settings.js'; + +const TO_BTC = { btc: 1, mbtc: 1e-3, bit: 1e-6, sat: 1e-8, msat: 1e-11 }; +const UNITS = ['fiat', 'btc', 'mbtc', 'bit', 'sat', 'msat']; + +export const title = () => t('conversion.title'); + +const parseNumber = str => parseLocaleNumber(str) || 0; + +function formatUnit(unit, value) { + if (value === 0) return ''; + const nl = localeTag(); + switch (unit) { + case 'fiat': return value.toLocaleString(nl, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + case 'btc': return value.toLocaleString(nl, { maximumFractionDigits: 8 }); + case 'mbtc': return value.toLocaleString(nl, { maximumFractionDigits: 5 }); + case 'bit': return value.toLocaleString(nl, { maximumFractionDigits: 2 }); + case 'sat': + case 'msat': return Math.round(value).toLocaleString(nl); + } +} + +export async function render(el) { + const n = fmtInt; + el.innerHTML = ` + +
+
+
${t('conversion.btcRate')}
+
€ -
+
${t('conversion.loading')}
+
+
+ +
+ ${field('fiat', t('conversion.fiat'), '€ EUR', t('conversion.fiatHint'), true)} + ${field('btc', 'Bitcoin', '₿ BTC', t('conversion.btcHint'))} + ${field('mbtc', 'Millibitcoin', 'mBTC', `1 BTC = ${n(1e3)} mBTC`)} + ${field('bit', 'Bit (µBTC)', 'bit', `1 BTC = ${n(1e6)} bits`)} + ${field('sat', 'Satoshi', 'sat', `1 BTC = ${n(1e8)} sats`)} + ${field('msat', 'Millisatoshi', 'msat', `1 sat = ${n(1e3)} msats · Lightning`)} +
+ +
+
${t('conversion.hierarchy')}
+
+ 1 BTC = ${n(1e3)} mBTC = ${n(1e6)} bits = ${n(1e8)} sats = ${n(1e11)} msats
+ 1 mBTC = ${n(1e3)} bits = ${n(1e5)} sats
+ 1 bit = 100 sats · 1 sat = ${n(1e3)} msats +
+
${t('conversion.satNote')}
+
+ + `; + + const q = id => el.querySelector('#' + id); + const currency = baseCurrency(); + let livePrice = null; + + function updateAll(sourceUnit, sourceValue) { + const rate = livePrice; + let btcValue; + if (sourceUnit === 'fiat') { + if (!rate) return; + btcValue = sourceValue / rate; + } else { + btcValue = sourceValue * TO_BTC[sourceUnit]; + } + UNITS.forEach(unit => { + if (unit === sourceUnit) return; + const value = unit === 'fiat' ? (rate ? btcValue * rate : 0) : btcValue / TO_BTC[unit]; + q(unit).value = formatUnit(unit, value); + }); + } + function recalcFromFiat() { + const f = q('fiat'); + if (f.value) updateAll('fiat', parseNumber(f.value)); + } + function renderPrice() { + const rate = livePrice; + q('price-display').textContent = rate ? fmtMoney(rate, currency, { decimals: 0 }) : `${currencySymbol(currency)} -`; + } + function applyCurrencyUI() { + q('fiat-name').textContent = currencyName(currency); + q('fiat-badge').textContent = `${currencySymbol(currency)} ${currency}`; + renderPrice(); + recalcFromFiat(); + } + + UNITS.forEach(unit => q(unit).addEventListener('input', function () { + updateAll(unit, parseNumber(this.value)); + })); + applyCurrencyUI(); + const btc = (await listAssets()).find(a => a.symbol === 'BTC'); + Promise.resolve(btc ? liveSpotFor(btc) : null).then(p => { + if (!p) { q('price-meta').textContent = t('conversion.rateUnavailable'); return; } + livePrice = basePriceOf(p); + renderPrice(); recalcFromFiat(); + const now = new Date().toLocaleTimeString(localeTag(), { hour: '2-digit', minute: '2-digit' }); + q('price-meta').textContent = `${t('conversion.updatedAt')} ${now}`; + }).catch(() => { q('price-meta').textContent = t('conversion.rateUnavailable'); }); +} + +function field(id, name, badge, hint, isFiat = false) { + const nameAttr = isFiat ? ' id="fiat-name"' : ''; + const badgeAttr = isFiat ? ' id="fiat-badge"' : ''; + return ` + `; +} diff --git a/client/pages/event-accounts.js b/client/pages/event-accounts.js new file mode 100644 index 0000000..96ff1a2 --- /dev/null +++ b/client/pages/event-accounts.js @@ -0,0 +1,428 @@ +// Generated, do not edit. sha256:aff33ec09f0738c89d055fc48911d73d2e952b3818493f0f70824b837ab7c4bf +import { listAccounts } from '../data/accounts.js'; +import { listAssets } from '../data/assets.js'; +import { accountHoldings } from '../data/positions.js'; +import { listOpenValuationDiffs, listValuations } from '../data/valuations.js'; +import { mountSparkline } from '../ui/sparkline.js'; +import { activityItems, heartbeat } from '../ui/activity.js'; +import { setRowFocus } from '../ui/row-focus.js'; +import { listTrades, listTransfers } from '../data/events.js'; +import { listAllSchedules, listInterest } from '../data/interest-schedules.js'; +import { scheduleForecast } from '../data/interest-credits.js'; +import { latestPricePoint } from '../data/price-history.js'; +import { basePriceOf } from '../price/providers.js'; +import { loadFiat } from '../ui/fiat.js'; +import { t } from '../i18n/index.js'; +import { assetLabelHtml, byKindAndLabel } from '../ui/asset-label.js'; +import { fmtBase, fmtUnit, fromSubunit, isBaseAsset, isMetal, val } from '../ui/format.js'; +import { esc } from '../ui/escape.js'; +import { emptyStateHtml } from '../ui/empty-state.js'; +import { create as createTabs } from '../ui/tabs.js'; +import { create as createFilter, resolveSelection } from '../ui/filter.js'; +import { getPref, setPref } from '../data/preferences.js'; +import { setPageHint } from '../ui/page-hint.js'; +import * as dialog from '../ui/dialog.js'; +import * as loading from '../ui/loading.js'; +import { localDate, localDateNl, localTime } from '../../shared/time.js'; +import { ACCOUNT_ASSET_KINDS, CONFIG, ENUMS, EVENTS_PAGE, TARGETS, balanceCaptureOn, targetHash } from '../../shared/config.js'; +import * as valuations from './valuations.js'; +import { recordBalance } from './valuations.js'; +import * as trades from './trades.js'; +import * as transfers from './transfers.js'; +import * as interest from './interest.js'; +import * as reservations from './reservations.js'; +import { listReservations } from '../data/reservations.js'; +import { reservationAccount } from '../actions/reservation-book.js'; + +const TABS = EVENTS_PAGE.TABS; +const DETAIL = [ + [TABS.VALUATIONS, valuations], + [TABS.TRADES, trades], + [TABS.TRANSFERS, transfers], + [TABS.INTEREST, interest], + [TABS.RESERVATIONS, reservations], +]; +let lastDetailTab = TABS.VALUATIONS; + +export async function render(el) { + el.innerHTML = '
'; + const host = el.querySelector('[data-acc-cards]'); + loading.show(host, 'cards', { count: 3 }); + setPageHint(el, { title: t('eventAccounts.hintTitle'), rows: [ + t('eventAccounts.hintBalance'), t('eventAccounts.hintDetail'), t('eventAccounts.hintInterest'), t('reservations.hint'), + t('eventAccounts.hintActivity'), t('eventAccounts.hintCapture'), + { heading: t('nav.valuations') }, t('valuations.rateNote'), + { heading: t('nav.trades') }, t('trades.fiatNote'), + { heading: t('nav.transfers') }, t('transfers.fiatNote'), + { heading: t('nav.interest') }, t('interest.importNote'), + ] }); + try { + await renderCards(host); + } catch (err) { + loading.error(host, err && err.message); + } +} + +async function loadModel() { + const none = () => []; + const [accounts, assets, open, schedules, interestRows, trades, transfers, valuationRows, reservationRows] = await Promise.all([ + listAccounts(), listAssets(), listOpenValuationDiffs(), + listAllSchedules().catch(none), listInterest().catch(none), + listTrades().catch(none), listTransfers().catch(none), listValuations().catch(none), + listReservations().catch(none), + ]); + const startYear = Number(await getPref('app', 'start_year').catch(() => null)) || CONFIG.START_YEAR.AUTO; + const fromIso = startYear !== CONFIG.START_YEAR.AUTO ? `${startYear}-01-01T12:00:00Z` : null; + const events = { accounts, schedules, interest: interestRows, trades, transfers, valuations: valuationRows, + reservations: reservationRows, interestDue: [], fromIso }; + const holdings = await accountHoldings(accounts.map(a => a.id)); + const assetById = new Map(assets.map(a => [a.id, a])); + + const held = [...new Set(holdings.map(h => h.assetId))].map(id => assetById.get(id)).filter(Boolean); + const spot = await loadFiat(held.filter(a => !isBaseAsset(a))).catch(() => new Map()); + const prices = new Map(); + for (const a of held) { + if (isBaseAsset(a)) { prices.set(a.id, 1); continue; } + let price = basePriceOf(spot.get(a.id)); + if (price == null) price = (await latestPricePoint(a.id).catch(() => null))?.fiatPrice ?? null; + prices.set(a.id, price); + } + + const openPer = new Map(); + for (const v of open) if (v.accountId != null) openPer.set(v.accountId, (openPer.get(v.accountId) || 0) + 1); + + const today = localDate(new Date().toISOString()); + const running = schedules.filter(s => !s.endsOn || s.endsOn >= today); + const forecasts = new Map(await Promise.all(running.filter(s => s.rateKind === 'fixed') + .map(async s => [s.id, await scheduleForecast(s).catch(() => null)]))); + events.interestDue = running.flatMap(s => (forecasts.get(s.id)?.upcoming || []) + .map(p => ({ accountId: s.accountId, periodEnd: p.periodEnd }))); + const interestOf = id => { + const own = running.filter(s => s.accountId === id); + const booked = interestRows.filter(r => r.toAccountId === id); + const next = own.map(s => { + const p = forecasts.get(s.id)?.upcoming[0]; + return p ? { periodEnd: p.periodEnd, amount: p.projectedSub, meta: payoutMeta(s) } : null; + }).filter(Boolean); + return { agreements: own.length, booked: booked.length, lastAt: booked[0]?.occurredAt ?? null, next }; + }; + + const cards = accounts.map(account => { + const lines = holdings.filter(h => h.accountId === account.id).map(h => { + const asset = assetById.get(h.assetId); + const price = prices.get(h.assetId); + const value = asset && price != null ? (h.saldo / Math.pow(10, asset.decimals)) * price : null; + return { ...h, asset, value }; + }).filter(l => l.asset).sort((a, b) => byKindAndLabel(a.asset, b.asset)); + const total = lines.reduce((s, l) => s + (l.value ?? 0), 0); + const own = reservationRows.filter(r => reservationAccount(r).id === account.id); + return { account, lines, total, priced: lines.every(l => l.value != null), open: openPer.get(account.id) || 0, + interest: interestOf(account.id), reservations: { n: own.length, nextAt: own[0]?.occurredAt ?? null } }; + }); + + const kindRank = k => { const i = ENUMS.ACCOUNT_KIND.indexOf(k); return i < 0 ? ENUMS.ACCOUNT_KIND.length : i; }; + return { + events, + cards: cards + .filter(c => c.account.isActive || c.lines.length) + .sort((a, b) => kindRank(a.account.kind) - kindRank(b.account.kind) || b.total - a.total + || a.account.name.localeCompare(b.account.name)), + }; +} + +function dayLabel(day, x, names) { + const date = localDateNl(new Date(x).toISOString()); + if (!day?.length) return date; + const counts = new Map(); + for (const it of day) counts.set(it.kind, (counts.get(it.kind) || 0) + 1); + const what = [...counts].map(([k, n]) => `${t('eventAccounts.act.' + k)}${n > 1 ? ` ${n}×` : ''}`).join(', '); + const who = names ? [...new Set(day.map(it => names.get(it.accountId)))].join(', ') : ''; + return `${date}: ${what}${who ? ` (${who})` : ''}`; +} + +const MARK_VARIANT = { balance: 'balance', agreementStart: 'agreement', agreementEnd: 'agreement', + interestDue: 'agreement', reservation: 'reservation' }; + +function mountActivity(host, events, accountIds, { onPick = null, nowText = null } = {}) { + const nowIso = new Date().toISOString(); + const items = activityItems({ accountIds, ...events }); + const beat = heartbeat(items, nowIso, events.fromIso); + if (!beat) { host.innerHTML = ''; return false; } + const names = accountIds.length > 1 ? new Map(events.accounts.map(a => [a.id, a.name])) : null; + const d = localDate(nowIso); + const todayX = Date.UTC(Number(d.slice(0, 4)), Number(d.slice(5, 7)) - 1, Number(d.slice(8, 10))); + const label = x => { + const day = dayLabel(beat.byDay.get(x), x, names); + return nowText && x === todayX ? `${day} · ${nowText}` : day; + }; + const marks = items.filter(it => it.dir === 0) + .map(it => ({ x: it.x, variant: MARK_VARIANT[it.kind] || 'account', label: label(it.x) })); + if (nowText) marks.push({ x: todayX, variant: 'now', label: label(todayX) }); + const peak = Math.max(1, ...beat.points.map(p => Math.abs(p[1]))); + mountSparkline(host, beat.points, { + yRange: [-peak, peak], + markers: marks, + snap: items.filter(it => it.dir !== 0).map(it => it.x), + formatValue: (_v, x) => label(x), + onPick: onPick && (x => { + const day = beat.byDay.get(x) || []; + const hit = day.find(it => it.table && it.dir !== 0) || day.find(it => it.table); + if (hit) onPick(hit); + }), + }); + return true; +} + +const PREF_SCOPE = EVENTS_PAGE.ROUTE; +const PREF_KINDS = 'accountKinds'; +const PREF_EMPTY = 'accountsEmpty'; + +async function renderCards(host) { + const { cards, events } = await loadModel(); + if (!cards.length) { + host.innerHTML = emptyStateHtml(t('eventAccounts.none'), { + why: t('eventAccounts.noneWhy'), href: targetHash(TARGETS.ACCOUNTS), linkText: t('eventAccounts.manage'), + }); + return; + } + const kinds = ENUMS.ACCOUNT_KIND.filter(k => cards.some(c => c.account.kind === k)); + const items = kinds.map(k => ({ id: k, label: t('enums.account_kind.' + k) })); + let selected = resolveSelection({ saved: await getPref(PREF_SCOPE, PREF_KINDS, null).catch(() => null), items }); + let showEmpty = (await getPref(PREF_SCOPE, PREF_EMPTY, true).catch(() => true)) !== false; + const hasEmpty = cards.some(c => !c.lines.length); + + const open = id => openDetail(id).then(() => { if (host.isConnected) renderCards(host); }); + const draw = () => { + const shown = cards.filter(c => selected.has(c.account.kind) && (showEmpty || c.lines.length)); + host.innerHTML = `
+
${hasEmpty + ? `
` : ''}
+
${shown.map(c => cardHtml(c)).join('')}
`; + host.querySelectorAll('[data-acc-spark]').forEach(s => mountActivity(s, events, [Number(s.dataset.accSpark)])); + const total = shown.reduce((s, c) => s + c.total, 0); + const partial = shown.some(c => c.lines.length && !c.priced); + const pulse = host.querySelector('[data-acc-pulse]'); + pulse.hidden = !mountActivity(pulse, events, shown.map(c => c.account.id), { + nowText: t(partial ? 'eventAccounts.nowTotalPartial' : 'eventAccounts.nowTotal', { bedrag: fmtBase(total) }), + }); + createFilter(host.querySelector('[data-filter]'), { + items, selected, + onChange: sel => { selected = sel; setPref(PREF_SCOPE, PREF_KINDS, [...sel]).catch(() => {}); draw(); }, + }); + host.querySelector('[data-empty-toggle]')?.addEventListener('click', () => { + showEmpty = !showEmpty; + setPref(PREF_SCOPE, PREF_EMPTY, showEmpty).catch(() => {}); + draw(); + }); + host.querySelectorAll('[data-account]').forEach(card => { + const id = Number(card.dataset.account); + card.addEventListener('click', () => open(id)); + card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(id); } }); + }); + }; + draw(); +} + +function lineHtml(l, pick, chosen) { + const value = isBaseAsset(l.asset) ? '' : (l.value != null ? val(fmtBase(l.value)) : t('eventAccounts.noPrice')); + const anchor = l.anchor + ? t('eventAccounts.lastBalance', { datum: localDateNl(l.anchor.rateAt) }) + : t('eventAccounts.fromBookings'); + const inner = ` +
${assetLabelHtml(l.asset)}${val(fmtUnit(l.saldo, l.asset))}
+
${anchor}${value}
`; + return pick + ? `
+ + ${anchorBtnHtml(l, 'acc-anchor-btn')} +
` + : `
${inner}
`; +} + +const anchorBtnHtml = (l, cls = '') => (balanceCaptureOn(l.asset) + ? `` + : ''); + +function badgesHtml(c) { + return [ + `${esc(t('enums.account_kind.' + c.account.kind))}`, + c.account.isActive ? '' : `${t('eventAccounts.closed')}`, + c.open ? `${t('eventAccounts.openGaps', { n: c.open })}` : '', + ].join(''); +} + +const totalHtml = c => (c.lines.length && c.priced ? `
${val(fmtBase(c.total))}
` : ''); + +const payoutMeta = s => ({ decimals: s.payout_decimals, unit: s.payout_unit, subunit: s.payout_subunit, displayUnit: s.payout_display_unit }); + +function interestHtml(c) { + const { agreements, booked, lastAt, next } = c.interest; + const parts = [ + agreements === 1 ? t('eventAccounts.interestAgreement') + : agreements > 1 ? t('eventAccounts.interestAgreements', { n: agreements }) : '', + booked ? t('eventAccounts.interestBooked', { n: booked, datum: localDateNl(lastAt) }) : '', + ...next.map(n => t('eventAccounts.interestNext', { datum: localDateNl(n.periodEnd), bedrag: fmtUnit(n.amount, n.meta) })), + ].filter(Boolean); + return parts.length + ? `
${parts.map(p => `${esc(p)}`).join(' · ')}
` : ''; +} + +function reservationHtml(c) { + const { n, nextAt } = c.reservations; + if (!n) return ''; + const parts = [n === 1 ? t('reservations.cardOne') : t('reservations.cardMany', { n }), + t('reservations.cardNext', { datum: localDateNl(nextAt) })]; + return `
${parts.map(p => `${esc(p)}`).join(' · ')}
`; +} + +function cardHtml(c) { + const { account } = c; + const lines = c.lines.length + ? c.lines.map(l => lineHtml(l, false)).join('') + : `
${t('eventAccounts.empty')}
`; + return `
+
+ ${esc(account.name)}${badgesHtml(c)} +
+ ${totalHtml(c)} + ${interestHtml(c)} + ${reservationHtml(c)} +
${lines}
+
+
`; +} + +function summaryHtml(c, assetId) { + if (!c.lines.length) return `
${t('eventAccounts.empty')}
`; + if (c.lines.length === 1) { + const l = c.lines[0]; + const anchor = l.anchor + ? t('eventAccounts.lastBalance', { datum: localDateNl(l.anchor.rateAt) }) + : t('eventAccounts.fromBookings'); + return `
+ ${assetLabelHtml(l.asset)}${anchor} + ${anchorBtnHtml(l)} +
`; + } + return c.lines.map(l => lineHtml(l, true, l.assetId === assetId)).join(''); +} +const asideHtml = c => `${badgesHtml(c)}${totalHtml(c)}`; + +const unitLabel = a => { + if (isMetal(a)) return ''; + const u = a.displayUnit === 'subunit' && a.subunit ? a.subunit : a.unit; + return u ? ` (${u})` : ''; +}; + +async function recordBalanceFor(line, accountId) { + const now = new Date().toISOString(); + const res = await dialog.form({ + title: t('eventAccounts.recordBalanceTitle', { asset: line.asset.symbol }), + fields: [ + { key: 'valuedOn', label: t('valuations.colDate'), type: 'date', value: localDate(now) }, + { key: '_time', label: t('valuations.colTime'), type: 'time', value: localTime(now) }, + { key: 'balance', label: `${t('valuations.colBalance')}${unitLabel(line.asset)}`, type: 'number', + value: String(fromSubunit(line.saldo, line.asset)) }, + ], + confirmLabel: t('eventAccounts.recordBalance'), + }); + if (!res) return false; + try { + await recordBalance({ ...res, accountId, assetId: line.assetId }); + return true; + } catch (err) { + await dialog.alert({ title: t('dialog.error'), message: err.message }); + return false; + } +} + +async function tabFor(line, accountId) { + if (line.anchor) return TABS.VALUATIONS; + const [tr, tf, ir] = await Promise.all([listTrades(), listTransfers(), listInterest()]).catch(() => [[], [], []]); + const own = r => r.assetId === line.assetId; + if (tr.some(r => own(r) && (r.type === 'buy' ? r.toAccountId : r.fromAccountId) === accountId)) return TABS.TRADES; + if (tf.some(r => own(r) && (r.fromAccountId === accountId || r.toAccountId === accountId))) return TABS.TRANSFERS; + if (ir.some(r => own(r) && r.toAccountId === accountId)) return TABS.INTEREST; + return null; +} + +async function openDetail(accountId) { + const model = await loadModel(); + const c = model.cards.find(x => x.account.id === accountId); + if (!c) return; + let assetId = null; + let tabs = null; + const closed = dialog.detail({ + title: c.account.name, size: 'xl', + html: `
+
+
`, + }); + const root = document.querySelector('.dialog-overlay .acc-detail'); + if (!root) return closed; + const summary = root.querySelector('[data-acc-summary]'); + const activity = root.querySelector('[data-acc-activity]'); + const aside = document.querySelector('.dialog-overlay [data-dialog-aside]'); + + const pickEvent = it => { + if (assetId != null && it.assetId !== assetId) { assetId = null; draw(); } + setRowFocus(it.table, it.id); + if (tabs.active !== it.table) tabs.select(it.table); + else tabs.redraw(); + }; + + let current = c; + let events = model.events; + const draw = () => { + summary.innerHTML = reservationHtml(current) + summaryHtml(current, assetId); + if (aside) aside.innerHTML = asideHtml(current); + }; + const drawActivity = () => { mountActivity(activity, events, [accountId], { onPick: pickEvent }); }; + const refreshSummary = async () => { + const fresh = await loadModel().catch(() => null); + const card = fresh?.cards.find(x => x.account.id === accountId); + if (card) { current = card; events = fresh.events; } + if (summary.isConnected) { draw(); drawActivity(); } + }; + draw(); + drawActivity(); + summary.addEventListener('click', async e => { + const anchorBtn = e.target.closest('[data-anchor-asset]'); + if (anchorBtn) { + const line = current.lines.find(l => l.assetId === Number(anchorBtn.dataset.anchorAsset)); + if (line && await recordBalanceFor(line, accountId)) { + assetId = line.assetId; + await refreshSummary(); + if (tabs.active !== TABS.VALUATIONS) tabs.select(TABS.VALUATIONS); + else tabs.redraw(); + } + return; + } + const b = e.target.closest('[data-pick-asset]'); + if (!b) return; + const id = Number(b.dataset.pickAsset); + assetId = assetId === id ? null : id; + draw(); + const line = current.lines.find(l => l.assetId === id); + const tab = assetId != null && line ? await tabFor(line, accountId) : null; + if (tab && tab !== tabs.active) tabs.select(tab); + else { tabs.redraw(); refreshSummary(); } + }); + + const assetKinds = [...new Set([...(ACCOUNT_ASSET_KINDS[c.account.kind] || ENUMS.ASSET_KIND), + ...c.lines.map(l => l.asset.kind)])]; + tabs = createTabs(root.querySelector('[data-acc-tabs]'), { + active: lastDetailTab, + items: DETAIL.map(([id]) => ({ id, label: t(`nav.${id}`) })), + render: (id, panel) => DETAIL.find(([x]) => x === id)[1].render(panel, { accountId, assetId, assetKinds }), + onChange: id => { lastDetailTab = id; refreshSummary(); }, + }); + const onLedger = () => { if (summary.isConnected) refreshSummary(); }; + document.addEventListener('ledger-changed', onLedger); + closed.finally(() => document.removeEventListener('ledger-changed', onLedger)); + return closed; +} diff --git a/client/pages/events.js b/client/pages/events.js new file mode 100644 index 0000000..265a005 --- /dev/null +++ b/client/pages/events.js @@ -0,0 +1,59 @@ +// Generated, do not edit. sha256:8af265c01fc0e6317d5563eb76a9a712b80e1831a203564f5447704bbed5c878 +import { t } from '../i18n/index.js'; +import { getPref, setPref } from '../data/preferences.js'; +import { EVENTS_PAGE, targetHash } from '../../shared/config.js'; +import { create as createTabs } from '../ui/tabs.js'; +import { attentionForRoute, attentionDotHtml } from '../ui/attention.js'; +import { clearPageHint } from '../ui/page-hint.js'; +import * as accounts from './event-accounts.js'; +import * as valuations from './valuations.js'; +import * as trades from './trades.js'; +import * as transfers from './transfers.js'; +import * as interest from './interest.js'; +import * as reservations from './reservations.js'; + +export const title = () => t('nav.events'); + +const ROUTE = EVENTS_PAGE.ROUTE; +const TABS = EVENTS_PAGE.TABS; +const TAB_IDS = Object.values(TABS); +const PREF_SCOPE = ROUTE; + +const RENDER = { + [TABS.ACCOUNTS]: (panel) => accounts.render(panel), + [TABS.VALUATIONS]: (panel) => valuations.render(panel), + [TABS.TRADES]: (panel) => trades.render(panel), + [TABS.TRANSFERS]: (panel) => transfers.render(panel), + [TABS.INTEREST]: (panel) => interest.render(panel), + [TABS.RESERVATIONS]: (panel) => reservations.render(panel), +}; + +const parseHash = () => (location.hash || '').replace(/^#\/?/, '').split('/')[1] || null; +const hashFor = tab => targetHash({ route: ROUTE, tab }); + +function tabDot(tabId) { + return attentionDotHtml(attentionForRoute(ROUTE).filter(a => (a.tab || '').split('/')[0] === tabId)); +} + +export async function render(el) { + let tab = parseHash(); + if (!TAB_IDS.includes(tab)) { + const att = attentionForRoute(ROUTE).find(a => TAB_IDS.includes((a.tab || '').split('/')[0])); + tab = att ? att.tab.split('/')[0] : await getPref(PREF_SCOPE, 'tab', TABS.ACCOUNTS); + if (!TAB_IDS.includes(tab)) tab = TABS.ACCOUNTS; + history.replaceState(null, '', hashFor(tab)); + } + + el.innerHTML = '
'; + createTabs(el.querySelector('[data-tabs]'), { + size: 'lg', + active: tab, + items: TAB_IDS.map(id => ({ id, label: t(`nav.${id}`), dot: () => tabDot(id) })), + render: (id, panel) => { clearPageHint(); return RENDER[id](panel); }, + onChange: (id) => { + setPref(PREF_SCOPE, 'tab', id).catch(() => { }); + if (location.hash !== hashFor(id)) history.pushState(null, '', hashFor(id)); + }, + syncOn: ['attention-changed'], + }); +} diff --git a/client/pages/guide.js b/client/pages/guide.js new file mode 100644 index 0000000..46086d3 --- /dev/null +++ b/client/pages/guide.js @@ -0,0 +1,148 @@ +// Generated, do not edit. sha256:a22e862b7b1defbcc74c1844981b5027dbd831d7e549f1dff573576ab6b2f26e +import { t, locale } from '../i18n/index.js'; +import { ROUTE_KEYS, TARGETS, PLACE_LABELS, targetHash } from '../../shared/config.js'; +import { GUIDE_SECTIONS, GUIDE_GROUPS, splitPlaceTokens, guideText } from '../../shared/guide.js'; +import { create as createTabs } from '../ui/tabs.js'; +import { esc } from '../ui/escape.js'; +import { CHEVRON_RIGHT, ADJUST } from '../ui/icons.js'; +import { GUIDE_FIGURE_DRAWINGS } from '../ui/guide-figures.js'; +import { placeAvailable } from '../routes.js'; + +export const title = () => t('guide.title'); +export const headerInColumn = true; + +const ROUTE = ROUTE_KEYS.GUIDE; +const IDS = GUIDE_SECTIONS.map(s => s.id); +const BY_ID = new Map(GUIDE_SECTIONS.map(s => [s.id, s])); +const GROUP_OF = new Map(GUIDE_GROUPS.flatMap(g => g.sections.map(id => [id, g.id]))); +const texts = () => guideText(locale()); +const titleOf = id => texts().sections[id].title; +const shown = id => { const p = BY_ID.get(id)?.place; return !p || placeAvailable(TARGETS[p]); }; + +const parseHash = () => (location.hash || '').replace(/^#\/?/, '').split('/')[1] || null; +const hashFor = id => targetHash({ route: ROUTE, tab: id }); + +const segLabel = id => { try { return t(PLACE_LABELS[id] || `nav.${id}`); } catch { return id; } }; +const segsOf = key => { const { route, tab, sub } = TARGETS[key]; return [route, tab, sub].filter(Boolean); }; +const placeName = key => segLabel(segsOf(key).at(-1)); + +function placeNameUnique(key) { + const naam = placeName(key); + const dubbel = Object.keys(TARGETS).some(k => k !== key && placeName(k) === naam); + if (!dubbel) return naam; + const segs = segsOf(key); + return `${segLabel(segs.at(-2) ?? segs[0])} › ${naam}`; +} + +const placeLink = (key, label, cls = '', after = '') => + `${esc(label)}${after}`; + +const ICONS = Object.freeze({ adjust: ADJUST }); + +function inline(text) { + return splitPlaceTokens(text).map(part => { + if (typeof part === 'string') return esc(part); + if (part.icon) return ICONS[part.icon] ? `${ICONS[part.icon]}` : ''; + return placeAvailable(TARGETS[part.place]) ? placeLink(part.place, placeName(part.place)) : esc(placeName(part.place)); + }).join(''); +} + +const lead = (term, text) => `${esc(term)} ${inline(text)}`; + +function block(b) { + if (typeof b === 'string') return `

${inline(b)}

`; + if (b.heading) return `

${esc(b.heading)}

`; + if (b.code) return `
${esc(b.code)}
`; + if (b.note) return `
${inline(b.note)}
`; + if (b.figure) { + const draw = GUIDE_FIGURE_DRAWINGS[b.figure]; + return draw ? `
` + + `
${draw(b.caption)}
` + + `
${inline(b.caption)}
` : ''; + } + if (b.list && b.list.every(Array.isArray)) { + return `
${b.list.map(([term, text]) => `
${esc(term)}
${inline(text)}
`).join('')}
`; + } + if (b.term) return `

${lead(b.term, b.text)}

`; + if (b.list) { + return `
    ${b.list.map(item => `
  • ${Array.isArray(item) ? lead(item[0], item[1]) : inline(item)}
  • `).join('')}
`; + } + return ''; +} + +function sectionHtml(s) { + const naar = s.place + ? placeLink(s.place, t('guide.goTo', { plek: placeNameUnique(s.place) }), 'btn btn-ghost btn-sm guide-goto', CHEVRON_RIGHT) + : ''; + const related = s.related.filter(shown); + const zie = related.length + ? `` + : ''; + return ` +
+

${esc(titleOf(s.id))}

+
${texts().sections[s.id].body.map(block).join('')}
+ ${naar || zie ? `
${zie}${naar}
` : ''} +
`; +} + +export async function render(el) { + const groups = GUIDE_GROUPS.map(g => ({ ...g, sections: g.sections.filter(shown) })).filter(g => g.sections.length); + let current = parseHash(); + if (!IDS.includes(current) || !shown(current)) { + current = groups[0].sections[0]; + history.replaceState(null, '', hashFor(current)); + } + + el.innerHTML = ` +
+ +
+ +
+
+
`; + + const menu = el.querySelector('[data-sections]'); + let panel = null; + let ready = false; + + const show = (sid) => { + current = sid; + panel.innerHTML = sectionHtml(BY_ID.get(sid)); + menu.querySelectorAll('[data-section]').forEach(b => { + const on = b.dataset.section === sid; + b.classList.toggle('is-on', on); + b.setAttribute('aria-selected', String(on)); + }); + if (ready && location.hash !== hashFor(sid)) history.pushState(null, '', hashFor(sid)); + }; + + const tabs = createTabs(el.querySelector('[data-groups]'), { + barHost: el.querySelector('[data-group-bar]'), + active: GROUP_OF.get(current), + items: groups.map(g => ({ id: g.id, label: esc(texts().groups[g.id]) })), + render: (gid, host) => { + panel = host; + const groep = groups.find(g => g.id === gid); + menu.innerHTML = groep.sections.map(sid => ` + `).join(''); + show(GROUP_OF.get(current) === gid ? current : groep.sections[0]); + }, + }); + ready = true; + + el.querySelector('.page-layout').onclick = e => { + const doel = e.target.closest('[data-section]'); + if (!doel || !BY_ID.has(doel.dataset.section)) return; + e.preventDefault(); + const sid = doel.dataset.section; + if (tabs && GROUP_OF.get(sid) !== tabs.active) { current = sid; tabs.select(GROUP_OF.get(sid)); } + else show(sid); + }; +} diff --git a/client/pages/home.js b/client/pages/home.js new file mode 100644 index 0000000..c5ceeea --- /dev/null +++ b/client/pages/home.js @@ -0,0 +1,157 @@ +// Generated, do not edit. sha256:148777e8a3d0dd6536d8b6d4b8cae6c0c4cbf2ed945fdf8bb8ed57c23e617bd8 +import { listBookableAssets } from '../data/assets.js'; +import { appSetting, setAppSetting } from '../app-settings.js'; +import { t } from '../i18n/index.js'; +import { renderNotifications } from '../actions/notifications-widget.js'; +import { currentStanding } from '../wealth/data.js'; +import { fmtBase, fmtBaseSigned, colouredSigns, fmtUnit, val } from '../ui/format.js'; +import { esc } from '../ui/escape.js'; +import { assetLabel, assetLabelHtml } from '../ui/asset-label.js'; +import { originsHtml } from '../ui/price-origin.js'; +import { setPageHint } from '../ui/page-hint.js'; +import { CHEVRON_RIGHT } from '../ui/icons.js'; +import { renderDashboard } from '../dashboard/dashboard.js'; +import { renderSpotWidget, renderSpotSettings } from '../widgets/spot.js'; +import { secondCurrencyOf, secondAmount, renderSecondCurrencySetting } from '../widgets/second-currency.js'; +import { STANDING_GROUPINGS, standingGroupingOf, groupStanding, standingToday, standingShowsToday } from '../widgets/standing-groups.js'; +import { renderMetricsWidget } from '../widgets/metrics.js'; +import { renderClocksWidget, renderClocksSettings } from '../widgets/clocks.js'; +import { renderReturnWidget, renderReturnSettings } from '../widgets/return.js'; +import { registerWidget, availableWidgets } from '../widgets/registry.js'; +import { DASHBOARD, TARGETS, targetHash } from '../../shared/config.js'; + +export const title = () => t('home.title'); + +registerWidget('notifications', { render: host => renderNotifications(host) }); +registerWidget('standing', { render: (host, inst) => renderStandingWidget(host, inst), settings: renderStandingSettings }); +registerWidget('spot', { render: (host, inst) => renderSpotWidget(host, inst), settings: renderSpotSettings }); +registerWidget('metrics', { render: host => renderMetricsWidget(host) }); +registerWidget('clocks', { render: (host, inst) => renderClocksWidget(host, inst), settings: renderClocksSettings }); +registerWidget('return', { render: (host, inst) => renderReturnWidget(host, inst), settings: renderReturnSettings }); + +async function buildCatalog(layout) { + const present = new Set(layout.map(it => it.instId)); + const persona = appSetting('persona'); + const { renderers } = availableWidgets(); + const items = []; + for (const [type, def] of Object.entries(DASHBOARD.WIDGETS)) { + if (def.instanceable || present.has(type) || !renderers[type]) continue; + if (def.persona && def.persona !== persona) continue; + items.push({ instId: type, type, params: null, label: t(def.label_key) }); + } + let n = 1; + while (present.has(`return-${n}`)) n++; + items.push({ instId: `return-${n}`, type: 'return', params: null, label: t('dashboard.widget.return') }); + for (const a of await listBookableAssets()) { + const instId = `spot-${a.id}`; + if (present.has(instId)) continue; + items.push({ + instId, type: 'spot', params: { assetId: a.id }, + label: `${t('dashboard.widget.spot')}: ${assetLabel(a)}`, + labelHtml: `${esc(t('dashboard.widget.spot'))}: ${assetLabelHtml(a)}`, + }); + } + return items; +} + +export async function render(el, opts = {}) { + if (!el.querySelector('#dashboard-root')) el.innerHTML = '
'; + + const { renderers, settings } = availableWidgets(); + await renderDashboard(el.querySelector('#dashboard-root'), { + renderers, + settings, + edit: !!opts.edit, + onEdit: opts.setEdit, + catalog: buildCatalog, + toolbarHost: document.getElementById('nav-tools') || document.getElementById('page-tools'), + }); + + setPageHint(el, { title: t('home.hintTitle'), rows: [ + t('home.hintEdit'), t('home.hintDone'), t('home.hintAdd'), t('home.hintWidget'), t('home.hintProfiles'), + ] }); +} + +function standingGroupLabel(g, by) { + const r = g.sample; + if (by === 'asset') return assetLabelHtml(r.asset); + if (by === 'assetKind') return esc(t('enums.asset_kind.' + r.asset.kind)); + if (by === 'accountKind') return esc(g.key === 'none' ? t('wealth.noAccount') : t('enums.account_kind.' + g.key)); + return esc(r.account.name); +} + +function todayHtml(amount, cls, always = false) { + if (amount == null || (!always && Math.abs(amount) < 0.005)) return ''; + const tone = amount > 0.005 ? 't-pos' : amount < -0.005 ? 't-neg' : ''; + return `${val(fmtBaseSigned(amount, colouredSigns()))}`; +} + +async function renderStandingSettings(host, instance, commit) { + host.classList.add('dash-settings--wide'); + const huidig = () => standingGroupingOf(instance.params && instance.params.groupBy); + const label = { account: 'home.groupAccount', accountKind: 'home.groupAccountKind', asset: 'home.groupAsset', assetKind: 'home.groupAssetKind' }; + const chips = () => STANDING_GROUPINGS.map(id => ``).join(''); + const block = document.createElement('div'); + block.className = 'dash-set-row'; + block.innerHTML = `${esc(t('home.groupBy'))}
${chips()}
`; + host.appendChild(block); + block.addEventListener('click', e => { + const chip = e.target.closest('[data-standing-group]'); + if (!chip || chip.dataset.standingGroup === huidig()) return; + instance.params = { ...(instance.params || {}), groupBy: standingGroupingOf(chip.dataset.standingGroup) }; + block.querySelector('.fbar').innerHTML = chips(); + commit({ resize: true }); + }); + const today = document.createElement('div'); + today.className = 'dash-set-row'; + today.innerHTML = ``; + host.appendChild(today); + today.addEventListener('change', e => { + const box = e.target.closest('[data-standing-today]'); + if (!box) return; + instance.params = { ...(instance.params || {}), showToday: box.checked }; + commit({ resize: true }); + }); + await renderSecondCurrencySetting(host, instance, commit); +} + +async function renderStandingWidget(host, instance) { + const by = standingGroupingOf(instance && instance.params && instance.params.groupBy); + const second = secondCurrencyOf(instance); + host.innerHTML = `
+
+
${t('home.standing')}
+ ${CHEVRON_RIGHT} +
+
…
+
`; + try { + const s = await currentStanding(); + const body = host.querySelector('#standing-body'); + if (s.rows.length === 0) { body.textContent = t('home.standingNone'); return; } + + const toon = standingShowsToday(instance && instance.params); + const perGroup = toon ? standingToday(s.rows, by, s.today) : new Map(); + const lines = groupStanding(s.rows, by).map(g => `
+ ${standingGroupLabel(g, by)} + ${todayHtml(perGroup.get(g.key), 'stw-today-row')} + ${val(fmtBase(g.value))}${originsHtml(g.origins)} +
`).join(''); + const vandaag = toon && s.today + ? `
${todayHtml(s.today.total, '', true)}
` : ''; + const tweede = second ? await secondAmount(s.total, second, { decimals: 0 }) : ''; + + body.className = ''; + body.innerHTML = ` +
${val(fmtBase(s.total))}${tweede ? `${val(tweede)}` : ''}${originsHtml(s.rows.map(r => ({ origin: r.origin, asset: r.asset })))}
+ ${vandaag} +
${s.rows.every(r => r.asset.id === s.rows[0].asset.id) ? val(fmtUnit(s.totalSats, s.rows[0].asset)) : ''}
+
${lines}
`; + + } catch (err) { + console.error('[standing-widget]', err); + const body = host.querySelector('#standing-body'); + if (body) body.textContent = '⚠ ' + err.message; + } +} diff --git a/client/pages/interest.js b/client/pages/interest.js new file mode 100644 index 0000000..6fc47ef --- /dev/null +++ b/client/pages/interest.js @@ -0,0 +1,366 @@ +// Generated, do not edit. sha256:b3c098ab83c22c91c33b24c5b6d1f67c7d41af1244da6963ffe67e33da9270d3 +import { listAccounts, listSelectableAccounts } from '../data/accounts.js'; +import { dayNl, localDate, localDateNl, localTime, toUtcIso } from '../../shared/time.js'; +import { listAssets, listBookableAssets } from '../data/assets.js'; +import { createEvent, updateEvent, deleteEvent } from '../data/events.js'; +import { listInterest, listAllSchedules, createSchedule, updateSchedule, deleteSchedule, + listPeriods, createPeriod, updatePeriod, deletePeriod } from '../data/interest-schedules.js'; +import { tableViewCfg } from '../data/preferences.js'; +import { t } from '../i18n/index.js'; +import { assetLabelHtml } from '../ui/asset-label.js'; +import * as dialog from '../ui/dialog.js'; +import { renderTable } from '../ui/table.js'; +import { takeRowFocus } from '../ui/row-focus.js'; +import { fmtUnit, val, subline, toSubunit, fromSubunit, pct } from '../ui/format.js'; +import { fiatAtPrice, fiatHistorical, fiatHistoricalOrigin, loadHistorical, loadFiat, fiatValue, fiatValueOrigin } from '../ui/fiat.js'; +import { bookingOriginHtml, originHtml } from '../ui/price-origin.js'; +import { renderScheduleStatus } from '../actions/interest-widget.js'; +import { compoundsViaBalance, looksLikeDepositWithoutPrincipal } from '../actions/interest-compounding.js'; +import { CONFIG, ENUMS, ACCOUNT_INTEREST_KINDS, TARGETS, targetHash } from '../../shared/config.js'; +import { esc } from '../ui/escape.js'; +import { emptyStateHtml } from '../ui/empty-state.js'; +import { setPageHint } from '../ui/page-hint.js'; + +let recordsApi = null; +let onlyAccount = null; +let onlyAsset = null; +let onlyKinds = null; +const assetAllowed = a => (onlyAsset == null || a.id === onlyAsset) && (onlyKinds == null || onlyKinds.includes(a.kind)); +const accountAllowed = a => onlyAccount == null || a.id === onlyAccount; +const withoutAccount = c => onlyAccount == null || (c.key !== 'accountId' && c.key !== 'toAccountId'); +const ownSchedules = async () => (await listAllSchedules()).filter(s => (onlyAccount == null || s.accountId === onlyAccount) + && (onlyAsset == null || s.principalAssetId === onlyAsset || s.payoutAssetId === onlyAsset)); +const ownInterest = async () => (await listInterest()).filter(r => (onlyAccount == null || r.toAccountId === onlyAccount) + && (onlyAsset == null || r.assetId === onlyAsset)); +async function herlaadRecords() { if (recordsApi) await recordsApi.refresh(await ownInterest()); } +let schedApi = null; +let herlaadStatus = () => {}; + +export async function render(el, { accountId = null, assetId = null, assetKinds = null } = {}) { + onlyAccount = accountId; + onlyAsset = assetId; + onlyKinds = assetKinds; + if ((await listAssets()).length === 0) { + el.innerHTML = ` + ${emptyStateHtml(t('interest.none'), { + why: t('interest.needAsset'), href: targetHash(TARGETS.ASSETS), linkText: t('common.manageAssets'), + })}`; + return; + } + el.innerHTML = ` +
+
+
`; + if (onlyAccount == null) setPageHint(el, { title: t('nav.interest'), rows: [t('interest.importNote')] }); + buildSchedules(el); + buildRecords(el); +} + +async function interestAccountOpts(huidigeId) { + return (await listSelectableAccounts({ ookId: Number(huidigeId) || null })) + .filter(a => ACCOUNT_INTEREST_KINDS.includes(a.kind) && accountAllowed(a)).map(a => ({ value: a.id, label: a.name })); +} + +async function buildSchedules(el) { + const alleAssets = await listAssets(); + const assetOpts = async () => (await listBookableAssets()).filter(assetAllowed).map(a => ({ value: a.id, label: a.symbol })); + const rateKindOpts = () => ENUMS.INTEREST_RATE_KIND.map(rk => ({ value: rk, label: t('enums.interest_rate_kind.' + rk) })); + const cadenceOpts = () => ENUMS.INTEREST_CADENCE.map(c => ({ value: c, label: t('enums.interest_cadence.' + c) })); + const weekdayOpts = () => [0, 1, 2, 3, 4, 5, 6].map(d => ({ value: d, label: t('accounts.wd' + d) })); + const cadenceLabel = r => t('enums.interest_cadence.' + r.creditCadence); + const principalMeta = r => ({ decimals: r.principal_decimals, unit: r.principal_unit, subunit: r.principal_subunit, displayUnit: r.principal_display_unit }); + + const toSched = async v => { + const accountId = Number(v.accountId ?? onlyAccount); + if (!accountId) throw new Error(t('interest.schAccountRequired')); + const payoutAssetId = Number(v.payoutAssetId); + if (!payoutAssetId) throw new Error(t('accounts.schPayoutRequired')); + const base = { + accountId, rateKind: v.rateKind, payoutAssetId, + startsOn: v.startsOn, endsOn: v.endsOn || null, + note: (v.note || '').trim() || null, + }; + if (v.rateKind === 'variable') { + return { ...base, principalAssetId: null, principalAmount: null, annualRate: null, + creditCadence: null, creditWeekday: null, creditDayOfMonth: null, compounding: 0 }; + } + const asset = (await listAssets()).find(a => a.id === Number(v.principalAssetId)); + if (!asset) throw new Error(t('accounts.schAssetRequired')); + const annualRate = Number(v.annualRate) / 100; + if (!Number.isFinite(annualRate)) throw new Error(t('accounts.schRateInvalid')); + return { + ...base, + principalAssetId: Number(v.principalAssetId), + principalAmount: String(v.principalAmount ?? '').trim() === '' ? null : toSubunit(v.principalAmount, asset), + annualRate, + creditCadence: v.creditCadence, + creditWeekday: v.creditCadence === 'weekly' ? Number(v.creditWeekday) : null, + creditDayOfMonth: v.creditCadence === 'monthly' ? Number(v.creditDayOfMonth) : null, + compounding: Number(v.compounding) === 1 ? 1 : 0, + }; + }; + const yesNoOpts = () => [{ value: 0, label: t('accounts.no') }, { value: 1, label: t('accounts.yes') }]; + const confirmDeposit = async v => { + if (!looksLikeDepositWithoutPrincipal(v)) return; + const ok = await dialog.confirm({ title: t('interest.depositTitle'), message: t('interest.depositWarning'), + confirmLabel: t('interest.depositSaveAnyway'), cancelLabel: t('interest.depositBack') }); + if (!ok) throw new Error(t('interest.depositNotSaved')); + }; + + const reloadStatus = async () => { + const host = el.querySelector('#sched-status'); + const accountIds = [...new Set((await ownSchedules()).map(s => s.accountId))]; + if (!accountIds.length) { host.innerHTML = ''; return; } + const accounts = await listAccounts(); + const naamHtml = id => onlyAccount != null ? '' : `
${esc(accounts.find(a => a.id === id)?.name || '')}
`; + host.innerHTML = `
+
${t('accounts.projection')} + ${t('accounts.projectionHint')}
+ ${accountIds.map(id => `
${naamHtml(id)}
`).join('')} +
`; + const getekend = await Promise.all(accountIds.map(id => + renderScheduleStatus(host.querySelector(`[data-acc="${id}"] [data-acc-status]`), id, reloadAll))); + accountIds.forEach((id, i) => { if (!getekend[i]) host.querySelector(`[data-acc="${id}"]`).remove(); }); + if (!getekend.some(Boolean)) host.innerHTML = ''; + }; + herlaadStatus = reloadStatus; + const reloadSched = async () => schedApi.refresh(await ownSchedules()); + const reloadAll = () => { reloadSched(); reloadStatus(); herlaadRecords(); }; + + schedApi = renderTable(el.querySelector('#sched'), { + columns: [ + { key: 'accountId', label: t('interest.colAccount'), sortable: true, sortValue: r => r.account_name, + render: r => esc(r.account_name), + group: { value: r => r.account_name, label: v => v }, + edit: { type: 'select', sticky: true, options: vals => interestAccountOpts(vals.accountId), value: r => r ? r.accountId : '' } }, + { key: 'rateKind', label: t('accounts.schRateType'), sortable: true, + render: r => t('enums.interest_rate_kind.' + r.rateKind), + group: { value: r => r.rateKind, label: v => t('enums.interest_rate_kind.' + v) }, + edit: { type: 'select', sticky: true, reactive: true, options: rateKindOpts } }, + { key: 'principalAssetId', label: t('accounts.schPrincipalAsset'), sortable: true, sortValue: r => r.principal_symbol, + render: r => r.principal_symbol || '-', + edit: { type: 'select', sticky: true, reactive: true, options: assetOpts, visible: v => v.rateKind === 'fixed' } }, + { key: 'principalAmount', label: t('accounts.schPrincipal'), align: 'num', sortable: true, sortValue: r => r.principalAmount, + render: r => { + if (r.principalAmount == null) return `${t('interest.principalFollowsBalance')}`; + const asset = alleAssets.find(a => a.id === r.principalAssetId); + const fiat = asset ? fiatValue(r.principalAmount, asset) : ''; + return `${val(fmtUnit(r.principalAmount, principalMeta(r)))}${subline(fiat ? val(fiat) + originHtml(fiatValueOrigin(asset), asset) : '')}`; + }, + edit: { type: 'number', attrs: { min: 0, step: 'any' }, visible: v => v.rateKind === 'fixed', + value: r => r && r.principalAmount != null ? fromSubunit(r.principalAmount, principalMeta(r)) : '', + convert: vals => alleAssets.find(a => a.id === Number(vals.principalAssetId)) } }, + { key: 'annualRate', label: t('accounts.schRate'), align: 'num', sortable: true, sortValue: r => r.annualRate, + render: r => r.annualRate != null ? pct(r.annualRate) : '-', + edit: { type: 'number', attrs: { min: 0, step: 'any' }, visible: v => v.rateKind === 'fixed', value: r => r && r.annualRate != null ? r.annualRate * 100 : '' } }, + { key: 'compounding', label: t('interest.colCompounding'), + render: r => r.rateKind !== 'fixed' ? '-' + : compoundsViaBalance(r) ? `${t('interest.compoundingViaBalance')}` + : t(r.compounding ? 'accounts.yes' : 'accounts.no'), + edit: { type: 'select', options: yesNoOpts, visible: v => v.rateKind === 'fixed', value: r => r ? r.compounding : 0 } }, + { key: 'payoutAssetId', label: t('accounts.schPayoutAsset'), sortable: true, sortValue: r => r.payout_symbol, + render: r => r.payout_symbol, + group: { value: r => r.payout_symbol, label: v => v }, + edit: { type: 'select', sticky: true, options: assetOpts } }, + { key: 'startsOn', label: t('accounts.schStart'), sortable: true, + render: r => dayNl(r.startsOn), + edit: { type: 'date', sticky: true, value: r => r ? r.startsOn : new Date().toISOString().slice(0, 10) } }, + { key: 'endsOn', label: t('accounts.schEnd'), + render: r => dayNl(r.endsOn) || `${t('interest.endsOnOpen')}`, + edit: { type: 'date', value: r => r && r.endsOn ? r.endsOn : '' } }, + { key: 'creditCadence', label: t('accounts.schCadence'), + render: r => r.creditCadence ? cadenceLabel(r) : '-', + group: { value: r => r.creditCadence || '', label: v => v ? t('enums.interest_cadence.' + v) : t('interest.cadenceNone') }, + edit: { type: 'select', sticky: true, options: cadenceOpts, reactive: true, visible: v => v.rateKind === 'fixed' } }, + { key: 'creditWeekday', label: t('accounts.schWeekday'), + render: r => r.creditCadence === 'weekly' ? t('accounts.wd' + r.creditWeekday) : '-', + edit: { type: 'select', options: weekdayOpts, visible: vals => vals.creditCadence === 'weekly' } }, + { key: 'creditDayOfMonth', label: t('accounts.schDayOfMonth'), + render: r => r.creditCadence === 'monthly' ? String(r.creditDayOfMonth) : '-', + edit: { type: 'number', attrs: { min: 1, max: 31, step: 1 }, visible: vals => vals.creditCadence === 'monthly' } }, + { key: 'note', label: t('accounts.schNote'), text: true, + render: r => `${esc(r.note || '')}`, + edit: { type: 'textarea', expand: true } }, + ].filter(withoutAccount), + rows: await ownSchedules(), + title: t('interest.schedulesTitle'), + ...(await tableViewCfg('interest.schedules', { key: 'startsOn', dir: 'desc' }, onlyAccount != null ? 'payoutAssetId' : null)), + emptyText: t('interest.noSchedules'), + newLabel: t('table.new'), + convertLabel: t('convert.title'), + collapsibleGroups: true, + newDefaults: { ...(onlyAccount != null ? { accountId: onlyAccount } : {}), rateKind: 'fixed', creditCadence: 'annual', principalAmount: '', annualRate: '', endsOn: '', note: '', creditWeekday: 1, creditDayOfMonth: 1, compounding: 0 }, + saveLabel: t('accounts.save'), cancelLabel: t('accounts.cancel'), + onCreate: async v => { await confirmDeposit(v); await createSchedule(await toSched(v)); reloadAll(); }, + onSave: async (id, v) => { await confirmDeposit(v); await updateSchedule(id, await toSched(v)); reloadAll(); }, + onDelete: async id => { + if (!(await dialog.confirm({ message: t('accounts.confirmDeleteSchedule'), danger: true }))) return; + try { await deleteSchedule(id); reloadAll(); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); } + }, + rowExpansion: () => '', + onExpansionMount: (r, host) => mountPeriods(host, r, reloadStatus), + }); + + reloadStatus(); + const inleg = [...new Set((await ownSchedules()).filter(s => s.principalAmount != null).map(s => s.principalAssetId))] + .map(id => alleAssets.find(a => a.id === id)).filter(Boolean); + if (inleg.length) loadFiat(inleg).then(reloadSched).catch(() => {}); + + const focusAccount = Number(sessionStorage.getItem(CONFIG.FOCUS.INTEREST_ACCOUNT) || 0); + sessionStorage.removeItem(CONFIG.FOCUS.INTEREST_ACCOUNT); + if (focusAccount) schedApi.focusGroup('accountId', r => r.accountId === focusAccount); +} + +async function assetOptsFull() { return (await listBookableAssets()).filter(assetAllowed).map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` })); } +async function accountOpts(huidigeId) { + return (await listSelectableAccounts({ ookId: Number(huidigeId) || null })).filter(accountAllowed).map(a => ({ value: a.id, label: a.name })); +} + +async function buildRecords(el) { + const alleAssets = await listAssets(); + const rows = ownInterest; + const meta = r => ({ decimals: r.asset_decimals, unit: r.asset_unit, subunit: r.asset_subunit, displayUnit: r.asset_display_unit }); + const assetOf = r => ({ ...meta(r), id: r.assetId, kind: r.asset_kind, symbol: r.asset_symbol }); + const today = new Date().toISOString().slice(0, 10); + + const columns = [ + { key: 'occurredAt', label: t('interest.colDate'), sortable: true, + render: r => localDateNl(r.occurredAt), + group: { value: r => localDate(r.occurredAt).slice(0, 4), label: v => v, order: 'desc' }, + edit: { type: 'date', sticky: true, attrs: { max: today }, value: r => r ? localDate(r.occurredAt) : today } }, + { key: '_time', label: t('interest.time'), + render: r => localTime(r.occurredAt), + edit: { type: 'time', value: r => r ? localTime(r.occurredAt) : '00:00' } }, + { key: 'toAccountId', label: t('interest.colAccount'), sortable: true, sortValue: r => r.to_name, + render: r => r.to_name || '-', group: { value: r => r.to_name || '-', label: v => v }, + edit: { type: 'select', sticky: true, options: vals => accountOpts(vals.toAccountId), value: r => r ? r.toAccountId ?? '' : '' } }, + { key: 'assetId', label: t('interest.colAsset'), sortable: true, sortValue: r => r.asset_symbol, + render: r => assetLabelHtml(r), group: { value: r => r.asset_symbol, label: v => v }, + edit: { type: 'select', sticky: true, reactive: true, options: assetOptsFull } }, + { key: 'amount', label: t('interest.colAmount'), align: 'num', sortable: true, sortValue: r => r.amount, + render: r => { + const asset = assetOf(r); + if (r.fiatPrice != null) { + const fiat = fiatAtPrice(r.amount, meta(r).decimals, r.fiatPrice); + return `${val(fmtUnit(r.amount, meta(r)))}${subline(fiat ? val(fiat) + bookingOriginHtml(r, asset) : '')}`; + } + const dag = localDate(r.occurredAt); + const fiat = fiatHistorical(r.amount, asset, dag); + return `${val(fmtUnit(r.amount, meta(r)))}${subline(fiat ? val(fiat) + originHtml(fiatHistoricalOrigin(asset, dag), asset) : '')}`; + }, + edit: { type: 'number', attrs: { min: 0, step: 'any' }, value: r => r ? fromSubunit(r.amount, meta(r)) : '', convert: vals => alleAssets.find(a => a.id === Number(vals.assetId)) } }, + { key: 'source', label: t('interest.colSource'), sortable: true, + render: r => r.source ? `${t('enums.event_source.' + r.source)}` : '', + group: { value: r => r.source, label: v => t('enums.event_source.' + v) } }, + { key: 'schedule', label: t('interest.colSchedule'), sortable: true, sortValue: r => r.schedule_label || '', + render: r => r.schedule_label || `-`, + group: { value: r => r.schedule_label || t('interest.noSchedule'), label: v => v } }, + { key: 'note', label: t('interest.colNote'), text: true, + render: r => `${esc(r.note || '')}`, + edit: { type: 'textarea', expand: true } }, + ].filter(withoutAccount); + + async function toPayload(v, existing) { + const asset = (await listAssets()).find(a => a.id === Number(v.assetId)); + const qty = parseFloat(v.amount); + const toAccountId = v.toAccountId ?? onlyAccount; + const account = toAccountId !== '' && toAccountId != null ? Number(toAccountId) : null; + if (!v.occurredAt || !asset || !account || !Number.isFinite(qty) || qty <= 0) { + throw new Error(t('interest.invalid')); + } + return { + assetId: Number(v.assetId), type: 'interest', + occurredAt: toUtcIso(`${v.occurredAt}T${v._time || '00:00'}:00`), + amount: toSubunit(qty, asset), + fiatPrice: existing ? existing.fiatPrice ?? null : null, + fiatFee: null, unitFee: null, + fromAccountId: null, toAccountId: account, + source: existing ? existing.source : 'manual', + note: (v.note || '').trim() || null, + }; + } + const reload = () => { herlaadRecords(); herlaadStatus(); }; + const editable = r => r.source === 'manual'; + + recordsApi = renderTable(el.querySelector('#tbl'), { + columns, rows: await rows(), + title: t('interest.recordsTitle'), + ...(await tableViewCfg('interest', { key: 'occurredAt', dir: 'desc' }, onlyAccount != null ? 'assetId' : null)), + emptyText: t('interest.none'), + noneLabel: t('table.none'), + collapsibleGroups: true, + groupSummary: items => { + const first = items[0]; if (!first) return ''; + if (!items.every(r => r.assetId === first.assetId)) return ''; + const tot = items.reduce((s, r) => s + (r.amount || 0), 0); + return val(fmtUnit(tot, meta(first))); + }, + newLabel: t('table.new'), + convertLabel: t('convert.title'), + ...(onlyAccount != null ? { newDefaults: { toAccountId: onlyAccount } } : {}), + saveLabel: t('interest.save'), cancelLabel: t('interest.cancel'), + rowEditable: editable, rowDeletable: editable, + onCreate: async v => { const id = await createEvent(await toPayload(v, null)); recordsApi.refresh(await rows(), { locate: id }); herlaadStatus(); }, + onSave: async (id, v) => { + const ex = (await listInterest()).find(x => x.id === id); + await updateEvent(id, await toPayload(v, ex)); reload(); + }, + onDelete: async id => { + if (!(await dialog.confirm({ message: t('interest.confirmDelete'), danger: true }))) return; + await deleteEvent(id); reload(); + }, + }); + recordsApi.focusRow(takeRowFocus('interest')); + const zonderPrijs = (await rows()).filter(r => r.fiatPrice == null); + loadHistorical(zonderPrijs.map(r => ({ assetId: r.assetId, dateKey: localDate(r.occurredAt) }))) + .then(async () => { if (recordsApi) recordsApi.refresh(await rows()); }).catch(() => {}); +} + +async function mountPeriods(host, sched, naWijziging) { + if (sched.rateKind !== 'fixed') { + host.innerHTML = `
${t('interest.periodsVariable')}
`; + return; + } + host.innerHTML = `
+
+
${t('interest.periodsBase')} ${esc(sched.startsOn)}: ${pct(sched.annualRate)}
+
`; + + const toPeriod = (v) => { + const rate = parseFloat(String(v.annualRate).replace(',', '.')); + if (!Number.isFinite(rate)) throw new Error(t('interest.periodRateRequired')); + return { startsOn: v.startsOn, annualRate: rate / 100 }; + }; + const dagErna = (iso) => { + const d = new Date(`${iso}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + 1); + return d.toISOString().slice(0, 10); + }; + let perioden = await listPeriods(sched.id); + const volgendeStart = () => dagErna(perioden.length ? perioden[perioden.length - 1].startsOn : sched.startsOn); + const herlaad = async (api) => { perioden = await listPeriods(sched.id); api.refresh(perioden); }; + + const api = renderTable(host.querySelector('[data-periods]'), { + columns: [ + { key: 'startsOn', label: t('interest.periodStartsOn'), sortable: true, + render: r => dayNl(r.startsOn), + edit: { type: 'date', value: r => r ? r.startsOn : volgendeStart() } }, + { key: 'annualRate', label: t('accounts.schRate'), align: 'num', + render: r => pct(r.annualRate), + edit: { type: 'number', attrs: { min: 0, step: 'any' }, value: r => r ? r.annualRate * 100 : '' } }, + ], + rows: perioden, + title: t('interest.periods'), + sort: { key: 'startsOn', dir: 'asc' }, + emptyText: t('interest.noPeriods'), + newLabel: t('table.new'), + newDefaults: { annualRate: '' }, + saveLabel: t('accounts.save'), cancelLabel: t('accounts.cancel'), + onCreate: async v => { await createPeriod(sched.id, toPeriod(v)); await herlaad(api); naWijziging(); }, + onSave: async (id, v) => { await updatePeriod(id, toPeriod(v)); await herlaad(api); naWijziging(); }, + onDelete: async id => { + if (!(await dialog.confirm({ message: t('interest.confirmDeletePeriod'), danger: true }))) return; + await deletePeriod(id); await herlaad(api); naWijziging(); + }, + }); +} diff --git a/client/pages/license.js b/client/pages/license.js new file mode 100644 index 0000000..e338556 --- /dev/null +++ b/client/pages/license.js @@ -0,0 +1,642 @@ +// Generated, do not edit. sha256:5dea810da6d84ed187a1154955f4194498f812771e3ddaa6b6dc7c547a305b8d +import { t, localeTag } from '../i18n/index.js'; +import { CONFIG } from '../../shared/config.js'; +import { esc } from '../ui/escape.js'; +import { licenseSnapshot, loadLicense, markLicenseSeen } from '../license.js'; +import { installLicense } from '../data/license-install.js'; +import { refreshAttention } from '../ui/attention.js'; +import { npubFromHex, shortKey, secretFromText, publicKeyOf, nsecFromSecret, newSecret } from '../nostr/keys.js'; +import { keySigner, extensionSigner, nip98Header } from '../nostr/nip98.js'; +import { qrSvg, invoiceQrText } from '../ui/qr.js'; +import { CHECK, CHEVRON } from '../ui/icons.js'; +import { splashMarkHtml } from '../ui/splash.js'; +import { expanders } from '../ui/expand.js'; +import { supportInfo, supportMessages, sendSupport, supportAttachment } from '../data/support.js'; +import { licenseShop, buyerLicenses, saleInfo, placeOrder, orderStatus } from '../data/license-buyer.js'; + +const BROKEN = new Set(['invalid', 'unreadable']); + +function buyerOf(s) { + const npub = npubFromHex(s.buyer); + return { text: shortKey(npub ?? s.buyer, 10), title: npub ?? s.buyer ?? '' }; +} + +function lines(s) { + const out = []; + const line = (text, title = '') => out.push({ text, title }); + + if (s.why === 'develop' || s.why === 'forced-develop') line(t('license.cardDevelop')); + line(s.level === 'full' ? t('license.cardFull') : t('license.cardFree')); + if (s.installed) { + const buyer = buyerOf(s); + line(t('license.cardFacts', { + buyer: buyer.text, issued: s.issued ?? '', + from: s.installed.majorFrom, + to: s.installed.majorTo === 'unlimited' ? t('license.unlimited') : s.installed.majorTo, + }), buyer.title); + if (!s.installedCovers) line(t('license.cardOutOfRange', { version: s.version })); + } + if (BROKEN.has(s.why)) line(`${t('license.cardProblem')} ${t(`license.why_${s.why}`)}.`); + if (s.level !== 'full') line(t('license.cardBuy')); + return out; +} + +async function installAndReload(text, message) { + message.className = 't-body-sm'; + message.textContent = ''; + try { + const result = await installLicense(text); + message.classList.add('t-pos'); + message.textContent = result.covers ? t('license.installOk') : t('license.installOkFuture'); + setTimeout(() => location.reload(), 1200); + } catch (err) { + message.classList.add('t-neg'); + message.textContent = err.message; + } +} + +const INFO = ['Nostr', 'Why', 'SignIn', 'Zap', 'Privacy']; +const link = (url, html) => `${html}`; +const payHtml = (expand) => `
${say('license.payTitle')}
+

${say('license.payIntro')}

` + + ['easy', 'other'].map(group => `
${say(`license.payGroup_${group}`)}
` + + CONFIG.LICENSE.PAY_APPS.filter(a => a.group === group) + .map(a => `

${link(a.url, `${esc(a.name)}`)} ${say(`license.payApp_${a.id}`)}

`).join('') + + '
').join('') + + `

${say('license.payNote')}

+
${say('license.nostrMore')}` + + INFO.map(k => `

${say(`license.info${k}Head`)} ${say(`license.info${k}`)}

`).join('') + + CONFIG.LICENSE.INFO_LINKS.map(l => `

${link(l.url, say(`license.link_${l.id}`))}

`).join('') + + '
'; + +let signer = null; +let newKey = null; + +const npubText = (hex) => { + const npub = npubFromHex(hex) ?? hex; + return `${esc(shortKey(npub, 10))}`; +}; +const say = (key, params) => esc(t(key, params)); + +async function drawSignIn(box, s) { + let shop = { serviceUrl: null, buyer: null }; + try { shop = await licenseShop(); } catch { } + serviceUrl = shop.serviceUrl; + drawBuy(); + drawSupport(); + + const head = `
${say('license.signInTitle')}
`; + + if (signer) { + const who = t(signer.kind === 'extension' ? 'license.signedInExtension' : 'license.signedIn', { npub: '§' }); + const [before, after] = who.split('§'); + const other = s.installed && s.buyer && s.buyer !== signer.pubkey; + box.innerHTML = `${head} +

${esc(before)}${npubText(signer.pubkey)}${esc(after)}

+ ${other ? `

${say('license.otherBuyer')}

` : ''} +
+

+
`; + box.querySelector('[data-signout]').onclick = () => { signer = null; drawSignIn(box, s); }; + await drawLicenses(box.querySelector('[data-licenses]'), box.querySelector('[data-signin-msg]'), shop); + return; + } + + if (newKey) { + const nsec = nsecFromSecret(newKey.secret); + box.innerHTML = `${head} +

${say('license.newKeyIntro')}

+ +

${say('license.newKeyPublic', { npub: npubFromHex(publicKeyOf(newKey.secret)) })}

+
+ + +
+ +
+ + +
`; + const go = box.querySelector('[data-go]'); + const saved = box.querySelector('[data-saved]'); + const allow = () => { go.disabled = !(saved.checked && newKey.copied); }; + saved.onchange = allow; + box.querySelector('[data-key-copy]').onclick = (e) => { + newKey.copied = true; + allow(); + copyText(nsec, box.querySelector('[data-nsec]')).then((ok) => { if (ok) e.target.textContent = t('license.newKeyCopied'); }); + }; + box.querySelector('[data-download]').onclick = () => { + download(t('license.newKeyFileName'), `${nsec}\n`); + newKey.copied = true; + allow(); + }; + go.onclick = () => { signer = keySigner(newKey.secret); newKey = null; drawSignIn(box, s); }; + box.querySelector('[data-cancel]').onclick = () => { newKey = null; drawSignIn(box, s); }; + return; + } + + const hasExtension = !!globalThis.nostr; + const known = s.installed && s.buyer ? { key: 'license.signInKnown', hex: s.buyer } + : shop.buyer ? { key: 'license.signInLast', hex: shop.buyer } : null; + box.innerHTML = `${head} +

${say('license.signInIntro')}

+ ${known ? `

${esc(t(known.key, { npub: '§' })).replace('§', npubText(known.hex))}

` : ''} +
+ + + +
+ ${hasExtension ? '' : `

${say('license.signInExtensionNone')}

`} + +

`; + + const msg = box.querySelector('[data-signin-msg]'); + const fault = (text) => { msg.className = 't-body-sm mt-sm t-neg'; msg.textContent = text; }; + box.querySelector('[data-ext]').onclick = async () => { + try { signer = await extensionSigner(); drawSignIn(box, s); } catch (err) { fault(err.message); } + }; + const form = box.querySelector('[data-key-form]'); + box.querySelector('[data-key]').onclick = () => { form.hidden = false; form.querySelector('input').focus(); }; + form.onsubmit = (e) => { + e.preventDefault(); + const input = form.querySelector('input'); + const { secret, error } = secretFromText(input.value); + input.value = ''; + if (error) { fault(t(error === 'npub' ? 'license.signInNpub' : 'license.signInBadKey')); return; } + signer = keySigner(secret); + drawSignIn(box, s); + }; + box.querySelector('[data-new]').onclick = () => { newKey = { secret: newSecret(), copied: false }; drawSignIn(box, s); }; +} + +const BUY_POLL_MS = 3000; +let buyBox = null; +let serviceUrl = null; +let purchase = null; +let pollTimer = null; + +const sats = (n) => Number(n).toLocaleString(localeTag()); +const clock = (ms) => new Date(ms).toLocaleTimeString(localeTag(), { hour: '2-digit', minute: '2-digit' }); +const FINAL = { expired: 'license.buyExpired', canceled: 'license.buyCanceled', underpaid: 'license.buyUnderpaid' }; +const AWAY = new Set(['marketService.unreachable', 'marketService.timeout', 'marketService.unavailable']); + +async function drawBuy() { + const box = buyBox; + if (!box) return; + const head = `
${say('license.buyTitle')}
`; + + if (purchase) { + const o = purchase.order; + const final = FINAL[o.status]; + const state = final ? say(final) + : o.status === 'paid' ? say(o.waived ? 'license.buyWaived' : 'license.buyPaid') + : say('license.buyWaiting'); + box.innerHTML = `${head} + ${o.status === 'open' && o.paymentRequest ? ` +

${say('license.buyInvoice', { time: clock(o.expiresAt) })}

+
${qrSvg(invoiceQrText(o.paymentRequest), { label: t('license.buyQr') })}
+ +
+ ${say('license.buyOpenWallet')} + +
` : ''} +

${state}

+

${purchase.note ? esc(purchase.note) : ''}

+ ${final ? `
` : ''}`; + const copy = box.querySelector('[data-invoice-copy]'); + if (copy) copy.onclick = (e) => copyText(o.paymentRequest, box.querySelector('[data-invoice]')).then((ok) => { if (ok) e.target.textContent = t('license.buyCopied'); }); + const again = box.querySelector('[data-buy-again]'); + if (again) again.onclick = () => { purchase = null; drawBuy(); }; + return; + } + + let sale = null; + let problem = null; + try { sale = await saleInfo(); } catch (err) { problem = AWAY.has(err.key) ? t('license.buyAway') : err.message; } + const price = sale?.open ? say('license.buyPrice', { major: sale.major, sats: sats(sale.priceSat) }) + : sale ? say('license.buyClosed') : esc(problem ?? ''); + box.innerHTML = `${head} +

${price}

+ ${sale?.open && !signer ? `

${say('license.buySignInFirst')}

` : ''} +
+

`; + box.querySelector('[data-buy-go]').onclick = async (e) => { + const msg = box.querySelector('[data-buy-msg]'); + e.target.disabled = true; + try { + const body = '{}'; + const header = await nip98Header(signer, { url: `${serviceUrl}/v1/orders`, method: 'POST', body }); + const { order, readKey } = await placeOrder(header, body); + purchase = { order, readKey, note: '' }; + drawBuy(); + schedulePoll(); + } catch (err) { + e.target.disabled = false; + msg.className = 't-body-sm mt-sm t-neg'; + msg.textContent = err.message; + } + }; +} + +function schedulePoll() { + clearTimeout(pollTimer); + pollTimer = setTimeout(pollOnce, BUY_POLL_MS); +} + +async function pollOnce() { + if (!purchase?.readKey) return; + try { + const { order, license } = await orderStatus(purchase.order.id, purchase.readKey); + purchase.order = order; + purchase.note = ''; + if (license) { + drawBuy(); + const msg = buyBox?.querySelector('[data-buy-msg]'); + if (!license.valid) { purchase.note = t('license.buyNotValid'); drawBuy(); return; } + if (msg && buyBox.isConnected) await installAndReload(license.file, msg); + else await installLicense(license.file).then(() => location.reload()); + return; + } + drawBuy(); + if (FINAL[order.status]) return; + } catch (err) { + purchase.note = err.message; + drawBuy(); + } + schedulePoll(); +} + +const SUPPORT_POLL_MS = 30_000; +let supportBox = null; +let supportDraft = ''; +let supportFile = null; +let supportTimer = null; +const shots = new Map(); + +const supportUrl = (info, path) => `${info.serviceUrl}/v1/support/${path}`; + +async function drawSupport() { + const box = supportBox; + if (!box) return; + clearInterval(supportTimer); + box.hidden = !signer; + if (!signer) return; + let info = null; + try { info = await supportInfo(); } catch { info = null; } + const head = `
${say('license.supportTitle')}
+ ${info ? `` : ''}
`; + const nostr = info?.nostr?.pubkey + ? `

${esc(t('license.supportNostr', { name: '§' })).replace('§', `${esc(info.nostr.name)} (${npubText(info.nostr.pubkey)})`)}

` : ''; + if (!info?.serviceUrl) { + box.innerHTML = `${head}

${say('license.supportIntro')}

${nostr}`; + return; + } + const mb = info.maxAttachmentBytes / 1024 / 1024; + box.innerHTML = `${head} +

${say('license.supportIntro')}

+
+ + +
+
+ + + + ${say('license.supportAttachHint', { mb })} +
+

+ ${nostr}`; + + const field = box.querySelector('#lic-support'); + const msg = box.querySelector('[data-support-msg]'); + const thread = box.querySelector('[data-support-thread]'); + const picker = box.querySelector('[data-support-input]'); + const show = (cls, line) => { msg.className = `t-body-sm mt-sm ${cls}`; msg.textContent = line; }; + field.oninput = () => { supportDraft = field.value; }; + + const showFile = () => { + const area = box.querySelector('[data-support-file]'); + area.innerHTML = supportFile ? `
${esc(t('license.supportScreenshot'))} +
` : ''; + area.querySelector('[data-support-unpick]')?.addEventListener('click', () => { supportFile = null; showFile(); }); + }; + const take = (file) => { + if (!file) return; + if (!info.attachmentTypes.includes(file.type)) { show('t-neg', t('license.supportAttachType')); return; } + if (file.size > info.maxAttachmentBytes) { show('t-neg', t('license.supportAttachTooLarge', { mb })); return; } + const reader = new FileReader(); + reader.onload = () => { + const url = String(reader.result); + supportFile = { type: file.type, data: url.slice(url.indexOf(',') + 1), url }; + show('', ''); + showFile(); + }; + reader.readAsDataURL(file); + }; + showFile(); + box.querySelector('[data-support-pick]').onclick = () => picker.click(); + picker.onchange = () => { take(picker.files?.[0]); picker.value = ''; }; + field.addEventListener('paste', (e) => { + const item = [...(e.clipboardData?.items ?? [])].find(i => i.kind === 'file' && i.type.startsWith('image/')); + if (item) { e.preventDefault(); take(item.getAsFile()); } + }); + + box.querySelector('[data-support-refresh]').onclick = () => drawThread(thread, info); + drawThread(thread, info); + if (signer.kind === 'key') { + supportTimer = setInterval(() => { + if (!thread.isConnected) { clearInterval(supportTimer); return; } + drawThread(thread, info, { quiet: true }); + }, SUPPORT_POLL_MS); + } + + box.querySelector('[data-support-send]').onclick = async (e) => { + const text = field.value.trim(); + if (!text && !supportFile) { show('t-neg', t('license.supportEmpty')); return; } + if (text.length > info.maxText) { show('t-neg', t('license.supportTooLong', { max: info.maxText })); return; } + e.target.disabled = true; + e.target.textContent = t('license.supportSending'); + try { + const body = JSON.stringify({ text, attachment: supportFile ? { type: supportFile.type, data: supportFile.data } : null }); + const header = await nip98Header(signer, { url: supportUrl(info, 'messages'), method: 'POST', body }); + const sent = await sendSupport(header, body); + if (supportFile && sent.attachments?.[0]) shots.set(sent.attachments[0].id, supportFile.url); + supportDraft = ''; + supportFile = null; + field.value = ''; + showFile(); + show('', ''); + await drawThread(thread, info); + } catch (err) { + show('t-neg', AWAY.has(err.key) ? t('license.supportAway') : err.message); + } finally { + e.target.disabled = false; + e.target.textContent = t('license.supportSend'); + } + }; +} + +async function drawThread(area, info, { quiet = false } = {}) { + if (!quiet) area.innerHTML = `

${say('license.supportThreadLoading')}

`; + let list; + try { + const header = await nip98Header(signer, { url: supportUrl(info, 'messages'), method: 'GET' }); + list = await supportMessages(header); + } catch (err) { + if (quiet) return; + area.innerHTML = `

${esc(AWAY.has(err.key) ? t('license.supportAway') : err.message)}

`; + return; + } + if (!list.length) { area.innerHTML = `

${say('license.supportThreadEmpty')}

`; return; } + const when = (ms) => new Date(ms).toLocaleString(localeTag(), { dateStyle: 'short', timeStyle: 'short' }); + const who = (m) => t(m.from === 'buyer' ? 'license.supportYou' : m.ai ? 'license.supportAi' : 'license.supportSeller'); + const shot = (a) => (shots.has(a.id) + ? `${esc(t('license.supportScreenshot'))}` + : ``); + area.innerHTML = list.map(m => `
+
${m.text ? `

${esc(m.text)}

` : ''}${m.attachments.map(shot).join('')}
+ ${esc(who(m))} · ${esc(when(m.at))} +
`).join(''); + const toEnd = () => { area.scrollTop = area.scrollHeight; }; + toEnd(); + area.querySelectorAll('img').forEach(img => img.addEventListener('load', toEnd, { once: true })); + + const load = async (button) => { + const id = button.dataset.shot; + try { + const header = await nip98Header(signer, { url: supportUrl(info, `attachments/${id}`), method: 'GET' }); + const { mime, data } = await supportAttachment(header, id); + if (!info.attachmentTypes.includes(mime) || !/^[A-Za-z0-9+/=]+$/.test(data)) return; + shots.set(id, `data:${mime};base64,${data}`); + const img = document.createElement('img'); + img.className = 'lic-shot'; + img.alt = t('license.supportScreenshot'); + img.addEventListener('load', toEnd, { once: true }); + img.src = shots.get(id); + button.replaceWith(img); + } catch (err) { + button.textContent = err.message; + } + }; + area.querySelectorAll('[data-shot]').forEach(button => { + if (signer.kind === 'key') load(button); + else button.onclick = () => load(button); + }); +} + +async function drawLicenses(area, msg, shop) { + area.innerHTML = `

${say('license.licensesLoading')}

`; + let list; + try { + const header = await nip98Header(signer, { url: `${shop.serviceUrl}/v1/license`, method: 'GET' }); + list = await buyerLicenses(header); + } catch (err) { + area.innerHTML = ''; + msg.className = 't-body-sm t-neg'; + msg.textContent = err.message; + return; + } + if (!list.length) { area.innerHTML = `

${say('license.licensesNone')}

`; return; } + area.innerHTML = list.map((l, i) => { + if (l.unreadable) return `

${say('license.licenseNotValid')}

`; + const text = say('license.licenseLine', { + level: t(`license.level_${l.level}`), from: l.majorFrom, + to: l.majorTo === 'unlimited' ? t('license.unlimited') : l.majorTo, issued: l.issued, + }); + const state = !l.valid ? say('license.licenseNotValid') + : l.installed ? `${say('license.licenseInstalled')}` + : `` + + (l.covers ? '' : ` ${say('license.licenseNotCovered')}`); + return `
${text}${state}
`; + }).join(''); + area.querySelectorAll('[data-install]').forEach(b => { b.onclick = () => installAndReload(list[Number(b.dataset.install)].file, msg); }); +} + +async function copyText(text, field) { + try { field.select(); if (document.execCommand('copy')) return true; } catch { } + try { await navigator.clipboard.writeText(text); return true; } catch { return false; } +} + +function download(name, text) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([text], { type: 'text/plain' })); + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); +} + +const mark = (v) => { + if (v === true) return `${CHECK}`; + if (v === false) return `-`; + if (v === 'partial') return `${esc(t('license.featPartial'))}`; + return `?`; +}; + +const inFree = f => (f.has ? CONFIG.LICENSE.FREE.HAS[f.has] : f.free); + +function groupFree(items) { + const v = items.map(inFree); + if (v.every(x => x === true)) return true; + if (v.every(x => x === false)) return false; + return v.some(x => x === true) ? 'partial' : null; +} + +const groupKey = g => `feat_${g}`; + +function featuresHtml(expand) { + const groups = CONFIG.LICENSE.FEATURE_GROUPS.map((g) => { + const items = CONFIG.LICENSE.FEATURES.filter(f => f.group === g); + const sub = items.map(f => ` +
  • ${esc(t(`license.feat_${f.id}`))}${mark(true)}${mark(inFree(f))}
  • `).join(''); + return ` +
    + + ${CHEVRON}${esc(t(`license.featGroup_${g}`))}${mark(true)}${mark(groupFree(items))} + +
      ${sub}
    +
    `; + }).join(''); + const open = CONFIG.LICENSE.FEATURES.some(f => inFree(f) === null); + return ` +
    ${splashMarkHtml()}
    ${esc(t('license.featTitle'))}
    +
    +
    ${esc(t('license.featFull'))}${esc(t('license.featFree'))}
    + ${groups} +
    + ${open ? `

    ${esc(t('license.featFreeOpen'))}

    ` : ''}`; +} + +function placeShot(shot, row) { + const gap = 8, pad = 8; + const r = row.getBoundingClientRect(); + const w = shot.offsetWidth, h = shot.offsetHeight; + let top = r.top - gap - h; + if (top < pad) top = r.bottom + gap; + const left = Math.min(Math.max(pad, r.left + 24), window.innerWidth - pad - w); + shot.style.left = `${left}px`; + shot.style.top = `${Math.min(Math.max(pad, top), window.innerHeight - pad - h)}px`; +} + +function bindFeatures(root) { + document.querySelector('body > .lic-feat-shot')?.remove(); + const shot = document.createElement('div'); + shot.className = 'lic-feat-shot'; + shot.hidden = true; + document.body.appendChild(shot); + let current = null; + let on = null; + const show = (row) => { + if (row === on && !shot.hidden) return; + on = row; + root.querySelectorAll('[data-feature]').forEach(r => r.classList.toggle('is-on', r === row)); + shot.hidden = false; + placeShot(shot, row); + const id = row.dataset.feature; + if (id === current) return; + current = id; + shot.innerHTML = ''; + const img = new Image(); + img.className = 'lic-feat-img'; + img.alt = t(`license.feat_${id}`); + img.onload = () => { if (current === id) shot.replaceChildren(img); }; + img.onerror = () => { if (current === id) shot.innerHTML = `

    ${esc(t('license.featNoShot'))}

    `; }; + img.src = `${CONFIG.LICENSE.FEATURE_IMAGES}/${id}.webp`; + }; + const hide = () => { + on = null; + shot.hidden = true; + root.querySelectorAll('.is-on').forEach(r => r.classList.remove('is-on')); + }; + const over = (e) => { + if (!root.isConnected) { stop(); return; } + const row = e.target.closest?.('[data-feature]'); + if (row && root.contains(row)) show(row); else hide(); + }; + const stop = () => { + shot.remove(); + document.removeEventListener('pointerover', over); + window.removeEventListener('scroll', hide, true); + window.removeEventListener('hashchange', stop); + }; + document.addEventListener('pointerover', over); + window.addEventListener('scroll', hide, { capture: true, passive: true }); + window.addEventListener('hashchange', stop); + root.addEventListener('focusin', (e) => { const row = e.target.closest('[data-feature]'); if (row) show(row); }); + root.addEventListener('focusout', hide); + root.querySelectorAll('.lic-group').forEach(d => d.addEventListener('toggle', hide)); +} + +export async function render(el) { + const s = licenseSnapshot() ?? await loadLicense(); + const expand = await expanders('license', { + nostr: false, ...Object.fromEntries(CONFIG.LICENSE.FEATURE_GROUPS.map(g => [groupKey(g), false])), + }); + + el.innerHTML = ` +
    +
    +
    +
    +
    ${esc(t('license.cardTitle'))}
    + ${esc(t('license.cardVersion', { version: s.version }))} +
    + ${lines(s).map(r => `

    ${esc(r.text)}

    `).join('')} +
    + ${esc(t('license.dropHere'))} + ${esc(t('license.dropOr'))} + +
    + +
    +
    +
    +
    ${payHtml(expand)}
    +
    +
    +
    ${featuresHtml(expand)}
    + +
    +
    `; + expand.bind(el); + bindFeatures(el.querySelector('[data-features]')); + buyBox = el.querySelector('[data-buy]'); + supportBox = el.querySelector('[data-support]'); + drawSignIn(el.querySelector('[data-signin]'), s); + + markLicenseSeen().then(() => refreshAttention()).catch(() => { }); + + const message = el.querySelector('#lic-msg'); + const install = (text) => installAndReload(text, message); + + const MAX = 64 * 1024; + const zone = el.querySelector('#lic-drop'); + const picker = el.querySelector('#lic-file'); + + async function fromFile(file) { + if (!file) return; + if (file.size > MAX) { + message.className = 't-body-sm t-neg'; + message.textContent = t('license.dropTooBig'); + return; + } + await install(await file.text()); + } + + zone.onclick = () => picker.click(); + zone.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); picker.click(); } }; + picker.onchange = () => fromFile(picker.files?.[0]); + + zone.ondragover = (e) => { e.preventDefault(); zone.classList.add('is-over'); }; + zone.ondragleave = () => zone.classList.remove('is-over'); + zone.ondrop = (e) => { + e.preventDefault(); + zone.classList.remove('is-over'); + fromFile(e.dataTransfer?.files?.[0]); + }; +} diff --git a/client/pages/metal-products.js b/client/pages/metal-products.js new file mode 100644 index 0000000..808d510 --- /dev/null +++ b/client/pages/metal-products.js @@ -0,0 +1,87 @@ +// Generated, do not edit. sha256:ae09cffb6bcb813ac103c0c39e8227c38347409ffb83175ba705ade666f63970 +import { listMetalAssets, listMetalProducts, createMetalProduct, updateMetalProduct, deleteMetalProduct } from '../data/metals.js'; +import { tableViewCfg } from '../data/preferences.js'; +import { t, localeTag } from '../i18n/index.js'; +import * as dialog from '../ui/dialog.js'; +import { ENUMS, CONFIG } from '../../shared/config.js'; +import { renderTable } from '../ui/table.js'; +import { toBaseByFactor, fromBaseByFactor } from '../ui/format.js'; +import { assetLabelHtml } from '../ui/asset-label.js'; +import { esc } from '../ui/escape.js'; + +let tableApi = null; + +export async function mount(host) { + await buildTable(host); +} + +async function assetOpts() { return (await listMetalAssets()).map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` })); } +function formOpts() { return ENUMS.METAL_FORM.map(v => ({ value: v, label: t('enums.metal_form.' + v) })); } + +const DEC = CONFIG.METALS.DISPLAY_DECIMALS; +const fmtG = grams => grams.toLocaleString(localeTag(), { maximumFractionDigits: DEC }); + +async function buildTable(el) { + const metalAssets = await listMetalAssets(); + + const columns = [ + { key: 'assetId', label: t('metalProducts.asset'), sortable: true, sortValue: r => r.asset_symbol, + render: r => assetLabelHtml({ asset_name: r.asset_name, asset_symbol: r.asset_symbol }), + group: { value: r => r.asset_symbol, label: v => v }, + edit: { type: 'select', options: assetOpts } }, + { key: 'name', label: t('metalProducts.name'), sortable: true, text: true, + edit: { type: 'text', placeholder: 'Krugerrand 1 oz' } }, + { key: 'form', label: t('metalProducts.form'), sortable: true, + render: r => t('enums.metal_form.' + r.form), + group: { value: r => r.form, label: v => t('enums.metal_form.' + v) }, + edit: { type: 'select', options: formOpts } }, + { key: 'fineGrams', label: t('metalProducts.fineWeight'), align: 'num', sortable: true, sortValue: r => r.fineGrams, + render: r => `${fmtG(fromBaseByFactor(r.fineGrams, 'g'))} g · ${fmtG(fromBaseByFactor(r.fineGrams, 'ozt'))} ozt`, + edit: { + render: vals => { + const grams = Number.isFinite(Number(vals.fineGrams)) && vals.fineGrams !== '' && vals.fineGrams != null + ? fromBaseByFactor(vals.fineGrams, 'g') : ''; + const opt = u => ``; + return `
    + + +
    `; + }, + read: host => { + if (!host) return null; + const n = parseFloat(host.querySelector('[data-mp-weight]').value); + const u = host.querySelector('[data-mp-unit]').value; + if (!Number.isFinite(n) || n <= 0) return null; + return toBaseByFactor(n, u); + }, + } }, + ]; + + function toPayload(v) { + const assetId = Number(v.assetId); + const name = String(v.name || '').trim(); + const fineGrams = v.fineGrams; + if (!Number.isInteger(assetId) || !name) throw new Error(t('metalProducts.pickMetalAndName')); + if (!Number.isInteger(fineGrams) || fineGrams <= 0) throw new Error(t('metalProducts.fillFineGrams')); + return { assetId, name, form: v.form, fineGrams }; + } + + tableApi = renderTable(el, { + columns, rows: await listMetalProducts(), + title: t('metalProducts.title'), + ...(await tableViewCfg('metal-products', { key: 'assetId', dir: 'asc' })), + emptyText: t('metalProducts.none'), + noneLabel: t('table.none'), + collapsibleGroups: true, + newLabel: t('table.new'), + newDefaults: { assetId: metalAssets[0] ? metalAssets[0].id : '', form: 'coin' }, + saveLabel: t('metalProducts.save'), cancelLabel: t('metalProducts.cancel'), + onCreate: async v => { const id = await createMetalProduct(toPayload(v)); tableApi.refresh(await listMetalProducts(), { locate: id }); }, + onSave: async (id, v) => { await updateMetalProduct(id, toPayload(v)); tableApi.refresh(await listMetalProducts()); }, + onDelete: async id => { + if (!(await dialog.confirm({ message: t('metalProducts.confirmDelete'), danger: true }))) return; + try { await deleteMetalProduct(id); tableApi.refresh(await listMetalProducts()); } + catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); } + }, + }); +} diff --git a/client/pages/price-history.js b/client/pages/price-history.js new file mode 100644 index 0000000..a40efd9 --- /dev/null +++ b/client/pages/price-history.js @@ -0,0 +1,790 @@ +// Generated, do not edit. sha256:242a49647c9f6c4f00caf9c66c75b5120a0a8d45f2c0c051b6878a5f798ae976 +import { tableViewCfg } from '../data/preferences.js'; +import { + addManualPricePoint, upsertPriceHistory, listAllPriceHistory, listPriceGaps, listPriceHistory, + ignorePriceGap, listIgnoredPriceGaps, unignorePriceGap, listPriceAbsences, deletePriceAbsences, +} from '../data/price-history.js'; +import { listAssets } from '../data/assets.js'; +import { deleteTableRow } from '../data/table-viewer.js'; +import { t, localeTag } from '../i18n/index.js'; +import { fmtBasePrice, priceFromBase, isBaseAsset } from '../ui/format.js'; +import { dayNl } from '../../shared/time.js'; +import { baseCurrency, appSetting } from '../app-settings.js'; +import { serviceCoverage } from '../data/market.js'; +import { renderTable } from '../ui/table.js'; +import { mountSparkline } from '../ui/sparkline.js'; +import { fetchPriceVia, primaryProvider, fillClosesFor, daysToAsk } from '../price/providers.js'; +import { getProvider, listProviders } from '../data/providers.js'; +import { apiMetricsTotals } from '../data/api-metrics.js'; +import { priceSourceChain } from '../data/price-source.js'; +import { loadMatrixContext, matrixCardHtml, wireMatrix } from '../ui/source-matrix.js'; +import { diagnoseSources } from '../../shared/source-diagnosis.js'; +import { attentionDotHtml } from '../ui/attention.js'; +import { hygieneReport } from '../data/hygiene.js'; +import { dailyProvenance, provenanceCounts, provenanceRuns, PROVENANCE_KINDS } from '../../shared/price-provenance.js'; +import { esc } from '../ui/escape.js'; +import { assetLabel, assetLabelHtml, byKindAndLabel } from '../ui/asset-label.js'; +import { REFRESH, EYE_OFF, EYE, CHEVRON_RIGHT, CLOSE } from '../ui/icons.js'; +import { create as createTabs } from '../ui/tabs.js'; +import { expanders } from '../ui/expand.js'; +import { notesHtml, mountNotes } from '../ui/notes.js'; +import * as dialog from '../ui/dialog.js'; +import { ENUMS, CONFIG } from '../../shared/config.js'; +import { loadCharting, readPalette } from '../ui/chart-setup.js'; +import { hexToRgb } from '../ui/charting.js'; +import { isMetal, priceUnitLabel } from '../../shared/units.js'; + +const todayIso = () => new Date().toISOString().slice(0, 10); + +export const title = () => t('priceHistory.title'); + +let phTable = null; + +function localDateParts(iso) { + const d = new Date(iso); + const p = n => String(n).padStart(2, '0'); + return { date: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`, time: `${p(d.getHours())}:${p(d.getMinutes())}` }; +} +function fmtDateTime(ts) { + return new Intl.DateTimeFormat(localeTag(), { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(ts * 1000)); +} +function displayDate(r) { return r.resolution === 'timestamp' ? localDateParts(r.observedAt).date : r.observedAt.slice(0, 10); } +function resLabel(res) { return res === 'timestamp' ? t('priceHistory.resTimestamp') : t('priceHistory.resDay'); } +const dash = '-'; + +async function fetchDayPoint(sourceKey, asset, dateKey) { + const midnight = Math.floor(Date.parse(`${dateKey}T00:00:00Z`) / 1000); + let pt = null; + try { pt = await fetchPriceVia(sourceKey, asset, 'history_point', dateKey); } catch { pt = null; } + if (!pt) { + try { + const grid = await fetchPriceVia(sourceKey, asset, 'daily_history', dateKey); + if (Array.isArray(grid)) pt = grid.find(x => new Date(x.time * 1000).toISOString().slice(0, 10) === dateKey) || null; + } catch { } + } + if (!pt && dateKey === todayIso()) { + try { const s = await fetchPriceVia(sourceKey, asset, 'spot', dateKey); if (s && Number.isFinite(s.price)) pt = { time: midnight, price: s.price }; } catch { } + } + if (!pt || !Number.isFinite(pt.price)) return null; + return { time: pt.time ?? midnight, price: pt.price }; +} +async function fetchSpotPoint(sourceKey, asset) { + let s = null; + try { s = await fetchPriceVia(sourceKey, asset, 'spot', null); } catch { return null; } + if (!s || !Number.isFinite(s.price)) return null; + return { time: Math.floor(Date.now() / 1000), price: s.price }; +} + +export async function render(el) { + el.innerHTML = `
    `; + alleAssets = await listAssets(); + await renderCards(el); +} + +const CARD_DAYS = 365; +let kaarten = new Map(); +let manualId = null; +let bronNamen = new Map(); + +async function renderCards(el) { + const host = el.querySelector('#ph-gaps'); + if (!host) return; + let gaten, genegeerd, manual, bronnen, controle, afwezig; + try { + [gaten, genegeerd, manual, bronnen, controle, afwezig] = await Promise.all([listPriceGaps(), listIgnoredPriceGaps(), + getProvider('manual').catch(() => null), bronContext().catch(() => null), + hygieneReport({ kinds: CONTROLE_SOORTEN }).catch(() => null), listPriceAbsences().catch(() => [])]); + } catch { host.innerHTML = ''; return; } + const verdachtPerAsset = new Map(); + for (const f of (controle && controle.findings) || []) { + for (const r of f.rows) { + const aid = r.row.assetId; + if (!verdachtPerAsset.has(aid)) verdachtPerAsset.set(aid, new Map()); + const perSoort = verdachtPerAsset.get(aid); + if (!perSoort.has(f.kind)) perSoort.set(f.kind, []); + perSoort.get(f.kind).push(r); + } + } + manualId = manual ? manual.id : null; + if (bronnen) bronNamen = new Map(bronnen.providers.map(p => [p.id, p.name])); + const groepeer = rijen => { + const m = new Map(); + for (const g of rijen) { if (!m.has(g.assetId)) m.set(g.assetId, []); m.get(g.assetId).push(g); } + return m; + }; + const perAsset = groepeer(gaten.rows); + const genegeerdPerAsset = groepeer(genegeerd); + const afwezigPerAsset = groepeer(afwezig); + const vandaag = todayIso(); + const venster = shiftIso(vandaag, -(CARD_DAYS - 1)); + const nieuw = new Map(); + for (const asset of [...alleAssets].sort(byKindAndLabel)) { + if (isBaseAsset(asset)) continue; + let rijen = []; + try { rijen = await listPriceHistory(asset.id); } catch { rijen = []; } + if (!rijen.length && !perAsset.has(asset.id) && !genegeerdPerAsset.has(asset.id)) continue; + const eerste = rijen.length ? rijen[rijen.length - 1].observedAt.slice(0, 10) : vandaag; + const dagen = dailyProvenance(rijen, { + assetKind: asset.kind, manualProviderId: manualId, today: vandaag, + from: eerste > venster ? eerste : venster, until: vandaag, + }); + let bevindingen = []; + if (bronnen) { + try { + const historyChain = await priceSourceChain(asset.id, 'history'); + bevindingen = diagnoseSources(asset, { providers: bronnen.providers, historyChain, usage: bronnen.usage, + serviceCovers: bronnen.serviceCovered.has(asset.id) }); + } catch { bevindingen = []; } + } + const verdacht = [...(verdachtPerAsset.get(asset.id) || new Map())].map(([kind, rows]) => ({ kind, rows })); + nieuw.set(asset.id, { + asset, dagen, gaps: perAsset.get(asset.id) || [], ignored: genegeerdPerAsset.get(asset.id) || [], + absent: afwezigPerAsset.get(asset.id) || [], bevindingen, verdacht, + }); + } + kaarten = nieuw; + const meer = gaten.total > gaten.rows.length + ? `
    ${esc(t('priceHistory.gapsMore', { n: gaten.total - gaten.rows.length }))}
    ` : ''; + const html = [...kaarten.values()].map(kaartHtml).join(''); + host.innerHTML = `${html ? `
    ${html}
    ` : ''}${meer}`; + for (const k of kaarten.values()) tekenLijn(host, k); + const grid = host.querySelector('.ph-grid'); + grid?.addEventListener('click', e => { + const kaart = e.target.closest('[data-detail]'); + if (kaart) openDetail(el, Number(kaart.dataset.detail)); + }); + grid?.addEventListener('keydown', e => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const kaart = e.target.closest('[data-detail]'); + if (kaart && e.target === kaart) { e.preventDefault(); openDetail(el, Number(kaart.dataset.detail)); } + }); +} + +async function bronContext() { + const month = new Date().toISOString().slice(0, 7); + const providers = (await Promise.all((await listProviders()).map(p => getProvider(p.key)))).filter(Boolean); + const usage = {}; + for (const p of providers) { + if (!p.callsPerMonth) continue; + try { usage[p.id] = (await apiMetricsTotals(p.id, `${month}-01T00:00`)).calls; } catch { } + } + return { providers, usage, serviceCovered: await serviceCovered() }; +} + +async function serviceCovered() { + let mode = 'off'; + try { mode = appSetting('market_spot'); } catch { } + if (mode === 'off') return new Set(); + try { + const r = await serviceCoverage(); + return new Set(r.status === 'ok' ? r.assetIds : []); + } catch { return new Set(); } +} + +const CONTROLE_SOORTEN = ['price_unit_swap', 'price_outlier', 'price_sources_disagree', + 'price_not_positive', 'price_in_future']; + +function meldingenVan({ gaps, bevindingen = [], verdacht = [] }) { + const uit = bevindingen.map(b => ({ severity: b.severity, tekst: t(`priceHistory.diagCard_${b.code}`, { namen: (b.names || []).join(', ') }) })); + const n = verdacht.reduce((s, v) => s + v.rows.length, 0); + if (n) uit.push({ severity: 'attention', tekst: telTekst('suspect', n) }); + const dagen = gaps.reduce((s, g) => s + g.days, 0); + if (gaps.length) uit.push({ severity: 'attention', tekst: t('priceHistory.cardGaps', { gaten: telTekst('gapCount', gaps.length), dagen: telTekst('dayCount', dagen) }) }); + return uit; +} +const meldingHtml = m => `${attentionDotHtml([{ severity: m.severity, label: m.tekst, fade: 'full' }])}${esc(m.tekst)}`; + +const daysIncl = (from, until) => Math.round((Date.parse(`${until}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86400000) + 1; +const shiftIso = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10); +const kindLabel = kind => t(`priceHistory.kind_${kind}`); +const telTekst = (stam, n) => t(`priceHistory.${stam}${n === 1 ? 'One' : 'Many'}`, { n }); +const soortTelling = (kind, n) => t('priceHistory.kindCount', { n, soort: t(`priceHistory.kindCount_${kind}${n === 1 ? 'One' : 'Many'}`) }); + +function kaartHtml(k) { + const { asset, dagen } = k; + const counts = provenanceCounts(dagen); + const telling = dagen.length + ? PROVENANCE_KINDS.filter(k => counts[k] > 0) + .map(k => `${esc(soortTelling(k, counts[k]))}`) + .join('') + : `${esc(t('priceHistory.cardNoPrices'))}`; + const periode = dagen.length ? `${esc(t('priceHistory.cardPeriod', { dagen: telTekst('dayCount', dagen.length) }))}` : ''; + const melding = meldingenVan(k).map(meldingHtml).join(''); + return `
    +
    + ${assetLabelHtml(asset)} + ${esc(t('enums.asset_kind.' + asset.kind))} +
    +
    ${periode}${telling}
    +
    ${melding}
    +
    +
    +
    +
    +
    `; +} + +function tekenLijn(host, { asset, dagen }) { + const lijnEl = host.querySelector(`[data-ph-spark="${asset.id}"]`); + const strookEl = host.querySelector(`[data-ph-strip="${asset.id}"]`); + if (!lijnEl || !strookEl || !dagen.length) return; + const perMs = new Map(); + const punten = []; + for (const d of dagen) { + if (d.fiatPrice == null) continue; + const ms = Date.parse(`${d.day}T00:00:00Z`); + perMs.set(ms, d); + punten.push([ms, priceFromBase(d.fiatPrice, asset)]); + } + mountSparkline(lijnEl, punten, { + tone: 'accent', + formatValue: (v, ms) => { + const d = perMs.get(ms); + return [fmtBasePrice(v),d ? d.day : null, d ? kindLabel(d.kind) : null].filter(Boolean).join(' · '); + }, + }); + tekenStrook(strookEl, dagen); +} + +function tekenStrook(strookEl, dagen) { + const n = dagen.length; + if (!n) { strookEl.innerHTML = ''; return; } + let x = 0; + const rects = provenanceRuns(dagen).map(r => { + const titel = r.days === 1 + ? t('priceHistory.stripDay', { dag: r.from, soort: kindLabel(r.kind) }) + : t('priceHistory.stripRange', { van: r.from, tot: r.until, soort: kindLabel(r.kind) }); + const rect = `${esc(titel)}`; + x += r.days; + return rect; + }).join(''); + strookEl.innerHTML = ``; +} + +let detailMelding = null; +let detail = null; +let detailChart = null; + +async function laadDetail(asset) { + let rijen = []; + try { rijen = await listPriceHistory(asset.id); } catch { rijen = []; } + detail = { assetId: asset.id, dagen: dailyProvenance(rijen, { assetKind: asset.kind, manualProviderId: manualId, today: todayIso() }) }; +} + +async function openDetail(el, assetId) { + const k = kaarten.get(assetId); + if (!k) return; + await laadDetail(k.asset); + dialog.detail({ title: assetLabel(k.asset), titleHtml: assetLabelHtml(k.asset), + asideHtml: `${t('enums.asset_kind.' + k.asset.kind)}`, + html: `
    +
    +
    +
    +
    +
    +
    ` }); + mountDetailTabs(el, assetId, 'provenance'); + tekenDetailGrafiek(k.asset).catch(() => {}); +} + +function mountDetailTabs(el, assetId, active) { + const host = document.querySelector('.dialog-overlay [data-detail-tabs]'); + const k = kaarten.get(assetId); + if (!host || !k) return; + createTabs(host, { + active, + items: [ + { id: 'provenance', label: t('priceHistory.tabProvenance') }, + { id: 'source', label: t('priceHistory.tabSource') }, + { id: 'check', label: t('priceHistory.tabCheck'), when: () => k.verdacht.length > 0 }, + { id: 'gaps', label: t('priceHistory.tabGaps'), when: () => k.gaps.length > 0 || k.ignored.length > 0 || k.absent.length > 0 }, + { id: 'prices', label: t('priceHistory.tabPrices') }, + { id: 'notes', label: t('notes.tab') }, + ], + render: (id, panel) => { + const m = detailMelding; + detailMelding = null; + const kop = m ? `
    ${esc(m)}
    ` : ''; + if (id !== 'prices') { koersFilter = null; koersFocus = null; } + if (id === 'source') bronPaneel(el, panel, k, kop).catch(e => { panel.innerHTML = kop + `
    ${esc(e.message)}
    `; }); + else if (id === 'check') { panel.innerHTML = kop + controleHtml(k); wireControle(el, panel, k); } + else if (id === 'gaps') gatenPaneel(el, panel, k, kop).catch(e => { panel.innerHTML = kop + `
    ${esc(e.message)}
    `; }); + else if (id === 'prices') koersenPaneel(el, panel, k, kop); + else if (id === 'notes') { panel.innerHTML = kop + notesHtml(); mountNotes(panel, k.asset.id); } + else { + panel.innerHTML = kop + herkomstHtml(k, detail && detail.assetId === assetId ? detail.dagen : k.dagen); + panel.querySelectorAll('[data-to-gap]').forEach(b => b.onclick = () => { + const [from, until] = b.dataset.toGap.split('|'); + gatFocus = { from, until }; + mountDetailTabs(el, assetId, 'gaps'); + }); + } + }, + }); +} + +async function naWijziging(el, assetId, tab, melding = null) { + document.dispatchEvent(new CustomEvent('price-gaps-changed')); + await renderCards(el); + detailMelding = melding; + const k = kaarten.get(assetId); + if (k) await laadDetail(k.asset); + mountDetailTabs(el, assetId, tab); + if (k) tekenDetailGrafiek(k.asset).catch(() => {}); +} + +async function tekenDetailGrafiek(asset) { + const canvas = document.querySelector('.dialog-overlay [data-ph-chart]'); + const strook = document.querySelector('.dialog-overlay [data-ph-detail-strip]'); + if (!canvas || !detail || detail.assetId !== asset.id) return; + if (strook) tekenStrook(strook, detail.dagen); + const Chart = await loadCharting(); + const pal = readPalette(); + const kleur = { close: pal.accent, manual: pal.blue, live: pal.accent, spot: pal.sigYellow, carried: pal.textSec, none: pal.sigRed }; + const punten = detail.dagen.map(d => ({ x: Date.parse(`${d.day}T00:00:00Z`), y: d.fiatPrice == null ? null : priceFromBase(d.fiatPrice, asset), d })); + const soortOp = i => punten[i] && punten[i].d.kind; + const rgb = hexToRgb(pal.accent) || { r: 128, g: 128, b: 128 }; + const schaduw = ({ chart }) => { + const a = chart.chartArea; + if (!a) return 'transparent'; + const g = chart.ctx.createLinearGradient(0, a.top, 0, a.top + a.height * 0.85); + g.addColorStop(0, `rgba(${rgb.r},${rgb.g},${rgb.b},0.28)`); + g.addColorStop(1, `rgba(${rgb.r},${rgb.g},${rgb.b},0)`); + return g; + }; + if (detailChart) detailChart.destroy(); + detailChart = new Chart(canvas.getContext('2d'), { + type: 'scatter', + data: { datasets: [{ + data: punten, showLine: true, spanGaps: false, borderWidth: 1.5, cubicInterpolationMode: 'monotone', + fill: 'start', backgroundColor: schaduw, + segment: { + borderColor: c => kleur[soortOp(c.p1DataIndex)] || pal.accent, + borderDash: c => (soortOp(c.p1DataIndex) === 'carried' ? [4, 3] : undefined), + }, + pointRadius: c => (['spot', 'manual'].includes(soortOp(c.dataIndex)) ? 2.5 : 0), + pointHoverRadius: 4, pointBorderWidth: 0, + pointBackgroundColor: c => kleur[soortOp(c.dataIndex)] || pal.accent, + }] }, + options: { + responsive: true, maintainAspectRatio: false, animation: false, + layout: { padding: { top: 6, bottom: 3 } }, + interaction: { mode: 'nearest', axis: 'x', intersect: false }, + plugins: { + legend: { display: false }, + tooltip: { + filter: i => i.parsed.y != null, + backgroundColor: pal.tooltipBg, borderColor: pal.border, borderWidth: 1, titleColor: pal.accent, bodyColor: pal.text, + callbacks: { + title: items => (items[0] ? punten[items[0].dataIndex].d.day : ''), + label: i => tooltipRegels(punten[i.dataIndex].d, i.parsed.y), + }, + }, + zoom: { zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' }, pan: { enabled: true, mode: 'x' } }, + }, + scales: { + x: { type: 'linear', display: false }, + y: { display: false }, + }, + }, + }); +} + +function tooltipRegels(d, prijs) { + const regels = [fmtBasePrice(prijs)]; + const bron = d.row ? bronNamen.get(d.row.providerId) : null; + regels.push(bron ? `${kindLabel(d.kind)} · ${bron}` : kindLabel(d.kind)); + if (d.kind === 'spot' && d.row) { + const moment = d.row.resolution === 'timestamp' ? d.row.observedAt : (d.row.updatedAt || d.row.createdAt); + if (moment) regels.push(t('priceHistory.tipMoment', { tijd: localDateParts(moment).time })); + const pct = v => (v == null ? '?' : `${v > 0 ? '+' : ''}${v.toLocaleString(localeTag(), { maximumFractionDigits: 2 })}%`); + if (d.deviation) regels.push(t('priceHistory.tipDeviation', { voor: pct(d.deviation.before), na: pct(d.deviation.after) })); + } + if (d.kind === 'carried') regels.push(t('priceHistory.provCarriedNote', { n: d.ageDays })); + return regels; +} + +const overlapt = (r, g) => r.from <= (g.until ?? g.untilDay) && r.until >= (g.from ?? g.fromDay); +function herkomstHtml(k, dagen) { + const { asset } = k; + const counts = provenanceCounts(dagen); + const telling = PROVENANCE_KINDS.filter(k => counts[k] > 0) + .map(k => `${esc(soortTelling(k, counts[k]))}`) + .join(''); + const kop = dagen.length + ? `
    ${esc(t('priceHistory.cardPeriod', { dagen: telTekst('dayCount', dagen.length) }))}${telling}
    ` + : `

    ${esc(t('priceHistory.cardNoPrices'))}

    `; + const dicht = CONFIG.PRICE_HISTORY.CLOSED_MARKET_DAYS[asset.kind] ?? 0; + const zwak = provenanceRuns(dagen) + .filter(r => !['close', 'live', 'manual'].includes(r.kind) && !(r.kind === 'carried' && r.days <= dicht)) + .reverse(); + if (!dagen.length) return kop; + if (!zwak.length) return `${kop}

    ${esc(t('priceHistory.provNoWeak'))}

    `; + const toelichting = r => { + const stuk = dagen.filter(d => d.day >= r.from && d.day <= r.until); + if (r.kind === 'carried') return t('priceHistory.provCarriedNote', { n: Math.max(...stuk.map(d => d.ageDays)) }); + if (r.kind !== 'spot') return ''; + const pcts = stuk.flatMap(d => [d.deviation?.before, d.deviation?.after]).filter(v => v != null); + if (!pcts.length) return ''; + const max = pcts.reduce((a, b) => (Math.abs(b) > Math.abs(a) ? b : a)); + return t('priceHistory.provSpotNote', { pct: `${max > 0 ? '+' : ''}${max.toLocaleString(localeTag(), { maximumFractionDigits: 2 })}%` }); + }; + const bronVan = r => { + const namen = [...new Set(dagen.filter(d => d.day >= r.from && d.day <= r.until && d.row) + .map(d => bronNamen.get(d.row.providerId)).filter(Boolean))]; + return namen.length ? esc(namen.join(', ')) : dash; + }; + const naarGat = r => (r.kind === 'none' && [...k.gaps, ...k.ignored].some(g => overlapt(r, g)) + ? `` : ''); + const rijen = zwak.map(r => ` + ${esc(r.days === 1 ? r.from : t('priceHistory.provRange', { van: r.from, tot: r.until }))} + ${esc(kindLabel(r.kind))} + ${r.days} + ${bronVan(r)} + ${esc(toelichting(r))} + ${naarGat(r)} + `).join(''); + return `
    ${kop} +
    + + + + + + + + + ${rijen} +
    ${esc(t('priceHistory.provColPeriod'))}${esc(t('priceHistory.provColKind'))}${esc(t('priceHistory.provColDays'))}${esc(t('priceHistory.provColSource'))}${esc(t('priceHistory.provColNote'))}
    `; +} + +async function bronPaneel(el, panel, k, kop) { + const { asset } = k; + const regels = k.bevindingen.map(b => { + const tekst = t(`priceHistory.diag_${b.code}${b.code === 'limitReached' && b.severity !== 'problem' ? 'Fallback' : ''}`, + { namen: (b.names || []).join(', '), symbool: asset.symbol }); + const voorstel = b.proposal + ? `
    ${esc(t('priceHistory.diagProposal', { naam: b.proposal.providerName }))}
    ` : ''; + return `
    ${meldingHtml({ severity: b.severity, tekst })}${voorstel}
    `; + }).join(''); + panel.innerHTML = `${kop}${regels}
    `; + const host = panel.querySelector('[data-ph-matrix]'); + host.innerHTML = matrixCardHtml(asset, await loadMatrixContext(), { title: false }); + wireMatrix(host, () => naWijziging(el, asset.id, 'source')); +} + +function controleHtml({ verdacht }) { + const maat = c => (c.factor != null ? t('priceHistory.checkFactor', { n: c.factor }) + : c.afwijkingProcent != null ? t('priceHistory.checkShare', { n: c.afwijkingProcent }) + : c.verschilProcent != null ? t('priceHistory.checkShare', { n: c.verschilProcent }) : ''); + const bron = r => (r.provider_name || [r.low_provider_name, r.high_provider_name].filter(Boolean).join(' / ')); + return verdacht.map(({ kind, rows }, ki) => ` +
    +
    + ${esc(t(`health.kind.${kind}`))} + +
    +

    ${esc(t(`health.why.${kind}`))}

    +
    ${rows.map(({ row, counts }, ri) => ` +
    + ${esc(dayNl(localDateParts(row.observedAt).date))} · ${esc(bron(row))} + + ${esc(maat(counts || {}))} + + +
    `).join('')}
    +
    `).join('') + `

    ${esc(t('priceHistory.checkHint'))}

    `; +} + +let koersFilter = null; +let koersFocus = null; + +const dagVan = row => row.observedAt.slice(0, 10); +function filterVoor(rows) { + const ids = new Set(rows.filter(r => r.row.priceHistoryId != null).map(r => r.row.priceHistoryId)); + const dagen = new Set(rows.filter(r => r.row.priceHistoryId == null).map(r => dagVan(r.row))); + return r => ids.has(r.id) || (r.resolution === 'day' && dagen.has(dagVan(r))); +} + +function wireControle(el, panel, k) { + const naarKoersen = () => mountDetailTabs(el, k.asset.id, 'prices'); + panel.querySelectorAll('[data-check-kind]').forEach(b => b.onclick = () => { + const { kind, rows } = k.verdacht[Number(b.dataset.checkKind)]; + koersFilter = { label: t('priceHistory.checkFiltered', { soort: t(`health.kind.${kind}`), n: rows.length }), match: filterVoor(rows) }; + naarKoersen(); + }); + panel.querySelectorAll('[data-check-row]').forEach(b => b.onclick = () => { + const [ki, ri] = b.dataset.checkRow.split(':').map(Number); + const r = k.verdacht[ki].rows[ri]; + if (r.row.priceHistoryId != null) koersFocus = r.row.priceHistoryId; + else koersFilter = { label: t('priceHistory.checkFilteredDay', { dag: dagVan(r.row) }), match: filterVoor([r]) }; + naarKoersen(); + }); +} + +function koersenPaneel(el, panel, k, kop) { + const filter = koersFilter + ? `
    ${esc(koersFilter.label)} +
    ` + : ''; + panel.innerHTML = kop + filter + '
    '; + panel.querySelector('[data-clear-filter]')?.addEventListener('click', () => { + koersFilter = null; + mountDetailTabs(el, k.asset.id, 'prices'); + }); + buildTable(el, panel.querySelector('[data-ph-table]'), k.asset); +} + +function voortgang(panel) { + const box = panel.querySelector('[data-confirm]'); + box.hidden = false; + box.innerHTML = `
    ${esc(t('priceHistory.gapBusy'))}
    +
    + +
    `; + return box.querySelector('[data-stop]'); +} + +const gatKnop = (attr, i, label, icoon) => + ``; + +let gatFocus = null; + +async function gatenPaneel(el, panel, k, kop) { + const dagen = k.gaps.reduce((s, g) => s + g.days, 0); + const focus = gatFocus; + gatFocus = null; + const inFocus = g => (focus && overlapt(focus, g) ? ' is-focus' : ''); + const expand = await expanders('priceHistory', { ignored: false, absent: false }); + const kopregel = k.gaps.length ? ` +
    + ${esc(t('priceHistory.gapCardSummary', { gaten: telTekst('gapCount', k.gaps.length), dagen: telTekst('dayCount', dagen) }))} + + + + +
    ` : `

    ${esc(t('priceHistory.gapNone'))}

    `; + const lijst = `
    ${k.gaps.map((g, i) => ` +
    + ${esc(t('priceHistory.gapLine', { van: g.from, tot: g.until, n: g.days }))} + ${gatKnop('data-gap', i, t('priceHistory.gapFill'), REFRESH)}${gatKnop('data-gap-ignore', i, t('priceHistory.gapIgnore'), EYE_OFF)} +
    `).join('')}
    `; + const openVoorFocus = k.ignored.some(g => inFocus(g)) && !expand.open('ignored'); + const genegeerd = k.ignored.length ? ` +
    + ${CHEVRON_RIGHT}${esc(t('priceHistory.tabIgnored'))} (${k.ignored.length}) +
    ${k.ignored.map(g => ` +
    + ${esc(t('priceHistory.gapLine', { van: g.fromDay, tot: g.untilDay, n: daysIncl(g.fromDay, g.untilDay) }))} + ${gatKnop('data-unignore', g.id, t('priceHistory.gapUnignore'), EYE)} +
    `).join('')}
    +
    ` : ''; + const afwezig = k.absent.length ? ` +
    + ${CHEVRON_RIGHT}${esc(t('priceHistory.tabAbsent'))} (${k.absent.length}) +

    ${esc(t('priceHistory.absentExplain'))}

    +
    ${k.absent.map((g, i) => ` +
    + ${esc(g.days === 1 ? t('priceHistory.absentLineOne', { bron: g.providerName, dag: g.fromDay }) + : t('priceHistory.absentLine', { bron: g.providerName, van: g.fromDay, tot: g.untilDay, dagen: telTekst('dayCount', g.days) }))} + ${gatKnop('data-absent-forget', i, t('priceHistory.absentForget'), CLOSE)} +
    `).join('')}
    +
    ` : ''; + panel.innerHTML = `${kop}${kopregel}${lijst}${genegeerd}${afwezig}`; + panel.querySelector('.gap-row.is-focus')?.scrollIntoView({ block: 'center' }); + expand.bind(panel); + panel.querySelector('[data-gap-all]')?.addEventListener('click', () => fillGaps(el, panel, k, k.gaps)); + panel.querySelectorAll('[data-gap]').forEach(b => b.onclick = () => fillGaps(el, panel, k, [k.gaps[Number(b.dataset.gap)]])); + panel.querySelectorAll('[data-gap-ignore]').forEach(b => b.onclick = () => negeer(el, k, [k.gaps[Number(b.dataset.gapIgnore)]])); + panel.querySelector('[data-gap-ignore-all]')?.addEventListener('click', () => negeer(el, k, k.gaps)); + panel.querySelectorAll('[data-unignore]').forEach(b => b.onclick = async () => { + let melding = null; + try { await unignorePriceGap(Number(b.dataset.unignore)); } catch (e) { melding = e.message; } + await naWijziging(el, k.asset.id, 'gaps', melding); + }); + panel.querySelectorAll('[data-absent-forget]').forEach(b => b.onclick = async () => { + const g = k.absent[Number(b.dataset.absentForget)]; + let melding = null; + try { await deletePriceAbsences({ assetId: g.assetId, providerId: g.providerId, from: g.fromDay, until: g.untilDay }); } + catch (e) { melding = e.message; } + await naWijziging(el, k.asset.id, 'gaps', melding); + }); +} + +async function negeer(el, k, gaps) { + const vraag = gaps.length === 1 ? t('priceHistory.gapIgnoreConfirm') : t('priceHistory.gapIgnoreAllConfirm', { n: gaps.length }); + const label = t(gaps.length === 1 ? 'priceHistory.gapIgnore' : 'priceHistory.gapIgnoreAll'); + if (!(await dialog.confirmOver({ title: label, message: vraag, confirmLabel: label }))) return; + let melding = null; + try { + for (const g of gaps) await ignorePriceGap({ assetId: k.asset.id, from: g.from, until: g.until }); + } catch (e) { melding = e.message; } + await naWijziging(el, k.asset.id, 'gaps', melding); +} + +async function fillGaps(el, panel, k, gaps) { + const { asset } = k; + if (!gaps || !gaps.length) return; + const reeks = await primaryProvider(asset, 'daily_history'); + const perDatum = await primaryProvider(asset, 'history_point'); + if (!reeks && !perDatum) { + detailMelding = t('priceHistory.gapNoSource', { asset: asset.symbol }); + mountDetailTabs(el, asset.id, 'gaps'); + return; + } + const dagen = gaps.reduce((s, g) => s + daysToAsk(asset, g.from, g.until), 0); + const vraag = reeks && perDatum ? t('priceHistory.gapConfirmBoth', { reeks: reeks.name, bron: perDatum.name, n: dagen }) + : reeks ? t('priceHistory.gapConfirmSeries', { reeks: reeks.name }) + : t('priceHistory.gapConfirmPoint', { bron: perDatum.name, n: dagen }); + if (!(await dialog.confirmOver({ title: t('priceHistory.gapFill'), message: vraag, confirmLabel: t('priceHistory.gapFill') }))) return; + + panel.querySelectorAll('button').forEach(b => { b.disabled = true; }); + const btn = voortgang(panel); + const stop = new AbortController(); + btn.disabled = false; + btn.onclick = () => { stop.abort(); btn.disabled = true; }; + let stopped = false, limitReached = false, fout = null; + try { + for (let i = 0; i < gaps.length && !stop.signal.aborted; i++) { + btn.textContent = gaps.length > 1 ? t('priceHistory.gapStopProgress', { i: i + 1, n: gaps.length }) : t('priceHistory.gapStop'); + const uit = await fillClosesFor(asset, gaps[i].from, gaps[i].until, { signal: stop.signal }); + if (uit.stopped) stopped = true; + if (uit.limitReached) { limitReached = true; break; } + } + if (stop.signal.aborted) stopped = true; + } catch (e) { + fout = e.message; + } + let resterend = []; + try { + resterend = (await listPriceGaps()).rows + .filter(g => g.assetId === asset.id && gaps.some(h => g.from <= h.until && g.until >= h.from)); + } catch { } + const rest = resterend.reduce((s, g) => s + g.days, 0); + let waarom = ''; + if (rest && stopped) waarom = t('priceHistory.gapStopped'); + else if (rest && limitReached) waarom = t('priceHistory.gapLimit'); + else if (rest) { + if (perDatum) waarom = t('priceHistory.gapWhyPoint', { bron: perDatum.name }); + else { + let begin = null; + try { + for (const r of await listPriceHistory(asset.id)) { + const dag = r.observedAt.slice(0, 10); + if (r.providerId === reeks.id && r.resolution === 'day' && (!begin || dag < begin)) begin = dag; + } + } catch { } + if (!begin) waarom = t('priceHistory.gapWhySeriesEmpty', { reeks: reeks.name }); + else if (resterend.some(g => g.from < begin)) waarom = t('priceHistory.gapWhySeries', { reeks: reeks.name, begin }); + else waarom = t('priceHistory.gapWhySeriesHole', { reeks: reeks.name, n: rest }); + } + } + const melding = fout || (!rest ? t('priceHistory.gapFilled', { asset: asset.symbol }) + : `${t('priceHistory.gapRemaining', { asset: asset.symbol, n: rest })} ${waarom}`); + await naWijziging(el, asset.id, 'gaps', melding); +} + +async function tableRows(asset) { + const rijen = await listAllPriceHistory({ assetId: asset.id }); + return koersFilter ? rijen.filter(koersFilter.match) : rijen; +} + +let alleAssets = []; +const assetVan = id => alleAssets.find(a => a.id === Number(id)) || null; + +const eenheidVan = asset => (asset ? priceUnitLabel(asset) : ''); +function eenheidOpts(a) { + if (!a || !isMetal(a)) return []; + return [a.unit, a.subunit].filter(Boolean).map(u => ({ value: u, label: u })); +} +function momentVan(vals) { + if (vals.resolution !== 'timestamp') return String(vals.date || ''); + const tijd = String(vals.time || '00:00'); + return new Date(`${vals.date}T${tijd}`).toISOString(); +} + +async function buildTable(el, host, asset) { + const tabelAsset = () => asset; + const columns = [ + { key: 'date', label: t('priceHistory.colDate'), sortable: true, sortValue: r => r.observedAt, render: r => r.observedAt ? esc(dayNl(displayDate(r))) : dash, group: { value: r => displayDate(r), label: v => dayNl(v), order: 'desc' }, + edit: { type: 'date', sticky: true, value: r => r && r.observedAt ? displayDate(r) : todayIso() } }, + { key: 'time', label: t('priceHistory.colTime'), sortable: true, sortValue: r => r.observedAt, render: r => r.resolution === 'timestamp' ? esc(localDateParts(r.observedAt).time) : dash, + edit: { type: 'time', visible: vals => vals.resolution === 'timestamp', + value: r => r && r.resolution === 'timestamp' ? localDateParts(r.observedAt).time : '00:00' } }, + { key: 'resolution', label: t('priceHistory.colResolution'), sortable: true, sortValue: r => r.resolution, render: r => r.resolution ? esc(resLabel(r.resolution)) : dash, group: { value: r => resLabel(r.resolution), label: v => v }, + edit: { type: 'select', reactive: true, sticky: true, value: r => r ? r.resolution : 'day', + options: () => ENUMS.PRICE_RESOLUTION.map(v => ({ value: v, label: resLabel(v) })) } }, + { key: 'source', label: t('priceHistory.colIntegration'), sortable: true, sortValue: r => r.provider_name, render: r => r.provider_name ? esc(r.provider_name) : dash, group: { value: r => r.provider_name, label: v => v } }, + { key: 'fiatPrice', label: baseCurrency(), align: 'num', sortable: true, sortValue: r => r.fiatPrice ?? -1, + render: r => r.fiatPrice != null ? fmtBasePrice(priceFromBase(r.fiatPrice, assetVan(r.assetId))) : dash, + edit: { type: 'number', attrs: { min: 0, step: 'any' }, + value: r => (r && r.fiatPrice != null ? priceFromBase(r.fiatPrice, assetVan(r.assetId)) : '') } }, + { key: 'priceUnit', label: t('priceHistory.colUnit'), + render: r => r.assetId ? esc(eenheidVan(assetVan(r.assetId))) : dash, + edit: { type: 'select', sticky: true, visible: () => isMetal(tabelAsset()), + options: () => eenheidOpts(tabelAsset()), value: () => eenheidVan(tabelAsset()) } }, + { key: 'updated', label: t('priceHistory.colUpdated'), sortable: true, sortValue: r => r.updatedAt || '', render: r => r.updatedAt ? esc(fmtDateTime(Math.floor(Date.parse(r.updatedAt) / 1000))) : dash }, + ]; + phTable = renderTable(host, { + columns, + rows: await tableRows(asset), + idKey: 'id', + ...(await tableViewCfg('prijsinfo', { key: 'date', dir: 'desc' })), + noneLabel: t('table.none'), + collapsibleGroups: true, + searchLabel: t('priceHistory.search'), + emptyText: t('priceHistory.noPoints'), + onDelete: id => onDeleteRow(el, asset, id), + onCreate: v => onCreateRow(el, asset, v), + extraActions: [{ key: 'refresh', label: t('priceHistory.refresh') }], + onAction: (key, id) => { if (key === 'refresh') onRefreshRow(el, asset, id); }, + }); + if (koersFocus != null) { phTable.focusRow(koersFocus); koersFocus = null; } +} + +async function naRijWijziging(el, asset, locate = null) { + if (phTable) phTable.refresh(await tableRows(asset), locate != null ? { locate } : undefined); + document.dispatchEvent(new CustomEvent('price-gaps-changed')); + await renderCards(el); + await laadDetail(asset); + tekenDetailGrafiek(asset).catch(() => {}); +} + +async function onCreateRow(el, asset, vals) { + const id = await addManualPricePoint({ + assetId: asset.id, + observedAt: momentVan(vals), + fiatPrice: vals.fiatPrice, + resolution: vals.resolution || 'day', + unit: vals.priceUnit || null, + }); + await naRijWijziging(el, asset, id); +} + +async function onDeleteRow(el, asset, id) { + if (!(await dialog.confirmOver({ message: t('priceHistory.confirmDeletePoint'), danger: true }))) return; + try { await deleteTableRow('PriceHistory', id); } catch { } + await naRijWijziging(el, asset); +} + +async function onRefreshRow(el, asset, id) { + const row = (await tableRows(asset)).find(r => r.id === id); + if (!row) return; + const a = { id: row.assetId, symbol: row.asset_symbol }; + const dateKey = row.observedAt.slice(0, 10); + try { + const bron = row.provider_key; + if (row.resolution === 'day') { + const pt = await fetchDayPoint(bron, a, dateKey); + if (pt) await upsertPriceHistory(a.id, [pt], bron, 'day'); + } else if (dateKey === todayIso()) { + const sp = await fetchSpotPoint(bron, a); + if (sp) await upsertPriceHistory(a.id, [sp], bron, 'timestamp'); + } + } catch { } + await naRijWijziging(el, asset); +} diff --git a/client/pages/reservations.js b/client/pages/reservations.js new file mode 100644 index 0000000..61b9c03 --- /dev/null +++ b/client/pages/reservations.js @@ -0,0 +1,93 @@ +// Generated, do not edit. sha256:877ef362c70027a6a3b598d45680633fae5e6da42177c2b820d61d28df5d8d58 +import { listSelectableAccounts } from '../data/accounts.js'; +import { listBookableAssets } from '../data/assets.js'; +import { createReservation, deleteReservation, listReservations, listReservationsForAccount, updateReservation } from '../data/reservations.js'; +import { tableViewCfg } from '../data/preferences.js'; +import { t } from '../i18n/index.js'; +import { assetLabelHtml } from '../ui/asset-label.js'; +import * as dialog from '../ui/dialog.js'; +import { renderTable } from '../ui/table.js'; +import { takeRowFocus } from '../ui/row-focus.js'; +import { fmtUnit, val, toSubunit, fromSubunit } from '../ui/format.js'; +import { esc } from '../ui/escape.js'; +import { setPageHint } from '../ui/page-hint.js'; +import { localDate, localDateNl, toUtcIso } from '../../shared/time.js'; +import { bookReservationFlow, reservationAccount, reservationDir, reservationMeta } from '../actions/reservation-book.js'; + +export async function render(el, { accountId = null, assetId = null, assetKinds = null } = {}) { + el.innerHTML = `
    `; + const onPage = accountId == null; + if (onPage) setPageHint(el, { title: t('nav.reservations'), rows: [t('reservations.hint')] }); + const allowed = a => (assetId == null || a.id === assetId) && (assetKinds == null || assetKinds.includes(a.kind)); + const assets = (await listBookableAssets()).filter(allowed); + const assetById = id => assets.find(a => a.id === Number(id)); + const rows = async () => (onPage ? await listReservations() : await listReservationsForAccount(accountId)) + .filter(r => assetId == null || r.assetId === assetId); + const now = new Date().toISOString(); + const dirLabel = d => t(d === 'out' ? 'reservations.dirOut' : 'reservations.dirIn'); + const accountOpts = async current => (await listSelectableAccounts({ ookId: Number(current) || null })) + .map(a => ({ value: a.id, label: a.name })); + + const columns = [ + { key: 'occurredAt', label: t('reservations.colDate'), sortable: true, + render: r => `${esc(localDateNl(r.occurredAt))}${r.occurredAt <= now + ? ` ${t('reservations.due')}` : ''}`, + edit: { type: 'date', value: r => (r ? localDate(r.occurredAt) : localDate(now)) } }, + ...(onPage ? [{ key: '_account', label: t('reservations.colAccount'), sortable: true, + sortValue: r => reservationAccount(r).name || '', + render: r => esc(reservationAccount(r).name || '-'), + group: { value: r => reservationAccount(r).name || '-', label: v => v }, + edit: { type: 'select', sticky: true, value: r => (r ? reservationAccount(r).id : ''), + options: vals => accountOpts(vals._account) } }] : []), + { key: '_dir', label: t('reservations.colDirection'), sortable: true, sortValue: r => reservationDir(r), + render: r => dirLabel(reservationDir(r)), + edit: { type: 'select', sticky: true, value: r => (r ? reservationDir(r) : 'out'), + options: () => [{ value: 'out', label: dirLabel('out') }, { value: 'in', label: dirLabel('in') }] } }, + { key: 'assetId', label: t('reservations.colAsset'), sortable: true, sortValue: r => r.asset_symbol, + render: r => assetLabelHtml(r), + edit: { type: 'select', sticky: true, options: () => assets.map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` })) } }, + { key: 'amount', label: t('reservations.colAmount'), align: 'num', sortable: true, sortValue: r => r.amount, + render: r => val(fmtUnit(r.amount, reservationMeta(r))), + edit: { type: 'number', attrs: { min: 0, step: 'any' }, + value: r => (r ? fromSubunit(r.amount, reservationMeta(r)) : '') } }, + { key: 'note', label: t('reservations.colNote'), text: true, + render: r => `${esc(r.note || '')}`, + edit: { type: 'text', expand: true } }, + ]; + + const toPayload = v => { + const asset = assetById(v.assetId); + const qty = parseFloat(v.amount); + const account = onPage ? Number(v._account) || null : accountId; + if (!v.occurredAt || !asset || !account || !Number.isFinite(qty) || qty <= 0) throw new Error(t('reservations.fill')); + return { + accountId: account, assetId: asset.id, direction: v._dir === 'in' ? 'in' : 'out', + amount: toSubunit(qty, asset), occurredAt: toUtcIso(`${v.occurredAt}T00:00:00`), + note: (v.note || '').trim() || null, + }; + }; + + let tableApi = null; + const reload = async () => tableApi.refresh(await rows()); + tableApi = renderTable(el.querySelector('#tbl'), { + columns, rows: await rows(), + ...(await tableViewCfg('reservations', { key: 'occurredAt', dir: 'asc' })), + emptyText: t(onPage ? 'reservations.noneAll' : 'reservations.none'), + noneLabel: t('table.none'), + newLabel: t('table.new'), + newDefaults: { _dir: 'out', assetId: assets[0]?.id ?? '', note: '' }, + saveLabel: t('transfers.save'), cancelLabel: t('transfers.cancel'), + onCreate: async v => { const id = await createReservation(toPayload(v)); tableApi.refresh(await rows(), { locate: id }); }, + onSave: async (id, v) => { await updateReservation(id, toPayload(v)); reload(); }, + onDelete: async id => { + if (!(await dialog.confirm({ message: t('reservations.confirmDelete'), danger: true }))) return; + await deleteReservation(id); reload(); + }, + extraActions: [{ key: 'book', label: t('reservations.book') }], + onAction: async (key, id) => { + const r = (await rows()).find(x => x.id === id); + if (key === 'book' && r && await bookReservationFlow(r)) reload(); + }, + }); + tableApi.focusRow(takeRowFocus('reservations')); +} diff --git a/client/pages/settings.js b/client/pages/settings.js new file mode 100644 index 0000000..c884f98 --- /dev/null +++ b/client/pages/settings.js @@ -0,0 +1,208 @@ +// Generated, do not edit. sha256:2c8b0cb9cf7b6e6878eecfd14445f87e0333bfc949df19e5e2747b65137b5411 +import { getSettings, updateSettings } from '../data/preferences.js'; +import { t, setLocale, LOCALES as CATALOGUES } from '../i18n/index.js'; +import { CONFIG, ENUMS, PREFERENCES } from '../../shared/config.js'; +import { setPageHint } from '../ui/page-hint.js'; +import { esc } from '../ui/escape.js'; +import { locked } from '../license.js'; +const LOCALES = Object.keys(CATALOGUES); + +const GLOW_GLASS = Object.freeze({ + off: { glow: 'off' }, + still: { glow: 'on', motion: 'off' }, + step: { glow: 'on', motion: 'step' }, + always: { glow: 'on', motion: 'always' }, +}); +const GLOW_BUSINESS = Object.freeze({ off: { glow: 'off' }, on: { glow: 'on' } }); + +export function glowChoice(s) { + if (s.glow === 'off') return 'off'; + if (s.profile !== 'glass') return 'on'; + return s.motion === 'off' ? 'still' : (s.motion || 'step'); +} + +export function glowPatch(profile, choice) { + const table = profile === 'glass' ? GLOW_GLASS : GLOW_BUSINESS; + return Object.hasOwn(table, choice) ? { ...table[choice] } : null; +} + +const media = q => typeof matchMedia === 'function' && matchMedia(q).matches; + +export async function render(el) { + const s = await getSettings(); + const glass = s.profile === 'glass'; + const business = !glass; + const glowVisible = glass || s.depth === 'deep'; + const motionBlocked = glass && s.glow !== 'off' && s.motion !== 'off' && media('(prefers-reduced-motion: reduce)'); + const transparencyBlocked = glass && media('(prefers-reduced-transparency: reduce)'); + const persona = !locked('signals'); + const kiosk = !locked('kiosk'); + + const glowOptions = glass + ? [['off', t('settings.glowOff')], ['still', t('settings.glowStill')], ['step', t('settings.glowStep')], ['always', t('settings.glowAlways')]] + : [['off', t('settings.glowOff')], ['on', t('settings.glowOn')]]; + + el.innerHTML = ` +
    +
    +
    ${t('settings.appearance')}
    + ${pillRow('theme', t('settings.theme'), [['dark', t('controls.dark')], ['light', t('controls.light')]], s.theme, { maxWidth: 300 })} + ${accentRow(s)} + ${pillRow('profile', t('settings.profile'), [['business', t('controls.business')], ['glass', t('controls.glass')]], s.profile, + { maxWidth: 300, notice: transparencyBlocked ? t('settings.transparencyBlocked') : '' })} + ${business ? pillRow('depth', t('settings.depth'), [['flat', t('controls.flat')], ['deep', t('controls.deep')]], s.depth, { maxWidth: 300 }) : ''} + ${glowVisible ? pillRow('glowmix', t(glass ? 'settings.glowBgMotion' : 'settings.glowBg'), glowOptions, glowChoice(s), + { maxWidth: glass ? 420 : 300, notice: motionBlocked ? t('settings.motionBlocked') : '' }) : ''} + ${glass && s.glow !== 'off' ? pillRow('glowshape', t('settings.glowShape'), + [['blobs', t('settings.glowShapeBlobs')], ['bands', t('settings.glowShapeBands')]], s.glowshape, { maxWidth: 300 }) : ''} + ${glass ? pillRow('rowsel', t('settings.rowsel'), + [['light', t('settings.rowselLight')], ['tint', t('settings.rowselTint')], ['pill', t('settings.rowselPill')]], s.rowsel, { maxWidth: 360 }) : ''} + ${glass ? pillRow('glass_render', t('settings.glassRender'), + [['mimic', t('settings.glassRenderMimic')], ['real', t('settings.glassRenderReal')]], s.glass_render, { maxWidth: 300 }) : ''} + ${glass ? glassRows(s) : ''} +
    +
    +
    ${t('settings.screen')}
    +
    + +
    + + +
    +
    + ${persona ? pillRow('persona', t('settings.persona'), + [['bitcoin', t('setup.persona_bitcoin')], ['broad', t('setup.persona_broad')]], s.persona, { maxWidth: 300 }) : ''} + ${pillRow('show_page_title', t('settings.pageTitle'), + [['1', t('settings.pageTitleShow')], ['0', t('settings.pageTitleHide')]], String(s.show_page_title), { maxWidth: 300 })} + ${pillRow('show_page_hint', t('settings.pageHint'), + [['1', t('settings.pageTitleShow')], ['0', t('settings.pageTitleHide')]], String(s.show_page_hint), { maxWidth: 300 })} + ${pillRow('result_signs', t('settings.resultSigns'), + [['none', t('settings.resultSignsNone')], ['negative', t('settings.resultSignsNegative')], ['both', t('settings.resultSignsBoth')]], + s.result_signs, { maxWidth: 420 })} + ${kiosk ? pillRow('kiosk', t('settings.kiosk'), + [['0', t('settings.kioskWindow')], ['1', t('settings.kioskFull')]], String(s.kiosk), { maxWidth: 300 }) : ''} + ${pillRow('auto_blur_min', t('settings.autoBlur'), + [...CONFIG.AUTO_BLUR.PRESETS_MIN.map(m => [String(m), `${m} ${t('settings.minutesShort')}`]), + [String(CONFIG.AUTO_BLUR.NEVER_MIN), t('settings.autoBlurManual')]], + String(s.auto_blur_min), { maxWidth: 420 })} +
    + + +
    +
    +
    `; + + setPageHint(el, { title: t('settings.hintTitle'), rows: [ + { heading: t('settings.appearance') }, + t('settings.hintProfile'), + t('settings.hintTheme'), + t('settings.depthHint'), + t('settings.hintGlow'), + t('settings.hintGlowShape'), + t('settings.hintRowsel'), + t('settings.hintGlassRender'), + t('settings.hintGlass'), + { heading: t('settings.hintAiHeading') }, + t('settings.hintAiIntro'), + t('settings.hintAiTempo'), + { heading: t('settings.hintScreenHeading') }, + `${t('settings.assetDisplay')}: ${t('settings.assetDisplayHint')}`, + ...(persona ? [`${t('settings.persona')}: ${t('settings.personaHint')}`] : []), + `${t('settings.pageTitle')}: ${t('settings.pageTitleHint')}`, + `${t('settings.pageHint')}: ${t('settings.pageHintHint')}`, + `${t('settings.resultSigns')}: ${t('settings.resultSignsHint')}`, + ...(kiosk ? [`${t('settings.kiosk')}: ${t('settings.kioskHint')}`] : []), + `${t('settings.autoBlur')}: ${t('settings.autoBlurHint')}`, + ] }); + + el.querySelectorAll('[data-field]').forEach(group => { + group.onclick = e => { + const b = e.target.closest('[data-v]'); if (!b) return; + const field = group.dataset.field; + if (field === 'glowmix') { + const patch = glowPatch(s.profile, b.dataset.v); + if (patch) apply(el, patch); + return; + } + apply(el, { [field]: b.dataset.v }); + }; + }); + el.querySelector('#f-locale').onchange = e => apply(el, { locale: e.target.value }); + for (const input of el.querySelectorAll('[data-glass]')) { + syncSlider(input); + input.oninput = () => syncSlider(input); + input.onchange = () => apply(el, { [input.dataset.glass]: Number(input.value) }); + } + const reset = el.querySelector('[data-glass-reset]'); + if (reset) reset.onclick = () => apply(el, Object.fromEntries(['glass_render', ...GLASS.map(([key]) => key)] + .map(key => [key, PREFERENCES.app[key].initial]))); + el.querySelector('[data-asset-display]').onclick = e => { + const b = e.target.closest('[data-toggle]'); if (!b) return; + const field = b.dataset.toggle; + const other = field === 'asset_show_name' ? 'asset_show_symbol' : 'asset_show_name'; + const next = s[field] ? 0 : 1; + if (next === 0 && !s[other]) return; + apply(el, { [field]: next }); + }; +} + +async function apply(el, patch) { + await updateSettings(patch); + if (patch.locale) setLocale(patch.locale); + document.dispatchEvent(new Event('settings-changed')); + render(el); +} + +function pillRow(field, label, options, current, { maxWidth = 500, notice = '' } = {}) { + const buttons = options.map(([v, lbl]) => + ``).join(''); + return ` +
    + +
    ${buttons}
    + ${notice ? `
    ${notice}
    ` : ''} +
    `; +} + +const GLASS = Object.freeze([ + ['glass_transparency', 'settings.glassTransparency', '--glass-transparency', '%', '%'], + ['glass_blur', 'settings.glassDialog', '--dialog-transparency', '%', ''], + ['glass_frost', 'settings.glassFrost', '--glass-frost', '%', '%'], +]); + +function glassRows(s) { + const rows = GLASS.map(([key, label, token, unit, tokenUnit]) => { + const { min, max } = PREFERENCES.app[key]; + return ``; + }).join(''); + return ` +
    + + ${rows} +
    +
    `; +} + +function syncSlider(input) { + const min = Number(input.min), max = Number(input.max), v = Number(input.value); + input.style.setProperty('--pct', `${((v - min) / (max - min)) * 100}%`); + const out = input.parentElement.querySelector('output'); + if (out) out.textContent = `${v}${input.dataset.unit}`; + document.documentElement.style.setProperty(input.dataset.token, `${v}${input.dataset.tokenUnit}`); +} + +function accentRow(s) { + const dots = ENUMS.ACCENT.map(a => { + const label = t(`controls.${a}`); + return ``; + }).join(''); + return ` +
    + +
    ${dots}
    +
    `; +} diff --git a/client/pages/stub.js b/client/pages/stub.js new file mode 100644 index 0000000..6defe24 --- /dev/null +++ b/client/pages/stub.js @@ -0,0 +1,13 @@ +// Generated, do not edit. sha256:3583845b6975a70bf6e3ec60e90764723b09df9714d36916609dadda9746f9e5 +import { t } from '../i18n/index.js'; +import { alertHtml } from '../ui/alert.js'; + +export function makeStub(navKey) { + return { + title: () => t(`nav.${navKey}`), + render(el) { + el.innerHTML = ` + ${alertHtml({ kind: 'neutral', icon: '🚧', title: t('stub.title'), body: t('stub.body') })}`; + }, + }; +} diff --git a/client/pages/system-tabs.js b/client/pages/system-tabs.js new file mode 100644 index 0000000..89ac4da --- /dev/null +++ b/client/pages/system-tabs.js @@ -0,0 +1,21 @@ +// Generated, do not edit. sha256:fbeb70898a324872c8f2444d8e03beae326a416826348a59cda101932ba82f5c +import { SYSTEM_PAGE } from '../../shared/config.js'; +import { createRegistry } from '../../shared/registry.js'; +import { locked } from '../license.js'; + +const tabs = createRegistry('system tab', { + validate: (e) => { if (!Object.values(SYSTEM_PAGE.TABS).includes(e.tab)) throw new Error(`Unknown system tab '${e.tab}'.`); }, +}); + +export function registerSystemTab(tab) { + const feature = tab.feature ?? null; + tabs.register({ ...tab, id: `${tab.id}:${feature ?? ''}`, tab: tab.id, feature, label: tab.label ?? `nav.${tab.id}` }); +} + +export function systemTabs() { + return Object.values(SYSTEM_PAGE.TABS).flatMap(id => { + const own = tabs.list().filter(r => r.tab === id).map(r => ({ ...r, id })); + const pick = own.find(r => r.feature && !locked(r.feature)) || own.find(r => !r.feature); + return pick ? [pick] : []; + }); +} diff --git a/client/pages/system.js b/client/pages/system.js new file mode 100644 index 0000000..c61087d --- /dev/null +++ b/client/pages/system.js @@ -0,0 +1,63 @@ +// Generated, do not edit. sha256:c54ac534bce5953f6b0bb207fdc1338feba7f454198f189ef3b97c5830760c48 +import { t } from '../i18n/index.js'; +import { getPref, setPref } from '../data/preferences.js'; +import { SYSTEM_PAGE, targetHash } from '../../shared/config.js'; +import { create as createTabs } from '../ui/tabs.js'; +import { attentionForRoute, attentionDotHtml } from '../ui/attention.js'; +import { registerSystemTab, systemTabs } from './system-tabs.js'; +import * as settings from './settings.js'; +import * as accountsPage from './accounts.js'; +import * as assetsPage from './assets.js'; +import * as connections from './connections.js'; +import * as aiPage from './ai.js'; +import * as licensePage from './license.js'; + +export const title = () => t('nav.system'); + +const ROUTE = SYSTEM_PAGE.ROUTE; +const TABS = SYSTEM_PAGE.TABS; +const PREF_SCOPE = ROUTE; + +registerSystemTab({ id: TABS.SETTINGS, render: panel => settings.render(panel) }); +registerSystemTab({ id: TABS.ASSETS, render: panel => assetsPage.render(panel) }); +registerSystemTab({ id: TABS.ACCOUNTS, render: panel => accountsPage.render(panel) }); +registerSystemTab({ id: TABS.INTEGRATIONS, label: 'nav.connections', subs: ['connections'], render: panel => connections.render(panel) }); +registerSystemTab({ id: TABS.AI, render: panel => aiPage.render(panel) }); +registerSystemTab({ id: TABS.LICENSE, render: panel => licensePage.render(panel) }); + +function parseHash() { + const parts = (location.hash || '').replace(/^#\/?/, '').split('/'); + return { tab: parts[1] || null, sub: parts[2] || null }; +} +const hashFor = tab => targetHash({ route: ROUTE, tab }); + +function tabDot(tabId) { + return attentionDotHtml(attentionForRoute(ROUTE).filter(a => (a.tab || '').split('/')[0] === tabId)); +} + +export async function render(el) { + const tabs = systemTabs(); + const TAB_IDS = tabs.map(x => x.id); + const { tab: hashTab, sub } = parseHash(); + let tab = TAB_IDS.includes(hashTab) ? hashTab : null; + if (!tab) { + const att = attentionForRoute(ROUTE).find(a => TAB_IDS.includes((a.tab || '').split('/')[0])); + tab = att ? att.tab.split('/')[0] : await getPref(PREF_SCOPE, 'tab', TAB_IDS[0]); + if (!TAB_IDS.includes(tab)) tab = TAB_IDS[0]; + history.replaceState(null, '', hashFor(tab)); + } + + el.innerHTML = '
    '; + let firstSub = sub; + createTabs(el.querySelector('[data-tabs]'), { + size: 'lg', + active: tab, + items: tabs.map(x => ({ id: x.id, label: t(x.label), dot: () => tabDot(x.id) })), + render: (id, panel) => { tabs.find(x => x.id === id).render(panel, firstSub); firstSub = null; }, + onChange: (id) => { + setPref(PREF_SCOPE, 'tab', id).catch(() => { }); + if (location.hash !== hashFor(id)) history.pushState(null, '', hashFor(id)); + }, + syncOn: ['attention-changed'], + }); +} diff --git a/client/pages/tracking.js b/client/pages/tracking.js new file mode 100644 index 0000000..de58d17 --- /dev/null +++ b/client/pages/tracking.js @@ -0,0 +1,606 @@ +// Generated, do not edit. sha256:fcc1131e05f7794fbe4f303c8c1237f96201e5ee9ed17491fd18d55b64828d40 +import { localDate } from '../../shared/time.js'; +import { listAccounts } from '../data/accounts.js'; +import { listLabels, createLabel } from '../data/labels.js'; +import { getPref, setPref } from '../data/preferences.js'; +import { walletTxEventMap, onchainAsset, syncAddressLinks } from '../data/tx-links.js'; +import { sourceBalance, accountOnchainBalance } from '../data/wallet-addresses.js'; +import { listWalletSources } from '../data/wallet-sources.js'; +import { listSourceTxs, setTxLabels, labelChannelTxs } from '../data/wallet-txs.js'; +import { syncTxProposals } from '../data/proposals.js'; +import { t, localeTag } from '../i18n/index.js'; +import { fmtInt, fmtUnit, val } from '../ui/format.js'; +import { matchDot, openMatchPopover } from '../ui/match-popover.js'; +import { copyButton } from '../ui/copy-button.js'; +import { CHEVRON, ARROW_DOWN, ARROW_UP } from '../ui/icons.js'; +import { isOnchainSource, scanSource } from '../onchain.js'; +import { loadFiat, fiatValue, fiatValueOrigin } from '../ui/fiat.js'; +import { originHtml } from '../ui/price-origin.js'; +import { labelComboMarkup, labelComboMount } from '../ui/label-combobox.js'; +import { resolveSelection } from '../ui/filter.js'; +import { volgMasonry } from '../ui/masonry.js'; +import { latestScanLog } from '../data/scan-logs.js'; +import { TARGETS, targetHash } from '../../shared/config.js'; +import { esc } from '../ui/escape.js'; +import { emptyStateHtml } from '../ui/empty-state.js'; +import { holdAttention } from '../ui/attention-ring.js'; +import { sourceBadgeText } from '../ui/source-badge.js'; +import { isLightningSource, scanLightningSource, lightningAccountSats, countMovements } from '../lightning/lightning-scan.js'; +import { memoLine } from '../lightning/memo-line.js'; + +const ACCOUNTS_HASH = targetHash(TARGETS.ACCOUNTS); + +const trunc = s => (s && s.length > 18) ? `${s.slice(0, 8)}…${s.slice(-6)}` : (s || ''); + +let selectedAccounts = null; +let eventMap = new Map(); +let btc = null; +let shownTxIds = new Set(); +let newTxIds = new Set(); +let scanBaseline = null; + +const FILTER_SCOPE = 'table.tracking'; +const FILTER_KEY = 'accounts'; +function saveFilter() { + setPref(FILTER_SCOPE, FILTER_KEY, [...selectedAccounts]).catch(() => { }); +} + +const COLLAPSE_KEY = 'collapsed'; +let collapsedSet = null; +let stopMasonry = null; +async function loadCollapsed() { + const ids = await getPref(FILTER_SCOPE, COLLAPSE_KEY, null); + return new Set(Array.isArray(ids) ? ids : []); +} +function saveCollapsed() { + setPref(FILTER_SCOPE, COLLAPSE_KEY, [...collapsedSet]).catch(() => { }); +} + +export const title = () => t('tracking.title'); + +export const headerInColumn = true; + +function btcAsset() { + return btc; +} +async function accountsWithSources() { + const accounts = await listAccounts(); + const bronnen = await Promise.all(accounts.map(a => listWalletSources(a.id))); + return accounts.filter((a, i) => bronnen[i].length > 0); +} +function fmtDate(key) { + const d = new Date(key + 'T00:00:00'); + return isNaN(d) ? key : d.toLocaleDateString(localeTag(), { day: 'numeric', month: 'short', year: 'numeric' }); +} +function fmtDateLong(key) { + const d = new Date(key + 'T00:00:00'); + return isNaN(d) ? key : d.toLocaleDateString(localeTag(), { day: 'numeric', month: 'long', year: 'numeric' }); +} + +function amt(amount) { + const a = btcAsset(); + return val(a ? fmtUnit(amount, a) : fmtInt(amount)); +} +function fiatSub(amount) { + const f = fiatValue(amount, btcAsset()); + return f ? val(f) + originHtml(fiatValueOrigin(btcAsset()), btcAsset()) : ''; +} +function amountHtml(amount, numClass = 't-num-md') { + return `
    ${amt(amount)}
    +
    ${fiatSub(amount)}
    `; +} + +function asideHtml(items) { + const rows = items.map(i => ` + `).join(''); + return ``; +} + +function stat(label, sleutel) { + return `
    ${label}
    …
    `; +} + +export async function render(el) { + btc = await onchainAsset(); + const accts = await accountsWithSources(); + if (accts.length === 0) { + el.innerHTML = `` + + emptyStateHtml(t('tracking.noAccountsWithSources'), { + href: ACCOUNTS_HASH, linkText: t('tracking.manageAccounts'), + }); + return; + } + const wallets = await Promise.all(accts.map(a => accountWallets(a.id))); + const items = accts.map((a, i) => ({ id: a.id, label: a.name, wallets: wallets[i].length })); + if (!selectedAccounts) + selectedAccounts = resolveSelection({ saved: await getPref(FILTER_SCOPE, FILTER_KEY, null), items }); + if (!collapsedSet) collapsedSet = await loadCollapsed(); + newTxIds = new Set(); + scanBaseline = null; + + el.innerHTML = `
    ${asideHtml(items)}` + + `
    ` + + `` + + `
    `; + + bindAside(el, items); + renderBody(el); + loadFiat().then(() => renderBody(el)).catch(() => {}); +} + +function bindAside(el, items) { + const aside = el.querySelector('.trk-aside'); + const alle = aside.querySelector('[data-acct-all]'); + const syncAct = () => { + const allOn = items.every(i => selectedAccounts.has(i.id)); + alle.textContent = allOn ? t('filter.none') : t('filter.all'); + }; + const zet = (sel) => { + selectedAccounts = sel; + for (const b of aside.querySelectorAll('[data-acct]')) { + const on = selectedAccounts.has(Number(b.dataset.acct)); + b.classList.toggle('is-on', on); + b.setAttribute('aria-pressed', String(on)); + } + syncAct(); + saveFilter(); + renderBody(el); + }; + syncAct(); + alle.onclick = () => zet(items.every(i => selectedAccounts.has(i.id)) + ? new Set() : new Set(items.map(i => i.id))); + aside.onclick = (e) => { + const b = e.target.closest('[data-acct]'); + if (!b) return; + const id = Number(b.dataset.acct); + const next = new Set(selectedAccounts); + if (next.has(id)) next.delete(id); else next.add(id); + zet(next); + }; + el.querySelector('#scan-all').onclick = () => scanAll(el); +} + +async function sourcesOf(accounts) { + const bronnen = await Promise.all(accounts.map(a => listWalletSources(a.id))); + return accounts.flatMap((a, i) => bronnen[i].map(s => ({ ...s, _account: a }))); +} +async function selectedSources() { + return sourcesOf((await accountsWithSources()).filter(a => selectedAccounts.has(a.id))); +} +async function allSources() { + return sourcesOf(await accountsWithSources()); +} + +function groupWallets(sources) { + const byKey = new Map(); + for (const s of sources) { + const named = (s.name || '').trim(); + const key = named ? `a${s._account.id}:${named}` : `s${s.id}`; + if (!byKey.has(key)) byKey.set(key, { account: s._account, name: named, sources: [] }); + byKey.get(key).sources.push(s); + } + const groups = [...byKey.values()]; + for (const g of groups) g.id = g.sources[0].id; + return groups; +} +const allWallets = async () => groupWallets(await allSources()); +const groupedWallets = async () => groupWallets(await selectedSources()); + +function sourceBadge(s) { + const { label, title } = sourceBadgeText(s); + return `${esc(label)}`; +} + +async function accountWallets(accountId) { + const byKey = new Map(); + for (const s of await listWalletSources(accountId)) { + const named = (s.name || '').trim(); + const key = named || `s${s.id}`; + if (!byKey.has(key)) byKey.set(key, { name: named, sources: [] }); + byKey.get(key).sources.push(s); + } + return [...byKey.values()]; +} + +async function renderBody(el) { + const body = el.querySelector('#acc-body'); + if (!body) return; + eventMap = await walletTxEventMap(); + + const wallets = await allWallets(); + const perWallet = await Promise.all(wallets.map(g => walletTxs(g.sources.map(s => s.id)))); + const ids = perWallet.flat().map(tx => tx.id); + if (scanBaseline) { + for (const id of ids) if (!scanBaseline.has(id)) newTxIds.add(id); + scanBaseline = null; + } + shownTxIds = new Set(ids); + const shown =wallets.map((g, i) => [g, perWallet[i]]).filter(([g]) => selectedAccounts.has(g.account.id)); + body.innerHTML = `
    ${ + (await Promise.all(shown.map(([g, txs]) => walletBlock(g, txs)))).join('')}
    `; + await vulKolom(el, wallets, perWallet); + + if (stopMasonry) stopMasonry(); + stopMasonry = volgMasonry(body.querySelector('.oca-grid'), ':scope > .oca-source'); + for (const card of body.querySelectorAll('.oca-source[data-new]')) holdAttention(card); + + const btc = await btcAsset(); + const openMatch = dot => openMatchPopover(dot, { + kind: 'tx', id: Number(dot.dataset.matchId), + assetId: btc ? btc.id : null, + onChange: () => syncAddressLinks().catch(() => 0).then(() => renderBody(el)), + }); + body.onclick = e => { + const collapseBtn = e.target.closest('[data-collapse]'); + if (collapseBtn) { + const id = Number(collapseBtn.dataset.collapse); + const card = collapseBtn.closest('.oca-source'); + const nowCollapsed = card.classList.toggle('is-collapsed'); + if (nowCollapsed) collapsedSet.add(id); else collapsedSet.delete(id); + collapseBtn.classList.toggle('is-open', !nowCollapsed); + saveCollapsed(); + return; + } + if (e.target.closest('summary') && + e.target.closest('[data-edit-label],[data-match-trigger],[data-label-combo]')) { + e.preventDefault(); + } + const dot = e.target.closest('[data-match-trigger]'); + if (dot) { openMatch(dot); return; } + const trigger = e.target.closest('[data-edit-label]'); + if (trigger) openLabelEditor(el, trigger); + }; + body.onkeydown = e => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const dot = e.target.closest('[data-match-trigger]'); + if (dot) { e.preventDefault(); openMatch(dot); } + }; +} + +async function vulKolom(el, wallets, perWallet) { + const zet = (sleutel, html) => { + const e = el.querySelector(`[data-trk="${sleutel}"]`); + if (e) e.innerHTML = html; + }; + const alle = await accountsWithSources(); + const bronnen = await sourcesOf(alle); + const bronnenVan = id => bronnen.filter(s => s._account.id === id); + const saldi = new Map(); + for (const a of alle) saldi.set(a.id, await accountOnchainBalance(a.id) + lightningAccountSats(bronnenVan(a.id))); + for (const a of alle) { + const cel = el.querySelector(`[data-acct-amt="${a.id}"]`); + if (cel) cel.innerHTML = amt(saldi.get(a.id)); + } + + const total = alle.reduce((sum, a) => sum + saldi.get(a.id), 0); + zet('total', amt(total)); + zet('fiat', fiatSub(total)); + + const { total: nTx, linked: nLinked } = + countMovements(perWallet.flat(), tx => (eventMap.get(tx.id) || []).length > 0); + zet('wallets', fmtInt(wallets.length)); + zet('txs', fmtInt(nTx)); + zet('linked', nTx ? `${fmtInt(nLinked)} / ${fmtInt(nTx)}` : '-'); + + const gekozen = alle.filter(a => selectedAccounts.has(a.id)); + const deel = gekozen.reduce((sum, a) => sum + saldi.get(a.id), 0); + zet('selection', gekozen.length === alle.length ? '' + : `${t('tracking.selectedSum')} ${amt(deel)} · ${t('tracking.selectedOf', { n: gekozen.length, m: alle.length })}`); + + const logs = await Promise.all(gekozen.map(a => latestScanLog(a.id))); + const tijden = gekozen.map((a, i) => [logs[i]?.scannedAt, ...bronnenVan(a.id).map(s => s.lastScannedAt)] + .filter(Boolean).sort().at(-1)).filter(Boolean); + zet('scanned', (!gekozen.length || tijden.length < gekozen.length) + ? t('tracking.scanNever') + : `${t('tracking.scanLast')} ${fmtDateLong(localDate(tijden.sort()[0]))}`); +} + +async function walletBlock(g, txs) { + const name = g.name || t('tracking.sourceFallback'); + const saldi = await Promise.all(g.sources.filter(s => !isLightningSource(s)).map(s => sourceBalance(s.id))); + const sats = saldi.reduce((sum, b) => sum + b.sats, 0) + lightningAccountSats(g.sources); + const badges = g.sources.map(sourceBadge).join(''); + const collapsed = collapsedSet.has(g.id); + const isNew = txs.some(tx => newTxIds.has(tx.id)); + return `
    + +
    +
    +
    ${esc(name)}
    +
    ${esc(g.account ? g.account.name : '')}
    +
    +
    ${badges}
    +
    ${amountHtml(sats)}
    +
    +
    ${walletMovements(txs)}
    +
    `; +} + +async function walletTxs(sourceIds) { + const seen = new Map(); + for (const id of sourceIds) for (const tx of await listSourceTxs(id)) if (!seen.has(tx.id)) seen.set(tx.id, tx); + return [...seen.values()]; +} + +function walletMovements(txs) { + if (txs.length === 0) { + return `
    ${t('tracking.noTxs')}
    `; + } + + const groups = new Map(); + for (const tx of txs) { + const key = tx.blockTime ? localDate(tx.blockTime) : ''; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(tx); + } + const keys = [...groups.keys()].sort((a, b) => { + if (a === '') return -1; + if (b === '') return 1; + return a < b ? 1 : (a > b ? -1 : 0); + }); + + return keys.map(k => { + const label = k === '' ? t('tracking.unconfirmed') : fmtDate(k); + return `
    +
    ${label}
    ${dayRows(groups.get(k))}
    +
    `; + }).join(''); +} + +function dayRows(txs) { + const byEvent = new Map(); + for (const tx of txs) { + for (const ev of (eventMap.get(tx.id) || [])) { + if (!byEvent.has(ev.eventId)) byEvent.set(ev.eventId, { ev, list: [] }); + byEvent.get(ev.eventId).list.push(tx); + } + } + const inGroup = new Set(); + for (const { list } of byEvent.values()) if (list.length >= 2) for (const tx of list) inGroup.add(tx.id); + const emitted = new Set(); + return txs.map(tx => { + const groups = (eventMap.get(tx.id) || []).filter(ev => byEvent.get(ev.eventId).list.length >= 2); + if (groups.length === 0) return inGroup.has(tx.id) ? '' : txRow(tx); + return groups.map(ev => { + if (emitted.has(ev.eventId)) return ''; + emitted.add(ev.eventId); + const g = byEvent.get(ev.eventId); + return eventGroupRow(g.ev, g.list); + }).join(''); + }).join(''); +} + +function eventGroupRow(ev, list) { + const out = ev.fromAccountId === list[0].accountId; + const dirTitle = out ? t('tracking.dirOut') : t('tracking.dirIn'); + const arrow = out ? ARROW_UP : ARROW_DOWN; + const typeLabel = t('enums.event_type.' + ev.type); + const other = out ? ev.to_name : ev.from_name; + const desc = ev.type === 'transfer' && other ? `${typeLabel} ${out ? '→' : '←'} ${other}` : typeLabel; + const allSame = list.every(x => x.labelId && x.labelId === list[0].labelId); + const shown = allSame ? list[0].label_name : desc; + const idsAttr = esc(JSON.stringify(list.map(x => x.id))); + const feeBlock = ev.unitFee + ? `
    +
    ${t('tracking.fee')} ${amt(ev.unitFee)}
    +
    ${fiatSub(ev.unitFee)}
    +
    ` + : ''; + return `
    + +
    ${arrow}
    +
    + › + ${esc(shown)} + ${matchDot({ matched: true, method: ev.method, kind: 'tx', id: list[0].id })} +
    +
    ${feeBlock} +
    ${amt(ev.amount)}
    ${fiatSub(ev.amount)}
    +
    +
    +
    ${list.map(txRow).join('')}
    +
    `; +} + +function txRow(tx) { + const dirTitle = tx.direction === 'in' ? t('tracking.dirIn') : t('tracking.dirOut'); + const arrow = tx.direction === 'in' ? ARROW_DOWN : ARROW_UP; + const links = eventMap.get(tx.id) || []; + const memo = !tx.address ? memoLine(tx.memo, tx.direction) : null; + const leeg = memo ? esc(memo.text) : t('tracking.addLabel'); + const mid = tx.labelId + ? `${esc(tx.label_name)}` + : `${tx.address ? trunc(tx.address) : leeg}`; + const copyBtn = tx.address ? copyButton(tx.address, t('tracking.copyAddr')) : ''; + const feeBlock = (tx.feeSats && tx.feeSats > 0) + ? `
    +
    ${t('tracking.fee')} ${amt(tx.feeSats)}
    +
    ${fiatSub(tx.feeSats)}
    +
    ` + : ''; + return `
    +
    ${arrow}
    +
    ${mid}${copyBtn}${matchDot({ matched: links.length > 0, method: links.length ? links[0].method : undefined, kind: 'tx', id: tx.id })}
    +
    + ${feeBlock} +
    +
    ${amt(tx.valueSats)}
    +
    ${fiatSub(tx.valueSats)}
    +
    +
    +
    `; +} + +function openLabelEditor(el, trigger) { + const ids = trigger.dataset.txIds ? JSON.parse(trigger.dataset.txIds) : [Number(trigger.dataset.txId)]; + const cur = trigger.dataset.labelId ? Number(trigger.dataset.labelId) : ''; + const cell = trigger.closest('[data-cell-tx]'); + if (!cell) return; + cell.innerHTML = `${labelComboMarkup('labelId', cur)}`; + const host = cell.querySelector('[data-label-combo]'); + labelComboMount(host, { listLabels, createLabel }, async id => { + await setTxLabels(ids, id); + renderBody(el); + }); + const input = host.querySelector('[data-label-input]'); + if (input) input.focus(); +} + +const logRow = (el, id) => el.querySelector(`[data-trk="scanlog"] [data-log="${id}"] .trk-scan-out`); +function logGroup(lijst, account) { + const key = String(account ? account.id : ''); + const bestaand = [...lijst.children].find(e => e.dataset.logAcct === key); + if (bestaand) return bestaand.querySelector('.trk-scan-wallets'); + const groep = document.createElement('li'); + groep.className = 'trk-scan-group'; + groep.dataset.logAcct = key; + const kop = document.createElement('div'); + kop.className = 't-label t-label--ter trk-scan-acct'; + kop.textContent = account ? account.name : ''; + const wallets = document.createElement('ul'); + wallets.className = 'trk-scan-wallets'; + groep.append(kop, wallets); + lijst.append(groep); + return wallets; +} +function addLogRow(el, g) { + const lijst = el.querySelector('[data-trk="scanlog"]'); + if (!lijst) return; + const li = document.createElement('li'); + li.className = 'trk-scan-row'; + li.dataset.log = g.id; + const naam = document.createElement('span'); + naam.className = 'trk-scan-name'; + naam.textContent = g.name || t('tracking.sourceFallback'); + const uit = document.createElement('span'); + uit.className = 'trk-scan-out t-muted'; + uit.textContent = t('tracking.scanning'); + li.append(naam, uit); + logGroup(lijst, g.account).append(li); +} +function setLogRow(el, id, cls, txt) { + const e = logRow(el, id); + if (e) { e.className = `trk-scan-out ${cls}`; e.textContent = txt; } +} + +async function scanOne(el, source, logId = source.id) { + return scanSource(source, txt => { const e = logRow(el, logId); if (e) e.textContent = txt; }); +} + +async function scanAll(el) { + const wallets = await groupedWallets(); + if (!wallets.length) return; + const knop = el.querySelector('#scan-all'); + const zetStatus = txt => { + const e = el.querySelector('[data-trk="scanstatus"]'); + if (e) e.textContent = txt; + }; + const results = {}; + const kanalen = []; + const voorstellen = new Map(); + const lnAccounts = new Set(); + let nWallets = 0, nMislukt = 0; + if (knop) knop.disabled = true; + const lijst = el.querySelector('[data-trk="scanlog"]'); + if (lijst) lijst.replaceChildren(); + const voor = shownTxIds; + newTxIds = new Set(); + try { + for (const [i, g] of wallets.entries()) { + zetStatus(t('tracking.scanProgress', { i: i + 1, n: wallets.length })); + addLogRow(el, g); + let count = 0, failed = 0, error = false, geslagen = 0, ln = 0, lnBronnen = 0; + for (const s of g.sources) { + if (isLightningSource(s)) { + lnBronnen++; + lnAccounts.add(s.accountId); + try { + const r = await scanLightningSource(s); + ln += r.count; + if (r.channelTxids.length) kanalen.push([s.accountId, r.channelTxids]); + } catch (err) { console.error('[lightning-scan]', err); error = true; } + continue; + } + if (!isOnchainSource(s)) { geslagen++; continue; } + try { + const r = await scanOne(el, s, g.id); + count += r.count; failed += r.failed; voorstellen.set(s.accountId, r.proposals || 0); + } catch (err) { console.error('[onchain-scan-all]', err); error = true; } + } + const skipped = geslagen && !lnBronnen && !count && !failed; + if (error) nMislukt++; else if (!skipped) nWallets++; + results[g.id] = error ? { error: true } : (skipped ? { skipped: true } : { count, failed, ln, lnBronnen }); + } + if (Object.values(results).some(r => r.lnBronnen)) await syncAddressLinks().catch(err => console.error('[lightning-links]', err)); + for (const [accountId, txids] of kanalen) { + await labelChannelTxs(accountId, txids).catch(err => console.error('[lightning-channel-label]', err)); + } + const btc = lnAccounts.size ? await btcAsset() : null; + if (btc) for (const accountId of lnAccounts) { + try { voorstellen.set(accountId, await syncTxProposals(accountId, btc.id)); } + catch (err) { console.error('[lightning-proposals]', err); } + } + } finally { + if (knop) knop.disabled = false; + } + const nVoorstellen = [...voorstellen.values()].reduce((s, n) => s + n, 0); + zetStatus(scanSummary(nWallets, nVoorstellen, nMislukt)); + for (const id in results) { + const r = results[id]; + if (r.error) setLogRow(el, id, 't-neg', t('tracking.scanError')); + else if (r.skipped) setLogRow(el, id, 't-muted', t('tracking.scanNotOnchain')); + else setLogRow(el, id, r.failed ? 't-neg' : 't-pos', scanDoneText(r)); + } + scanBaseline = voor; + await renderBody(el); +} + +function scanSummary(nWallets, nVoorstellen, nMislukt) { + const deel = [`${fmtInt(nWallets)} ${nWallets === 1 ? t('tracking.walletOne') : t('tracking.walletMany')} ${t('tracking.scanUpdated')}`]; + if (nVoorstellen) deel.push(`${fmtInt(nVoorstellen)} ${nVoorstellen === 1 ? t('tracking.proposalOne') : t('tracking.proposalMany')}`); + if (nMislukt) deel.push(`${fmtInt(nMislukt)} ${t('tracking.scanFailedN')}`); + return deel.join(' · '); +} + +function scanDoneText(r) { + const delen = []; + if (r.count || r.failed || !r.lnBronnen) delen.push(`${r.count} ${t('tracking.addressesScanned')}`); + if (r.failed) delen.push(`${r.failed} ${t('tracking.addressesFailed')}`); + if (r.lnBronnen) delen.push(`${fmtInt(r.ln)} ${t('tracking.lightningMovements')}`); + return delen.join(' · '); +} diff --git a/client/pages/trades.js b/client/pages/trades.js new file mode 100644 index 0000000..68b09ae --- /dev/null +++ b/client/pages/trades.js @@ -0,0 +1,375 @@ +// Generated, do not edit. sha256:d029e4f661a0e4d29fa7970f6f2aff7a15cdf1a9757dd86f22ff6a54e12c7c53 +import { listSelectableAccounts } from '../data/accounts.js'; +import { localDate, localDateNl, localTime, toUtcIso } from '../../shared/time.js'; +import { listAssets, listBookableAssets } from '../data/assets.js'; +import { listTrades, createEvent, updateEvent, deleteEvent } from '../data/events.js'; +import { listMetalProducts, listMetalProductsForAsset } from '../data/metals.js'; +import { tableViewCfg } from '../data/preferences.js'; +import { eventLinkMap, onchainAsset } from '../data/tx-links.js'; +import { t } from '../i18n/index.js'; +import { assetLabelHtml } from '../ui/asset-label.js'; +import * as dialog from '../ui/dialog.js'; +import { ENUMS, CONFIG, TARGETS, targetHash } from '../../shared/config.js'; +import { renderTable } from '../ui/table.js'; +import { takeRowFocus } from '../ui/row-focus.js'; +import { fmtBase, fmtBasePrice, fmtUnit, val, toSubunit, fromSubunit, priceToBase, priceFromBase, isMetal } from '../ui/format.js'; +import { totalFromPrice, priceFromTotal, amountFromTotal } from '../../shared/units.js'; +import { priceUnitLabel } from '../../shared/units.js'; +import { matchDot, wireMatchTriggers } from '../ui/match-popover.js'; +import { spotOnDate } from '../ui/fiat.js'; +import { ARROW_DOWN, ARROW_UP } from '../ui/icons.js'; +import { esc } from '../ui/escape.js'; +import { emptyStateHtml } from '../ui/empty-state.js'; +import { setPageHint } from '../ui/page-hint.js'; +import { bookingOriginHtml } from '../ui/price-origin.js'; +import { listPriceHistory } from '../data/price-history.js'; +import { herkomstContext } from '../wealth/data.js'; +import { provenanceOn, originFrom } from '../../shared/price-provenance.js'; + +let tableApi = null; +let onlyAccount = null; +let onlyAsset = null; +let onlyKinds = null; +const assetAllowed = a => (onlyAsset == null || a.id === onlyAsset) && (onlyKinds == null || onlyKinds.includes(a.kind)); + +export async function render(el, { accountId = null, assetId = null, assetKinds = null } = {}) { + onlyAccount = accountId; + onlyAsset = assetId; + onlyKinds = assetKinds; + if ((await listAssets()).length === 0) { + el.innerHTML = emptyStateHtml(t('trades.none'), { + why: t('trades.needAsset'), href: targetHash(TARGETS.ASSETS), linkText: t('common.manageAssets'), + }); + return; + } + el.innerHTML = `
    `; + if (onlyAccount == null) setPageHint(el, { title: t('nav.trades'), rows: [t('trades.fiatNote')] }); + buildTable(el); +} + +function typeOpts() { + return ENUMS.EVENT_TYPE.filter(x => x === 'buy' || x === 'sell') + .map(x => ({ value: x, label: t('enums.event_type.' + x) })); +} +async function assetOptsOfKind(kind) { + return (await listBookableAssets()).filter(a => (!kind || a.kind === kind) && assetAllowed(a)) + .map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` })); +} +async function accountOpts(huidigeId) { + const accounts = (await listSelectableAccounts({ ookId: Number(huidigeId) || null })) + .filter(a => onlyAccount == null || a.id === onlyAccount); + return [...(onlyAccount == null ? [{ value: '', label: '-' }] : []), ...accounts.map(a => ({ value: a.id, label: a.name }))]; +} +async function productOpts(assetId) { + const id = Number(assetId); + const opts = [{ value: '', label: t('trades.looseWeight') }]; + if (Number.isInteger(id)) for (const p of await listMetalProductsForAsset(id)) opts.push({ value: p.id, label: p.name }); + return opts; +} + +async function dagHerkomsten(trades) { + const uit = new Map(); + const perAsset = new Map(); + for (const tr of trades) { + if (tr.priceOrigin !== 'market_day' || tr.fiatPrice == null) continue; + if (!perAsset.has(tr.assetId)) perAsset.set(tr.assetId, []); + perAsset.get(tr.assetId).push(tr); + } + if (!perAsset.size) return uit; + const ctx = await herkomstContext(); + for (const [assetId, lijst] of perAsset) { + let rijen = []; + try { rijen = await listPriceHistory(assetId); } catch { continue; } + for (const tr of lijst) { + const d = provenanceOn(rijen, tr.occurredAt.slice(0, 10), + { assetKind: tr.asset_kind, manualProviderId: ctx.manualId, usedPrice: tr.fiatPrice }); + if (d && d.fiatPrice != null && Math.abs(d.fiatPrice - tr.fiatPrice) <= 1e-9 * Math.max(1, Math.abs(tr.fiatPrice))) { + uit.set(tr.id, originFrom(d, ctx.names)); + } + } + } + return uit; +} + +async function buildTable(el) { + let dagHerkomst = new Map(); + const rows = async () => { + const lijst = (await listTrades()) + .filter(tr => (onlyAccount == null || (tr.type === 'buy' ? tr.toAccountId : tr.fromAccountId) === onlyAccount) + && (onlyAsset == null || tr.assetId === onlyAsset)) + .map(tr => ({ ...tr, loc: tr.type === 'buy' ? tr.to_name : tr.from_name })); + try { dagHerkomst = await dagHerkomsten(lijst); } catch { dagHerkomst = new Map(); } + return lijst; + }; + const meta = r => ({ kind: r.asset_kind, decimals: r.asset_decimals, unit: r.asset_unit, subunit: r.asset_subunit, displayUnit: r.asset_display_unit }); + const btc = await onchainAsset(); + let linkMap = await eventLinkMap(); + const today = new Date().toISOString().slice(0, 10); + const alleAssets = await listAssets(); + const alleProducten = await listMetalProducts(); + const assetById = id => alleAssets.find(a => a.id === Number(id)); + const firstOf = kind => { const a = alleAssets.find(x => x.kind === kind && assetAllowed(x)); return a ? a.id : ''; }; + + async function resolvePrice(v) { + const asset = alleAssets.find(a => a.id === Number(v.assetId)); + if (!asset || !v.occurredAt) return; + if (v.priceOrigin === 'none') { v.fiatPrice = 0; return; } + const spot = await spotOnDate(asset, v.occurredAt); + if (v.fiatPrice === '' || v.fiatPrice == null) { + if (spot == null) throw new Error(t('trades.noSpotForDate')); + v.fiatPrice = spot; + v.priceOrigin = 'market_day'; + return; + } + const entered = parseFloat(v.fiatPrice); + if (spot != null && spot > 0 && Number.isFinite(entered)) { + const dev = Math.abs(entered - spot) / spot; + if (dev > CONFIG.METALS.SPOT_DEVIATION_WARN) { + const ok = await dialog.confirm({ + message: `De ingevoerde prijs (${fmtBasePrice(entered)}) wijkt ${Math.round(dev * 100)}% af van de spot op ${v.occurredAt} (${fmtBasePrice(spot)}). Toch opslaan?`, + }); + if (!ok) throw new Error(t('trades.saveCancelled')); + } + } + } + + const convertAsset = vals => (vals.productId !== '' && vals.productId != null) ? null : (assetById(vals.assetId) || null); + + function productCell(r) { + if (r.productId && r.product_name) return `${esc(r.product_name)} ×${r.productCount}`; + if (r.asset_kind === 'metal') return `${t('trades.looseWeight')}`; + return ''; + } + + function herkomstCel(r) { + const hints = { + paid: 'trades.originPaidHint', market_day: 'trades.originMarketDay', + market_moment: 'trades.originMarketMoment', none: 'trades.originNoneHint', + }; + if (!r.priceOrigin) { + return `?`; + } + if (r.priceOrigin === 'paid') return ''; + return `${esc(t('enums.event_price_origin.' + r.priceOrigin))}`; + } + + function herkomstOpts(vals) { + const opts = [ + { value: '', label: t('trades.originPick') }, + { value: 'paid', label: t('trades.originPickPaid') }, + { value: 'none', label: t('trades.originPickGift') }, + ]; + const huidig = vals && vals.priceOrigin; + if (huidig && !opts.some(o => o.value === huidig)) { + opts.push({ value: huidig, label: t('enums.event_price_origin.' + huidig) }); + } + return opts; + } + + const columns = [ + { key: 'occurredAt', label: t('trades.colDate'), sortable: true, + render: r => `${btc && r.assetId === btc.id ? matchDot({ matched: linkMap.has(r.id), method: linkMap.get(r.id), kind: 'event', id: r.id }) : ''}${localDateNl(r.occurredAt)}`, + group: { value: r => localDate(r.occurredAt).slice(0, 4), label: v => v, order: 'desc' }, + edit: { type: 'date', sticky: true, attrs: { max: today }, value: r => r ? localDate(r.occurredAt) : today } }, + { key: '_time', label: t('trades.time'), + render: r => localTime(r.occurredAt), + edit: { type: 'time', value: r => r ? localTime(r.occurredAt) : '12:00' } }, + { key: 'loc', label: t('trades.colAccount'), sortable: true, + render: r => r.loc || '-', group: { value: r => r.loc || '-', label: v => v }, + edit: { type: 'select', sticky: true, options: vals => accountOpts(vals.loc), + value: r => r ? (r.type === 'sell' ? r.fromAccountId : r.toAccountId) ?? '' : '' } }, + { key: 'assetId', label: t('trades.colAsset'), sortable: true, sortValue: r => r.asset_symbol, + render: r => assetLabelHtml(r), group: { value: r => r.asset_symbol, label: v => v }, + edit: { type: 'select', sticky: true, reactive: true, options: vals => assetOptsOfKind(vals.asset_kind) } }, + { key: 'asset_kind', label: t('trades.colKind'), sortable: true, + render: r => t('enums.asset_kind.' + r.asset_kind), + group: { value: r => r.asset_kind, label: v => t('enums.asset_kind.' + v) }, + edit: { value: r => r ? r.asset_kind : '', visible: () => false } }, + { key: 'type', label: t('trades.colType'), sortable: true, + render: r => r.type === 'buy' + ? `${ARROW_DOWN}` + : `${ARROW_UP}`, + group: { value: r => r.type, label: v => t('enums.event_type.' + v) }, + edit: { type: 'select', reactive: true, options: typeOpts } }, + { key: 'productId', label: t('trades.colProduct'), sortable: true, sortValue: r => r.product_name || '', + render: productCell, + edit: { type: 'select', sticky: true, reactive: true, visible: vals => vals.asset_kind === 'metal', + options: vals => productOpts(vals.assetId), + value: r => r ? (r.productId ?? '') : '' } }, + { key: 'amount', label: t('trades.colAmount'), align: 'num', sortable: true, sortValue: r => r.amount, + render: r => val(fmtUnit(r.amount, meta(r))), + edit: { type: 'number', reactive: true, attrs: { min: 0, step: 'any' }, + value: r => r ? (r.productCount != null ? r.productCount : fromSubunit(r.amount, meta(r))) : '', + convert: convertAsset } }, + { key: 'priceOrigin', label: t('trades.colOrigin'), sortable: true, + sortValue: r => r.priceOrigin || '', + render: herkomstCel, + group: { value: r => r.priceOrigin || '', label: v => (v ? t('enums.event_price_origin.' + v) : t('trades.originUnknown')) }, + edit: { type: 'select', reactive: true, options: herkomstOpts, + value: r => r ? (r.priceOrigin ?? '') : '' } }, + { key: 'fiatTotal', label: t('trades.colTotal'), align: 'num', sortable: true, + sortValue: r => totalFromPrice(r.amount, meta(r).decimals, r.fiatPrice, r.fiatFee, r.type) ?? -1, + render: r => { + const tot = totalFromPrice(r.amount, meta(r).decimals, r.fiatPrice, r.fiatFee, r.type); + return tot == null ? '-' : val(fmtBase(tot)); + }, + edit: { type: 'number', reactive: true, attrs: { min: 0, step: 'any' }, + visible: vals => vals.priceOrigin !== 'none', + value: r => { + const tot = r ? totalFromPrice(r.amount, meta(r).decimals, r.fiatPrice, r.fiatFee, r.type) : null; + return tot == null ? '' : Math.round(tot * 100) / 100; + } } }, + { key: 'fiatPrice', label: t('trades.colPrice'), align: 'num', sortable: true, sortValue: r => r.fiatPrice, + render: r => `${val(fmtBasePrice(priceFromBase(r.fiatPrice, meta(r))))} /${priceUnitLabel(meta(r))}` + + bookingOriginHtml(r, { ...meta(r), symbol: r.asset_symbol }, dagHerkomst.get(r.id) || null), + edit: { type: 'number', reactive: true, attrs: { min: 0, step: 'any' }, + visible: vals => vals.priceOrigin !== 'none', + value: r => r ? priceFromBase(r.fiatPrice, meta(r)) : '' } }, + { key: 'fiatFee', label: t('trades.colFee'), align: 'num', sortable: true, sortValue: r => r.fiatFee ?? -1, + render: r => r.fiatFee != null ? val(fmtBase(r.fiatFee)) : '-', + edit: { type: 'number', reactive: true, attrs: { min: 0, step: 'any' }, + visible: vals => vals.priceOrigin !== 'none', + value: r => r && r.fiatFee != null ? r.fiatFee : '' } }, + { key: 'note', label: t('trades.colNote'), text: true, + render: r => `${esc(r.note || '')}`, + edit: { type: 'textarea', expand: true } }, + { key: 'source', label: t('trades.colSource'), sortable: true, + render: r => `${r.source ? t('enums.event_source.' + r.source) : '-'}`, + group: { value: r => r.source || 'manual', label: v => t('enums.event_source.' + v) } }, + ].filter(c => onlyAccount == null || c.key !== 'loc'); + + function hoeveelheidBasis(v, asset) { + if (!asset) return null; + const wantProduct = isMetal(asset) && v.productId !== '' && v.productId != null; + if (wantProduct) { + const product = alleProducten.find(p => p.id === Number(v.productId) && p.assetId === asset.id); + const cnt = parseInt(v.amount, 10); + if (!product || !Number.isInteger(cnt) || cnt <= 0) return null; + return product.fineGrams * cnt; + } + const qty = parseFloat(v.amount); + if (!Number.isFinite(qty) || qty <= 0) return null; + return toSubunit(qty, asset); + } + + function afgeleid(v, sleutel, aangeraakt = new Set()) { + if (v.priceOrigin === 'none') return { fiatPrice: '0', fiatTotal: '0', fiatFee: '' }; + const asset = assetById(v.assetId); + if (!asset) return null; + const dec = asset.decimals; + const rond = n => (n == null ? '' : String(Math.round(n * 100) / 100)); + const gevuld = x => x !== '' && x != null && Number.isFinite(Number(x)); + + const opProduct = isMetal(asset) && v.productId !== '' && v.productId != null; + if (!opProduct && !aangeraakt.has('amount') && gevuld(v.fiatTotal) && gevuld(v.fiatPrice)) { + const basisUitGeld = amountFromTotal( + v.fiatTotal, priceToBase(parseFloat(v.fiatPrice), asset), dec, v.fiatFee, v.type); + if (basisUitGeld !== null) { + const getoond = fromSubunit(Math.round(basisUitGeld), asset); + return { amount: getoond === null ? '' : String(getoond) }; + } + } + + const basis = hoeveelheidBasis(v, asset); + if (basis === null) return null; + + const naarPrijs = sleutel === 'fiatTotal' + || (gevuld(v.fiatTotal) && (sleutel === 'fiatFee' || !gevuld(v.fiatPrice))); + if (naarPrijs) { + const prijs = priceFromTotal(v.fiatTotal, basis, dec, v.fiatFee, v.type); + return prijs === null ? null : { fiatPrice: rond(priceFromBase(prijs, asset)) }; + } + const totaal = totalFromPrice(basis, dec, priceToBase(parseFloat(v.fiatPrice), asset), v.fiatFee, v.type); + return totaal === null ? null : { fiatTotal: rond(totaal) }; + } + + function herkomst(v, existing, priceBasis) { + const gekozen = v.priceOrigin ?? ''; + const opgeslagen = existing ? (existing.priceOrigin ?? '') : ''; + if (gekozen !== opgeslagen) return gekozen === '' ? null : gekozen; + if (existing && existing.fiatPrice != null) { + const zelfde = Math.abs(priceBasis - existing.fiatPrice) <= 1e-9 * Math.max(1, Math.abs(existing.fiatPrice)); + if (zelfde) return existing.priceOrigin ?? null; + } + return gekozen === '' ? 'paid' : gekozen; + } + + async function toPayload(v, existing) { + const asset = alleAssets.find(a => a.id === Number(v.assetId)); + const price = parseFloat(v.fiatPrice); + if (!v.occurredAt || !asset || !Number.isFinite(price)) throw new Error(t('trades.fillDateAssetPrice')); + + let productId = null, productCount = null, amount; + const wantProduct = isMetal(asset) && v.productId !== '' && v.productId != null; + if (wantProduct) { + const product = (await listMetalProducts()).find(p => p.id === Number(v.productId) && p.assetId === asset.id); + if (!product) throw new Error(t('trades.pickValidProduct')); + const cnt = parseInt(v.amount, 10); + if (!Number.isInteger(cnt) || cnt <= 0) throw new Error(t('trades.fillCount')); + productId = product.id; productCount = cnt; + amount = product.fineGrams * cnt; + } else { + const qty = parseFloat(v.amount); + if (!Number.isFinite(qty) || qty <= 0) throw new Error(t('trades.fillQuantity')); + amount = toSubunit(qty, asset); + } + const fiatPrice = priceToBase(price, asset); + const locVal = v.loc ?? onlyAccount; + const loc = locVal !== '' && locVal != null ? Number(locVal) : null; + return { + priceOrigin: herkomst(v, existing, fiatPrice), + assetId: asset.id, type: v.type, + occurredAt: toUtcIso(`${v.occurredAt}T${v._time || '00:00'}:00`), + amount, fiatPrice, + fiatFee: v.fiatFee === '' || v.fiatFee == null ? null : parseFloat(v.fiatFee), + unitFee: existing ? existing.unitFee ?? null : null, + productId, productCount, + fromAccountId: v.type === 'sell' ? loc : null, + toAccountId: v.type === 'buy' ? loc : null, + source: existing ? existing.source : 'manual', + note: (v.note || '').trim() || null, + }; + } + const reload = async () => tableApi.refresh(await rows()); + + tableApi = renderTable(el.querySelector('#tbl'), { + columns, rows: await rows(), + onEditChange: (vals, sleutel, aangeraakt) => afgeleid(vals, sleutel, aangeraakt), + ...(await tableViewCfg('trades', { key: 'occurredAt', dir: 'desc' }, onlyAccount != null ? 'assetId' : null)), + emptyText: t('trades.none'), + noneLabel: t('table.none'), + collapsibleGroups: true, + groupSummary: items => { + const first = items[0]; if (!first) return ''; + if (!items.every(r => r.assetId === first.assetId)) return ''; + const net = items.reduce((s, r) => s + (r.type === 'buy' ? r.amount : -r.amount), 0); + return `${net < 0 ? '\u2212' : ''}${val(fmtUnit(Math.abs(net), meta(first)))}`; + }, + newLabel: t('table.new'), + convertLabel: t('convert.title'), + newDefaults: { type: 'buy', loc: onlyAccount ?? '', fiatFee: '' }, + newOptions: [ + { key: 'crypto', label: t('trades.addCrypto'), defaults: { asset_kind: 'crypto', type: 'buy', assetId: firstOf('crypto') } }, + { key: 'metal', label: t('trades.addMetal'), defaults: { asset_kind: 'metal', type: 'buy', assetId: firstOf('metal') } }, + { key: 'fiat', label: t('trades.addFiat'), defaults: { asset_kind: 'fiat', type: 'buy', assetId: firstOf('fiat') } }, + ].filter(o => o.defaults.assetId !== ''), + saveLabel: t('trades.save'), cancelLabel: t('trades.cancel'), + onCreate: async v => { await resolvePrice(v); const id = await createEvent(await toPayload(v, null)); tableApi.refresh(await rows(), { locate: id }); }, + onSave: async (id, v) => { + await resolvePrice(v); + const ex = (await listTrades()).find(x => x.id === id); + await updateEvent(id, await toPayload(v, ex)); reload(); + }, + onDelete: async id => { + if (!(await dialog.confirm({ message: t('trades.confirmDelete'), danger: true }))) return; + await deleteEvent(id); reload(); + }, + }); + + wireMatchTriggers(el.querySelector('#tbl'), (kind, id) => ({ + kind, id, assetId: btc ? btc.id : null, + onChange: async () => { linkMap = await eventLinkMap(); tableApi.refresh(await rows()); }, + })); + + tableApi.focusRow(takeRowFocus('trades')); +} diff --git a/client/pages/transfers.js b/client/pages/transfers.js new file mode 100644 index 0000000..67da601 --- /dev/null +++ b/client/pages/transfers.js @@ -0,0 +1,238 @@ +// Generated, do not edit. sha256:691f92a0fa91d8a9f654cd1214fd7139438481faeed67130424bba664831b55c +import { listSelectableAccounts } from '../data/accounts.js'; +import { localDate, localDateNl, localTime, toUtcIso } from '../../shared/time.js'; +import { listAssets, listBookableAssets } from '../data/assets.js'; +import { listTransfers, createEvent, updateEvent, deleteEvent } from '../data/events.js'; +import { listMetalProducts, listMetalProductsForAsset } from '../data/metals.js'; +import { tableViewCfg } from '../data/preferences.js'; +import { eventLinkMap, onchainAsset } from '../data/tx-links.js'; +import { t } from '../i18n/index.js'; +import { assetLabelHtml } from '../ui/asset-label.js'; +import * as dialog from '../ui/dialog.js'; +import { renderTable } from '../ui/table.js'; +import { takeRowFocus } from '../ui/row-focus.js'; +import { fmtUnit, val, subline, toSubunit, fromSubunit, isMetal } from '../ui/format.js'; +import { matchDot, wireMatchTriggers } from '../ui/match-popover.js'; +import { loadHistorical, fiatHistorical, fiatHistoricalOrigin } from '../ui/fiat.js'; +import { originHtml } from '../ui/price-origin.js'; +import { copyButton } from '../ui/copy-button.js'; +import { TARGETS, targetHash } from '../../shared/config.js'; +import { esc } from '../ui/escape.js'; +import { emptyStateHtml } from '../ui/empty-state.js'; +import { setPageHint } from '../ui/page-hint.js'; + +let tableApi = null; +let onlyAccount = null; +let onlyAsset = null; +let onlyKinds = null; +const assetAllowed = a => (onlyAsset == null || a.id === onlyAsset) && (onlyKinds == null || onlyKinds.includes(a.kind)); +const shortAddr = a => (a && a.length > 16) ? `${a.slice(0, 8)}…${a.slice(-6)}` : (a || ''); + +export async function render(el, { accountId = null, assetId = null, assetKinds = null } = {}) { + onlyAccount = accountId; + onlyAsset = assetId; + onlyKinds = assetKinds; + if ((await listAssets()).length === 0) { + el.innerHTML = ` + ${emptyStateHtml(t('transfers.none'), { + why: t('transfers.needAsset'), href: targetHash(TARGETS.ASSETS), linkText: t('common.manageAssets'), + })}`; + return; + } + el.innerHTML = `
    `; + if (onlyAccount == null) setPageHint(el, { title: t('nav.transfers'), rows: [t('transfers.fiatNote')] }); + buildTable(el); +} + +async function assetOptsOfKind(kind) { + return (await listBookableAssets()).filter(a => (!kind || a.kind === kind) && assetAllowed(a)) + .map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` })); +} +async function accountOpts(huidigeId) { + return [{ value: '', label: '-' }, + ...(await listSelectableAccounts({ ookId: Number(huidigeId) || null })).map(a => ({ value: a.id, label: a.name }))]; +} +async function productOpts(assetId) { + const id = Number(assetId); + const opts = [{ value: '', label: t('transfers.looseWeight') }]; + if (Number.isInteger(id)) for (const p of await listMetalProductsForAsset(id)) opts.push({ value: p.id, label: p.name }); + return opts; +} + +async function buildTable(el) { + const rows = async () => (await listTransfers()) + .filter(r => (onlyAccount == null || r.fromAccountId === onlyAccount || r.toAccountId === onlyAccount) + && (onlyAsset == null || r.assetId === onlyAsset)); + const meta = r => ({ id: r.assetId, kind: r.asset_kind, decimals: r.asset_decimals, unit: r.asset_unit, subunit: r.asset_subunit, displayUnit: r.asset_display_unit }); + const herkomst = r => originHtml(fiatHistoricalOrigin(meta(r), localDate(r.occurredAt)), { ...meta(r), symbol: r.asset_symbol }); + const btc = await onchainAsset(); + let linkMap = await eventLinkMap(); + const today = new Date().toISOString().slice(0, 10); + const alleAssets = await listAssets(); + const assetById = id => alleAssets.find(a => a.id === Number(id)); + const firstOf = kind => { const a = alleAssets.find(x => x.kind === kind && assetAllowed(x)); return a ? a.id : ''; }; + const convertAsset = vals => (vals.productId !== '' && vals.productId != null) ? null : (assetById(vals.assetId) || null); + + const dirOf = r => (r.toAccountId === onlyAccount && r.fromAccountId !== onlyAccount ? 'in' : 'out'); + const otherOf = r => (dirOf(r) === 'in' + ? { id: r.fromAccountId, name: r.from_name } + : { id: r.toAccountId, name: r.to_name }); + + function productCell(r) { + if (r.productId && r.product_name) return `${esc(r.product_name)} ×${r.productCount}`; + if (r.asset_kind === 'metal') return `${t('transfers.looseWeight')}`; + return ''; + } + + const columns = [ + { key: 'occurredAt', label: t('transfers.colDate'), sortable: true, + render: r => `${btc && r.assetId === btc.id ? matchDot({ matched: linkMap.has(r.id), method: linkMap.get(r.id), kind: 'event', id: r.id }) : ''}${localDateNl(r.occurredAt)}`, + group: { value: r => localDate(r.occurredAt).slice(0, 4), label: v => v, order: 'desc' }, + edit: { type: 'date', sticky: true, attrs: { max: today }, value: r => r ? localDate(r.occurredAt) : today } }, + { key: '_time', label: t('transfers.time'), + render: r => localTime(r.occurredAt), + edit: { type: 'time', value: r => r ? localTime(r.occurredAt) : '12:00' } }, + { key: 'assetId', label: t('transfers.colAsset'), sortable: true, sortValue: r => r.asset_symbol, + render: r => assetLabelHtml(r), group: { value: r => r.asset_symbol, label: v => v }, + edit: { type: 'select', sticky: true, reactive: true, options: vals => assetOptsOfKind(vals.asset_kind) } }, + { key: 'asset_kind', label: t('transfers.colKind'), sortable: true, + render: r => t('enums.asset_kind.' + r.asset_kind), + group: { value: r => r.asset_kind, label: v => t('enums.asset_kind.' + v) }, + edit: { value: r => r ? r.asset_kind : '', visible: () => false } }, + { key: 'productId', label: t('transfers.colProduct'), sortable: true, sortValue: r => r.product_name || '', + render: productCell, + edit: { type: 'select', sticky: true, reactive: true, visible: vals => vals.asset_kind === 'metal', + options: vals => productOpts(vals.assetId), + value: r => r ? (r.productId ?? '') : '' } }, + { key: 'amount', label: t('transfers.colAmount'), align: 'num', sortable: true, sortValue: r => r.amount, + render: r => { const fiat = fiatHistorical(r.amount, meta(r), localDate(r.occurredAt)); return `${val(fmtUnit(r.amount, meta(r)))}${subline(fiat ? val(fiat) + herkomst(r) : '')}`; }, + edit: { type: 'number', attrs: { min: 0, step: 'any' }, + value: r => r ? (r.productCount != null ? r.productCount : fromSubunit(r.amount, meta(r))) : '', + convert: convertAsset } }, + ...(onlyAccount == null ? [ + { key: 'fromAccountId', label: t('transfers.colFrom'), sortable: true, sortValue: r => r.from_name || '', + render: r => r.from_name || '-', group: { value: r => r.from_name || '-', label: v => v }, + edit: { type: 'select', sticky: true, options: vals => accountOpts(vals.fromAccountId) } }, + { key: 'toAccountId', label: t('transfers.colTo'), sortable: true, sortValue: r => r.to_name || '', + render: r => r.to_name || '-', group: { value: r => r.to_name || '-', label: v => v }, + edit: { type: 'select', sticky: true, options: vals => accountOpts(vals.toAccountId) } }, + ] : [ + { key: '_dir', label: t('transfers.colDirection'), sortable: true, sortValue: r => dirOf(r), + render: r => t(dirOf(r) === 'in' ? 'transfers.dirIn' : 'transfers.dirOut'), + group: { value: r => dirOf(r), label: v => t(v === 'in' ? 'transfers.dirIn' : 'transfers.dirOut') }, + edit: { type: 'select', sticky: true, value: r => (r ? dirOf(r) : 'in'), + options: () => [{ value: 'in', label: t('transfers.dirIn') }, { value: 'out', label: t('transfers.dirOut') }] } }, + { key: '_other', label: t('transfers.colOther'), sortable: true, sortValue: r => otherOf(r).name || '', + render: r => esc(otherOf(r).name || '-'), + group: { value: r => otherOf(r).name || '-', label: v => v }, + edit: { type: 'select', sticky: true, options: vals => accountOpts(vals._other), + value: r => (r ? otherOf(r).id ?? '' : '') } }, + ]), + { key: 'unitFee', label: t('transfers.colFee'), align: 'num', sortable: true, sortValue: r => r.unitFee ?? -1, + render: r => { if (r.unitFee == null) return `-${subline('')}`; const fiat = fiatHistorical(r.unitFee, meta(r), localDate(r.occurredAt)); return `${val(fmtUnit(r.unitFee, meta(r)))}${subline(fiat ? val(fiat) + herkomst(r) : '')}`; }, + edit: { type: 'number', attrs: { min: 0, step: 'any' }, value: r => r && r.unitFee != null ? fromSubunit(r.unitFee, meta(r)) : '', convert: convertAsset } }, + { key: 'priceOrigin', label: t('transfers.colOrigin'), + render: r => (r.priceOrigin === 'none' + ? `${t('transfers.originNone')}` + : `-`), + group: { value: r => (r.priceOrigin === 'none' ? t('transfers.originNone') : '-'), label: v => v }, + edit: { type: 'select', value: r => (r && r.priceOrigin === 'none' ? 'none' : ''), + options: () => ([ + { value: '', label: t('transfers.originPick') }, + { value: 'none', label: t('transfers.originNonePick') }, + ]) } }, + { key: 'note', label: t('transfers.colNote'), text: true, + render: r => `${esc(r.note || '')}`, + edit: { type: 'textarea', expand: true } }, + { key: 'externalAddress', label: t('transfers.colAddress'), + render: r => r.externalAddress ? `${esc(shortAddr(r.externalAddress))}${copyButton(r.externalAddress)}` : '', + edit: { type: 'text', expand: true, placeholder: t('transfers.addressPlaceholder') } }, + { key: 'source', label: t('transfers.colSource'), sortable: true, + render: r => `${r.source ? t('enums.event_source.' + r.source) : '-'}`, + group: { value: r => r.source || 'manual', label: v => t('enums.event_source.' + v) } }, + ]; + + async function toPayload(v, existing) { + const asset = alleAssets.find(a => a.id === Number(v.assetId)); + if (!v.occurredAt || !asset) throw new Error(t('transfers.fillDateAsset')); + + let productId = null, productCount = null, amount; + const wantProduct = isMetal(asset) && v.productId !== '' && v.productId != null; + if (wantProduct) { + const product = (await listMetalProducts()).find(p => p.id === Number(v.productId) && p.assetId === asset.id); + if (!product) throw new Error(t('transfers.pickValidProduct')); + const cnt = parseInt(v.amount, 10); + if (!Number.isInteger(cnt) || cnt <= 0) throw new Error(t('transfers.fillCount')); + productId = product.id; productCount = cnt; + amount = product.fineGrams * cnt; + } else { + const qty = parseFloat(v.amount); + if (!Number.isFinite(qty) || qty <= 0) throw new Error(t('transfers.fillDateAssetQuantity')); + amount = toSubunit(qty, asset); + } + const idOf = x => (x !== '' && x != null ? Number(x) : null); + let from = idOf(v.fromAccountId), to = idOf(v.toAccountId); + if (onlyAccount != null) { + const other = idOf(v._other); + [from, to] = v._dir === 'in' ? [other, onlyAccount] : [onlyAccount, other]; + } + if (!from && !to) throw new Error(t('transfers.fillOneAccount')); + if (onlyAccount != null && from !== onlyAccount && to !== onlyAccount) throw new Error(t('transfers.fillThisAccount')); + return { + assetId: asset.id, type: 'transfer', + occurredAt: toUtcIso(`${v.occurredAt}T${v._time || '00:00'}:00`), + amount, fiatPrice: null, + fiatFee: existing ? existing.fiatFee ?? null : null, + unitFee: v.unitFee === '' || v.unitFee == null ? null : toSubunit(parseFloat(v.unitFee), asset), + productId, productCount, + fromAccountId: from, toAccountId: to, + source: existing ? existing.source : 'manual', + note: (v.note || '').trim() || null, + externalAddress: (v.externalAddress || '').trim() || null, + priceOrigin: v.priceOrigin === 'none' ? 'none' : null, + }; + } + const reload = async () => tableApi.refresh(await rows()); + const historieRijen = await rows(); + loadHistorical(historieRijen.map(r => ({ assetId: r.assetId, dateKey: localDate(r.occurredAt) }))) + .then(async () => { if (tableApi) tableApi.refresh(await rows()); }).catch(() => {}); + + tableApi = renderTable(el.querySelector('#tbl'), { + columns, rows: await rows(), + ...(await tableViewCfg('transfers', { key: 'occurredAt', dir: 'desc' }, onlyAccount != null ? 'assetId' : null)), + emptyText: t('transfers.none'), + noneLabel: t('table.none'), + collapsibleGroups: true, + groupSummary: items => { + const first = items[0]; if (!first) return ''; + if (!items.every(r => r.assetId === first.assetId)) return ''; + const tot = items.reduce((s, r) => s + (r.amount || 0), 0); + return val(fmtUnit(tot, meta(first))); + }, + newLabel: t('table.new'), + convertLabel: t('convert.title'), + newDefaults: { fromAccountId: '', toAccountId: '', unitFee: '' }, + newOptions: [ + { key: 'crypto', label: t('transfers.addCrypto'), defaults: { asset_kind: 'crypto', assetId: firstOf('crypto') } }, + { key: 'metal', label: t('transfers.addMetal'), defaults: { asset_kind: 'metal', assetId: firstOf('metal') } }, + { key: 'fiat', label: t('transfers.addFiat'), defaults: { asset_kind: 'fiat', assetId: firstOf('fiat') } }, + ].filter(o => o.defaults.assetId !== ''), + saveLabel: t('transfers.save'), cancelLabel: t('transfers.cancel'), + onCreate: async v => { const id = await createEvent(await toPayload(v, null)); tableApi.refresh(await rows(), { locate: id }); }, + onSave: async (id, v) => { + const ex = (await listTransfers()).find(x => x.id === id); + await updateEvent(id, await toPayload(v, ex)); reload(); + }, + onDelete: async id => { + if (!(await dialog.confirm({ message: t('transfers.confirmDelete'), danger: true }))) return; + await deleteEvent(id); reload(); + }, + }); + + wireMatchTriggers(el.querySelector('#tbl'), (kind, id) => ({ + kind, id, assetId: btc ? btc.id : null, + onChange: async () => { linkMap = await eventLinkMap(); tableApi.refresh(await rows()); }, + })); + + tableApi.focusRow(takeRowFocus('transfers')); +} diff --git a/client/pages/valuations.js b/client/pages/valuations.js new file mode 100644 index 0000000..c0955ea --- /dev/null +++ b/client/pages/valuations.js @@ -0,0 +1,200 @@ +// Generated, do not edit. sha256:84e73291cc02df3176d4ba8acbc3d3f1b844080d320978a75a2af8c8bde70e30 +import { listSelectableAccounts } from '../data/accounts.js'; +import { dayNl, localTime, toUtcIso } from '../../shared/time.js'; +import { listAssets, listBookableAssets } from '../data/assets.js'; +import { tableViewCfg } from '../data/preferences.js'; +import { getPriceAt } from '../data/price-history.js'; +import { listValuations, valuationDiffs, updateValuation, deleteValuation, createValuation, + acceptValuationGap } from '../data/valuations.js'; +import { t } from '../i18n/index.js'; +import { assetLabelHtml } from '../ui/asset-label.js'; +import * as dialog from '../ui/dialog.js'; +import { esc } from '../ui/escape.js'; +import { renderTable } from '../ui/table.js'; +import { takeRowFocus } from '../ui/row-focus.js'; +import { fmtBase, fmtBasePrice, fmtUnit, isBaseAsset, val, toSubunit, fromSubunit } from '../ui/format.js'; +import { baseCurrency } from '../app-settings.js'; +import { ensurePriceHistoryFor, historicalPointForVia, primarySourceKey } from '../price/providers.js'; +import { bookDifference, acceptDifference } from '../actions/valuation-widget.js'; +import { TARGETS, targetHash } from '../../shared/config.js'; +import { emptyStateHtml } from '../ui/empty-state.js'; +import { capacityCfg } from '../ui/lock.js'; +import { setPageHint } from '../ui/page-hint.js'; + +let tableApi = null; +let onlyAccount = null; +let onlyAsset = null; +let onlyKinds = null; +const assetAllowed = a => (onlyAsset == null || a.id === onlyAsset) && (onlyKinds == null || onlyKinds.includes(a.kind)); + +export async function render(el, { accountId = null, assetId = null, assetKinds = null } = {}) { + onlyAccount = accountId; + onlyAsset = assetId; + onlyKinds = assetKinds; + if ((await listAssets()).length === 0) { + el.innerHTML = ` + ${emptyStateHtml(t('valuations.none'), { + why: t('valuations.needAsset'), href: targetHash(TARGETS.ASSETS), linkText: t('common.manageAssets'), + })}`; + return; + } + el.innerHTML = `
    `; + if (onlyAccount == null) setPageHint(el, { title: t('nav.valuations'), rows: [t('valuations.rateNote')] }); + buildTable(el); +} + +async function assetOpts() { return (await listBookableAssets()).filter(assetAllowed).map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` })); } +async function accountOpts(huidigeId) { + const accounts = (await listSelectableAccounts({ ookId: Number(huidigeId) || null })) + .filter(a => onlyAccount == null || a.id === onlyAccount); + return [...(onlyAccount == null ? [{ value: '', label: '-' }] : []), ...accounts.map(a => ({ value: a.id, label: a.name }))]; +} +const meta = r => ({ kind: r.asset_kind, decimals: r.asset_decimals, unit: r.asset_unit, subunit: r.asset_subunit, displayUnit: r.asset_display_unit }); +const isRekenmunt = r => isBaseAsset({ symbol: r.asset_symbol }); + +async function valuationPayload(v, existing) { + const asset = (await listAssets()).find(a => a.id === Number(v.assetId)); + const qty = parseFloat(v.balance); + if (!v.valuedOn || !asset || !Number.isFinite(qty) || qty < 0) { + throw new Error(t('valuations.invalid')); + } + const balance = toSubunit(qty, asset); + const rateAt = toUtcIso(`${v.valuedOn}T${v._time || '00:00'}:00`); + let fiatRate = v.fiatRate === '' || v.fiatRate == null ? null : parseFloat(v.fiatRate); + if (fiatRate == null) { + try { await ensurePriceHistoryFor(asset); } catch { } + const readSrc = (await primarySourceKey(asset, 'daily_history')) + || (await primarySourceKey(asset, 'history_point')); + const at = await getPriceAt(asset.id, Math.floor(Date.parse(rateAt) / 1000), readSrc); + fiatRate = at ? at.fiatPrice : null; + if (fiatRate == null) { + const p = await historicalPointForVia(asset, v.valuedOn).catch(() => null); + fiatRate = p ? p.price : null; + } + if (fiatRate == null) throw new Error(t('valuations.noRate')); + } + const accountId = v.accountId ?? onlyAccount; + return { + assetId: Number(v.assetId), valuedOn: v.valuedOn, + accountId: accountId !== '' && accountId != null ? Number(accountId) : null, + balance, fiatRate, rateAt, + source: existing ? existing.source : 'manual', + }; +} + +export async function recordBalance(v) { + return createValuation(await valuationPayload({ fiatRate: '', ...v }, null)); +} + +async function buildTable(el) { + const alleAssets = await listAssets(); + const verschillen = new Map(); + const rows = async () => { + const rijen = (await listValuations()).filter(r => (onlyAccount == null || r.accountId === onlyAccount) + && (onlyAsset == null || r.assetId === onlyAsset)); + verschillen.clear(); + for (const s of await valuationDiffs()) verschillen.set(s.id, s); + return rijen; + }; + const today = new Date().toISOString().slice(0, 10); + + const columns = [ + { key: 'valuedOn', label: t('valuations.colDate'), sortable: true, + render: r => dayNl(r.valuedOn), + group: { value: r => r.valuedOn.slice(0, 4), label: v => v, order: 'desc' }, + edit: { type: 'date', sticky: true, value: r => r ? r.valuedOn : today } }, + { key: '_time', label: t('valuations.colTime'), + render: r => localTime(r.rateAt), + edit: { type: 'time', value: r => r && r.rateAt ? localTime(r.rateAt) : '00:00' } }, + { key: 'accountId', label: t('valuations.colAccount'), sortable: true, sortValue: r => r.account_name || '', + render: r => r.account_name || '-', group: { value: r => r.account_name || '-', label: v => v }, + edit: { type: 'select', sticky: true, options: vals => accountOpts(vals.accountId), value: r => r ? r.accountId ?? '' : '' } }, + { key: 'assetId', label: t('valuations.colAsset'), sortable: true, sortValue: r => r.asset_symbol, + render: r => assetLabelHtml(r), group: { value: r => r.asset_symbol, label: v => v }, + edit: { type: 'select', sticky: true, reactive: true, options: assetOpts } }, + { key: 'balance', label: t('valuations.colBalance'), align: 'num', sortable: true, sortValue: r => r.balance, + render: r => { + const fiat = (r.fiatRate && !isRekenmunt(r)) ? fmtBase(r.balance / Math.pow(10, r.asset_decimals) * r.fiatRate) : ''; + return `${val(fmtUnit(r.balance, meta(r)))}${fiat ? `
    ${val(fiat)}
    ` : ''}`; + }, + edit: { type: 'number', attrs: { min: 0, step: 'any' }, value: r => r ? fromSubunit(r.balance, meta(r)) : '', convert: vals => alleAssets.find(a => a.id === Number(vals.assetId)) } }, + { key: 'fiatRate', label: t('valuations.colRate', { munt: baseCurrency() }), align: 'num', sortable: true, sortValue: r => r.fiatRate, + render: r => (r.fiatRate && !isRekenmunt(r)) ? val(fmtBasePrice(r.fiatRate)) : '-', + edit: { type: 'number', attrs: { min: 0, step: 'any' }, placeholder: t('valuations.ratePlaceholder'), + visible: vals => !isBaseAsset(alleAssets.find(a => a.id === Number(vals.assetId))), + value: r => r && r.fiatRate ? r.fiatRate : '' } }, + { key: 'source', label: t('valuations.colSource'), + render: r => r.source ? `${t('enums.valuation_source.' + r.source)}` : '-' }, + { key: '_diff', label: t('valuations.colDiff'), align: 'num', + render: r => { + const s = verschillen.get(r.id); + const d = s ? s.gap : null; + if (d == null) return '-'; + if (s.future) return `⏳`; + if (!s.reconcilable) return `⚓`; + if (d === 0) return `✓`; + if (s.accepted) { + return ``; + } + return `${val(fmtUnit(d, meta(r)))} + + `; + } }, + ].filter(c => onlyAccount == null || c.key !== 'accountId'); + + const toPayload = valuationPayload; + const rijen = await rows(); + let used = rijen.length; + const reload = async () => { const r = await rows(); used = r.length; tableApi.refresh(r); }; + tableApi = renderTable(el.querySelector('#tbl'), { + columns, rows: rijen, + ...capacityCfg('valuations', () => used), + ...(await tableViewCfg('valuations', { key: 'valuedOn', dir: 'desc' }, onlyAccount != null ? 'assetId' : null)), + emptyText: t('valuations.none'), + noneLabel: t('table.none'), + collapsibleGroups: true, + newLabel: t('table.new'), + convertLabel: t('convert.title'), + newDefaults: { accountId: onlyAccount ?? '', fiatRate: '', ...(onlyAsset != null ? { assetId: onlyAsset } : {}) }, + saveLabel: t('trades.save'), cancelLabel: t('trades.cancel'), + onCreate: async v => { + const id = await createValuation(await toPayload(v, null)); + const r = await rows(); + used = r.length; + tableApi.refresh(r, { locate: id }); + }, + onSave: async (id, v) => { + const ex = (await listValuations()).find(x => x.id === id); + await updateValuation(id, await toPayload(v, ex)); reload(); + }, + onDelete: async id => { + if (!(await dialog.confirm({ message: t('valuations.confirmDelete'), danger: true }))) return; + await deleteValuation(id); reload(); + }, + }); + + el.querySelector('#tbl').addEventListener('click', async e => { + const b = e.target.closest('[data-book]'); + if (b) { + e.preventDefault(); e.stopPropagation(); + bookDifference((await rows()).find(x => x.id === Number(b.dataset.book)), reload); + return; + } + const a = e.target.closest('[data-accept]'); + if (a) { + e.preventDefault(); e.stopPropagation(); + acceptDifference(a.dataset.accept, reload); + return; + } + const u = e.target.closest('[data-unaccept]'); + if (u) { + e.preventDefault(); e.stopPropagation(); + await acceptValuationGap(Number(u.dataset.unaccept), false); + reload(); + } + }, true); + + tableApi.focusRow(takeRowFocus('valuations')); +} diff --git a/client/pages/wealth.js b/client/pages/wealth.js new file mode 100644 index 0000000..62d8066 --- /dev/null +++ b/client/pages/wealth.js @@ -0,0 +1,1444 @@ +// Generated, do not edit. sha256:57bab9aa8f9259dfb0fc3db59e9e7a9ee92e130476263caf08353fbfd938fe6a +import { accountHoldings, assetPositionsByAccount, positionsByAsset } from '../data/positions.js'; +import { listAssets } from '../data/assets.js'; +import { isOwnWealth } from '../../shared/wealth-scope.js'; +import { listAssetTrades } from '../data/events.js'; +import { listAccounts } from '../data/accounts.js'; +import { metalHoldings } from '../data/metals.js'; +import { latestPricePoint } from '../data/price-history.js'; +import { herkomstContext } from '../wealth/data.js'; +import { lastSpotSource, basePriceOf } from '../price/providers.js'; +import { originHtml, originsHtml, infoLinesHtml } from '../ui/price-origin.js'; +import { provenanceOfRow, provenanceOn, originFrom, liveOrigin } from '../../shared/price-provenance.js'; +import { t } from '../i18n/index.js'; +import { assetLabel, assetLabelHtml, byKindAndLabel } from '../ui/asset-label.js'; +import { fmtAmount, fmtBase, fmtBasePrice, fmtBaseSigned, colouredSigns, fmtPct, fmtUnit, priceFromBase, subline, val, isMetal, isBaseAsset } from '../ui/format.js'; +import { CONFIG, ENUMS } from '../../shared/config.js'; +import { loadFiat } from '../ui/fiat.js'; +import * as loading from '../ui/loading.js'; +import * as dialog from '../ui/dialog.js'; +import { tekenVerdeling } from '../wealth/chart.js'; +import { accountSeries, dagreeksVanNu, jaarVensters, meetVenster, metSpotVandaag, seriesFor, windowDelta, windowResult } from '../wealth/series.js'; +import { accountCards } from '../wealth/account-cards.js'; +import { create as createFilter } from '../ui/filter.js'; +import { windowTwr } from '../wealth/performance.js'; +import { tekenJaarGrafiek } from '../wealth/year-chart.js'; +import { mountSparkline } from '../ui/sparkline.js'; +import { openReturnDialog, mountReturnPanel, refreshReturn } from '../wealth/return-dialog.js'; +import { create as createTabs } from '../ui/tabs.js'; +import { notesHtml, mountNotes } from '../ui/notes.js'; +import { ARROW_DOWN, ARROW_UP, CHEVRON_RIGHT } from '../ui/icons.js'; +import { getPref, setPref } from '../data/preferences.js'; +import { esc } from '../ui/escape.js'; +import { emptyStateHtml } from '../ui/empty-state.js'; +import { ALL_YEARS, choiceToText, choiceFromText, withoutYear, yearsIn } from '../wealth/year-range.js'; +import { sameSelection, selectionFromStored, hiddenFromStored, hiddenAfterFilter, splitHidden, + hiddenKindsFromStored, accountKindItems, accountKindOf, NO_ACCOUNT_KIND } from '../wealth/selection.js'; +import { setPageHint, SAMPLES } from '../ui/page-hint.js'; +import { assetMetrics, combinePositions } from '../../shared/wealth-metrics.js'; +import { localDateNl, nowUtcIso } from '../../shared/time.js'; + +export const title = () => t('wealth.title'); + +const BASE = CONFIG.METALS.BASE_DECIMALS; +const DASH = '-'; +const gekleurd = () => ({ signs: colouredSigns() }); + +export const headerInColumn = true; + +export async function render(el) { + el.innerHTML = `
    `; + const host = el.querySelector('#vm'); + loading.show(host, 'cards', { count: 3 }); + load(host); +} + +async function standenOp(peil, koersVan = null, toon = LEEG, herkomstVoor = null, { holdings = null } = {}) { + const [alle, perAccount] = await Promise.all([ + positionsByAsset(peil ?? undefined, { ownOnly: ownOnly() }), + holdings ?? accountHoldings(alleAccounts.filter(a => !ownOnly() || isOwnWealth(a)).map(a => a.id), peil ?? undefined), + ]); + const positions = alle.filter(p => p.net !== 0 || toon.has(p.id)); + const assets = positions.map(p => ({ + id: p.id, symbol: p.symbol, name: p.name, kind: p.kind, decimals: p.decimals, + unit: p.unit, subunit: p.subunit, displayUnit: p.displayUnit, + })); + + let koersen; + let herkomsten = positions.map(() => null); + if (koersVan) { + koersen = positions.map(p => koersVan(p.id)); + if (herkomstVoor) herkomsten = positions.map((p, i) => herkomstVoor(p, koersen[i])); + } else { + const spot = await loadFiat(assets); + const ctx = await herkomstContext(); + const lastStored = async id => { try { return await latestPricePoint(id); } catch { return null; } }; + const priceOf = async id => { + const s = spot.get(id); + const prijs = basePriceOf(s); + if (prijs != null) { + const asset = assets.find(a => a.id === id); + return { fiatPrice: prijs, origin: liveOrigin(prijs, lastSpotSource(asset)) }; + } + const rij = await lastStored(id); + if (!rij) return { fiatPrice: null, origin: null }; + return { fiatPrice: rij.fiatPrice, origin: originFrom(provenanceOfRow(rij, { manualProviderId: ctx.manualId }), ctx.names) }; + }; + const uit = await Promise.all(positions.map(p => priceOf(p.id))); + koersen = uit.map(u => u.fiatPrice); + herkomsten = uit.map(u => u.origin); + } + + let total = 0; + const kaarten = positions.map((p, i) => { + const asset = assets.find(a => a.id === p.id); + const fiatPrice = koersen[i]; + const k = assetMetrics(p, fiatPrice); + if (k.value != null) total += k.value; + return { asset, p, m: { fiatPrice, value: k.value, avgMain: k.avgPrice, unrealized: k.unrealized, + avgPremium: k.avgPremium, invested: k.invested, origin: herkomsten[i] } }; + }); + + if (!ownOnly() && excludedAccounts.length) await splitsEigenEnBeheerd(kaarten, perAccount, peil); + + kaarten.sort((a, b) => byKindAndLabel(a.asset, b.asset)); + + return { + kaarten, total, holdings: perAccount, + invested: kaarten.reduce((s, k) => s + k.m.invested, 0), + unrealized: kaarten.reduce((s, k) => s + (k.m.unrealized ?? 0), 0), + }; +} + +async function load(host) { + if (verdeling) { verdeling.destroy(); verdeling = null; } + ringenBelofte = null; + hoverGekoppeld = false; + selectie = null; + reeksBelofte = null; + getekendPeil = null; + nuReeks = null; + getekendVenster = null; + keuze = keuzeUitTekst(await getPref('page.wealth', 'year', String(new Date().getFullYear()))); + alleAccounts = await listAccounts(); + excludedAccounts = alleAccounts.filter(a => !isOwnWealth(a)); + showAll = excludedAccounts.length > 0 + && (await getPref('page.wealth', 'showAll', false).catch(() => false)) === true; + const bewaard = await getPref('page.wealth', 'selection', null).catch(() => null); + verborgen = hiddenFromStored(await getPref('page.wealth', 'hidden', []).catch(() => [])); + verborgenSoorten = hiddenKindsFromStored(await getPref('page.wealth', 'hiddenAccountKinds', []).catch(() => []), ENUMS.ACCOUNT_KIND); + groepering = (await getPref('page.wealth', 'grouping', GROEP.ASSET).catch(() => GROEP.ASSET)) === GROEP.ACCOUNT + ? GROEP.ACCOUNT : GROEP.ASSET; + + const wachtOpReeks = keuze !== ALLE_JAREN && keuze.tot < new Date().getFullYear(); + if (wachtOpReeks && !reeksBelofte) reeksBelofte = reeksOphalen(); + + nuStanden = await standenOp(null); + if (!nuStanden.kaarten.length && ownOnly()) { + showAll = true; + if (reeksBelofte) reeksBelofte = reeksOphalen(); + nuStanden = await standenOp(null); + } + if (!nuStanden.kaarten.length) { + host.innerHTML = `` + + emptyStateHtml(t('wealth.none')); + return; + } + const holdings = await metalHoldings({ ownOnly: ownOnly() }); + + const kolom = ` + `; + + const reeksVooraf = wachtOpReeks ? await reeksBelofte.catch(() => null) : null; + + host.innerHTML = `
    ${kolom}` + + `
    ` + + `` + + `
    `; + + selectie = bewaard ? selectionFromStored(bewaard) : null; + koppelGroepering(host); + + legendaAanmelden(host); + buitenRegels() + .then(regels => { if (regels.length && host.isConnected) legendaAanmelden(host, regels); }) + .catch(err => console.warn('[vermogen] buiten het eigen vermogen niet gewaardeerd:', err)); + + let ringenAlGetekend = false; + if (reeksVooraf) { + loading.show(host.querySelector('.wealth-grid'), 'cards', { count: Math.max(1, Math.min(nuStanden.kaarten.length, 6)) }); + vulJaarkeuze(host, reeksVooraf); + await toonJaar(host, reeksVooraf); + ringenAlGetekend = getekendPeil !== null; + if (!ringenAlGetekend) tekenKaarten(host, nuStanden, `${PEIL_NU}|`); + } else { + tekenKaarten(host, nuStanden, `${PEIL_NU}|`); + vulTijdreeks(host); + } + if (selectie && !kaartenVoor(host, selectie).length) pasSelectieToe(host, null); + + host.addEventListener('click', e => { + if (e.target.closest('[data-jaaroverzicht]')) { openJaaroverzicht(); return; } + if (e.target.closest('[data-rendement]')) { openRendement(); return; } + if (e.composedPath().some(n => n instanceof Element && n.matches('[data-groepering], [data-filter]'))) return; + const detailAccount = e.target.closest('[data-detail-account]'); + if (detailAccount) { + const c = getekendeAccounts.find(x => accountSleutel(x) === Number(detailAccount.dataset.detailAccount)); + if (c) openAccountDetail(c); + return; + } + const accountKaart = e.target.closest('[data-account]'); + if (accountKaart) { + const c = getekendeAccounts.find(x => accountSleutel(x) === Number(accountKaart.dataset.account)); + zetSelectie(host, c ? { account: accountSleutel(c), assetIds: c.assetIds } : null); + return; + } + const detail = e.target.closest('[data-detail]'); + if (detail) { + const id = Number(detail.dataset.detail); + const k = nuStanden.kaarten.find(x => x.asset.id === id) + || (getekendeStanden?.kaarten || []).find(x => x.asset.id === id); + if (k) openDetail(k, holdings); + return; + } + const kaart = e.target.closest('[data-asset]'); + zetSelectie(host, kaart ? { assetIds: [Number(kaart.dataset.asset)] } : null); + }); + host.addEventListener('change', async e => { + const box = e.target.closest('[data-wealth-all]'); + if (!box) return; + await setPref('page.wealth', 'showAll', box.checked).catch(() => {}); + if (host.isConnected) render(host.parentElement); + }); + + if (!ringenAlGetekend) tekenRingen(host, nuStanden.kaarten, standenGen); + + spotHost = host; + if (!spotGebonden) { document.addEventListener('tick:spot', verversSpot); spotGebonden = true; } +} + +let spotHost = null; +let spotGebonden = false; +let nuReeks = null; +let getekendVenster = null; + +async function verversSpot() { + const host = spotHost; + if (!host || !host.isConnected) { spotHost = null; return; } + if (!nuReeks || !getekendVenster) return; + if (peilVan(nuReeks, getekendVenster) !== PEIL_NU) return; + + const gen = ++standenGen; + let standen; + try { + const holdings = nuStanden.holdings; + nuStanden = await standenOp(null, null, LEEG, null, { holdings }); + const bewogen = assetsInVenster(nuReeks, getekendVenster); + const nuHeeft = new Set(nuStanden.kaarten.map(k => k.asset.id)); + const extra = [...bewogen].some(id => !nuHeeft.has(id)); + standen = extra ? await standenOp(null, null, bewogen, null, { holdings }) : nuStanden; + } catch (err) { console.warn('[vermogen] spot niet ververst:', err); return; } + if (gen !== standenGen || !host.isConnected) return; + + const prices = new Map(); + for (const k of standen.kaarten) if (k.m.fiatPrice != null) prices.set(k.asset.id, k.m.fiatPrice); + metSpotVandaag(nuReeks, standen.kaarten.map(k => k.asset), prices); + + tekenKaarten(host, standen, getekendPeil); + vulSparklines(host, nuReeks, getekendVenster); + vulTwr(host, nuReeks, getekendVenster); + refreshReturn(); +} + +const ringHaken = host => ({ + onHover: sel => { markeerKaarten(host, sel); verdeling?.markeer(sel); }, + onSelect: sel => zetSelectie(host, sel), +}); + +function tekenKaarten(host, standen, peil) { + const grid = host.querySelector('.wealth-grid'); + if (!grid) return; + if (groepering === GROEP.ACCOUNT) { + getekendeAccounts = accountCards({ kaarten: standen.kaarten, holdings: standen.holdings || [], + accounts: alleAccounts, toon: accountsInVenster(nuReeks, getekendVenster) }); + const { shown } = splitHidden(getekendeAccounts, verborgenSoorten, accountKindOf); + grid.innerHTML = shown.map(c => accountCardHtml(c, standen.total)).join(''); + const soortLabel = kind => (kind === NO_ACCOUNT_KIND ? t('wealth.noAccount') : t(`enums.account_kind.${kind}`)); + tekenFilter(host, accountKindItems(getekendeAccounts).map(kind => ({ id: kind, label: soortLabel(kind) })), verborgenSoorten); + } else { + const { shown } = splitHidden(standen.kaarten, verborgen); + grid.innerHTML = shown.map(k => cardHtml(k.asset, k.p, k.m, standen.total)).join(''); + tekenFilter(host, standen.kaarten.map(k => ({ id: k.asset.id, label: k.asset.symbol, title: k.asset.name })), verborgen); + } + const zet = (sleutel, html) => { + const el = host.querySelector(`[data-som="${sleutel}"]`); + if (el) el.innerHTML = html; + }; + const herkomsten = standen.kaarten.filter(k => k.m.value != null).map(k => ({ origin: k.m.origin, asset: k.asset })); + zet('total', `${val(fmtBase(standen.total))}${originsHtml(herkomsten)}`); + zet('invested', val(fmtBase(standen.invested))); + zet('unrealized', kleurBedrag(standen.unrealized)); + getekendeStanden = standen; + getekendPeil = peil; + pasSelectieToe(host, selectie); +} + +let reeksBelofte = null; + +let excludedAccounts = []; +let showAll = false; +const ownOnly = () => excludedAccounts.length > 0 && !showAll; +const scopeOf = id => excludedAccounts.find(a => a.id === id)?.wealthScope ?? null; +const reeksOphalen = () => dagreeksVanNu({ + excludedAccounts: ownOnly() ? new Set(excludedAccounts.map(a => a.id)) : null, + managedAccounts: new Set(excludedAccounts.map(a => a.id)), +}); +const reeksNodig = () => (reeksBelofte ??= reeksOphalen()); + +async function splitsEigenEnBeheerd(kaarten, perAccount, peil) { + const beheerd = new Set(excludedAccounts.map(a => a.id)); + const metBeheerd = kaarten.filter(k => perAccount.some(h => h.assetId === k.asset.id && beheerd.has(h.accountId))); + if (!metBeheerd.length) return; + const [eigenRijen, reeks] = await Promise.all([ + positionsByAsset(peil ?? undefined, { ownOnly: true }), + reeksNodig().catch(() => null), + ]); + const potten = reeks?.beheerdeKostprijs?.(peil ?? nowUtcIso()) ?? new Map(); + const leeg = { buy_amount: 0, buy_spot_raw: 0, buy_fee_total: 0, buy_events: 0, gift_amount: 0 }; + for (const k of metBeheerd) { + const eigenRij = eigenRijen.find(r => r.id === k.asset.id) ?? { ...k.p, ...leeg, net: 0 }; + const eigen = assetMetrics(eigenRij, k.m.fiatPrice); + const posities = new Map(); + const delen = perAccount.filter(h => h.assetId === k.asset.id && beheerd.has(h.accountId)).map(h => { + const pot = potten.get(`${h.accountId}|${k.asset.id}`) ?? leeg; + const positie = { decimals: k.asset.decimals, net: h.saldo, ...pot }; + posities.set(h.accountId, positie); + const m = assetMetrics(positie, k.m.fiatPrice); + return { accountId: h.accountId, saldo: h.saldo, cost: m.avgCost != null ? m.units * m.avgCost : null, unrealized: m.unrealized }; + }); + const alle = [{ saldo: eigenRij.net, unrealized: eigen.unrealized }, ...delen].filter(d => d.saldo); + const vanDerden = delen.filter(d => d.saldo && scopeOf(d.accountId) === 'managed'); + const derden = vanDerden.length + ? assetMetrics(combinePositions(vanDerden.map(d => posities.get(d.accountId))), k.m.fiatPrice) : null; + k.m = { ...k.m, + unrealized: alle.some(d => d.unrealized == null) ? null : alle.reduce((s, d) => s + d.unrealized, 0), + avgMain: eigen.avgPrice, avgPremium: eigen.avgPremium, invested: eigen.invested, + eigen: { net: eigenRij.net, unrealized: eigen.unrealized }, beheerd: delen, + derden: derden && { avgMain: derden.avgPrice, avgPremium: derden.avgPremium, invested: derden.invested, + unrealized: derden.unrealized } }; + } +} + +const PEIL_NU = 'nu'; +const LEEG = new Set(); +const LEEG_MAP = new Map(); +let nuStanden = { kaarten: [], total: 0, invested: 0, unrealized: 0 }; +let getekendeStanden = null; +let getekendPeil = null; +let standenGen = 0; +const ALLE_JAREN = ALL_YEARS; +const keuzeTekst = choiceToText; +const keuzeUitTekst = choiceFromText; +const zonderJaar = withoutYear; +const jarenIn = yearsIn; +let keuze = { van: new Date().getFullYear(), tot: new Date().getFullYear() }; + +function vulTijdreeks(host) { + if (!reeksBelofte) reeksBelofte = reeksOphalen(); + reeksBelofte + .then(reeks => { vulJaarkeuze(host, reeks); toonJaar(host, reeks); }) + .catch(err => console.warn('[vermogen] tijdreeks niet getekend:', err)); +} + +function vensterVan(reeks) { + const heel = { van: 0, tot: reeks.dagen.length - 1 }; + if (keuze === ALLE_JAREN) return heel; + const vensters = jaarVensters(reeks.dagen); + const gevonden = jarenIn(keuze).map(j => vensters.get(j)).filter(Boolean); + if (!gevonden.length) return heel; + return { van: gevonden[0].van, tot: gevonden[gevonden.length - 1].tot }; +} + +function peilVan(reeks, venster) { + const meet = meetVenster(venster, reeks.dagen.length); + if (meet.tot >= reeks.dagen.length - 1) return PEIL_NU; + return `${reeks.dagen[meet.tot]}T23:59:59.999Z`; +} + +function assetsInVenster(reeks, venster) { + const uit = new Set(); + for (const [id, eenheden] of (reeks.eenhedenPerAsset || LEEG_MAP)) { + for (let i = venster.van; i <= venster.tot; i++) { + if (eenheden[i]) { uit.add(id); break; } + } + } + const raakt = dagen => (dagen || []).some(i => i >= venster.van && i <= venster.tot); + for (const [id, dagen] of (reeks.aankopen || LEEG_MAP)) if (raakt(dagen)) uit.add(id); + for (const [id, dagen] of (reeks.verkopen || LEEG_MAP)) if (raakt(dagen)) uit.add(id); + return uit; +} + +async function toonJaar(host, reeks) { + const venster = vensterVan(reeks); + nuReeks = reeks; + getekendVenster = venster; + vulSparklines(host, reeks, venster); + vulTwr(host, reeks, venster); + await zetStanden(host, reeks, venster); +} + +async function zetStanden(host, reeks, venster) { + const peil = peilVan(reeks, venster); + const bewogen = assetsInVenster(reeks, venster); + const nuHeeft = new Set(nuStanden.kaarten.map(k => k.asset.id)); + const extra = [...bewogen].filter(id => !nuHeeft.has(id)).sort((a, b) => a - b); + const sleutel = `${peil}|${extra.join(',')}`; + if (sleutel === getekendPeil) { if (groepering === GROEP.ACCOUNT) hertekenRaster(host); return; } + const gen = ++standenGen; + let standen = nuStanden; + if (peil !== PEIL_NU || extra.length) { + const dagIndex = meetVenster(venster, reeks.dagen.length).tot; + const koersVan = id => reeks.koersPerAsset?.get(id)?.[dagIndex] ?? null; + const herkomstVoor = async () => { + const ctx = await herkomstContext(); + const dag = reeks.dagen[dagIndex]; + return (p, price) => (price == null ? null : originFrom(provenanceOn(reeks.rijenPerAsset?.get(p.id) || [], dag, + { assetKind: p.kind, manualProviderId: ctx.manualId, usedPrice: price }), ctx.names)); + }; + try { + standen = peil === PEIL_NU + ? await standenOp(null, null, bewogen) + : await standenOp(peil, koersVan, bewogen, await herkomstVoor()); + } catch (err) { console.warn('[vermogen] standen van de periode niet opgehaald:', err); return; } + if (gen !== standenGen || !host.isConnected) return; + } + tekenKaarten(host, standen, sleutel); + vulSparklines(host, reeks, venster); + vulTwr(host, reeks, venster); + await tekenRingen(host, standen.kaarten, gen); +} + +async function tekenRingen(host, kaarten, gen) { + const cvSoorten = host.querySelector('[data-ring="kinds"]'); + const cvAssets = host.querySelector('[data-ring="assets"]'); + if (!cvSoorten && !cvAssets) return; + try { await ringenBelofte; } catch { } + verdeling?.destroy(); + verdeling = null; + try { + ringenBelofte = tekenVerdeling(cvSoorten, cvAssets, kaarten, ringHaken(host)); + const v = await ringenBelofte; + if (gen !== standenGen || !host.isConnected) { v?.destroy(); return; } + verdeling = v; + if (!v) { if (!hoverGekoppeld) host.querySelector('.wealth-header-rings')?.remove(); return; } + if (!hoverGekoppeld) { hoverGekoppeld = true; koppelKaartHover(host); } + v.selecteer(selectie); + } catch (err) { + console.warn('[vermogen] cirkelgrafieken niet getekend:', err); + if (gen === standenGen && !hoverGekoppeld) host.querySelector('.wealth-header-rings')?.remove(); + } +} + +function vulJaarkeuze(host, reeks) { + const balk = host.querySelector('[data-jaren]'); + if (!balk) return; + const vensters = jaarVensters(reeks.dagen); + const heel = { van: 0, tot: reeks.dagen.length - 1 }; + const pctVan = j => { const v = vensters.get(j); return v ? rendementVanVenster(reeks, v) : null; }; + + const bekend = [...vensters.keys()]; + if (keuze !== ALLE_JAREN && bekend.length) { + const raak = jarenIn(keuze).filter(j => vensters.has(j)); + if (!raak.length) { const laatste = Math.max(...bekend); keuze = { van: laatste, tot: laatste }; } + else keuze = { van: raak[0], tot: raak[raak.length - 1] }; + } + + balk.innerHTML = ` +
    +
    +
    + ${bekend.slice().sort((a, b) => b - a).map(j => menuKnop(j, String(j), pctVan(j))).join('')} + ${menuKnop(ALLE_JAREN, t('wealth.allYears'), rendementVanVenster(reeks, heel))} +
    +
    ${t('wealth.yearRangeTip')}
    +
    `; + tekenKaders(balk, pctVan, rendementVanVenster(reeks, heel)); + + balk.onclick = e => { + const uit = e.target.closest('[data-jaar-uit]'); + if (uit) { + keuze = zonderJaar(keuze, Number(uit.dataset.jaarUit)); + setPref('page.wealth', 'year', keuzeTekst(keuze)).catch(() => { }); + tekenKaders(balk, pctVan, rendementVanVenster(reeks, heel)); + toonJaar(host, reeks); + return; + } + const b = e.target.closest('[data-jaar]'); + if (!b) return; + if (b.dataset.jaar === ALLE_JAREN) keuze = ALLE_JAREN; + else { + const gekozen = Number(b.dataset.jaar); + const anker = e.shiftKey && keuze !== ALLE_JAREN ? keuze.van : gekozen; + keuze = { van: Math.min(anker, gekozen), tot: Math.max(anker, gekozen) }; + } + setPref('page.wealth', 'year', keuzeTekst(keuze)).catch(() => { }); + tekenKaders(balk, pctVan, rendementVanVenster(reeks, heel)); + toonJaar(host, reeks); + }; +} + +function menuKnop(waarde, label, pct) { + return ``; +} + +function jaarInhoud(label, pct) { + if (pct == null) return esc(label); + return `${esc(label)}${fmtPct(pct, gekleurd())}`; +} + +const pctCel = p => (p == null ? DASH : `${fmtPct(p, gekleurd())}`); +const bedragCel = v => (v == null || Math.abs(v) < 0.01 ? DASH : kleurBedrag(v)); + +function pctToon(pct) { + const zichtbaar = Math.round(pct * 1000); + return zichtbaar > 0 ? 't-pos' : zichtbaar < 0 ? 't-neg' : ''; +} + +function tekenKaders(balk, pctVan, pctHeel) { + const kader = (label, pct, jaar) => (jaar == null + ? `${jaarInhoud(label, pct)}` + : ``); + const kaders = keuze === ALLE_JAREN + ? kader(t('wealth.allYears'), pctHeel, null) + : jarenIn(keuze).map(j => kader(String(j), pctVan(j), j)).join(''); + balk.querySelector('[data-kaders]').innerHTML = kaders; + const gekozen = keuze === ALLE_JAREN ? [ALLE_JAREN] : jarenIn(keuze).map(String); + for (const k of balk.querySelectorAll('[data-jaar]')) k.classList.toggle('is-on', gekozen.includes(k.dataset.jaar)); +} + +function assetRendement(reeks, id, venster) { + return windowTwr(seriesFor(reeks, id), meetVenster(venster, reeks.dagen.length)); +} + +function rendementVanVenster(reeks, venster) { + return windowTwr(seriesFor(reeks), meetVenster(venster, reeks.dagen.length)); +} + +let verdeling = null; +let ringenBelofte = null; +let hoverGekoppeld = false; +let selectie = null; +let verborgen = new Set(); +let verborgenSoorten = new Set(); + +const GROEP = Object.freeze({ ASSET: 'asset', ACCOUNT: 'account' }); +let groepering = GROEP.ASSET; +let alleAccounts = []; +let getekendeAccounts = []; +const accountSleutel = c => c.accountId ?? 0; +const reeksSleutel = sleutel => (sleutel === 0 ? null : sleutel); +const accountNaam = c => c.account?.name ?? t('wealth.noAccount'); + +function koppelGroepering(host) { + const bar = host.querySelector('[data-groepering]'); + if (!bar) return; + const knop = (id, label) => ``; + const teken = () => { + bar.innerHTML = knop(GROEP.ASSET, t('wealth.perAsset')) + knop(GROEP.ACCOUNT, t('wealth.perAccount')); + }; + teken(); + bar.onclick = e => { + const id = e.target.closest('[data-groep]')?.dataset.groep; + if (!id || id === groepering) return; + groepering = id; + setPref('page.wealth', 'grouping', id).catch(() => { }); + teken(); + hertekenRaster(host); + }; +} + +function hertekenRaster(host) { + if (!host?.isConnected || !getekendeStanden) return; + tekenKaarten(host, getekendeStanden, getekendPeil); + if (nuReeks && getekendVenster) { vulSparklines(host, nuReeks, getekendVenster); vulTwr(host, nuReeks, getekendVenster); } +} + +function tekenFilter(host, items, hidden) { + const el = host.querySelector('[data-filter]'); + if (!el) return; + createFilter(el, { + items, + selected: new Set(items.map(i => i.id).filter(id => !hidden.has(id))), + onChange: sel => { + const lijst = hiddenAfterFilter(hidden, items.map(i => i.id), sel); + if (groepering === GROEP.ACCOUNT) verborgenSoorten = new Set(lijst); + else verborgen = new Set(lijst); + setPref('page.wealth', groepering === GROEP.ACCOUNT ? 'hiddenAccountKinds' : 'hidden', lijst).catch(() => { }); + hertekenRaster(host); + }, + }); +} + +function accountsInVenster(reeks, venster) { + const uit = new Set(); + if (!reeks || !venster) return uit; + for (const [id, bezit] of (reeks.bezitPerAccount || LEEG_MAP)) { + for (let i = venster.van; i <= venster.tot; i++) if (bezit[i]) { uit.add(id); break; } + } + return uit; +} + +const zelfdeSelectie = sameSelection; + +function zetSelectie(host, sel) { + pasSelectieToe(host, zelfdeSelectie(selectie, sel) ? null : sel); + setPref('page.wealth', 'selection', selectie).catch(() => { }); +} + +function pasSelectieToe(host, sel) { + selectie = sel; + for (const el of host.querySelectorAll('.wealth-card.is-selected')) el.classList.remove('is-selected'); + for (const el of kaartenVoor(host, selectie)) el.classList.add('is-selected'); + verdeling?.selecteer(selectie); +} + +function kaartenVoor(host, sel) { + if (!sel) return []; + const accountKaarten = [...host.querySelectorAll('[data-account]')]; + if (accountKaarten.length) { + if (sel.account != null) return accountKaarten.filter(el => Number(el.dataset.account) === sel.account); + if (sel.kind) return accountKaarten.filter(el => el.dataset.kinds.split(' ').includes(sel.kind)); + const ids = new Set((sel.assetIds || []).map(String)); + return accountKaarten.filter(el => el.dataset.assets.split(' ').some(id => ids.has(id))); + } + const kies = sel.assetIds?.length + ? sel.assetIds.map(id => `[data-asset="${CSS.escape(String(id))}"]`).join(',') + : (sel.kind ? `[data-kind="${CSS.escape(sel.kind)}"]` : null); + return kies ? [...host.querySelectorAll(kies)] : []; +} + +function markeerKaarten(host, sel) { + for (const el of host.querySelectorAll('.wealth-card.is-linked')) el.classList.remove('is-linked'); + for (const el of kaartenVoor(host, sel)) el.classList.add('is-linked'); +} + +function koppelKaartHover(host) { + const grid = host.querySelector('.wealth-grid'); + if (!grid) return; + let actief = null; + grid.addEventListener('mouseover', e => { + const kaart = e.target.closest('[data-asset], [data-account]'); + const id = kaart ? (kaart.dataset.asset ? `a${kaart.dataset.asset}` : `r${kaart.dataset.account}`) : null; + if (id === actief) return; + actief = id; + markeerKaarten(host, null); + const assetIds = !kaart ? null : kaart.dataset.asset ? [Number(kaart.dataset.asset)] + : kaart.dataset.assets.split(' ').filter(Boolean).map(Number); + verdeling?.markeer(assetIds?.length ? { assetIds } : null); + }); + grid.addEventListener('mouseleave', () => { + actief = null; + markeerKaarten(host, null); + verdeling?.markeer(null); + }); +} + +async function openDetail(k, holdings) { + const [alleRijen, accounts, alleTrades, reeks] = await Promise.all([ + assetPositionsByAccount(k.asset.id), + listAccounts(), + listAssetTrades(k.asset.id).catch(() => []), + (reeksBelofte || Promise.resolve(null)).catch(() => null), + ]); + const buiten = new Set(ownOnly() ? excludedAccounts.map(a => a.id) : []); + const accountVan = tr => (tr.type === 'sell' ? tr.fromAccountId : tr.toAccountId); + const vanDerden = new Set(k.m.derden ? (k.m.beheerd || []).map(d => d.accountId).filter(id => scopeOf(id) === 'managed') : []); + const deel = derden => ({ + rijen: alleRijen.filter(r => !buiten.has(r.accountId) && vanDerden.has(r.accountId) === derden), + trades: alleTrades.filter(tr => !buiten.has(accountVan(tr)) && vanDerden.has(accountVan(tr)) === derden), + }); + const eigen = deel(false); + const derden = vanDerden.size ? deel(true) : null; + const naam = new Map(accounts.map(a => [a.id, a.name])); + dialog.detail({ title: assetLabel(k.asset), titleHtml: assetLabelHtml(k.asset), + asideHtml: `${t('enums.asset_kind.' + k.asset.kind)}`, html: detailHtml() }); + const body = document.querySelector('.dialog-overlay .dialog-body--rich'); + if (!body) return; + body.closest('.dialog')?.insertAdjacentHTML('afterbegin', + `${jaarInhoud(t('wealth.allYears'), null)}`); + mountDetailTabs(body, k, { eigen, derden }, naam, holdings, reeks); + tekenDetailGrafiek(body, k, reeks, derden ? [...eigen.trades, ...derden.trades] : eigen.trades); +} + +function tekenDetailGrafiek(body, k, reeks, trades) { + const el = body.querySelector('[data-detail-spark]'); + if (!el) return; + const leeg = () => { + el.innerHTML = `
    ${t('wealth.noChart')}
    `; + }; + if (!reeks?.dagen?.length) return leeg(); + const dagenMs = reeks.dagen.map(d => Date.parse(`${d}T00:00:00Z`)); + const heel = { van: 0, tot: reeks.dagen.length - 1 }; + const gesneden = assetReeks(reeks, k.asset.id, heel, dagenMs); + if (!gesneden) return leeg(); + const markers = trades + .map(tr => ({ x: dagMs(tr.occurredAt), variant: tr.type === 'sell' ? 'sell' : 'buy', key: `t${tr.id}`, + label: tr.label_names || undefined })) + .filter(m => m.x != null && m.x >= gesneden.vanMs && m.x <= gesneden.totMs); + mountSparkline(el, gesneden.punten, { + tone: 'accent', vergelijk: gesneden.inleg, formatValue: sparkLabel, markers, + }); + + const licht = (id, aan) => { + body.querySelector(`[data-trade="${CSS.escape(String(id))}"]`)?.classList.toggle('is-active', aan); + el.querySelector(`[data-mark="t${CSS.escape(String(id))}"]`)?.classList.toggle('is-active', aan); + }; + let actief = null; + body.addEventListener('mouseover', e => { + const rij = e.target.closest('[data-trade]'); + const id = rij ? rij.dataset.trade : null; + if (id === actief) return; + if (actief) licht(actief, false); + actief = id; + if (actief) licht(actief, true); + }); + body.addEventListener('mouseleave', () => { if (actief) { licht(actief, false); actief = null; } }); +} + +function dagMs(iso) { + const d = String(iso || '').slice(0, 10); + const ms = Date.parse(`${d}T00:00:00Z`); + return Number.isFinite(ms) ? ms : null; +} + +function detailHtml() { + return ` +
    +
    +
    +
    +
    +
    `; +} + +function detailCijfersHtml(asset, f) { + const cijfers = [ + [t('wealth.avgSpot'), f.avgMain != null ? val(fmtBasePrice(priceFromBase(f.avgMain, asset))) : null], + [t('wealth.avgPremium'), f.avgPremium != null ? val(fmtBase(f.avgPremium)) : null], + [t('wealth.invested'), isBaseAsset(asset) ? null : val(fmtBase(f.invested))], + ].filter(([, v]) => v != null); + if (!cijfers.length) return ''; + return `
    ${cijfers.map(([l, v]) => stat(l, v)).join('')}
    `; +} + +function derdenRegelsHtml({ asset, m }) { + const delen = (m.beheerd || []).filter(d => d.saldo && scopeOf(d.accountId) === 'managed'); + if (!delen.length) return ''; + const naam = id => alleAccounts.find(a => a.id === id)?.name ?? `#${id}`; + return ` +
    + + + + + + ${delen.map(d => ` + + + + + `).join('')}
    ${t('wealth.account')}${t('wealth.quantity')}${t('wealth.costOnArrival')}${t('wealth.unrealized')}
    ${esc(naam(d.accountId))}${val(fmtUnit(d.saldo, asset))}${d.cost == null ? DASH : val(fmtBase(d.cost))}${d.unrealized == null ? DASH : kleurBedrag(d.unrealized)}
    +
    `; +} + +function mountDetailTabs(body, k, delen, naam, holdings, reeks) { + const host = body.querySelector('[data-detail-tabs]'); + if (!host) return; + const { eigen, derden } = delen; + createTabs(host, { + items: [ + { id: 'accounts', label: t('wealth.ownWealth') }, + { id: 'others', label: t('wealth.othersWealth'), when: () => !!derden }, + { id: 'products', label: t('wealth.perProduct'), when: () => isMetal(k.asset) }, + { id: 'return', label: t('wealth.returnTitle') }, + { id: 'notes', label: t('notes.tab') }, + ], + render: (id, panel) => { + body.querySelector('.wealth-detail')?.classList.toggle('is-return', id === 'return'); + if (id === 'notes') { panel.innerHTML = notesHtml(); mountNotes(panel, k.asset.id); return; } + if (id === 'return') { + panel.innerHTML = `
    +
    ${perJaarHtml(k.asset, reeks)}
    +
    +
    `; + mountReturnPanel(panel.querySelector('.return-view'), { reeks, asset: k.asset }); + return; + } + if (id === 'products') { panel.innerHTML = metalBreakdown(k.asset, k.p, k.m.fiatPrice, holdings); return; } + panel.innerHTML = id === 'others' + ? detailCijfersHtml(k.asset, k.m.derden) + derdenRegelsHtml(k) + perAccountHtml(k, derden.rijen, naam, derden.trades) + : detailCijfersHtml(k.asset, k.m) + perAccountHtml(k, eigen.rijen, naam, eigen.trades); + }, + }); +} + +async function openJaaroverzicht() { + const reeks = await (reeksBelofte || Promise.resolve(null)).catch(() => null); + const startjaar = Number(await getPref('app', 'start_year').catch(() => null)) || CONFIG.START_YEAR.AUTO; + const rijen = jaaroverzichtRijen(reeks, startjaar); + const grafiek = rijen?.jaren.length ? `
    ` : ''; + const klaar = dialog.detail({ + title: t('wealth.yearOverviewTitle'), + html: `
    ${grafiek}${jaaroverzichtHtml(rijen)}
    `, + }); + const canvas = document.querySelector('.dialog-overlay [data-jaargrafiek]'); + if (!canvas) return; + const chart = await tekenJaarGrafiek(canvas, [...rijen.jaren].reverse()).catch(() => null); + klaar.then(() => chart?.destroy()); +} + +async function openRendement() { + const reeks = await (reeksBelofte || Promise.resolve(null)).catch(() => null); + const kaarten = (getekendeStanden || nuStanden)?.kaarten || []; + let page = null; + if (reeks?.dagen?.length && keuze !== ALLE_JAREN) { + const jaren = jarenIn(keuze); + page = { label: jaren.length === 1 ? String(jaren[0]) : `${jaren[0]}-${jaren[jaren.length - 1]}`, venster: vensterVan(reeks) }; + } + openReturnDialog({ reeks, assets: kaarten.map(k => k.asset), page, selection: selectie }); +} + +function jaaroverzichtRijen(reeks, startjaar = CONFIG.START_YEAR.AUTO) { + if (!reeks?.dagen?.length) return null; + const lengte = reeks.dagen.length; + const ids = [...(reeks.perAsset || new Map()).keys()]; + const totaal = seriesFor(reeks); + const rij = (label, venster) => { + const meet = meetVenster(venster, lengte); + let gerealiseerd = 0, verkocht = false, onbekend = false; + for (const id of ids) { + const g = gerealiseerdOver(reeks, id, meet); + if (g.onbekend) onbekend = true; + else if (g.bedrag != null) { gerealiseerd += g.bedrag; verkocht = true; } + } + return { + label, + waarde: reeks.waarde[meet.tot], + inleg: windowDelta(reeks.ingelegd, meet), + pct: rendementVanVenster(reeks, venster), + sinds: rendementVanVenster(reeks, { van: 0, tot: venster.tot }), + resultaat: windowResult(totaal, meet), + gerealiseerd: onbekend || !verkocht ? null : gerealiseerd, + }; + }; + const jaren = [...jaarVensters(reeks.dagen)] + .filter(([jaar]) => jaar >= startjaar) + .map(([jaar, v]) => rij(String(jaar), v)) + .filter(r => (r.waarde || 0) >= 0.01 || Math.abs(r.inleg) >= 0.01 || r.gerealiseerd != null || Math.abs(r.resultaat) >= 0.01) + .reverse(); + return { jaren, alles: rij(t('wealth.allYears'), { van: 0, tot: lengte - 1 }) }; +} + +function jaaroverzichtHtml(rijen) { + if (!rijen?.jaren.length) return `
    ${t('wealth.noYears')}
    `; + const { jaren, alles } = rijen; + const bedrag = v => (v == null || Math.abs(v) < 0.01 ? DASH : val(fmtBase(v))); + const cel = r => ` + ${bedrag(r.waarde)} + ${bedrag(r.inleg)} + ${pctCel(r.pct)} + ${pctCel(r.sinds)} + ${bedragCel(r.resultaat)} + ${r.gerealiseerd == null ? DASH : kleurBedrag(r.gerealiseerd)}`; + return `
    + + + + + + + + + + + ${jaren.map(r => `${cel(r)}`).join('')} + ${cel(alles)} + +
    ${t('wealth.year')}${t('wealth.valueEnd')}${t('wealth.investedNet')}${t('wealth.twr')}${t('wealth.sinceStart')}${t('wealth.result')}${t('wealth.realized')}
    ${esc(r.label)}
    ${esc(alles.label)}
    `; +} + +function perJaarHtml(asset, reeks) { + if (!reeks?.dagen?.length) return `
    ${t('wealth.noYears')}
    `; + const lengte = reeks.dagen.length; + const rij = (label, venster) => { + const meet = meetVenster(venster, lengte); + const pctOver = v => (isBaseAsset(asset) ? null : assetRendement(reeks, asset.id, v)); + return { + label, + waarde: reeks.perAsset?.get(asset.id)?.[meet.tot], + inleg: windowDelta(reeks.inlegPerAsset?.get(asset.id), meet), + pct: pctOver(venster), + sinds: pctOver({ van: 0, tot: venster.tot }), + g: gerealiseerdOver(reeks, asset.id, meet), + rente: overVenster(reeks.rentePerAsset?.get(asset.id), meet), + resultaat: windowResult(seriesFor(reeks, asset.id), meet), + }; + }; + const jaren = [...jaarVensters(reeks.dagen)].map(([jaar, v]) => rij(String(jaar), v)) + .filter(r => r.pct != null || r.g.bedrag != null || r.g.onbekend || Math.abs(r.rente) >= 0.01 || Math.abs(r.resultaat) >= 0.01) + .reverse(); + if (!jaren.length) return `
    ${t('wealth.noYears')}
    `; + const alles = rij(t('wealth.allYears'), { van: 0, tot: lengte - 1 }); + + const bedrag = v => (v == null || Math.abs(v) < 0.01 ? DASH : val(fmtBase(v))); + const cel = r => ` + ${bedrag(r.waarde)} + ${bedrag(r.inleg)} + ${pctCel(r.pct)} + ${pctCel(r.sinds)} + ${bedragCel(r.resultaat)} + ${r.g.bedrag == null ? DASH : kleurBedrag(r.g.bedrag)}`; + return `
    + + + + + + + + + + + ${jaren.map(r => `${cel(r)}`).join('')} + ${cel(alles)} + +
    ${t('wealth.year')}${t('wealth.valueEnd')}${t('wealth.investedNet')}${t('wealth.twr')}${t('wealth.sinceStart')}${t('wealth.result')}${t('wealth.realized')}
    ${esc(r.label)}
    ${esc(alles.label)}
    `; +} + +function perAccountHtml(k, rijen, naam, trades) { + const { asset, m } = k; + const rekeningVan = tr => (tr.type === 'sell' ? tr.fromAccountId : tr.toAccountId) ?? null; + const perRekening = new Map(); + for (const tr of trades) { + const id = rekeningVan(tr); + if (!perRekening.has(id)) perRekening.set(id, []); + perRekening.get(id).push(tr); + } + + const standen = new Map(rijen.map(r => [r.accountId ?? null, r])); + const ids = [...standen.keys()]; + for (const id of perRekening.keys()) if (!standen.has(id)) ids.push(id); + ids.sort((a, b) => (a === null ? 1 : 0) - (b === null ? 1 : 0)); + if (!ids.length) return `
    ${t('wealth.noAccountRows')}
    `; + + const groepen = ids.map(id => { + const r = standen.get(id); + const saldo = r ? r.saldo : null; + const waarde = (saldo != null && m.fiatPrice != null) + ? fmtBase((saldo / Math.pow(10, asset.decimals)) * m.fiatPrice) : null; + const label = id == null ? t('wealth.noAccount') : (naam.get(id) ?? `#${id}`); + const samenvatting = [saldo != null ? fmtUnit(saldo, asset) : null, waarde] + .filter(Boolean).map(x => val(x)).join(' · '); + const boekingen = handelRijen(asset, perRekening.get(id) || []); + return `
    + ${esc(label)} + ${samenvatting ? `${samenvatting}${waarde ? originHtml(m.origin, asset) : ''}` : ''} +
    ${boekingen}`; + }).join(''); + + return `
    + + + + + + + + ${groepen} +
    ${t('wealth.tradeDate')}${t('wealth.tradeType')}${t('wealth.quantity')}${t('wealth.tradePrice')}${t('wealth.tradeAmount')}
    `; +} + +function handelRijen(asset, trades) { + if (!trades.length) { + return `${t('wealth.noTrades')}`; + } + const deler = Math.pow(10, asset.decimals); + return trades.map(tr => { + const koop = tr.type !== 'sell'; + const bedrag = ((Number(tr.amount) || 0) / deler) * (Number(tr.fiatPrice) || 0); + const kosten = Number(tr.fiatFee) || 0; + const soort = t(koop ? 'enums.event_type.buy' : 'enums.event_type.sell'); + const labels = tr.label_names ? subline(`${esc(tr.label_names)}`) : ''; + return ` + ${esc(sparkDatum(dagMs(tr.occurredAt) ?? Date.now()))}${labels} + ${koop ? ARROW_DOWN : ARROW_UP} + ${val(fmtUnit(tr.amount, asset))} + ${tr.fiatPrice == null ? DASH : val(fmtBasePrice(priceFromBase(tr.fiatPrice, asset)))} + ${val(fmtBase(bedrag))}${kosten ? ` ${koop ? '+' : '−'} ${val(fmtBase(kosten))}` : ''} + `; + }).join(''); +} + +function aandeel(waarde, total) { + if (waarde == null || !(total > 0)) return '-'; + return val(`${((waarde / total) * 100).toFixed(1).replace('.', ',')}%`); +} + +function legendaAanmelden(host, buiten = []) { + setPageHint(host, { + title: t('wealth.legendTitle'), + rows: [ + ...buiten, + { sample: SAMPLES.line, text: t('wealth.legendSparkValue') }, + { sample: SAMPLES.dashed, text: t('wealth.legendSparkInvested') }, + { sample: SAMPLES.dot, text: t('wealth.legendBuy') }, + { sample: SAMPLES.dotOpen, text: t('wealth.legendSell') }, + { sample: SAMPLES.ring, text: t('wealth.legendRings') }, + t('wealth.legendInvested'), + t('wealth.legendTwr'), + t('wealth.legendUnexplained'), + t('wealth.legendRange'), + t('wealth.legendChain'), + t('wealth.legendDetail'), + t('wealth.legendFilter'), + t('wealth.legendAccounts'), + t('wealth.legendUnrealized'), + t('wealth.legendResult'), + t('wealth.legendNoPrice'), + ...(excludedAccounts.length ? [t('wealth.legendShowAll')] : []), + ], + }); +} + +async function buitenRegels() { + if (!excludedAccounts.length) return []; + if (showAll) return [t('wealth.legendShowAllOn')]; + const soort = new Map(excludedAccounts.map(a => [a.id, a.wealthScope])); + const rijen = await accountHoldings(excludedAccounts.map(a => a.id)); + const assets = (await listAssets()).filter(a => rijen.some(r => r.assetId === a.id)); + const spot = assets.length ? await loadFiat(assets) : new Map(); + const som = { managed: 0, none: 0 }; + for (const r of rijen) { + const asset = assets.find(a => a.id === r.assetId); + const prijs = basePriceOf(spot.get(r.assetId)); + if (!asset || prijs == null) continue; + som[soort.get(r.accountId)] += (r.saldo / Math.pow(10, asset.decimals)) * prijs; + } + const scopes = new Set(excludedAccounts.map(a => a.wealthScope)); + return [ + ...(scopes.has('managed') ? [t('wealth.legendManaged', { bedrag: fmtBase(som.managed) })] : []), + ...(scopes.has('none') ? [t('wealth.legendNotCounted', { bedrag: fmtBase(som.none) })] : []), + ]; +} + +const tekenKleur = (getal, html) => `${html}`; +const kleurBedrag = v => tekenKleur(v, val(fmtBaseSigned(v, colouredSigns()))); + +function stat(label, value) { + return `
    ${label}
    ${value ?? DASH}
    `; +} + +function statLeeg(label, sleutel) { + return `
    +
    ${label}
    +
    `; +} + +function cardHtml(asset, p, m, total) { + const valueStr = m.value != null + ? `${val(fmtBase(m.value))}${originHtml(m.origin, asset)}` + : null; + const unrealStr = m.unrealized != null ? kleurBedrag(m.unrealized) : null; + const aandeelStr = m.value != null && total > 0 ? aandeel(m.value, total) : null; + + const geenKoers = m.fiatPrice == null + ? `
    ${t('wealth.noPrice')}
    ` + : ''; + + const stats = ` +
    + ${stat(t('wealth.quantity'), `${val(fmtUnit(p.net, asset))}${p.net === 0 ? `` : ''}`)} + ${stat(t('wealth.value'), valueStr)} + ${stat(t('wealth.share'), aandeelStr)} + ${stat(t('wealth.unrealized'), unrealStr)} + ${stat(t('wealth.result'), `…`)} + ${stat(t('wealth.twr'), `…`)} + ${statLeeg(t('wealth.unexplained'), asset.id)} +
    `; + + return `
    +
    + ${assetLabelHtml(asset)} + ${t('enums.asset_kind.' + asset.kind)} + +
    + ${stats} + ${geenKoers} +
    +
    +
    +
    `; +} + +function accountCardHtml(c, total) { + const key = accountSleutel(c); + const herkomsten = c.lines.filter(l => l.value != null).map(l => ({ origin: l.origin, asset: l.asset })); + const valueStr = c.value != null ? `${val(fmtBase(c.value))}${originsHtml(herkomsten)}` : null; + const unrealStr = c.unrealized != null ? kleurBedrag(c.unrealized) : null; + const aandeelStr = c.value != null && total > 0 ? aandeel(c.value, total) : null; + const rekenvaluta = c.lines.length > 0 && c.lines.every(l => isBaseAsset(l.asset)); + const geenKoers = c.lines.some(l => l.value == null); + const naam = accountNaam(c); + return `
    +
    + ${esc(naam)} + ${c.account ? `${t('enums.account_kind.' + c.account.kind)}` : ''} + +
    +
    + ${stat(t('wealth.share'), aandeelStr)} + ${stat(t('wealth.value'), valueStr)} + ${statLeeg(t('wealth.unexplained'), `r${key}`)} + ${stat(t('wealth.unrealized'), unrealStr)} + ${stat(t('wealth.result'), `…`)} + ${stat(t('wealth.twr'), `…`)} +
    +
    +
    +
    +
    `; +} + +function accountReeks(reeks, key, venster, dagenMs) { + const id = reeksSleutel(key); + const s = accountSeries(reeks, id); + const vanaf = Math.max(reeks.eersteDagPerAccount?.get(id) ?? 0, venster.van); + const tot = venster.tot + 1; + const dagen = dagenMs.slice(vanaf, tot); + const punten = puntenVan(s.waarden.slice(vanaf, tot), dagen); + if (punten.length < 2 || !punten.some(p => p[1] > 0)) return null; + const ingebracht = s.waarden.map((_, i) => (s.inleg[i] || 0) + (s.ankers[i] || 0) - (s.rente[i] || 0)); + return { punten, inleg: puntenVan(ingebracht.slice(vanaf, tot), dagen), vanMs: dagenMs[vanaf], totMs: dagenMs[venster.tot] }; +} + +async function openAccountDetail(c) { + const reeks = await (reeksBelofte || Promise.resolve(null)).catch(() => null); + const naam = accountNaam(c); + dialog.detail({ title: naam, + asideHtml: c.account ? `${t('enums.account_kind.' + c.account.kind)}` : '', html: detailHtml() }); + const body = document.querySelector('.dialog-overlay .dialog-body--rich'); + if (!body) return; + body.closest('.dialog')?.insertAdjacentHTML('afterbegin', + `${jaarInhoud(t('wealth.allYears'), null)}`); + createTabs(body.querySelector('[data-detail-tabs]'), { + items: [{ id: 'assets', label: t('wealth.perAsset') }, { id: 'return', label: t('wealth.returnTitle') }], + render: (id, panel) => { + panel.innerHTML = id === 'assets' ? accountAssetsHtml(c) : accountJarenHtml(reeks, accountSleutel(c)); + }, + }); + const el = body.querySelector('[data-detail-spark]'); + if (!el) return; + const dagenMs = reeks?.dagen?.map(d => Date.parse(`${d}T00:00:00Z`)) || []; + const gesneden = reeks?.dagen?.length ? accountReeks(reeks, accountSleutel(c), { van: 0, tot: reeks.dagen.length - 1 }, dagenMs) : null; + if (!gesneden) { el.innerHTML = `
    ${t('wealth.noChart')}
    `; return; } + mountSparkline(el, gesneden.punten, { tone: 'accent', vergelijk: gesneden.inleg, formatValue: sparkLabel }); +} + +function accountAssetsHtml(c) { + if (!c.lines.length) return `
    ${t('wealth.noAccountAssets')}
    `; + const rijen = c.lines.map(l => ` + ${assetLabelHtml(l.asset)} + ${val(fmtUnit(l.saldo, l.asset))} + ${l.value == null ? DASH : `${val(fmtBase(l.value))}${originHtml(l.origin, l.asset)}`} + ${l.value == null || !(c.value > 0) ? DASH : aandeel(l.value, c.value)} + `).join(''); + return `
    + + + + + + + ${rijen} +
    ${t('wealth.asset')}${t('wealth.quantity')}${t('wealth.value')}${t('wealth.shareOfAccount')}
    `; +} + +function accountJarenHtml(reeks, key) { + if (!reeks?.dagen?.length) return `
    ${t('wealth.noYears')}
    `; + const lengte = reeks.dagen.length; + const s = accountSeries(reeks, reeksSleutel(key)); + const ingebracht = s.waarden.map((_, i) => (s.inleg[i] || 0) + (s.ankers[i] || 0) - (s.rente[i] || 0)); + const rij = (label, venster) => { + const meet = meetVenster(venster, lengte); + return { + label, + waarde: s.waarden[meet.tot], + inleg: windowDelta(ingebracht, meet), + pct: windowTwr(s, meet), + sinds: windowTwr(s, meetVenster({ van: 0, tot: venster.tot }, lengte)), + resultaat: windowResult(s, meet), + }; + }; + const jaren = [...jaarVensters(reeks.dagen)].map(([jaar, v]) => rij(String(jaar), v)) + .filter(r => r.pct != null || Math.abs(r.resultaat) >= 0.01 || Math.abs(r.inleg) >= 0.01) + .reverse(); + if (!jaren.length) return `
    ${t('wealth.noYears')}
    `; + const alles = rij(t('wealth.allYears'), { van: 0, tot: lengte - 1 }); + const bedrag = v => (v == null || Math.abs(v) < 0.01 ? DASH : val(fmtBase(v))); + const cel = r => ` + ${bedrag(r.waarde)} + ${bedrag(r.inleg)} + ${pctCel(r.pct)} + ${pctCel(r.sinds)} + ${bedragCel(r.resultaat)}`; + return `
    + + + + + + + + + + ${jaren.map(r => `${cel(r)}`).join('')} + ${cel(alles)} + +
    ${t('wealth.year')}${t('wealth.valueEnd')}${t('wealth.broughtInNet')}${t('wealth.twr')}${t('wealth.sinceStart')}${t('wealth.result')}
    ${esc(r.label)}
    ${esc(alles.label)}
    `; +} + +function assetReeks(reeks, id, venster, dagenMs) { + const vanaf = Math.max(reeks.eersteDagPerAsset?.get(id) ?? 0, venster.van); + const tot = venster.tot + 1; + const dagenVanAsset = dagenMs.slice(vanaf, tot); + const punten = puntenVan((reeks.perAsset.get(id) || []).slice(vanaf, tot), dagenVanAsset); + if (punten.length < 2 || !punten.some(p => p[1] > 0)) return null; + const inleg = puntenVan((reeks.inlegPerAsset?.get(id) || []).slice(vanaf, tot), dagenVanAsset); + return { punten, inleg, vanMs: dagenMs[vanaf], totMs: dagenMs[venster.tot] }; +} + +const sparkLabel = (v, ms, in_) => [fmtBase(v), in_ == null ? null : gatTekst(v - in_), sparkDatum(ms)] + .filter(Boolean).join(' · '); + +async function vulSparklines(host, reeks, venster) { + const dagenMs = reeks.dagen.map(d => Date.parse(`${d}T00:00:00Z`)); + for (const el of host.querySelectorAll('[data-spark-asset]')) { + const id = Number(el.dataset.sparkAsset); + const gesneden = assetReeks(reeks, id, venster, dagenMs); + if (!gesneden) { el.innerHTML = ''; continue; } + const { punten, inleg } = gesneden; + const stippen = [ + ...(reeks.aankopen?.get(id) || []).map(i => ({ x: dagenMs[i], variant: 'buy' })), + ...(reeks.verkopen?.get(id) || []).map(i => ({ x: dagenMs[i], variant: 'sell' })), + ].filter(s => s.x != null && s.x >= gesneden.vanMs && s.x <= gesneden.totMs); + mountSparkline(el, punten, { + tone: 'accent', + vergelijk: inleg, + formatValue: sparkLabel, + markers: stippen, + }); + } + for (const el of host.querySelectorAll('[data-spark-account]')) { + const gesneden = accountReeks(reeks, Number(el.dataset.sparkAccount), venster, dagenMs); + if (!gesneden) { el.innerHTML = ''; continue; } + mountSparkline(el, gesneden.punten, { tone: 'accent', vergelijk: gesneden.inleg, formatValue: sparkLabel }); + } +} + +function vulTwr(host, reeks, venster) { + const zet = (sleutel, waarde) => { + const el = host.querySelector(`[data-twr="${CSS.escape(String(sleutel))}"]`); + if (!el) return; + el.textContent = waarde == null ? DASH : fmtPct(waarde, gekleurd()); + el.classList.toggle('t-muted-cell', waarde == null); + el.closest('.wealth-card .wealth-stat')?.classList.toggle('is-leeg', waarde == null); + const toon = waarde == null ? '' : pctToon(waarde); + el.classList.toggle('t-pos', toon === 't-pos'); + el.classList.toggle('t-neg', toon === 't-neg'); + el.classList.toggle('delta', waarde != null); + }; + zet('totaal', rendementVanVenster(reeks, venster)); + const rekenvaluta = id => !!host.querySelector(`[data-asset="${CSS.escape(String(id))}"][data-rekenvaluta]`); + for (const id of reeks.perAsset.keys()) zet(id, rekenvaluta(id) ? null : assetRendement(reeks, id, venster)); + for (const el of host.querySelectorAll('[data-account]')) { + const key = Number(el.dataset.account); + zet(`r${key}`, el.hasAttribute('data-rekenvaluta') ? null + : windowTwr(accountSeries(reeks, reeksSleutel(key)), meetVenster(venster, reeks.dagen.length))); + } + vulNietVerklaard(host, reeks, meetVenster(venster, reeks.dagen.length)); + vulResultaat(host, reeks, meetVenster(venster, reeks.dagen.length)); + vulNulDatum(host, reeks, venster); +} + +function vulResultaat(host, reeks, venster) { + const zet = (sleutel, bedrag) => { + const el = host.querySelector(`[data-resultaat="${CSS.escape(String(sleutel))}"]`); + if (!el) return; + const kaart = el.closest('.wealth-card'); + const ongerealiseerd = kaart?.dataset.ongerealiseerd; + if (bedrag != null && ongerealiseerd !== undefined && ongerealiseerd !== '' + && Math.abs(bedrag - Number(ongerealiseerd)) < 0.005) bedrag = null; + const nul = bedrag != null && Math.abs(bedrag) < 0.005; + el.textContent = bedrag == null ? DASH : fmtBaseSigned(bedrag, colouredSigns()); + el.classList.toggle('t-muted-cell', bedrag == null); + el.classList.toggle('val', bedrag != null); + el.classList.toggle('t-pos', !nul && bedrag > 0); + el.classList.toggle('t-neg', !nul && bedrag < 0); + el.closest('.wealth-card .wealth-stat')?.classList.toggle('is-leeg', bedrag == null); + }; + const geenKoers = sel => !!host.querySelector(`${sel}[data-geenkoers]`); + zet('totaal', windowResult(seriesFor(reeks), venster)); + for (const id of reeks.perAsset.keys()) { + zet(id, geenKoers(`[data-asset="${CSS.escape(String(id))}"]`) ? null : windowResult(seriesFor(reeks, id), venster)); + } + for (const el of host.querySelectorAll('[data-account]')) { + const key = Number(el.dataset.account); + zet(`r${key}`, el.hasAttribute('data-geenkoers') ? null : windowResult(accountSeries(reeks, reeksSleutel(key)), venster)); + } +} + +function vulNulDatum(host, reeks, venster) { + for (const el of host.querySelectorAll('[data-nul]')) { + const eenheden = reeks.eenhedenPerAsset?.get(Number(el.dataset.nul)) || []; + let i = Math.min(venster.tot, eenheden.length - 1); + while (i >= 0 && !eenheden[i]) i--; + const dag = i >= 0 ? reeks.dagen[i + 1] : null; + el.innerHTML = dag + ? infoLinesHtml([t('wealth.noLongerHeld'), t('wealth.noLongerHeldSince', { dag: sparkDatum(Date.parse(`${dag}T00:00:00Z`)) })]) + : ''; + } +} + +function gerealiseerdOver(reeks, id, venster) { + const bedrag = overVenster(reeks.gerealiseerdPerAsset?.get(id), venster); + const onbekend = overVenster(reeks.onbekendPerAsset?.get(id), venster) > 0; + const verkocht = Math.abs(bedrag) >= 0.005 || onbekend || verkochtIn(reeks, id, venster); + return { bedrag: verkocht && !onbekend ? bedrag : null, onbekend }; +} + +const verkochtIn = (reeks, id, venster) => + (reeks.verkopen?.get(id) || []).some(i => (venster.van > 0 ? i > venster.van : i >= 0) && i <= venster.tot); + +const overVenster = windowDelta; + +function vulNietVerklaard(host, reeks, venster) { + const zet = (sleutel, amount) => { + const el = host.querySelector(`[data-nietverklaard="${CSS.escape(String(sleutel))}"]`); + if (!el) return; + const rij = el.closest('[data-nietverklaard-rij]'); + el.textContent = ''; + rij?.classList.add('is-leeg'); + if (amount == null || Math.abs(amount) < 0.01) return; + el.textContent = fmtBaseSigned(amount, 'both'); + rij?.classList.remove('is-leeg'); + }; + let totaal = 0; + for (const [id, r] of (reeks.nietVerklaardPerAsset || new Map())) { totaal += overVenster(r, venster); zet(id, overVenster(r, venster)); } + zet('totaal', totaal); + for (const [id, r] of (reeks.nietVerklaardPerAccount || new Map())) zet(`r${id ?? 0}`, overVenster(r, venster)); +} + +const puntenVan = (reeks, dagenMs) => (reeks || []) + .map((v, i) => [dagenMs[i], v]) + .filter(p => typeof p[1] === 'number'); + +const gatTekst = v => fmtBaseSigned(v, 'both'); + +const sparkDatum = ms => localDateNl(new Date(ms).toISOString()); + +function metalBreakdown(asset, p, fiatPrice, holdings) { + const prods = holdings.filter(h => h.assetId === p.id); + const valOfBase = base => fiatPrice != null ? (base / Math.pow(10, BASE)) * fiatPrice : null; + const weightCell = base => { + const { primary, secondary } = fmtAmount(base, asset); + return val(primary) + (secondary ? ` · ${val(secondary)}` : ''); + }; + const valueCell = base => { const v = valOfBase(base); return v != null ? val(fmtBase(v)) : '-'; }; + + const perProduct = h => assetMetrics({ + decimals: BASE, net: h.net_count * h.fineGrams, + buy_amount: h.buy_amount, buy_spot_raw: h.buy_spot_raw, + buy_fee_total: h.buy_premium, buy_events: h.buy_events, + gift_amount: h.gift_amount, + }, fiatPrice); + + const rows = prods.map(h => { + const base = h.net_count * h.fineGrams; + const pm = perProduct(h); + const kostprijs = pm.avgCost != null ? (base / Math.pow(10, BASE)) * pm.avgCost : null; + const pct = (pm.unrealized != null && kostprijs) ? pm.unrealized / kostprijs : null; + return ` + ${esc(h.name)} ${t('enums.metal_form.' + h.form)} + ${val(String(h.net_count))} + ${weightCell(base)} + ${valueCell(base)} + ${pm.unrealized == null ? '-' : kleurBedrag(pm.unrealized)} + ${pct == null ? '-' : `${fmtPct(pct, gekleurd())}`} + `; + }); + + const allocBase = prods.reduce((s, h) => s + h.net_count * h.fineGrams, 0); + const unallocBase = p.net - allocBase; + if (unallocBase > 0) { + rows.push(` + ${t('wealth.unallocated')} + - + ${weightCell(unallocBase)} + ${valueCell(unallocBase)} + - + - + `); + } + if (!rows.length) return ''; + + return `
    +
    + + + + + + + + + ${rows.join('')} +
    ${t('wealth.product')}${t('wealth.count')}${t('wealth.weight')}${t('wealth.value')}${t('wealth.unrealized')}${t('wealth.result')}
    +
    `; +} diff --git a/client/portfolio.js b/client/portfolio.js new file mode 100644 index 0000000..2993055 --- /dev/null +++ b/client/portfolio.js @@ -0,0 +1,98 @@ +// Generated, do not edit. sha256:0b8a580ca944f426e2e3aa4800988bad2046937a8c0409e4dceebf09b6b3e9f7 +import { CONFIG } from '../shared/config.js'; +import { t } from './i18n/index.js'; +import { serverFoutTekst } from './data/_rpc.js'; +import { locked } from './license.js'; + +const SLEUTEL = CONFIG.PORTFOLIO.ACTIVE_KEY; +const WISSEL_SLEUTEL = CONFIG.PORTFOLIO.SWITCH_KEY; + +export function cacheSleutels(naam = cachedPortfolioName()) { + const achtervoegsel = naam ? `:${naam}` : ''; + return { + theme: `ui_theme${achtervoegsel}`, accent: `ui_accent${achtervoegsel}`, depth: `ui_depth${achtervoegsel}`, + look: `ui_look${achtervoegsel}`, + }; +} + +export function cachedPortfolioName() { + try { return localStorage.getItem(SLEUTEL) || ''; } + catch { return ''; } +} + +function toonNaam(naam) { + const html = document.documentElement; + if (naam) { + html.dataset.portfolio = naam; + html.style.setProperty('--portfolio-name', JSON.stringify(naam)); + } else { + delete html.dataset.portfolio; + html.style.removeProperty('--portfolio-name'); + } + try { if (naam) localStorage.setItem(SLEUTEL, naam); else localStorage.removeItem(SLEUTEL); } + catch { } +} + +let opgebouwdMet = null; + +export async function refreshActivePortfolio() { + try { + const r = await fetch('/api/portfolios'); + if (!r.ok) return cachedPortfolioName(); + const uit = await r.json(); + toonNaam(locked('portfolios') ? '' : uit.actief || ''); + if (opgebouwdMet === null) opgebouwdMet = uit.actief || ''; + return uit.actief || ''; + } catch { + return cachedPortfolioName(); + } +} + +export function watchPortfolioChanges({ minIntervalMs = 2000 } = {}) { + let laatst = 0; + const kijk = async () => { + if (document.visibilityState === 'hidden') return; + if (Date.now() - laatst < minIntervalMs) return; + laatst = Date.now(); + try { + const r = await fetch('/api/portfolios', { cache: 'no-store' }); + if (!r.ok) return; + const { actief } = await r.json(); + if (opgebouwdMet && actief && actief !== opgebouwdMet) location.reload(); + } catch { } + }; + window.addEventListener('focus', kijk); + document.addEventListener('visibilitychange', kijk); +} + +export async function switchPortfolio(naam) { + try { localStorage.setItem(WISSEL_SLEUTEL, '1'); } catch { } + let uit; + try { + const r = await fetch('/api/portfolios/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ naam }), + }); + uit = await r.json(); + if (!r.ok) throw new Error(serverFoutTekst(uit) || t('portfolios.switchFailed')); + } catch (e) { + try { localStorage.removeItem(WISSEL_SLEUTEL); } catch { } + throw e; + } + if (uit.gewisseld) toonNaam(uit.naam); + else { try { localStorage.removeItem(WISSEL_SLEUTEL); } catch { } } + return uit; +} + +export async function reloadWhenBack({ intervalMs = 250, timeoutMs = 30_000 } = {}) { + const eind = Date.now() + timeoutMs; + while (Date.now() < eind) { + await new Promise(klaar => setTimeout(klaar, intervalMs)); + try { + const r = await fetch('/api/health', { cache: 'no-store' }); + if (r.ok) { location.reload(); return true; } + } catch { } + } + return false; +} diff --git a/client/price/btc-explorer.js b/client/price/btc-explorer.js new file mode 100644 index 0000000..ad88925 --- /dev/null +++ b/client/price/btc-explorer.js @@ -0,0 +1,74 @@ +// Generated, do not edit. sha256:e9ea9293b00980ab0270b38c345d130a439421cd662f57bea63c105e66a1587f +import { CONFIG } from '../../shared/config.js'; +import { activeConnection } from '../data/connections.js'; +import { t } from '../i18n/index.js'; + +export async function explorerBase() { + return (await activeConnection('btc_explorer')).url; +} + +const HTTP_TIMEOUT_MS = 12000; +const HTTP_RETRIES = 1; +const HTTP_BACKOFF_MS = 800; +export async function fetchJson(url, label, options = {}) { + let laatste; + for (let poging = 0; poging <= HTTP_RETRIES; poging++) { + if (poging) await new Promise(r => setTimeout(r, HTTP_BACKOFF_MS * poging)); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), HTTP_TIMEOUT_MS); + try { + const res = await fetch(url, { ...options, signal: ctrl.signal }); + if (res.ok) return await res.json(); + laatste = new Error(t('integrations.price.callFailed', { label, status: res.status })); + if (res.status < 500 && res.status !== 429) throw laatste; + } catch (e) { + if (e === laatste) throw e; + laatste = e.name === 'AbortError' + ? new Error(t('integrations.price.callTimeout', { label, s: HTTP_TIMEOUT_MS / 1000 })) + : e; + } finally { + clearTimeout(timer); + } + } + throw laatste; +} + +export async function addressInfo(addr) { + const base = await explorerBase(); + const d = await fetchJson(`${base}/api/address/${addr}`, t('integrations.price.labelAddress', { adres: addr })); + const balance_sats = d.chain_stats.funded_txo_sum - d.chain_stats.spent_txo_sum; + const tx_count = d.chain_stats.tx_count + d.mempool_stats.tx_count; + return { balance_sats, tx_count }; +} + +const PAGE = 25; +const MAX_PAGES = 200; +const lastConfirmedTxid = batch => { + for (let i = batch.length - 1; i >= 0; i--) { + if (batch[i].status && batch[i].status.confirmed) return batch[i].txid; + } + return null; +}; +export async function addressTxs(addr) { + const base = await explorerBase(); + const all = await fetchJson(`${base}/api/address/${addr}/txs`, t('integrations.price.labelAddressTxs', { adres: addr })); + + let cursor = lastConfirmedTxid(all); + let pages = 0; + while (cursor && pages < MAX_PAGES) { + await new Promise(r => setTimeout(r, 150)); + let batch; + try { + batch = await fetchJson(`${base}/api/address/${addr}/txs/chain/${cursor}`, t('integrations.price.labelAddressTxsPage')); + } catch (e) { + console.warn(`[addressTxs] pagination stopped for ${addr}: ${e.message}`); + break; + } + if (!batch.length) break; + all.push(...batch); + if (batch.length < PAGE) break; + cursor = batch[batch.length - 1].txid; + pages++; + } + return all; +} diff --git a/client/price/intraday.js b/client/price/intraday.js new file mode 100644 index 0000000..fd27ee3 --- /dev/null +++ b/client/price/intraday.js @@ -0,0 +1,30 @@ +// Generated, do not edit. sha256:7227a0859017790e220d3094fb16cfe6c1e290c2297569d78de93f970684ea0d +import { intradaySeries } from '../data/market.js'; +import { currentRate } from './providers.js'; +import { t } from '../i18n/index.js'; + +const FRESH_MS = 60_000; +const memo = new Map(); +const NONE = Object.freeze({ points: null }); + +async function toBase(r) { + if (r.status !== 'ok') return NONE; + const rate = await currentRate(r.currency); + if (rate == null) return NONE; + const named = src => t('marketService.sourceLive', { source: src }); + const src = r.sourceLabel || r.source; + const source = src ? named(src) : t('connections.kindMarket'); + const sources = new Map(); + for (const [ms, , own] of r.points) if (own) sources.set(ms, named(own)); + return { points: r.points.map(([ms, p]) => [ms, p * rate]), source, sources }; +} + +export async function loadIntraday(assetId, sinceMs) { + const hit = memo.get(assetId); + if (hit && hit.since === sinceMs && Date.now() - hit.at < FRESH_MS) return hit.result; + let result; + try { result = await toBase(await intradaySeries(assetId, sinceMs)); } + catch { result = NONE; } + memo.set(assetId, { at: Date.now(), since: sinceMs, result }); + return result; +} diff --git a/client/price/momentum.js b/client/price/momentum.js new file mode 100644 index 0000000..8b535bd --- /dev/null +++ b/client/price/momentum.js @@ -0,0 +1,71 @@ +// Generated, do not edit. sha256:48f77fc8fc8efc987ebeae10a9b33669b28691941eb865e07c2c66413d1ef0cf +import { getPref, setPref } from '../data/preferences.js'; + +export const MOMENTUM = Object.freeze({ + SIZE: 60, + MAX_AGE_MS: 6 * 3600000, +}); + +const PREF_SCOPE = 'momentum'; + +const recent = (moves, nowMs) => moves.filter(m => nowMs - m.at <= MOMENTUM.MAX_AGE_MS); + +export function addPrice(buffer, price, nowMs) { + if (!Number.isFinite(price) || price <= 0) return buffer; + const prev = buffer || { last: null, lastAt: 0, moves: [] }; + const fresh = prev.last != null && nowMs - prev.lastAt <= MOMENTUM.MAX_AGE_MS; + if (fresh && price === prev.last) return prev; + const kept = recent(prev.moves, nowMs); + const moves = fresh ? [...kept, { at: nowMs, dir: price > prev.last ? 1 : -1 }].slice(-MOMENTUM.SIZE) : kept; + return { last: price, lastAt: nowMs, moves }; +} + +export function restore(saved, nowMs) { + if (!saved || !Number.isFinite(saved.last) || !Number.isFinite(saved.lastAt) || !Array.isArray(saved.moves)) return null; + const moves = saved.moves + .filter(m => m && Number.isFinite(m.at) && (m.dir === 1 || m.dir === -1)) + .slice(-MOMENTUM.SIZE); + return { last: saved.last, lastAt: saved.lastAt, moves: recent(moves, nowMs) }; +} + +export function tally(buffer, nowMs) { + const moves = recent(buffer?.moves || [], nowMs); + let balance = 0, tone = null; + for (const m of moves) { + balance += m.dir; + if (balance !== 0) tone = balance > 0 ? 'up' : 'down'; + } + const up = moves.filter(m => m.dir > 0).length; + return { up, down: moves.length - up, tone }; +} + +const buffers = new Map(); +const loads = new Map(); + +export function loadMomentum(assetId, nowMs = Date.now()) { + if (assetId == null) return Promise.resolve(); + if (!loads.has(assetId)) { + loads.set(assetId, (async () => { + try { buffers.set(assetId, restore(await getPref(PREF_SCOPE, String(assetId), null), nowMs)); } + catch { buffers.set(assetId, null); } + })()); + } + return loads.get(assetId); +} + +export async function recordSpot(assetId, price, nowMs = Date.now()) { + if (assetId == null) return; + await loadMomentum(assetId, nowMs); + const prev = buffers.get(assetId) || null; + const next = addPrice(prev, price, nowMs); + if (next === prev) return; + buffers.set(assetId, next); + try { await setPref(PREF_SCOPE, String(assetId), next); } + catch { } +} + +export function momentumOf(assetId, nowMs = Date.now()) { + return tally(buffers.get(assetId) || null, nowMs); +} + +export function clearMomentum() { buffers.clear(); loads.clear(); } diff --git a/client/price/providers.js b/client/price/providers.js new file mode 100644 index 0000000..183f5be --- /dev/null +++ b/client/price/providers.js @@ -0,0 +1,566 @@ +// Generated, do not edit. sha256:1f114683952f59d1ca0874094fffe2f799e396366bc22e66b0b9cf051e48c0cd +import { recordApiCall, apiMetricsTotals } from '../data/api-metrics.js'; +import { serverFoutTekst } from '../data/_rpc.js'; +import { t } from '../i18n/index.js'; +import { priceSourceChain, ownPriceSourceChain } from '../data/price-source.js'; +import { upsertPriceHistory, listPriceHistory, getPricePoint, recordCurrentSpot, latestSpotPoint, + listNoPriceDays, recordNoPriceDays } from '../data/price-history.js'; +import { getProvider } from '../data/providers.js'; +import { listAssets } from '../data/assets.js'; +import { CONFIG, sourceCapabilityOf } from '../../shared/config.js'; +import { baseCurrency, appSetting } from '../app-settings.js'; +import { recordSpot } from './momentum.js'; +import { serviceSpot, serviceDayHistory, serviceCoverage } from '../data/market.js'; + +const perfNow = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()); + +function emitApiCall(detail) { + if (typeof document !== 'undefined' && typeof document.dispatchEvent === 'function') { + document.dispatchEvent(new CustomEvent('api-call', { detail })); + } +} + +function serviceCalled(asset, capability, ok, t0) { + emitApiCall({ provider_key: CONFIG.MARKET_SERVICE.PROVIDER_KEY, capability, symbol: asset.symbol, ok, + latency_ms: Math.round(perfNow() - t0), ts: Date.now() }); +} + +function vasteKoers(provider, capability, date) { + if (capability !== 'spot' && capability !== 'history_point') { + throw new Error(t('integrations.price.notFromFixedRate', { provider: provider.key, capability })); + } + const uit = { price: provider.fixedRate.fiatPrice }; + if (capability !== 'history_point') return uit; + const time = Math.floor(Date.parse(`${date}T00:00:00Z`) / 1000); + if (!Number.isFinite(time)) throw new Error(t('integrations.price.invalidDate', { provider: provider.key, datum: date })); + return { ...uit, time }; +} + +const usageThisMonth = new Map(); +async function usageFor(provider) { + if (!provider.callsPerMonth || !Number.isInteger(provider.id)) return null; + const month = new Date().toISOString().slice(0, 7); + let u = usageThisMonth.get(provider.id); + if (!u || u.month !== month) { + let calls = 0; + try { calls = (await apiMetricsTotals(provider.id, `${month}-01T00:00`)).calls; } catch { } + u = { month, calls }; + usageThisMonth.set(provider.id, u); + } + return u; +} +export function forgetUsage() { usageThisMonth.clear(); } +if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { + document.addEventListener('providers-changed', forgetUsage); +} + +function intervalOf(provider, capability) { + const e = provider && Array.isArray(provider.endpoints) && provider.endpoints.find(x => x.capability === capability); + return e && e.minIntervalSeconds > 0 ? e.minIntervalSeconds : null; +} + +const lastCallAt = new Map(); +function sleep(ms, signal) { + return new Promise((resolve, reject) => { + if (signal && signal.aborted) { reject(stoppedError()); return; } + const timer = setTimeout(() => { if (signal) signal.removeEventListener('abort', onAbort); resolve(); }, ms); + const onAbort = () => { clearTimeout(timer); reject(stoppedError()); }; + if (signal) signal.addEventListener('abort', onAbort, { once: true }); + }); +} +function stoppedError() { + const e = new Error('Stopped while waiting for the minimum interval of the source.'); + e.stopped = true; + return e; +} +async function spacing(provider, capability, signal) { + const seconds = capability === 'spot' ? null : intervalOf(provider, capability); + if (!seconds) return false; + const last = lastCallAt.get(`${provider.id}|${capability}`); + const wait = last == null ? 0 : last + seconds * 1000 - Date.now(); + if (wait > 0) await sleep(wait, signal); + return true; +} +export function forgetCallTimes() { lastCallAt.clear(); } + +async function callPrice(provider, capability, symbol, date, { signal = null } = {}) { + if (provider.fixedRate) return vasteKoers(provider, capability, date); + const tegoed = await usageFor(provider); + if (tegoed && tegoed.calls >= provider.callsPerMonth) { + const e = new Error(t('integrations.price.limitReached', { provider: provider.name, limiet: provider.callsPerMonth })); + e.limitReached = true; + throw e; + } + if (await spacing(provider, capability, signal)) lastCallAt.set(`${provider.id}|${capability}`, Date.now()); + if (tegoed) tegoed.calls++; + const t0 = perfNow(); + let status = 0, ok = false, data = null, err = null; + try { + const res = await fetch('/api/price/call', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider, capability, symbol, date }), + }); + status = res.status; + data = await res.json().catch(() => ({})); + if (!res.ok || (data && data.error)) { + err = new Error(serverFoutTekst(data) || t('integrations.priceCallFailed', { status })); + if (data && data.key) { err.key = data.key; err.params = data.params || null; } + } else ok = true; + } catch (e) { + err = e; + } + const latency = Math.round(perfNow() - t0); + emitApiCall({ provider_key: provider.key, provider_name: provider.name, capability, symbol, ok, http_status: status, latency_ms: latency, ts: Date.now() }); + try { await recordApiCall({ providerId: provider.id, capability, ok, latency_ms: latency }); } + catch { } + if (err) throw err; + return inBaseCurrency(provider, capability, symbol, date, data); +} + +const dayOf = time => new Date(time * 1000).toISOString().slice(0, 10); + +async function rateLookup(currency) { + const asset = (await listAssets()).find(a => a.symbol === currency && a.kind === 'fiat'); + if (!asset) return () => null; + try { await ensurePriceHistoryFor(asset); } catch { } + const rows = (await listPriceHistory(asset.id)) + .filter(r => r.resolution === 'day' && r.fiatPrice != null && r.fiatPrice > 0) + .map(r => ({ day: r.observedAt.slice(0, 10), fiatPrice: r.fiatPrice })) + .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); + const maxAge = CONFIG.PRICE_HISTORY.FX_MAX_AGE_DAYS; + return day => { + let lo = 0, hi = rows.length - 1, hit = null; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (rows[mid].day <= day) { hit = rows[mid]; lo = mid + 1; } else hi = mid - 1; + } + if (!hit) return null; + const age = (Date.parse(`${day}T00:00:00Z`) - Date.parse(`${hit.day}T00:00:00Z`)) / 86400000; + return age <= maxAge ? hit.fiatPrice : null; + }; +} + +export async function rateSourceFor(currency) { + if (!currency || currency === baseCurrency()) return null; + const asset = (await listAssets()).find(a => a.symbol === currency && a.kind === 'fiat'); + if (!asset) return null; + return (await primaryProvider(asset, 'daily_history')) + || (await primaryProvider(asset, 'spot')) + || (await serviceRateSource(asset)); +} + +async function serviceRateSource(asset) { + if (marketSpotMode() === 'off') return null; + try { + const r = await serviceCoverage(); + if (r.status !== 'ok' || !r.assetIds.includes(asset.id)) return null; + return await getProvider(CONFIG.MARKET_SERVICE.PROVIDER_KEY); + } catch { return null; } +} + +export const basePriceOf = spot => (spot && Number.isFinite(spot.price) ? spot.price : null); + +export async function currentRate(currency) { + if (currency === baseCurrency()) return 1; + return (await rateLookup(currency))(todayIso()); +} + +export async function rateOn(currency, day) { + if (currency === baseCurrency()) return 1; + return (await rateLookup(currency))(day); +} + +async function inBaseCurrency(provider, capability, symbol, date, data) { + const endpoint = provider.endpoints.find(e => e.capability === capability); + const currency = endpoint && endpoint.quoteCurrency; + if (!currency || currency === baseCurrency()) return data; + if (symbol === currency) throw new Error(`Provider ${provider.key}: ${currency} cannot be quoted in ${currency}.`); + const rate = await rateLookup(currency); + const convert = (p, day) => { + const r = rate(day); + if (!Number.isFinite(p.price) || r == null) return null; + return { ...p, price: p.price * r }; + }; + if (capability === 'daily_history') { + return (Array.isArray(data) ? data : []).map(p => convert(p, dayOf(p.time))).filter(Boolean); + } + const day = capability === 'history_point' ? (date || dayOf(data.time)) : todayIso(); + const uit = convert(data, day); + if (!uit) throw new Error(t('integrations.price.noExchangeRate', { valuta: currency, datum: day, munt: baseCurrency() })); + return uit; +} +function hasCapability(provider, capability) { + return !!(provider && provider.endpoints.some(e => e.capability === capability)); +} +const todayIso = () => new Date().toISOString().slice(0, 10); + +async function newestStoredDate(assetId) { + const rows = (await listPriceHistory(assetId)).filter(r => r.resolution === 'day'); + return rows.length ? rows[0].observedAt.slice(0, 10) : null; +} + +const seriesFetchedOn = new Map(); + +export function forgetSeriesFetches() { seriesFetchedOn.clear(); serviceAskAfter.clear(); } + +if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { + document.addEventListener('providers-changed', forgetSeriesFetches); +} + +async function ensureHistoryWith(provider, asset) { + if (!provider) return; + const newest = await newestStoredDate(asset.id); + if (newest && newest >= todayIso()) return; + if (hasCapability(provider, 'daily_history')) { + const sleutel = `${provider.key}|${asset.id}`; + if (seriesFetchedOn.get(sleutel) === todayIso()) return; + const points = await callPrice(provider, 'daily_history', asset.symbol); + if (Array.isArray(points) && points.length) await upsertPriceHistory(asset.id, points, provider.key, 'day'); + seriesFetchedOn.set(sleutel, todayIso()); + return; + } + if (hasCapability(provider, 'spot')) { + const p = await callPrice(provider, 'spot', asset.symbol); + if (p && Number.isFinite(p.price)) { + const time = Math.floor(Date.parse(`${todayIso()}T00:00:00Z`) / 1000); + await upsertPriceHistory(asset.id, [{ time, price: p.price }], provider.key, 'day'); + } + } +} +async function serviceDaysAnswer(asset, sinceMs) { + const since = Math.max(sinceMs, Date.now() - CONFIG.MARKET_SERVICE.HISTORY_MAX_DAYS * 86_400_000); + let r; + const t0 = perfNow(); + try { r = await serviceDayHistory(asset.id, since); } catch { serviceCalled(asset, 'daily_history', false, t0); return { failed: true, retryLater: true }; } + serviceCalled(asset, 'daily_history', r.status === 'ok', t0); + if (r.status !== 'ok') { + return { failed: true, retryLater: r.status === 'unavailable' && !!r.retryLater, refused: r.status === 'unavailable' && !r.retryLater }; + } + const points = r.days.map(([ms, price, sourceName]) => ({ time: Math.floor(ms / 1000), price, ...(sourceName ? { sourceName } : {}) })); + if (r.currency === baseCurrency()) return { points }; + if (asset.kind === 'fiat' && asset.symbol === r.currency) return { failed: true }; + const rate = await rateLookup(r.currency); + return { + points: points.map((p) => { + const x = rate(dayOf(p.time)); + return x == null ? null : { ...p, price: p.price * x }; + }).filter(Boolean), + }; +} +async function serviceDays(asset, sinceMs) { + return (await serviceDaysAnswer(asset, sinceMs)).points ?? null; +} + +const serviceAskAfter = new Map(); + +async function ensureHistoryFromService(asset) { + const sleutel = `${CONFIG.MARKET_SERVICE.PROVIDER_KEY}|${asset.id}`; + if (seriesFetchedOn.get(sleutel) === todayIso()) return true; + if ((serviceAskAfter.get(sleutel) ?? 0) > Date.now()) return false; + const yesterday = new Date(Date.parse(`${todayIso()}T00:00:00Z`) - 86_400_000).toISOString().slice(0, 10); + const newest = (await listPriceHistory(asset.id)) + .filter(row => row.resolution === 'day' && row.observedAt.slice(0, 10) < todayIso()) + .map(row => row.observedAt.slice(0, 10)).sort().at(-1) ?? null; + if (newest && newest >= yesterday) return true; + const answer = await serviceDaysAnswer(asset, newest ? Date.parse(`${newest}T00:00:00Z`) + 86_400_000 : 0); + if (!answer.points) { + if (answer.refused) serviceAskAfter.set(sleutel, Date.parse(`${todayIso()}T00:00:00Z`) + 86_400_000); + else if (answer.retryLater) serviceAskAfter.set(sleutel, Date.now() + CONFIG.MARKET_SERVICE.RETRY_PAUSE_MS); + return false; + } + const points = answer.points; + if (points.length) await upsertPriceHistory(asset.id, points, CONFIG.MARKET_SERVICE.PROVIDER_KEY, 'day'); + seriesFetchedOn.set(sleutel, todayIso()); + return true; +} + +export async function ensurePriceHistoryFor(asset) { + const mode = marketSpotMode(); + const grid = await primaryProvider(asset, 'daily_history'); + const own = grid || await primaryProvider(asset, 'spot'); + if (mode === 'first') { + const chosen = grid && (await ownPriceSourceChain(asset.id, sourceCapabilityOf('daily_history'))).includes(grid.key); + if (!chosen && await ensureHistoryFromService(asset)) return; + } + if (mode !== 'fallback') return ensureHistoryWith(own, asset); + if (own) { + try { return await ensureHistoryWith(own, asset); } catch (e) { + if (await ensureHistoryFromService(asset)) return; + throw e; + } + } + await ensureHistoryFromService(asset); +} + +export async function resolveChain(asset, capability) { + if (!asset) return []; + const keuze = sourceCapabilityOf(capability); + if (!keuze) return []; + const keys = await priceSourceChain(asset.id, keuze); + const providers = await Promise.all(keys.map(key => getProvider(key))); + return providers.filter(p => p && p.isActive && hasCapability(p, capability) && covers(p, asset.symbol)); +} +function covers(provider, symbol) { + return Array.isArray(provider.coverage) && provider.coverage.includes(symbol); +} + +export async function primaryProvider(asset, capability) { + return (await resolveChain(asset, capability))[0] || null; +} +export async function primarySourceKey(asset, capability) { + const p = await primaryProvider(asset, capability); + return p ? p.key : null; +} + +const spotAnswers = new Map(); +async function recentSpot(provider, asset, seconds) { + const sleutel = `${provider.id}|${asset.symbol}`; + const houdbaar = at => Date.now() - at < seconds * 1000; + const m = spotAnswers.get(sleutel); + if (m && houdbaar(m.at)) return m.spot; + try { + const r = await latestSpotPoint(asset.id, provider.key); + const at = r ? Date.parse(r.observedAt) : NaN; + if (Number.isFinite(at) && houdbaar(at)) { + const spot = { price: r.fiatPrice }; + spotAnswers.set(sleutel, { at, spot }); + return spot; + } + } catch { } + return null; +} +if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { + document.addEventListener('providers-changed', () => spotAnswers.clear()); +} +export function forgetSpotAnswers() { spotAnswers.clear(); } + +async function spotVia(provider, asset) { + const seconds = intervalOf(provider, 'spot'); + if (seconds) { + const vers = await recentSpot(provider, asset, seconds); + if (vers) return vers; + } + const spot = await callPrice(provider, 'spot', asset.symbol); + if (seconds) spotAnswers.set(`${provider.id}|${asset.symbol}`, { at: Date.now(), spot }); + try { await recordCurrentSpot(asset.id, provider.key, spot); } catch { } + return spot; +} + +const spotCache = new Map(); +const spotSources = new Map(); + +export function clearSpotCache() { spotCache.clear(); spotSources.clear(); } + +export function lastSpotSource(asset) { + return spotSources.get(asset && asset.id) ?? null; +} + +if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { + document.addEventListener('tick:spot', clearSpotCache); + document.addEventListener('providers-changed', clearSpotCache); +} + +function marketSpotMode() { + try { return appSetting('market_spot'); } catch { return 'off'; } +} + +async function askService(asset) { + let r; + const t0 = perfNow(); + try { r = await serviceSpot(asset.id); } catch { serviceCalled(asset, 'spot', false, t0); return null; } + serviceCalled(asset, 'spot', r.status === 'ok', t0); + if (r.status === 'unavailable') return { unavailable: true }; + if (r.status !== 'ok') return null; + const rate = await currentRate(r.currency); + if (rate == null) return null; + const spot = { price: r.price * rate }; + try { await recordCurrentSpot(asset.id, CONFIG.MARKET_SERVICE.PROVIDER_KEY, spot); } catch { } + return { spot, label: t('marketService.sourceLive', { source: r.sourceLabel || r.source || '?' }) }; +} + +async function cachedServiceSpot(asset) { + try { + const r = await latestSpotPoint(asset.id, CONFIG.MARKET_SERVICE.PROVIDER_KEY); + if (!r) return null; + return { spot: { price: r.fiatPrice }, label: t('marketService.sourceCached', { when: r.observedAt.slice(0, 16).replace('T', ' ') }) }; + } catch { return null; } +} + +function keep(sleutel, spot, label, fresh) { + spotCache.set(sleutel, spot); + spotSources.set(sleutel, label); + if (fresh) recordSpot(sleutel, basePriceOf(spot)); + return spot; +} + +async function firstSpot(sleutel, asset, providers) { + for (const provider of providers) { + try { + return keep(sleutel, await spotVia(provider, asset), provider.name, true); + } catch { } + } + return null; +} + +export async function liveSpotFor(asset) { + const sleutel = asset && asset.id; + if (spotCache.has(sleutel)) return spotCache.get(sleutel); + const mode = marketSpotMode(); + let chain = await resolveChain(asset, 'spot'); + let dienst = null; + if (mode === 'first') { + const eigen = new Set(await ownPriceSourceChain(asset.id, sourceCapabilityOf('spot'))); + const gekozen = await firstSpot(sleutel, asset, chain.filter(p => eigen.has(p.key))); + if (gekozen) return gekozen; + dienst = await askService(asset); + if (dienst?.spot) return keep(sleutel, dienst.spot, dienst.label, true); + chain = chain.filter(p => !eigen.has(p.key)); + } + const lokaal = await firstSpot(sleutel, asset, chain); + if (lokaal) return lokaal; + if (mode === 'fallback') { + dienst = await askService(asset); + if (dienst?.spot) return keep(sleutel, dienst.spot, dienst.label, true); + } + if (dienst?.unavailable) { + const cached = await cachedServiceSpot(asset); + if (cached) return keep(sleutel, cached.spot, cached.label, false); + } + return null; +} + +export async function liveSpotPriceFor(asset) { + return basePriceOf(await liveSpotFor(asset)); +} + +export async function fetchPriceVia(providerKey, asset, capability, dateKey) { + const provider = await getProvider(providerKey); + if (!provider || !provider.isActive || !hasCapability(provider, capability)) return null; + return callPrice(provider, capability, asset.symbol, dateKey); +} + +export async function hasDailyGridFor(asset) { + return !!(await primaryProvider(asset, 'daily_history')); +} + +async function historyPointWith(provider, asset, dateKey) { + if (!hasCapability(provider, 'history_point')) return null; + return callPrice(provider, 'history_point', asset.symbol, dateKey); +} +export async function historicalPointForVia(asset, dateKey) { + for (const provider of await resolveChain(asset, 'history_point')) { + try { const p = await historyPointWith(provider, asset, dateKey); if (p) return p; } + catch { } + } + return null; +} + +async function ensurePointsWith(provider, asset, dateKeys) { + if (!hasCapability(provider, 'history_point')) return; + for (const key of dateKeys) { + if (await getPricePoint(asset.id, key, { derived: false })) continue; + try { + const p = await callPrice(provider, 'history_point', asset.symbol, key); + if (p && Number.isFinite(p.price)) await upsertPriceHistory(asset.id, [p], provider.key, 'day'); + } catch { } + } +} +export async function ensurePointsAtFor(asset, dateKeys) { + return ensurePointsWith(await primaryProvider(asset, 'history_point'), asset, dateKeys); +} + +const shiftDay = (dayKey, n) => new Date(Date.parse(`${dayKey}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10); +const isWeekend = dayKey => { const d = new Date(`${dayKey}T00:00:00Z`).getUTCDay(); return d === 0 || d === 6; }; +const skipsWeekend = asset => CONFIG.PRICE_HISTORY.WEEKEND_PRICE[asset.kind] === false; + +export function daysToAsk(asset, fromDay, untilDay) { + let n = 0; + for (let day = fromDay; day <= untilDay; day = shiftDay(day, 1)) if (!(skipsWeekend(asset) && isWeekend(day))) n++; + return n; +} + +function isClose(row, manualId) { + if (row.resolution !== 'day') return false; + if (manualId != null && row.providerId === manualId) return true; + const written = row.updatedAt || row.createdAt; + return !!written && written.slice(0, 10) > row.observedAt.slice(0, 10); +} + +export async function fillClosesFor(asset, fromDay, untilDay, { today = todayIso(), signal = null } = {}) { + const uit = { requested: 0, written: 0, limitReached: false, stopped: false }; + if (!asset) return uit; + const series = await primaryProvider(asset, 'daily_history'); + if (series) { try { await ensureHistoryWith(series, asset); } catch { } } + const last = untilDay < shiftDay(today, -1) ? untilDay : shiftDay(today, -1); + if (marketSpotMode() !== 'off' && fromDay <= last) { + const points = await serviceDays(asset, Date.parse(`${fromDay}T00:00:00Z`)); + const inGap = (points || []).filter(p => dayOf(p.time) >= fromDay && dayOf(p.time) <= last); + if (inGap.length) { + await upsertPriceHistory(asset.id, inGap, CONFIG.MARKET_SERVICE.PROVIDER_KEY, 'day'); + uit.written += inGap.length; + } + } + const chain = await resolveChain(asset, 'history_point'); + if (!chain.length) return uit; + if (fromDay > last) return uit; + const manual = await getProvider('manual'); + const closed = new Set((await listPriceHistory(asset.id, fromDay)) + .filter(r => isClose(r, manual ? manual.id : null)) + .map(r => r.observedAt.slice(0, 10))); + const op = new Set(); + const skipWeekend = skipsWeekend(asset); + const noPrice = new Set((await listNoPriceDays(asset.id, fromDay)).map(r => `${r.providerId}|${r.day}`)); + const found = new Map(); + const delivered = new Set(); + for (let day = fromDay; day <= last; day = shiftDay(day, 1)) { + if (closed.has(day)) continue; + if (skipWeekend && isWeekend(day)) continue; + if (signal && signal.aborted) { uit.stopped = true; break; } + if (op.size === chain.length) { uit.limitReached = true; break; } + const first = chain.find(p => !op.has(p.id)); + if (noPrice.has(`${first.id}|${day}`)) continue; + uit.requested++; + for (const provider of chain) { + if (op.has(provider.id)) continue; + if (noPrice.has(`${provider.id}|${day}`)) break; + try { + const p = await callPrice(provider, 'history_point', asset.symbol, day, { signal }); + if (p && Number.isFinite(p.price)) { + await upsertPriceHistory(asset.id, [p], provider.key, 'day'); + uit.written++; + delivered.add(provider.id); + } + break; + } catch (e) { + if (e && e.limitReached) { op.add(provider.id); continue; } + if (isNoPrice(e)) { if (!found.has(provider.id)) found.set(provider.id, []); found.get(provider.id).push(day); } + break; + } + } + } + for (const [providerId, days] of found) { + if (!delivered.has(providerId)) continue; + const provider = chain.find(p => p.id === providerId); + try { await recordNoPriceDays(asset.id, provider.key, days); } catch { } + } + if (op.size === chain.length) uit.limitReached = true; + return uit; +} + +function isNoPrice(e) { + if (!e) return false; + if (e.key === 'integrations.price.noValidPrice') return true; + return e.key === 'integrations.price.httpFailed' && e.params && Number(e.params.status) === 404; +} + +export async function refreshRecentCloses(assets, { today = todayIso() } = {}) { + const until = shiftDay(today, -1); + const from = shiftDay(today, -CONFIG.PRICE_HISTORY.RECENT_CLOSE_DAYS); + let written = 0; + for (const asset of assets || []) { + try { written += (await fillClosesFor(asset, from, until, { today })).written; } + catch { } + } + return { written }; +} diff --git a/client/price/wallet-scanner.js b/client/price/wallet-scanner.js new file mode 100644 index 0000000..168a8a5 --- /dev/null +++ b/client/price/wallet-scanner.js @@ -0,0 +1,161 @@ +// Generated, do not edit. sha256:a16dd8b1a3974fb4acdf7f84e8ff322821ad367d6c5137af68317f24a7ee0d3d +import { HDKey, base58check, bech32, bech32m, secp256k1, sha256, ripemd160, + hexToBytes, concatBytes, utf8ToBytes } from '../vendor/crypto/noble-scure.js'; +import { addressTxs, fetchJson, explorerBase } from './btc-explorer.js'; + +const b58 = base58check(sha256); +const hash160 = bytes => ripemd160(sha256(bytes)); +const XPUB_VERSION = hexToBytes('0488b21e'); +const TAP_TWEAK_TAG = sha256(utf8ToBytes('TapTweak')); +const bytesToBig = b => { let n = 0n; for (const x of b) n = (n << 8n) | BigInt(x); return n; }; +const bigTo32 = n => { const o = new Uint8Array(32); for (let i = 31; i >= 0; i--) { o[i] = Number(n & 0xffn); n >>= 8n; } return o; }; + +function taprootOutputKey(pub33) { + const xonly = pub33.slice(1); + const t = bytesToBig(sha256(concatBytes(TAP_TWEAK_TAG, TAP_TWEAK_TAG, xonly))) % secp256k1.Point.Fn.ORDER; + const P = secp256k1.Point.fromBytes(concatBytes(Uint8Array.of(0x02), xonly)); + const Q = P.add(secp256k1.Point.BASE.multiply(t)); + return bigTo32(Q.toAffine().x); +} + +const SCAN = { + GAP_LIMIT: 20, + DELAY_MS: 250, +}; + +const apiBase = async () => (await explorerBase()) + '/api'; + +const wacht = ms => new Promise(r => setTimeout(r, ms)); + +function typeUitPrefix(key) { + if (key.startsWith('ypub')) return 'p2sh-p2wpkh'; + if (key.startsWith('zpub') || key.startsWith('xpub')) return 'p2wpkh'; + return 'p2pkh'; +} + +function naarXpub(extKey) { + const data = b58.decode(extKey); + const out = new Uint8Array(data.length); + out.set(XPUB_VERSION, 0); + out.set(data.slice(4), 4); + return b58.encode(out); +} + +function adresVanPubkey(pubkey, type) { + if (type === 'p2tr') { + return bech32m.encode('bc', [1, ...bech32m.toWords(taprootOutputKey(pubkey))]); + } + const h = hash160(pubkey); + if (type === 'p2wpkh') { + return bech32.encode('bc', [0, ...bech32.toWords(h)]); + } + if (type === 'p2sh-p2wpkh') { + const redeem = new Uint8Array(22); + redeem[0] = 0x00; redeem[1] = 0x14; redeem.set(h, 2); + const sh = hash160(redeem); + const payload = new Uint8Array(21); payload[0] = 0x05; payload.set(sh, 1); + return b58.encode(payload); + } + const payload = new Uint8Array(21); payload[0] = 0x00; payload.set(h, 1); + return b58.encode(payload); +} + +function maakDeriver(extKey, scriptType) { + const type = scriptType || typeUitPrefix(extKey); + const hd = HDKey.fromExtendedKey(naarXpub(extKey)); + return (chain, index) => { + const child = hd.deriveChild(chain).deriveChild(index); + return adresVanPubkey(child.publicKey, type); + }; +} + +export function firstReceiveAddress(extKey, scriptType) { + return maakDeriver(extKey, scriptType)(0, 0); +} + +async function haalAdresInfoOp(adres) { + const info = await fetchJson(`${await apiBase()}/address/${adres}`, `Adres ${adres}`); + const bevestigd = info.chain_stats.funded_txo_sum - info.chain_stats.spent_txo_sum; + const onbevestigd = info.mempool_stats.funded_txo_sum - info.mempool_stats.spent_txo_sum; + const tx_aantal = info.chain_stats.tx_count + info.mempool_stats.tx_count; + return { saldo_satoshi: bevestigd, saldo_onbevestigd: onbevestigd, tx_aantal }; +} + +async function scanChain(deriver, chain, onVoortgang) { + const resultaten = []; + let legeReeks = 0, index = 0; + const chainLabel = chain === 0 ? 'ontvangst' : 'change'; + while (legeReeks < SCAN.GAP_LIMIT) { + const adres = deriver(chain, index); + onVoortgang?.({ fase: 'scan', chain: chainLabel, index, adres }); + const info = await haalAdresInfoOp(adres); + await wacht(SCAN.DELAY_MS); + if (info.tx_aantal > 0) { + resultaten.push({ + adres, chain, chainLabel, index, + saldo_satoshi: info.saldo_satoshi, + tx_aantal: info.tx_aantal, + heeftSaldo: info.saldo_satoshi > 0, + }); + legeReeks = 0; + } else { + legeReeks++; + } + index++; + } + return resultaten; +} + +export async function scanMovements(addresses, accountId, sourceId, onVoortgang, { ownAddresses = [] } = {}) { + const own = new Set([...addresses, ...ownAddresses]); + const gezien = new Set(); + const rijen = []; + const failed = []; + let i = 0; + for (const adres of addresses) { + onVoortgang?.({ fase: 'tx', index: i++, total: addresses.length, adres }); + let txs; + try { txs = await addressTxs(adres); } + catch (e) { console.warn(`[scanMovements] adres mislukt: ${adres}`, e); failed.push(adres); onVoortgang?.({ fase: 'tx-error', adres, error: e.message }); continue; } + await wacht(SCAN.DELAY_MS); + for (const tx of txs) { + if (gezien.has(tx.txid)) continue; + gezien.add(tx.txid); + const inSum = (tx.vin || []).reduce((s, v) => s + (v.prevout && own.has(v.prevout.scriptpubkey_address) ? v.prevout.value : 0), 0); + const outSum = (tx.vout || []).reduce((s, o) => s + (own.has(o.scriptpubkey_address) ? o.value : 0), 0); + const net = outSum - inSum; + if (net === 0) continue; + const direction = net > 0 ? 'in' : 'out'; + const fee = tx.fee ?? 0; + const value_sats = direction === 'out' ? Math.max(0, Math.abs(net) - fee) : Math.abs(net); + const fee_sats = direction === 'out' ? fee : 0; + const pool = direction === 'in' ? (tx.vout || []) : (tx.vin || []).map(v => v.prevout).filter(Boolean); + let repAddr = null, repVal = -1; + for (const o of pool) { + if (own.has(o.scriptpubkey_address) && o.value > repVal) { repVal = o.value; repAddr = o.scriptpubkey_address; } + } + const conf = tx.status && tx.status.confirmed && tx.status.block_height && tx.status.block_time; + rijen.push({ + accountId, sourceId, txid: tx.txid, + direction, valueSats: value_sats, feeSats: fee_sats, + blockHeight: conf ? tx.status.block_height : null, + blockTime: conf ? new Date(tx.status.block_time * 1000).toISOString() : null, + address: repAddr, + }); + } + } + return { rows: rijen, failed }; +} + +export async function scanWallet(xpub, scriptType, onVoortgang) { + onVoortgang?.({ fase: 'start' }); + const deriver = maakDeriver(xpub, scriptType); + const [extern, intern] = await Promise.all([ + scanChain(deriver, 0, onVoortgang), + scanChain(deriver, 1, onVoortgang), + ]); + const alleAdressen = [...extern, ...intern]; + const totaalSaldo_satoshi = alleAdressen.reduce((s, a) => s + a.saldo_satoshi, 0); + onVoortgang?.({ fase: 'klaar' }); + return { xpub, totaalSaldo_satoshi, alleAdressen }; +} diff --git a/client/routes.js b/client/routes.js new file mode 100644 index 0000000..1669d4c --- /dev/null +++ b/client/routes.js @@ -0,0 +1,26 @@ +// Generated, do not edit. sha256:4950de4d7773457ba3e059bdd6064ec381481cef089a6220acf9873b2bbee2e1 +import { ROUTE_KEYS, SYSTEM_PAGE } from '../shared/config.js'; +import { createRegistry } from '../shared/registry.js'; +import { locked } from './license.js'; +import { systemTabs } from './pages/system-tabs.js'; + +const routes = createRegistry('route', { + validate: (e) => { if (!Object.values(ROUTE_KEYS).includes(e.id)) throw new Error(`Unknown route '${e.id}'.`); }, +}); + +export function registerRoute(key, page, { feature = null } = {}) { + routes.register({ id: key, page, feature }); +} + +export function routePage(key) { + const r = routes.get(key); + return r && !(r.feature && locked(r.feature)) ? r.page : null; +} + +export function placeAvailable(target) { + if (!target?.route) return false; + if (!routePage(target.route) && !routePage(target.route.split('/')[0])) return false; + if (target.route !== SYSTEM_PAGE.ROUTE || !target.tab) return true; + const tab = systemTabs().find(t => t.id === target.tab); + return !!tab && (!target.sub || !tab.subs || tab.subs.includes(target.sub)); +} diff --git a/client/signals/constants.js b/client/signals/constants.js new file mode 100644 index 0000000..c4614eb --- /dev/null +++ b/client/signals/constants.js @@ -0,0 +1,28 @@ +// Generated, do not edit. sha256:9ca0c45b0945e94868e99e2fd93c72e9ebb7d1e8b99a59b57aa8e5980d47892d +export const GENESIS_ISO = '2009-01-03'; + +export const POWERLAW_PROJECT_DAYS = 365; + +export const POWERLAW = Object.freeze({ + FIT: Object.freeze({ A: 1e-17, N: 5.8 }), + LOWER: Object.freeze({ DEFAULT: 0.47 }), + UPPER: Object.freeze({ DEFAULT_BY_CCY: Object.freeze({ EUR: 4.5, USD: 5.0 }) }), +}); + +export const WMA = Object.freeze({ + WEEKS: 200, + FAST_WEEKS: 50, + SIGNAL_MULT: 1.2, + RED_MULT: 1.5, +}); + +export const MAYER = Object.freeze({ + DAYS: 200, + GREEN_BELOW: 1.0, + RED_ABOVE: 2.4, +}); + +export const ZOOM_RECENT_DAYS = 180; + +export const MS_PER_DAY = 24 * 60 * 60 * 1000; +export const MS_PER_WEEK = 7 * MS_PER_DAY; diff --git a/client/signals/data.js b/client/signals/data.js new file mode 100644 index 0000000..b16bf4a --- /dev/null +++ b/client/signals/data.js @@ -0,0 +1,127 @@ +// Generated, do not edit. sha256:19640cdaa8812f06608f0f5d7ab246cafacd608773aca0ad6879af4404a7d7ed +import { GENESIS_ISO, MS_PER_WEEK, MS_PER_DAY, WMA, MAYER, POWERLAW } from './constants.js'; +import { t } from '../i18n/index.js'; + +const GENESIS_MS = new Date(GENESIS_ISO).getTime(); + +export function daysSinceGenesis(tsMs) { + return (tsMs - GENESIS_MS) / (24 * 60 * 60 * 1000); +} +export function genesisMs() { return GENESIS_MS; } + +export function toDailyGrid(rows) { + const byDay = new Map(); + for (const r of rows) { + const v = r.fiatPrice; + if (v != null && v > 0) byDay.set(r.observedAt.slice(0, 10), v); + } + if (byDay.size === 0) throw new Error(t('signals.noHistory')); + const days = [...byDay.keys()].sort(); + const startMs = Date.parse(`${days[0]}T00:00:00Z`); + const endMs = Date.parse(`${days[days.length - 1]}T00:00:00Z`); + const out = []; + let last = null; + for (let ms = startMs; ms <= endMs; ms += MS_PER_DAY) { + const iso = new Date(ms).toISOString().slice(0, 10); + if (byDay.has(iso)) last = byDay.get(iso); + out.push([ms, last]); + } + return out; +} + +export function dailyGridSources(rows) { + const byDay = new Map(); + for (const r of rows) { + const v = r.fiatPrice; + if (v != null && v > 0) { + byDay.set(Date.parse(`${r.observedAt.slice(0, 10)}T00:00:00Z`), { providerId: r.providerId, sourceName: r.sourceName ?? null }); + } + } + return byDay; +} + +export function powerLawModel() { + return { A: POWERLAW.FIT.A, n: POWERLAW.FIT.N }; +} +export function powerLawValue(fit, tsMs) { + return fit.A * Math.pow(daysSinceGenesis(tsMs), fit.n) * (fit.scaleAt ? fit.scaleAt(tsMs) : 1); +} + +export function powerLawFor(currency, usdRates = null) { + const own = POWERLAW.UPPER.DEFAULT_BY_CCY[String(currency).toUpperCase()]; + if (own != null) return { fit: powerLawModel(), upper: own }; + if (!usdRates || !usdRates.length) return null; + return { fit: { ...powerLawModel(), scaleAt: ts => rateOn(usdRates, ts) }, upper: POWERLAW.UPPER.DEFAULT_BY_CCY.USD }; +} + +function rateOn(grid, ts) { + let lo = 0, hi = grid.length - 1, hit = 0; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (grid[mid][0] <= ts) { hit = mid; lo = mid + 1; } else hi = mid - 1; + } + return grid[hit][1]; +} + +export function weeklyAggregate(daily) { + const out = []; + if (daily.length === 0) return out; + let week = Math.floor(daily[0][0] / MS_PER_WEEK); + let sum = 0, count = 0; + for (const [ts, price] of daily) { + const w = Math.floor(ts / MS_PER_WEEK); + if (w !== week) { + if (count > 0) out.push([week * MS_PER_WEEK, sum / count]); + week = w; sum = price; count = 1; + } else { sum += price; count++; } + } + if (count > 0) out.push([week * MS_PER_WEEK, sum / count]); + return out; +} + +export function movingAverage(series, window) { + const out = []; + let sum = 0; + for (let i = 0; i < series.length; i++) { + sum += series[i][1]; + if (i >= window) sum -= series[i - window][1]; + out.push({ x: series[i][0], y: i >= window - 1 ? sum / window : null }); + } + return out; +} + +export function mayerSeries(daily, days) { + const ma = movingAverage(daily, days); + return daily.map(([ts, price], i) => ({ + x: ts, + y: ma[i].y == null ? null : price / ma[i].y, + })); +} + +export function powerLawSignal(daily, fit, lowerFactor, upperFactor) { + const [ts, price] = daily[daily.length - 1]; + const f = powerLawValue(fit, ts); + if (price <= f * lowerFactor) return { color: 'green', key: 'plStrong' }; + if (price <= f) return { color: 'green', key: 'plBuy' }; + if (price <= f * upperFactor) return { color: 'yellow', key: 'plAbove' }; + return { color: 'red', key: 'plFar' }; +} + +export function wmaSignal(weekly) { + const ma = movingAverage(weekly, WMA.WEEKS); + const lastWma = [...ma].reverse().find(d => d.y != null); + if (!lastWma || weekly.length === 0) return null; + const ratio = weekly[weekly.length - 1][1] / lastWma.y; + if (ratio < WMA.SIGNAL_MULT) return { color: 'green', key: 'wmaBuy', mult: ratio }; + if (ratio < WMA.RED_MULT) return { color: 'yellow', key: 'wmaAbove', mult: ratio }; + return { color: 'red', key: 'wmaFar', mult: ratio }; +} + +export function mayerSignal(mayer) { + const last = [...mayer].reverse().find(d => d.y != null); + if (!last) return null; + const v = last.y; + if (v < MAYER.GREEN_BELOW) return { color: 'green', key: 'mayerLow', val: v }; + if (v < MAYER.RED_ABOVE) return { color: 'yellow', key: 'mayerMid', val: v }; + return { color: 'red', key: 'mayerHigh', val: v }; +} diff --git a/client/tax/constants.js b/client/tax/constants.js new file mode 100644 index 0000000..cdacd5d --- /dev/null +++ b/client/tax/constants.js @@ -0,0 +1,37 @@ +// Generated, do not edit. sha256:f9192eade666f121065dd68b1fecc5564b3f834c856bf8388ee039cdf9da7929 +import { TAX_CATEGORY_BY_ASSET_KIND } from '../../shared/config.js'; + +export const VERMOGENSCATEGORIE = Object.freeze({ + BANK: 'bank', + OVERIG: 'overig', +}); + +export const REIKWIJDTE = Object.freeze({ + ALL: 'all', + CATEGORY: 'category', + NONE: 'none', +}); + +export function reikwijdteVoor(account, asset) { + return (account && account.taxScope) || (asset && asset.taxScope) || REIKWIJDTE.ALL; +} + +export const CATEGORIE_PER_ASSET_KIND = TAX_CATEGORY_BY_ASSET_KIND; + +export function categorieVoorAsset(asset) { + if (!asset || !asset.kind) return null; + return CATEGORIE_PER_ASSET_KIND[asset.kind] ?? null; +} + +export function categorieVoor(account, asset) { + return (account && account.taxCategory) || (asset && asset.taxCategory) || categorieVoorAsset(asset); +} + +export function categorieGekozen(rows) { + const gekozen = c => c === VERMOGENSCATEGORIE.BANK || c === VERMOGENSCATEGORIE.OVERIG; + return rows.some(r => gekozen(r.account?.taxCategory) || gekozen(r.asset?.taxCategory)); +} + +export const REGIME = Object.freeze({ + AANWAS_VANAF: 2028, +}); diff --git a/client/ui/activity.js b/client/ui/activity.js new file mode 100644 index 0000000..bc62feb --- /dev/null +++ b/client/ui/activity.js @@ -0,0 +1,63 @@ +// Generated, do not edit. sha256:88f1ff40757f8e5095efe909c3a45c2f437406121459c4091bd0b4d3c80eac8c +import { localDate } from '../../shared/time.js'; + +const DAY_MS = 86400000; + +const dayMs = iso => { + const d = localDate(iso); + return d ? Date.UTC(Number(d.slice(0, 4)), Number(d.slice(5, 7)) - 1, Number(d.slice(8, 10))) : null; +}; + +export function activityItems({ accountIds, trades = [], transfers = [], interest = [], valuations = [], + schedules = [], accounts = [], reservations = [], interestDue = [] }) { + const ids = new Set(accountIds); + const items = []; + const add = (at, accountId, kind, dir, table = null, id = null, assetId = null) => { + const x = dayMs(at); + if (x != null && ids.has(accountId)) items.push({ x, accountId, kind, dir, table, id, assetId }); + }; + for (const r of trades) { + if (r.type === 'buy') add(r.occurredAt, r.toAccountId, 'buy', 1, 'trades', r.id, r.assetId); + else if (r.type === 'sell') add(r.occurredAt, r.fromAccountId, 'sell', -1, 'trades', r.id, r.assetId); + } + for (const r of transfers) { + add(r.occurredAt, r.fromAccountId, 'transferOut', -1, 'transfers', r.id, r.assetId); + add(r.occurredAt, r.toAccountId, 'transferIn', 1, 'transfers', r.id, r.assetId); + } + for (const r of interest) add(r.occurredAt, r.toAccountId, 'interest', 1, 'interest', r.id, r.assetId); + for (const r of valuations) add(r.rateAt || r.valuedOn, r.accountId, 'balance', 0, 'valuations', r.id, r.assetId); + for (const s of schedules) { + add(s.startsOn, s.accountId, 'agreementStart', 0); + if (s.endsOn) add(s.endsOn, s.accountId, 'agreementEnd', 0); + } + for (const r of reservations) add(r.occurredAt, r.fromAccountId ?? r.toAccountId, 'reservation', 0, 'reservations', r.id, r.assetId); + for (const p of interestDue) add(p.periodEnd, p.accountId, 'interestDue', 0); + for (const a of accounts) { + if (a.createdAt) add(a.createdAt, a.id, 'accountOpened', 0); + if (a.closedAt) add(a.closedAt, a.id, 'accountClosed', 0); + if (a.updatedAt) add(a.updatedAt, a.id, 'accountChanged', 0); + } + return items; +} + +export function heartbeat(items, todayIso, fromIso = null) { + const from = fromIso ? dayMs(fromIso) : null; + if (from != null) items = items.filter(it => it.x >= from); + if (!items.length) return null; + const byDay = new Map(); + for (const it of items) { + if (!byDay.has(it.x)) byDay.set(it.x, []); + byDay.get(it.x).push(it); + } + const today = dayMs(todayIso); + const last = Math.max(...byDay.keys(), today); + const first = Math.min(from ?? Math.min(...byDay.keys(), today) - DAY_MS, last - DAY_MS); + const points = []; + for (let x = first; x <= last; x += DAY_MS) { + const day = byDay.get(x) || []; + const up = day.filter(i => i.dir > 0).length; + const down = day.filter(i => i.dir < 0).length; + points.push([x, up || down ? (up >= down ? Math.sqrt(up) : -Math.sqrt(down)) : 0]); + } + return { points, byDay }; +} diff --git a/client/ui/alert.js b/client/ui/alert.js new file mode 100644 index 0000000..a5ddd05 --- /dev/null +++ b/client/ui/alert.js @@ -0,0 +1,14 @@ +// Generated, do not edit. sha256:9524042357ce22fcf244cdc26ee3cb73b68a915569fd002433a8ce0705465b4d +import { esc } from './escape.js'; + +const ICONS = { info: 'ℹ️', success: '✓', error: '⚠️', neutral: '' }; + +export function alertHtml({ kind = 'neutral', title = '', body = '', icon, extra = '', className = '' }) { + if (!(kind in ICONS)) throw new Error(`Unknown alert kind: ${kind}.`); + const teken = icon === undefined ? ICONS[kind] : (icon || ''); + const iconHtml = teken ? `${teken}` : ''; + const titleHtml = title ? `
    ${esc(title)}
    ` : ''; + const bodyHtml = body ? `
    ${esc(body)}
    ` : ''; + return `
    ${iconHtml}` + + `
    ${titleHtml}${bodyHtml}${extra}
    `; +} diff --git a/client/ui/asset-label.js b/client/ui/asset-label.js new file mode 100644 index 0000000..184e355 --- /dev/null +++ b/client/ui/asset-label.js @@ -0,0 +1,41 @@ +// Generated, do not edit. sha256:514022a1fc2c9eb3b97d3f6069234344075741ea67baafe13687b608706d1657 +import { appSetting } from '../app-settings.js'; +import { ENUMS } from '../../shared/config.js'; + +const veld = (a, eigen, alias) => String((eigen in a ? a[eigen] : (alias in a ? a[alias] : '')) ?? '').trim(); + +const zelfde = (name, symbol) => !!name && name.toLowerCase() === symbol.toLowerCase(); + +export function assetLabel(a) { + if (!a) return ''; + const name = veld(a, 'name', 'asset_name'); + const symbol = veld(a, 'symbol', 'asset_symbol'); + const showName = appSetting('asset_show_name') === 1; + const showSymbol = appSetting('asset_show_symbol') === 1; + + if (showName && showSymbol) return name && symbol && !zelfde(name, symbol) ? `${name} (${symbol})` : (name || symbol); + if (showName) return name || symbol; + return symbol || name; +} + +export function byKindAndLabel(a, b) { + const soort = x => { const i = ENUMS.ASSET_KIND.indexOf(x && x.kind); return i === -1 ? Number.MAX_SAFE_INTEGER : i; }; + return soort(a) - soort(b) + || assetLabel(a).localeCompare(assetLabel(b), 'nl', { sensitivity: 'base' }) + || String(a.symbol || '').localeCompare(String(b.symbol || '')); +} + +export function assetLabelHtml(a, { withoutSymbol = false } = {}) { + if (!a) return ''; + const e = s => String(s ?? '').trim().replace(/&/g, '&').replace(/ `${txt}`; + if (withoutSymbol) return nm(showName ? (name || symbol) : (symbol || name)); + if (showName && showSymbol && name && symbol && !zelfde(name, symbol)) return `${nm(name)} ${symbol}`; + if (showName) return nm(name || symbol); + return nm(symbol || name); +} diff --git a/client/ui/attention-ring.js b/client/ui/attention-ring.js new file mode 100644 index 0000000..fe9cf37 --- /dev/null +++ b/client/ui/attention-ring.js @@ -0,0 +1,31 @@ +// Generated, do not edit. sha256:0c32d1144a8a497df1fbe9e1a74301ff3b50ab942217dad783d7ff7727f69eb8 +import { CONFIG } from '../../shared/config.js'; + +const timers = new WeakMap(); + +export function pulseAttention(card, { rounds = CONFIG.ATTENTION_RING.ROUNDS } = {}) { + if (!card) return; + ringIn(card); + card.classList.add('is-attention'); + clearTimeout(timers.get(card)); + timers.set(card, setTimeout(() => card.classList.remove('is-attention'), rounds * CONFIG.ATTENTION_RING.ROUND_MS)); +} + +export function holdAttention(card) { + if (!card) return; + ringIn(card); + clearTimeout(timers.get(card)); + card.classList.add('is-attention'); +} + +function ringIn(card) { + let ring = card.querySelector(':scope > .attn'); + if (!ring) { + ring = document.createElement('span'); + ring.className = 'attn'; + ring.setAttribute('aria-hidden', 'true'); + ring.innerHTML = ''; + card.prepend(ring); + } + card.style.setProperty('--attn-d', `${Math.ceil(Math.hypot(card.offsetWidth, card.offsetHeight))}px`); +} diff --git a/client/ui/attention-sources.js b/client/ui/attention-sources.js new file mode 100644 index 0000000..dd494d5 --- /dev/null +++ b/client/ui/attention-sources.js @@ -0,0 +1,203 @@ +// Generated, do not edit. sha256:51b53dc3f01e74bb7f221f45ce7f2b9b6f6a164a12cedc96e33ad4f1b1b701a0 +import { currentNotifications } from '../data/notifications.js'; +import { countAiToolCallsSince } from '../data/ai-audit.js'; +import { getPref } from '../data/preferences.js'; +import { listProviders, getProvider } from '../data/providers.js'; +import { listPriceSource } from '../data/price-source.js'; +import { listPriceGaps } from '../data/price-history.js'; +import { apiCallsByProvider } from '../data/api-metrics.js'; +import { rateSourceFor } from '../price/providers.js'; +import { t } from '../i18n/index.js'; +import { CONFIG, TARGETS, NOTIFICATION_TARGETS, SOURCE_ENDPOINTS, targetHash, targetTab } from '../../shared/config.js'; +import { registerAttention } from './attention.js'; +import { appSetting, baseCurrency } from '../app-settings.js'; +import { licenseChangedSinceSeen } from '../license.js'; + +async function integrationsNeedingKey() { + const rijen = await listProviders(); + const configs = await Promise.all(rijen.map(r => getProvider(r.key))); + const providers = configs + .filter(p => p && p.isActive && p.authenticationRequired && p.authenticationKind !== 'none'); + const out = []; + for (const p of providers) { + let complete = false; + try { + const st = await (await fetch(`/api/keys/status?provider=${encodeURIComponent(p.key)}&authenticationKind=${encodeURIComponent(p.authenticationKind)}`)).json(); + complete = !!(st && st.complete); + } catch { continue; } + if (!complete) { + out.push({ + id: `integration:${p.key}`, + severity: 'problem', + route: TARGETS.PROVIDER_KEYS.route, + tab: targetTab(TARGETS.PROVIDER_KEYS), + hash: targetHash(TARGETS.PROVIDER_KEYS), + label: p.name + t('attention.integrationNoKeySuffix'), + }); + } + } + return out; +} + +async function sourcesNearLimit() { + const maand = `${new Date().toISOString().slice(0, 7)}-01T00:00`; + const calls = new Map((await apiCallsByProvider(maand)).map(r => [r.providerId, r.calls])); + const out = []; + for (const r of await listProviders()) { + if (!r.isActive || !calls.get(r.id)) continue; + const p = await getProvider(r.key); + if (!p || !p.callsPerMonth) continue; + const n = calls.get(r.id); + const deel = n / p.callsPerMonth; + if (deel < CONFIG.API_USAGE.WARN_SHARE) continue; + out.push({ + id: `usage:${p.key}`, + severity: deel >= 1 ? 'problem' : 'attention', + route: TARGETS.PROVIDER_KEYS.route, + tab: targetTab(TARGETS.PROVIDER_KEYS), + hash: targetHash(TARGETS.PROVIDER_KEYS), + label: t(deel >= 1 ? 'attention.usageOver' : 'attention.usageNear', { naam: p.name, calls: n, limiet: p.callsPerMonth }), + }); + } + return out; +} + +async function sourcesWithoutRate() { + const configs = new Map(); + for (const r of await listProviders()) configs.set(r.id, await getProvider(r.key)); + const wanted = new Map(); + for (const cel of await listPriceSource()) { + const p = configs.get(cel.providerId); + if (cel.rank == null || !p || !p.isActive) continue; + for (const e of (p ? p.endpoints : []).filter(x => SOURCE_ENDPOINTS[cel.capability].includes(x.capability))) { + const currency = e.quoteCurrency; + if (!currency || currency === baseCurrency()) continue; + wanted.set(`${p.key}|${currency}`, { provider: p, currency }); + } + } + const out = []; + for (const { provider, currency } of wanted.values()) { + if (await rateSourceFor(currency)) continue; + out.push({ + id: `fx:${provider.key}:${currency}`, + severity: 'problem', + route: TARGETS.SOURCE_PROPOSALS.route, + tab: targetTab(TARGETS.SOURCE_PROPOSALS), + hash: targetHash(TARGETS.SOURCE_PROPOSALS), + label: `${provider.name}: ${t('integrations.noRateSource', { valuta: currency })}`, + }); + } + return out; +} + +const GAPS_TTL_MS = 10 * 60 * 1000; +let gapsCache = { at: 0, total: 0 }; +if (typeof document !== 'undefined') { + document.addEventListener('price-gaps-changed', () => { gapsCache = { at: 0, total: 0 }; }); +} +async function priceGaps() { + if (Date.now() - gapsCache.at > GAPS_TTL_MS) { + try { gapsCache = { at: Date.now(), total: (await listPriceGaps()).total }; } + catch { return []; } + } + if (!gapsCache.total) return []; + return [{ + id: 'price:gaps', + severity: 'attention', + route: TARGETS.PRICE_HISTORY.route, + tab: targetTab(TARGETS.PRICE_HISTORY), + hash: targetHash(TARGETS.PRICE_HISTORY), + label: t('attention.priceGaps', { n: gapsCache.total }), + }]; +} + +const ACTIE_LABEL = { + reconcile: n => t('attention.actionsReconcilePrefix') + n + t('attention.actionsReconcileSuffix'), + interest: n => t('attention.actionsInterestPrefix') + n, + valuation: n => t('attention.actionsValuationPrefix') + n, + ai_proposal: n => t('attention.actionsAiPrefix') + n, + reservation: n => t('attention.actionsReservationPrefix') + n, +}; +async function openActions() { + const meldingen = await currentNotifications(); + return meldingen.filter(n => ACTIE_LABEL[n.kind] && n.count > 0).map(n => ({ + id: `actions:${n.kind}`, + severity: 'attention', + route: NOTIFICATION_TARGETS[n.kind].route, + tab: targetTab(NOTIFICATION_TARGETS[n.kind]), + hash: targetHash(NOTIFICATION_TARGETS[n.kind]), + count: n.count, + label: ACTIE_LABEL[n.kind](n.count), + })); +} + +function backupReminder() { + const last = appSetting('last_backup_at'); + const dayMs = 86400000; + if (!last) { + return [{ id: 'backup:never', route: null, severity: 'attention', hash: targetHash(TARGETS.PORTFOLIOS), + label: t('attention.backupNever') }]; + } + const days = Math.floor((Date.now() - new Date(last).getTime()) / dayMs); + if (Number.isFinite(days) && days >= CONFIG.BACKUP.REMIND_AFTER_DAYS) { + return [{ id: 'backup:stale', route: null, severity: 'attention', hash: targetHash(TARGETS.PORTFOLIOS), + label: t('attention.backupStalePrefix') + days + t('attention.backupStaleSuffix') }]; + } + return []; +} + +async function aiActivity() { + const gezien = await getPref('ai', 'seen_call_id', 0); + const { count } = await countAiToolCallsSince(Number(gezien) || 0); + if (count <= 0) return []; + return [{ + id: 'ai:activity', + severity: 'attention', + route: TARGETS.AI_ACTIVITY.route, + tab: targetTab(TARGETS.AI_ACTIVITY), + hash: targetHash(TARGETS.AI_ACTIVITY), + count, + label: t('attention.aiActivityPrefix') + count + t('attention.aiActivitySuffix'), + }]; +} + +async function licenseChanged() { + if (!await licenseChangedSinceSeen()) return []; + return [{ + id: 'license:changed', + severity: 'attention', + route: TARGETS.LICENSE.route, + tab: targetTab(TARGETS.LICENSE), + hash: targetHash(TARGETS.LICENSE), + label: t('attention.licenseChanged'), + }]; +} + +let registered = false; +function healthCheckReminder() { + const last = appSetting('last_health_check_at'); + const days = last ? Math.floor((Date.now() - new Date(last).getTime()) / 86400000) : null; + if (!last) { + return [{ id: 'health:never', route: null, severity: 'attention', hash: targetHash(TARGETS.HEALTH), + label: t('attention.healthNever') }]; + } + if (Number.isFinite(days) && days >= CONFIG.HEALTH_CHECK.REMIND_AFTER_DAYS) { + return [{ id: 'health:stale', route: null, severity: 'attention', hash: targetHash(TARGETS.HEALTH), + label: t('attention.healthStalePrefix') + days + t('attention.healthStaleSuffix') }]; + } + return []; +} + +export function registerAttentionSources() { + if (registered) return; + registered = true; + registerAttention(integrationsNeedingKey); + registerAttention(sourcesWithoutRate); + registerAttention(sourcesNearLimit); + registerAttention(priceGaps); + registerAttention(openActions); + registerAttention(backupReminder); + registerAttention(healthCheckReminder); + registerAttention(aiActivity); + registerAttention(licenseChanged); +} diff --git a/client/ui/attention.js b/client/ui/attention.js new file mode 100644 index 0000000..69bb30b --- /dev/null +++ b/client/ui/attention.js @@ -0,0 +1,160 @@ +// Generated, do not edit. sha256:08b414b8e046583bdfdf9c3302134a9e220a7c451265f464b9ed56dca2d3d460 +import { getPref, setPref } from '../data/preferences.js'; +import { CONFIG, targetHash } from '../../shared/config.js'; +import { esc } from './escape.js'; + +const producers = []; +let cache = []; +let cacheKey = ''; + +const PREF_SCOPE = 'attention'; +const PREF_ACT = 'activated'; +const PREF_SEEN = 'seen'; +const PREF_DAY = 'day'; +const activated = new Map(); +const seen = new Set(); +let loaded = false; +let dayN = 0; +const stateKey = a => `${a.id}|${a.severity || 'attention'}`; + +function todayStr() { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +async function ensureLoaded() { + if (loaded) return; + loaded = true; + try { + const act = await getPref(PREF_SCOPE, PREF_ACT, {}); + if (act && typeof act === 'object' && !Array.isArray(act)) { + for (const [id, s] of Object.entries(act)) activated.set(String(id), Number(s) || 0); + } + } catch { } + try { + const s = await getPref(PREF_SCOPE, PREF_SEEN, []); + if (Array.isArray(s)) for (const k of s) seen.add(String(k)); + } catch { } + let day = { date: '', n: 0 }; + try { const d = await getPref(PREF_SCOPE, PREF_DAY, null); if (d && typeof d === 'object') day = d; } catch { } + const today = todayStr(); + if (day.date !== today) { day = { date: today, n: (Number(day.n) || 0) + 1 }; saveDay(day); } + dayN = Number(day.n) || 1; +} +async function saveDay(day) { try { await setPref(PREF_SCOPE, PREF_DAY, day); } catch { } } +async function saveActivated() { + try { await setPref(PREF_SCOPE, PREF_ACT, Object.fromEntries(activated)); } catch { } +} +async function saveSeen() { + try { await setPref(PREF_SCOPE, PREF_SEEN, [...seen]); } catch { } +} + +function fadeFor(a) { + const k = stateKey(a); + if (!activated.has(k)) return 'hidden'; + const age = dayN - activated.get(k); + if (age >= CONFIG.ATTENTION.HIDE_AFTER_DAYS) return 'hidden'; + if (age >= CONFIG.ATTENTION.FADE_AFTER_DAYS || seen.has(k)) return 'dim'; + return 'full'; +} + +function currentRouteKey() { + const h = (typeof location !== 'undefined' ? location.hash : '') || ''; + return h.replace(/^#\/?/, '') || 'home'; +} + +function opDezePlek(a, here) { + const hereHead = here.split('/')[0]; + if (!a.tab) return a.route === here || a.route === hereHead; + const plek = `${a.route}/${a.tab}`; + return here === plek || here.startsWith(`${plek}/`); +} + +export function registerAttention(producer) { + if (typeof producer === 'function') producers.push(producer); + return () => { + const i = producers.indexOf(producer); + if (i >= 0) producers.splice(i, 1); + }; +} + +export async function refreshAttention() { + const collected = []; + for (const p of producers) { + try { + const items = await p(); + if (Array.isArray(items)) collected.push(...items.filter(Boolean)); + } catch { } + } + const norm = collected.map(a => ({ + id: String(a.id), + severity: a.severity || 'attention', + route: a.route ?? null, + tab: a.tab ?? null, + hash: a.hash || (a.route ? targetHash({ route: a.route }) : null), + label: a.label || '', + count: Number.isFinite(a.count) ? a.count : null, + })).sort((x, y) => (x.route || '').localeCompare(y.route || '') || x.id.localeCompare(y.id)); + + cache = norm; + + await ensureLoaded(); + const here = currentRouteKey(); + const liveKeys = new Set(norm.map(stateKey)); + let activatedChanged = false, seenChanged = false; + for (const a of norm) { + const k = stateKey(a); + if (activated.has(k)) { + if (a.route !== null && opDezePlek(a, here) && !seen.has(k)) { seen.add(k); seenChanged = true; } + continue; + } + if (a.route === null || !opDezePlek(a, here)) { + activated.set(k, dayN); activatedChanged = true; + } + } + for (const k of [...activated.keys()]) { + if (!liveKeys.has(k)) { activated.delete(k); activatedChanged = true; } + } + for (const k of [...seen]) { + if (!liveKeys.has(k)) { seen.delete(k); seenChanged = true; } + } + if (activatedChanged) saveActivated(); + if (seenChanged) saveSeen(); + + const actKey = norm.filter(a => activated.has(stateKey(a))).map(a => `${stateKey(a)}:${fadeFor(a)}`).sort().join(','); + const key = JSON.stringify(norm) + '|' + actKey; + const changed = key !== cacheKey; + cacheKey = key; + if (changed && typeof document !== 'undefined' && typeof document.dispatchEvent === 'function') { + document.dispatchEvent(new CustomEvent('attention-changed')); + } + return cache; +} + +export function attentionItems() { return cache; } + +export function generalAttention() { + return cache.filter(a => a.route === null).map(a => ({ ...a, fade: fadeFor(a) })).filter(a => a.fade !== 'hidden'); +} + +export function attentionForRoute(route) { + return cache.filter(a => a.route === route) + .map(a => ({ ...a, fade: fadeFor(a) })).filter(a => a.fade !== 'hidden'); +} +export function hasAttentionForRoute(route) { return attentionForRoute(route).length > 0; } + +export function attentionHasProblem(items) { + return (items || []).some(a => a.severity === 'problem'); +} + +export function attentionDimmed(items) { + return !!(items && items.length) && items.every(a => a.fade === 'dim'); +} + +export function attentionDotHtml(items) { + if (!items || !items.length) return ''; + const hint = items.map(a => a.label).filter(Boolean).join(' \u00b7 '); + const dim = attentionDimmed(items); + const stuk = attentionHasProblem(items); + return ` `; +} diff --git a/client/ui/aurora.js b/client/ui/aurora.js new file mode 100644 index 0000000..d594f7d --- /dev/null +++ b/client/ui/aurora.js @@ -0,0 +1,187 @@ +// Generated, do not edit. sha256:8ea45396bfd27f4dbf4c416aa596846fa428d7394799796a19ce526c30b7ada7 +import { CONFIG } from '../../shared/config.js'; + +const BLOBS = Object.freeze([ + Object.freeze({ cls: 'blob-a', x: [0, 42], y: [0, 26], firstMs: 4000 }), + Object.freeze({ cls: 'blob-b', x: [-52, 0], y: [0, 32], firstMs: 14000 }), + Object.freeze({ cls: 'blob-c', x: [-38, 38], y: [-38, -4], firstMs: 26000, sink: 0.3 }), +]); +const SINK_VH = 80; + +export const BAND_LAYERS = Object.freeze([ + Object.freeze({ w: 1400, h: 1800, count: 4, len: [700, 1300], width: [40, 70], alpha: [0.2, 0.4], blur: 12, dur: 21, opacity: 0.75, tilt: 10, seed: 19 }), + Object.freeze({ w: 1800, h: 2100, count: 3, len: [900, 1600], width: [110, 180], alpha: [0.12, 0.26], blur: 28, dur: 17, opacity: 0.9, tilt: 12, seed: 29 }), + Object.freeze({ w: 2200, h: 2400, count: 2, len: [1200, 1900], width: [300, 450], alpha: [0.08, 0.16], blur: 64, dur: 14, opacity: 1, tilt: 14, seed: 43 }), +]); +const FADE_MS = 5200; + +const between = (rnd, a, b) => a + rnd() * (b - a); + +export function tempoOf(key) { + return CONFIG.GLOW_TEMPO[key] || CONFIG.GLOW_TEMPO.calm; +} + +export function motionMode({ profile, glow, motion, reducedMotion = false, hidden = false } = {}) { + if (profile !== 'glass' || glow === 'off' || reducedMotion || hidden) return 'off'; + return motion === 'step' || motion === 'always' ? motion : 'off'; +} + +export function nextDelayMs(tempo, rnd = Math.random) { + return Math.round((tempo.MOVE_S + between(rnd, tempo.PAUSE_MIN_S, tempo.PAUSE_MAX_S)) * 1000); +} + +export function driftTransform(area, rnd = Math.random) { + const sinks = area.sink ? rnd() < area.sink : false; + const x = between(rnd, area.x[0], area.x[1]).toFixed(1); + const y = sinks ? SINK_VH.toFixed(1) : between(rnd, area.y[0], area.y[1]).toFixed(1); + const turn = between(rnd, -16, 16).toFixed(1); + const sx = between(rnd, 0.85, 1.2).toFixed(3); + const sy = between(rnd, 0.85, 1.2).toFixed(3); + return `translate(${x}vw,${y}vh) rotate(${turn}deg) scale(${sx},${sy})`; +} + +export function nextCore(cores, i, rnd = Math.random, chance = CONFIG.GLOW_CORE_CHANCE) { + if (cores[i]) return false; + if (cores.some((c, n) => c && n !== i)) return false; + return rnd() < chance; +} + +export function showerMs(tempo, on, rnd = Math.random) { + return Math.round((on ? tempo.MOVE_S * 2 : between(rnd, tempo.PAUSE_MIN_S, tempo.PAUSE_MAX_S)) * 1000); +} + +function seeded(a) { + return () => { + a |= 0; a = a + 0x6D2B79F5 | 0; + let t = Math.imul(a ^ a >>> 15, 1 | a); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} + +export function bandTile({ w, h, count, len, width, alpha, blur, seed }) { + const rnd = seeded(seed); + const halo = blur * 3, pad = 2 + halo; + let shapes = ''; + for (let i = 0; i < count; i++) { + const sw = between(rnd, width[0], width[1]); + const x = between(rnd, pad, w - pad - sw), y = between(rnd, 0, h); + const L = between(rnd, len[0], len[1]), a = between(rnd, alpha[0], alpha[1]); + for (const dy of [0, -h, h]) { + if (dy < 0 && y + L + halo <= h) continue; + if (dy > 0 && y - halo >= 0) continue; + shapes += ``; + } + } + const svg = `` + + '' + + '' + + `` + + `${shapes}`; + return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`; +} + +const timers = []; +let shapeTimer = null; +let showerTimer = null; +let fadeTimer = null; +let bandsDrawn = false; +let wired = false; +let last = {}; + +function drawBands(box) { + if (bandsDrawn) return; + bandsDrawn = true; + box.querySelectorAll('.band').forEach((el, i) => { + const spec = BAND_LAYERS[i]; + if (!spec) return; + el.style.maskImage = el.style.webkitMaskImage = bandTile(spec); + el.style.maskSize = el.style.webkitMaskSize = `${spec.w}px ${spec.h}px`; + el.style.setProperty('--tile-h', `${spec.h}px`); + el.style.setProperty('--band-dur', `${spec.dur}s`); + el.style.setProperty('--tilt', `${spec.tilt}deg`); + el.style.opacity = String(spec.opacity); + }); +} + +function shower(box, tempo, on) { + clearTimeout(showerTimer); clearTimeout(fadeTimer); + box.classList.add('is-falling'); + box.classList.toggle('is-shown', on); + if (!on) fadeTimer = setTimeout(() => box.classList.remove('is-falling'), FADE_MS); + showerTimer = setTimeout(() => shower(box, tempo, !on), showerMs(tempo, on)); +} + +function applyBands(box, bands, mode, tempo) { + clearTimeout(showerTimer); clearTimeout(fadeTimer); + box.classList.remove('is-falling'); + if (bands) drawBands(box); + box.classList.toggle('is-shown', bands); + if (!bands) return; + if (mode === 'always') box.classList.add('is-falling'); + else if (mode === 'step') shower(box, tempo, true); +} + +function elements() { + return BLOBS.map(b => document.querySelector(`.aurora .${b.cls}`)); +} + +function coresOf(els) { return els.map(el => !!el?.classList.contains('is-core')); } + +function reshape(els, i) { + const el = els[i]; + if (el) el.classList.toggle('is-core', nextCore(coresOf(els), i)); +} + +function stopAll(els, keepTransform) { + timers.forEach(t => clearTimeout(t)); + timers.length = 0; + clearTimeout(shapeTimer); + for (const el of els) { + if (!el) continue; + el.classList.remove('is-core'); + if (!keepTransform) el.style.transform = ''; + } +} + +export function applyAurora(settings = last) { + last = settings; + const root = document.documentElement; + const els = elements(); + if (!els.some(Boolean)) return; + const tempo = tempoOf(settings.glow_tempo); + root.style.setProperty('--drift-dur', `${tempo.MOVE_S}s`); + root.style.setProperty('--drift-mult', String(tempo.ALWAYS_MULT)); + const mode = motionMode({ + profile: settings.profile, glow: settings.glow, motion: settings.motion, + reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches, + hidden: document.hidden, + }); + const bands = settings.glowshape === 'bands'; + const box = document.querySelector('.aurora .bands'); + if (box) applyBands(box, bands, mode, tempo); + const blobMode = bands ? 'off' : mode; + stopAll(els, blobMode === 'step'); + if (blobMode === 'step') { + BLOBS.forEach((b, i) => { + const tick = () => { + if (els[i]) els[i].style.transform = driftTransform(b); + reshape(els, i); + timers[i] = setTimeout(tick, nextDelayMs(tempo)); + }; + timers[i] = setTimeout(tick, b.firstMs); + }); + } else if (blobMode === 'always') { + const tick = () => { + reshape(els, Math.floor(Math.random() * els.length)); + shapeTimer = setTimeout(tick, nextDelayMs(tempo) / els.length); + }; + shapeTimer = setTimeout(tick, 20000); + } + if (!wired) { + wired = true; + document.addEventListener('visibilitychange', () => applyAurora()); + matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => applyAurora()); + } +} diff --git a/client/ui/blur.js b/client/ui/blur.js new file mode 100644 index 0000000..b265e22 --- /dev/null +++ b/client/ui/blur.js @@ -0,0 +1,56 @@ +// Generated, do not edit. sha256:3f4bf112e28f009a738272dd18bef14d5e6736a31c2f3e1f6e434733f909d6ea +import { appSetting, setAppSetting } from '../app-settings.js'; +import { t } from '../i18n/index.js'; +import { EYE, EYE_OFF } from './icons.js'; +import { CONFIG } from '../../shared/config.js'; + +let idleTimer = null; +let btn = null; + +function isOn() { return appSetting('blur') === 1; } + +export const isBlurred = () => isOn(); + +export const unblur = () => setOn(false); + +function apply(on) { + document.documentElement.dataset.blur = on ? 'on' : 'off'; + if (btn) { + btn.innerHTML = on ? EYE_OFF : EYE; + btn.classList.toggle('is-on', on); + } + document.dispatchEvent(new CustomEvent('blur-changed', { detail: { on } })); +} + +function armIdle() { + clearTimeout(idleTimer); + const min = appSetting('auto_blur_min'); + if (min >= CONFIG.AUTO_BLUR.NEVER_MIN) return; + idleTimer = setTimeout(() => { setOn(true); }, min * 60 * 1000); +} + +async function setOn(on) { + apply(on); + try { await setAppSetting('blur', on ? 1 : 0); } catch { } + if (on) clearTimeout(idleTimer); else armIdle(); +} + +function onActivity() { if (!isOn()) armIdle(); } + +export function initPrivacyBlur(hostEl) { + btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'statusbar-eye'; + btn.title = t('blur.toggle'); + btn.setAttribute('aria-label', t('blur.toggle')); + btn.onclick = () => { setOn(!isOn()); }; + hostEl.appendChild(btn); + + apply(isOn()); + armIdle(); + + for (const ev of ['pointerdown', 'keydown', 'wheel', 'touchstart']) { + document.addEventListener(ev, onActivity, { passive: true }); + } + document.addEventListener('settings-changed', () => { if (!isOn()) armIdle(); }); +} diff --git a/client/ui/chart-setup.js b/client/ui/chart-setup.js new file mode 100644 index 0000000..449b5d3 --- /dev/null +++ b/client/ui/chart-setup.js @@ -0,0 +1,28 @@ +// Generated, do not edit. sha256:39cef5d1d7c6691e4a7d1766d7878d7b1a1aa62aaa034e47b2930230f92c8080 +import { CONFIG } from '../../shared/config.js'; +import { t } from '../i18n/index.js'; +import { loadCharting as loadSharedCharting, readPalette as readSharedPalette, tintSeries } from './charting.js'; + +export function loadCharting() { + const c = CONFIG.CHART_LIBS; + if (!c || !c.CHARTJS || !c.HAMMER || !c.ZOOM) { + throw new Error('Chart libs are missing in shared/config.js (CHART_LIBS).'); + } + return loadSharedCharting(c, { failed: src => t('integrations.scriptLoadFailed', { src }) }); +} + +export function readPalette() { + const cs = getComputedStyle(document.documentElement); + const v = n => cs.getPropertyValue(n).trim(); + const shared = readSharedPalette(); + const [, mid, faint] = tintSeries(shared.accent, 3, { min: 0.45 }); + return { + ...shared, + border: v('--border'), accentMid: mid, accentFaint: faint, blue: v('--sig-blue'), + red: v('--sig-red'), sigGreen: v('--sig-green'), sigYellow: v('--sig-yellow'), sigRed: v('--sig-red'), + greenZone: v('--sig-green-zone'), yellowZone: v('--sig-yellow-zone'), redZone: v('--sig-red-zone'), + ringSeam: parseFloat(v('--ring-seam')) || 0, + ringSheen: v('--ring-sheen') === '1', + hairline: v('--hairline'), rimBottom: v('--rim-bottom'), accentGlow: v('--accent-glow'), + }; +} diff --git a/client/ui/charting.js b/client/ui/charting.js new file mode 100644 index 0000000..48b46f3 --- /dev/null +++ b/client/ui/charting.js @@ -0,0 +1,48 @@ +// Generated, do not edit. sha256:ee03300e55aca9bafacba37ec01daf41bd49c8f2f20ec64a315107a3b816d0b3 +let chartingPromise = null; + +export function loadCharting(libs, { failed = (src) => `Cannot load ${src}.` } = {}) { + if (chartingPromise) return chartingPromise; + const c = libs; + const addScript = src => new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = src; s.async = false; + s.onload = resolve; s.onerror = () => reject(new Error(failed(src))); + document.head.appendChild(s); + }); + chartingPromise = (async () => { + if (!window.Chart) await addScript(c.CHARTJS); + if (!window.Hammer) await addScript(c.HAMMER); + await addScript(c.ZOOM); + return window.Chart; + })(); + chartingPromise.catch(() => { chartingPromise = null; }); + return chartingPromise; +} + +export function hexToRgb(hex) { + const s = String(hex || '').trim().replace(/^#/, ''); + const full = s.length === 3 ? s.split('').map(c => c + c).join('') : s; + if (!/^[0-9a-fA-F]{6}$/.test(full)) return null; + return { r: parseInt(full.slice(0, 2), 16), g: parseInt(full.slice(2, 4), 16), b: parseInt(full.slice(4, 6), 16) }; +} + +export function tintSeries(hex, count, { min = 0.28, max = 1 } = {}) { + const n = Math.max(0, Math.floor(count) || 0); + if (n === 0) return []; + const rgb = hexToRgb(hex) || { r: 128, g: 128, b: 128 }; + const step = n === 1 ? 0 : (max - min) / (n - 1); + return Array.from({ length: n }, (_, i) => `rgba(${rgb.r},${rgb.g},${rgb.b},${Math.round((max - i * step) * 1000) / 1000})`); +} + +export function readPalette() { + const cs = getComputedStyle(document.documentElement); + const v = n => cs.getPropertyValue(n).trim(); + const accent = v('--accent'); + return { + text: v('--text-primary'), textSec: v('--text-sec'), textTer: v('--text-ter'), grid: v('--border'), + accent, accentSoft: v('--accent-soft'), pos: v('--pos'), neg: v('--neg'), surface: v('--bg-elevated'), + tooltipBg: v('--bg-overlay'), borderMid: v('--border-mid'), + series: (n) => tintSeries(accent, n, { min: 0.45 }), + }; +} diff --git a/client/ui/copy-button.js b/client/ui/copy-button.js new file mode 100644 index 0000000..2819022 --- /dev/null +++ b/client/ui/copy-button.js @@ -0,0 +1,43 @@ +// Generated, do not edit. sha256:2c4f9a283b8c1358df232fdf7f2ef98f6443ae5dd830e97908059c85c34cc9d7 +import { t } from '../i18n/index.js'; +import { esc } from './escape.js'; + +const GLYPH = '⧉'; + +export function copyButton(text, title) { + if (text == null || text === '') return ''; + const ttl = esc(title || t('action.copy')); + return ``; +} + +const CLEAR_AFTER_MS = 20000; +let clearTimer = null; +let pendingClear = false; + +function clearClipboard() { + navigator.clipboard.writeText('') + .then(() => { pendingClear = false; }) + .catch(() => { pendingClear = true; window.addEventListener('focus', onFocusClear, { once: true }); }); +} +function onFocusClear() { if (pendingClear) clearClipboard(); } +function scheduleClear() { + clearTimeout(clearTimer); + pendingClear = false; + clearTimer = setTimeout(clearClipboard, CLEAR_AFTER_MS); +} + +let installed = false; +function install() { + if (installed) return; + installed = true; + document.addEventListener('click', e => { + const btn = e.target.closest('[data-copy]'); + if (!btn) return; + e.preventDefault(); + e.stopPropagation(); + navigator.clipboard.writeText(btn.dataset.copy) + .then(() => { btn.classList.add('is-copied'); setTimeout(() => btn.classList.remove('is-copied'), 1200); scheduleClear(); }) + .catch(() => {}); + }, true); +} +install(); diff --git a/client/ui/dialog.js b/client/ui/dialog.js new file mode 100644 index 0000000..58ccfdb --- /dev/null +++ b/client/ui/dialog.js @@ -0,0 +1,303 @@ +// Generated, do not edit. sha256:26856efc0d14b9a73e26fd078294b6d0c3f7eebff4e23cc52adf3839ca9c9ebe +import { esc } from './escape.js'; + +const CLOSE = ''; +const DEFAULT_LABELS = Object.freeze({ + ok: 'OK', cancel: 'Cancel', close: 'Close', next: 'Next', back: 'Back', finish: 'Finish', skip: 'Skip', +}); + +let labels = { ...DEFAULT_LABELS }; +let closeIcon = CLOSE; +let onDetail = null; + +export function configureDialog({ labels: l = null, closeIcon: icon = null, onDetail: hook } = {}) { + labels = { ...DEFAULT_LABELS, ...(l || {}) }; + closeIcon = icon || CLOSE; + onDetail = typeof hook === 'function' ? hook : null; +} + +let overlayEl = null; +let activeResolve = null; +let overlayIsDetail = false; + +export function isDialogOpen(el = null) { + if (topEl) return true; + if (!overlayEl) return false; + return !(el && overlayEl.contains(el)); +} + +let prevOverflow = null; +let prevPadding = null; + +function lockPage() { + if (prevOverflow !== null) return; + const el = document.documentElement; + const gutter = window.innerWidth - el.clientWidth; + prevOverflow = el.style.overflow; + prevPadding = el.style.paddingRight; + el.style.overflow = 'hidden'; + if (gutter > 0) el.style.paddingRight = `${gutter}px`; +} + +function unlockPage() { + if (prevOverflow === null) return; + const el = document.documentElement; + el.style.overflow = prevOverflow; + el.style.paddingRight = prevPadding; + prevOverflow = null; + prevPadding = null; +} + +function settle(result) { + if (topEl) settleTop(false); + document.removeEventListener('keydown', onKey, true); + if (overlayEl) { overlayEl.remove(); overlayEl = null; } + overlayIsDetail = false; + unlockPage(); + const r = activeResolve; activeResolve = null; + if (r) r(result); +} +function onKey(e) { + if (!overlayEl || topEl) return; + if (overlayIsDetail && e.target && typeof e.target.matches === 'function' && overlayEl.contains(e.target) + && e.target.matches('input, select, textarea')) return; + if (e.key === 'Escape') { e.preventDefault(); settle(false); } +} + +let topEl = null; +let topResolve = null; +let topReturn = null; + +function settleTop(result) { + document.removeEventListener('keydown', onKeyTop, true); + if (topEl) { topEl.remove(); topEl = null; } + const r = topResolve; topResolve = null; + if (topReturn && topReturn.isConnected) topReturn.focus(); + topReturn = null; + if (r) r(result); +} +function onKeyTop(e) { + if (topEl && e.key === 'Escape') { e.preventDefault(); settleTop(false); } +} + +export function confirmOver({ title, message, confirmLabel, cancelLabel, danger } = {}) { + if (!overlayEl) return confirm({ title, message, confirmLabel, cancelLabel, danger }); + return openTop({ title, bodyHtml: `
    ${esc(message)}
    `, + showCancel: true, confirmLabel, cancelLabel, danger }); +} + +function openTop({ title, bodyHtml, showCancel, confirmLabel, cancelLabel, danger, collect = null }) { + if (topEl) settleTop(false); + return new Promise(resolve => { + topResolve = resolve; + topReturn = document.activeElement; + topEl = document.createElement('div'); + topEl.className = 'dialog-overlay dialog-overlay--stacked'; + topEl.innerHTML = ` +
    + ${title ? `
    ${esc(title)}
    ` : ''} + ${bodyHtml} +
    + ${showCancel ? `` : ''} + +
    +
    `; + const outcome = ok => (collect ? (ok ? collect(topEl) : null) : ok); + topEl.addEventListener('click', e => { + const b = e.target.closest('[data-dlg]'); + if (b) settleTop(outcome(b.dataset.dlg === 'ok')); + }); + if (collect) { + topEl.addEventListener('keydown', e => { + if (e.key === 'Enter' && e.target.matches('input[data-dlg-field]')) { e.preventDefault(); settleTop(outcome(true)); } + }); + } + document.body.appendChild(topEl); + document.addEventListener('keydown', onKeyTop, true); + (topEl.querySelector('[data-dlg-field]') || topEl.querySelector('[data-dlg="ok"]')).focus(); + }); +} + +function open({ title, message, bodyHtml, wide, confirmLabel, cancelLabel, danger, showCancel, showOk = true, + closeCross = false, titleAside = false }) { + if (overlayEl) settle(false); + return new Promise(resolve => { + activeResolve = resolve; + const confirmCls = danger ? 'btn-danger' : 'btn-primary'; + overlayEl = document.createElement('div'); + overlayEl.className = 'dialog-overlay'; + const actions = showCancel || showOk ? ` +
    + ${showCancel ? `` : ''} + ${showOk ? `` : ''} +
    ` : ''; + const body = bodyHtml != null + ? `
    ${bodyHtml}
    ` + : `
    ${esc(message)}
    `; + const head = title + ? `
    + ${esc(title)} + ${titleAside ? '' : ''} +
    ` + : ''; + const cross = closeCross + ? `` + : ''; + overlayEl.innerHTML = ` + `; + const backdropCloses = !(showOk || showCancel || closeCross); + overlayEl.addEventListener('click', e => { + if (e.target === overlayEl) { if (backdropCloses) settle(false); return; } + const b = e.target.closest('[data-dlg]'); + if (b) settle(b.dataset.dlg === 'ok'); + }); + document.body.appendChild(overlayEl); + lockPage(); + document.addEventListener('keydown', onKey, true); + (overlayEl.querySelector('[data-dlg="ok"]') || overlayEl.querySelector('.dialog')).focus(); + }); +} + +export function stepper({ title, steps, state = {}, skipLabel, finishLabel, hero = '' } = {}) { + if (overlayEl) settle(false); + return new Promise(resolve => { + activeResolve = resolve; + const visible = () => steps.filter(s => !(s.skip && s.skip(state))); + let i = 0; + overlayEl = document.createElement('div'); + overlayEl.className = 'dialog-overlay dialog-overlay--focus'; + overlayEl.innerHTML = ` + `; + const body = overlayEl.querySelector('.dialog-body'); + const actions = overlayEl.querySelector('.stepper-actions'); + const draw = () => { + const list = visible(); + i = Math.min(i, list.length - 1); + const step = list[i]; + const last = i === list.length - 1; + const ready = !step.ready || step.ready(state); + body.innerHTML = `${step.title ? `
    ${esc(step.title)}
    ` : ''}${step.html(state)}`; + actions.innerHTML = ` + + ${i > 0 ? `` : ''} + `; + const focus = overlayEl.querySelector('[data-step="next"]:not([disabled])') || overlayEl.querySelector('.dialog'); + focus.focus(); + }; + overlayEl.addEventListener('click', e => { + const set = e.target.closest('[data-step-set]'); + if (set) { state[set.dataset.stepSet] = set.dataset.value; draw(); return; } + const toggle = e.target.closest('[data-step-toggle]'); + if (toggle) { + const key = toggle.dataset.stepToggle; + const v = toggle.dataset.value; + const list = Array.isArray(state[key]) ? state[key] : []; + state[key] = list.includes(v) ? list.filter(x => x !== v) : [...list, v]; + draw(); + return; + } + const b = e.target.closest('[data-step]'); + if (!b || b.disabled) return; + if (b.dataset.step === 'skip') settle(false); + else if (b.dataset.step === 'back') { i = Math.max(0, i - 1); draw(); } + else if (i === visible().length - 1) settle(state); + else { i += 1; draw(); } + }); + document.body.appendChild(overlayEl); + lockPage(); + document.addEventListener('keydown', onKey, true); + draw(); + }); +} + +export function confirm({ title, message, confirmLabel, cancelLabel, danger } = {}) { + if (overlayEl && overlayIsDetail) return confirmOver({ title, message, confirmLabel, cancelLabel, danger }); + return open({ title, message, confirmLabel, cancelLabel, danger, showCancel: true }); +} + +export function alert({ title, message, confirmLabel } = {}) { + if (overlayEl && overlayIsDetail) { + return openTop({ title, bodyHtml: `
    ${esc(message)}
    `, showCancel: false, confirmLabel }); + } + return open({ title, message, confirmLabel, danger: false, showCancel: false }); +} + +export function notice({ title, message } = {}) { + return open({ title, message, showCancel: false, showOk: false }); +} + +export function detail({ title, html, size, titleHtml, asideHtml } = {}) { + const done = open({ title, bodyHtml: html ?? '', wide: true, showCancel: false, showOk: false, + closeCross: true, titleAside: true }); + overlayIsDetail = true; + const d = overlayEl.querySelector('.dialog'); + if (size === 'xl') d.classList.add('dialog--xl'); + if (titleHtml) d.querySelector('.dialog-title-text').innerHTML = titleHtml; + if (asideHtml) d.querySelector('[data-dialog-aside]').innerHTML = asideHtml; + if (onDetail) onDetail(d, done); + return done; +} + +function fieldsHtml(fields) { + return `
    + ${fields.map(f => ` +
    + + ${f.type === 'textarea' + ? `` + : f.type === 'select' + ? `` + : ``} +
    `).join('')} +
    `; +} +const fieldValues = host => { + const out = {}; + host.querySelectorAll('[data-dlg-field]').forEach(i => { out[i.dataset.dlgField] = i.value; }); + return out; +}; + +export function form({ title, fields = [], confirmLabel, cancelLabel } = {}) { + if (overlayEl && overlayIsDetail) { + return openTop({ title, bodyHtml: fieldsHtml(fields), showCancel: true, confirmLabel, cancelLabel, collect: fieldValues }); + } + if (overlayEl) settle(false); + return new Promise(resolve => { + activeResolve = resolve; + overlayEl = document.createElement('div'); + overlayEl.className = 'dialog-overlay'; + overlayEl.innerHTML = ` + `; + const values = () => fieldValues(overlayEl); + overlayEl.addEventListener('click', e => { + if (e.target === overlayEl) return; + const b = e.target.closest('[data-dlg]'); + if (b) settle(b.dataset.dlg === 'ok' ? values() : null); + }); + overlayEl.addEventListener('keydown', e => { + if (e.key === 'Enter' && e.target.matches('input[data-dlg-field]')) { e.preventDefault(); settle(values()); } + }); + document.body.appendChild(overlayEl); + lockPage(); + document.addEventListener('keydown', onKey, true); + const first = overlayEl.querySelector('[data-dlg-field]'); + if (first) first.focus(); + }); +} diff --git a/client/ui/empty-state.js b/client/ui/empty-state.js new file mode 100644 index 0000000..00e996d --- /dev/null +++ b/client/ui/empty-state.js @@ -0,0 +1,8 @@ +// Generated, do not edit. sha256:8efb043edf9aeef15b56ebe14e6ecd130f9141c2173576297d6d687a1b9c7d88 +import { esc } from './escape.js'; + +export function emptyStateHtml(text, { why = '', href = '', linkText = '' } = {}) { + const reden = why ? ` ${esc(why)}` : ''; + const link = href && linkText ? ` ${esc(linkText)}` : ''; + return `
    ${esc(text)}${reden}${link}
    `; +} diff --git a/client/ui/error-panel.js b/client/ui/error-panel.js new file mode 100644 index 0000000..d1d7f2a --- /dev/null +++ b/client/ui/error-panel.js @@ -0,0 +1,18 @@ +// Generated, do not edit. sha256:38e0d607ac0466a47b09dd863442a411b1bd579da41867454f04940a19819271 +import { t } from '../i18n/index.js'; +import { esc } from './escape.js'; +import { alertHtml } from './alert.js'; + +const label = (key, fallback) => { try { return t(key); } catch { return fallback; } }; + +export function renderErrorPanel(host, err, context) { + if (!host) return; + console.error(`[render] ${context || ''}`, err); + const title = label('error.panelTitle', 'Er ging iets mis bij het laden van deze sectie'); + const hint = label('error.panelHint', 'De rest van de app blijft werken. Details hieronder (ook in de console).'); + const msg = err && err.message ? err.message : String(err); + host.innerHTML = alertHtml({ + kind: 'error', title, body: hint, + extra: `
    ${esc(msg)}
    `, + }); +} diff --git a/client/ui/escape.js b/client/ui/escape.js new file mode 100644 index 0000000..3f46f99 --- /dev/null +++ b/client/ui/escape.js @@ -0,0 +1,10 @@ +// Generated, do not edit. sha256:b6966cd87b9511be3b1677051299af34144f53d2923d0082003d5a66a5025eb3 +const HTML_ENTITIES = Object.freeze({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}); + +export const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => HTML_ENTITIES[c]); diff --git a/client/ui/expand.js b/client/ui/expand.js new file mode 100644 index 0000000..ea86f79 --- /dev/null +++ b/client/ui/expand.js @@ -0,0 +1,32 @@ +// Generated, do not edit. sha256:1a4cf8672609e7907c7ebb7585b76d7f298027e896295fa767475cc915ce9da4 +import { getPref, setPref } from '../data/preferences.js'; + +const SCOPE = 'expand'; + +export async function expanders(pagina, standaard = {}) { + let bewaard = {}; + try { + const rij = await getPref(SCOPE, pagina, {}); + if (rij && typeof rij === 'object' && !Array.isArray(rij)) bewaard = rij; + } catch { } + + const stand = { ...standaard, ...bewaard }; + + return { + open: naam => stand[naam] === true, + + attr(naam) { + return `data-expand="${naam}"${stand[naam] === true ? ' open' : ''}`; + }, + + bind(host) { + if (!host) return; + for (const kader of host.querySelectorAll('[data-expand]')) { + kader.addEventListener('toggle', () => { + stand[kader.dataset.expand] = kader.open; + setPref(SCOPE, pagina, stand).catch(() => { }); + }); + } + }, + }; +} diff --git a/client/ui/fiat.js b/client/ui/fiat.js new file mode 100644 index 0000000..dc3fd07 --- /dev/null +++ b/client/ui/fiat.js @@ -0,0 +1,142 @@ +// Generated, do not edit. sha256:90b33d041c8c7e24dc16bdda9708154576175dfedc5c9ea7860984b38b588f03 +import { listAssets } from '../data/assets.js'; +import { upsertPriceHistory, getPricePoint, getPriceAt } from '../data/price-history.js'; +import { onchainAsset } from '../data/tx-links.js'; +import { liveSpotFor, ensurePriceHistoryFor, hasDailyGridFor, historicalPointForVia, primarySourceKey, lastSpotSource, basePriceOf, rateOn } from '../price/providers.js'; +import { priceAtMoment } from '../data/market.js'; +import { baseCurrency } from '../app-settings.js'; +import { getProvider, listProviders } from '../data/providers.js'; +import { provenanceOfRow, originFrom, liveOrigin } from '../../shared/price-provenance.js'; +import { fmtBase, fmtMoney, isBaseAsset, priceFromBase } from './format.js'; + +const spot = new Map(); +const hist = new Map(); + +const defaultTarget = async () => onchainAsset(); +const mainValue = (amountSub, asset) => amountSub / Math.pow(10, asset.decimals ?? 8); +const histKey = (assetId, dateKey) => `${assetId}:${dateKey}`; +const unixAtMidnight = dateKey => Math.floor(Date.parse(`${dateKey}T00:00:00Z`) / 1000); + +export async function loadFiat(assets = null) { + const targets = assets || [await defaultTarget()]; + for (const asset of targets) { + if (!asset) continue; + try { + const s = await liveSpotFor(asset); + spot.set(asset.id, s || { price: null }); + } catch { } + } + return spot; +} + +export function fiatValue(amountSub, asset) { + if (isBaseAsset(asset)) return ''; + const s = asset && spot.get(asset.id); + if (!s) return ''; + const price = basePriceOf(s); + if (price == null) return ''; + return fmtMoney(mainValue(amountSub, asset) * price, baseCurrency()); +} + +export async function loadHistorical(items) { + if (!namen.size) await laadNamen(); + const byAsset = new Map(); + for (const it of items) { + if (!it || !it.assetId || !it.dateKey) continue; + if (!byAsset.has(it.assetId)) byAsset.set(it.assetId, new Set()); + byAsset.get(it.assetId).add(it.dateKey); + } + for (const [assetId, keys] of byAsset) { + const asset = (await listAssets()).find(a => a.id === assetId); + if (asset) await loadHistoricalForAsset(asset, keys); + } +} + +async function loadHistoricalForAsset(asset, keys) { + const gridSrc = await primarySourceKey(asset, 'daily_history'); + const pointSrc = await primarySourceKey(asset, 'history_point'); + const readSrc = gridSrc || pointSrc; + const hasGrid = await hasDailyGridFor(asset); + const punten = await Promise.all([...keys].map(k => getPricePoint(asset.id, k))); + if (hasGrid && punten.some(p => !p)) { + try { await ensurePriceHistoryFor(asset); } catch { } + } + for (const key of keys) { + const hk = histKey(asset.id, key); + if (hist.has(hk)) continue; + const stored = await getPriceAt(asset.id, unixAtMidnight(key), readSrc); + if (stored) { hist.set(hk, stored.fiatPrice); histRij.set(hk, stored); continue; } + if (!hasGrid) { + try { + const p = await historicalPointForVia(asset, key); + if (p) { + await upsertPriceHistory(asset.id, [p], pointSrc, 'day'); + hist.set(hk, p.price); + const bron = await getProvider(pointSrc).catch(() => null); + histRij.set(hk, { fiatPrice: p.price, providerId: bron ? bron.id : null, resolution: 'day', + observedAt: `${key}T00:00:00Z`, createdAt: new Date().toISOString() }); + } + else hist.set(hk, null); + } catch { hist.set(hk, null); } + } else { + hist.set(hk, null); + } + } +} + +export function fiatHistorical(amountSub, asset, dateKey) { + if (!asset) return ''; + const price = hist.get(histKey(asset.id, dateKey)); + if (price == null) return ''; + return fmtBase(mainValue(amountSub, asset) * price); +} + +const histRij = new Map(); +let namen = new Map(); +let manualId = null; +async function laadNamen() { + try { + const providers = await listProviders(); + namen = new Map(providers.map(p => [p.id, p.name])); + const m = providers.find(p => p.key === 'manual'); + manualId = m ? m.id : null; + } catch { } +} + +export function fiatHistoricalOrigin(asset, dateKey) { + if (!asset || isBaseAsset(asset)) return null; + const rij = histRij.get(histKey(asset.id, dateKey)); + return rij ? originFrom(provenanceOfRow(rij, { day: dateKey, manualProviderId: manualId }), namen) : null; +} + +export function fiatValueOrigin(asset) { + if (!asset || isBaseAsset(asset)) return null; + const s = spot.get(asset.id); + const prijs = basePriceOf(s); + return prijs != null ? liveOrigin(prijs, lastSpotSource(asset)) : null; +} + +export async function spotOnDate(asset, dateKey) { + if (!asset || !dateKey) return null; + try { await loadHistorical([{ assetId: asset.id, dateKey }]); } catch { } + const pt = await getPricePoint(asset.id, dateKey); + return pt && pt.fiatPrice != null ? priceFromBase(pt.fiatPrice, asset) : null; +} + +export async function spotAtMoment(asset, atIso) { + if (!asset || !atIso) return null; + try { + const r = await priceAtMoment(asset.id, atIso); + if (r.status !== 'ok') return null; + const rate = await rateOn(r.currency, String(atIso).slice(0, 10)); + if (rate == null) return null; + return { price: priceFromBase(r.price * rate, asset), at: r.at, source: r.source }; + } catch { + return null; + } +} + +export function fiatAtPrice(amountSub, decimals, pricePerUnit) { + if (pricePerUnit == null) return ''; + return fmtBase((amountSub / Math.pow(10, decimals ?? 8)) * pricePerUnit); +} diff --git a/client/ui/filter.js b/client/ui/filter.js new file mode 100644 index 0000000..172c9a7 --- /dev/null +++ b/client/ui/filter.js @@ -0,0 +1,52 @@ +// Generated, do not edit. sha256:8ff8b9a894c249281ed87f26a36d73c4e403eda8ce50d7da357c4aad90e8f817 +import { esc } from './escape.js'; + +const DEFAULT_LABELS = Object.freeze({ all: 'All', none: 'None' }); +let labels = { ...DEFAULT_LABELS }; + +export function configureFilter({ labels: l = null } = {}) { + labels = { ...DEFAULT_LABELS, ...(l || {}) }; +} + +export function resolveSelection({ saved, items, initial }) { + const ids = items.map(i => i.id); + const valid = new Set(ids); + if (Array.isArray(saved)) { + const kept = saved.filter(id => valid.has(id)); + if (kept.length || saved.length === 0) return new Set(kept); + } + const init = initial ? [...initial(items)].filter(id => valid.has(id)) : ids; + return new Set(init.length ? init : ids); +} + +export function create(host, opts) { + const items = opts.items || []; + const selected = opts.selected instanceof Set ? opts.selected : new Set(opts.selected || []); + const dis = opts.disabled ? ' disabled' : ''; + const collapsedIds = opts.collapsedIds instanceof Set ? opts.collapsedIds : new Set(); + + host.classList.add('fbar', 'fbar--chip', ...(opts.className ? opts.className.split(' ') : [])); + + const allOn = items.every(i => selected.has(i.id)); + const act = ``; + + const btn = (i, idx) => + ``; + + host.innerHTML = act + items.map(btn).join(''); + + host.onclick = (e) => { + if (opts.disabled) return; + if (e.target.closest('[data-fact]')) { + opts.onChange(allOn ? new Set() : new Set(items.map(i => i.id))); + return; + } + const b = e.target.closest('[data-fi]'); + if (!b) return; + const id = items[Number(b.dataset.fi)].id; + if (typeof opts.onCycle === 'function') { opts.onCycle(id); return; } + const next = new Set(selected); + if (next.has(id)) next.delete(id); else next.add(id); + opts.onChange(next); + }; +} diff --git a/client/ui/format.js b/client/ui/format.js new file mode 100644 index 0000000..d8c68bc --- /dev/null +++ b/client/ui/format.js @@ -0,0 +1,147 @@ +// Generated, do not edit. sha256:de7888fd00d7c2b69a752977ab166eb917e92dd391509e94d32511d630d11079 +import { CONFIG } from '../../shared/config.js'; +import { isMetal, metalPrimary, metalSecondary, fromBaseByFactor } from '../../shared/units.js'; +import { esc } from './escape.js'; +import { appSetting, baseCurrency } from '../app-settings.js'; +import { localeTag } from '../i18n/index.js'; + +export { + isMetal, hasSubunit, toBaseByFactor, fromBaseByFactor, + mainToSub, subToMain, toSubunit, fromSubunit, + priceToBase, priceFromBase, +} from '../../shared/units.js'; + +export const fmtInt = n => Math.round(n).toLocaleString(localeTag()); + +export function parseLocaleNumber(text) { + const s = String(text ?? '').replace(/[^0-9.,-]/g, ''); + if (!/\d/.test(s)) return NaN; + const decimal = new Intl.NumberFormat(localeTag()).formatToParts(1.5).find(p => p.type === 'decimal').value; + const group = decimal === ',' ? '.' : ','; + let whole = s, frac = ''; + const at = s.lastIndexOf(decimal); + if (at >= 0) { + whole = s.slice(0, at); + frac = s.slice(at + 1); + } else { + const pieces = s.split(group); + const head = pieces[0].replace('-', ''); + if (pieces.length === 2 && (pieces[1].length !== 3 || head === '' || head === '0')) [whole, frac] = pieces; + } + return Number(`${whole.replaceAll(group, '') || '0'}.${frac.replace(/[.,]/g, '') || '0'}`); +} + +const MUNT = new Map(); +function munt(currency) { + const code = String(currency).toUpperCase(); + if (!CONFIG.PORTFOLIO.BASE_CURRENCIES.includes(code)) throw new Error(`Unknown currency: '${currency}'.`); + const key = `${localeTag()}|${code}`; + if (!MUNT.has(key)) { + const nf = new Intl.NumberFormat(localeTag(), { style: 'currency', currency: code, currencyDisplay: 'narrowSymbol' }); + MUNT.set(key, { + sym: nf.formatToParts(0).find(p => p.type === 'currency').value, + dec: nf.resolvedOptions().maximumFractionDigits, + }); + } + return MUNT.get(key); +} + +export const currencySymbol = currency => munt(currency).sym; + +export function currencyName(currency) { + munt(currency); + const naam = new Intl.DisplayNames([localeTag()], { type: 'currency' }).of(String(currency).toUpperCase()); + return naam.charAt(0).toUpperCase() + naam.slice(1); +} + +export function fmtMoney(value, currency, { decimals } = {}) { + const { sym, dec } = munt(currency); + if (decimals != null) { + return `${sym} ${Number(value).toLocaleString(localeTag(), { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}`; + } + const abs = Math.abs(Number(value)); + const maxDec = (abs > 0 && abs < 0.5 * Math.pow(10, -dec)) ? Math.min(8, dec - Math.floor(Math.log10(abs))) : dec; + return `${sym} ${Number(value).toLocaleString(localeTag(), { minimumFractionDigits: dec, maximumFractionDigits: maxDec })}`; +} + +export const fmtBase = value => fmtMoney(value, baseCurrency()); + +export const pct = (fractie) => `${+(fractie * 100).toFixed(4)}%`; + +export function fmtPct(fractie, { fine = false, signs = 'both' } = {}) { + if (typeof fractie !== 'number' || !Number.isFinite(fractie)) return ''; + const tekst = d => Math.abs(fractie * 100).toLocaleString(localeTag(), { minimumFractionDigits: d, maximumFractionDigits: d }); + const nul = s => !/[1-9]/.test(s); + let getal = tekst(1); + if (fine && nul(getal) && !nul(tekst(2))) getal = tekst(2); + const teken = nul(getal) ? '' : signFor(fractie >= 0, signs); + return `${teken}${getal}%`; +} + +const signFor = (positief, signs) => (positief + ? (signs === 'both' ? '+' : '') + : (signs === 'none' ? '' : '\u2212')); + +export const colouredSigns = () => appSetting('result_signs'); + +export function fmtBaseSigned(value, signs) { + const v = Number(value) || 0; + if (Math.abs(v) < 0.005) return fmtBase(0); + const tekst = fmtBase(Math.abs(v)); + const spatie = tekst.indexOf(' '); + return `${tekst.slice(0, spatie + 1)}${signFor(v > 0, signs)}${tekst.slice(spatie + 1)}`; +} + +export function pctTone(fractie, opts = {}) { + const s = fmtPct(fractie, { ...opts, signs: 'both' }); + return s.startsWith('+') ? 't-pos' : s.startsWith('\u2212') ? 't-neg' : ''; +} + +function priceDecimals(abs) { + if (abs >= 100) return 2; + if (abs >= 1) return 4; + return 8; +} +export function fmtPrice(value, currency) { + const { sym, dec } = munt(currency); + const n = Number(value); + const max = Math.max(dec, priceDecimals(Math.abs(n))); + return `${sym} ${n.toLocaleString(localeTag(), { minimumFractionDigits: dec, maximumFractionDigits: max })}`; +} + +export const fmtBasePrice = value => fmtPrice(value, baseCurrency()); + +export const val = text => `${esc(text)}`; + +export const subline = html => (html ? `
    ${html}
    ` : ''); + +const M = CONFIG.METALS; +function fmtMetal(amount, code) { + return `${fromBaseByFactor(amount, code).toLocaleString(localeTag(), { maximumFractionDigits: M.DISPLAY_DECIMALS })} ${code}`; +} + +const isFiat = asset => !!asset && asset.kind === 'fiat'; +const minDecimalen = (asset, dec) => isFiat(asset) ? dec : 0; + +export const isBaseAsset = asset => !!asset && asset.symbol === baseCurrency(); + +export function fmtUnit(amount, asset) { + if (isMetal(asset)) return fmtMetal(amount, metalPrimary(asset)); + const dec = asset.decimals ?? 0; + if (asset.displayUnit === 'subunit' && asset.subunit) return `${fmtInt(amount)} ${asset.subunit}`; + const main = amount / Math.pow(10, dec); + return `${main.toLocaleString(localeTag(), { minimumFractionDigits: minDecimalen(asset, dec), maximumFractionDigits: dec })} ${asset.unit}`; +} + +export function fmtAmount(amount, asset) { + if (isMetal(asset)) { + const sec = metalSecondary(asset); + return { primary: fmtMetal(amount, metalPrimary(asset)), secondary: sec ? fmtMetal(amount, sec) : '' }; + } + const dec = asset.decimals ?? 0; + const main = amount / Math.pow(10, dec); + const subStr = asset.subunit ? `${fmtInt(amount)} ${asset.subunit}` : ''; + const mainStr = `${main.toLocaleString(localeTag(), { minimumFractionDigits: minDecimalen(asset, dec), maximumFractionDigits: dec })} ${asset.unit}`; + const useSub = asset.displayUnit === 'subunit' && asset.subunit; + return useSub ? { primary: subStr, secondary: mainStr } : { primary: mainStr, secondary: subStr }; +} diff --git a/client/ui/guide-figures.js b/client/ui/guide-figures.js new file mode 100644 index 0000000..dd9fae1 --- /dev/null +++ b/client/ui/guide-figures.js @@ -0,0 +1,83 @@ +// Generated, do not edit. sha256:ea89ed2f3c7667a985d117ad93531e86e7422f6ef0152c179e997cfe96d6abd0 +import { t } from '../i18n/index.js'; +import { esc } from './escape.js'; + +const TEXT = 'fill:var(--text-primary);font-size:13px'; +const SOFT = 'fill:var(--text-sec);font-size:12px'; + +const label = (x, y, text, style = TEXT, anchor = 'start') => + `${esc(text)}`; + +const svg = (w, h, caption, body) => + `' + + `${body}`; + +function standPeriod(caption) { + return svg(640, 184, caption, [ + ``, + label(465, 38, t('guide.figures.period'), 'fill:var(--accent-text);font-size:12px', 'middle'), + ``, + label(102, 71, t('guide.figures.sinceBuy')), + ``, + label(342, 113, t('guide.figures.inPeriod')), + ``, + ...[90, 330, 600].map(x => ``), + label(90, 176, t('guide.figures.purchase'), SOFT, 'middle'), + label(330, 176, t('guide.figures.yearStart'), SOFT, 'middle'), + label(600, 176, t('guide.figures.now'), SOFT, 'end'), + ].join('')); +} + +function accountExample(caption) { + const y = v => (230 - ((v - 140) / 60) * 200).toFixed(1); + const cost = y(191.2), inn = y(154.92), now = y(186.43); + const waarde = [[260, inn], [300, 168], [340, 173], [380, 145], [420, 124], [460, 132], [495, 96], [520, now]]; + const lijn = waarde.map(([x, v]) => `${x},${v}`).join(' '); + return svg(680, 290, caption, [ + ``, + ``, + ``, + label(94, Number(cost) - 8, t('guide.figures.cost'), SOFT), + ``, + label(268, Number(inn) + 20, t('guide.figures.broughtIn'), 'fill:var(--accent-text);font-size:12px'), + ``, + ``, + ``, + label(544, Number(now) - 3, t('guide.figures.unrealized'), 'fill:var(--neg);font-size:12px'), + ``, + label(544, 128, t('guide.figures.result'), 'fill:var(--pos);font-size:12px'), + label(544, 144, t('guide.figures.resultAmount'), 'fill:var(--pos);font-size:12px'), + ``, + ...[80, 260, 520].map(x => ``), + label(80, 254, t('guide.figures.purchaseExchange'), SOFT, 'middle'), + label(260, 254, t('guide.figures.transfer'), SOFT, 'middle'), + label(520, 254, t('guide.figures.value'), SOFT, 'middle'), + ``, + label(90, 282, t('guide.figures.legendValue'), SOFT), + ``, + label(190, 282, t('guide.figures.legendBroughtIn'), SOFT), + ``, + label(350, 282, t('guide.figures.legendCost'), SOFT), + ].join('')); +} + +function resultSum(caption) { + const deel = (x, w, opacity, text) => + `` + + label(x + 10, 82, text); + return svg(640, 104, caption, [ + label(180, 32, t('guide.figures.result'), TEXT, 'end'), + ``, + label(180, 82, t('guide.figures.roughly'), SOFT, 'end'), + deel(196, 220, 0.25, t('guide.figures.unrealizedPart')), + deel(420, 120, 0.15, t('guide.figures.realized')), + deel(544, 56, 0.06, t('guide.figures.interest')), + ].join('')); +} + +export const GUIDE_FIGURE_DRAWINGS = Object.freeze({ + 'stand-period': standPeriod, + 'account-example': accountExample, + 'result-sum': resultSum, +}); diff --git a/client/ui/hint.js b/client/ui/hint.js new file mode 100644 index 0000000..432498f --- /dev/null +++ b/client/ui/hint.js @@ -0,0 +1,72 @@ +// Generated, do not edit. sha256:618e9d10819427320df9cea13ee26eca05db3121f4e69b541d2a8a431e85957a +import { esc } from './escape.js'; + +const MOUNT = 'page-hint'; +const INFO = ''; + +let lead = ''; +let empty = null; + +export function configureHint({ lead: l = '', empty: e = null } = {}) { + lead = l || ''; + empty = e; +} + +function legend(title, content, className = '') { + return ` +
    + + +
    `; +} + +export function setPageHint(host, { title, rows = [] } = {}) { + const mount = document.getElementById(MOUNT); + if (!mount || !host?.isConnected) return; + mount.innerHTML = legend(title, rows.map(row).join('')); + watchFit(mount); +} + +export function clearPageHint() { + const mount = document.getElementById(MOUNT); + if (!mount) return; + mount.innerHTML = empty ? legend(empty.title, '', empty.className || '') : ''; + if (empty) watchFit(mount); +} + +function watchFit(mount) { + if (mount.dataset.fitWatch) return; + mount.dataset.fitWatch = '1'; + const fit = () => { + const panel = mount.querySelector('.hint-panel'); + if (!panel) return; + panel.classList.remove('is-wide'); + if (panel.scrollHeight > panel.clientHeight + 1) panel.classList.add('is-wide'); + }; + mount.addEventListener('pointerenter', fit); + mount.addEventListener('focusin', fit); +} + +function row(r) { + if (r && typeof r === 'object' && r.heading) return `
    ${r.heading}
    `; + const text = typeof r === 'string' ? r : (r?.text ?? ''); + const sample = typeof r === 'string' ? '' : (r?.sample ?? ''); + return `
    ${sample}${text}
    `; +} + +export const SAMPLES = Object.freeze({ + line: '', + dashed: '', + dot: '', + dotOpen: '', + ring: '', + cellFull: '', + cellPart: '', + cellLow: '', + cellClosed: '', + cellNone: '', +}); diff --git a/client/ui/icons.js b/client/ui/icons.js new file mode 100644 index 0000000..d1e4837 --- /dev/null +++ b/client/ui/icons.js @@ -0,0 +1,39 @@ +// Generated, do not edit. sha256:06627f5d01d37034867aaf9d6b234f888eb4b99f4ecd0e53cb5df7c45bce718a +export const CHEVRON = ''; +export const CHEVRON_RIGHT = ''; + +export const ARROW_DOWN = ''; +export const ARROW_UP = ''; + +export const EYE = ''; +export const EYE_OFF = ''; + +export const SWAP = ''; + +export const BITCOIN = ''; + +export const CHECK_CIRCLE = ''; + +export const MERGE = ''; + +export const ADJUST = ''; + +export const GRIP = ''; + +export const CLOSE = ''; + +export const PLUS = ''; + +export const PENCIL = ''; + +export const TRASH = ''; + +export const INFO = ''; + +export const SHIELD = ''; + +export const CHECK = ''; + +export const REFRESH = ''; + +export const COPY = ''; diff --git a/client/ui/label-combobox.js b/client/ui/label-combobox.js new file mode 100644 index 0000000..b8c6771 --- /dev/null +++ b/client/ui/label-combobox.js @@ -0,0 +1,83 @@ +// Generated, do not edit. sha256:9856275bb06c64defb28cac958ae6f3ea1416a635a680ca0739edd9bad0379ee +import * as dialog from './dialog.js'; +import { esc } from './escape.js'; + +let _seq = 0; + +export function labelComboMarkup(fieldKey, currentId) { + const id = currentId == null ? '' : currentId; + const dl = `lc-dl-${++_seq}`; + return ` + + + + + + `; +} + +export function labelComboRead(host) { + if (!host) return null; + const v = host.querySelector('[data-label-id]').value; + return v === '' ? null : Number(v); +} + +export function labelComboMount(host, labelApi, onChange) { + const hidden = host.querySelector('[data-label-id]'); + const input = host.querySelector('[data-label-input]'); + const datalist = host.querySelector('datalist'); + const addBtn = host.querySelector('[data-label-add]'); + const delBtn = host.querySelector('[data-label-del]'); + + let cache = []; + const byName = name => { + const n = name.trim().toLowerCase(); + return cache.find(l => l.name.toLowerCase() === n) || null; + }; + + function refreshList() { + datalist.innerHTML = cache.map(l => ``).join(''); + } + function setId(id) { + hidden.value = id == null ? '' : id; + const cur = cache.find(l => String(l.id) === String(id)); + input.value = cur ? cur.name : ''; + delBtn.disabled = !cur; + } + async function load() { + cache = (await labelApi.listLabels()) || []; + refreshList(); + } + + input.onfocus = () => { input.value = ''; load(); }; + input.onblur = () => setTimeout(() => { + if (document.activeElement === input) return; + if (!input.value.trim()) { + const cur = cache.find(l => String(l.id) === String(hidden.value)); + input.value = cur ? cur.name : ''; + } + }, 150); + + input.onchange = () => { + const name = input.value.trim(); + if (!name) { setId(''); onChange?.(null); return; } + const m = byName(name); + if (m) { setId(m.id); onChange?.(m.id); } + else { hidden.value = ''; delBtn.disabled = true; } + }; + + addBtn.onclick = async () => { + const name = input.value.trim(); + if (!name) { input.focus(); return; } + const m = byName(name); + if (m) { setId(m.id); onChange?.(m.id); return; } + try { + const id = await labelApi.createLabel({ name }); + await load(); setId(id); onChange?.(id); + } catch (e) { dialog.alert({ message: e.message }); } + }; + + delBtn.onclick = () => { setId(''); onChange?.(null); }; + + return load().then(() => setId(hidden.value || '')); +} diff --git a/client/ui/loading.js b/client/ui/loading.js new file mode 100644 index 0000000..7a95b80 --- /dev/null +++ b/client/ui/loading.js @@ -0,0 +1,81 @@ +// Generated, do not edit. sha256:3eb303d976e1be5498b5aef4a7cdd95c577070d967bef44ed7c9e2767146c564 +import { esc } from './escape.js'; + +const DEFAULT_LABELS = Object.freeze({ loading: 'Loading…', loadError: 'Could not load this.' }); +let labels = { ...DEFAULT_LABELS }; + +export function configureLoading({ labels: l = null } = {}) { + labels = { ...DEFAULT_LABELS, ...(l || {}) }; +} + +const sizes = ({ w, h, r, flex } = {}) => + (w ? ` data-skel-w="${esc(w)}"` : '') + (h ? ` data-skel-h="${esc(h)}"` : '') + + (r ? ` data-skel-r="${esc(r)}"` : '') + (flex ? ` data-skel-flex="${esc(flex)}"` : ''); + +export function sizeSkeleton(host) { + if (!host) return; + for (const el of host.querySelectorAll('[data-skel-w], [data-skel-h], [data-skel-r], [data-skel-flex]')) { + const d = el.dataset; + if (d.skelW) el.style.setProperty('width', d.skelW); + if (d.skelH) el.style.setProperty('height', d.skelH); + if (d.skelR) el.style.setProperty('border-radius', d.skelR); + if (d.skelFlex) el.style.setProperty('flex', d.skelFlex); + } +} + +export function block(w = '100%', h = '1rem', radius = 'var(--radius-sm)') { + return ``; +} +export function line(w = '100%') { + return ``; +} +export function circle(size = '2rem') { + return ``; +} +export function card({ lines = 3 } = {}) { + const body = Array.from({ length: lines }, (_, i) => + `
    ${line(i === 0 ? '55%' : '35%')}${line('20%')}
    `).join(''); + return `
    +
    ${circle('1.6rem')}${line('40%')}
    ${block('3rem', '1.1rem')}
    +
    ${body}
    +
    `; +} +export function table({ rows = 6, cols = 4 } = {}) { + const cell = (head) => ``; + const row = (head) => `
    ${Array.from({ length: cols }, () => `
    ${cell(head)}
    `).join('')}
    `; + const bodyRows = Array.from({ length: rows }, () => row(false)).join(''); + return `
    ${row(true)}${bodyRows}
    `; +} +export function cards({ count = 3, lines = 3 } = {}) { + return `
    ${Array.from({ length: count }, () => card({ lines })).join('')}
    `; +} + +export function skeleton(parts) { + const inner = Array.isArray(parts) ? parts.join('') : String(parts ?? ''); + const label = `${esc(labels.loading)}`; + return `
    ${label}
    `; +} + +export function show(host, variant = 'lines', opts = {}) { + if (!host) return; + let parts; + if (variant === 'cards') parts = cards(opts); + else if (variant === 'table') parts = table(opts); + else if (variant === 'card') parts = card(opts); + else parts = Array.from({ length: opts.count || 3 }, (_, i) => line(i % 2 ? '70%' : '90%')); + host.innerHTML = skeleton(parts); + sizeSkeleton(host); +} + +export function spinner(host, label = labels.loading) { + if (!host) return; + host.innerHTML = `
    + + ${esc(label)} +
    `; +} + +export function error(host, msg) { + if (!host) return; + host.innerHTML = ``; +} diff --git a/client/ui/lock.js b/client/ui/lock.js new file mode 100644 index 0000000..f88ed29 --- /dev/null +++ b/client/ui/lock.js @@ -0,0 +1,37 @@ +// Generated, do not edit. sha256:4bf6f8ef0a0bf339d290a677844034b6b894f341f060833980b2dc5ccb489b09 +import { esc } from './escape.js'; +import { SHIELD } from './icons.js'; +import { t } from '../i18n/index.js'; +import { TARGETS, targetHash } from '../../shared/config.js'; +import { limitFor } from '../license.js'; + +export function lockIcon(title = t('license.lockedTitle')) { + return `${SHIELD}`; +} + +export function lockPanelHtml(what) { + const href = targetHash(TARGETS.LICENSE); + return `
    +
    ${lockIcon()}${esc(t('license.lockedTitle'))}
    +

    ${esc(what)}

    +

    ${esc(t('license.whatIsPaid'))}

    +
    `; +} + +export function lockNoteHtml(what) { + return `
    ${lockIcon()}${esc(what)} ` + + `${esc(t('license.whatIsPaid'))}
    `; +} + +export function counterHtml(text) { + return text ? `${esc(text)}` : ''; +} + +export function capacityCfg(kind, used) { + const max = limitFor(kind); + if (max === null) return {}; + return { + toolbarNote: () => t('license.counter', { n: used(), max }), + createHidden: () => used() >= max, + }; +} diff --git a/client/ui/masonry.js b/client/ui/masonry.js new file mode 100644 index 0000000..f32e28e --- /dev/null +++ b/client/ui/masonry.js @@ -0,0 +1,52 @@ +// Generated, do not edit. sha256:87d755cce2f1ccb8186c668965f676f63e76de1e6021910a7efccabd9473cc5d +export function spreidOverKolommen(hoogtes, kolommen) { + const n = Math.max(1, Math.floor(kolommen) || 1); + const som = new Array(n).fill(0); + return hoogtes.map(h => { + let k = 0; + for (let j = 1; j < n; j++) if (som[j] < som[k]) k = j; + som[k] += h; + return k; + }); +} + +export function kolomAantal(el) { + const raw = getComputedStyle(el).getPropertyValue('--cols'); + const n = parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : 1; +} + +export function layoutMasonry(host, kaartSelector = ':scope > *') { + const bestaande = [...host.querySelectorAll(':scope > .masonry-col')]; + const kaarten = bestaande.length + ? bestaande.flatMap(c => [...c.children]) + : [...host.querySelectorAll(kaartSelector)]; + if (!kaarten.length) { host.replaceChildren(); return; } + + const n = kolomAantal(host); + const kolommen = Array.from({ length: n }, () => { + const c = document.createElement('div'); + c.className = 'masonry-col'; + return c; + }); + host.replaceChildren(...kolommen); + + kolommen[0].append(...kaarten); + if (n === 1) return; + const hoogtes = kaarten.map(k => k.offsetHeight); + const doel = spreidOverKolommen(hoogtes, n); + kaarten.forEach((k, i) => kolommen[doel[i]].appendChild(k)); +} + +export function volgMasonry(host, kaartSelector) { + let laatste = kolomAantal(host); + layoutMasonry(host, kaartSelector); + const onResize = () => { + const nu = kolomAantal(host); + if (nu === laatste) return; + laatste = nu; + layoutMasonry(host, kaartSelector); + }; + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); +} diff --git a/client/ui/match-popover.js b/client/ui/match-popover.js new file mode 100644 index 0000000..81873d3 --- /dev/null +++ b/client/ui/match-popover.js @@ -0,0 +1,130 @@ +// Generated, do not edit. sha256:864ef84f3268d175b0ba11b7c30d7395b9e816338f8739d95658a7e8e95ba768 +import { localDateNl } from '../../shared/time.js'; +import { + linkTxEvent, unlinkTxEvent, onchainAsset, linksForTx, linksForEvent, eventCandidatesForTx, + txCandidatesForEvent +} from '../data/tx-links.js'; +import { t } from '../i18n/index.js'; +import { fmtInt } from './format.js'; +import { openPopover, closePopover } from './popover.js'; +import { esc } from './escape.js'; + +const trunc = s => (s && s.length > 22) ? `${s.slice(0, 10)}…${s.slice(-8)}` : (s || ''); +const MANUAL = 'manual'; + +export function matchDot({ matched, method, kind, id }) { + const title = matched ? t('link.' + method) : t('match.add'); + return ``; +} + +export function closeMatchPopover() { closePopover(); } + +export async function openMatchPopover(anchorEl, { kind, id, assetId, onChange }) { + let changed = false; + + const unit = (await onchainAsset() || {}).subunit || ''; + const sats = n => `${fmtInt(n)} ${unit}`.trim(); + const dateOf = iso => iso ? localDateNl(iso) : t('match.unconfirmed'); + let filter = ''; + + const currentLinks = () => kind === 'tx' ? linksForTx(id) : linksForEvent(id); + const rawCands = () => kind === 'tx' ? eventCandidatesForTx(id, assetId) : txCandidatesForEvent(id); + const pairFor = itemId => kind === 'tx' ? { tx: id, ev: itemId } : { tx: itemId, ev: id }; + + const eventLine = e => ({ + l1: `${esc(t('enums.event_type.' + e.type))} · ${sats(e.amount)}`, + l2: `${dateOf(e.occurredAt)} · ${esc(e.from_name || '-')} → ${esc(e.to_name || '-')}`, + text: `${t('enums.event_type.' + e.type)} ${e.amount} ${e.occurredAt} ${e.from_name || ''} ${e.to_name || ''}`, + }); + const txLine = x => ({ + l1: `${x.direction === 'in' ? '↓' : '↑'} ${sats(x.valueSats)}`, + l2: `${dateOf(x.blockTime)} · ${esc(x.account_name || '-')}${x.address ? ' · ' + esc(trunc(x.address)) : ''}`, + text: `${x.direction} ${x.valueSats} ${x.blockTime || ''} ${x.account_name || ''} ${x.txid || ''} ${x.address || ''}`, + }); + const lineOf = item => kind === 'tx' ? eventLine(item) : txLine(item); + + async function candidates() { + const list = await rawCands(); + if (!filter) return list; + const f = filter.toLowerCase(); + return list.filter(c => lineOf(c).text.toLowerCase().includes(f)); + } + + async function syncAnchor() { + if (anchorEl.isConnected) anchorEl.classList.toggle('is-matched', (await currentLinks()).length > 0); + } + + const { el: pop, position } = openPopover(anchorEl, { + className: 'match-pop', + onClose: () => { + document.querySelectorAll('.tx-link.is-open').forEach(d => d.classList.remove('is-open')); + if (changed && onChange) onChange(); + }, + }); + anchorEl.classList.add('is-open'); + + async function draw() { + const links = await currentLinks(); + const cands = await candidates(); + const addLabel = kind === 'tx' ? t('match.toEvent') : t('match.toTx'); + + const linksHtml = links.length ? ` +
    ${t('match.linked')}
    + ${links.map(it => { + const ln = lineOf(it); + return ``; + }).join('')} +
    ` : ''; + + const listHtml = cands.length + ? cands.map(c => { + const ln = lineOf(c); + return `
    +
    ${ln.l1}
    ${ln.l2}
    +
    `; + }).join('') + : `
    ${t('match.noCandidates')}
    `; + + pop.innerHTML = `${linksHtml} +
    ${esc(addLabel)}
    + +
    ${listHtml}
    `; + position(); + } + + pop.addEventListener('click', async e => { + const un = e.target.closest('[data-unlink]'); + if (un) { const p = pairFor(Number(un.dataset.unlink)); await unlinkTxEvent(p.tx, p.ev); changed = true; await syncAnchor(); await draw(); return; } + const lk = e.target.closest('[data-link]'); + if (lk) { const p = pairFor(Number(lk.dataset.link)); await linkTxEvent(p.tx, p.ev, MANUAL); changed = true; await syncAnchor(); await draw(); return; } + }); + pop.addEventListener('input', e => { + if (e.target.matches('[data-filter]')) { filter = e.target.value; draw(); } + }); + + await draw(); +} + +export function wireMatchTriggers(container, optsFactory) { + const open = el => openMatchPopover(el, optsFactory(el.dataset.matchKind, Number(el.dataset.matchId))); + const onClick = e => { + const dot = e.target.closest('[data-match-trigger]'); + if (!dot || !container.contains(dot)) return; + e.preventDefault(); e.stopPropagation(); + open(dot); + }; + const onKey = e => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const dot = e.target.closest('[data-match-trigger]'); + if (!dot || !container.contains(dot)) return; + e.preventDefault(); e.stopPropagation(); + open(dot); + }; + container.addEventListener('click', onClick, true); + container.addEventListener('keydown', onKey, true); +} diff --git a/client/ui/notes.js b/client/ui/notes.js new file mode 100644 index 0000000..81818cc --- /dev/null +++ b/client/ui/notes.js @@ -0,0 +1,68 @@ +// Generated, do not edit. sha256:dd3cdccc38ee940cd5da182fe49a2feb4a0d00eb7f08ee8c95f1fe0e712cbd46 +import { getAssetNote, setAssetNote } from '../data/asset-notes.js'; +import { t } from '../i18n/index.js'; +import { esc } from './escape.js'; + +const SAVE_DELAY_MS = 700; + +function nuLokaal() { + const d = new Date(); + const p = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; +} + +export function notesHtml() { + return `
    +
    + + +
    + +
    +
    `; +} + +export async function mountNotes(host, assetId) { + const when = host.querySelector('[data-notes-when]'); + const line = host.querySelector('[data-notes-line]'); + const body = host.querySelector('[data-notes-body]'); + const status = host.querySelector('[data-notes-status]'); + if (!when || !line || !body) return; + when.value = nuLokaal(); + let whenTouched = false; + when.addEventListener('input', () => { whenTouched = true; }); + line.addEventListener('focus', () => { if (!whenTouched) when.value = nuLokaal(); }); + + let opgeslagen = ''; + try { opgeslagen = (await getAssetNote(assetId))?.body || ''; } catch (e) { status.textContent = e.message; } + body.value = opgeslagen; + + let timer = null; + async function bewaar() { + clearTimeout(timer); timer = null; + const tekst = body.value; + if (tekst === opgeslagen) return; + status.textContent = t('notes.saving'); + try { + await setAssetNote(assetId, tekst); + opgeslagen = tekst; + status.textContent = t('notes.saved'); + } catch (e) { status.textContent = e.message; } + } + body.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(bewaar, SAVE_DELAY_MS); }); + body.addEventListener('blur', bewaar); + + line.addEventListener('keydown', e => { + if (e.key !== 'Enter') return; + e.preventDefault(); + const tekst = line.value.trim(); + if (!tekst) return; + const stempel = (when.value || nuLokaal()).replace('T', ' '); + const rest = body.value.replace(/^\s+/, ''); + body.value = `${stempel} ${tekst}${rest ? `\n${rest}` : ''}`; + line.value = ''; + whenTouched = false; + when.value = nuLokaal(); + bewaar(); + }); +} diff --git a/client/ui/page-hint.js b/client/ui/page-hint.js new file mode 100644 index 0000000..3be6be6 --- /dev/null +++ b/client/ui/page-hint.js @@ -0,0 +1,11 @@ +// Generated, do not edit. sha256:e707d55c60167dc48ebad04a3ae56015d4265e62aba96f4c7672c6d5d9b9e466 +import { configureHint, setPageHint, clearPageHint, SAMPLES } from './hint.js'; +import { INFO } from './icons.js'; +import { t } from '../i18n/index.js'; + +configureHint({ + lead: `
    ${INFO}${t('priceOrigin.pageWarn')}
    `, + empty: { title: t('priceOrigin.pageWarnTitle'), className: 'is-fallback' }, +}); + +export { setPageHint, clearPageHint, SAMPLES }; diff --git a/client/ui/popover.js b/client/ui/popover.js new file mode 100644 index 0000000..54ce48b --- /dev/null +++ b/client/ui/popover.js @@ -0,0 +1,60 @@ +// Generated, do not edit. sha256:0538b89d6c5865b07db285c6e78de905962d2d2f2b35ac9aeee58cf04054fbe2 +let current = null; + +export function isPopoverOpen() { return current !== null; } + +export function closePopover() { + if (!current) return; + const c = current; + current = null; + c.cleanup(); + c.el.remove(); + if (c.onClose) c.onClose(); +} + +export function openPopover(anchorEl, { className, onClose } = {}) { + closePopover(); + + const el = document.createElement('div'); + el.className = className || 'popover'; + const overlays = document.querySelectorAll('.dialog-overlay'); + (overlays.length ? overlays[overlays.length - 1] : document.body).appendChild(el); + + function position() { + const r = anchorEl.getBoundingClientRect(); + const pad = 8; + let left = r.left; + let top = r.bottom + 6; + const pw = el.offsetWidth, ph = el.offsetHeight; + if (left + pw > window.innerWidth - pad) left = window.innerWidth - pad - pw; + if (left < pad) left = pad; + if (top + ph > window.innerHeight - pad) top = Math.max(pad, r.top - ph - 6); + el.style.left = `${left}px`; + el.style.top = `${top}px`; + } + + const reposition = () => { if (current && current.el === el) position(); }; + const onDocClick = e => { + if (el.contains(e.target) || (anchorEl && anchorEl.contains(e.target))) return; + closePopover(); + }; + const onKey = e => { if (e.key === 'Escape') closePopover(); }; + + let bindTimer = null; + const cleanup = () => { + clearTimeout(bindTimer); + document.removeEventListener('click', onDocClick, true); + document.removeEventListener('keydown', onKey); + window.removeEventListener('resize', reposition); + window.removeEventListener('scroll', reposition, true); + }; + + current = { el, cleanup, onClose }; + + bindTimer = setTimeout(() => document.addEventListener('click', onDocClick, true), 0); + document.addEventListener('keydown', onKey); + window.addEventListener('resize', reposition); + window.addEventListener('scroll', reposition, true); + + return { el, position, close: closePopover }; +} diff --git a/client/ui/price-origin.js b/client/ui/price-origin.js new file mode 100644 index 0000000..ec4149c --- /dev/null +++ b/client/ui/price-origin.js @@ -0,0 +1,169 @@ +// Generated, do not edit. sha256:b9ab466fcbb17da69242310739ff9bb1a6f805583cc7c757d134c773ce086dc3 +import { t, localeTag } from '../i18n/index.js'; +import { esc } from './escape.js'; +import { INFO } from './icons.js'; +import { openPopover, closePopover } from './popover.js'; +import { fmtBasePrice, priceFromBase, isBaseAsset as isBase } from './format.js'; +import { priceUnitLabel } from '../../shared/units.js'; +import { assetLabel } from './asset-label.js'; + +const dagTekst = iso => new Intl.DateTimeFormat(localeTag(), { day: 'numeric', month: 'short', year: 'numeric' }) + .format(new Date(`${iso}T00:00:00Z`)); +const tijdTekst = iso => new Intl.DateTimeFormat(localeTag(), { hour: '2-digit', minute: '2-digit' }).format(new Date(iso)); +const pct = v => `${v > 0 ? '+' : ''}${v.toLocaleString(localeTag(), { maximumFractionDigits: 2 })}%`; + +const STERKTE = { close: 'high', manual: 'high', live: 'high', spot: 'lower', carried: 'low' }; + +export function originLines(origin, asset) { + if (!origin || origin.fiatPrice == null || isBase(asset)) return []; + const prijs = fmtBasePrice(priceFromBase(origin.fiatPrice, asset)); + const eenheid = asset ? priceUnitLabel(asset) : null; + const bron = origin.source || t('priceOrigin.someSource'); + const regels = [eenheid + ? t('priceOrigin.pricePer', { prijs, eenheid, dag: dagTekst(origin.day) }) + : t('priceOrigin.price', { prijs, dag: dagTekst(origin.day) })]; + if (origin.kind === 'close') regels.push(t('priceOrigin.close', { bron })); + else if (origin.kind === 'manual') regels.push(t('priceOrigin.manual')); + else if (origin.kind === 'live') regels.push(origin.source ? t('priceOrigin.liveFrom', { bron }) : t('priceOrigin.live')); + else if (origin.kind === 'spot') { + regels.push(origin.moment ? t('priceOrigin.spotAt', { tijd: tijdTekst(origin.moment), bron }) : t('priceOrigin.spot', { bron })); + const d = origin.deviation; + const kanten = d ? [d.before != null ? t('priceOrigin.devBefore', { pct: pct(d.before) }) : null, + d.after != null ? t('priceOrigin.devAfter', { pct: pct(d.after) }) : null].filter(Boolean) : []; + if (kanten.length) regels.push(kanten.join(', ')); + } else if (origin.kind === 'carried') { + regels.push(t('priceOrigin.carried', { dag: dagTekst(origin.sourceDay || origin.day), n: origin.ageDays, bron })); + } + const sterkte = STERKTE[origin.kind]; + if (sterkte) regels.push(t('priceOrigin.strength', { sterkte: t(`priceOrigin.strength_${sterkte}`) })); + return regels; +} + +export function originHtml(origin, asset, voor = []) { + return knopHtml([...voor, ...originLines(origin, asset)]); +} + +const RANG = { high: 3, lower: 2, low: 1 }; +const rang = o => RANG[STERKTE[o.kind]] ?? 0; + +export function originSummaryLines(items) { + return samenvattingVan(items).regels; +} + +function samenvattingVan(items) { + const assetSleutel = x => (x.asset ? x.asset.id ?? x.asset.symbol : '?'); + const uniek = new Map(); + const zonder = new Map(); + for (const x of items || []) { + if (!x || isBase(x.asset)) continue; + if (!x.origin || x.origin.fiatPrice == null) { if (!zonder.has(assetSleutel(x))) zonder.set(assetSleutel(x), x); continue; } + const sleutel = `${assetSleutel(x)}|${x.origin.day}`; + if (!uniek.has(sleutel)) uniek.set(sleutel, x); + } + const met = [...uniek.values()]; + if (!met.length && !zonder.size) return { regels: [], onvolledig: false }; + if (zonder.size) { + const eigen = met.length === 1 + ? originLines(met[0].origin, met[0].asset).map((r, i) => (i === 0 ? `${assetLabel(met[0].asset)}: ${r}` : r)) + : met.length ? samenvatting(met) : []; + const zonderSterkte = eigen.length ? eigen.slice(0, -1) : []; + const namen = [...zonder.values()].map(x => assetLabel(x.asset)).join(', '); + return { onvolledig: true, regels: [...zonderSterkte, t('priceOrigin.sumMissing', { assets: namen }), + t('priceOrigin.strength', { sterkte: t('priceOrigin.strength_missing') })] }; + } + return { onvolledig: false, regels: met.length === 1 ? originLines(met[0].origin, met[0].asset) : samenvatting(met) }; +} + +function samenvatting(met) { + const telling = new Map(); + for (const { origin } of met) telling.set(origin.kind, (telling.get(origin.kind) || 0) + 1); + const soorten = ['close', 'manual', 'live', 'spot', 'carried'].filter(k => telling.has(k)) + .map(k => { const n = telling.get(k); return `${n} ${t(`priceHistory.kindCount_${k}${n === 1 ? 'One' : 'Many'}`)}`; }); + const zwakste = met.reduce((a, b) => (rang(b.origin) < rang(a.origin) + || (rang(b.origin) === rang(a.origin) && (b.origin.ageDays || 0) > (a.origin.ageDays || 0)) ? b : a)); + const regels = [t('priceOrigin.sumHead', { n: met.length }), soorten.join(', ')]; + if (STERKTE[zwakste.origin.kind] !== 'high') { + const zin = originLines(zwakste.origin, zwakste.asset)[1] || ''; + regels.push(t('priceOrigin.sumWeakest', { asset: assetLabel(zwakste.asset), zin: zin.charAt(0).toLowerCase() + zin.slice(1) })); + } + regels.push(t('priceOrigin.strength', { sterkte: t(`priceOrigin.strength_${STERKTE[zwakste.origin.kind]}`) })); + return regels; +} + +export function originsHtml(items, voor = []) { + const { regels, onvolledig } = samenvattingVan(items); + return knopHtml([...voor, ...regels], { warn: onvolledig }); +} + +export function warningHtml(regels) { + return knopHtml(regels, { warn: true }); +} + +const BOEKING_STERKTE = { paid: 'high', market_moment: 'high', market_day: 'lower' }; + +export function bookingOriginLines(b, asset, dayOrigin = null) { + if (!b || b.priceOrigin === 'none' || b.fiatPrice == null || isBase(asset)) return []; + const prijs = fmtBasePrice(priceFromBase(b.fiatPrice, asset)); + const eenheid = asset ? priceUnitLabel(asset) : null; + const dag = dagTekst(String(b.occurredAt).slice(0, 10)); + const regels = [eenheid ? t('priceOrigin.bookingPricePer', { prijs, eenheid }) : t('priceOrigin.bookingPrice', { prijs })]; + if (b.priceOrigin === 'paid') regels.push(t('priceOrigin.bookingPaid')); + else if (b.priceOrigin === 'market_moment') regels.push(t('priceOrigin.bookingMoment')); + else if (b.priceOrigin === 'market_day') { + const zin = dayOrigin ? originLines(dayOrigin, asset)[1] : null; + regels.push(zin ? t('priceOrigin.bookingDayFrom', { dag, zin: zin.charAt(0).toLowerCase() + zin.slice(1) }) + : t('priceOrigin.bookingDay', { dag })); + regels.push(t('priceOrigin.bookingDayNote')); + } else { + regels.push(t('priceOrigin.bookingUnknown')); + } + const sterkte = b.priceOrigin === 'market_day' && dayOrigin && dayOrigin.kind === 'carried' + ? 'low' : (BOEKING_STERKTE[b.priceOrigin] || 'unknown'); + regels.push(t('priceOrigin.strength', { sterkte: t(`priceOrigin.strength_${sterkte}`) })); + return regels; +} + +export function bookingOriginHtml(b, asset, dayOrigin = null) { + return knopHtml(bookingOriginLines(b, asset, dayOrigin)); +} + +export function infoLinesHtml(regels) { + return knopHtml(regels); +} + +function knopHtml(regels, { warn = false } = {}) { + if (!regels.length) return ''; + return ``; +} + +let openFor = null; +function toon(btn) { + if (openFor === btn) return; + let regels = []; + try { regels = JSON.parse(btn.dataset.originLines || '[]'); } catch { regels = []; } + if (!regels.length) return; + const pop = openPopover(btn, { className: 'po-pop', onClose: () => { openFor = null; } }); + pop.el.setAttribute('role', 'tooltip'); + pop.el.innerHTML = regels.map((r, i) => `
    ${esc(r)}
    `).join(''); + pop.position(); + openFor = btn; +} +function verberg(btn) { if (openFor === btn) closePopover(); } + +if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { + const btnVan = e => (e.target && e.target.closest ? e.target.closest('.po-btn') : null); + document.addEventListener('mouseover', e => { const b = btnVan(e); if (b) toon(b); }); + document.addEventListener('mouseout', e => { + const b = btnVan(e); + if (b && !(e.relatedTarget && b.contains(e.relatedTarget))) verberg(b); + }); + document.addEventListener('focusin', e => { const b = btnVan(e); if (b) toon(b); }); + document.addEventListener('focusout', e => { const b = btnVan(e); if (b) verberg(b); }); + document.addEventListener('click', e => { + const b = btnVan(e); + if (!b) return; + e.stopPropagation(); + toon(b); + }, true); +} diff --git a/client/ui/qr.js b/client/ui/qr.js new file mode 100644 index 0000000..1a86b05 --- /dev/null +++ b/client/ui/qr.js @@ -0,0 +1,24 @@ +// Generated, do not edit. sha256:0357e7318367558ea1c4f3c06bae817f3ef795f556618cac3bc184f31a478f4c +import qrcode from '../vendor/qrcode/qrcode.mjs'; +import { esc } from './escape.js'; + +const ALPHANUMERIC = /^[0-9A-Z $%*+\-./:]*$/; + +export const invoiceQrText = (paymentRequest) => `LIGHTNING:${String(paymentRequest).toUpperCase()}`; + +export function qrMatrix(text) { + const qr = qrcode(0, 'M'); + qr.addData(text, ALPHANUMERIC.test(text) ? 'Alphanumeric' : 'Byte'); + qr.make(); + const n = qr.getModuleCount(); + return Array.from({ length: n }, (_, r) => Array.from({ length: n }, (_, c) => qr.isDark(r, c))); +} + +export function qrSvg(text, { quiet = 4, label = '', px = 240 } = {}) { + const m = qrMatrix(text); + const size = m.length + quiet * 2; + let d = ''; + m.forEach((row, r) => row.forEach((dark, c) => { if (dark) d += `M${c + quiet} ${r + quiet}h1v1h-1z`; })); + return `` + + ``; +} diff --git a/client/ui/range-cycle.js b/client/ui/range-cycle.js new file mode 100644 index 0000000..96e7986 --- /dev/null +++ b/client/ui/range-cycle.js @@ -0,0 +1,81 @@ +// Generated, do not edit. sha256:f30cb8310951acef1b77d15dedcb9471def9f3c4d10c55de27cbd826be0db541 +import { t, localeTag } from '../i18n/index.js'; +import { withSpot } from './sparkline.js'; + +const MS_PER_DAY = 86400000; + +export const RANGE_DAYS = Object.freeze({ week: 7, month: 30, year: 365 }); + +export const SPOT_RANGE_DAYS = Object.freeze({ day: 1, ...RANGE_DAYS }); + +export function intradayStart(grid, nowMs = Date.now()) { + const today = Math.floor(nowMs / MS_PER_DAY) * MS_PER_DAY; + const closes = Array.isArray(grid) ? grid.filter(p => p[0] < today) : []; + return closes.length ? closes[closes.length - 1][0] + MS_PER_DAY : today; +} + +export function rangeSeries(grid, range, spot, nowMs = Date.now(), intraday = null) { + if (range === 'day') { + const two = withSpot(grid, spot, nowMs).slice(-2); + if (two.length < 2 || !Number.isFinite(spot) || !Array.isArray(intraday) || !intraday.length) return two; + const start = intradayStart(grid, nowMs); + const inner = intraday.filter(([t, p]) => t > start && t < nowMs && Number.isFinite(p)); + return inner.length ? [[start, two[0][1]], ...inner, [nowMs, spot]] : two; + } + const cutoff = nowMs - (SPOT_RANGE_DAYS[range] ?? RANGE_DAYS.week) * MS_PER_DAY; + const slice = grid.filter(p => p[0] >= cutoff); + return withSpot(slice.length >= 2 ? slice : grid, spot, nowMs); +} + +export function rangePointLabel(range, priceText, ms, source = null) { + const moment = !Number.isFinite(ms) ? null : range === 'day' + ? new Date(ms).toLocaleString(localeTag(), { ...DATUM, hour: '2-digit', minute: '2-digit' }) + : utcDay(ms); + return [priceText, moment, source].filter(Boolean).join('\n'); +} + +const DATUM = { day: 'numeric', month: 'short', year: 'numeric' }; +const utcDay = ms => new Date(ms).toLocaleDateString(localeTag(), { ...DATUM, timeZone: 'UTC' }); + +export function namedDaySources(byDay, providers) { + const names = new Map((providers || []).map(p => [p.id, p.name])); + const out = new Map(); + for (const [ms, { providerId, sourceName }] of byDay || []) { + const name = names.get(providerId); + if (name) out.set(ms, sourceName ? t('range.pointVia', { name, source: sourceName }) : name); + } + return out; +} + +export function rangePointSources(points, { range, spot = null, nowMs = Date.now(), live = null, intraday = null, intradayAt = null, days = new Map() } = {}) { + const out = new Map(); + if (!Array.isArray(points) || !points.length) return out; + const today = Math.floor(nowMs / MS_PER_DAY) * MS_PER_DAY; + const own = [...days.keys()].sort((a, b) => a - b); + const last = points.length - 1; + points.forEach(([ms], i) => { + let label = null; + if (i === last && Number.isFinite(spot) && (ms === nowMs || ms === today)) label = live; + else if (range === 'day' && i > 0 && i < last) label = intradayAt?.get(ms) ?? intraday; + else if (days.has(ms)) label = days.get(ms); + else { + const from = own.filter(d => d < ms).pop(); + if (from != null && days.get(from)) label = t('range.pointCarried', { bron: days.get(from), dag: utcDay(from) }); + } + if (label) out.set(ms, label); + }); + return out; +} + +export const rangeLabel = r => t('range.' + r); + +export const rangeCycleButton = range => + ``; + +export function wireRangeCycle(wrap, getRange, onChange, ranges = RANGE_DAYS) { + wrap.onclick = e => { + if (!e.target.closest('.range-cycle-btn')) return; + const order = Object.keys(ranges); + onChange(order[(order.indexOf(getRange()) + 1) % order.length]); + }; +} diff --git a/client/ui/render-turn.js b/client/ui/render-turn.js new file mode 100644 index 0000000..eb051a4 --- /dev/null +++ b/client/ui/render-turn.js @@ -0,0 +1,8 @@ +// Generated, do not edit. sha256:e6ada8471a98e1a9a9c718825aa1e371ce7d568ba6108b0249aab352b6a55e48 +export function createRenderTurns() { + let newest = 0; + return { + take: () => ++newest, + isCurrent: (turn) => turn === newest, + }; +} diff --git a/client/ui/row-focus.js b/client/ui/row-focus.js new file mode 100644 index 0000000..b504d3a --- /dev/null +++ b/client/ui/row-focus.js @@ -0,0 +1,25 @@ +// Generated, do not edit. sha256:8c2d29dc1ab4de132603fd0dc064664fee50070f3e3eb2720a5819c260fc1066 +import { CONFIG } from '../../shared/config.js'; + +export function setRowFocus(table, id) { + if (!table || id == null) return; + try { + sessionStorage.setItem(CONFIG.FOCUS.ROW, JSON.stringify({ table, id, at: Date.now() })); + } catch { } +} + +export function takeRowFocus(table) { + let raw; + try { raw = sessionStorage.getItem(CONFIG.FOCUS.ROW); } catch { return null; } + if (!raw) return null; + let data; + try { data = JSON.parse(raw); } catch { remove(); return null; } + if (!data || data.table !== table) return null; + remove(); + if (!Number.isFinite(data.at) || Date.now() - data.at > CONFIG.FOCUS.ROW_TTL_MS) return null; + return data.id ?? null; +} + +function remove() { + try { sessionStorage.removeItem(CONFIG.FOCUS.ROW); } catch { } +} diff --git a/client/ui/scheduler.js b/client/ui/scheduler.js new file mode 100644 index 0000000..b761e76 --- /dev/null +++ b/client/ui/scheduler.js @@ -0,0 +1,58 @@ +// Generated, do not edit. sha256:e2e2ad5112c13991dd020e8231ee0569ddfef89365ad659158d7e3d71cc43739 +import { timerSetting } from '../app-settings.js'; +import { CONFIG } from '../../shared/config.js'; +import { pollChanges } from '../changes.js'; + +const TASKS = [ + { + key: 'spot_refresh', + prefField: 'spot_refresh_seconds', + run: () => document.dispatchEvent(new CustomEvent('tick:spot')), + }, + { + key: 'bus_poll', + prefField: 'bus_poll_seconds', + run: () => pollChanges(), + }, +]; + +const handles = new Map(); + +function intervalSecondsFor(task) { + const s = timerSetting(task.prefField); + return Number.isInteger(s) ? s : CONFIG.TIMERS.OFF_SECONDS; +} + +function stop(task) { + const h = handles.get(task.key); + if (h !== undefined) { clearTimeout(h); handles.delete(task.key); } +} + +function start(task) { + stop(task); + if (intervalSecondsFor(task) >= CONFIG.TIMERS.OFF_SECONDS) return; + const tick = async () => { + if (document.visibilityState !== 'hidden') { + try { await task.run(); } catch (e) { console.error('[scheduler]', task.key, e); } + } + const next = intervalSecondsFor(task); + if (next >= CONFIG.TIMERS.OFF_SECONDS) { handles.delete(task.key); return; } + handles.set(task.key, setTimeout(tick, next * 1000)); + }; + const seconds = intervalSecondsFor(task); + handles.set(task.key, setTimeout(tick, seconds * 1000)); +} + +function startAll() { for (const task of TASKS) start(task); } + +export function initScheduler() { + startAll(); + document.addEventListener('settings-changed', startAll); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') return; + for (const task of TASKS) { + if (!handles.has(task.key)) continue; + try { task.run(); } catch (e) { console.error('[scheduler]', task.key, e); } + } + }); +} diff --git a/client/ui/select-label.js b/client/ui/select-label.js new file mode 100644 index 0000000..5dd20a2 --- /dev/null +++ b/client/ui/select-label.js @@ -0,0 +1,4 @@ +// Generated, do not edit. sha256:0076d8366cfc258ec98999c73e935ac15cfca0066278d4c19d6490ed44c8437b +import { CHEVRON } from './icons.js'; + +export const selectLabel = selectHtml => `${selectHtml}${CHEVRON}`; diff --git a/client/ui/shared-setup.js b/client/ui/shared-setup.js new file mode 100644 index 0000000..5b14a98 --- /dev/null +++ b/client/ui/shared-setup.js @@ -0,0 +1,61 @@ +// Generated, do not edit. sha256:b9399a3f08d3b6cb92193bf072e6f03992ddd419185b08d9e50b633d21567d88 +import { configureDialog } from './dialog.js'; +import { configureTabs } from './tabs.js'; +import { configureFilter } from './filter.js'; +import { configureLoading } from './loading.js'; +import { configureTable } from './table.js'; +import { openUnitConvert } from './unit-convert.js'; +import { hasSubunit } from './format.js'; +import { SWAP } from './icons.js'; +import { appSetting } from '../app-settings.js'; +import { renderErrorPanel } from './error-panel.js'; +import { isBlurred, unblur } from './blur.js'; +import { CLOSE, EYE_OFF } from './icons.js'; +import { t } from '../i18n/index.js'; + +export function mountBlurEye(d, done) { + const title = d.querySelector('.dialog-title'); + if (!title) return; + const sync = e => { + const on = e?.detail ? e.detail.on : isBlurred(); + const eye = title.querySelector('.dialog-eye'); + if (!on) { eye?.remove(); return; } + if (eye) return; + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'dialog-eye'; + b.title = t('blur.showAmounts'); + b.setAttribute('aria-label', t('blur.showAmounts')); + b.innerHTML = EYE_OFF; + b.addEventListener('click', () => unblur()); + title.appendChild(b); + }; + sync(); + document.addEventListener('blur-changed', sync); + done.finally(() => document.removeEventListener('blur-changed', sync)); +} + +export function configureSharedUi() { + configureDialog({ + labels: { + ok: t('dialog.ok'), cancel: t('dialog.cancel'), close: t('dialog.close'), next: t('dialog.next'), + back: t('dialog.back'), finish: t('dialog.finish'), skip: t('dialog.skip'), + }, + closeIcon: CLOSE, + onDetail: mountBlurEye, + }); + + configureTabs({ onError: renderErrorPanel }); + configureFilter({ labels: { all: t('filter.all'), none: t('filter.none') } }); + configureLoading({ labels: { loading: t('common.loading'), loadError: t('common.loadError') } }); + configureTable({ + labels: { + new: t('table.new'), save: t('table.save'), cancel: t('table.cancel'), none: t('table.none'), + search: t('table.search'), count: t('table.count'), edit: t('table.edit'), + }, + convert: { canConvert: hasSubunit, open: openUnitConvert, icon: SWAP }, + repeatKey: () => appSetting('repeat_entry_key'), + }); +} + +configureSharedUi(); diff --git a/client/ui/source-badge.js b/client/ui/source-badge.js new file mode 100644 index 0000000..c23169f --- /dev/null +++ b/client/ui/source-badge.js @@ -0,0 +1,13 @@ +// Generated, do not edit. sha256:c4f15e69aa0e49c8fbb2c22a84f818ac3c22099c20d6b44edd9364730bb3cf39 +import { t } from '../i18n/index.js'; + +const SCRIPT_BADGE = { p2tr: 'Taproot', p2wpkh: 'SegWit', 'p2sh-p2wpkh': 'Nested', p2pkh: 'Legacy' }; + +export function sourceBadgeText(s) { + switch (s?.kind) { + case 'xpub': return { label: SCRIPT_BADGE[s.scriptType] || 'xpub', title: null }; + case 'nwc': return { label: t('tracking.badgeNwc'), title: t('tracking.badgeNwcHint') }; + case 'lnd': return { label: t('tracking.badgeLnd'), title: t('tracking.badgeLndHint') }; + default: return { label: t('accounts.address'), title: null }; + } +} diff --git a/client/ui/source-matrix.js b/client/ui/source-matrix.js new file mode 100644 index 0000000..3a229e4 --- /dev/null +++ b/client/ui/source-matrix.js @@ -0,0 +1,147 @@ +// Generated, do not edit. sha256:e64fde93e62c826f1ddca6217aab270e6309de4394ce5cb0e258a0e0fdb72d5c +import { t } from '../i18n/index.js'; +import { listPriceSource, setPriceSourceRank, clearPriceSourceCell, confirmPriceSourceCell } from '../data/price-source.js'; +import { listProviders, getProvider } from '../data/providers.js'; +import { rateSourceFor } from '../price/providers.js'; +import { serviceCoverage } from '../data/market.js'; +import { SOURCE_CAPABILITIES, SOURCE_ENDPOINTS } from '../../shared/config.js'; +import { baseCurrency, appSetting } from '../app-settings.js'; +import { assetLabelHtml } from './asset-label.js'; +import { esc } from './escape.js'; +import * as dialog from './dialog.js'; + +const endpointsUnder = (p, cap) => (p.endpoints || []).filter(e => SOURCE_ENDPOINTS[cap].includes(e.capability)); +const answers = (p, cap) => endpointsUnder(p, cap).length > 0; + +function quoteOf(p, cap) { + const e = endpointsUnder(p, cap).find(x => x.quoteCurrency && x.quoteCurrency !== baseCurrency()); + return e ? e.quoteCurrency : null; +} +const currenciesOf = p => [...new Set(SOURCE_CAPABILITIES.map(c => quoteOf(p, c)).filter(Boolean))]; + +export async function loadMatrixContext() { + const alle = (await Promise.all((await listProviders()).map(p => getProvider(p.key)))).filter(Boolean); + const providers = alle.filter(p => !p.isSystem); + const cell = new Map(); + for (const r of await listPriceSource()) { + if (r.rank == null) continue; + cell.set(`${r.assetId}|${r.capability}|${r.providerId}`, + { rank: r.rank, proposed: r.status === 'proposed', own: r.status === 'manual' }); + } + const rateSource = new Map(); + for (const p of providers) { + for (const c of currenciesOf(p)) if (!rateSource.has(c)) rateSource.set(c, await rateSourceFor(c)); + } + return { alle, providers, cell, rateSource, service: await serviceContext() }; +} + +async function serviceContext() { + let mode = 'off'; + try { mode = appSetting('market_spot'); } catch { } + if (mode === 'off') return { mode, covered: new Set() }; + try { + const r = await serviceCoverage(); + return { mode, covered: new Set(r.status === 'ok' ? r.assetIds : []) }; + } catch { return { mode, covered: new Set() }; } +} + +export function serviceOrder(chosen, mode) { + const byRank = [...chosen].sort((x, y) => x.rank - y.rank); + const order = mode === 'first' + ? [...byRank.filter(c => c.own), 'service', ...byRank.filter(c => !c.own)] + : [...byRank, 'service']; + const ranks = new Map(); + let service = null; + order.forEach((c, i) => { if (c === 'service') service = i + 1; else ranks.set(c.providerId, i + 1); }); + return { ranks, service }; +} + +const marker = (ctx, v) => `${esc(v)}`; +const offTitle = p => (p.isActive ? '' : ` title="${esc(t('integrations.inactiveNoEffect'))}"`); + +function kopVan(a, stand, title = true) { + if (!title && !stand) return ''; + return `
    + ${title ? `
    ${assetLabelHtml(a)}${esc(t('enums.asset_kind.' + a.kind))}
    ` : ''} + ${stand ? `
    ${esc(stand)}
    ` : ''} +
    `; +} + +export function matrixCardHtml(a, ctx, { title = true } = {}) { + const cols = ctx.providers.filter(p => (p.coverage || []).includes(a.symbol)); + const svc = ctx.service && ctx.service.mode !== 'off' && ctx.service.covered.has(a.id); + if (!cols.length && !svc) { + const systeem = ctx.alle.find(p => p.isSystem && (p.coverage || []).includes(a.symbol)); + const tekst = systeem ? esc(t('integrations.systemSource').replace('{naam}', systeem.name)) : esc(t('integrations.noCoverage')); + return `
    ${kopVan(a, '', title)}
    ${tekst}
    `; + } + const svcTitle = svc ? esc(t('integrations.mxServiceHint_' + ctx.service.mode)) : ''; + const naamKop = `${cols.map(p => + `${esc(p.name)}`).join('')}${ + svc ? `Atreides Services` : ''}`; + const muntKop = `${cols.map(p => + `${currenciesOf(p).map(v => marker(ctx, v)).join(' ')}`).join('')}${svc ? '' : ''}`; + const head = naamKop + muntKop; + const rank = (c, p, shown = c.rank) => + `${shown}`; + let open = 0, voorstellen = 0; + const leeg = []; + const rows = SOURCE_CAPABILITIES.filter(cap => cols.some(p => answers(p, cap)) || (svc && cap === 'spot')); + const body = rows.map(cap => { + const metDienst = svc && cap === 'spot'; + const volgorde = metDienst + ? serviceOrder(cols.map(p => ({ providerId: p.id, ...ctx.cell.get(`${a.id}|${cap}|${p.id}`) })) + .filter(c => c.rank != null), ctx.service.mode) + : null; + if (!metDienst && !cols.some(p => ctx.cell.has(`${a.id}|${cap}|${p.id}`))) leeg.push(t('integrations.sourceCap.' + cap)); + const dienstCel = svc + ? (metDienst + ? `${volgorde.service}` + : ``) + : ''; + const cells = cols.map(p => { + if (!answers(p, cap)) return ``; + const c = ctx.cell.get(`${a.id}|${cap}|${p.id}`); + const valuta = quoteOf(p, cap); + if (valuta && !ctx.rateSource.get(valuta) && !c) { + return ``; + } + if (!c) open++; + else if (c.proposed) voorstellen++; + const inner = c ? rank(c, p, volgorde ? volgorde.ranks.get(p.id) : c.rank) : `+`; + return `${inner}`; + }).join(''); + return `${esc(t('integrations.sourceCap.' + cap))}${cells}${dienstCel}`; + }).join(''); + const stand = [ + leeg.length ? t('integrations.mxToSetUp', { wat: leeg.join(' en ') }) : '', + voorstellen ? t(voorstellen === 1 ? 'integrations.mxProposalsOne' : 'integrations.mxProposalsMany', { n: voorstellen }) : '', + open ? t(open === 1 ? 'integrations.mxOpenOne' : 'integrations.mxOpenMany', { n: open }) : t('integrations.mxAllChosen'), + ].filter(Boolean).join(' · '); + return `
    ${kopVan(a, stand.charAt(0).toUpperCase() + stand.slice(1), title)}
    ${head}${body}
    `; +} + +export function wireMatrix(host, redraw) { + host.querySelectorAll('.mx-cell').forEach(td => td.onclick = async () => { + const d = td.dataset; + const a = Number(d.a), p = Number(d.p); + try { + if (d.has === '1' && d.proposed === '1') await confirmPriceSourceCell(a, d.cap, p); + else if (d.has === '1') await clearPriceSourceCell(a, d.cap, p); + else await setPriceSourceRank(a, d.cap, p); + document.dispatchEvent(new CustomEvent('providers-changed')); + } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); } + await redraw(); + }); +} + +export function matrixLegendRows(ctx) { + const uit = ctx.providers.some(p => !p.isActive) + ? [{ sample: '1', text: esc(t('integrations.inactiveHint')) }] + : []; + const munten = [...ctx.rateSource].map(([v, via]) => ({ + sample: marker(ctx, v), + text: esc(via ? t('integrations.convertedVia', { naam: via.name, valuta: v, munt: baseCurrency() }) : t('integrations.noRateSource', { valuta: v })), + })); + return [...uit, ...munten]; +} diff --git a/client/ui/sparkline.js b/client/ui/sparkline.js new file mode 100644 index 0000000..74befa1 --- /dev/null +++ b/client/ui/sparkline.js @@ -0,0 +1,214 @@ +// Generated, do not edit. sha256:eb749d1dca35ea9b39abff8cba70a365f5bed2b3d725946f3e7eb2d00e35dd60 +import { esc } from './escape.js'; +import { fmtPct } from './format.js'; + +let uid = 0; + +const VIEW_W = 100, VIEW_H = 100, PAD_X = 0, PAD_Y = 3; + +const DAY_MS = 86400000; + +const GRAD_FADE_AT = 0.85; + +const SNAP_PX = 10; + +export function labelLeft(x, labelW, chartW, gap = 4) { + const half = labelW / 2 + gap; + if (half * 2 >= chartW) return chartW / 2; + return Math.min(chartW - half, Math.max(half, x)); +} + +export function magnetischeIndex(punten, markeringen, fx, tolerantie) { + let bestI = 0, bestD = Infinity; + punten.forEach((p, i) => { const d = Math.abs(p.fx - fx); if (d < bestD) { bestD = d; bestI = i; } }); + let markI = -1, markD = Infinity; + for (const i of markeringen) { + if (!punten[i]) continue; + const d = Math.abs(punten[i].fx - fx); + if (d < markD) { markD = d; markI = i; } + } + return (markI >= 0 && markD <= tolerantie) ? markI : bestI; +} + +export function trapPunten(punten) { + const uit = []; + for (let i = 0; i < punten.length; i++) { + if (i > 0 && Math.abs(punten[i - 1].py - punten[i].py) >= 0.01) { + uit.push({ px: punten[i].px, py: punten[i - 1].py }); + } + uit.push(punten[i]); + } + return uit; +} + +export function smoothPath(punten) { + const p = punten; + const n = p.length; + if (!n) return ''; + const f = v => v.toFixed(2); + if (n < 3) return `M${f(p[0].px)},${f(p[0].py)}` + (n === 2 ? ` L${f(p[1].px)},${f(p[1].py)}` : ''); + const h = [], s = []; + for (let i = 0; i < n - 1; i++) { + h.push(p[i + 1].px - p[i].px); + s.push(h[i] ? (p[i + 1].py - p[i].py) / h[i] : 0); + } + const m = [s[0]]; + for (let i = 1; i < n - 1; i++) { + if (s[i - 1] * s[i] <= 0) { m.push(0); continue; } + const w1 = 2 * h[i] + h[i - 1], w2 = h[i] + 2 * h[i - 1]; + m.push((w1 + w2) / (w1 / s[i - 1] + w2 / s[i])); + } + m.push(s[n - 2]); + let d = `M${f(p[0].px)},${f(p[0].py)}`; + for (let i = 0; i < n - 1; i++) { + const t = h[i] / 3; + d += ` C${f(p[i].px + t)},${f(p[i].py + m[i] * t)} ${f(p[i + 1].px - t)},${f(p[i + 1].py - m[i + 1] * t)} ${f(p[i + 1].px)},${f(p[i + 1].py)}`; + } + return d; +} + +export function mountSparkline(host, points, { tone = 'accent', formatValue = String, markers = [], vergelijk = null, + snap = [], onPick = null, yRange = null, smooth = true } = {}) { + host.innerHTML = ''; + if (!points || points.length < 2) return; + + const tweede = Array.isArray(vergelijk) && vergelijk.length === points.length ? vergelijk : null; + const ys = points.map(p => p[1]).concat(tweede ? tweede.map(p => p[1]) : []); + const minX = points[0][0], maxX = points[points.length - 1][0]; + const minY = yRange ? yRange[0] : Math.min(...ys), maxY = yRange ? yRange[1] : Math.max(...ys); + const spanX = (maxX - minX) || 1, spanY = (maxY - minY) || 1; + + const pts = points.map(([x, y]) => { + const px = PAD_X + ((x - minX) / spanX) * (VIEW_W - 2 * PAD_X); + const py = VIEW_H - PAD_Y - ((y - minY) / spanY) * (VIEW_H - 2 * PAD_Y); + return { fx: px / VIEW_W, fy: py / VIEW_H, px, py, price: y, x }; + }); + + const cmpPts2 = tweede ? tweede.map(([x, y]) => { + const px = PAD_X + ((x - minX) / spanX) * (VIEW_W - 2 * PAD_X); + const py = VIEW_H - PAD_Y - ((y - minY) / spanY) * (VIEW_H - 2 * PAD_Y); + return { fx: px / VIEW_W, fy: py / VIEW_H, px, py }; + }) : null; + + const markPts = cmpPts2 || pts; + const markIdx = []; + const gezien = new Set(); + let markHtml = ''; + for (const m of markers) { + const n = dichtstbij(pts, m?.x); + const variant = String(m?.variant || '').replace(/[^a-z0-9-]/gi, ''); + const label = m?.label == null ? '' : String(m.label); + const key = m?.key == null ? '' : String(m.key).replace(/[^a-z0-9_:-]/gi, ''); + const sleutel = `${variant}|${n}`; + if (n < 0 || gezien.has(sleutel)) continue; + gezien.add(sleutel); + markIdx.push(n); + markHtml += `
    `; + } + for (const x of snap) { const n = dichtstbij(pts, x); if (n >= 0) markIdx.push(n); } + + const linePts = pts.map(p => `${p.px.toFixed(2)},${p.py.toFixed(2)}`).join(' '); + const cmpPts = cmpPts2 + ? trapPunten(cmpPts2).map(p => `${p.px.toFixed(2)},${p.py.toFixed(2)}`).join(' ') + : ''; + const areaPts = `${pts[0].px.toFixed(2)},${VIEW_H} ${linePts} ${pts[pts.length - 1].px.toFixed(2)},${VIEW_H}`; + const gid = `spk-grad-${++uid}`; + const curve = smooth ? smoothPath(pts) : ''; + const lineAttrs = `fill="none" stroke="currentColor" stroke-width="1.5" + stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"`; + const areaSvg = smooth + ? `` + : ``; + const lineSvg = smooth + ? `` + : ``; + + const root = document.createElement('div'); + root.className = `spark-mini is-${tone}${onPick ? ' is-pickable' : ''}`; + root.innerHTML = ` + +
    + ${markHtml} + + +
    + `; + host.appendChild(root); + + const vline = root.querySelector('.spark-mini-vline'); + const dot = root.querySelector('.spark-mini-dot'); + const label = root.querySelector('.spark-mini-label'); + + function indexAt(e) { + const rect = root.getBoundingClientRect(); + if (!rect.width) return -1; + const fx = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); + return magnetischeIndex(pts, markIdx, fx, SNAP_PX / rect.width); + } + function show(e) { + const bestI = indexAt(e); + if (bestI < 0) return; + const best = pts[bestI]; + const leftPct = best.fx * 100, topPct = best.fy * 100; + vline.style.left = `${leftPct}%`; + dot.style.setProperty('--spark-x', `${leftPct}%`); + dot.style.top = `${topPct}%`; + label.textContent = formatValue(best.price, best.x, tweede ? tweede[bestI]?.[1] : undefined); + label.classList.toggle('is-below', best.fy < 0.5); + vline.hidden = dot.hidden = label.hidden = false; + const rw = root.clientWidth; + label.style.left = rw + ? `${labelLeft(best.fx * rw, label.offsetWidth, rw)}px` + : `${Math.min(88, Math.max(12, leftPct))}%`; + label.style.top = `${topPct}%`; + } + function hide() { vline.hidden = dot.hidden = label.hidden = true; } + + root.addEventListener('pointermove', show); + root.addEventListener('pointerdown', show); + root.addEventListener('pointerleave', hide); + root.addEventListener('pointercancel', hide); + if (onPick) root.addEventListener('click', e => { const i = indexAt(e); if (i >= 0) onPick(pts[i].x, i); }); +} + +function dichtstbij(pts, x) { + let best = -1, bestD = Infinity; + for (let i = 0; i < pts.length; i++) { + const d = Math.abs(pts[i].x - x); + if (d < bestD) { bestD = d; best = i; } + } + return best; +} + +export function withSpot(points, price, nowMs = Date.now()) { + if (!Array.isArray(points) || !points.length) return points; + if (!Number.isFinite(price) || price <= 0) return points; + const vandaag = Math.floor(nowMs / DAY_MS) * DAY_MS; + const laatste = points[points.length - 1][0]; + if (laatste > vandaag) return points; + if (laatste === vandaag) return [...points.slice(0, -1), [vandaag, price]]; + return [...points, [vandaag, price]]; +} + +export const sliceTone = slice => (slice[slice.length - 1][1] >= slice[0][1] ? 'up' : 'down'); + +export function changePctHtml(slice, suffixHtml = '', { fine = false, signs = 'negative' } = {}) { + if (!slice || slice.length < 2) return ''; + const first = slice[0][1], last = slice[slice.length - 1][1]; + if (!(first > 0)) return ''; + const change = (last - first) / first; + return `${fmtPct(change, { fine, signs })}${suffixHtml}`; +} diff --git a/client/ui/splash.js b/client/ui/splash.js new file mode 100644 index 0000000..005b895 --- /dev/null +++ b/client/ui/splash.js @@ -0,0 +1,97 @@ +// Generated, do not edit. sha256:a433ae61cf1d51dd5a396f13922a2aaa329bfea96e00b35d9456a671a84c5e04 +import { CONFIG } from '../../shared/config.js'; +import { t } from '../i18n/index.js'; + +const bootT0 = performance.now(); + +(function splashGateFallback() { + const html = document.documentElement; + if (html.dataset.splash === 'on') return; + try { + const KEY = 'splash_last_shown'; + const d = new Date(); + const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + const force = location.search.includes('splash') || location.hash.includes('splash'); + if (force || localStorage.getItem(KEY) !== today) { + console.warn('[splash] boot-gate.js draaide niet (niet geladen of oude index.html uit de cache?); vangnet in ui/splash.js toont de splash.'); + localStorage.setItem(KEY, today); + html.dataset.splash = 'on'; + } + } catch { html.dataset.splash = 'on'; } +})(); + +let typeTimer = 0; + +const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +function fadeOut(splash) { + splash.style.transitionDuration = `${CONFIG.SPLASH.FADE_MS}ms`; + splash.classList.add('is-done'); + setTimeout(() => reset(splash), CONFIG.SPLASH.FADE_MS + 50); +} + +function reset(splash) { + clearTimeout(typeTimer); + delete document.documentElement.dataset.splash; + splash.classList.remove('is-done', 'is-manual', 'is-typed'); + splash.style.transitionDuration = ''; + const out = splash.querySelector('.splash-quote-text'); + if (out) out.textContent = ''; +} + +export function finishSplash() { + const splash = document.getElementById('splash'); + if (!splash || document.documentElement.dataset.splash !== 'on') return; + const wait = Math.max(0, CONFIG.SPLASH.MIN_MS - (performance.now() - bootT0)); + setTimeout(() => fadeOut(splash), wait); +} + +function showManualSplash() { + const splash = document.getElementById('splash'); + if (!splash || document.documentElement.dataset.splash === 'on') return; + splash.classList.add('is-manual'); + document.documentElement.dataset.splash = 'on'; + + const out = splash.querySelector('.splash-quote-text'); + if (!out) console.warn('[splash] Citaat-element ontbreekt (oude index.html uit de cache?); één keer hard verversen.'); + const text = t('splash.quote'); + const done = () => splash.classList.add('is-typed'); + if (!out) { + } else if (reducedMotion()) { + out.textContent = text; + done(); + } else { + let i = 0; + const step = () => { + out.textContent = text.slice(0, ++i); + if (i >= text.length) return done(); + const pause = CONFIG.SPLASH.QUOTE_CHAR_MS + + (text[i - 1] === ' ' ? CONFIG.SPLASH.QUOTE_WORD_PAUSE_MS : 0); + typeTimer = setTimeout(step, pause); + }; + typeTimer = setTimeout(step, CONFIG.SPLASH.QUOTE_START_MS); + } + + const ctl = new AbortController(); + const close = () => { ctl.abort(); fadeOut(splash); }; + splash.addEventListener('click', close, { signal: ctl.signal }); + window.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); }, { signal: ctl.signal }); +} + +export function splashMarkHtml() { + return ``; +} + +export function initSplashReplay() { + for (const el of document.querySelectorAll('.sidebar-logo, .mobile-topbar-logo')) { + el.title = t('splash.logoHint'); + el.addEventListener('click', showManualSplash); + } +} diff --git a/client/ui/status-bar.js b/client/ui/status-bar.js new file mode 100644 index 0000000..6ddbfd9 --- /dev/null +++ b/client/ui/status-bar.js @@ -0,0 +1,179 @@ +// Generated, do not edit. sha256:ab0e4307745aa40cfdb48df36e50ccead564b09ed673bdaf465f9ded7d5651dd +import { listProviders, getProvider } from '../data/providers.js'; +import { t } from '../i18n/index.js'; +import { CONFIG, TARGETS, targetHash } from '../../shared/config.js'; +import { appSetting } from '../app-settings.js'; +import { initPrivacyBlur } from './blur.js'; +import { generalAttention, attentionHasProblem, attentionDimmed } from './attention.js'; +import { openPopover, closePopover } from './popover.js'; +import { esc } from './escape.js'; + +const FADE_OK_MS = 2600; +const FADE_FAIL_MS = 6000; +const LABEL_MS = 2600; +const MAX_COLS = 6; +const MAX_ROWS = 3; +const MOBILE_MQ = '(max-width: 768px)'; + +let rootEl = null; +let dotsEl = null; +let labelEls = []; +let dotTimers = new Map(); +let labelSlots = []; +let dotKeys = ''; +let attnEl = null; +let labelActive = 0; +let attnMenuOpen = false; + +async function callableProviders() { + const rijen = await listProviders(); + const configs = await Promise.all(rijen.map(r => getProvider(r.key))); + return configs.filter(p => p && p.isActive && ((!p.isSystem && p.endpoints.length > 0) || isAskedService(p))); +} + +function isAskedService(p) { + if (p.key !== CONFIG.MARKET_SERVICE.PROVIDER_KEY) return false; + try { return appSetting('market_spot') !== 'off'; } catch { return false; } +} + +function rowsFor(n) { + if (n <= 1) return 1; + if (n <= MAX_COLS * 2) return 2; + return MAX_ROWS; +} + +async function syncDots() { + const provs = await callableProviders(); + const keys = provs.map(p => p.key).join('|'); + if (keys === dotKeys) return; + dotKeys = keys; + const rows = rowsFor(provs.length); + const cols = Math.max(1, Math.ceil(provs.length / rows)); + dotsEl.style.setProperty('--sb-rows', String(rows)); + rootEl.style.setProperty('--sb-vak', `max(2.1rem, ${cols * 7 + (cols - 1) * 5}px)`); + dotsEl.innerHTML = provs.map(p => + `` + ).join(''); + dotTimers.clear(); +} + +function lightDot(key, ok) { + const dot = dotsEl.querySelector(`[data-provider-key="${CSS.escape(key)}"]`); + if (!dot) return; + dot.classList.remove('is-active', 'is-ok', 'is-fail'); + void dot.offsetWidth; + dot.classList.add('is-active', ok ? 'is-ok' : 'is-fail'); + const prev = dotTimers.get(key); + if (prev) clearTimeout(prev); + dotTimers.set(key, setTimeout(() => dot.classList.remove('is-active', 'is-ok', 'is-fail'), + ok ? FADE_OK_MS : FADE_FAIL_MS)); +} + +function showSymbol(sym) { + if (!sym) return; + let slot = labelSlots.find(s => s.ts === 0); + if (!slot) slot = labelSlots[0].ts <= labelSlots[1].ts ? labelSlots[0] : labelSlots[1]; + slot.ts = Date.now(); + slot.el.textContent = sym; + slot.el.classList.remove('is-flash'); + void slot.el.offsetWidth; + slot.el.classList.add('is-flash'); + labelActive++; + syncAttention(); + if (slot.timer) clearTimeout(slot.timer); + slot.timer = setTimeout(() => { + slot.el.classList.remove('is-flash'); slot.el.textContent = ''; slot.ts = 0; + labelActive = Math.max(0, labelActive - 1); + syncAttention(); + }, LABEL_MS); +} + +function syncAttention() { + if (!attnEl) return; + const items = generalAttention(); + const show = items.length > 0 && labelActive === 0; + const dim = attentionDimmed(items); + attnEl.textContent = items.length >= 2 ? String(items.length) : '!'; + attnEl.classList.toggle('is-shown', show); + attnEl.classList.toggle('is-count', items.length >= 2); + attnEl.classList.toggle('is-dim', dim); + attnEl.classList.toggle('is-problem', attentionHasProblem(items)); + attnEl.setAttribute('aria-hidden', show ? 'false' : 'true'); + attnEl.tabIndex = show ? 0 : -1; + if (!show && attnMenuOpen) closePopover(); +} + +function openAttentionMenu() { + if (attnMenuOpen) return; + const items = generalAttention(); + if (!items.length) return; + const { el, position } = openPopover(attnEl, { + className: 'popover attn-menu', + onClose: () => { attnMenuOpen = false; }, + }); + attnMenuOpen = true; + el.innerHTML = + `
    ${esc(t('attention.title'))}
    ` + + items.map(a => ``).join(''); + position(); + el.addEventListener('click', (e) => { + const b = e.target.closest('[data-hash]'); + if (!b) return; + closePopover(); + if (b.dataset.hash) location.hash = b.dataset.hash; + }); +} + +function place(mq) { + const slot = document.getElementById(mq.matches ? 'sb-slot-mobile' : 'sb-slot-desktop'); + if (slot && rootEl.parentElement !== slot) slot.appendChild(rootEl); +} + +export function initStatusBar() { + if (rootEl) return; + rootEl = document.createElement('div'); + rootEl.className = 'statusbar'; + rootEl.setAttribute('role', 'group'); + rootEl.setAttribute('aria-label', t('integrations.indicatorHint')); + rootEl.innerHTML = + '
    ' + + '' + + '
    ' + + '' + + '
    ' + + '' + + '
    '; + + dotsEl = rootEl.querySelector('.statusbar-dots'); + labelEls = [...rootEl.querySelectorAll('.statusbar-label')]; + labelSlots = labelEls.map(el => ({ el, ts: 0, timer: null })); + attnEl = rootEl.querySelector('.statusbar-attn'); + + attnEl.addEventListener('click', openAttentionMenu); + + initPrivacyBlur(rootEl.querySelector('.statusbar-blur')); + syncDots(); + + dotsEl.addEventListener('click', (e) => { + const dot = e.target.closest('[data-provider-key]'); + if (!dot) return; + try { sessionStorage.setItem('integrations.focus', dot.dataset.providerKey); } catch { } + location.hash = targetHash(TARGETS.INTEGRATIONS); + }); + + document.addEventListener('api-call', (e) => { + const d = e.detail || {}; + if (d.provider_key) lightDot(d.provider_key, !!d.ok); + if (d.symbol) showSymbol(d.symbol); + }); + + document.addEventListener('providers-changed', syncDots); + window.addEventListener('hashchange', syncDots); + + document.addEventListener('attention-changed', syncAttention); + syncAttention(); + + const mq = window.matchMedia(MOBILE_MQ); + mq.addEventListener('change', () => place(mq)); + place(mq); +} diff --git a/client/ui/table-edit-values.js b/client/ui/table-edit-values.js new file mode 100644 index 0000000..5685085 --- /dev/null +++ b/client/ui/table-edit-values.js @@ -0,0 +1,24 @@ +// Generated, do not edit. sha256:b5a4abd8ddb07f0a2874418e2afd4b67b87a2f07cb6b67fe7d38692911fdc843 +export function seedValues(columns, row, newDefaults) { + const v = {}; + for (const c of columns) { + if (!c.edit) continue; + v[c.key] = c.edit.value ? c.edit.value(row) : (row ? row[c.key] : undefined); + } + if (!row && newDefaults) Object.assign(v, newDefaults); + return v; +} + +export function normalizeOptions(list) { + return (list || []).map(x => ((typeof x === 'object') ? x : { value: x, label: x })); +} + +export async function loadOptions(columns, vals, cache) { + cache.clear(); + for (const col of columns) { + if (!col.edit || col.edit.options === undefined) continue; + const o = typeof col.edit.options === 'function' ? await col.edit.options(vals) : col.edit.options; + cache.set(col.key, normalizeOptions(o)); + } + return cache; +} diff --git a/client/ui/table-edit.js b/client/ui/table-edit.js new file mode 100644 index 0000000..60e22ba --- /dev/null +++ b/client/ui/table-edit.js @@ -0,0 +1,67 @@ +// Generated, do not edit. sha256:374bda270315c76a934d2540ed9d4724c841659867d96ba0c265c5a8c61bbca3 +import { esc } from './escape.js'; +import { cellClassAttr } from './table-markup.js'; + +export function editInputHtml(col, vals, { options = [], convert = null } = {}) { + const e = col.edit; + const cur = vals[col.key]; + if (e.render) return e.render(vals); + const react = e.reactive ? ' data-reactive="1"' : ''; + const extra = e.attrs ? ' ' + Object.entries(e.attrs).map(([k, v]) => `${k}="${esc(v)}"`).join(' ') : ''; + const phVal = typeof e.placeholder === 'function' ? e.placeholder(vals) : e.placeholder; + const ph = phVal ? ` placeholder="${esc(phVal)}"` : ''; + if (e.type === 'select') { + const opts = options.map(o => + ``).join(''); + return ``; + } + if (e.type === 'checkbox') { + return ``; + } + const val = cur == null ? '' : esc(cur); + if (e.type === 'textarea') { + return ``; + } + const type = e.type === 'number' ? 'number' : (e.type === 'date' ? 'date' : (e.type === 'time' ? 'time' : 'text')); + const inputHtml = ``; + if (!e.convert || !convert || !convert.canConvert(e.convert(vals))) return inputHtml; + const cvLabel = esc(convert.label || ''); + return `
    ${inputHtml}
    `; +} + +export function editCellHtml(col, vals, opts) { + const vis = !col.edit.visible || col.edit.visible(vals); + if (col.edit.expand) return ``; + if (!vis) return ``; + return `${editInputHtml(col, vals, opts)}`; +} + +export function expandRowHtml({ editableCols, vals, error, colCount, optsFor, actions = '' }) { + const fields = editableCols + .filter(c => c.edit.expand && (!c.edit.visible || c.edit.visible(vals))) + .map(c => { + const full = c.edit.type === 'textarea' || c.edit.full; + return `
    + ${editInputHtml(c, vals, optsFor(c))} +
    `; + }).join(''); + const err = error ? `
    ${esc(error)}
    ` : ''; + if (!fields && !err && !actions) return ''; + const fieldsHtml = fields ? `
    ${fields}
    ` : ''; + return ` + ${fieldsHtml}${err}${actions} + `; +} + +export function captureValues(scope, editableCols, vals) { + for (const c of editableCols) { + if (c.edit.read) { + vals[c.key] = c.edit.read(scope.querySelector(`[data-field="${c.key}"]`)); + continue; + } + const node = scope.querySelector(`[data-field="${c.key}"]`); + if (!node) continue; + vals[c.key] = node.type === 'checkbox' ? node.checked : node.value; + } + return vals; +} diff --git a/client/ui/table-focus.js b/client/ui/table-focus.js new file mode 100644 index 0000000..534012d --- /dev/null +++ b/client/ui/table-focus.js @@ -0,0 +1,46 @@ +// Generated, do not edit. sha256:6afcbeb7660ad5dcdb3d575aeb4495b0e547e5f54ce5b5f18abe560cc6fb7b7e +const FOCUS_ANCHORS = ['data-field', 'data-convert', 'data-save', 'data-cancel']; +const SELECTION_TYPES = new Set(['text', 'search', 'url', 'tel', 'password']); + +function hasSelection(node) { + if (!node) return false; + if (node.tagName === 'TEXTAREA') return true; + return node.tagName === 'INPUT' && SELECTION_TYPES.has(node.type); +} + +function focusAnchor(node) { + for (const attr of FOCUS_ANCHORS) { + const host = node.closest(`[${attr}]`); + if (!host) continue; + const value = host.getAttribute(attr); + return { sel: value ? `[${attr}="${value}"]` : `[${attr}]`, node: host }; + } + return null; +} + +export function captureFocus(el, editing) { + if (!editing) return null; + const active = document.activeElement; + if (!active || !el.contains(active)) return null; + const anchor = focusAnchor(active); + if (!anchor) return null; + if (!hasSelection(anchor.node)) return { sel: anchor.sel, start: null, end: null }; + return { sel: anchor.sel, start: anchor.node.selectionStart, end: anchor.node.selectionEnd }; +} + +export function restoreFocus(el, snap) { + if (!snap) return; + const field = el.querySelector(snap.sel); + if (!field || typeof field.focus !== 'function') return; + field.focus({ preventScroll: true }); + if (snap.start == null || !hasSelection(field)) return; + try { field.setSelectionRange(snap.start, snap.end); } catch { } +} + +const FOCUSABLE = 'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled])'; +export function focusFirstField(el) { + const row = el.querySelector('[data-edit-row]'); + if (!row) return; + const field = row.querySelector(FOCUSABLE) || el.querySelector(`.edit-expand-row ${FOCUSABLE}`); + if (field) field.focus(); +} diff --git a/client/ui/table-groups.js b/client/ui/table-groups.js new file mode 100644 index 0000000..72293f9 --- /dev/null +++ b/client/ui/table-groups.js @@ -0,0 +1,57 @@ +// Generated, do not edit. sha256:50118a60294dbc8f28d9357f89a87533bb1550041305794ac6ceb8f28c573fcc +import { compare } from './table-rows.js'; + +const SEP = '\u0000'; + +export function groupSections(rows, { columns, groupKey, groupKey2 = null, groupKey3 = null, sort = null, collapsed = null }) { + const col = columns.find(c => c.key === groupKey); + const col2 = groupKey2 ? columns.find(c => c.key === groupKey2) : null; + const col3 = groupKey3 ? columns.find(c => c.key === groupKey3) : null; + const orderOf = (c, key) => ((sort && sort.key === key) ? sort.dir : (c.group.order || 'asc')); + const order = orderOf(col, groupKey); + const order2 = col2 ? orderOf(col2, groupKey2) : 'asc'; + const order3 = col3 ? orderOf(col3, groupKey3) : 'asc'; + + const buckets = new Map(); + for (const r of rows) { + const v1 = col.group.value(r); + const v2 = col2 ? col2.group.value(r) : null; + const v3 = col3 ? col3.group.value(r) : null; + const key = [String(v1), col2 ? String(v2) : '', col3 ? String(v3) : ''].join(SEP); + if (!buckets.has(key)) buckets.set(key, { v1, v2, v3, items: [] }); + buckets.get(key).items.push(r); + } + const entries = [...buckets.values()].sort((a, b) => { + const c1 = compare(a.v1, b.v1, order); + if (c1 !== 0 || !col2) return c1; + const c2 = compare(a.v2, b.v2, order2); + return (c2 !== 0 || !col3) ? c2 : compare(a.v3, b.v3, order3); + }); + + let itemsByV1 = null; + if (collapsed) { + itemsByV1 = new Map(); + for (const e of entries) { + const tk = String(e.v1); + if (!itemsByV1.has(tk)) itemsByV1.set(tk, []); + itemsByV1.get(tk).push(...e.items); + } + } + const shownCollapsed = new Set(); + const out = []; + for (const { v1, v2, v3, items } of entries) { + const token = String(v1); + const l1 = col.group.label ? col.group.label(v1) : v1; + if (collapsed && collapsed.has(token)) { + if (shownCollapsed.has(token)) continue; + shownCollapsed.add(token); + out.push({ token, value: v1, label: l1, items: itemsByV1.get(token) || items, collapsed: true }); + continue; + } + let label = l1; + if (col2) label += ` - ${col2.group.label ? col2.group.label(v2) : v2}`; + if (col3) label += ` - ${col3.group.label ? col3.group.label(v3) : v3}`; + out.push({ token, value: v1, label, items, collapsed: false }); + } + return out; +} diff --git a/client/ui/table-markup.js b/client/ui/table-markup.js new file mode 100644 index 0000000..9223d28 --- /dev/null +++ b/client/ui/table-markup.js @@ -0,0 +1,112 @@ +// Generated, do not edit. sha256:a4f2c112e439e3cff424dfd351eedcec45c341af567633d67ac86c751e1e4999 +import { esc } from './escape.js'; + +export function cellClasses(col) { + const classes = []; + if (col.align === 'num') classes.push('num'); + else if (col.align === 'center') classes.push('center'); + if (col.text) classes.push('text'); + return classes; +} + +export function cellClassAttr(col) { + const classes = cellClasses(col); + return classes.length ? ` class="${classes.join(' ')}"` : ''; +} + +export function cellHtml(col, row) { + const raw = col.render ? col.render(row) : esc(row[col.key] ?? ''); + const content = col.text ? `
    ${raw}
    ` : raw; + return `${content}`; +} + +export function expCellHtml(hasExpansion, isOpen, chevron) { + if (!hasExpansion) return ''; + return ``; +} + +export function headRowHtml({ columns, sort, hasExpansion, hasActions }) { + const ths = columns.map(col => { + const classes = cellClasses(col); + const label = esc(col.label || ''); + if (!col.sortable) return `${label}`; + const active = sort && sort.key === col.key; + const arrow = active ? `` : ''; + const ariaSort = active ? ` aria-sort="${sort.dir === 'asc' ? 'ascending' : 'descending'}"` : ''; + return `${label}${arrow}`; + }).join(''); + const expTh = hasExpansion ? '' : ''; + return `${expTh}${ths}${hasActions ? '' : ''}`; +} + +const PLUS = ''; + +export function controlsHtml({ cfg, labels, canCreate, groupCols, groupKey, groupKey2, groupKey3, search, editing }) { + const dis = editing ? ' disabled' : ''; + const newLabel = esc(cfg.newLabel || labels.new); + const newBtn = canCreate + ? `` + : ''; + const noteText = typeof cfg.toolbarNote === 'function' ? cfg.toolbarNote() : cfg.toolbarNote; + const note = noteText ? `${esc(noteText)}` : ''; + const toolbarBtns = (cfg.toolbarActions || []).map(a => a.icon + ? `` + : ``).join(''); + let group = ''; + if (groupCols.length && cfg.groupChoice !== false) { + const noneText = esc(cfg.noneLabel || labels.none); + const aria = ` aria-label="${esc(cfg.groupLabel || cfg.noneLabel || labels.none)}"`; + const none = ``; + const opt = (c, chosen) => ``; + const select = (level, cols, chosen) => + ``; + group = select(1, groupCols, groupKey); + if (groupKey) { + group += select(2, groupCols.filter(c => c.key !== groupKey), groupKey2); + if (groupKey2) { + const cols3 = groupCols.filter(c => c.key !== groupKey && c.key !== groupKey2); + if (cols3.length) group += select(3, cols3, groupKey3); + } + } + } + const searchText = esc(cfg.searchLabel || labels.search); + const searchBox = ``; + const filterSlot = groupKey && cfg.groupChoice !== false && cfg.columns.some(c => c.key === groupKey) + ? '
    ' : ''; + const countBox = `
    ${cfg.rows.length}
    `; + return `
    ${newBtn}${note}${toolbarBtns}${group}${searchBox}${filterSlot}${countBox}
    `; +} + +const sameId = (a, b) => a != null && b != null && String(a) === String(b); + +export function editActionsHtml({ cfg, labels }) { + return `
    + + +
    `; +} + +export function actionsCellHtml({ cfg, labels, row, idKey, isEditRow, editing, selectedId }) { + if (isEditRow || editing) return ''; + const id = row[idKey]; + if (!sameId(id, selectedId)) return ''; + const eid = esc(id); + const extra = (cfg.extraActions || []).map(a => + ``).join(''); + const edit = (cfg.onSave && (!cfg.rowEditable || cfg.rowEditable(row))) ? `` : ''; + const del = (cfg.onDelete && (!cfg.rowDeletable || cfg.rowDeletable(row))) ? `` : ''; + return `
    ${extra}${edit}${del}
    `; +} + +export function groupHeaderRowHtml({ token, label, count, summaryHtml = '', isCollapsed = false, collapsible, colCount, chevron }) { + const chev = collapsible ? `` : ''; + const attrs = collapsible ? ` data-gcollapse="${esc(token)}" role="button" tabindex="0"` : ''; + const sum = summaryHtml ? `${summaryHtml}` : ''; + return `
    ${chev}${esc(label)}${count}${sum}
    `; +} + +export function frameHtml({ controls, head, body, hasExpansion, title = '' }) { + const wrapClasses = hasExpansion ? ' sticky-sel has-exp' : ''; + const titleHtml = title ? `
    ${esc(title)}
    ` : ''; + return `
    ${titleHtml}${controls}
    ${head}${body}
    `; +} diff --git a/client/ui/table-rows.js b/client/ui/table-rows.js new file mode 100644 index 0000000..a4cffdb --- /dev/null +++ b/client/ui/table-rows.js @@ -0,0 +1,61 @@ +// Generated, do not edit. sha256:6f5f28d079f7f6e64bd296677a36276bfb053415d4ab690712c66e663e2b15d6 +export const valueOf = (col, row) => (col.sortValue ? col.sortValue(row) : row[col.key]); + +export function compare(a, b, dir) { + if (a == null && b == null) return 0; + if (a == null) return 1; + if (b == null) return -1; + const r = (typeof a === 'number' && typeof b === 'number') + ? a - b + : String(a).localeCompare(String(b), 'nl', { numeric: true }); + return dir === 'desc' ? -r : r; +} + +export function sortRows(rows, columns, sort) { + if (!sort) return rows.slice(); + const col = columns.find(c => c.key === sort.key); + if (!col) return rows.slice(); + return rows.slice().sort((x, y) => compare(valueOf(col, x), valueOf(col, y), sort.dir)); +} + +export function rowText(row, columns) { + return columns.map(c => { + const v = c.render ? c.render(row) : valueOf(c, row); + return String(v == null ? '' : v).replace(/<[^>]*>/g, ' '); + }).join(' ').toLowerCase(); +} + +export function filterRows(rows, columns, { search = '', groupKey = null, hidden = new Set() } = {}) { + const q = search.trim().toLowerCase(); + let out = q ? rows.filter(r => rowText(r, columns).includes(q)) : rows; + if (groupKey && hidden.size) { + const col = columns.find(c => c.key === groupKey); + if (col) out = out.filter(r => !hidden.has(String(col.group.value(r)))); + } + return out; +} + +export function groupValues(rows, col, order = 'asc') { + const seen = new Map(); + for (const r of rows) { + const v = col.group.value(r); + const tok = String(v); + if (!seen.has(tok)) seen.set(tok, v); + } + return [...seen.entries()] + .map(([token, value]) => ({ token, value, label: col.group.label ? col.group.label(value) : value })) + .sort((a, b) => compare(a.value, b.value, order)); +} + +export const toggleToken = (token, list = []) => + (list.includes(token) ? list.filter(t => t !== token) : [...list, token]); + +export function cycleGroupToken(token, hiddenList = [], collapsedList = []) { + const hidden = new Set(hiddenList); + const collapsed = new Set(collapsedList); + const shown = !hidden.has(token); + if (shown && !collapsed.has(token)) collapsed.add(token); + else if (shown) { collapsed.delete(token); hidden.add(token); } + else { hidden.delete(token); collapsed.delete(token); } + return { hidden: [...hidden], collapsed: [...collapsed] }; +} diff --git a/client/ui/table-state.js b/client/ui/table-state.js new file mode 100644 index 0000000..baa7c89 --- /dev/null +++ b/client/ui/table-state.js @@ -0,0 +1,48 @@ +// Generated, do not edit. sha256:3da7d727d9cd395ad6f678ed5763ee67308c18ed602c6296f49806e3a2cada5f +export function initialState(cfg) { + const columnExists = key => !!key && cfg.columns.some(c => c.key === key); + const groupable = key => !!key && cfg.columns.some(c => c.key === key && c.group); + const usableSort = s => ((s && columnExists(s.key)) ? { ...s } : null); + return { + sort: usableSort(cfg.sort) || usableSort(cfg.defaultSort), + groupKey: groupable(cfg.group) ? cfg.group : null, + groupKey2: groupable(cfg.group2) ? cfg.group2 : null, + groupKey3: groupable(cfg.group3) ? cfg.group3 : null, + groupFilter: cfg.groupFilter ? { ...cfg.groupFilter } : {}, + groupCollapsed: cfg.groupCollapsed ? { ...cfg.groupCollapsed } : {}, + sticky: cfg.sticky ? { ...cfg.sticky } : null, + }; +} + +export function chooseGroup(keys, level, value) { + let { groupKey, groupKey2, groupKey3 } = keys; + const v = value || null; + if (level === 1) { + groupKey = v; + if (!groupKey) { groupKey2 = null; groupKey3 = null; } + else { if (groupKey2 === groupKey) groupKey2 = null; if (groupKey3 === groupKey) groupKey3 = null; } + } else if (level === 2) { + groupKey2 = v; + if (!groupKey2) groupKey3 = null; + else if (groupKey3 === groupKey2) groupKey3 = null; + } else if (level === 3) { + groupKey3 = v; + } + return { groupKey, groupKey2, groupKey3 }; +} + +export function revealGroup({ groupKey, groupFilter, groupCollapsed }, token) { + if (!groupKey) return { groupFilter, groupCollapsed, changed: false }; + let changed = false; + const hidden = groupFilter[groupKey] || []; + if (hidden.includes(token)) { + groupFilter = { ...groupFilter, [groupKey]: hidden.filter(t => t !== token) }; + changed = true; + } + const collapsed = groupCollapsed[groupKey] || []; + if (collapsed.includes(token)) { + groupCollapsed = { ...groupCollapsed, [groupKey]: collapsed.filter(t => t !== token) }; + changed = true; + } + return { groupFilter, groupCollapsed, changed }; +} diff --git a/client/ui/table-sticky.js b/client/ui/table-sticky.js new file mode 100644 index 0000000..6d39c3b --- /dev/null +++ b/client/ui/table-sticky.js @@ -0,0 +1,26 @@ +// Generated, do not edit. sha256:0727197b9fd0ac80c6a541e3be09548dc3b7f76576643552b5f17b4dbd19dd61 +export function seedSticky({ columns, seeded, remembered, options }) { + const out = { ...seeded }; + if (!remembered) return out; + for (const col of columns) { + if (!col.edit || !col.edit.sticky) continue; + if (!Object.prototype.hasOwnProperty.call(remembered, col.key)) continue; + const value = remembered[col.key]; + if (value === undefined) continue; + const list = options && typeof options.get === 'function' ? options.get(col.key) : null; + if (list && !list.some(o => String(o.value) === String(value))) continue; + out[col.key] = value; + } + return out; +} + +export function harvestSticky(columns, vals) { + const out = {}; + if (!vals) return out; + for (const col of columns) { + if (!col.edit || !col.edit.sticky) continue; + if (vals[col.key] === undefined) continue; + out[col.key] = vals[col.key]; + } + return out; +} diff --git a/client/ui/table.js b/client/ui/table.js new file mode 100644 index 0000000..2f5e542 --- /dev/null +++ b/client/ui/table.js @@ -0,0 +1,542 @@ +// Generated, do not edit. sha256:b90f82710740940a1caabe2a9b3167bc6ea309d53e7f934fd79a4afdb106eabc +import { openPopover, closePopover, isPopoverOpen } from './popover.js'; +import { isDialogOpen } from './dialog.js'; +import { create as createFilter } from './filter.js'; +import { esc } from './escape.js'; +import { seedSticky, harvestSticky } from './table-sticky.js'; +import * as tableRows from './table-rows.js'; +import { initialState, chooseGroup, revealGroup } from './table-state.js'; +import { groupSections } from './table-groups.js'; +import { seedValues, loadOptions } from './table-edit-values.js'; +import { cellHtml, expCellHtml, headRowHtml, controlsHtml, actionsCellHtml, editActionsHtml, groupHeaderRowHtml, frameHtml } from './table-markup.js'; +import { editCellHtml, expandRowHtml, captureValues } from './table-edit.js'; +import { captureFocus as captureTableFocus, restoreFocus as restoreTableFocus, focusFirstField as focusFirstEditField } from './table-focus.js'; + +const CHEVRON = ''; + +const DEFAULT_LABELS = Object.freeze({ + new: 'New', save: 'Save', cancel: 'Cancel', none: 'Ungrouped', search: 'Search…', count: 'Records', edit: 'Edit', +}); +let labels = { ...DEFAULT_LABELS }; +let convertHook = null; +let repeatKeyOf = () => 'ctrl_enter'; +let storagePrefix = 'table.'; + +export function configureTable({ labels: l = null, convert = null, repeatKey = null, storagePrefix: prefix = null } = {}) { + labels = { ...DEFAULT_LABELS, ...(l || {}) }; + convertHook = convert; + repeatKeyOf = typeof repeatKey === 'function' ? repeatKey : () => 'ctrl_enter'; + storagePrefix = prefix || 'table.'; +} + +function readState(key) { + if (!key) return null; + try { return JSON.parse(globalThis.localStorage?.getItem(storagePrefix + key) ?? 'null'); } catch { return null; } +} +function writeState(key, state) { + if (!key) return; + try { globalThis.localStorage?.setItem(storagePrefix + key, JSON.stringify(state)); } catch { } +} + +const sameId = (a, b) => a != null && b != null && String(a) === String(b); + +const OWN_CLICK = 'button, a, input, select, textarea, label, summary'; + +const NEW = Symbol('new'); + +export function keyAction({ key, ctrl, tag, isButton, popoverOpen, dialogOpen, repeatKey }) { + const overlay = !!popoverOpen || !!dialogOpen; + if (key === 'Enter') { + if (overlay) return null; + if (isButton && !ctrl) return null; + if (tag === 'TEXTAREA' && !ctrl) return null; + const repeatOnBare = repeatKey === 'enter'; + if (ctrl) return repeatOnBare ? 'save' : 'saveNext'; + return repeatOnBare ? 'saveNext' : 'save'; + } + if (key === 'Escape') return overlay ? null : 'cancel'; + return null; +} + +export function renderTable(el, cfg) { + const idKey = cfg.idKey || 'id'; + const editableCols = cfg.columns.filter(c => c.edit); + const canCreate = () => !!cfg.onCreate + && !(typeof cfg.createHidden === 'function' ? cfg.createHidden() : cfg.createHidden); + const hasActions = !!(cfg.onSave || cfg.onDelete || cfg.onCreate || (cfg.extraActions && cfg.extraActions.length)); + const groupCols = cfg.columns.filter(c => c.group); + const hasExpansion = !!cfg.rowExpansion; + + const stored = readState(cfg.stateKey); + const storedGroupFits = stored && cfg.columns.some(c => c.key === stored.group && c.group); + const start = stored ? { + ...cfg, + sort: stored.sort ?? cfg.sort, + defaultSort: cfg.defaultSort ?? cfg.sort, + group: stored.group === null ? null : (storedGroupFits ? stored.group : cfg.group), + } : cfg; + let { sort, groupKey, groupKey2, groupKey3, groupFilter, groupCollapsed, sticky } = initialState(start); + const collapsible = !!cfg.collapsibleGroups; + let search = ''; + let altCount = 0; + let selectedId = null; + let editId = null; + let editVals = null; + let editError = ''; + let editTouched = new Set(); + + const editing = () => editId !== null; + function persistState() { + if (cfg.onStateChange) cfg.onStateChange({ group: groupKey, group2: groupKey2, group3: groupKey3, sort, groupFilter, groupCollapsed }); + writeState(cfg.stateKey, { sort, group: groupKey }); + } + + const sortRows = rows => tableRows.sortRows(rows, cfg.columns, sort); + function hiddenTokens() { return new Set(groupKey ? (groupFilter[groupKey] || []) : []); } + function collapsedTokens() { return new Set(groupKey ? (groupCollapsed[groupKey] || []) : []); } + function toggleCollapsed(tok) { + if (!groupKey) return; + groupCollapsed = { ...groupCollapsed, [groupKey]: tableRows.toggleToken(tok, groupCollapsed[groupKey]) }; + persistState(); draw(); + } + function cycleGroup(tok) { + if (!groupKey) return; + const { hidden, collapsed } = tableRows.cycleGroupToken(tok, groupFilter[groupKey], groupCollapsed[groupKey]); + groupFilter = { ...groupFilter, [groupKey]: hidden }; + groupCollapsed = { ...groupCollapsed, [groupKey]: collapsed }; + persistState(); draw(); + } + function distinctGroupVals(col) { + const order = (sort && sort.key === groupKey) ? sort.dir : (col.group.order || 'asc'); + return tableRows.groupValues(cfg.rows, col, order); + } + function setGroupFilter(hiddenArr) { if (!groupKey) return; groupFilter = { ...groupFilter, [groupKey]: hiddenArr }; persistState(); draw(); } + function showGroup(tok) { + const r = revealGroup({ groupKey, groupFilter, groupCollapsed }, tok); + groupFilter = r.groupFilter; + groupCollapsed = r.groupCollapsed; + return r.changed; + } + function setGroupKeys(level, value) { + ({ groupKey, groupKey2, groupKey3 } = chooseGroup({ groupKey, groupKey2, groupKey3 }, level, value)); + } + function mountGroupFilter() { + const fh = el.querySelector('[data-gfilter]'); + if (!fh) return; + const gc = cfg.columns.find(c => c.key === groupKey); + const vals = gc ? distinctGroupVals(gc) : []; + const hidden = hiddenTokens(); + createFilter(fh, { + items: vals.map(v => ({ id: v.token, label: v.label })), + selected: new Set(vals.filter(v => !hidden.has(v.token)).map(v => v.token)), + className: 'fbar--tctl', + disabled: editing(), + collapsible, + collapsedIds: collapsible ? collapsedTokens() : null, + onCycle: collapsible ? cycleGroup : null, + onChange: sel => setGroupFilter(vals.map(v => v.token).filter(tok => !sel.has(tok))), + }); + } + const filterRows = rows => + tableRows.filterRows(rows, cfg.columns, { search, groupKey, hidden: hiddenTokens() }); + + const seedVals = row => seedValues(editableCols, row, cfg.newDefaults); + + const optionCache = new Map(); + const loadOptionLists = () => loadOptions(editableCols, editVals, optionCache); + + function optionList(col) { + return optionCache.get(col.key) || []; + } + + const cell = cellHtml; + const convert = convertHook ? { label: cfg.convertLabel || '', canConvert: convertHook.canConvert, icon: convertHook.icon } : null; + const inputOpts = col => ({ options: optionList(col), convert }); + const editCell = col => editCellHtml(col, editVals, inputOpts(col)); + function actionsCell(row, isEditRow) { + if (!hasActions) return ''; + return actionsCellHtml({ cfg, labels, row, idKey, isEditRow, editing: editing(), selectedId }); + } + const selectable = hasActions || hasExpansion; + const isEditRowOf = id => editId !== null && editId !== NEW && sameId(editId, id); + + const colCount = () => cfg.columns.length + (hasActions ? 1 : 0) + (hasExpansion ? 1 : 0); + + const expCell = isOpen => expCellHtml(hasExpansion, isOpen, CHEVRON); + const expandRow = () => expandRowHtml({ editableCols, vals: editVals, error: editError, colCount: colCount(), optsFor: inputOpts, actions: editActionsHtml({ cfg, labels }) }); + + function editRowHtml(row) { + const cells = cfg.columns.map(c => c.edit ? editCell(c) : cell(c, row || emptyRow())).join(''); + const emptyExp = hasExpansion ? '' : ''; + return `${emptyExp}${cells}${actionsCell(row, true)}` + expandRow(); + } + + function emptyRow() { const r = {}; for (const c of cfg.columns) r[c.key] = ''; return r; } + + function dataRow(row) { + const id = row[idKey]; + const alt = (altCount++ % 2 === 1); + if (isEditRowOf(id)) return editRowHtml(row); + const selected = !editing() && sameId(id, selectedId); + const classes = []; + if (cfg.rowClass) { const own = cfg.rowClass(row); if (own) classes.push(own); } + if (alt) classes.push('row-alt'); + if (selected) classes.push('row-selected'); + const selCls = classes.length ? ` class="${esc(classes.join(' '))}"` : ''; + const expansion = (selected && cfg.rowExpansion) + ? `${cfg.rowExpansion(row)}` : ''; + const keys = hasExpansion ? ` tabindex="0" aria-expanded="${!!expansion}"` : ''; + return `${expCell(!!expansion)}${cfg.columns.map(c => cell(c, row)).join('')}${actionsCell(row, false)}` + expansion; + } + + const wrapTable = bodyHtml => frameHtml({ + controls: controlsHtml({ cfg, labels, canCreate: canCreate(), groupCols, groupKey, groupKey2, groupKey3, search, editing: editing() }), + head: headRowHtml({ columns: cfg.columns, sort, hasExpansion, hasActions }), + body: bodyHtml, + hasExpansion, + title: cfg.title, + }); + + function bodyContentHtml() { + const rows = sortRows(filterRows(cfg.rows)); + altCount = 0; + const newRowHtml = (editId === NEW) ? editRowHtml(null) : ''; + + if (rows.length === 0 && editId !== NEW) return ``; + + if (!groupKey) return newRowHtml + rows.map(dataRow).join(''); + + const sectionList = groupSections(rows, { + columns: cfg.columns, groupKey, groupKey2, groupKey3, sort, + collapsed: collapsible ? collapsedTokens() : null, + }); + const groupHeaderRow = (token, label, count, summaryHtml, isCollapsed) => + groupHeaderRowHtml({ token, label, count, summaryHtml, isCollapsed, collapsible, colCount: colCount(), chevron: CHEVRON }); + const sections = sectionList.map(s => { + if (s.collapsed) { + const summary = cfg.groupSummary ? cfg.groupSummary(s.items, s.value) : ''; + return groupHeaderRow(s.token, s.label, s.items.length, summary, true); + } + return groupHeaderRow(s.token, s.label, s.items.length, '', false) + s.items.map(dataRow).join(''); + }).join(''); + return newRowHtml + sections; + } + + const headObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(entries => { + if (!el.isConnected) { headObserver.disconnect(); return; } + for (const { target } of entries) { + if (!target.isConnected) continue; + const wrap = target.closest('.table-wrap'); + wrap?.style.setProperty('--sticky-row-top', target.offsetHeight + 'px'); + if (wrap) measurePinnedRow(wrap); + } + }); + + function measurePinnedRow(wrap) { + if (!wrap.classList.contains('sticky-sel')) return; + const row = [...wrap.querySelectorAll('tr.row-selected')].find(tr => tr.closest('.table-wrap') === wrap); + if (!row) return; + row.style.setProperty('--row-w', row.offsetWidth + 'px'); + for (const td of row.children) td.style.setProperty('--cell-x', (td.offsetLeft || 0) + 'px'); + } + + function draw() { + const prev = el.querySelector('.table-wrap'); + const sTop = prev ? prev.scrollTop : 0; + const sLeft = prev ? prev.scrollLeft : 0; + const focus = captureFocus(); + el.innerHTML = wrapTable(bodyContentHtml()); + bind(); + mountGroupFilter(); + const next = el.querySelector('.table-wrap'); + if (next) { + next.scrollTop = sTop; next.scrollLeft = sLeft; + const head = next.querySelector('thead'); + headObserver?.disconnect(); + if (head) { + next.style.setProperty('--sticky-row-top', head.offsetHeight + 'px'); + headObserver?.observe(head); + } + measurePinnedRow(next); + } + titleTruncated(); + mountDrawer(); + restoreFocus(focus); + cfg.onDraw?.(el); + } + + const captureFocus = () => captureTableFocus(el, editing()); + const restoreFocus = snap => restoreTableFocus(el, snap); + + function mountDrawer() { + if (selectedId == null || !cfg.onExpansionMount || !cfg.rowExpansion) return; + const tr = [...el.querySelectorAll('tr.row-expansion')].find(t => sameId(t.dataset.expId, selectedId)); + const host = tr && tr.querySelector('td'); + if (!host) return; + const row = cfg.rows.find(r => sameId(r[idKey], selectedId)); + if (row) cfg.onExpansionMount(row, host); + } + + function titleTruncated() { + for (const cel of el.querySelectorAll('.cell-clamp')) { + if (cel.scrollHeight > cel.clientHeight + 1) cel.title = cel.textContent.trim(); + else cel.removeAttribute('title'); + } + } + + function updateBody() { + const tb = el.querySelector('tbody'); + if (tb) tb.innerHTML = bodyContentHtml(); + titleTruncated(); + mountDrawer(); + cfg.onDraw?.(el); + } + + const captureVals = () => captureValues(el.querySelector('tbody') || el, editableCols, editVals); + + async function startEdit(id) { + const row = cfg.rows.find(r => sameId(r[idKey], id)); + editId = row ? row[idKey] : id; editVals = seedVals(row); editError = ''; selectedId = null; editTouched = new Set(); + await loadOptionLists(); + draw(); + focusFirstField(); + } + async function startNew(extra) { + editId = NEW; editVals = seedVals(null); if (extra) Object.assign(editVals, extra); editError = ''; selectedId = null; editTouched = new Set(); + await loadOptionLists(); + editVals = seedSticky({ columns: editableCols, seeded: editVals, remembered: sticky, options: optionCache }); + draw(); + focusFirstField(); + } + + const focusFirstField = () => focusFirstEditField(el); + function cancelEdit() { editId = null; editVals = null; editError = ''; draw(); } + + function rememberSticky(harvested) { + sticky = harvested; + if (cfg.onStickyChange) cfg.onStickyChange(sticky); + } + + let repeatPending = false; + + async function commit(repeat) { + captureVals(); + const wasNew = editId === NEW; + const doRepeat = !!repeat && wasNew && !!cfg.onCreate; + repeatPending = doRepeat; + const harvested = wasNew ? harvestSticky(editableCols, editVals) : null; + try { + if (wasNew) await cfg.onCreate(editVals); + else await cfg.onSave(editId, editVals); + if (harvested) rememberSticky(harvested); + editId = null; editVals = null; editError = ''; + repeatPending = false; + if (doRepeat && canCreate()) await startNew(); + else draw(); + } catch (e) { + repeatPending = false; + editError = e.message || String(e); + draw(); + } + } + + function openConvert(triggerEl) { + const key = triggerEl.dataset.convert; + const col = cfg.columns.find(c => c.key === key); + if (!col || !col.edit || typeof col.edit.convert !== 'function') return; + captureVals(); + const asset = col.edit.convert(editVals); + const input = el.querySelector(`[data-field="${key}"]`); + if (!asset || !convertHook || !convertHook.canConvert(asset) || !input) return; + if (typeof col.edit.onConvert === 'function') { + col.edit.onConvert(triggerEl, { + asset, input, vals: editVals, + setValue: v => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }, + setFields: obj => Object.assign(editVals, obj), + }); + return; + } + convertHook.open(triggerEl, { + asset, + value: input.value, + onApply: v => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }, + }); + } + + function openNewMenu(anchor) { + const { el: pop, position } = openPopover(anchor, { className: 'popover new-menu' }); + pop.innerHTML = cfg.newOptions.map((o, i) => + ``).join(''); + position(); + pop.addEventListener('click', e => { + const b = e.target.closest('[data-new-opt]'); + if (!b) return; + const opt = cfg.newOptions[Number(b.dataset.newOpt)]; + closePopover(); + startNew(opt.defaults); + }); + } + + const idOf = text => { const r = cfg.rows.find(x => sameId(x[idKey], text)); return r ? r[idKey] : text; }; + + function sortOn(key) { + sort = sort && sort.key === key ? { key, dir: sort.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' }; + persistState(); draw(); + } + + function toggleRow(idText) { + const id = idOf(idText); + selectedId = sameId(selectedId, id) ? null : id; + draw(); + } + + function bind() { + el.onclick = e => { + e.stopPropagation(); + if (editing()) { + if (e.target.closest('[data-save]')) { commit(); return; } + if (e.target.closest('[data-cancel]')) { cancelEdit(); return; } + const cv = e.target.closest('[data-convert]'); + if (cv) { openConvert(cv); return; } + return; + } + const th = e.target.closest('[data-sort]'); + if (th) { sortOn(th.dataset.sort); return; } + const ed = e.target.closest('[data-edit]'); + if (ed && cfg.onSave) { startEdit(idOf(ed.dataset.edit)); return; } + const dl = e.target.closest('[data-del]'); + if (dl && cfg.onDelete) { cfg.onDelete(idOf(dl.dataset.del)); return; } + const act = e.target.closest('[data-action]'); + if (act && cfg.onAction) { cfg.onAction(act.dataset.action, idOf(act.dataset.id)); return; } + const gt = e.target.closest('[data-gcollapse]'); + if (gt) { toggleCollapsed(gt.dataset.gcollapse); return; } + const row = e.target.closest('tr[data-row-id]'); + if (!row || !selectable) return; + const own = e.target.closest(OWN_CLICK); + if (own && row.contains(own) && !own.classList.contains('chev-btn')) return; + toggleRow(row.dataset.rowId); + }; + + const newBtn = el.querySelector('[data-tbl-new]'); + if (newBtn) newBtn.onclick = () => { + if (editing()) return; + if (cfg.newOptions && cfg.newOptions.length) openNewMenu(newBtn); + else startNew(); + }; + for (const b of el.querySelectorAll('[data-tbl-act]')) { + if (!el.contains(b) || b.closest('.table-frame')?.parentElement !== el) continue; + const a = (cfg.toolbarActions || []).find(x => String(x.id) === b.dataset.tblAct); + if (a) b.onclick = () => { if (!editing() && cfg.onToolbarAction) cfg.onToolbarAction(a.id, b); }; + } + + const searchEl = el.querySelector('[data-tbl-search]'); + if (searchEl) searchEl.oninput = () => { search = searchEl.value; updateBody(); }; + el.oninput = e => { + const field = e.target && e.target.dataset && e.target.dataset.field; + if (editing() && field) editTouched.add(field); + }; + + el.onkeydown = e => { + if (!editing()) { + if (e.key !== 'Enter' && e.key !== ' ') return; + const th = e.target.closest?.('th[data-sort]'); + if (th) { e.preventDefault(); e.stopPropagation(); sortOn(th.dataset.sort); return; } + if (hasExpansion && e.target.matches?.('tr[data-row-id]')) { + e.preventDefault(); e.stopPropagation(); toggleRow(e.target.dataset.rowId); + } + return; + } + const action = keyAction({ + key: e.key, + ctrl: e.ctrlKey || e.metaKey, + tag: e.target.tagName, + isButton: !!e.target.closest('button'), + popoverOpen: isPopoverOpen(), + dialogOpen: isDialogOpen(e.target), + repeatKey: repeatKeyOf(), + }); + if (!action) return; + e.preventDefault(); + e.stopPropagation(); + if (action === 'cancel') cancelEdit(); + else commit(action === 'saveNext'); + }; + + el.onchange = e => { + e.stopPropagation(); + const level = Number(e.target.dataset && e.target.dataset.tblGroup) || 0; + if (level && !editing()) { setGroupKeys(level, e.target.value); persistState(); draw(); return; } + if (editing() && e.target.matches('[data-reactive]')) { + captureVals(); + if (typeof cfg.onEditChange === 'function') { + const patch = cfg.onEditChange({ ...editVals }, e.target.dataset.field, new Set(editTouched)); + if (patch) Object.assign(editVals, patch); + } + loadOptionLists().then(draw); + } + }; + } + + function clearHighlight() { selectedId = null; editId = null; editVals = null; editError = ''; draw(); } + function refresh(rows, opts) { + if (rows) cfg.rows = rows; + if (selectedId != null && !cfg.rows.some(r => sameId(r[idKey], selectedId))) selectedId = null; + if (repeatPending) return; + if (opts && opts.locate != null) { + search = ''; + if (groupKey) { + const gc = cfg.columns.find(c => c.key === groupKey); + const newRow = gc && cfg.rows.find(r => sameId(r[idKey], opts.locate)); + if (newRow && showGroup(String(gc.group.value(newRow)))) persistState(); + } + selectedId = idOf(opts.locate); + editId = null; editVals = null; editError = ''; + draw(); + requestAnimationFrame(() => locateRow(opts.locate)); + return; + } + draw(); + } + const rowEl = id => [...el.querySelectorAll('tr[data-row-id]')].find(t => sameId(t.dataset.rowId, id)); + function locateRow(id) { + const row = rowEl(id); + if (row) row.scrollIntoView({ block: 'center' }); + } + + function focusGroup(key, predicate) { + const col = cfg.columns.find(c => c.key === key && c.group); + if (!col) return false; + const row = cfg.rows.find(predicate); + if (!row) return false; + const tok = String(col.group.value(row)); + setGroupKeys(1, key); + showGroup(tok); + search = ''; + selectedId = null; editId = null; editVals = null; editError = ''; + draw(); + const headers = [...el.querySelectorAll('[data-gcollapse]')]; + const header = headers.find(k => k.dataset.gcollapse === tok); + const target = header || rowEl(row[idKey]); + if (target) requestAnimationFrame(() => target.scrollIntoView({ block: 'center' })); + return true; + } + + function focusRow(id) { + if (id == null) return false; + const row = cfg.rows.find(r => String(r[idKey]) === String(id)); + if (!row) return false; + if (groupKey) { + const gc = cfg.columns.find(c => c.key === groupKey && c.group); + if (gc) showGroup(String(gc.group.value(row))); + } + search = ''; + selectedId = row[idKey]; + editId = null; editVals = null; editError = ''; + draw(); + requestAnimationFrame(() => locateRow(row[idKey])); + return true; + } + + draw(); + return { clearHighlight, refresh, focusGroup, focusRow }; +} diff --git a/client/ui/tabs.js b/client/ui/tabs.js new file mode 100644 index 0000000..0edd2b4 --- /dev/null +++ b/client/ui/tabs.js @@ -0,0 +1,109 @@ +// Generated, do not edit. sha256:09856719543cb019750495d6ca12c5afc9eb3cc35cdf6a20a0f887e365cc177f +import { esc } from './escape.js'; + +function plainError(panel, err, id) { + console.error(`[tabs] ${id || ''}`, err); + panel.innerHTML = `
    ${esc(err && err.message ? err.message : String(err))}
    `; +} +let onError = plainError; + +export function configureTabs({ onError: handler = null } = {}) { + onError = handler || plainError; +} + +export function rowLayout(count, content, room) { + const rows = count > 1 && content > room ? 2 : 1; + return { rows, cols: Math.ceil(count / rows) }; +} + +export function create(host, opts = {}) { + if (!host) return null; + const { items = [], render, onChange, size = '', syncOn = [], barHost = null } = opts; + const list = items.filter(it => it && (!it.when || it.when())); + + let current = opts.active; + if (!list.some(it => it.id === current)) current = list[0] && list[0].id; + + const bar = document.createElement('div'); + bar.className = 'tabset' + (size === 'lg' ? ' tabset--lg' : ''); + bar.setAttribute('role', 'tablist'); + const panel = document.createElement('div'); + panel.setAttribute('role', 'tabpanel'); + + if (host._tabsCleanup) host._tabsCleanup(); + host.innerHTML = ''; + if (barHost) { barHost.innerHTML = ''; barHost.append(bar); host.append(panel); } + else host.append(bar, panel); + + function drawBar() { + bar.innerHTML = list.map(it => { + const on = it.id === current; + const dot = typeof it.dot === 'function' ? (it.dot() || '') : (it.dot || ''); + return ``; + }).join(''); + bar.querySelectorAll('[data-tab]').forEach(b => { b.onclick = () => select(b.dataset.tab); }); + if (size === 'lg') fitRows(); + } + + function fitRows() { + if (!bar.isConnected) return; + bar.classList.remove('tabset--rows'); + const box = bar.getBoundingClientRect(); + const last = bar.children[bar.children.length - 1]; + const reach = last ? last.getBoundingClientRect().right - box.left : 0; + const { rows, cols } = rowLayout(list.length, Math.round(reach), Math.round(box.width)); + if (rows < 2) return; + bar.classList.add('tabset--rows'); + bar.style.setProperty('--tabset-cols', String(cols)); + } + let seenWidth = -1; + const resize = size === 'lg' && typeof ResizeObserver === 'function' + ? new ResizeObserver(entries => { + if (!bar.isConnected) { cleanup(); return; } + const width = entries[entries.length - 1].contentRect.width; + if (width === seenWidth) return; + seenWidth = width; + fitRows(); + }) + : null; + + function drawPanel() { + panel.innerHTML = ''; + if (!render) return; + try { + const r = render(current, panel); + if (r && typeof r.then === 'function') r.catch(err => onError(panel, err, current)); + } catch (err) { onError(panel, err, current); } + } + + function select(id, force) { + if (!force && id === current) return; + if (!list.some(it => it.id === id)) return; + current = id; + drawBar(); + drawPanel(); + if (onChange) onChange(id); + } + + const onSync = () => { if (!bar.isConnected) { cleanup(); return; } drawBar(); }; + function cleanup() { + syncOn.forEach(ev => document.removeEventListener(ev, onSync)); + if (resize) resize.disconnect(); + if (host._tabsCleanup === cleanup) host._tabsCleanup = null; + } + syncOn.forEach(ev => document.addEventListener(ev, onSync)); + if (resize) resize.observe(bar); + host._tabsCleanup = cleanup; + + drawBar(); + drawPanel(); + + return { + el: host, bar, panel, + get active() { return current; }, + select: id => select(id), + refresh: () => drawBar(), + redraw: () => drawPanel(), + destroy: cleanup, + }; +} diff --git a/client/ui/task-card.js b/client/ui/task-card.js new file mode 100644 index 0000000..dfd0314 --- /dev/null +++ b/client/ui/task-card.js @@ -0,0 +1,13 @@ +// Generated, do not edit. sha256:a87223ad75216e76cfa18c625050ed381897b003ba4c77f9b54956a8260e76fb +import { esc } from './escape.js'; +import { CHECK_CIRCLE } from './icons.js'; + +export function taskCardHtml({ title, body = '', actions = '', className = '' }) { + const label = `
    ${esc(title)}
    `; + const head = actions ? `
    ${label}${actions}
    ` : label; + return `
    ${head}${body}
    `; +} + +export function nothingPendingHtml() { + return `
    ${CHECK_CIRCLE}
    `; +} diff --git a/client/ui/unit-convert.js b/client/ui/unit-convert.js new file mode 100644 index 0000000..5918ba0 --- /dev/null +++ b/client/ui/unit-convert.js @@ -0,0 +1,63 @@ +// Generated, do not edit. sha256:9537fc0b4eaa35092768e80eee626c20b2e7da8195e3aa3271037126f3d4cfa3 +import { t } from '../i18n/index.js'; +import { mainToSub, subToMain, hasSubunit, isMetal } from './format.js'; +import { openPopover, closePopover } from './popover.js'; +import { CONFIG } from '../../shared/config.js'; +import { esc } from './escape.js'; + +export function openUnitConvert(anchorEl, { asset, value, onApply }) { + if (!hasSubunit(asset)) return; + const metal = isMetal(asset); + const dec = asset.decimals || 0; + const subField = asset.displayUnit === 'subunit'; + + const U = CONFIG.METALS.UNITS; + const round6 = x => Number(Number(x).toFixed(6)); + const m2s = metal ? (m => round6(Number(m) * U[asset.unit] / U[asset.subunit])) : (m => mainToSub(m, dec)); + const s2m = metal ? (s => round6(Number(s) * U[asset.subunit] / U[asset.unit])) : (s => subToMain(s, dec)); + + const seed = parseFloat(value); + let mainVal = '', subVal = ''; + if (Number.isFinite(seed)) { + if (subField) { subVal = metal ? seed : Math.round(seed); mainVal = s2m(subVal); } + else { mainVal = seed; subVal = m2s(mainVal); } + } + + const mainLabel = esc(asset.unit || t('convert.main')); + const subLabel = esc(asset.subunit || t('convert.sub')); + const subStep = metal ? 'any' : '1'; + + const { el, position } = openPopover(anchorEl, { + className: 'conv-pop', + onClose: () => { if (document.activeElement === document.body) anchorEl.focus(); }, + }); + el.innerHTML = ` +
    ${esc(t('convert.title'))}
    +
    + + +
    +
    + + +
    + `; + position(); + + const mainEl = el.querySelector('[data-conv-main]'); + const subEl = el.querySelector('[data-conv-sub]'); + + mainEl.oninput = () => { const m = parseFloat(mainEl.value); subEl.value = Number.isFinite(m) ? m2s(m) : ''; }; + subEl.oninput = () => { const s = parseFloat(subEl.value); mainEl.value = Number.isFinite(s) ? s2m(s) : ''; }; + + const apply = () => { + const out = subField ? subEl.value : mainEl.value; + if (out === '') return; + onApply(String(out)); + closePopover(); + }; + el.querySelector('[data-conv-apply]').onclick = apply; + el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); apply(); } }); + + (subField ? mainEl : subEl).focus(); +} diff --git a/client/wealth/account-cards.js b/client/wealth/account-cards.js new file mode 100644 index 0000000..47d3a79 --- /dev/null +++ b/client/wealth/account-cards.js @@ -0,0 +1,54 @@ +// Generated, do not edit. sha256:771d7bfccd806caf4ec8847da01e643d018dcebd8d547afd6fc2a566be8ad622 +import { ENUMS } from '../../shared/config.js'; +import { byKindAndLabel } from '../ui/asset-label.js'; + +export function accountCards({ kaarten = [], holdings = [], accounts = [], toon = new Set() } = {}) { + const perAsset = new Map(kaarten.map(k => [k.asset.id, k])); + const lijnen = new Map(); + const lijn = (accountId, k, saldo) => { + if (!lijnen.has(accountId)) lijnen.set(accountId, []); + lijnen.get(accountId).push({ k, saldo }); + }; + const opAccount = new Map(); + for (const h of holdings) { + const k = perAsset.get(h.assetId); + if (!k || !h.saldo) continue; + lijn(h.accountId, k, h.saldo); + opAccount.set(h.assetId, (opAccount.get(h.assetId) || 0) + h.saldo); + } + for (const k of kaarten) { + const rest = (k.p.net || 0) - (opAccount.get(k.asset.id) || 0); + if (rest) lijn(null, k, rest); + } + for (const id of toon) if (!lijnen.has(id)) lijnen.set(id, []); + + const naam = new Map(accounts.map(a => [a.id, a])); + const kaartVan = (accountId, rijen) => { + let value = null, unrealized = null; + const lines = rijen.map(({ k, saldo }) => { + const units = saldo / Math.pow(10, k.asset.decimals); + const v = k.m.fiatPrice != null ? units * k.m.fiatPrice : null; + if (v != null) value = (value ?? 0) + v; + const deel = ongerealiseerdVan(k, accountId, saldo); + if (deel != null) unrealized = (unrealized ?? 0) + deel; + return { asset: k.asset, saldo, value: v, origin: k.m.origin }; + }).sort((a, b) => byKindAndLabel(a.asset, b.asset)); + return { + accountId, account: naam.get(accountId) || null, value, unrealized, lines, + assetIds: lines.map(l => l.asset.id), + kinds: [...new Set(lines.map(l => l.asset.kind))], + }; + }; + const ongerealiseerdVan = (k, accountId, saldo) => { + const eigenDeel = k.m.beheerd?.find(d => d.accountId === accountId); + if (eigenDeel) return eigenDeel.unrealized; + const basis = k.m.eigen ?? { net: k.p.net, unrealized: k.m.unrealized }; + return basis.unrealized != null && basis.net ? basis.unrealized * (saldo / basis.net) : null; + }; + const soort = c => (c.accountId == null ? ENUMS.ACCOUNT_KIND.length + 1 + : c.account ? ENUMS.ACCOUNT_KIND.indexOf(c.account.kind) : ENUMS.ACCOUNT_KIND.length); + return [...lijnen].map(([id, rijen]) => kaartVan(id, rijen)) + .filter(c => c.lines.length || c.account) + .sort((a, b) => soort(a) - soort(b) + || String(a.account?.name ?? '').localeCompare(String(b.account?.name ?? ''), 'nl')); +} diff --git a/client/wealth/carried.js b/client/wealth/carried.js new file mode 100644 index 0000000..e3d82d1 --- /dev/null +++ b/client/wealth/carried.js @@ -0,0 +1,38 @@ +// Generated, do not edit. sha256:f962f4bc88fe2f11615f93f44c35fe729fcb597398ce82af6f05055c78fd9a0a +import { CONFIG } from '../../shared/config.js'; + +export function carriedPerAsset({ dagen = [], assets = [], rijenPerAsset = new Map() } = {}) { + const uit = new Map(); + for (const a of assets) { + if (a.kind === 'fiat') continue; + const echt = new Set((rijenPerAsset.get(a.id) || []).filter(r => r.fiatPrice != null && r.fiatPrice > 0) + .map(r => String(r.observedAt).slice(0, 10))); + if (dagen.length) echt.add(dagen[dagen.length - 1]); + const dicht = CONFIG.PRICE_HISTORY.CLOSED_MARKET_DAYS[a.kind] ?? 0; + const vlag = dagen.map(() => false); + let gezien = false; + let begin = null; + const sluit = eind => { + if (begin != null && eind - begin > dicht) for (let k = begin; k < eind; k++) vlag[k] = true; + begin = null; + }; + dagen.forEach((dag, i) => { + if (echt.has(dag)) { sluit(i); gezien = true; return; } + if (gezien && begin == null) begin = i; + }); + sluit(dagen.length); + uit.set(a.id, vlag); + } + return uit; +} + +export function carriedDays(reeks = {}, ids = null, assets = []) { + const kandidaten = assets.filter(a => ids == null || ids.includes(a.id)); + const vlaggen = carriedPerAsset({ dagen: reeks.dagen || [], assets: kandidaten, rijenPerAsset: reeks.rijenPerAsset || new Map() }); + const uit = new Set(); + for (const [id, vlag] of vlaggen) { + const eenheden = reeks.eenhedenPerAsset?.get(id) || []; + vlag.forEach((aan, i) => { if (aan && eenheden[i]) uit.add(i); }); + } + return uit; +} diff --git a/client/wealth/chart.js b/client/wealth/chart.js new file mode 100644 index 0000000..59075cc --- /dev/null +++ b/client/wealth/chart.js @@ -0,0 +1,361 @@ +// Generated, do not edit. sha256:2f0f6627ca3113d46ca463586c34fddd9f180542b18f6825cae3c17d5a06bf19 +import { loadCharting, readPalette } from '../ui/chart-setup.js'; +import { tintSeries } from '../ui/charting.js'; +import { t, localeTag } from '../i18n/index.js'; +import { fmtBase } from '../ui/format.js'; +import { ENUMS } from '../../shared/config.js'; + +export function ringData(kaarten, { restVanaf = 0.02 } = {}) { + const bruikbaar = kaarten.filter(k => typeof k.m.value === 'number' && k.m.value > 0); + const alle = bruikbaar + .map(k => ({ ids: [k.asset.id], kinds: [k.asset.kind], label: k.asset.symbol, value: k.m.value, kind: k.asset.kind })) + .sort((a, b) => b.value - a.value); + const perSoort = new Map(); + for (const a of alle) { + const vorig = perSoort.get(a.kind) || { value: 0, ids: [] }; + perSoort.set(a.kind, { value: vorig.value + a.value, ids: [...vorig.ids, ...a.ids] }); + } + + const totaal = alle.reduce((s, a) => s + a.value, 0); + const grens = totaal * restVanaf; + const klein = alle.filter(a => a.value < grens); + const assets = klein.length >= 2 + ? [...alle.filter(a => a.value >= grens), { + ids: klein.flatMap(a => a.ids), + kinds: [...new Set(klein.flatMap(a => a.kinds))], + label: `${t('wealth.ringOther')} (${klein.length})`, + value: klein.reduce((s, a) => s + a.value, 0), + kind: null, + }] + : alle; + const soorten = ENUMS.ASSET_KIND + .filter(kind => perSoort.has(kind)) + .map(kind => ({ + kind, label: t('enums.asset_kind.' + kind), + value: perSoort.get(kind).value, ids: perSoort.get(kind).ids, kinds: [kind], + })); + + return { soorten, assets }; +} + +export function segmentIndices(segmenten = [], sel) { + if (!sel) return []; + const uit = []; + if (sel.assetIds?.length) { + const wil = new Set(sel.assetIds); + segmenten.forEach((s, i) => { if ((s.ids || []).some(id => wil.has(id))) uit.push(i); }); + return uit; + } + if (sel.kind) { + segmenten.forEach((s, i) => { if ((s.kinds || []).includes(sel.kind)) uit.push(i); }); + } + return uit; +} + +export function uitsteek(aandeel, max = UITSTEEK) { + const a = Math.min(Math.max(Number(aandeel) || 0, 0), 1); + return Math.round(max * (1 - a)); +} + +export function nadrukKleuren(basis = [], aan = new Set()) { + return basis.map((k, n) => (aan.size === 0 || aan.has(n) ? k : dim(k))); +} + +export function glansStops(rgba) { + const m = /^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$/.exec(String(rgba)); + if (!m) return [rgba, rgba]; + const [r, g, b, a] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]; + const licht = c => Math.round(c + (255 - c) * 0.18); + const binnen = Math.round(a * 0.72 * 1000) / 1000; + const buiten = Math.round(Math.min(1, a * 1.1) * 1000) / 1000; + return [`rgba(${r},${g},${b},${binnen})`, `rgba(${licht(r)},${licht(g)},${licht(b)},${buiten})`]; +} + +export function outerEdge(arc, { offset = 0, spacing = 0 } = {}) { + const { x, y, startAngle, endAngle, innerRadius = 0, outerRadius } = arc; + const g = endAngle - startAngle; + const mid = (startAngle + endAngle) / 2; + const shift = offset / 4; + const grow = shift * (1 - Math.sin(Math.min(Math.PI, g || 0))); + const half = spacing / 2; + const r = Math.max(outerRadius + half + grow, 0); + let trimSpacing = 0; + if (half) { + const avg = ((innerRadius > 0 ? innerRadius - half : 0) + (r > 0 ? r - half : 0)) / 2; + trimSpacing = (g - (avg !== 0 ? (g * avg) / (avg + half) : g)) / 2; + } + const trimOffset = r > 0 ? (g - Math.max(0.001, g * r - grow / Math.PI) / r) / 2 : 0; + return { + cx: x + Math.cos(mid) * shift, cy: y + Math.sin(mid) * shift, r, + start: startAngle + trimOffset + trimSpacing, end: endAngle - trimOffset - trimSpacing, + }; +} + +export function dim(rgba, factor = 0.35) { + const m = /^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$/.exec(String(rgba)); + if (!m) return rgba; + const a = Math.round(Number(m[4]) * factor * 1000) / 1000; + return `rgba(${m[1]},${m[2]},${m[3]},${a})`; +} + +const UITSTEEK = 14; + +const REST_UITSTEEK = 6; +const basisUitsteek = segmenten => segmenten.map(s => (s.kind === null ? REST_UITSTEEK : 0)); + +const PCT_IN_BOOG = 5; + +const pctTekst = pct => `${pct.toLocaleString(localeTag(), { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`; + +function pasNadrukToe(chart) { + const ds = chart?.data?.datasets?.[0]; + if (!ds) return; + const st = chart._vermogen || {}; + const aan = new Set([...(st.hover || []), ...(st.selectie || [])]); + ds._kleuren = nadrukKleuren(ds._basisKleuren, aan); + if (!ds._glans) ds.backgroundColor = ds._kleuren; + const basis = ds._basisOffset || []; + const totaal = (ds.data || []).reduce((s, v) => s + v, 0); + ds.offset = basis.map((b, n) => (aan.has(n) ? b + uitsteek(totaal > 0 ? ds.data[n] / totaal : 0) : b)); + chart.update('none'); +} + +export async function tekenVerdeling(canvasSoorten, canvasAssets, kaarten, { onHover = () => {}, onSelect = () => {} } = {}) { + const { soorten, assets } = ringData(kaarten); + if (!assets.length) return null; + + const Chart = await loadCharting(); + const pal = readPalette(); + const grafieken = [ + canvasSoorten && bouwRing(Chart, pal, canvasSoorten, soorten, { onHover, onSelect, soortRing: true }), + canvasAssets && bouwRing(Chart, pal, canvasAssets, assets, { onHover, onSelect, soortRing: false }), + ].filter(Boolean); + if (!grafieken.length) return null; + + const zet = (veld, sel) => { + for (const g of grafieken) { + g._vermogen = { ...g._vermogen, [veld]: segmentIndices(g.data.datasets[0]._segmenten, sel) }; + pasNadrukToe(g); + } + }; + return { + markeer: sel => zet('hover', sel), + selecteer: sel => zet('selectie', sel), + destroy: () => { grafieken.forEach(g => g.destroy()); hideRingTip(); }, + }; +} + +let ringTipEl = null; +const hideRingTip = () => { if (ringTipEl) ringTipEl.classList.remove('is-visible'); }; + +function ringTip({ chart, tooltip }, deel) { + if (!tooltip || tooltip.opacity === 0 || !tooltip.dataPoints?.length) { hideRingTip(); return; } + if (!ringTipEl || !ringTipEl.isConnected) { + ringTipEl = document.createElement('div'); + ringTipEl.className = 'ring-tip'; + ringTipEl.setAttribute('role', 'tooltip'); + document.body.appendChild(ringTipEl); + window.addEventListener('scroll', hideRingTip, { capture: true, passive: true }); + } + const punt = tooltip.dataPoints[0]; + const bedrag = document.createElement('span'); + bedrag.className = 'val'; + bedrag.textContent = fmtBase(punt.parsed); + const naam = punt.dataset._segmenten?.[punt.dataIndex]?.label ?? ''; + const naamEl = document.createElement('span'); + naamEl.className = 'ring-tip-label'; + naamEl.textContent = naam; + const pct = document.createElement('span'); + pct.className = 'ring-tip-pct'; + pct.textContent = pctTekst(deel(punt.parsed)); + const regel = document.createElement('span'); + regel.className = 'ring-tip-row'; + regel.append(bedrag, pct); + ringTipEl.replaceChildren(naamEl, regel); + const r = chart.canvas.getBoundingClientRect(); + ringTipEl.style.left = `${Math.round(r.left + tooltip.caretX)}px`; + ringTipEl.style.top = `${Math.round(r.top + tooltip.caretY)}px`; + ringTipEl.classList.add('is-visible'); +} + +function bouwRing(Chart, pal, canvas, segmenten, { onHover, onSelect, soortRing }) { + if (!segmenten.length) return null; + const kleuren = tintSeries(pal.accent, segmenten.length); + const totaal = segmenten.reduce((s, a) => s + a.value, 0); + const deel = v => (totaal > 0 ? (v / totaal) * 100 : 0); + const meldingVan = n => (soortRing + ? { kind: segmenten[n].kind } + : { assetIds: segmenten[n].ids ?? [] }); + let laatst = null; + + const grafiek = new Chart(canvas, { + type: 'doughnut', + plugins: [glasPlugin(pal), percentagePlugin(pal), middenLabelPlugin(pal, deel)], + data: { + datasets: [{ + data: segmenten.map(s => s.value), + backgroundColor: pal.ringSheen ? glansVulling : kleuren, + _kleuren: kleuren, + _glans: pal.ringSheen, + spacing: pal.ringSeam, + borderWidth: pal.ringSeam > 0 ? 0 : 1, + _segmenten: segmenten, + _basisKleuren: kleuren, + offset: basisUitsteek(segmenten), _basisOffset: basisUitsteek(segmenten), + hoverOffset: 0, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + layout: { padding: REST_UITSTEEK + UITSTEEK + 4 }, + onHover: (evt, actief) => { + const el = actief[0]; + if (!el || el.index === laatst) return; + laatst = el.index; + onHover(meldingVan(el.index)); + }, + onClick: (evt, actief) => { + const el = actief[0]; + return onSelect(el ? meldingVan(el.index) : null); + }, + plugins: { + legend: { display: false }, + tooltip: { + enabled: false, + external: ctx => ringTip(ctx, deel), + }, + }, + borderColor: pal.tooltipBg, + borderWidth: 1, + cutout: '58%', + }, + }); + + canvas.addEventListener('mouseleave', () => { laatst = null; onHover(null); }); + return grafiek; +} + +function glansVulling(context) { + const kleur = context.dataset._kleuren?.[context.dataIndex]; + const { chart } = context; + const area = chart?.chartArea; + if (!kleur || !area) return kleur; + const r = Math.min(area.right - area.left, area.bottom - area.top) / 2; + if (!(r > 0)) return kleur; + const cx = (area.left + area.right) / 2; + const cy = (area.top + area.bottom) / 2; + const [binnen, buiten] = glansStops(kleur); + const g = chart.ctx.createRadialGradient(cx, cy, r * 0.58, cx, cy, r); + g.addColorStop(0, binnen); + g.addColorStop(1, buiten); + return g; +} + +function glasPlugin(pal) { + return { + id: 'wealth-glass', + afterDatasetsDraw(c) { + if (!pal.ringSheen) return; + const meta = c.getDatasetMeta(0); + if (!meta?.data?.length || meta.hidden) return; + const { ctx } = c; + const st = c._vermogen || {}; + const aan = new Set([...(st.hover || []), ...(st.selectie || [])]); + if (aan.size && pal.accentGlow) { + ctx.save(); + ctx.shadowColor = pal.accentGlow; + ctx.shadowBlur = 18; + for (const n of aan) meta.data[n]?.draw(ctx); + ctx.restore(); + } + if (!pal.hairline) return; + const eerste = meta.data[0]; + const lijn = ctx.createLinearGradient(0, eerste.y - eerste.outerRadius, 0, eerste.y + eerste.outerRadius); + lijn.addColorStop(0, pal.hairline); + lijn.addColorStop(1, pal.rimBottom || 'transparent'); + ctx.save(); + ctx.strokeStyle = lijn; + ctx.lineWidth = 1; + for (const boog of meta.data) { + const rand = outerEdge(boog, { offset: boog.options?.offset || 0, spacing: boog.options?.spacing || 0 }); + if (rand.end - rand.start <= 0 || rand.r <= 1) continue; + ctx.beginPath(); + ctx.arc(rand.cx, rand.cy, rand.r - 0.5, rand.start, rand.end); + ctx.stroke(); + } + ctx.restore(); + }, + }; +} + +function percentagePlugin(pal) { + return { + id: 'wealth-pct', + afterDatasetsDraw(c) { + const { ctx } = c; + const meta = c.getDatasetMeta(0); + if (!meta || meta.hidden) return; + const rijen = c.data.datasets[0].data; + const som = rijen.reduce((s, v) => s + v, 0); + ctx.save(); + ctx.font = '600 11px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + meta.data.forEach((boog, n) => { + const pct = som > 0 ? (rijen[n] / som) * 100 : 0; + if (pct < PCT_IN_BOOG) return; + const { x, y } = boog.getCenterPoint(); + const tekst = pctTekst(pct); + ctx.lineWidth = 3; + ctx.strokeStyle = pal.tooltipBg; + ctx.strokeText(tekst, x, y); + ctx.fillStyle = pal.text; + ctx.fillText(tekst, x, y); + }); + ctx.restore(); + }, + }; +} + +function middenLabelPlugin(pal, deel) { + return { + id: 'wealth-hole', + afterDatasetsDraw(c) { + const st = c._vermogen || {}; + const n = (st.hover || [])[0] ?? (st.selectie || [])[0]; + if (n == null) return; + const ds = c.data.datasets[0]; + const seg = ds._segmenten?.[n]; + if (!seg) return; + const meta = c.getDatasetMeta(0); + const boog = meta?.data?.[n]; + if (!boog) return; + + const { ctx } = c; + const x = boog.x; + const y = boog.y; + const ruimte = (boog.innerRadius ?? 40) * 1.7; + ctx.save(); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = pal.text; + ctx.font = '700 1.15rem system-ui, sans-serif'; + ctx.fillText(pctTekst(deel(seg.value)), x, y - 12); + ctx.font = '600 0.78rem system-ui, sans-serif'; + ctx.fillText(kort(ctx, seg.label, ruimte), x, y + 6); + ctx.fillStyle = pal.textSec; + ctx.font = '0.72rem system-ui, sans-serif'; + ctx.fillText(kort(ctx, fmtBase(seg.value), ruimte), x, y + 22); + ctx.restore(); + }, + }; +} + +function kort(ctx, tekst, breedte) { + const s = String(tekst ?? ''); + if (ctx.measureText(s).width <= breedte) return s; + let uit = s; + while (uit.length > 1 && ctx.measureText(uit + '…').width > breedte) uit = uit.slice(0, -1); + return uit + '…'; +} diff --git a/client/wealth/data.js b/client/wealth/data.js new file mode 100644 index 0000000..edfe44d --- /dev/null +++ b/client/wealth/data.js @@ -0,0 +1,538 @@ +// Generated, do not edit. sha256:8f2431fd9a93cf7a3b491d7a9c3586996866c1c56d872590bc1b901665a63a6e +import { listAccounts } from '../data/accounts.js'; +import { listAssets } from '../data/assets.js'; +import { listEvents, listFlowLegs, listTradeEvents } from '../data/events.js'; +import { firstPositionDate, positionAt, positionsByAsset, unexplainedByPair } from '../data/positions.js'; +import { listPriceHistory } from '../data/price-history.js'; +import { getPref } from '../data/preferences.js'; +import { toDailyGrid } from '../signals/data.js'; +import { listProviders } from '../data/providers.js'; +import { provenanceOn, provenanceOfRow, originFrom, liveOrigin } from '../../shared/price-provenance.js'; +import { ensurePriceHistoryFor, ensurePointsAtFor, liveSpotPriceFor, lastSpotSource } from '../price/providers.js'; +import { REIKWIJDTE, categorieVoor, reikwijdteVoor } from '../tax/constants.js'; +import { isOwnWealth } from '../../shared/wealth-scope.js'; +import { CONFIG } from '../../shared/config.js'; +import { modifiedDietz, irr, averageCapital } from './performance.js'; +import { dagreeks, metSpotVandaag, todayResults } from './series.js'; +import { listValuations } from '../data/valuations.js'; +import { toUtcIso, nowUtcIso } from '../../shared/time.js'; +import { t } from '../i18n/index.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function priceAt(raster, isoDate) { + if (!raster || !raster.grid || raster.grid.length === 0) return null; + const { grid, dagen, kind } = raster; + const ts = Date.parse(`${isoDate}T00:00:00Z`); + if (ts < grid[0][0]) return null; + const grens = CONFIG.PRICE_HISTORY.CARRY_FORWARD_DAYS[kind]; + if (grens != null && !versGenoeg(dagen, ts, grens)) return null; + const idx = Math.min(grid.length - 1, Math.floor((ts - grid[0][0]) / DAY_MS)); + return grid[idx][1]; +} + +function versGenoeg(dagen, ts, grens) { + if (!dagen || dagen.length === 0) return false; + for (let i = dagen.length - 1; i >= 0; i--) { + if (dagen[i] <= ts) return ts - dagen[i] <= grens * DAY_MS; + } + return false; +} + +const peilIso = year => toUtcIso(`${year}-01-01T00:00:00`); + +const localNowIso = () => nowUtcIso(); + +async function liveSpots(assets) { + const m = new Map(); + for (const a of assets) { + try { const price = await liveSpotPriceFor(a); if (price != null) m.set(a.id, price); } + catch { } + } + return m; +} + +function waardeVan(sats, div, price) { + if (sats === 0) return 0; + return price != null ? sats / div * price : null; +} + +function currentPriceFor(asset, raster, live) { + const spot = live.get(asset.id); + if (spot != null) return spot; + const grid = raster && raster.grid; + return grid && grid.length ? grid[grid.length - 1][1] : null; +} + +function raster(rows, asset) { + const dagen = [...new Set(rows.filter(r => r.fiatPrice != null && r.fiatPrice > 0).map(r => r.observedAt.slice(0, 10)))] + .sort() + .map(d => Date.parse(`${d}T00:00:00Z`)); + return { grid: toDailyGrid(rows), dagen, kind: asset.kind, rows }; +} + +function herkomstVan(eigenRaster, isoDate, price, ctx) { + if (price == null || !eigenRaster || !eigenRaster.rows) return null; + return originFrom(provenanceOn(eigenRaster.rows, isoDate, + { assetKind: eigenRaster.kind, manualProviderId: ctx.manualId, usedPrice: price }), ctx.names); +} + +function huidigeHerkomst(asset, eigenRaster, live, price, ctx) { + if (price == null) return null; + if (live.get(asset.id) != null) return liveOrigin(price, lastSpotSource(asset)); + const grid = eigenRaster && eigenRaster.grid; + if (!grid || !grid.length || !eigenRaster.rows) return null; + const d = provenanceOn(eigenRaster.rows, new Date(grid[grid.length - 1][0]).toISOString().slice(0, 10), + { assetKind: eigenRaster.kind, manualProviderId: ctx.manualId, usedPrice: price }); + if (!d || !d.row) return null; + return originFrom(provenanceOfRow(d.row, { manualProviderId: ctx.manualId }), ctx.names); +} + +export async function herkomstContext() { + try { + const providers = await listProviders(); + const manual = providers.find(p => p.key === 'manual'); + return { names: new Map(providers.map(p => [p.id, p.name])), manualId: manual ? manual.id : null }; + } catch { + return { names: new Map(), manualId: null }; + } +} + +async function buildGrids(assets, peilDates = []) { + for (const a of assets) { + try { await ensurePriceHistoryFor(a); } + catch { } + } + for (const a of assets) { + if (peilDates.length) { try { await ensurePointsAtFor(a, peilDates); } catch { } } + } + const grids = new Map(); + for (const a of assets) { + try { grids.set(a.id, raster(await listPriceHistory(a.id), a)); } + catch { grids.set(a.id, null); } + } + return grids; +} + +async function flows(fromIso, untilIso, grids, telt = () => true, grens = []) { + let deposits = 0, withdrawals = 0, giften = 0; + const legs = []; + for (const e of await listTradeEvents(fromIso, untilIso)) { + const kant = e.type === 'buy' ? e.toAccountId + : e.type === 'sell' ? e.fromAccountId + : (e.fromAccountId ?? e.toAccountId); + if (!telt(kant, e.assetId)) continue; + const eenheden = e.amount / Math.pow(10, e.decimals); + const fee = e.fiatFee != null ? e.fiatFee : 0; + if (e.type === 'transfer' && e.fromAccountId != null && fee > 0) { + deposits += fee; + legs.push({ fiat: fee, atIso: e.occurredAt }); + } + if (e.type === 'transfer' && e.priceOrigin !== 'none') { + if ((e.fromAccountId == null) === (e.toAccountId == null)) continue; + const eigenRaster = grids ? grids.get(e.assetId) : null; + const prijs = eigenRaster ? priceAt(eigenRaster, e.occurredAt.slice(0, 10)) : null; + if (prijs == null) continue; + const fiat = eenheden * prijs; + if (e.fromAccountId == null) { deposits += fiat; legs.push({ fiat, atIso: e.occurredAt }); } + else { withdrawals += fiat; legs.push({ fiat: -fiat, atIso: e.occurredAt }); } + continue; + } + if (e.priceOrigin === 'none') { + if (e.type === 'transfer' && (e.fromAccountId == null) === (e.toAccountId == null)) continue; + const eigenRaster = grids ? grids.get(e.assetId) : null; + const prijs = eigenRaster ? priceAt(eigenRaster, e.occurredAt.slice(0, 10)) : null; + const binnen = e.type === 'transfer' ? e.fromAccountId == null : e.type === 'buy'; + if (prijs != null) { + const fiat = (binnen ? 1 : -1) * eenheden * prijs; + giften += fiat; + legs.push({ fiat, atIso: e.occurredAt }); + } + continue; + } + const fiat = eenheden * e.fiatPrice; + if (e.type === 'buy') { + deposits += fiat + fee; + legs.push({ fiat: fiat + fee, atIso: e.occurredAt }); + } else { + withdrawals += fiat - fee; + legs.push({ fiat: -(fiat - fee), atIso: e.occurredAt }); + } + } + for (const g of grens) { + if (!(g.occurredAt > fromIso && g.occurredAt <= untilIso)) continue; + const eigenRaster = grids ? grids.get(g.assetId) : null; + const prijs = eigenRaster ? priceAt(eigenRaster, g.occurredAt.slice(0, 10)) : null; + if (prijs == null) continue; + const fiat = g.eenheden * prijs; + if (g.naarBuiten) { withdrawals += fiat; legs.push({ fiat: -fiat, atIso: g.occurredAt }); } + else { deposits += fiat; legs.push({ fiat, atIso: g.occurredAt }); } + } + return { deposits, withdrawals, giften, legs }; +} + +function legValue(leg, eigenRaster) { + const units = leg.units / Math.pow(10, leg.decimals); + if (leg.priceOrigin !== 'none') { + if (leg.type === 'buy') return units * leg.fiatPrice + (leg.fiatFee || 0); + if (leg.type === 'sell') return units * leg.fiatPrice - (leg.fiatFee || 0); + } + const price = priceAt(eigenRaster, leg.occurredAt.slice(0, 10)); + return price == null ? null : units * price; +} + +async function pairFlows(fromIso, untilIso, grids) { + const uit = new Map(); + for (const leg of await listFlowLegs(fromIso, untilIso)) { + const key = `${leg.accountId}|${leg.assetId}`; + const rec = uit.get(key) || { in: 0, uit: 0, onvolledig: false, legs: [] }; + const fiat = legValue(leg, grids.get(leg.assetId)); + if (fiat == null) rec.onvolledig = true; + else { + if (leg.direction === 'in') rec.in += fiat; + else rec.uit += fiat; + rec.legs.push({ fiat: leg.direction === 'in' ? fiat : -fiat, atIso: leg.occurredAt }); + } + const fee = Number(leg.fiatFee) || 0; + if (leg.type === 'transfer' && leg.direction === 'out' && fee > 0) { + rec.in += fee; + rec.legs.push({ fiat: fee, atIso: leg.occurredAt }); + } + uit.set(key, rec); + } + return uit; +} + +async function setRowPct(rows, grids, { y, running, vanIso, totIso, openingPerPaar, methode }) { + const stromen = await pairFlows(vanIso, totIso, grids); + for (const r of rows) { + const eindCel = running ? r.current : r.perYear[y + 1]; + const beginCel = r.perYear[y]; + if (!eindCel || !beginCel) continue; + const key = `${r.account.id}|${r.asset.id}`; + const stroom = stromen.get(key) || { in: 0, uit: 0, onvolledig: false, legs: [] }; + const begin = beginCel.value; + const bruikbaar = begin != null && eindCel.value != null && !stroom.onvolledig; + eindCel.gain = bruikbaar + ? eindCel.value - begin - stroom.in + stroom.uit - (openingPerPaar.get(key) || 0) + : null; + eindCel.pct = bruikbaar && begin > 0 + ? rendementVan(methode, { + gain: eindCel.gain, begin, eind: eindCel.value - (openingPerPaar.get(key) || 0), flows: stroom.legs, + fromIso: vanIso, untilIso: totIso, + }) + : null; + eindCel.basis = bruikbaar && begin > 0 + ? averageCapital(begin, stroom.legs, { fromIso: vanIso, untilIso: totIso }) + : null; + eindCel.weging = bruikbaar + ? averageCapital(begin, stroom.legs, { fromIso: vanIso, untilIso: totIso }) + : null; + } +} + +export function sumReturn(cellen = []) { + let gain = 0, weging = 0, begin = 0; + for (const { begin: b, eind: e } of cellen) { + if (!e || !b || e.gain == null || e.weging == null) return null; + gain += e.gain; + weging += e.weging; + begin += b.value || 0; + } + if (!cellen.length) return null; + return { gain, pct: begin > 0 && weging > 0 ? gain / weging : null }; +} + +function rendementVan(methode, { gain, begin, eind, flows, fromIso, untilIso }) { + if (methode === 'irr') { + const r = irr([ + { fiat: -begin, atIso: fromIso }, + ...flows.map(f => ({ fiat: -f.fiat, atIso: f.atIso })), + { fiat: eind, atIso: untilIso }, + ], { fromIso }); + if (r != null) return r; + } + return modifiedDietz({ gain, begin, flows, fromIso, untilIso }); +} + +const geenRekening = () => ({ id: null, name: t('wealth.noAccount'), taxCategory: null, taxScope: null, isUnassigned: true }); + +async function restPerAsset(peil, perAssetSom) { + const uit = new Map(); + for (const r of await positionsByAsset(peil)) { + const rest = r.net - (perAssetSom.get(r.id) || 0); + if (rest !== 0) uit.set(r.id, rest); + } + return uit; +} + +const withoutTaxSettings = x => ({ ...x, taxCategory: null, taxScope: null }); + +export async function buildWealthModel({ ignoreTaxSettings = false } = {}) { + let assets = await listAssets(); + let accounts = await listAccounts(); + const hasTaxSettings = [...assets, ...accounts].some(x => x.taxCategory != null || x.taxScope != null); + if (ignoreTaxSettings) { + assets = assets.map(withoutTaxSettings); + accounts = accounts.map(withoutTaxSettings); + } + const first = await firstPositionDate(); + const now = new Date(); + const nowIso = localNowIso(); + if (!first || assets.length === 0) return { years: [], rows: [], totals: null }; + + const firstYear = Number(first.slice(0, 4)); + const huidigJaar = now.getFullYear(); + const startPref = Number(await getPref('app', 'start_year')) || CONFIG.START_YEAR.AUTO; + const methode = String(await getPref('app', 'mwr_method') || 'dietz'); + const startYear = Math.min(Math.max(firstYear + 1, startPref), huidigJaar); + const ingekort = startYear > firstYear + 1; + const years = []; + for (let y = startYear; y <= huidigJaar; y++) years.push(y); + + const grids = await buildGrids(assets, years.map(y => `${y}-01-01`)); + const live = await liveSpots(assets); + const multiAsset = assets.length > 1; + const hctx = await herkomstContext(); + + let rows = []; + const somPerAsset = new Map(); + for (const moment of [...years.map(peilIso), nowIso]) somPerAsset.set(moment, new Map()); + const telMee = (moment, assetId, sats) => { + const m = somPerAsset.get(moment); + m.set(assetId, (m.get(assetId) || 0) + sats); + }; + for (const ac of accounts) { + for (const a of assets) { + const eigenRaster = grids.get(a.id); + const div = Math.pow(10, a.decimals); + const perYear = {}; + let any = false; + for (const y of years) { + const sats = await positionAt(ac.id, a.id, peilIso(y)); + telMee(peilIso(y), a.id, sats); + const price = priceAt(eigenRaster, `${y}-01-01`); + perYear[y] = { sats, price, value: waardeVan(sats, div, price), gain: null, pct: null, + origin: sats === 0 ? null : herkomstVan(eigenRaster, `${y}-01-01`, price, hctx) }; + if (sats !== 0) any = true; + } + const curSats = await positionAt(ac.id, a.id, nowIso); + telMee(nowIso, a.id, curSats); + const curPrice = currentPriceFor(a, eigenRaster, live); + const current = { sats: curSats, price: curPrice, value: waardeVan(curSats, div, curPrice), gain: null, pct: null, + origin: curSats === 0 ? null : huidigeHerkomst(a, eigenRaster, live, curPrice, hctx) }; + if (curSats !== 0) any = true; + if (!any) continue; + rows.push({ + account: ac, asset: a, + categorie: categorieVoor(ac, a), + reikwijdte: reikwijdteVoor(ac, a), + name: multiAsset ? `${ac.name} · ${a.symbol}` : ac.name, + perYear, current, + }); + } + } + + const restPerMoment = new Map(); + for (const [moment, som] of somPerAsset) restPerMoment.set(moment, await restPerAsset(moment, som)); + for (const a of assets) { + const div = Math.pow(10, a.decimals); + const eigenRaster = grids.get(a.id); + const perYear = {}; + let any = false; + for (const y of years) { + const sats = restPerMoment.get(peilIso(y)).get(a.id) || 0; + const price = priceAt(eigenRaster, `${y}-01-01`); + perYear[y] = { sats, price, value: waardeVan(sats, div, price), gain: null, pct: null, + origin: sats === 0 ? null : herkomstVan(eigenRaster, `${y}-01-01`, price, hctx) }; + if (sats !== 0) any = true; + } + const curSats = restPerMoment.get(nowIso).get(a.id) || 0; + const curPrice = currentPriceFor(a, eigenRaster, live); + const current = { sats: curSats, price: curPrice, value: waardeVan(curSats, div, curPrice), gain: null, pct: null, + origin: curSats === 0 ? null : huidigeHerkomst(a, eigenRaster, live, curPrice, hctx) }; + if (curSats !== 0) any = true; + if (!any) continue; + const ac = geenRekening(); + rows.push({ + account: ac, asset: a, + categorie: categorieVoor(ac, a), + reikwijdte: reikwijdteVoor(ac, a), + name: multiAsset ? `${ac.name} · ${a.symbol}` : ac.name, + perYear, current, + }); + } + + const buitenBox3 = rows + .filter(r => r.reikwijdte === REIKWIJDTE.NONE) + .map(r => ({ name: r.name, value: r.current.value })); + rows = rows.filter(r => r.reikwijdte !== REIKWIJDTE.NONE); + const categoryRows = rows; + rows = rows.filter(r => r.reikwijdte !== REIKWIJDTE.CATEGORY); + + const emptyYears = years.filter(y => rows.every(r => r.perYear[y].sats === 0)); + + const totals = { perYear: {}, current: 0 }; + for (const y of years) totals.perYear[y] = rows.reduce((s, r) => s + (r.perYear[y].value || 0), 0); + totals.current = rows.reduce((s, r) => s + (r.current.value || 0), 0); + + const ONBEKEND = 'onbekend'; + const catVanRij = (r) => r.categorie || ONBEKEND; + const perCategorie = (waardeVanRij, lijst = rows) => { + const uit = {}; + for (const r of lijst) { + const cat = catVanRij(r); + uit[cat] = (uit[cat] || 0) + (waardeVanRij(r) || 0); + } + return uit; + }; + totals.perYearPerCategorie = {}; + for (const y of years) totals.perYearPerCategorie[y] = perCategorie(r => r.perYear[y].value); + totals.currentPerCategorie = perCategorie(r => r.current.value); + const categorySplit = { perYear: {}, current: perCategorie(r => r.current.value, categoryRows) }; + for (const y of years) categorySplit.perYear[y] = perCategorie(r => r.perYear[y].value, categoryRows); + + const jaarTotaal = (perCat) => Object.values(perCat).reduce((s, v) => s + v, 0); + + const rijPerPaar = new Map(rows.map(r => [`${r.account.id}|${r.asset.id}`, r])); + + const rekeningVan = new Map(accounts.map(a => [a.id, a])); + const assetVan = new Map(assets.map(a => [a.id, a])); + const telt = (accountId, assetId) => + reikwijdteVoor(accountId == null ? geenRekening() : rekeningVan.get(accountId), assetVan.get(assetId)) + === REIKWIJDTE.ALL; + const grens = (await listEvents()) + .filter(e => e.type === 'transfer' && e.fromAccountId != null && e.toAccountId != null + && telt(e.fromAccountId, e.assetId) !== telt(e.toAccountId, e.assetId)) + .map(e => ({ + assetId: e.assetId, occurredAt: e.occurredAt, naarBuiten: telt(e.fromAccountId, e.assetId), + eenheden: (Number(e.amount) || 0) / Math.pow(10, assetVan.get(e.assetId)?.decimals ?? 0), + })); + + const aanwas = []; + for (const y of years) { + const running = y === now.getFullYear(); + const perCatBegin = totals.perYearPerCategorie[y]; + const perCatEind = running ? totals.currentPerCategorie : totals.perYearPerCategorie[y + 1]; + const begin = jaarTotaal(perCatBegin); + const eindIso = running ? nowIso : peilIso(y + 1); + const eind = perCatEind ? jaarTotaal(perCatEind) : null; + if (eind == null) continue; + let beginWeg = 0, eindWeg = 0; + const zonderKoers = []; + for (const r of rows) { + const b = r.perYear[y], e = running ? r.current : r.perYear[y + 1]; + if (!b || !e) continue; + const bOnbekend = b.sats !== 0 && b.value == null; + const eOnbekend = e.sats !== 0 && e.value == null; + if (bOnbekend === eOnbekend) continue; + if (bOnbekend) eindWeg += e.value || 0; + else beginWeg += b.value || 0; + zonderKoers.push(r.name); + } + const f = await flows(peilIso(y), eindIso, grids, telt, grens); + let unexplained = 0; + let uitBalansen = 0; + const unexplainedRows = []; + const openingPerPaar = new Map(); + for (const brok of await unexplainedByPair(peilIso(y), eindIso)) { + const paar = `${brok.accountId}|${brok.assetId}`; + const r = rijPerPaar.get(paar); + if (!r) continue; + const prijs = running ? r.current.price : (r.perYear[y + 1] ? r.perYear[y + 1].price : null); + if (prijs == null) continue; + const valueOf = eenheden => eenheden / Math.pow(10, r.asset.decimals) * prijs; + const opening = valueOf(brok.openingUnits); + const value = valueOf(brok.units); + uitBalansen += opening; + openingPerPaar.set(paar, opening); + if (Math.abs(value - opening) < 0.005) continue; + unexplained += value - opening; + unexplainedRows.push({ name: r.name, units: brok.units, value: value - opening }); + } + unexplainedRows.sort((a, b) => Math.abs(b.value) - Math.abs(a.value)); + await setRowPct(rows, grids, { y, running, vanIso: peilIso(y), totIso: eindIso, openingPerPaar, methode }); + const result = (eind - eindWeg) - (begin - beginWeg) - f.deposits + f.withdrawals - uitBalansen - f.giften; + const beginMeet = begin - beginWeg; + const pct = beginMeet > 0 + ? rendementVan(methode, { + gain: result, begin: beginMeet, eind: eind - eindWeg - uitBalansen, flows: f.legs, + fromIso: peilIso(y), untilIso: eindIso, + }) + : null; + aanwas.push({ + year: y, running, begin, eind, beginWeg, + deposits: f.deposits, withdrawals: f.withdrawals, uitBalansen, giften: f.giften, + result, pct, unexplained, unexplainedRows, zonderKoers, + }); + } + + const pct = { perYear: {}, current: null }; + for (const y of years) pct.perYear[y] = null; + for (const a of aanwas) { + if (a.running) pct.current = a.pct; else pct.perYear[a.year + 1] = a.pct; + } + + const perJaar = years.map(y => { + const perCategorie = totals.perYearPerCategorie[y] || {}; + const rendement = aanwas.find(x => x.year === y) || null; + return { + year: y, + value: totals.perYear[y], + perCategorie, + categorieCompleet: !(perCategorie[ONBEKEND] > 0), + rendement: rendement ? rendement.result : null, + running: rendement ? rendement.running : false, + }; + }); + + return { years, nowIso, rows, totals, pct, aanwas, perJaar, multiAsset, buitenBox3, firstYear, + ingekort, emptyYears, mwrMethod: methode, categoryRows, categorySplit, hasTaxSettings }; +} + +export async function currentStanding() { + const assets = await listAssets(); + if (assets.length === 0) return { rows: [], total: 0, totalSats: 0, today: null }; + const grids = await buildGrids(assets); + const live = await liveSpots(assets); + const hctx = await herkomstContext(); + const nowIso = localNowIso(); + const rows = []; + let total = 0, totalSats = 0; + const som = new Map(); + const voegToe = (ac, a, sats) => { + if (sats === 0) return; + const eigenRaster = grids.get(a.id); + const price = currentPriceFor(a, eigenRaster, live); + const value = price != null ? sats / Math.pow(10, a.decimals) * price : null; + const origin = value != null ? huidigeHerkomst(a, eigenRaster, live, price, hctx) : null; + rows.push({ account: ac, asset: a, sats, value, origin }); + if (value != null) total += value; + totalSats += sats; + }; + const accounts = await listAccounts(); + for (const ac of accounts) { + const eigen = isOwnWealth(ac); + for (const a of assets) { + const sats = await positionAt(ac.id, a.id, nowIso); + som.set(a.id, (som.get(a.id) || 0) + sats); + if (eigen) voegToe(ac, a, sats); + } + } + const rest = await restPerAsset(nowIso, som); + for (const a of assets) voegToe(geenRekening(), a, rest.get(a.id) || 0); + rows.sort((x, y) => (y.value || 0) - (x.value || 0)); + return { rows, total, totalSats, today: await resultToday(assets, grids, live, accounts) }; +} + +async function resultToday(assets, grids, live, accounts) { + try { + const [events, valuations] = await Promise.all([listEvents(), listValuations()]); + const prijzen = new Map(assets.map(a => [a.id, grids.get(a.id)?.grid || []])); + const excludedAccounts = new Set(accounts.filter(ac => !isOwnWealth(ac)).map(ac => ac.id)); + return todayResults(metSpotVandaag(dagreeks({ events, valuations, assets, prijzen, excludedAccounts }), assets, live)); + } catch (err) { + console.warn('[standing] today\'s result not computed:', err); + return null; + } +} diff --git a/client/wealth/performance.js b/client/wealth/performance.js new file mode 100644 index 0000000..0d0a31a --- /dev/null +++ b/client/wealth/performance.js @@ -0,0 +1,150 @@ +// Generated, do not edit. sha256:effc21e27380a5223a30516e074971aec0e56f100cf6c94c8684cfdf36fcca0e +import { CONFIG } from '../../shared/config.js'; + +export function weightOf(atIso, fromIso, untilIso) { + const from = Date.parse(fromIso); + const until = Date.parse(untilIso); + const at = Date.parse(atIso); + if (!Number.isFinite(from) || !Number.isFinite(until) || !Number.isFinite(at)) return null; + const span = until - from; + if (span <= 0) return 1; + return Math.min(1, Math.max(0, (until - at) / span)); +} + +export function averageCapital(begin, flows = [], { fromIso, untilIso } = {}) { + if (typeof begin !== 'number' || !Number.isFinite(begin)) return null; + let som = begin; + for (const f of flows) { + const w = weightOf(f?.atIso, fromIso, untilIso); + const fiat = Number(f?.fiat); + if (w == null || !Number.isFinite(fiat)) continue; + som += fiat * w; + } + return som; +} + +export function modifiedDietz({ gain, begin, flows = [], fromIso, untilIso } = {}) { + if (typeof gain !== 'number' || !Number.isFinite(gain)) return null; + const noemer = averageCapital(begin, flows, { fromIso, untilIso }); + if (noemer == null || !(noemer > 0)) return null; + return gain / noemer; +} + +export function dailyFlows(cumulative = []) { + return cumulative.map((v, i) => (i === 0 ? 0 : (Number(v) || 0) - (Number(cumulative[i - 1]) || 0))); +} + +export function timeWeighted(values = [], flows = [], outflows = []) { + let factor = 1; + let measured = false; + for (const f of dailyFactors(values, flows, outflows)) { + if (f == null) continue; + factor *= f; + measured = true; + } + return measured ? factor - 1 : null; +} + +export function dailyFactors(values = [], flows = [], outflows = []) { + if (!Array.isArray(values)) return []; + const out = values.map(() => null); + let closed = false; + for (let i = 1; i < values.length; i++) { + const f = Number(flows[i]) || 0; + const inflow = Math.max(f, 0); + const outflow = Math.max(-f, 0) + (Number(outflows[i]) || 0); + const previous = Number(values[i - 1]); + if (closed) { + const reopen = inflow > 0 && (!(previous > 0) || inflow >= CONFIG.WEALTH_RETURN.REOPEN_MULTIPLE * previous); + if (!reopen) continue; + closed = false; + } + const end = Number(values[i]); + const base = previous + inflow; + if (!Number.isFinite(end) || !Number.isFinite(base) || !(base > 0)) continue; + out[i] = (end + outflow) / base; + if (outflow > 0 && end < CONFIG.WEALTH_RETURN.DUST_SHARE * base) closed = true; + } + return out; +} + +export function lastDayHeld(units = [], from = 0, to = 0) { + let i = Math.min(to, units.length - 1); + while (i > from && !units[i]) i--; + return i >= from && units[i] ? i : null; +} + +export function windowTwr(reeks = {}, venster = {}) { + const s = windowSlices(reeks, venster); + return s ? timeWeighted(s.values, s.inflows, s.outflows) : null; +} + +export function windowFactors(reeks = {}, venster = {}) { + const out = (reeks.waarden || []).map(() => null); + const s = windowSlices(reeks, venster); + if (!s) return out; + dailyFactors(s.values, s.inflows, s.outflows).forEach((f, k) => { + const i = s.first + k; + if (i >= 0 && i < out.length) out[i] = f; + }); + return out; +} + +function windowSlices(reeks, venster) { + const waarden = reeks.waarden || []; + const van = Number(venster.van) || 0; + const start = reeks.eersteDag == null ? 0 : Number(reeks.eersteDag) - 1; + const vanaf = Math.max(start, van > 0 ? van : -1); + if (reeks.eenheden && lastDayHeld(reeks.eenheden, Math.max(vanaf, 0), venster.tot) == null) return null; + const tot = Math.min(venster.tot, waarden.length - 1) + 1; + const nulDag = vanaf < 0 ? [0] : []; + const snee = r => [...nulDag, ...(r || []).slice(Math.max(vanaf, 0), tot)]; + const { erin, eruit } = flowsOf(snee(reeks.inleg), snee(reeks.ankers), reeks.uit ? snee(reeks.uit) : []); + return { values: snee(waarden), inflows: erin, outflows: eruit, first: vanaf }; +} + +export function flowsOf(inleg = [], ankers = [], uit = []) { + const bruto = dailyFlows(inleg.map((v, i) => (Number(v) || 0) + (Number(uit[i]) || 0))); + const verkocht = dailyFlows(uit); + const anker = dailyFlows(ankers); + return { + erin: bruto.map((v, i) => v + Math.max(anker[i] || 0, 0)), + eruit: bruto.map((_, i) => (verkocht[i] || 0) + Math.max(-(anker[i] || 0), 0)), + }; +} + +const IRR_EPS = 1e-7; +const IRR_STAPPEN = 200; +const IRR_MIN = -0.9999, IRR_MAX = 10; + +function contanteWaarde(kasstromen, rente) { + let som = 0; + for (const k of kasstromen) som += k.fiat / Math.pow(1 + rente, k.jaren); + return som; +} + +export function irr(flows = [], { fromIso } = {}) { + const start = Date.parse(fromIso ?? flows[0]?.atIso); + if (!Number.isFinite(start)) return null; + const kasstromen = []; + for (const f of flows) { + const at = Date.parse(f?.atIso); + const fiat = Number(f?.fiat); + if (!Number.isFinite(at) || !Number.isFinite(fiat)) return null; + kasstromen.push({ fiat, jaren: (at - start) / (365.2425 * 24 * 60 * 60 * 1000) }); + } + if (!kasstromen.some(k => k.fiat > 0) || !kasstromen.some(k => k.fiat < 0)) return null; + + let laag = IRR_MIN, hoog = IRR_MAX; + let fLaag = contanteWaarde(kasstromen, laag); + let fHoog = contanteWaarde(kasstromen, hoog); + if (!Number.isFinite(fLaag) || !Number.isFinite(fHoog) || fLaag * fHoog > 0) return null; + for (let i = 0; i < IRR_STAPPEN; i++) { + const midden = (laag + hoog) / 2; + const fMidden = contanteWaarde(kasstromen, midden); + if (!Number.isFinite(fMidden)) return null; + if (Math.abs(fMidden) < IRR_EPS || (hoog - laag) / 2 < IRR_EPS) return midden; + if (fLaag * fMidden <= 0) { hoog = midden; fHoog = fMidden; } else { laag = midden; fLaag = fMidden; } + } + return (laag + hoog) / 2; +} diff --git a/client/wealth/return-chart.js b/client/wealth/return-chart.js new file mode 100644 index 0000000..79b43a3 --- /dev/null +++ b/client/wealth/return-chart.js @@ -0,0 +1,143 @@ +// Generated, do not edit. sha256:80acd8f6f37bf4f64acd4a1259ffb1f609bdfe3d99f67a807eaad2154af68875 +import { loadCharting, readPalette } from '../ui/chart-setup.js'; +import { hexToRgb } from '../ui/charting.js'; +import { t } from '../i18n/index.js'; +import { fmtBaseSigned, fmtPct, colouredSigns } from '../ui/format.js'; + +let tipEl = null; +const hide = () => tipEl?.classList.remove('is-visible'); + +const tone = v => (v > 0.005 ? 't-pos' : v < -0.005 ? 't-neg' : ''); + +function row(label, amount, pct, extra = '') { + const r = document.createElement('span'); + r.className = `ring-tip-row ${extra}`.trim(); + const name = document.createElement('span'); + name.className = 'ring-tip-pct'; + name.textContent = label; + const value = document.createElement('span'); + value.className = `val ${tone(amount)}`; + value.textContent = fmtBaseSigned(amount, colouredSigns()) + (pct == null ? '' : ` (${fmtPct(pct, { fine: true, signs: colouredSigns() })})`); + r.append(name, value); + return r; +} + +function tip({ chart, tooltip }, points, labels) { + if (!tipEl || !tipEl.isConnected) { + tipEl = document.createElement('div'); + tipEl.className = 'ring-tip'; + tipEl.setAttribute('role', 'tooltip'); + document.body.appendChild(tipEl); + window.addEventListener('scroll', hide, { capture: true, passive: true }); + } + if (!tooltip.opacity || !tooltip.dataPoints?.length) { hide(); return; } + const n = tooltip.dataPoints[0].dataIndex; + const p = points[n]; + const head = document.createElement('span'); + head.className = 'ring-tip-label'; + head.textContent = labels[n]; + const rows = [head]; + if (p.day != null) rows.push(row(t('wealth.returnDay'), p.day, p.pct)); + for (const c of p.top || []) rows.push(row(c.label, c.day, c.pct, 'ring-tip-row--sub')); + rows.push(row(t('wealth.returnSoFar'), p.cumulative, null)); + if (p.carried) { + const note = document.createElement('span'); + note.className = 'ring-tip-row ring-tip-row--sub t-muted'; + note.textContent = t('wealth.returnCarried'); + rows.push(note); + } + tipEl.replaceChildren(...rows); + const box = chart.canvas.getBoundingClientRect(); + tipEl.style.left = `${Math.round(box.left + tooltip.caretX)}px`; + tipEl.style.top = `${Math.round(box.top + tooltip.caretY)}px`; + tipEl.classList.add('is-visible'); +} + +const rgba = (hex, a) => { const c = hexToRgb(hex); return c ? `rgba(${c.r},${c.g},${c.b},${a})` : null; }; + +function gradientFill(green, red, pal) { + return { + id: 'return-gradient-fill', + afterLayout(chart) { + const { top, bottom } = chart.chartArea || {}; + const zero = chart.scales?.y?.getPixelForValue(0); + if (![top, bottom, zero].every(Number.isFinite)) return; + const upTop = rgba(green, 0.42), upZero = rgba(green, 0.04); + const downZero = rgba(red, 0.04), downBottom = rgba(red, 0.42); + if (!upTop || !downBottom) return; + const { ctx } = chart; + const up = ctx.createLinearGradient(0, top, 0, Math.max(zero, top + 1)); + up.addColorStop(0, upTop); + up.addColorStop(1, upZero); + const down = ctx.createLinearGradient(0, Math.min(zero, bottom - 1), 0, bottom); + down.addColorStop(0, downZero); + down.addColorStop(1, downBottom); + chart.data.datasets[0].fill = { target: 'origin', above: up, below: down }; + }, + }; +} + +function hoverLine(pal) { + return { + id: 'return-hover-line', + afterDatasetsDraw(chart) { + const active = chart.tooltip?.getActiveElements?.() || []; + if (!active.length) return; + const x = active[0].element.x; + const { top, bottom } = chart.chartArea; + const { ctx } = chart; + ctx.save(); + ctx.strokeStyle = pal.textSec; + ctx.globalAlpha = 0.6; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(Math.round(x) + 0.5, top); + ctx.lineTo(Math.round(x) + 0.5, bottom); + ctx.stroke(); + ctx.restore(); + }, + }; +} + +export async function drawReturnChart(canvas, points, labels) { + const Chart = await loadCharting(); + const pal = readPalette(); + const green = pal.sigGreen, red = pal.sigRed; + const chart = new Chart(canvas, { + type: 'line', + plugins: [hoverLine(pal), gradientFill(green, red, pal)], + data: { + labels, + datasets: [{ + data: points.map(p => p.cumulative), + borderColor: green, borderWidth: 2, pointRadius: 0, pointHoverRadius: 3, + segment: { + borderColor: c => (c.p1.parsed.y < 0 ? red : green), + borderDash: c => (points[c.p0DataIndex]?.carried || points[c.p1DataIndex]?.carried ? [4, 4] : undefined), + }, + pointHoverBackgroundColor: c => ((c.parsed?.y ?? 0) < 0 ? red : green), cubicInterpolationMode: 'monotone', + fill: { target: 'origin', above: pal.greenZone, below: pal.redZone }, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { mode: 'index', intersect: false }, + layout: { padding: { top: 6, right: 0, bottom: 6, left: 0 } }, + scales: { + x: { display: false }, + y: { beginAtZero: true, ticks: { display: false }, border: { display: false }, afterFit: s => { s.width = 0; }, + grid: { color: c => (c.tick?.value === 0 ? pal.textSec : 'transparent') } }, + }, + plugins: { + legend: { display: false }, + tooltip: { enabled: false, external: ctx => tip(ctx, points, labels) }, + }, + }, + }); + canvas.addEventListener('mouseleave', hide); + const destroy = chart.destroy.bind(chart); + chart.destroy = () => { hide(); destroy(); }; + return chart; +} diff --git a/client/wealth/return-dialog.js b/client/wealth/return-dialog.js new file mode 100644 index 0000000..fccc7f2 --- /dev/null +++ b/client/wealth/return-dialog.js @@ -0,0 +1,181 @@ +// Generated, do not edit. sha256:c1446a22c58388fe401f93d2f54194b187120f3e6ec07b997b51d0acb9bb51b4 +import * as dialog from '../ui/dialog.js'; +import { esc } from '../ui/escape.js'; +import { fmtBaseSigned, fmtPct, pctTone, colouredSigns, isBaseAsset } from '../ui/format.js'; +import { assetLabel, byKindAndLabel } from '../ui/asset-label.js'; +import { t } from '../i18n/index.js'; +import { localDateNl } from '../../shared/time.js'; +import { ENUMS } from '../../shared/config.js'; +import { seriesFor } from './series.js'; +import { periodChoices, startPeriod, returnLine, idsForSelection, contributorsPerDay } from './return-view.js'; +import { getPref, setPref } from '../data/preferences.js'; +import { drawReturnChart } from './return-chart.js'; +import { carriedDays } from './carried.js'; +import { selectLabel } from '../ui/select-label.js'; + +const DASH = '-'; +const PREF_SCOPE = 'wealth.return'; +const live = new Set(); + +export function openReturnDialog({ reeks, assets = [], page = null, selection = null } = {}) { + const closed = dialog.detail({ title: t('wealth.returnTitle'), html: '
    ' }); + const hosts = document.querySelectorAll('.dialog-overlay [data-return]'); + const host = hosts[hosts.length - 1]; + if (!host) return; + const controls = host.closest('.dialog')?.querySelector('[data-dialog-aside]') || null; + const ids = idsForSelection(selection, assets); + const view = mount(host, { reeks, assets, page, data: dataKeyFor(selection, ids), selectionIds: ids, controls }); + closed.then(() => view.destroy()); +} + +export async function mountReturnPanel(host, { reeks, asset }) { + const key = String(asset.id); + const stored = await getPref(PREF_SCOPE, key, null).catch(() => null); + if (!host.isConnected) return null; + return mount(host, { reeks, assets: [asset], page: null, data: `asset:${asset.id}`, stored, + onPeriod: period => setPref(PREF_SCOPE, key, period).catch(() => { }) }); +} + +export function refreshReturn() { + for (const v of [...live]) v.render(); +} + +export function lineForData({ reeks, assets = [], data = 'total', venster = null, selectionIds = null }) { + const ids = idsOf(data, assets, selectionIds); + const line = returnLine(seriesFor(reeks, ids && ids.length === 1 ? ids[0] : ids), venster); + const noPct = withoutPct(ids, assets); + const top = contributorsOf(reeks, ids ?? assets.map(a => a.id), venster, assets); + const carried = carriedDays(reeks, ids, assets); + return { + amount: line.amount, + pct: noPct ? null : line.pct, + points: line.points.map(p => ({ ...p, pct: noPct ? null : p.pct, top: top.get(p.i) || [], carried: carried.has(p.i) })), + }; +} + +export function drawLine(canvas, reeks, points) { + return drawReturnChart(canvas, points, points.map(p => localDateNl(`${reeks.dagen[p.i]}T00:00:00Z`))); +} + +function dataKeyFor(selection, ids) { + if (!ids) return 'total'; + if (selection?.kind) return `kind:${selection.kind}`; + if (ids.length === 1) return `asset:${ids[0]}`; + return 'selection'; +} + +function idsOf(key, assets, selectionIds) { + if (key === 'total') return null; + if (key === 'selection') return selectionIds; + if (key.startsWith('kind:')) return assets.filter(a => a.kind === key.slice(5)).map(a => a.id); + return [Number(key.slice(6))]; +} + +const withoutPct = (ids, assets) => ids?.length === 1 && isBaseAsset(assets.find(a => a.id === ids[0]) || {}); + +function contributorsOf(reeks, ids, venster, assets) { + const byId = new Map(assets.map(a => [a.id, a])); + const named = new Map(); + for (const [i, list] of contributorsPerDay(reeks, ids, venster)) { + named.set(i, list.filter(c => byId.has(c.id)).map(c => ({ + label: assetLabel(byId.get(c.id)), day: c.day, pct: isBaseAsset(byId.get(c.id)) ? null : c.pct, + }))); + } + return named; +} + +function mount(host, { reeks, assets, page, data, selectionIds = null, controls = null, stored = null, onPeriod = null }) { + const length = reeks?.dagen?.length || 0; + const periods = periodChoices(length, page); + const state = { data, period: startPeriod(periods, stored), chart: null, gen: 0, lines: null }; + + const view = { + render() { + if (!host.isConnected) { view.destroy(); return; } + if (!length) { host.innerHTML = `
    ${t('wealth.noChart')}
    `; return; } + const lines = new Map(periods.map(p => [p.key, lineForData({ reeks, assets, data: state.data, venster: p.venster, selectionIds })])); + state.lines = lines; + if (controls) controls.innerHTML = dataSelect(assets, state.data, selectionIds); + host.innerHTML = ` +
    ${periods.map(p => periodButton(p, lines.get(p.key), state.period)).join('')}
    +
    `; + drawChart(lines.get(state.period)?.points || []); + }, + destroy() { + live.delete(view); + state.chart?.destroy(); + state.chart = null; + }, + }; + + async function drawChart(points) { + const gen = ++state.gen; + state.chart?.destroy(); + state.chart = null; + const canvas = host.querySelector('[data-return-chart]'); + if (!canvas) return; + if (points.length < 2) { + canvas.parentElement.innerHTML = `
    ${t('wealth.noChart')}
    `; + return; + } + const chart = await drawLine(canvas, reeks, points).catch(() => null); + if (gen !== state.gen || !host.isConnected) { chart?.destroy(); return; } + state.chart = chart; + } + + host.addEventListener('click', e => { + const btn = e.target.closest('[data-period]'); + if (!btn || btn.disabled || btn.dataset.period === state.period) return; + state.period = btn.dataset.period; + onPeriod?.(state.period); + for (const b of host.querySelectorAll('[data-period]')) b.classList.toggle('is-on', b === btn); + if (!host.querySelector('[data-return-chart]')) { + host.querySelector('.return-plot').innerHTML = ''; + } + drawChart(state.lines?.get(state.period)?.points || []); + }); + controls?.addEventListener('change', e => { + const sel = e.target.closest('[data-return-data]'); + if (!sel) return; + state.data = sel.value; + view.render(); + }); + + live.add(view); + view.render(); + return view; +} + +function periodLabel(p) { + if (p.key === 'page') return p.label ?? DASH; + if (p.days == null) return t('wealth.sinceStart'); + if (p.days === 1) return t('wealth.periodDay'); + if (p.days === 365) return t('wealth.periodYear'); + return t('wealth.periodDays', { n: p.days }); +} + +const tone = v => (v == null ? '' : v > 0.005 ? 't-pos' : v < -0.005 ? 't-neg' : ''); + +function periodButton(p, line, current) { + const on = p.key === current ? ' is-on' : ''; + const off = p.venster ? '' : ' disabled'; + const amount = line?.amount == null ? DASH : `${esc(fmtBaseSigned(line.amount, colouredSigns()))}`; + const pct = line?.pct == null ? '' : fmtPct(line.pct, { fine: p.days === 1, signs: colouredSigns() }); + return ``; +} + +function dataSelect(assets, current, selectionIds) { + const opt = (value, label) => ``; + const kinds = ENUMS.ASSET_KIND.filter(k => assets.some(a => a.kind === k)); + const sorted = [...assets].sort(byKindAndLabel); + return selectLabel(``); +} diff --git a/client/wealth/return-view.js b/client/wealth/return-view.js new file mode 100644 index 0000000..0156142 --- /dev/null +++ b/client/wealth/return-view.js @@ -0,0 +1,61 @@ +// Generated, do not edit. sha256:79d0944a46421ed3e71c6aff535ca9c3c77b373253595260dd7e749f587d9edd +import { CONFIG } from '../../shared/config.js'; +import { windowTwr, windowFactors } from './performance.js'; +import { dailyResults, meetVenster, rollingWindows, seriesFor, windowResult } from './series.js'; + +export function periodChoices(length = 0, page = null, days = CONFIG.WEALTH_RETURN.ROLLING_DAYS) { + const rolling = rollingWindows(length, days) + .map(p => ({ key: p.days == null ? 'start' : `d${p.days}`, days: p.days, label: null, venster: p.venster })); + if (!page?.venster || !rolling.length) return rolling; + const venster = meetVenster(page.venster, length); + if (venster.van <= 0 && venster.tot >= length - 1) return rolling; + return [{ key: 'page', days: null, label: page.label ?? null, venster }, ...rolling]; +} + +export function startPeriod(periods, stored = null) { + if (stored && periods.some(p => p.key === stored && p.venster)) return stored; + return periods.some(p => p.key === 'page') ? 'page' : 'start'; +} + +export function returnLine(input = {}, venster = null) { + if (!venster) return { amount: null, pct: null, points: [] }; + const results = dailyResults(input); + const factors = windowFactors(input, venster); + const tot = Math.min(venster.tot, results.length - 1); + const points = []; + let cumulative = 0; + if (venster.van > 0) points.push({ i: venster.van, cumulative: 0, day: null, pct: null }); + for (let i = venster.van > 0 ? venster.van + 1 : 0; i <= tot; i++) { + cumulative += results[i]; + points.push({ i, cumulative, day: results[i], pct: factors[i] == null ? null : factors[i] - 1 }); + } + return { amount: windowResult(input, venster), pct: windowTwr(input, venster), points }; +} + +export function contributorsPerDay(reeks = {}, ids = [], venster = null, n = 3) { + const out = new Map(); + if (!venster || !ids || ids.length < 2) return out; + const per = ids.map(id => { + const input = seriesFor(reeks, id); + return { id, results: dailyResults(input), factors: windowFactors(input, venster) }; + }); + const tot = Math.min(venster.tot, (reeks.dagen?.length || 0) - 1); + for (let i = venster.van > 0 ? venster.van + 1 : 0; i <= tot; i++) { + const top = per + .map(p => ({ id: p.id, day: p.results[i] || 0, pct: p.factors[i] == null ? null : p.factors[i] - 1 })) + .filter(c => Math.abs(c.day) >= 0.005) + .sort((a, b) => Math.abs(b.day) - Math.abs(a.day)) + .slice(0, n); + if (top.length) out.set(i, top); + } + return out; +} + +export function idsForSelection(sel, assets = []) { + if (sel?.assetIds?.length) return [...sel.assetIds]; + if (sel?.kind) { + const ids = assets.filter(a => a.kind === sel.kind).map(a => a.id); + return ids.length ? ids : null; + } + return null; +} diff --git a/client/wealth/selection.js b/client/wealth/selection.js new file mode 100644 index 0000000..09c1ae8 --- /dev/null +++ b/client/wealth/selection.js @@ -0,0 +1,52 @@ +// Generated, do not edit. sha256:0655118dcbbdee6f738790678dd93e04fa937dd8c25c946b1d37d8d899f98d2a +export function sameSelection(a, b) { + if (!a || !b) return a === b; + if (a.kind || b.kind) return a.kind === b.kind; + if (a.account != null || b.account != null) return a.account === b.account; + return String(a.assetIds) === String(b.assetIds); +} + +export function selectionFromStored(bewaard) { + if (!bewaard || typeof bewaard !== 'object') return null; + if (bewaard.kind) return { kind: String(bewaard.kind) }; + const ids = Array.isArray(bewaard.assetIds) ? bewaard.assetIds.map(Number).filter(n => Number.isInteger(n) && n > 0) : []; + const account = Number(bewaard.account); + if (bewaard.account != null && Number.isInteger(account) && account >= 0) return { account, assetIds: ids }; + return ids.length ? { assetIds: ids } : null; +} + +export function hiddenFromStored(bewaard, { allowZero = false } = {}) { + if (!Array.isArray(bewaard)) return new Set(); + return new Set(bewaard.map(Number).filter(n => Number.isInteger(n) && (allowZero ? n >= 0 : n > 0))); +} + +export function hiddenAfterFilter(hidden, itemIds, selected) { + const chips = new Set(itemIds); + const s = new Set([...hidden].filter(id => !chips.has(id))); + for (const id of itemIds) if (!selected.has(id)) s.add(id); + return [...s].sort((a, b) => (typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b)))); +} + +export const NO_ACCOUNT_KIND = 'none'; +export const accountKindOf = c => c.account?.kind ?? NO_ACCOUNT_KIND; + +export function hiddenKindsFromStored(bewaard, allowed = []) { + if (!Array.isArray(bewaard)) return new Set(); + const geldig = new Set([...allowed, NO_ACCOUNT_KIND]); + return new Set(bewaard.map(String).filter(k => geldig.has(k))); +} + +export function accountKindItems(cards = []) { + const uit = []; + for (const c of cards) { + const kind = accountKindOf(c); + if (!uit.includes(kind)) uit.push(kind); + } + return uit; +} + +export function splitHidden(kaarten = [], hidden = new Set(), keyOf = k => k.asset.id) { + const shown = [], verborgen = []; + for (const k of kaarten) (hidden.has(keyOf(k)) ? verborgen : shown).push(k); + return { shown, hidden: verborgen }; +} diff --git a/client/wealth/series.js b/client/wealth/series.js new file mode 100644 index 0000000..29209d9 --- /dev/null +++ b/client/wealth/series.js @@ -0,0 +1,554 @@ +// Generated, do not edit. sha256:ed8ca8faa6323fa2c4ff508c950678174612f6518b6d32ceba92945717d7bce7 +import { listEvents } from '../data/events.js'; +import { listValuations } from '../data/valuations.js'; +import { listAssets } from '../data/assets.js'; +import { listPriceHistory } from '../data/price-history.js'; +import { toDailyGrid } from '../signals/data.js'; +import { liveSpotPriceFor } from '../price/providers.js'; +import { nowUtcIso } from '../../shared/time.js'; +import { saleResult, feeResult } from '../../shared/wealth-metrics.js'; +import { CONFIG } from '../../shared/config.js'; +import { windowTwr } from './performance.js'; + +const DAG_MS = 24 * 60 * 60 * 1000; + +export const dagVan = iso => String(iso).slice(0, 10); + +const paar = (accountId, assetId) => `${accountId ?? 'geen'}|${assetId}`; +const accountVanSleutel = sleutel => { + const deel = String(sleutel).split('|')[0]; + return deel === 'geen' ? null : Number(deel); +}; + +export function dagenTussen(vanDag, totDag) { + const start = Date.parse(`${vanDag}T00:00:00Z`); + const eind = Date.parse(`${totDag}T00:00:00Z`); + if (!Number.isFinite(start) || !Number.isFinite(eind) || eind < start) return []; + const uit = []; + for (let t = start; t <= eind; t += DAG_MS) uit.push(new Date(t).toISOString().slice(0, 10)); + return uit; +} + +export function deltasVan(e) { + const bedrag = Number(e.amount) || 0; + const fee = Number(e.unitFee) || 0; + switch (e.type) { + case 'buy': + case 'interest': + return [{ sleutel: paar(e.toAccountId, e.assetId), delta: bedrag }]; + case 'sell': + return [{ sleutel: paar(e.fromAccountId, e.assetId), delta: -(bedrag + fee) }]; + case 'transfer': + return [ + ...(e.fromAccountId != null ? [{ sleutel: paar(e.fromAccountId, e.assetId), delta: -(bedrag + fee) }] : []), + ...(e.toAccountId != null ? [{ sleutel: paar(e.toAccountId, e.assetId), delta: bedrag }] : []), + ]; + default: + return []; + } +} + +export function ownView(e, excluded) { + if (!excluded || !excluded.size) return e; + const outside = id => id != null && excluded.has(id); + if (e.type === 'transfer') { + const fromOwn = e.fromAccountId != null && !outside(e.fromAccountId); + const toOwn = e.toAccountId != null && !outside(e.toAccountId); + if (!fromOwn && !toOwn) return null; + if (fromOwn && outside(e.toAccountId)) return { ...e, toAccountId: null, crossing: -1 }; + if (toOwn && outside(e.fromAccountId)) return { ...e, fromAccountId: null, crossing: 1 }; + return e; + } + if (e.type === 'sell') return outside(e.fromAccountId) ? null : e; + return outside(e.toAccountId) ? null : e; +} + +function accountsVan(boekingen, ankers) { + const uit = new Set(); + for (const e of boekingen) for (const { sleutel } of deltasVan(e)) uit.add(accountVanSleutel(sleutel)); + for (const v of ankers) uit.add(v.accountId); + return [...uit]; +} + +function accountFlows(e, waardeVan) { + const v = waardeVan(e.amount); + if (v == null) return []; + const van = e.fromAccountId ?? null; + const naar = e.toAccountId ?? null; + if (e.type === 'transfer') { + if (van != null && naar != null) return van === naar ? [] : [{ accountId: van, fiat: -v }, { accountId: naar, fiat: v }]; + if (van == null && naar == null) return []; + return van == null ? [{ accountId: naar, fiat: v }] : [{ accountId: van, fiat: -v }]; + } + const uit = []; + if (e.priceOrigin === 'none') uit.push({ accountId: e.type === 'sell' ? van : naar, fiat: e.type === 'buy' ? v : -v }); + if (e.type === 'interest') uit.push({ accountId: naar, fiat: v, rente: v }); + return uit; +} + +const oplopend = perAsset => new Map([...perAsset].map(([id, set]) => [id, [...set].sort((a, b) => a - b)])); + +function koersOp(grid, dag) { + if (!grid || !grid.length) return null; + const ts = Date.parse(`${dag}T00:00:00Z`); + if (ts < grid[0][0]) return null; + const i = Math.min(grid.length - 1, Math.floor((ts - grid[0][0]) / DAG_MS)); + return grid[i][1]; +} + +export function dagreeks({ events = [], valuations = [], assets = [], prijzen = new Map(), + vanDag = null, totDag = null, excludedAccounts = null } = {}) { + const buiten = excludedAccounts && excludedAccounts.size ? excludedAccounts : null; + const ankers = valuations.filter(v => v.accountId != null && !(buiten && buiten.has(v.accountId))); + const boekingen = buiten ? events.map(e => ownView(e, buiten)).filter(Boolean) : events; + const eersteDag = vanDag ?? vroegsteDag(boekingen, ankers); + const laatsteDag = totDag ?? dagVan(nowUtcIso()); + const dagen = eersteDag ? dagenTussen(eersteDag, laatsteDag) : []; + const leeg = { dagen: [], waarde: [], ingelegd: [], uitgehaald: [], perAsset: new Map(), inlegPerAsset: new Map(), uitPerAsset: new Map(), ankersPerAsset: new Map(), rentePerAsset: new Map(), gerealiseerdPerAsset: new Map(), onbekendPerAsset: new Map(), nietVerklaardPerAsset: new Map(), + koersPerAsset: new Map(), eenhedenPerAsset: new Map(), + aankopen: new Map(), verkopen: new Map(), eersteDagPerAsset: new Map(), + perAccount: new Map(), inlegPerAccount: new Map(), uitPerAccount: new Map(), ankersPerAccount: new Map(), + rentePerAccount: new Map(), nietVerklaardPerAccount: new Map(), bezitPerAccount: new Map(), + eersteDagPerAccount: new Map(), standNu: [] }; + if (!dagen.length) return leeg; + + const tijdlijn = [ + ...boekingen.map(e => ({ op: e.occurredAt, soort: 'boeking', e })), + ...ankers.map(v => ({ op: v.rateAt, soort: 'anker', v })), + ].sort((a, b) => String(a.op).localeCompare(String(b.op))); + + const decimalen = new Map(assets.map(a => [a.id, Math.pow(10, a.decimals)])); + const stand = new Map(); + const assetVanPaar = new Map(); + let inleg = 0; + + const waarde = []; + const ingelegd = []; + const perAsset = new Map(assets.map(a => [a.id, []])); + const koersPerAsset = new Map(assets.map(a => [a.id, []])); + const eenhedenPerAsset = new Map(assets.map(a => [a.id, []])); + const inlegPerAsset = new Map(assets.map(a => [a.id, []])); + const inlegNu = new Map(assets.map(a => [a.id, 0])); + let uit = 0; + const uitgehaald = []; + const uitPerAsset = new Map(assets.map(a => [a.id, []])); + const uitNu = new Map(assets.map(a => [a.id, 0])); + const ankersPerAsset = new Map(assets.map(a => [a.id, []])); + const ankerNu = new Map(assets.map(a => [a.id, 0])); + const rentePerAsset = new Map(assets.map(a => [a.id, []])); + const renteNu = new Map(assets.map(a => [a.id, 0])); + const gerealiseerdPerAsset = new Map(assets.map(a => [a.id, []])); + const gerealiseerdNu = new Map(assets.map(a => [a.id, 0])); + const onbekendPerAsset = new Map(assets.map(a => [a.id, []])); + const onbekendNu = new Map(assets.map(a => [a.id, 0])); + const kostprijsPerAsset = new Map(assets.map(a => [a.id, { buy_amount: 0, buy_spot_raw: 0, buy_fee_total: 0, gift_amount: 0 }])); + const eenhedenVan = assetId => { + let n = 0; + for (const [sleutel, eenheden] of stand) if (assetVanPaar.get(sleutel) === assetId) n += eenheden; + return n; + }; + const nietVerklaardPerAsset = new Map(assets.map(a => [a.id, []])); + const nietVerklaardNu = new Map(assets.map(a => [a.id, 0])); + const geboekt = new Set(); + const aankoopDagen = new Map(assets.map(a => [a.id, new Set()])); + const verkoopDagen = new Map(assets.map(a => [a.id, new Set()])); + const eersteDagPerAsset = new Map(assets.map(a => [a.id, null])); + + const accounts = accountsVan(boekingen, ankers); + const perAccountReeks = () => new Map(accounts.map(id => [id, []])); + const perAccountNu = () => new Map(accounts.map(id => [id, 0])); + const acc = { + reeks: { waarde: perAccountReeks(), inleg: perAccountReeks(), uit: perAccountReeks(), ankers: perAccountReeks(), + rente: perAccountReeks(), nietVerklaard: perAccountReeks(), bezit: perAccountReeks() }, + nu: { inleg: perAccountNu(), uit: perAccountNu(), ankers: perAccountNu(), rente: perAccountNu(), nietVerklaard: perAccountNu() }, + }; + const accBij = (soort, accountId, v) => { if (acc.nu[soort].has(accountId)) acc.nu[soort].set(accountId, acc.nu[soort].get(accountId) + v); }; + const eersteDagPerAccount = new Map(accounts.map(id => [id, null])); + + let i = 0; + for (const [dagNr, dag] of dagen.entries()) { + const grens = `${dag}T23:59:59.999Z`; + while (i < tijdlijn.length && String(tijdlijn[i].op) <= grens) { + const item = tijdlijn[i++]; + if (item.soort === 'anker') { + const s = paar(item.v.accountId, item.v.assetId); + const nieuw = Number(item.v.balance) || 0; + const sprong = nieuw - (stand.get(s) || 0); + const koers = koersOp(prijzen.get(item.v.assetId), dag); + if (sprong !== 0 && koers != null && ankerNu.has(item.v.assetId)) { + const fiat = (sprong / (decimalen.get(item.v.assetId) || 1)) * koers; + ankerNu.set(item.v.assetId, ankerNu.get(item.v.assetId) + fiat); + const bevinding = geboekt.has(s) && !item.v.gapAcceptedAt; + if (bevinding) nietVerklaardNu.set(item.v.assetId, nietVerklaardNu.get(item.v.assetId) + fiat); + accBij('ankers', item.v.accountId, fiat); + if (bevinding) accBij('nietVerklaard', item.v.accountId, fiat); + } + stand.set(s, nieuw); + assetVanPaar.set(s, item.v.assetId); + continue; + } + const e = item.e; + const kp = kostprijsPerAsset.get(e.assetId); + if (kp && e.type === 'sell' && e.priceOrigin !== 'none') { + const decimals = assets.find(a => a.id === e.assetId)?.decimals ?? 0; + const r = saleResult({ decimals, net: eenhedenVan(e.assetId), ...kp }, e); + if (r == null) onbekendNu.set(e.assetId, onbekendNu.get(e.assetId) + 1); + else gerealiseerdNu.set(e.assetId, gerealiseerdNu.get(e.assetId) + r); + } + if (kp && e.type === 'transfer' && e.fromAccountId != null && Number(e.unitFee) > 0) { + const decimals = assets.find(a => a.id === e.assetId)?.decimals ?? 0; + const r = feeResult({ decimals, net: eenhedenVan(e.assetId), ...kp }, e.unitFee); + if (r == null) onbekendNu.set(e.assetId, onbekendNu.get(e.assetId) + 1); + else gerealiseerdNu.set(e.assetId, gerealiseerdNu.get(e.assetId) + r); + } + if (kp && e.type === 'transfer' && e.fromAccountId != null && Number(e.fiatFee) > 0) { + gerealiseerdNu.set(e.assetId, gerealiseerdNu.get(e.assetId) - Number(e.fiatFee)); + } + if (kp) { + const n = Number(e.amount) || 0; + if (e.type === 'buy' && e.priceOrigin !== 'none') { + kp.buy_amount += n; + kp.buy_spot_raw += n * (Number(e.fiatPrice) || 0); + kp.buy_fee_total += Number(e.fiatFee) || 0; + } + if (e.priceOrigin === 'none') { + if (e.type === 'buy') kp.gift_amount += n; + else if (e.type === 'sell') kp.gift_amount -= n; + else if (e.type === 'transfer' && e.fromAccountId == null && e.toAccountId != null) kp.gift_amount += n; + else if (e.type === 'transfer' && e.toAccountId == null && e.fromAccountId != null) kp.gift_amount -= n; + } + } + for (const { sleutel, delta } of deltasVan(e)) { + stand.set(sleutel, (stand.get(sleutel) || 0) + delta); + assetVanPaar.set(sleutel, e.assetId); + geboekt.add(sleutel); + } + const bij = inlegVan(e, decimalen.get(e.assetId) || 1); + inleg += bij; + if (inlegNu.has(e.assetId)) inlegNu.set(e.assetId, inlegNu.get(e.assetId) + bij); + const eruit = uitVan(e, decimalen.get(e.assetId) || 1); + uit += eruit; + if (uitNu.has(e.assetId)) uitNu.set(e.assetId, uitNu.get(e.assetId) + eruit); + if (decimalen.has(e.assetId)) { + const kant = e.type === 'sell' || e.type === 'transfer' ? (e.fromAccountId ?? null) : (e.toAccountId ?? null); + accBij('inleg', kant, bij); + accBij('uit', kant, eruit); + const koers = koersOp(prijzen.get(e.assetId), dag); + const waardeVan = n => (koers == null ? null : ((Number(n) || 0) / decimalen.get(e.assetId)) * koers); + for (const f of accountFlows(e, waardeVan)) { + accBij('ankers', f.accountId, f.fiat); + if (f.rente) accBij('rente', f.accountId, f.rente); + } + } + const eenzijdig = e.type === 'transfer' && (e.fromAccountId == null) !== (e.toAccountId == null); + if (e.priceOrigin === 'none' && (e.type !== 'transfer' || eenzijdig) && ankerNu.has(e.assetId)) { + const koers = koersOp(prijzen.get(e.assetId), dag); + if (koers != null) { + const binnen = e.type === 'transfer' ? e.fromAccountId == null : e.type === 'buy'; + const fiat = ((Number(e.amount) || 0) / (decimalen.get(e.assetId) || 1)) * koers; + ankerNu.set(e.assetId, ankerNu.get(e.assetId) + (binnen ? fiat : -fiat)); + } + } + if (eenzijdig && e.priceOrigin !== 'none' && ankerNu.has(e.assetId)) { + const koers = koersOp(prijzen.get(e.assetId), dag); + if (koers != null) { + const fiat = ((Number(e.amount) || 0) / (decimalen.get(e.assetId) || 1)) * koers; + ankerNu.set(e.assetId, ankerNu.get(e.assetId) + (e.fromAccountId == null ? fiat : -fiat)); + } + } + if (e.type === 'interest' && ankerNu.has(e.assetId)) { + const koers = koersOp(prijzen.get(e.assetId), dag); + if (koers != null) { + const fiat = ((Number(e.amount) || 0) / (decimalen.get(e.assetId) || 1)) * koers; + ankerNu.set(e.assetId, ankerNu.get(e.assetId) + fiat); + renteNu.set(e.assetId, renteNu.get(e.assetId) + fiat); + } + } + if (dagVan(e.occurredAt) === dag) { + if (e.type === 'buy') aankoopDagen.get(e.assetId)?.add(dagNr); + else if (e.type === 'sell') verkoopDagen.get(e.assetId)?.add(dagNr); + } + } + + const eenhedenVandaag = new Map(); + for (const [sleutel, eenheden] of stand) { + const assetId = assetVanPaar.get(sleutel); + eenhedenVandaag.set(assetId, (eenhedenVandaag.get(assetId) || 0) + eenheden); + } + let dagTotaal = 0; + for (const a of assets) { + const eenheden = eenhedenVandaag.get(a.id) || 0; + if (eenheden !== 0 && eersteDagPerAsset.get(a.id) === null) eersteDagPerAsset.set(a.id, dagNr); + const koers = koersOp(prijzen.get(a.id), dag); + const v = koers == null ? 0 : (eenheden / (decimalen.get(a.id) || 1)) * koers; + perAsset.get(a.id).push(v); + koersPerAsset.get(a.id).push(koers); + eenhedenPerAsset.get(a.id).push(eenheden); + inlegPerAsset.get(a.id).push(inlegNu.get(a.id) || 0); + uitPerAsset.get(a.id).push(uitNu.get(a.id) || 0); + ankersPerAsset.get(a.id).push(ankerNu.get(a.id) || 0); + rentePerAsset.get(a.id).push(renteNu.get(a.id) || 0); + gerealiseerdPerAsset.get(a.id).push(gerealiseerdNu.get(a.id) || 0); + onbekendPerAsset.get(a.id).push(onbekendNu.get(a.id) || 0); + nietVerklaardPerAsset.get(a.id).push(nietVerklaardNu.get(a.id) || 0); + dagTotaal += v; + } + waarde.push(dagTotaal); + ingelegd.push(inleg); + uitgehaald.push(uit); + + const accWaarde = new Map(accounts.map(id => [id, 0])); + const accBezit = new Map(accounts.map(id => [id, 0])); + for (const [sleutel, eenheden] of stand) { + const assetId = assetVanPaar.get(sleutel); + const accountId = accountVanSleutel(sleutel); + if (!eenheden || !decimalen.has(assetId) || !accWaarde.has(accountId)) continue; + const koers = koersOp(prijzen.get(assetId), dag); + if (koers != null) accWaarde.set(accountId, accWaarde.get(accountId) + (eenheden / decimalen.get(assetId)) * koers); + accBezit.set(accountId, accBezit.get(accountId) + 1); + } + for (const id of accounts) { + if (accBezit.get(id) && eersteDagPerAccount.get(id) === null) eersteDagPerAccount.set(id, dagNr); + acc.reeks.waarde.get(id).push(accWaarde.get(id)); + acc.reeks.bezit.get(id).push(accBezit.get(id)); + for (const soort of Object.keys(acc.nu)) acc.reeks[soort].get(id).push(acc.nu[soort].get(id)); + } + } + + return { + dagen, waarde, ingelegd, uitgehaald, perAsset, inlegPerAsset, uitPerAsset, ankersPerAsset, rentePerAsset, + gerealiseerdPerAsset, onbekendPerAsset, nietVerklaardPerAsset, + koersPerAsset, eenhedenPerAsset, eersteDagPerAsset, + aankopen: oplopend(aankoopDagen), + verkopen: oplopend(verkoopDagen), + perAccount: acc.reeks.waarde, inlegPerAccount: acc.reeks.inleg, uitPerAccount: acc.reeks.uit, + ankersPerAccount: acc.reeks.ankers, rentePerAccount: acc.reeks.rente, nietVerklaardPerAccount: acc.reeks.nietVerklaard, + bezitPerAccount: acc.reeks.bezit, eersteDagPerAccount, + standNu: [...stand].filter(([s, n]) => n && decimalen.has(assetVanPaar.get(s))) + .map(([s, n]) => ({ accountId: accountVanSleutel(s), assetId: assetVanPaar.get(s), eenheden: n, + deler: decimalen.get(assetVanPaar.get(s)) })), + }; +} + +export function accountSeries(reeks = {}, accountId = null) { + return { + waarden: reeks.perAccount?.get(accountId) || [], + inleg: reeks.inlegPerAccount?.get(accountId) || [], + uit: reeks.uitPerAccount?.get(accountId) || [], + ankers: reeks.ankersPerAccount?.get(accountId) || [], + rente: reeks.rentePerAccount?.get(accountId) || [], + eenheden: reeks.bezitPerAccount?.get(accountId), + eersteDag: reeks.eersteDagPerAccount?.get(accountId) ?? 0, + }; +} + +export function inlegVan(e, deler) { + const kosten = Number(e.fiatFee) || 0; + if (e.type === 'buy') return ((Number(e.amount) || 0) / deler) * (Number(e.fiatPrice) || 0) + kosten; + if (e.type === 'sell') return -(((Number(e.amount) || 0) / deler) * (Number(e.fiatPrice) || 0)) + kosten; + if (e.type === 'transfer' && e.fromAccountId != null) return kosten; + return 0; +} + +export function uitVan(e, deler) { + if (e.type !== 'sell') return 0; + return ((Number(e.amount) || 0) / deler) * (Number(e.fiatPrice) || 0) - (Number(e.fiatFee) || 0); +} + +export function metSpotVandaag(reeks, assets = [], spots = new Map(), vandaag = dagVan(nowUtcIso())) { + const i = (reeks?.dagen?.length || 0) - 1; + if (i < 0 || reeks.dagen[i] !== vandaag) return reeks; + const decimalen = new Map(assets.map(a => [a.id, Math.pow(10, a.decimals)])); + let totaal = 0; + for (const [assetId, waarden] of reeks.perAsset) { + const spot = Number(spots.get(assetId)); + const eenheden = reeks.eenhedenPerAsset?.get(assetId); + if (Number.isFinite(spot) && spot > 0 && eenheden && decimalen.has(assetId)) { + reeks.koersPerAsset.get(assetId)[i] = spot; + waarden[i] = (eenheden[i] / decimalen.get(assetId)) * spot; + } + totaal += waarden[i] || 0; + } + reeks.waarde[i] = totaal; + if (reeks.perAccount?.size) { + const perAccount = new Map([...reeks.perAccount.keys()].map(id => [id, 0])); + for (const { accountId, assetId, eenheden, deler } of (reeks.standNu || [])) { + const koers = reeks.koersPerAsset?.get(assetId)?.[i]; + if (koers != null && deler && perAccount.has(accountId)) { + perAccount.set(accountId, perAccount.get(accountId) + (eenheden / deler) * koers); + } + } + for (const [id, waarde] of perAccount) reeks.perAccount.get(id)[i] = waarde; + } + return reeks; +} + +export function jaarVensters(dagen = []) { + const uit = new Map(); + dagen.forEach((dag, i) => { + const jaar = Number(String(dag).slice(0, 4)); + if (!Number.isFinite(jaar)) return; + const v = uit.get(jaar); + if (v) v.tot = i; + else uit.set(jaar, { van: i, tot: i }); + }); + return uit; +} + +export function meetVenster({ van, tot } = {}, lengte = Infinity) { + return { van, tot: Math.min((Number(tot) || 0) + 1, lengte - 1) }; +} + +export function windowDelta(r, { van = 0, tot = 0 } = {}) { + if (!Array.isArray(r) || !r.length) return 0; + const eind = Number(r[Math.min(tot, r.length - 1)]) || 0; + return eind - (van > 0 ? (Number(r[van]) || 0) : 0); +} + +export function windowResult({ waarden = [], inleg = [], ankers = [], rente = [] } = {}, venster = {}) { + return windowDelta(waarden, venster) - windowDelta(inleg, venster) + - windowDelta(ankers, venster) + windowDelta(rente, venster); +} + +export function dailyResults({ waarden = [], inleg = [], ankers = [], rente = [] } = {}) { + const step = (r, i) => (Number(r[i]) || 0) - (i > 0 ? (Number(r[i - 1]) || 0) : 0); + return waarden.map((_, i) => step(waarden, i) - step(inleg, i) - step(ankers, i) + step(rente, i)); +} + +export function todayResults(reeks = {}, vandaag = dagVan(nowUtcIso())) { + const i = (reeks.dagen?.length || 0) - 1; + if (i < 0 || reeks.dagen[i] !== vandaag) return null; + const last = input => dailyResults(input)[i]; + const perAsset = new Map(); + for (const id of (reeks.perAsset?.keys?.() || [])) perAsset.set(id, last(seriesFor(reeks, id))); + return { total: last(seriesFor(reeks)), perAsset }; +} + +export function seriesFor(reeks = {}, assetId = null) { + if (assetId == null) { + return { + waarden: reeks.waarde || [], inleg: reeks.ingelegd || [], uit: reeks.uitgehaald || [], + ankers: sumPerDay(reeks.ankersPerAsset), rente: sumPerDay(reeks.rentePerAsset), eersteDag: 0, + }; + } + if (Array.isArray(assetId)) { + const only = map => new Map([...(map || new Map())].filter(([id]) => assetId.includes(id))); + const firsts = assetId.map(id => reeks.eersteDagPerAsset?.get(id)).filter(d => d != null); + return { + waarden: sumPerDay(only(reeks.perAsset)), inleg: sumPerDay(only(reeks.inlegPerAsset)), + uit: sumPerDay(only(reeks.uitPerAsset)), ankers: sumPerDay(only(reeks.ankersPerAsset)), + rente: sumPerDay(only(reeks.rentePerAsset)), eersteDag: firsts.length ? Math.min(...firsts) : 0, + }; + } + return { + waarden: reeks.perAsset?.get(assetId) || [], + inleg: reeks.inlegPerAsset?.get(assetId) || [], + uit: reeks.uitPerAsset?.get(assetId) || [], + ankers: reeks.ankersPerAsset?.get(assetId) || [], + rente: reeks.rentePerAsset?.get(assetId) || [], + eenheden: reeks.eenhedenPerAsset?.get(assetId), + eersteDag: reeks.eersteDagPerAsset?.get(assetId) ?? 0, + }; +} + +function sumPerDay(perAsset) { + const out = []; + for (const r of (perAsset?.values?.() || [])) { + r.forEach((v, i) => { out[i] = (out[i] || 0) + v; }); + } + return out; +} + +export function rollingWindows(length = 0, days = CONFIG.WEALTH_RETURN.ROLLING_DAYS) { + const last = length - 1; + if (last < 0) return []; + return [ + ...days.map(n => ({ days: n, venster: last - n >= 1 ? { van: last - n, tot: last } : null })), + { days: null, venster: { van: 0, tot: last } }, + ]; +} + +export function rollingReturns(input = {}, days = CONFIG.WEALTH_RETURN.ROLLING_DAYS) { + return rollingWindows((input.waarden || []).length, days).map(({ days: n, venster }) => ({ + days: n, + venster, + amount: venster ? windowResult(input, venster) : null, + pct: venster ? windowTwr(input, venster) : null, + })); +} + +export function vroegsteDag(events, ankers) { + const momenten = [...events.map(e => e.occurredAt), ...ankers.map(v => v.rateAt)] + .filter(Boolean).map(dagVan).sort(); + return momenten.length ? momenten[0] : null; +} + +export function managedCostPots({ events = [], prijzen = new Map(), managed = new Set(), totIso = null } = {}) { + const uit = new Map(); + if (!managed || !managed.size) return uit; + const pot = (accountId, assetId) => { + if (accountId == null || !managed.has(accountId)) return null; + const sleutel = `${accountId}|${assetId}`; + if (!uit.has(sleutel)) uit.set(sleutel, { buy_amount: 0, buy_spot_raw: 0, buy_fee_total: 0, buy_events: 0, gift_amount: 0 }); + return uit.get(sleutel); + }; + const volgorde = events.filter(e => !totIso || String(e.occurredAt) <= totIso) + .sort((a, b) => String(a.occurredAt).localeCompare(String(b.occurredAt))); + for (const e of volgorde) { + const n = Number(e.amount) || 0; + const geen = e.priceOrigin === 'none'; + if (e.type === 'buy') { + const p = pot(e.toAccountId, e.assetId); + if (!p) continue; + if (geen) { p.gift_amount += n; continue; } + p.buy_amount += n; + p.buy_spot_raw += n * (Number(e.fiatPrice) || 0); + p.buy_fee_total += Number(e.fiatFee) || 0; + p.buy_events += 1; + } else if (e.type === 'sell') { + const p = pot(e.fromAccountId, e.assetId); + if (p && geen) p.gift_amount -= n; + } else if (e.type === 'transfer') { + const van = e.fromAccountId ?? null; + const naar = e.toAccountId ?? null; + const p = pot(naar, e.assetId); + if (p && van != null && van !== naar) { + const koers = koersOp(prijzen.get(e.assetId), dagVan(e.occurredAt)); + if (koers != null) { p.buy_amount += n; p.buy_spot_raw += n * koers; } + } else if (p && van == null && geen) { + p.gift_amount += n; + } + const q = naar == null ? pot(van, e.assetId) : null; + if (q && geen) q.gift_amount -= n; + } + } + return uit; +} + +export async function dagreeksVanNu({ vanDag = null, totDag = null, excludedAccounts = null, managedAccounts = null } = {}) { + const [events, valuations, assets] = await Promise.all([listEvents(), listValuations(), listAssets()]); + + const prijzen = new Map(); + const rijenPerAsset = new Map(); + for (const a of assets) { + let rijen = []; + try { rijen = await listPriceHistory(a.id); } catch { rijen = []; } + rijenPerAsset.set(a.id, rijen); + try { + prijzen.set(a.id, toDailyGrid(rijen)); + } catch { + prijzen.set(a.id, []); + } + } + const spots = new Map(); + for (const a of assets) { + try { const price = await liveSpotPriceFor(a); if (price != null) spots.set(a.id, price); } + catch { } + } + const reeks = metSpotVandaag(dagreeks({ events, valuations, assets, prijzen, vanDag, totDag, excludedAccounts }), assets, spots); + reeks.rijenPerAsset = rijenPerAsset; + if (managedAccounts && managedAccounts.size) { + reeks.beheerdeKostprijs = totIso => managedCostPots({ events, prijzen, managed: managedAccounts, totIso }); + } + return reeks; +} diff --git a/client/wealth/year-chart.js b/client/wealth/year-chart.js new file mode 100644 index 0000000..df84d8e --- /dev/null +++ b/client/wealth/year-chart.js @@ -0,0 +1,80 @@ +// Generated, do not edit. sha256:2b462247335f4413d5ae4038e57da8d4d3e70b4fb676fc84dcbba876e90e1c18 +import { loadCharting, readPalette } from '../ui/chart-setup.js'; +import { t } from '../i18n/index.js'; +import { fmtBase } from '../ui/format.js'; + +let tipEl = null; +const verberg = () => tipEl?.classList.remove('is-visible'); + +function tip({ chart, tooltip }, rijen) { + if (!tipEl) { + tipEl = document.createElement('div'); + tipEl.className = 'ring-tip'; + document.body.appendChild(tipEl); + window.addEventListener('scroll', verberg, { capture: true, passive: true }); + } + if (!tooltip.opacity || !tooltip.dataPoints?.length) { verberg(); return; } + const r = rijen[tooltip.dataPoints[0].dataIndex]; + const regel = (label, bedrag) => { + const rij = document.createElement('span'); + rij.className = 'ring-tip-row'; + const naam = document.createElement('span'); + naam.className = 'ring-tip-pct'; + naam.textContent = label; + const val = document.createElement('span'); + val.className = 'val'; + val.textContent = fmtBase(bedrag || 0); + rij.append(naam, val); + return rij; + }; + const kop = document.createElement('span'); + kop.className = 'ring-tip-label'; + kop.textContent = r.label; + tipEl.replaceChildren(kop, + regel(t('wealth.valueEnd'), r.waarde), + regel(t('wealth.investedNet'), r.inleg), + regel(t('wealth.result'), r.resultaat)); + const box = chart.canvas.getBoundingClientRect(); + tipEl.style.left = `${Math.round(box.left + tooltip.caretX)}px`; + tipEl.style.top = `${Math.round(box.top + tooltip.caretY)}px`; + tipEl.classList.add('is-visible'); +} + +export async function tekenJaarGrafiek(canvas, rijen) { + const Chart = await loadCharting(); + const pal = readPalette(); + const resultaat = rijen.map(r => r.resultaat || 0); + const grafiek = new Chart(canvas, { + data: { + labels: rijen.map(r => r.label), + datasets: [ + { type: 'bar', label: t('wealth.investedNet'), data: rijen.map(r => r.inleg || 0), + backgroundColor: pal.accentFaint, order: 2, yAxisID: 'y' }, + { type: 'bar', label: t('wealth.result'), data: resultaat, + backgroundColor: resultaat.map(v => (v >= 0 ? pal.sigGreen : pal.sigRed)), order: 2, yAxisID: 'y' }, + { type: 'line', label: t('wealth.valueEnd'), data: rijen.map(r => r.waarde || 0), + borderColor: pal.accent, backgroundColor: pal.accent, borderWidth: 2, pointRadius: 3, + cubicInterpolationMode: 'monotone', order: 1, yAxisID: 'waarde' }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { mode: 'index', intersect: false }, + scales: { + x: { grid: { display: false }, ticks: { color: pal.textSec } }, + y: { ticks: { display: false }, grid: { color: pal.grid }, border: { display: false } }, + waarde: { position: 'right', display: false, beginAtZero: true }, + }, + plugins: { + legend: { display: true, position: 'bottom', labels: { color: pal.textSec, boxWidth: 10, boxHeight: 10 } }, + tooltip: { enabled: false, external: ctx => tip(ctx, rijen) }, + }, + }, + }); + canvas.addEventListener('mouseleave', verberg); + const destroy = grafiek.destroy.bind(grafiek); + grafiek.destroy = () => { verberg(); destroy(); }; + return grafiek; +} diff --git a/client/wealth/year-range.js b/client/wealth/year-range.js new file mode 100644 index 0000000..8641ac7 --- /dev/null +++ b/client/wealth/year-range.js @@ -0,0 +1,27 @@ +// Generated, do not edit. sha256:e2b7a86933c03dfaed3d23288997f18fd48f414cd445220f5c23fdf7ceb2cc5e +export const ALL_YEARS = 'alles'; + +export function choiceToText(k) { + if (k === ALL_YEARS) return ALL_YEARS; + return k.van === k.tot ? String(k.van) : `${k.van}-${k.tot}`; +} + +export function choiceFromText(tekst) { + if (tekst === ALL_YEARS) return ALL_YEARS; + const m = /^(\d{4})(?:-(\d{4}))?$/.exec(String(tekst ?? '')); + if (!m) { const nu = new Date().getFullYear(); return { van: nu, tot: nu }; } + const a = Number(m[1]); + const b = m[2] ? Number(m[2]) : a; + return { van: Math.min(a, b), tot: Math.max(a, b) }; +} + +export function withoutYear(k, jaar) { + if (k === ALL_YEARS || !Number.isFinite(jaar)) return k; + if (jaar >= k.tot) return ALL_YEARS; + return { van: jaar + 1, tot: k.tot }; +} + +export function yearsIn(k) { + if (k === ALL_YEARS) return []; + return Array.from({ length: k.tot - k.van + 1 }, (_, i) => k.van + i); +} diff --git a/client/widgets/clocks.js b/client/widgets/clocks.js new file mode 100644 index 0000000..455d423 --- /dev/null +++ b/client/widgets/clocks.js @@ -0,0 +1,287 @@ +// Generated, do not edit. sha256:f7ff570558c71c4401b25418a5f1ab453ce90d9a53873cee3fc08aace66e3174 +import { t, localeTag } from '../i18n/index.js'; +import { CLOCKS } from '../../shared/config.js'; +import { durationParts, exchangeStatus, localClock } from './exchange-times.js'; + +const WEEKDAY_NUM = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; + +const pad2 = n => String(n).padStart(2, '0'); +const hhmm = (h, m) => `${pad2(h)}:${pad2(m)}`; +const cityFromTz = tz => tz.split('/').pop().replace(/_/g, ' '); +const _dateFmt = new Map(); +function tzDate(tz, now) { + const key = `${localeTag()}|${tz}`; + let f = _dateFmt.get(key); + if (!f) { f = new Intl.DateTimeFormat(localeTag(), { timeZone: tz, weekday: 'short', day: 'numeric', month: 'short' }); _dateFmt.set(key, f); } + return f.format(now); +} + +function clockHint(isLocal, own, h, m) { + if (isLocal) return t('clocks.hoverLocal'); + let d = ((h * 60 + m) - (own.h * 60 + own.m)) % 1440; + if (d > 720) d -= 1440; else if (d <= -720) d += 1440; + if (d === 0) return t('clocks.hoverSame'); + const abs = Math.abs(d), uur = Math.floor(abs / 60), min = abs % 60; + const dur = []; + if (uur) dur.push(`${uur} uur`); + if (min) dur.push(`${min} ${min === 1 ? 'minuut' : 'minuten'}`); + return `${dur.join(' en ')} ${d < 0 ? 'achter' : 'voor'}`; +} + +const _offFmt = new Map(); +function tzOffsetMinutes(tz, now) { + let f = _offFmt.get(tz); + if (!f) { f = new Intl.DateTimeFormat('en-GB', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); _offFmt.set(tz, f); } + const p = {}; for (const part of f.formatToParts(now)) p[part.type] = part.value; + const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second); + return Math.round((asUTC - now.getTime()) / 60000); +} +const norm1440 = o => ((o % 1440) + 1440) % 1440; +const circDist = (a, b) => { const d = Math.abs(a - b); return Math.min(d, 1440 - d); }; + +function spreadCityKeys(now) { + const localOff = norm1440(tzOffsetMinutes(Intl.DateTimeFormat().resolvedOptions().timeZone, now)); + const chosen = [localOff]; + const cand = CLOCKS.CITIES.map(c => ({ key: c.key, off: norm1440(tzOffsetMinutes(c.tz, now)) })); + const picked = []; + for (let i = 0; i < 3 && cand.length; i++) { + let best = -1, bestMin = -1; + cand.forEach((c, idx) => { + let m = Infinity; + for (const o of chosen) m = Math.min(m, circDist(c.off, o)); + if (m > bestMin) { bestMin = m; best = idx; } + }); + if (best < 0) break; + chosen.push(cand[best].off); + picked.push(cand[best].key); + cand.splice(best, 1); + } + return picked; +} + +function clockKeys(instance) { + const p = instance && instance.params; + const c = p && p.cities; + return Array.isArray(c) ? c : ['local', ...spreadCityKeys(new Date())]; +} + +function faceSvg({ seconds = false, mini = false } = {}) { + let ticks = ''; + for (let i = 0; i < 12; i++) { + const major = i % 3 === 0; + if (mini && !major) continue; + const a = (i * 30) * Math.PI / 180; + const r1 = major ? 38 : 42.5; + const x1 = (50 + Math.sin(a) * r1).toFixed(2), y1 = (50 - Math.cos(a) * r1).toFixed(2); + const x2 = (50 + Math.sin(a) * 46).toFixed(2), y2 = (50 - Math.cos(a) * 46).toFixed(2); + ticks += ``; + } + const sec = seconds ? '' : ''; + return ``; +} + +function setHands(svg, h, m, s) { + if (!svg) return; + const hourA = ((h % 12) + m / 60) * 30; + const minA = (m + (s == null ? 0 : s) / 60) * 6; + const hour = svg.querySelector('.clockw-hour'); + const min = svg.querySelector('.clockw-min'); + if (hour) hour.setAttribute('transform', `rotate(${hourA.toFixed(2)} 50 50)`); + if (min) min.setAttribute('transform', `rotate(${minA.toFixed(2)} 50 50)`); + const sec = svg.querySelector('.clockw-sec'); + if (sec && s != null) sec.setAttribute('transform', `rotate(${(s * 6).toFixed(2)} 50 50)`); +} + +function tzParts(tz, now) { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: tz, hour12: false, weekday: 'short', hour: '2-digit', minute: '2-digit', second: '2-digit', + }).formatToParts(now); + const part = type => parts.find(p => p.type === type)?.value; + let h = Number(part('hour')); + if (h === 24) h = 0; + return { wd: WEEKDAY_NUM[part('weekday')], h, m: Number(part('minute')), s: Number(part('second')) }; +} + +function exchangeState(ex, now) { + const { h, m } = tzParts(ex.tz, now); + const st = exchangeStatus(ex, now); + const minutes = (a, b) => (a - b) / 60000; + return { + ex, hh: h, mm: m, isOpen: st.isOpen, status: st, + sinceOpen: st.isOpen ? minutes(now, st.openedAt) : null, + untilOpen: st.opensAt ? minutes(st.opensAt, now) : Infinity, + }; +} + +const DURATION_KEY = { min: 'clocks.durMin', hourMin: 'clocks.durHourMin', hours: 'clocks.durHours', days: 'clocks.durDays' }; +function durationText(ms) { + const p = durationParts(ms); + if (p.unit === 'days' && p.n === 1) return t('clocks.durDay'); + return t(DURATION_KEY[p.unit], { n: p.n, m: p.m }); +} + +function rowTexts(s, now) { + const st = s.status; + if (st.isOpen) { + return { sub: t('clocks.openUntil', { tijd: localClock(st.closesAt, now).time }), right: t('clocks.closesIn', { duur: durationText(st.closesAt - now) }) }; + } + if (!st.opensAt) return { sub: '', right: '' }; + const c = localClock(st.opensAt, now); + const sub = c.otherDay ? t('clocks.opensOn', { dag: c.weekday, tijd: c.time }) : t('clocks.opensAt', { tijd: c.time }); + return { sub, right: t('clocks.opensIn', { duur: durationText(st.opensAt - now) }) }; +} + +function orderStates(states) { + const open = states.filter(s => s.isOpen).sort((a, b) => a.sinceOpen - b.sinceOpen); + const shut = states.filter(s => !s.isOpen).sort((a, b) => a.untilOpen - b.untilOpen); + return [...open, ...shut]; +} + +function dedupeStates(states) { + const bySig = new Map(); + for (const s of states) { + const sig = `${s.ex.tz}|${s.ex.open}|${s.ex.close}|${s.ex.days.join('')}`; + const hit = bySig.get(sig); + if (hit) hit.names.push(s.ex.name); + else bySig.set(sig, { ...s, names: [s.ex.name] }); + } + return [...bySig.values()].map(s => ({ ...s, ex: { ...s.ex, name: s.names.join(' / ') } })); +} + +function worldEntries(instance) { + const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone; + const resolve = k => k === 'local' + ? { key: 'local', name: cityFromTz(localTz), tz: localTz, local: true } + : (CLOCKS.CITIES.find(c => c.key === k) ? { ...CLOCKS.CITIES.find(c => c.key === k), local: false } : null); + const entries = clockKeys(instance).map(resolve).filter(Boolean); + const local = entries.find(e => e.local); + const others = entries.filter(e => !e.local); + if (!local) return others; + const mid = Math.floor(others.length / 2); + return [...others.slice(0, mid), local, ...others.slice(mid)]; +} + +function worldClockMarkup(e) { + return `
    +
    ${faceSvg({ seconds: e.local })}
    +
    + ${e.name} + + +
    +
    `; +} + +function rowMarkup(s) { + return `
    + ${faceSvg({ mini: true })} + + ${s.ex.name} + + + +
    `; +} + +function applyRow(row, s, now) { + setHands(row.querySelector('.clockw-face'), s.hh, s.mm, null); + row.querySelector('.clockw-face').classList.toggle('is-open', s.isOpen); + row.classList.toggle('is-open', s.isOpen); + const { sub, right } = rowTexts(s, now); + row.querySelector('.clockw-ex-sub').textContent = sub; + row.querySelector('.clockw-open').textContent = right; +} + +function flashRow(listEl, key) { + const row = listEl.querySelector(`[data-ex="${key}"]`); + if (!row) return; + row.classList.remove('is-flash'); + void row.offsetWidth; + row.classList.add('is-flash'); + setTimeout(() => row.classList.remove('is-flash'), 1600); +} + +function showExchanges(instance) { + const p = instance && instance.params; + return !!(p && p.showExchanges === true); +} + +export function renderClocksWidget(host, instance) { + if (host._clockTimer) { clearInterval(host._clockTimer); host._clockTimer = null; } + + const entries = worldEntries(instance); + const showEx = showExchanges(instance); + host.innerHTML = `
    +
    ${t('dashboard.widget.clocks')}
    +
    ${entries.map(worldClockMarkup).join('')}
    + ${showEx ? `
    ${t('clocks.listHead')}
    ` : ''} +
    `; + + const listEl = host.querySelector('[data-clock-list]'); + const prevOpen = host._exOpen || {}; + let shownKeys = ''; + + const update = () => { + if (!host.isConnected) { clearInterval(host._clockTimer); host._clockTimer = null; return; } + const now = new Date(); + + const own = tzParts(Intl.DateTimeFormat().resolvedOptions().timeZone, now); + host.querySelectorAll('.clockw-clock').forEach(el => { + const { h, m, s } = tzParts(el.dataset.tz, now); + setHands(el.querySelector('.clockw-face'), h, m, el.dataset.local === '1' ? s : null); + const time = el.querySelector('.clockw-clock-time'); + if (time) time.textContent = hhmm(h, m); + const date = el.querySelector('.clockw-clock-date'); + if (date) date.textContent = tzDate(el.dataset.tz, now); + el.title = clockHint(el.dataset.local === '1', own, h, m); + }); + + if (!listEl) return; + const states = dedupeStates(CLOCKS.EXCHANGES.map(ex => exchangeState(ex, now))); + const ordered = orderStates(states).slice(0, CLOCKS.LIST_MAX); + const keys = ordered.map(s => s.ex.key).join(','); + if (keys !== shownKeys) { shownKeys = keys; listEl.innerHTML = ordered.map(rowMarkup).join(''); } + ordered.forEach(s => { const row = listEl.querySelector(`[data-ex="${s.ex.key}"]`); if (row) applyRow(row, s, now); }); + + states.forEach(s => { if (s.isOpen && prevOpen[s.ex.key] === false) flashRow(listEl, s.ex.key); }); + states.forEach(s => { prevOpen[s.ex.key] = s.isOpen; }); + host._exOpen = prevOpen; + }; + + update(); + host._clockTimer = setInterval(update, 1000); +} + +export function renderClocksSettings(host, instance, commit) { + const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone; + const selected = new Set(clockKeys(instance)); + const showEx = showExchanges(instance); + const entries = [{ key: 'local', name: `${t('clocks.localLabel')} (${cityFromTz(localTz)})` }, ...CLOCKS.CITIES]; + host.innerHTML = `
    + +
    +
    ${t('clocks.chooseClocks')}
    + ${entries.map(e => ``).join('')} +
    `; + host.querySelector('.clockw-settings').addEventListener('change', e => { + const exToggle = e.target.closest('input[data-ex-toggle]'); + if (exToggle) { + instance.params = { ...(instance.params || {}), showExchanges: exToggle.checked }; + commit({ resize: true }); + return; + } + const cb = e.target.closest('input[data-city]'); + if (!cb) return; + const set = new Set(clockKeys(instance)); + if (cb.checked) set.add(cb.dataset.city); else set.delete(cb.dataset.city); + instance.params = { ...(instance.params || {}), cities: [...set] }; + commit({ resize: true }); + }); +} diff --git a/client/widgets/exchange-times.js b/client/widgets/exchange-times.js new file mode 100644 index 0000000..81ba67e --- /dev/null +++ b/client/widgets/exchange-times.js @@ -0,0 +1,76 @@ +// Generated, do not edit. sha256:f7c492e9043516674843077cdedb9f9190b5176696652a8825a3e593c111d7ea +import { localeTag } from '../i18n/index.js'; + +const MIN = 60 * 1000; +const WEEKDAY_NUM = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; + +const _partsFmt = new Map(); +function wallParts(tz, instant) { + let f = _partsFmt.get(tz); + if (!f) { + f = new Intl.DateTimeFormat('en-US', { + timeZone: tz, hourCycle: 'h23', weekday: 'short', + year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', + }); + _partsFmt.set(tz, f); + } + const p = {}; + for (const part of f.formatToParts(instant)) p[part.type] = part.value; + return { y: +p.year, mo: +p.month, d: +p.day, wd: WEEKDAY_NUM[p.weekday], h: +p.hour % 24, mi: +p.minute }; +} + +export function wallToInstant(tz, y, mo, d, h, mi) { + const target = Date.UTC(y, mo - 1, d, h, mi); + let t = target; + for (let i = 0; i < 2; i++) { + const w = wallParts(tz, new Date(t)); + t += target - Date.UTC(w.y, w.mo - 1, w.d, w.h, w.mi); + } + return new Date(t); +} + +const hm = (s) => s.split(':').map(Number); + +export function exchangeStatus(ex, now = new Date()) { + const [oh, om] = hm(ex.open); + const [ch, cm] = hm(ex.close); + const today = wallParts(ex.tz, now); + for (let d = 0; d < 8; d++) { + const day = new Date(Date.UTC(today.y, today.mo - 1, today.d + d)); + if (!ex.days.includes(day.getUTCDay())) continue; + const y = day.getUTCFullYear(), mo = day.getUTCMonth() + 1, dd = day.getUTCDate(); + const opens = wallToInstant(ex.tz, y, mo, dd, oh, om); + const closes = wallToInstant(ex.tz, y, mo, dd, ch, cm); + if (now < opens) return { isOpen: false, openedAt: null, closesAt: null, opensAt: opens }; + if (now < closes) return { isOpen: true, openedAt: opens, closesAt: closes, opensAt: null }; + } + return { isOpen: false, openedAt: null, closesAt: null, opensAt: null }; +} + +export function durationParts(ms) { + const minutes = Math.max(1, Math.ceil(ms / MIN)); + if (minutes < 60) return { unit: 'min', n: minutes }; + if (minutes < 180) return { unit: 'hourMin', n: Math.floor(minutes / 60), m: minutes % 60 }; + if (minutes < 24 * 60) return { unit: 'hours', n: Math.round(minutes / 60) }; + return { unit: 'days', n: Math.round(minutes / (24 * 60)) }; +} + +const _clockFmt = new Map(); +export function localClock(instant, now = new Date(), tz = undefined) { + const tag = localeTag(); + const key = `${tag}|${tz || ''}`; + let f = _clockFmt.get(key); + if (!f) { + f = { + time: new Intl.DateTimeFormat(tag, { timeZone: tz, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }), + day: new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }), + weekday: new Intl.DateTimeFormat(tag, { timeZone: tz, weekday: 'short' }), + }; + _clockFmt.set(key, f); + } + return { + time: f.time.format(instant), + otherDay: f.day.format(instant) !== f.day.format(now), + weekday: f.weekday.format(instant), + }; +} diff --git a/client/widgets/metrics.js b/client/widgets/metrics.js new file mode 100644 index 0000000..cfc3cf6 --- /dev/null +++ b/client/widgets/metrics.js @@ -0,0 +1,53 @@ +// Generated, do not edit. sha256:1196ce67207fc0b54b2fc602d2f16cc37c4414638c2f9a09a7c16d83c58acd2f +import { apiMetricsGlobal } from '../data/api-metrics.js'; +import { getSettings } from '../data/preferences.js'; +import { t } from '../i18n/index.js'; +import { esc } from '../ui/escape.js'; +import { fmtInt } from '../ui/format.js'; +import { CONFIG, METRICS } from '../../shared/config.js'; + +function sinceBucket(minutes) { + return new Date(Date.now() - minutes * 60000).toISOString().slice(0, 16); +} + +function failState(calls, fail) { + if (!calls) return 'ok'; + const ratio = fail / calls; + if (ratio >= METRICS.FAIL_BAD) return 'bad'; + if (ratio >= METRICS.FAIL_WARN) return 'warn'; + return 'ok'; +} + +function tileHtml(label, value, state) { + return `
    + +
    ${value}
    ${label}
    +
    `; +} + +export async function renderMetricsWidget(host) { + try { + const settings = await getSettings(); + const schema = settings ? settings.version : null; + const dbState = schema === CONFIG.SCHEMA_VERSION ? 'ok' : 'warn'; + + const m = await apiMetricsGlobal(sinceBucket(METRICS.WINDOW_MINUTES)); + const errState = failState(m.calls, m.fail); + const errPct = m.calls ? Math.round((m.fail / m.calls) * 100) : 0; + + const tiles = [ + tileHtml(t('sysmetrics.db'), `v${schema != null ? schema : '?'}`, dbState), + tileHtml(t('sysmetrics.calls'), fmtInt(m.calls), m.calls ? 'ok' : 'warn'), + tileHtml(t('sysmetrics.errors'), `${errPct}%`, errState), + ]; + + host.innerHTML = `
    +
    ${t('dashboard.widget.metrics')}
    +
    ${tiles.join('')}
    +
    ${t('sysmetrics.window')} ${METRICS.WINDOW_MINUTES} min
    +
    `; + } catch (err) { + console.error('[metrics-widget]', err); + host.innerHTML = `
    ${t('dashboard.widget.metrics')}
    ⚠ ${esc(err.message)}
    `; + } +} diff --git a/client/widgets/momentum-dot.js b/client/widgets/momentum-dot.js new file mode 100644 index 0000000..976e1e2 --- /dev/null +++ b/client/widgets/momentum-dot.js @@ -0,0 +1,32 @@ +// Generated, do not edit. sha256:bee329db148fa1f318902e8add4230015eac6bdf80b4700aa7a993e8d1c5e7ee +import { momentumOf } from '../price/momentum.js'; +import { esc } from '../ui/escape.js'; +import { t } from '../i18n/index.js'; + +export function momentumOn(instance) { + const p = instance && instance.params; + return !(p && p.momentum === false); +} + +export function momentumDotHtml(assetId, on) { + if (!on || assetId == null) return ''; + const { up, down, tone } = momentumOf(assetId); + if (!tone) return ''; + const n = up + down; + const pct = Math.round(((tone === 'up' ? up : down) / n) * 100); + const hint = t(tone === 'up' ? 'dashboard.momentumUp' : 'dashboard.momentumDown', { pct, n }); + return ``; +} + +export function renderMomentumSetting(host, instance, commit) { + const row = document.createElement('div'); + row.className = 'dash-set-row'; + row.innerHTML = ``; + host.appendChild(row); + row.addEventListener('change', e => { + const box = e.target.closest('[data-momentum]'); + if (!box) return; + instance.params = { ...(instance.params || {}), momentum: box.checked }; + commit(); + }); +} diff --git a/client/widgets/registry.js b/client/widgets/registry.js new file mode 100644 index 0000000..f331dc2 --- /dev/null +++ b/client/widgets/registry.js @@ -0,0 +1,23 @@ +// Generated, do not edit. sha256:9a4b9605453795f4e63a44a3dbe52ca8e7e112a8edb0a2dcc85664207e4801cc +import { DASHBOARD } from '../../shared/config.js'; +import { createRegistry } from '../../shared/registry.js'; +import { locked } from '../license.js'; + +const widgets = createRegistry('widget', { + validate: (e) => { if (!DASHBOARD.WIDGETS[e.id]) throw new Error(`Unknown widget type '${e.id}'.`); }, +}); + +export function registerWidget(type, { render, settings = null, feature = null }) { + widgets.register({ id: type, render, settings, feature }); +} + +export function availableWidgets() { + const renderers = {}; + const settings = {}; + for (const w of widgets.list()) { + if (w.feature && locked(w.feature)) continue; + renderers[w.id] = w.render; + if (w.settings) settings[w.id] = w.settings; + } + return { renderers, settings }; +} diff --git a/client/widgets/return.js b/client/widgets/return.js new file mode 100644 index 0000000..bec9c59 --- /dev/null +++ b/client/widgets/return.js @@ -0,0 +1,114 @@ +// Generated, do not edit. sha256:ae0f941ec7a830f9f94ae85e37de1157b6e15f16cc2e9dc3f0c5c7243d3eaafd +import { listAssets } from '../data/assets.js'; +import { listAccounts } from '../data/accounts.js'; +import { getPref, setPref } from '../data/preferences.js'; +import { t } from '../i18n/index.js'; +import { esc } from '../ui/escape.js'; +import { fmtBaseSigned, fmtPct, pctTone, colouredSigns } from '../ui/format.js'; +import { assetLabel, assetLabelHtml, byKindAndLabel } from '../ui/asset-label.js'; +import { CHEVRON_RIGHT } from '../ui/icons.js'; +import { rangeLabel, rangeCycleButton, wireRangeCycle } from '../ui/range-cycle.js'; +import { isOwnWealth } from '../../shared/wealth-scope.js'; +import { ENUMS, TARGETS, targetHash } from '../../shared/config.js'; +import { dagreeksVanNu } from '../wealth/series.js'; +import { periodChoices } from '../wealth/return-view.js'; +import { lineForData, drawLine } from '../wealth/return-dialog.js'; + +const PREF_SCOPE = 'widget.return'; +const RANGES = Object.freeze({ day: 1, week: 7, month: 30, year: 365, all: 0 }); +const PERIOD_OF = Object.freeze({ day: 'd1', week: 'd7', month: 'd30', year: 'd365', all: 'start' }); +const DEFAULT_RANGE = 'month'; + +const dataOf = instance => (typeof instance?.params?.data === 'string' ? instance.params.data : 'total'); + +function titleHtml(data, assets) { + const base = esc(t('dashboard.widget.return')); + if (data.startsWith('asset:')) { + const a = assets.find(x => x.id === Number(data.slice(6))); + return a ? `${base}: ${assetLabelHtml(a)}` : base; + } + if (data.startsWith('kind:')) return `${base}: ${esc(t('enums.asset_kind.' + data.slice(5)))}`; + return base; +} + +export async function renderReturnWidget(host, instance) { + const data = dataOf(instance); + const key = instance?.instId || 'return'; + const [accounts, assets, stored] = await Promise.all([ + listAccounts(), listAssets(), getPref(PREF_SCOPE, key, DEFAULT_RANGE).catch(() => DEFAULT_RANGE), + ]); + let range = RANGES[stored] != null ? stored : DEFAULT_RANGE; + host.innerHTML = `
    +
    +
    ${titleHtml(data, assets)}
    + ${CHEVRON_RIGHT} +
    +
    …
    +
    +
    +
    ${rangeCycleButton(range)}
    +
    +
    +
    `; + const amountEl = host.querySelector('[data-return-amount]'); + const pctEl = host.querySelector('[data-return-pct]'); + let chart = null; + let gen = 0; + + try { + const excludedAccounts = new Set(accounts.filter(ac => !isOwnWealth(ac)).map(ac => ac.id)); + const reeks = await dagreeksVanNu({ excludedAccounts }); + if (!host.isConnected) return; + const held = assets.filter(a => (reeks.perAsset?.get(a.id) || []).some(v => v > 0)); + const periods = periodChoices(reeks.dagen?.length || 0, null); + + const draw = async () => { + const g = ++gen; + const btn = host.querySelector('.range-cycle-btn'); + if (btn) btn.textContent = rangeLabel(range); + const venster = periods.find(p => p.key === PERIOD_OF[range])?.venster || null; + const line = lineForData({ reeks, assets: held, data, venster }); + amountEl.innerHTML = line.amount == null ? '-' + : `${esc(fmtBaseSigned(line.amount, 'both'))}`; + pctEl.innerHTML = line.pct == null ? '' : `${esc(fmtPct(line.pct, { fine: range === 'day', signs: colouredSigns() }))}`; + chart?.destroy(); + chart = null; + let canvas = host.querySelector('[data-return-chart]'); + const plot = host.querySelector('.return-plot'); + if (line.points.length < 2) { plot.innerHTML = `
    ${esc(t('wealth.noChart'))}
    `; return; } + if (!canvas) { plot.innerHTML = ''; canvas = plot.firstElementChild; } + const c = await drawLine(canvas, reeks, line.points).catch(() => null); + if (g !== gen || !host.isConnected) { c?.destroy(); return; } + chart = c; + }; + await draw(); + wireRangeCycle(host.querySelector('[data-return-range]'), () => range, next => { + range = next; + setPref(PREF_SCOPE, key, range).catch(() => { }); + draw(); + }, RANGES); + } catch (err) { + console.error('[return-widget]', err); + amountEl.textContent = '⚠ ' + err.message; + } +} + +export async function renderReturnSettings(host, instance, commit) { + const assets = (await listAssets()).sort(byKindAndLabel); + const current = dataOf(instance); + const opt = (value, label) => ``; + const kinds = ENUMS.ASSET_KIND.filter(k => assets.some(a => a.kind === k)); + const row = document.createElement('div'); + row.className = 'dash-set-row'; + row.innerHTML = `${esc(t('wealth.returnData'))} + `; + host.appendChild(row); + row.querySelector('[data-return-setting]').addEventListener('change', e => { + instance.params = { ...(instance.params || {}), data: e.target.value }; + commit(); + }); +} diff --git a/client/widgets/second-currency.js b/client/widgets/second-currency.js new file mode 100644 index 0000000..0522452 --- /dev/null +++ b/client/widgets/second-currency.js @@ -0,0 +1,43 @@ +// Generated, do not edit. sha256:9910b171f8780710e55bbf6e2270b3758af0e766b12a3590b21420b1bc45cfe8 +import { listAssets } from '../data/assets.js'; +import { baseCurrency } from '../app-settings.js'; +import { currentRate } from '../price/providers.js'; +import { fmtMoney } from '../ui/format.js'; +import { esc } from '../ui/escape.js'; +import { t } from '../i18n/index.js'; + +export function secondCurrencyOf(instance, fallback = null) { + const p = instance && instance.params; + const v = p && Object.prototype.hasOwnProperty.call(p, 'second') ? p.second : fallback; + return typeof v === 'string' && v !== '' && v !== baseCurrency() ? v : null; +} + +export async function secondCurrencyChoices() { + const base = baseCurrency(); + const fiat = (await listAssets()).filter(a => a.kind === 'fiat' && a.symbol !== base); + const rates = await Promise.all(fiat.map(a => currentRate(a.symbol).catch(() => null))); + return fiat.filter((_, i) => rates[i] != null).map(a => a.symbol).sort(); +} + +export async function secondAmount(baseAmount, code, fmtOpts = {}) { + if (!code || baseAmount == null || !Number.isFinite(baseAmount)) return ''; + const rate = await currentRate(code).catch(() => null); + return rate ? fmtMoney(baseAmount / rate, code, fmtOpts) : ''; +} + +export async function renderSecondCurrencySetting(host, instance, commit, fallback = null) { + const current = secondCurrencyOf(instance, fallback); + const choices = await secondCurrencyChoices(); + if (current && !choices.includes(current)) choices.unshift(current); + const opties = [``] + .concat(choices.map(c => ``)).join(''); + const block = document.createElement('label'); + block.className = 'dash-set-row'; + block.innerHTML = `${esc(t('dashboard.secondCurrency'))} + `; + host.appendChild(block); + block.querySelector('select').addEventListener('change', e => { + instance.params = { ...(instance.params || {}), second: e.target.value || null }; + commit(); + }); +} diff --git a/client/widgets/spot.js b/client/widgets/spot.js new file mode 100644 index 0000000..7156c67 --- /dev/null +++ b/client/widgets/spot.js @@ -0,0 +1,172 @@ +// Generated, do not edit. sha256:a0b282c1ed4d4e3e20c57f97c21ad932b410360a0adabc7ef61e694ce97a0ed9 +import { listAssets } from '../data/assets.js'; +import { getPref, setPref } from '../data/preferences.js'; +import { baseCurrency } from '../app-settings.js'; +import { listPriceHistory } from '../data/price-history.js'; +import { t } from '../i18n/index.js'; +import { toDailyGrid, dailyGridSources } from '../signals/data.js'; +import { liveSpotFor, basePriceOf, lastSpotSource } from '../price/providers.js'; +import { listProviders } from '../data/providers.js'; +import { fmtMoney, colouredSigns } from '../ui/format.js'; +import { assetLabelHtml } from '../ui/asset-label.js'; +import { priceFromBase } from '../../shared/units.js'; +import { secondCurrencyOf, secondAmount, renderSecondCurrencySetting } from './second-currency.js'; +import { mountSparkline, changePctHtml, sliceTone } from '../ui/sparkline.js'; +import { RANGE_DAYS, SPOT_RANGE_DAYS, rangeSeries, intradayStart, rangeLabel, rangeCycleButton, wireRangeCycle, rangePointLabel, rangePointSources, namedDaySources } from '../ui/range-cycle.js'; +import { loadIntraday } from '../price/intraday.js'; +import { momentumOn, momentumDotHtml, renderMomentumSetting } from './momentum-dot.js'; +import { loadMomentum } from '../price/momentum.js'; + +const MS_PER_DAY = 86400000; + +function fromDateStr(days) { + return new Date(Date.now() - (days + 5) * MS_PER_DAY).toISOString().slice(0, 10); +} + +const liveHosts = new Set(); +let spotBound = false; + +async function refreshOneSpot(entry) { + try { + const spot = await liveSpotFor(entry.asset); + if (!entry.host.isConnected) { liveHosts.delete(entry); return; } + const live = basePriceOf(spot); + if (live == null) return; + entry.setLive(live); + } catch { } +} + +async function refreshAllSpots() { + for (const entry of liveHosts) { + if (!entry.host.isConnected) { liveHosts.delete(entry); continue; } + refreshOneSpot(entry); + } +} + +export async function renderSpotSettings(host, instance, commit) { + await renderSecondCurrencySetting(host, instance, commit); + renderMomentumSetting(host, instance, commit); +} + +export async function renderSpotWidget(host, instance) { + const assetId = instance && instance.params ? instance.params.assetId : null; + const asset = (await listAssets()).find(a => a.id === assetId); + if (!asset) { + host.innerHTML = `
    \u26A0 ${t('spot.unknownAsset')}
    `; + return; + } + const cur = baseCurrency(); + const headHtml = assetLabelHtml(asset); + const inUnit = p => (p == null ? null : priceFromBase(p, asset)); + const second = secondCurrencyOf(instance); + const fmtOpts = p => ({ decimals: p >= 1000 ? 0 : 2 }); + + let range = await getPref('widget.spot', String(assetId), 'week'); + if (!SPOT_RANGE_DAYS[range]) range = 'week'; + + host.innerHTML = `
    +
    +
    ${headHtml}
    +
    -
    +
    +
    +
    +
    ${rangeCycleButton(range)}
    +
    +
    + +
    `; + + let grid = null; + let live = null; + let intraday = null; + let intradaySource = null; + let intradaySources = null; + let daySources = new Map(); + const priceEl = host.querySelector('#spot-price'); + const changeEl = host.querySelector('#spot-change'); + const sparkEl = host.querySelector('#spot-spark'); + const secondEl = host.querySelector('#spot-second'); + + async function showSecond(price) { + if (!second) return; + const tekst = await secondAmount(price, second, fmtOpts(price)); + secondEl.textContent = tekst || ''; + secondEl.classList.toggle('hidden', !tekst); + } + + function setLive(basePrice) { + const price = inUnit(basePrice); + if (!Number.isFinite(price)) return; + live = price; + priceEl.textContent = fmtMoney(price, cur, fmtOpts(price)); + showSecond(price); + draw(); + refreshIntraday(); + } + + async function refreshIntraday() { + if (range !== 'day' || !grid) return; + const r = await loadIntraday(asset.id, intradayStart(grid)); + if (!host.isConnected) return; + intraday = r.points ? r.points.map(([ms, p]) => [ms, inUnit(p)]) : null; + intradaySource = r.source || null; + intradaySources = r.sources || null; + draw(); + } + + const entry = { host, asset, setLive }; + liveHosts.add(entry); + if (!spotBound) { document.addEventListener('tick:spot', refreshAllSpots); spotBound = true; } + + function draw() { + const rb = host.querySelector('.range-cycle-btn'); + if (rb) rb.textContent = rangeLabel(range); + if (!grid) return; + const nowMs = Date.now(); + const use = rangeSeries(grid, range, live, nowMs, range === 'day' ? intraday : null); + const sources = rangePointSources(use, { range, spot: live, nowMs, live: lastSpotSource(asset), + intraday: intradaySource, intradayAt: intradaySources, days: daySources }); + mountSparkline(sparkEl, use, { + tone: sliceTone(use), + formatValue: (p, ms) => rangePointLabel(range, fmtMoney(p, cur, { decimals: p >= 1000 ? 0 : 2 }), ms, sources.get(ms)), + }); + changeEl.innerHTML = changePctHtml(use, momentumDotHtml(live != null ? asset.id : null, momentumOn(instance)), { fine: range === 'day', signs: colouredSigns() }); + } + + draw(); + + try { + const [spot, rows, , providers] = await Promise.all([ + liveSpotFor(asset).catch(() => null), + Promise.resolve(await listPriceHistory(assetId, fromDateStr(RANGE_DAYS.year))), + loadMomentum(asset.id), + listProviders().catch(() => []), + ]); + + try { grid = toDailyGrid(rows).map(([d, p]) => [d, inUnit(p)]); } + catch { grid = null; } + daySources = namedDaySources(dailyGridSources(rows), providers); + + const spotNu = inUnit(basePriceOf(spot)); + const lastClose = grid && grid.length ? grid[grid.length - 1][1] : null; + if (spotNu != null) live = spotNu; + const price = spotNu != null ? spotNu : lastClose; + priceEl.textContent = price != null ? fmtMoney(price, cur, fmtOpts(price)) : '-'; + if (price != null) showSecond(price); + + if (!grid) sparkEl.innerHTML = `
    ${t('spot.noHistory')}
    `; + else { draw(); refreshIntraday(); } + } catch (err) { + console.error('[spot-widget]', err); + const e = host.querySelector('#spot-err'); + if (e) { e.classList.remove('hidden'); e.textContent = '\u26A0 ' + err.message; } + } + + wireRangeCycle(host.querySelector('#spot-range'), () => range, next => { + range = next; + setPref('widget.spot', String(assetId), range).catch(() => { }); + draw(); + refreshIntraday(); + }, SPOT_RANGE_DAYS); +} diff --git a/client/widgets/standing-groups.js b/client/widgets/standing-groups.js new file mode 100644 index 0000000..8238f95 --- /dev/null +++ b/client/widgets/standing-groups.js @@ -0,0 +1,42 @@ +// Generated, do not edit. sha256:e8b81d0ee6b08d9e5a1155c5a9b2d57a27a3e3e4cf29c9e9449b7eaea522f722 +export const STANDING_GROUPINGS = Object.freeze(['account', 'accountKind', 'asset', 'assetKind']); + +export const standingShowsToday = params => !(params && params.showToday === false); + +export const standingGroupingOf = v => (STANDING_GROUPINGS.includes(v) ? v : 'account'); + +const KEYS = { + account: r => (r.account && r.account.id != null ? `a${r.account.id}` : 'none'), + accountKind: r => (r.account && r.account.kind ? r.account.kind : 'none'), + asset: r => `s${r.asset.id}`, + assetKind: r => r.asset.kind, +}; + +export function groupStanding(rows, by) { + const keyOf = KEYS[standingGroupingOf(by)]; + const groups = new Map(); + for (const r of rows) { + const key = keyOf(r); + let g = groups.get(key); + if (!g) { g = { key, sample: r, value: 0, origins: [] }; groups.set(key, g); } + g.value += r.value || 0; + g.origins.push({ origin: r.origin, asset: r.asset }); + } + return [...groups.values()].sort((a, b) => b.value - a.value); +} + +export function standingToday(rows, by, today) { + const out = new Map(); + const grouping = standingGroupingOf(by); + if (!today?.perAsset || (grouping !== 'asset' && grouping !== 'assetKind')) return out; + const seen = new Set(); + for (const r of rows) { + if (seen.has(r.asset.id)) continue; + seen.add(r.asset.id); + const amount = today.perAsset.get(r.asset.id); + if (amount == null) continue; + const key = KEYS[grouping](r); + out.set(key, (out.get(key) || 0) + amount); + } + return out; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d89e4e1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,647 @@ +{ + "name": "assetapp", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "assetapp", + "version": "0.2.0", + "dependencies": { + "@noble/curves": "2.4.0" + }, + "devDependencies": { + "@fontsource-variable/inter": "5.3.0", + "@noble/ciphers": "2.4.0", + "@noble/hashes": "2.4.0", + "@scure/base": "2.4.0", + "@scure/bip32": "2.4.0", + "chart.js": "4.5.1", + "chartjs-plugin-zoom": "2.2.0", + "esbuild": "0.28.2", + "hammerjs": "2.0.8", + "qrcode-generator": "2.0.4" + }, + "engines": { + "node": ">=22.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz", + "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.4.0.tgz", + "integrity": "sha512-i3DS0CptAocyvqE4n3SUkpzeQK4vJMFwWLofTwRiiKo2aWojBOfyMCgfKw9HVpO6fSY5AK86sHS/Uzn8kK9Few==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/curves": "2.4.0", + "@noble/hashes": "2.4.0", + "@scure/base": "2.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-plugin-zoom": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", + "integrity": "sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.45", + "hammerjs": "^2.0.8" + }, + "peerDependencies": { + "chart.js": ">=3.2.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..338db86 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "assetapp", + "version": "0.2.0", + "private": true, + "type": "module", + "scripts": { + "vendor": "node scripts/vendor.mjs", + "prestart": "node scripts/vendor.mjs", + "start": "node server/supervisor.mjs", + "start:server": "node server/index.mjs", + "mcp": "node server/mcp/stdio.mjs", + "pretest": "node scripts/vendor.mjs", + "test": "node --disable-warning=ExperimentalWarning --test --experimental-test-module-mocks \"tests/**/*.test.mjs\"", + "test:watch": "node --disable-warning=ExperimentalWarning --test --watch --experimental-test-module-mocks \"tests/**/*.test.mjs\"", + "test:repo": "node --disable-warning=ExperimentalWarning --test --experimental-test-module-mocks \"tests/repo/**/*.test.mjs\"" + }, + "engines": { + "node": ">=22.5" + }, + "dependencies": { + "@noble/curves": "2.4.0" + }, + "devDependencies": { + "@fontsource-variable/inter": "5.3.0", + "@noble/ciphers": "2.4.0", + "@noble/hashes": "2.4.0", + "@scure/base": "2.4.0", + "@scure/bip32": "2.4.0", + "chart.js": "4.5.1", + "chartjs-plugin-zoom": "2.2.0", + "esbuild": "0.28.2", + "hammerjs": "2.0.8", + "qrcode-generator": "2.0.4" + } +} diff --git a/scripts/vendor-entries/crypto.js b/scripts/vendor-entries/crypto.js new file mode 100644 index 0000000..262f992 --- /dev/null +++ b/scripts/vendor-entries/crypto.js @@ -0,0 +1,13 @@ +// Generated, do not edit. sha256:66149bd143afa432d9d09519581acf122a3b5d0a21975829b89df297fac236de +export { secp256k1, schnorr } from '@noble/curves/secp256k1.js'; +export { sha256 } from '@noble/hashes/sha2.js'; +export { bytesToHex, hexToBytes, utf8ToBytes, concatBytes } from '@noble/hashes/utils.js'; + +export { chacha20 } from '@noble/ciphers/chacha.js'; +export { extract as hkdfExtract, expand as hkdfExpand } from '@noble/hashes/hkdf.js'; +export { hmac } from '@noble/hashes/hmac.js'; +export { base64 } from '@scure/base'; + +export { ripemd160 } from '@noble/hashes/legacy.js'; +export { HDKey } from '@scure/bip32'; +export { base58check, bech32, bech32m } from '@scure/base'; diff --git a/scripts/vendor.mjs b/scripts/vendor.mjs new file mode 100644 index 0000000..ada1153 --- /dev/null +++ b/scripts/vendor.mjs @@ -0,0 +1,189 @@ +// Generated, do not edit. sha256:bd50331802703178a5561c2051cfa66fb4fdc608745b61d6cac1576e04cefdc9 +import { readFile, writeFile, mkdir, copyFile, rm, readdir } from 'node:fs/promises'; +import { join, resolve, dirname, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const MODULES = join(ROOT, 'node_modules'); +const VENDOR = join(ROOT, 'client', 'vendor'); + +function fout(bericht) { + console.error(`\n[vendor] ${bericht}\n`); + process.exit(1); +} + +export function pinnedVersion(naam, pkgJson) { + const alles = { ...((pkgJson && pkgJson.dependencies) || {}), ...((pkgJson && pkgJson.devDependencies) || {}) }; + const pin = alles[naam]; + return /^\d+\.\d+\.\d+$/.test(String(pin ?? '')) ? pin : null; +} + +export function pinMismatch(naam, installed, pkgJson) { + const pin = pinnedVersion(naam, pkgJson); + if (!pin || pin === installed) return null; + return `${naam} staat op ${installed} in node_modules, maar package.json pint ${pin}.\n` + + ' Dat gebeurt na een `git pull` die package.json wijzigde: er is nog niets geinstalleerd.\n' + + ' Draai eerst: npm install'; +} + +let projectPkg = null; +async function pakketVersie(naam) { + let versie; + try { + versie = JSON.parse(await readFile(join(MODULES, naam, 'package.json'), 'utf8')).version; + } catch { + fout(`${naam} niet gevonden in node_modules. Draai eerst: npm install`); + } + projectPkg ??= JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf8')); + const mis = pinMismatch(naam, versie, projectPkg); + if (mis) fout(mis); + return versie; +} + +async function vendorInter() { + const naam = '@fontsource-variable/inter'; + const versie = await pakketVersie(naam); + const bron = join(MODULES, naam); + const doel = join(VENDOR, 'inter'); + await mkdir(join(doel, 'files'), { recursive: true }); + + let bestanden; + try { + bestanden = (await readdir(join(bron, 'files'))).filter(f => f.endsWith('-opsz-normal.woff2')); + } catch { + fout(`${naam}/files/ ontbreekt: pakket onvolledig of van vorm veranderd.`); + } + if (bestanden.length === 0) fout(`${naam}: geen *-opsz-normal.woff2 gevonden (andere pakketvorm?).`); + for (const f of bestanden) await copyFile(join(bron, 'files', f), join(doel, 'files', f)); + + let css; + try { + css = await readFile(join(bron, 'opsz.css'), 'utf8'); + } catch { + fout(`${naam}/opsz.css ontbreekt: pakket van vorm veranderd; CSS niet af te leiden.`); + } + const gekopieerd = new Set(bestanden); + const blokken = css.split('@font-face').slice(1) + .map(b => `@font-face${b.slice(0, b.indexOf('}') + 1)}`) + .filter(b => { + const m = b.match(/url\(\.\/files\/([^)]+)\)/); + return m && gekopieerd.has(basename(m[1])); + }) + .map(b => b.replace(/'Inter Variable'/g, `'Inter'`)); + + if (blokken.length !== bestanden.length) { + fout(`Inter: ${bestanden.length} woff2 gekopieerd maar ${blokken.length} @font-face-blokken ` + + `gevonden. Die twee horen gelijk te zijn; controleer de vorm van opsz.css.`); + } + + const kop = [ + '/* GEGENEREERD door scripts/vendor.mjs: niet met de hand bewerken.', + ` * Bron: ${naam} ${versie}, opsz.css (upright subsets).`, + " * Aangepast: font-family 'Inter Variable' -> 'Inter'. Verder ongewijzigd.", + ' */', + ].join('\n'); + await writeFile(join(doel, 'inter.css'), `${kop}\n\n${blokken.join('\n\n')}\n`, 'utf8'); + + return `${naam} ${versie} → client/vendor/inter/ (${bestanden.length} subsets + inter.css)`; +} + +const CHART_LIBS = [ + { pakket: 'chart.js', bestand: 'dist/chart.umd.js', doelmap: 'chartjs' }, + { pakket: 'hammerjs', bestand: 'hammer.min.js', doelmap: 'hammerjs' }, + { pakket: 'chartjs-plugin-zoom', bestand: 'dist/chartjs-plugin-zoom.min.js', doelmap: 'chartjs-plugin-zoom' }, +]; + +async function vendorChartLibs() { + const regels = []; + for (const { pakket, bestand, doelmap } of CHART_LIBS) { + const versie = await pakketVersie(pakket); + const naam = basename(bestand); + const doel = join(VENDOR, doelmap); + await mkdir(doel, { recursive: true }); + + let code; + try { + code = await readFile(join(MODULES, pakket, bestand), 'utf8'); + } catch { + fout(`${pakket}/${bestand} ontbreekt: pakket onvolledig of van vorm veranderd.`); + } + const zonderMap = code.replace(/\r?\n\/\/# sourceMappingURL=[^\r\n]*\s*$/, '\n'); + if (/sourceMappingURL/.test(zonderMap)) { + fout(`${pakket}: sourceMappingURL-verwijzing niet verwijderd: controleer de vorm van ${naam}.`); + } + await writeFile(join(doel, naam), zonderMap, 'utf8'); + regels.push(`${pakket} ${versie} → client/vendor/${doelmap}/${naam}`); + } + return regels; +} + +async function vendorCrypto() { + const { build } = await import('esbuild'); + const versies = []; + for (const p of ['@noble/curves', '@noble/hashes', '@noble/ciphers', '@scure/bip32', '@scure/base']) { + versies.push(`${p} ${await pakketVersie(p)}`); + } + const esbuildVersie = await pakketVersie('esbuild'); + + const doel = join(VENDOR, 'crypto'); + await mkdir(doel, { recursive: true }); + const uit = join(doel, 'noble-scure.js'); + + try { + await build({ + entryPoints: [join(ROOT, 'scripts', 'vendor-entries', 'crypto.js')], + outfile: uit, + bundle: true, + format: 'esm', + target: ['es2022'], + minify: true, + sourcemap: false, + legalComments: 'none', + logLevel: 'warning', + }); + } catch (e) { + fout(`esbuild kon de crypto-bundel niet bouwen: ${e.message}`); + } + + const code = await readFile(uit, 'utf8'); + const verwacht = ['secp256k1', 'schnorr', 'sha256', 'ripemd160', 'HDKey', 'base58check', 'bech32', 'bech32m', + 'bytesToHex', 'hexToBytes', 'utf8ToBytes', 'concatBytes', + 'chacha20', 'hkdfExtract', 'hkdfExpand', 'hmac', 'base64']; + const exportRegel = code.match(/export\s*\{[^}]*\}/g)?.join(' ') ?? ''; + const missend = verwacht.filter(n => !new RegExp(`\\b${n}\\b`).test(exportRegel)); + if (missend.length) { + fout(`crypto-bundel mist exports: ${missend.join(', ')}. Controleer scripts/vendor-entries/crypto.js.`); + } + + const kb = Math.round(code.length / 1024); + return `${versies.join(' + ')} → client/vendor/crypto/noble-scure.js (${kb} KB, gebundeld met esbuild ${esbuildVersie})`; +} + +async function vendorQr() { + const pakket = 'qrcode-generator'; + const versie = await pakketVersie(pakket); + const doel = join(VENDOR, 'qrcode'); + await mkdir(doel, { recursive: true }); + let code; + try { + code = await readFile(join(MODULES, pakket, 'dist', 'qrcode.mjs'), 'utf8'); + } catch { + fout(`${pakket}/dist/qrcode.mjs ontbreekt: pakket onvolledig of van vorm veranderd.`); + } + if (!/export default qrcode;/.test(code) || /^\s*import\s/m.test(code) || /sourceMappingURL/.test(code)) { + fout(`${pakket}: dist/qrcode.mjs heeft niet de verwachte vorm (één default export, geen imports, geen sourcemap).`); + } + await writeFile(join(doel, 'qrcode.mjs'), code, 'utf8'); + return `${pakket} ${versie} → client/vendor/qrcode/qrcode.mjs`; +} + +async function main() { + await rm(VENDOR, { recursive: true, force: true }); + await mkdir(VENDOR, { recursive: true }); + + const regels = [await vendorInter(), ...(await vendorChartLibs()), await vendorCrypto(), await vendorQr()]; + console.log('[vendor] klaar:'); + for (const r of regels) console.log(` - ${r}`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/server/ai/aliases.js b/server/ai/aliases.js new file mode 100644 index 0000000..294e221 --- /dev/null +++ b/server/ai/aliases.js @@ -0,0 +1,88 @@ +// Generated, do not edit. sha256:09721f6e6374d0297156a1b8af0d4578d0bbb9c98d4ea7c96458dafbb2d56dcc +import { createHmac } from 'node:crypto'; + +const HANDLE_TEKENS = 6; + +const BECH32 = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; +const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; +const HEX = '0123456789abcdef'; + +const SOORTEN = Object.freeze({ + address: Object.freeze({ prefix: 'bc1q', lengte: 42, alfabet: BECH32, onmogelijk: 'o' }), + xpub: Object.freeze({ prefix: 'xpub', lengte: 111, alfabet: BASE58, onmogelijk: '0' }), + txid: Object.freeze({ prefix: '', lengte: 64, alfabet: HEX, onmogelijk: 'z' }), +}); + +export const ALIAS_SOORTEN = Object.freeze(Object.keys(SOORTEN)); + +function vul(alfabet, lengte, zaad) { + let s = ''; + for (let i = 0; i < lengte; i++) { + const paar = parseInt(zaad.slice((i * 2) % zaad.length, (i * 2) % zaad.length + 2) || '0', 16); + s += alfabet[(paar + i) % alfabet.length]; + } + return s; +} + +export function schijnwaarde(soort, zaad) { + const s = SOORTEN[soort]; + if (!s) throw new Error(`unknown alias kind: ${soort}`); + const romp = vul(s.alfabet, s.lengte - s.prefix.length, String(zaad)); + const met = `${romp.slice(0, 2)}${s.onmogelijk}${romp.slice(3)}`; + return `${s.prefix}${met}`; +} + +export function isSchijnwaarde(tekst) { + if (typeof tekst !== 'string') return false; + for (const [, s] of Object.entries(SOORTEN)) { + if (s.prefix && !tekst.startsWith(s.prefix)) continue; + if (tekst.length !== s.lengte) continue; + const data = tekst.slice(s.prefix.length); + if (data.includes(s.onmogelijk)) return true; + } + return false; +} + +export function createAliasRegistry({ secret } = {}) { + if (typeof secret !== 'string' || secret.length < 32) { + throw new Error('An alias registry without a secret is not allowed.'); + } + const heen = new Map(); + const terug = new Map(); + + const afgeleid = (soort, waarde) => + createHmac('sha256', secret).update(`${soort}\0${waarde}`).digest('hex'); + + function onthoud(sleutel, uit, soort, waarde) { + const bestaand = terug.get(uit); + if (bestaand && bestaand.waarde !== waarde) { + throw new Error(`Handle collision on '${uit}': two different values yield the same handle.`); + } + heen.set(sleutel, uit); + terug.set(uit, { soort, waarde }); + return uit; + } + + return { + alias(soort, waarde) { + const sleutel = `${soort} ${waarde}`; + if (heen.has(sleutel)) return heen.get(sleutel); + return onthoud(sleutel, schijnwaarde(soort, afgeleid(soort, waarde)), soort, waarde); + }, + + handle(soort, waarde) { + const sleutel = `${soort} ${waarde}`; + if (heen.has(sleutel)) return heen.get(sleutel); + return onthoud(sleutel, `${soort}:${afgeleid(soort, waarde).slice(0, HANDLE_TEKENS)}`, soort, waarde); + }, + + resolve(uit) { + const r = terug.get(uit); + return r ? r.waarde : null; + }, + + uitgegeven() { + return [...terug.entries()].map(([uit, r]) => ({ uit, soort: r.soort })); + }, + }; +} diff --git a/server/ai/buckets.js b/server/ai/buckets.js new file mode 100644 index 0000000..309e0ca --- /dev/null +++ b/server/ai/buckets.js @@ -0,0 +1,90 @@ +// Generated, do not edit. sha256:74e4ed13a040cfe4e2dc4c23217c88607a06c14c8cc97aceaf7026b8d073ba91 +import { CONFIG } from '../../shared/config.js'; + +const LADDERS = CONFIG.AI.BUCKET_LADDERS; + +function bodemVan(ladderNaam) { + const min = CONFIG.AI.BUCKET_MIN_VALUES[ladderNaam] || 0; + if (!min) return 0; + const ladder = LADDERS[ladderNaam]; + let smalst = Infinity; + for (let i = 0; i < ladder.length; i++) { + const onder = ladder[i]; + const boven = i + 1 < ladder.length ? ladder[i + 1] : 10; + smalst = Math.min(smalst, (boven - onder) / onder); + } + return min / smalst; +} + +export function bucket(waarde, ladderNaam = 'COARSE') { + const ladder = LADDERS[ladderNaam]; + if (!ladder) throw new Error(`unknown ladder: ${ladderNaam}`); + if (typeof waarde !== 'number' || !Number.isFinite(waarde)) return null; + + if (waarde === 0) return { from: 0, to: 0, exact: true }; + + const negatief = waarde < 0; + const abs = Math.abs(waarde); + + const bodem = Number.isInteger(waarde) ? bodemVan(ladderNaam) : 0; + if (bodem && abs < bodem) { + return negatief + ? { from: -bodem, to: 0, exact: false, floor: true } + : { from: 0, to: bodem, exact: false, floor: true }; + } + + const orde = Math.floor(Math.log10(abs)); + const schaal = 10 ** orde; + const genormaliseerd = Math.min(Math.max(abs / schaal, 1), 9.999999999); + + let onder = ladder[0]; + let boven = 10; + for (let i = 0; i < ladder.length; i++) { + if (genormaliseerd >= ladder[i]) { + onder = ladder[i]; + boven = i + 1 < ladder.length ? ladder[i + 1] : 10; + } + } + + const van = onder * schaal; + const tot = boven * schaal; + return negatief ? { from: -tot, to: -van, exact: false } : { from: van, to: tot, exact: false }; +} + +export function bucketLabel(waarde, ladderNaam = 'COARSE') { + const b = bucket(waarde, ladderNaam); + if (!b) return null; + if (b.exact) return String(b.from); + if (b.floor) return b.to === 0 ? `meer dan ${getal(b.from)}` : `minder dan ${getal(b.to)}`; + return `${getal(b.from)} tot ${getal(b.to)}`; +} + +function getal(n) { + return Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100); +} + +export function shareBucket(pct) { + const grenzen = CONFIG.AI.SHARE_BUCKETS; + if (typeof pct !== 'number' || !Number.isFinite(pct) || pct < 0) return null; + if (pct === 0) return { from: 0, to: 0, below: false }; + if (pct < grenzen[0]) return { from: 0, to: grenzen[0], below: true }; + + let onder = grenzen[0]; + let boven = grenzen[grenzen.length - 1]; + for (let i = 0; i < grenzen.length; i++) { + if (pct >= grenzen[i]) { + onder = grenzen[i]; + boven = i + 1 < grenzen.length ? grenzen[i + 1] : grenzen[i]; + } + } + if (onder === boven) return { from: 100, to: 100, below: false }; + return { from: onder, to: boven, below: false }; +} + +export function shareLabel(pct) { + const b = shareBucket(pct); + if (!b) return null; + if (b.from === b.to) return `${b.from}%`; + if (b.below) return `minder dan ${b.to}%`; + return `${b.from} tot ${b.to}%`; +} diff --git a/server/ai/gate.js b/server/ai/gate.js new file mode 100644 index 0000000..388cd1d --- /dev/null +++ b/server/ai/gate.js @@ -0,0 +1,105 @@ +// Generated, do not edit. sha256:ec282b725055d62dd35348c8698b9fb220cb23c5aa15fc53463e42d07d187d42 +import { getPref } from '../data/preferences.js'; +import { ENUMS } from '../../shared/config.js'; +import { hasFeature } from '../license.mjs'; + +export const AI_DISABLED = 'ai_disabled'; + +export const AI_WRITE_DENIED = 'ai_write_denied'; + +export const AI_CONTROL_DENIED = 'ai_control_denied'; + +export const AI_UNLICENSED = 'ai_unlicensed'; + +export const AI_UNLICENSED_TEXT = + 'De AI-koppeling hoort bij de betaalde versie van AssetApp. De gratis versie laat de administratie ' + + 'niet lezen of wijzigen via een AI-koppeling. Er is niets gelezen en niets gewijzigd; deze poging ' + + 'staat wel in het activiteitenlogboek.'; + +export const AI_INACTIVE = 'ai_inactive'; + +export const AI_REFUSALS = Object.freeze([AI_UNLICENSED, AI_INACTIVE, AI_DISABLED, AI_WRITE_DENIED, AI_CONTROL_DENIED]); + +export const AI_INACTIVE_TEXT = + 'De AI-koppeling van AssetApp staat uit. Er wordt niets gelezen, niets gewijzigd en niets getoond, ' + + 'ook geen verbindingstest. De eigenaar van de app zet hem weer aan met de schuif "Actief" onder ' + + 'Systeem, tabblad AI-activiteit. Deze poging staat wel in het activiteitenlogboek.'; + +export const AI_DISABLED_TEXT = + 'De AI-koppeling van AssetApp mag de administratie niet lezen. De koppeling zelf staat wel aan; ' + + 'lezen is een aparte schuif die de eigenaar van de app zelf aanzet onder Systeem, tabblad ' + + 'AI-activiteit. Er is niets gelezen en niets gewijzigd; deze poging staat wel in het ' + + 'activiteitenlogboek.'; + +export function isAiEnabled() { + try { return getPref('ai', 'allow_read') !== 0; } + catch { return false; } +} + +export function isAiActive() { + try { return getPref('ai', 'enabled') !== 0; } + catch { return false; } +} + +export function assertAiEnabled() { + if (isAiEnabled()) return; + const e = new Error(AI_DISABLED_TEXT); + e.code = AI_DISABLED; + throw e; +} + +export const AI_WRITE_DENIED_TEXT = + 'De AI-koppeling van AssetApp mag niets wijzigen. Lezen kan wel; schrijven is een aparte instelling ' + + 'die de eigenaar van de app zelf aanzet onder Systeem, tabblad AI-activiteit. Er is niets ' + + 'gewijzigd; deze poging staat wel in het activiteitenlogboek.'; + +export function isAiWriteAllowed() { + try { return getPref('ai', 'allow_write') === 1; } + catch { return false; } +} + +export const AI_CONTROL_DENIED_TEXT = + 'De AI-koppeling van AssetApp mag de app niet besturen. Lezen kan wel; navigeren is een aparte ' + + 'instelling die de eigenaar van de app zelf aanzet onder Systeem, tabblad AI-activiteit. Er is ' + + 'niets gewijzigd; deze poging staat wel in het activiteitenlogboek.'; + +export function isAiControlAllowed() { + try { return getPref('ai', 'allow_control') === 1; } + catch { return false; } +} + +export function isAiRawAllowed() { + try { return getPref('ai', 'allow_raw') === 1; } + catch { return false; } +} + +function weigering(scope) { + if (!hasFeature('ai')) return { code: AI_UNLICENSED, text: AI_UNLICENSED_TEXT }; + if (!isAiActive()) return { code: AI_INACTIVE, text: AI_INACTIVE_TEXT }; + if (scope === 'none') return null; + if (scope === 'read') { + return isAiEnabled() ? null : { code: AI_DISABLED, text: AI_DISABLED_TEXT }; + } + if (scope === 'write') { + return isAiWriteAllowed() ? null : { code: AI_WRITE_DENIED, text: AI_WRITE_DENIED_TEXT }; + } + if (scope === 'control') { + return isAiControlAllowed() ? null : { code: AI_CONTROL_DENIED, text: AI_CONTROL_DENIED_TEXT }; + } + return { + code: AI_DISABLED, + text: `Onbekende scope '${scope}'; toegestaan is ${ENUMS.AI_SCOPE.join(', ')}.`, + }; +} + +export function assertScope(scope) { + const w = weigering(scope); + if (!w) return; + const e = new Error(w.text); + e.code = w.code; + throw e; +} + +export function mayScope(scope) { + return weigering(scope) === null; +} diff --git a/server/ai/handle-secret.mjs b/server/ai/handle-secret.mjs new file mode 100644 index 0000000..1113d2b --- /dev/null +++ b/server/ai/handle-secret.mjs @@ -0,0 +1,25 @@ +// Generated, do not edit. sha256:ffc38e35f09c2a28784811c7505bad194c65e207ac58b284c202aff9a6c08ec2 +import { randomBytes } from 'node:crypto'; + +import { readSecretsStore, writeSecretsStore, secretsFile } from '../keys.mjs'; + +const SLEUTEL = 'ai_handle_secret'; + +const cache = new Map(); + +export async function handleSecret() { + const pad = secretsFile(); + if (cache.has(pad)) return cache.get(pad); + const store = await readSecretsStore(); + const bestaand = store[SLEUTEL]; + if (typeof bestaand === 'string' && bestaand.length >= 32) { + cache.set(pad, bestaand); + return bestaand; + } + const nieuw = randomBytes(32).toString('hex'); + await writeSecretsStore({ ...store, [SLEUTEL]: nieuw }); + cache.set(pad, nieuw); + return nieuw; +} + +export function _resetHandleSecret() { cache.clear(); } diff --git a/server/ai/limits.js b/server/ai/limits.js new file mode 100644 index 0000000..1727bc3 --- /dev/null +++ b/server/ai/limits.js @@ -0,0 +1,31 @@ +// Generated, do not edit. sha256:ec3bd3327478b77670de174a8843f03ad4b292ec52893e4b4f0f49c508b72a5f +import { CONFIG } from '../../shared/config.js'; + +export function omittedNote(omitted, max) { + return ` ... en nog ${omitted} niet getoond (grens: ${max} per lijst).`; +} + +export function limitRows(rows, max = CONFIG.AI.LIMITS.MAX_ROWS_PER_LIST) { + const alle = Array.isArray(rows) ? rows : []; + if (alle.length <= max) return { rows: alle, omitted: 0, capped: false }; + return { rows: alle.slice(0, max), omitted: alle.length - max, capped: true }; +} + +export function limitedList(rows, render, max = CONFIG.AI.LIMITS.MAX_ROWS_PER_LIST) { + const { rows: zichtbaar, omitted, capped } = limitRows(rows, max); + const regels = zichtbaar.map(render); + if (capped) regels.push(omittedNote(omitted, max)); + return { lines: regels, omitted, capped }; +} + +export function limitText(text, max = CONFIG.AI.LIMITS.MAX_TEXT_CHARS) { + const heel = String(text ?? ''); + if (heel.length <= max) return { text: heel, omitted: 0, capped: false }; + const markering = `\n[Afgekapt: dit antwoord was te lang en is ingekort op ${max} tekens.]`; + const ruimte = Math.max(0, max - markering.length); + return { + text: heel.slice(0, ruimte) + markering.slice(0, max), + omitted: heel.length - ruimte, + capped: true, + }; +} diff --git a/server/ai/policy.js b/server/ai/policy.js new file mode 100644 index 0000000..9b5b031 --- /dev/null +++ b/server/ai/policy.js @@ -0,0 +1,231 @@ +// Generated, do not edit. sha256:49025914ad8e6294f0b8c9d075be9b80de842094dd53941a1aaa93bc73409a0d +import { CONFIG } from '../../shared/config.js'; + +const RANG = new Map(CONFIG.AI.FIELD_CLASSES.map((k, i) => [k, i])); + +export const FIELD_POLICY = Object.freeze({ + rawData: 'drop', + settings: 'drop', + value: 'drop', + arguments: 'drop', + text: 'drop', + payload: 'drop', + messageParts: 'drop', + keyHeader: 'drop', + signatureHeader: 'drop', + timestampHeader: 'drop', + windowHeader: 'drop', + authenticationHeaderName: 'drop', + authenticationParameterName: 'drop', + + address: 'chain', + externalAddress: 'chain', + automaticLabel: 'chain', + txid: 'chain', + transactionHash: 'chain', + blockHeight: 'chain', + blockTime: 'chain', + chainIndex: 'chain', + derivationIndex: 'chain', + scriptType: 'chain', + + id: 'handle', + assetId: 'handle', + accountId: 'handle', + fromAccountId: 'handle', + toAccountId: 'handle', + productId: 'handle', + providerId: 'handle', + connectionId: 'handle', + sourceId: 'handle', + labelId: 'handle', + eventId: 'handle', + scheduleId: 'handle', + profileId: 'handle', + walletTransactionId: 'handle', + resolvedEventId: 'handle', + createdEventId: 'handle', + createdAccountId: 'handle', + createdValuationId: 'handle', + callId: 'handle', + claimedBy: 'handle', + principalAssetId: 'handle', + payoutAssetId: 'handle', + name: 'handle', + searchName: 'handle', + description: 'handle', + note: 'handle', + memo: 'handle', + body: 'handle', + fingerprint: 'handle', + summary: 'handle', + origin: 'handle', + filename: 'handle', + importReference: 'handle', + externalId: 'handle', + instanceKey: 'handle', + sessionId: 'handle', + client: 'handle', + detail: 'handle', + fieldsShared: 'handle', + url: 'handle', + baseUrl: 'handle', + scope: 'plain', + + amount: 'quantity', + balance: 'quantity', + balanceSats: 'quantity', + balanceFound: 'quantity', + valueSats: 'quantity', + feeSats: 'quantity', + unitFee: 'quantity', + principalAmount: 'quantity', + fineGrams: 'quantity', + productCount: 'quantity', + + fiatFee: 'quantityFiat', + fiatValue: 'quantityFiat', + + fiatPrice: 'rate', + fiatRate: 'rate', + annualRate: 'rate', + + key: 'plain', + portfolio: 'plain', + symbol: 'plain', + unit: 'plain', + subunit: 'plain', + displayUnit: 'plain', + decimals: 'plain', + kind: 'plain', + taxCategory: 'plain', + taxScope: 'plain', + wealthScope: 'plain', + balanceCapture: 'plain', + state: 'plain', + form: 'plain', + type: 'plain', + accountKind: 'plain', + eventType: 'plain', + source: 'plain', + priceOrigin: 'plain', + sourceName: 'plain', + status: 'plain', + lastStatus: 'plain', + direction: 'plain', + suggestedType: 'plain', + method: 'plain', + capability: 'plain', + resolution: 'plain', + transform: 'plain', + internalField: 'plain', + jsonPath: 'plain', + pathTemplate: 'plain', + queryTemplate: 'plain', + quoteCurrency: 'plain', + baseCurrency: 'plain', + currency: 'plain', + quoteUnit: 'plain', + minIntervalSeconds: 'plain', + callsPerMonth: 'plain', + hashAlgorithm: 'plain', + encoding: 'plain', + authenticationKind: 'plain', + authenticationRequired: 'plain', + rateKind: 'plain', + creditCadence: 'plain', + creditWeekday: 'plain', + creditDayOfMonth: 'plain', + compounding: 'plain', + outcome: 'plain', + tool: 'plain', + isActive: 'plain', + isSeed: 'plain', + isSystem: 'plain', + isCatchAll: 'plain', + isOwnCustody: 'plain', + scanEnabled: 'plain', + rank: 'plain', + sortOrder: 'plain', + version: 'plain', + serverVersion: 'plain', + count: 'plain', + highWater: 'plain', + rowCount: 'plain', + okCount: 'plain', + failCount: 'plain', + addressesFound: 'plain', + proposalsCreated: 'plain', + transactionCount: 'plain', + charactersOut: 'plain', + sumLatencyMs: 'plain', + maxLatencyMs: 'plain', + layer: 'plain', + target: 'plain', + providerKey: 'plain', + subject: 'plain', + httpStatus: 'plain', + errorKey: 'plain', + latencyMs: 'plain', + requestId: 'plain', + windowMs: 'plain', + gridX: 'plain', + gridY: 'plain', + spanWidth: 'plain', + spanHeight: 'plain', + bucketMinute: 'plain', + tableName: 'plain', + operation: 'plain', + rowId: 'handle', + originId: 'handle', + changes: 'drop', + occurredAt: 'plain', + claimedAt: 'plain', + expiresAt: 'plain', + resolvedAt: 'plain', + createdAt: 'plain', + updatedAt: 'plain', + closedAt: 'plain', + detectedAt: 'plain', + dismissedAt: 'plain', + gapAcceptedAt: 'plain', + importedAt: 'plain', + observedAt: 'plain', + scannedAt: 'plain', + lastScannedAt: 'plain', + lastCheckedAt: 'plain', + rateAt: 'plain', + valuedOn: 'plain', + fromDay: 'plain', + untilDay: 'plain', + day: 'plain', + startsOn: 'plain', + endsOn: 'plain', + periodStart: 'plain', + periodEnd: 'plain', +}); + +export const TABLE_POLICY = Object.freeze({ + Preference: Object.freeze({ value: 'plain' }), +}); + +export function fieldClass(field, table = null) { + if (table == null && typeof field === 'string') { + const punt = field.indexOf('.'); + if (punt > 0) return fieldClass(field.slice(punt + 1), field.slice(0, punt)); + } + + const basis = Object.hasOwn(FIELD_POLICY, field) ? FIELD_POLICY[field] : null; + + if (basis === null || table == null) return basis; + if (!Object.hasOwn(TABLE_POLICY, table)) return basis; + + const perTabel = TABLE_POLICY[table]; + return Object.hasOwn(perTabel, field) ? perTabel[field] : basis; +} + +export function strictestClass(a, b) { + if (a == null) return b; + if (b == null) return a; + return (RANG.get(a) ?? -1) >= (RANG.get(b) ?? -1) ? a : b; +} diff --git a/server/ai/precision.js b/server/ai/precision.js new file mode 100644 index 0000000..bb4174c --- /dev/null +++ b/server/ai/precision.js @@ -0,0 +1,41 @@ +// Generated, do not edit. sha256:5d115259dddad7958f7f0a20af1477db47898da5fb7fe4a5f958f0a3bc9b0df0 +import { isAiRawAllowed } from './gate.js'; +import { getPref } from '../data/preferences.js'; + +function bucketing(key) { + try { return getPref('ai', key) !== 0; } + catch { return true; } +} + +export function precisionSettings() { + if (isAiRawAllowed()) { + return Object.freeze({ + rawValues: true, + bucketShares: false, + bucketTotals: false, + bucketAmounts: false, + bucketQuantities: false, + }); + } + return Object.freeze({ + rawValues: false, + bucketShares: bucketing('bucket_shares'), + bucketTotals: bucketing('bucket_totals'), + bucketAmounts: bucketing('bucket_amounts'), + bucketQuantities: bucketing('bucket_quantities'), + }); +} + +export function shareDisclosure(precision) { + const p = precision ?? {}; + if (p.rawValues) return 'exact'; + if (!p.bucketAmounts) return 'exact'; + if (p.bucketShares) return 'bucketed'; + if (p.bucketTotals) return 'exact'; + return 'omit'; +} + +export function precisionKey(precision) { + const p = precision ?? {}; + return Object.keys(p).sort().map(k => `${k}=${String(p[k])}`).join(',') || 'leeg'; +} diff --git a/server/ai/translate.js b/server/ai/translate.js new file mode 100644 index 0000000..498a8c1 --- /dev/null +++ b/server/ai/translate.js @@ -0,0 +1,139 @@ +// Generated, do not edit. sha256:45a1e1f166c06347eecc16618109c15256ed61caf29cc462a558b68275a28363 +import { fieldClass } from './policy.js'; +import { createAliasRegistry, isSchijnwaarde } from './aliases.js'; +import { bucketLabel } from './buckets.js'; + +const SCHIJN_SOORT = Object.freeze({ + address: 'address', + externalAddress: 'address', + automaticLabel: 'address', + txid: 'txid', + transactionHash: 'txid', +}); + +const HANDLE_SOORT = Object.freeze({ + assetId: 'asset', + accountId: 'account', + fromAccountId: 'account', + toAccountId: 'account', + productId: 'product', + providerId: 'provider', + connectionId: 'connection', + sourceId: 'walletSource', + labelId: 'label', + eventId: 'event', + scheduleId: 'schedule', + profileId: 'profile', + walletTransactionId: 'walletTransaction', + resolvedEventId: 'event', + createdEventId: 'event', + createdAccountId: 'account', + createdValuationId: 'valuation', + principalAssetId: 'asset', + payoutAssetId: 'asset', +}); + +export function createTranslator({ secret, precision } = {}) { + const stand = Object.freeze({ rawValues: false, ...(precision ?? {}) }); + const raw = stand.rawValues === true; + const registry = createAliasRegistry({ secret }); + const gedeeld = new Set(); + + const uitgaand = new Map(); + + function onthoudUit(uit, echt) { + uitgaand.set(uit, echt); + return uit; + } + + function veld(naam, waarde, entiteit, tabel, rol) { + const klasse = fieldClass(naam, tabel); + + const logNaam = (tabel != null && klasse !== fieldClass(naam)) ? `${tabel}.${naam}` : naam; + + if (klasse === null || klasse === 'drop') return undefined; + + if (waarde === null || waarde === undefined) { + gedeeld.add(logNaam); + return null; + } + + switch (klasse) { + case 'plain': + case 'rate': + gedeeld.add(logNaam); + return waarde; + + case 'quantity': + case 'quantityFiat': { + if (raw) { gedeeld.add(logNaam); return waarde; } + const emmeren = klasse === 'quantity' + ? stand.bucketQuantities !== false + : (rol === 'total' ? stand.bucketTotals !== false : stand.bucketAmounts !== false); + if (!emmeren) { gedeeld.add(logNaam); return waarde; } + const label = bucketLabel(Number(waarde), klasse === 'quantityFiat' ? 'FINE' : 'COARSE'); + if (label === null) return undefined; + gedeeld.add(logNaam); + return label; + } + + case 'chain': { + const soort = SCHIJN_SOORT[naam]; + if (!soort) return undefined; + gedeeld.add(logNaam); + if (raw) return waarde; + return onthoudUit(registry.alias(soort, String(waarde)), String(waarde)); + } + + case 'handle': { + if (raw) { gedeeld.add(logNaam); return waarde; } + const soort = naam === 'id' ? (entiteit || 'record') : (HANDLE_SOORT[naam] || 'text'); + gedeeld.add(logNaam); + return onthoudUit(registry.handle(soort, String(waarde)), String(waarde)); + } + + default: + throw new Error(`no handling for class '${klasse}' (field '${naam}')`); + } + } + + function toAi(waarde, opties = {}) { + const entiteit = opties.entity || null; + const tabel = opties.table || null; + const rol = opties.role || 'row'; + if (Array.isArray(waarde)) return waarde.map(v => toAi(v, opties)); + if (waarde === null || typeof waarde !== 'object') return waarde; + + const uit = {}; + for (const [naam, v] of Object.entries(waarde)) { + const vertaald = (v !== null && typeof v === 'object') + ? (fieldClass(naam, tabel) === null ? undefined : toAi(v, {})) + : veld(naam, v, entiteit, tabel, rol); + if (vertaald !== undefined) uit[naam] = vertaald; + } + return uit; + } + + function fromAi(tekst) { + if (typeof tekst !== 'string') return { text: tekst, unresolved: [] }; + let uit = tekst; + + const sleutels = [...uitgaand.keys()].sort((a, b) => b.length - a.length); + for (const s of sleutels) uit = uit.split(s).join(uitgaand.get(s)); + + const unresolved = []; + for (const m of uit.matchAll(/\b(?:bc1q[0-9a-z]{38}|xpub[0-9A-Za-z]{107}|[0-9a-z]{64})\b/g)) { + if (isSchijnwaarde(m[0])) unresolved.push(m[0]); + } + return { text: uit, unresolved }; + } + + return { + toAi, + fromAi, + beginCall() { gedeeld.clear(); }, + fieldsShared: () => [...gedeeld].sort(), + resolve: uit => registry.resolve(uit), + precision: stand, + }; +} diff --git a/server/api.mjs b/server/api.mjs new file mode 100644 index 0000000..7690806 --- /dev/null +++ b/server/api.mjs @@ -0,0 +1,293 @@ +// Generated, do not edit. sha256:6b0db04ca1e019c207aa13f2f0133b94c5f5783d9d7742ec8001cff39b53d404 +import * as custom from './price/custom.js'; +import { getKeys, setKeys, keyStatus } from './keys.mjs'; +import * as market from './market-service.mjs'; +import { handleDataApi } from './data-api.mjs'; +import { recordIntegrationCall } from './data/integration-log.js'; +import { mcpClientConfig } from './mcp/client-config.mjs'; +import { dryRunTool, dryRunTools } from './mcp/dry-run.mjs'; +import { exportDatabase } from './db-export.mjs'; +import { exportPriceHistory } from './price-export.mjs'; +import { exportIntegrations } from './integrations-export.mjs'; +import { restoreDatabase } from './db-restore.mjs'; +import { startFreshDatabase, chooseSetupCurrency } from './db-fresh.mjs'; +import { createBackup, restoreBackup, isBackupBestand } from './db-backup.mjs'; +import { + overviewPortfolios, createPortfolio, renamePortfolio, archivePortfolio, purgeArchived, + activatePortfolio, +} from './portfolios-admin.mjs'; +import { credentialsStatus, setCredentials, clearCredentials } from './auth.mjs'; +import { closeDatabase } from './db.js'; +import { apiRoutes, services } from './registers.mjs'; +import { CONFIG } from '../shared/config.js'; +import { AppError, errorPayload } from '../shared/app-error.js'; + +function stopVoorWissel() { + try { closeDatabase(); } catch { } + process.exit(CONFIG.PORTFOLIO.RESTART_EXIT_CODE); +} + +function stopVoorSluiten() { + services.get('kiosk')?.close(); + try { closeDatabase(); } catch { } + process.exit(0); +} + +const isLoopback = (req) => ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket?.remoteAddress); +const recordedBrowser = () => services.get('kiosk')?.recorded() ?? null; + +function sendJson(res, status, obj) { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'X-Content-Type-Options': 'nosniff' }); + res.end(JSON.stringify(obj)); +} + +const sendFout = (res, status, e) => sendJson(res, status, errorPayload(e)); + +function readRawBody(req, max = 100 * 1024 * 1024) { + return new Promise((resolve, reject) => { + let len = 0; const chunks = []; + req.on('data', c => { + len += c.length; + if (len > max) { req.destroy(); reject(new AppError('common.fileTooLarge', null, 'File too large.')); } + else chunks.push(c); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +export function readJsonBody(req, max = 256 * 1024) { + return new Promise((resolve, reject) => { + let len = 0; const chunks = []; + req.on('data', c => { + len += c.length; + if (len > max) { req.destroy(); reject(new Error('Body te groot.')); } + else chunks.push(c); + }); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf-8'); + if (!raw) return resolve({}); + try { resolve(JSON.parse(raw)); } catch { reject(new Error('Invalid JSON.')); } + }); + req.on('error', reject); + }); +} + +export async function handleApi(req, res, url) { + const u = new URL(url, 'http://localhost'); + const path = u.pathname; + const q = u.searchParams; + + if (path === '/api/health') { sendJson(res, 200, { ok: true }); return true; } + + if (path === '/api/app/window' && req.method === 'GET') { + sendJson(res, 200, { canClose: isLoopback(req) && recordedBrowser() !== null }); + return true; + } + if (path === '/api/app/close' && req.method === 'POST') { + if (!isLoopback(req) || !recordedBrowser()) { + sendFout(res, 409, new AppError('appWindow.closeUnavailable', null, + 'Closing is only possible on this machine, in the app window the app started.')); + return true; + } + res.on('finish', stopVoorSluiten); + sendJson(res, 200, { closing: true }); + return true; + } + + if (path.startsWith('/api/data/')) return handleDataApi(req, res, url); + + if (path === '/api/mcp/config' && req.method === 'GET') { + sendJson(res, 200, mcpClientConfig()); + return true; + } + + if (path === '/api/mcp/tools' && req.method === 'GET') { + sendJson(res, 200, { tools: dryRunTools() }); + return true; + } + if (path === '/api/mcp/dry-run' && req.method === 'POST') { + try { + const body = await readJsonBody(req); + sendJson(res, 200, await dryRunTool(body.tool, body.arguments)); + } catch (e) { + sendFout(res, 400, e); + } + return true; + } + + if (path === '/api/db/export' && req.method === 'GET') { + try { await exportDatabase(res); } + catch (e) { sendFout(res, 500, e); } + return true; + } + + if (path === '/api/price-history/export' && req.method === 'GET') { + try { exportPriceHistory(res); } + catch (e) { sendFout(res, 500, e); } + return true; + } + + if (path === '/api/integrations/export' && req.method === 'GET') { + try { exportIntegrations(res); } + catch (e) { sendFout(res, 500, e); } + return true; + } + + if (path === '/api/db/backup' && req.method === 'POST') { + try { + const { passphrase } = await readJsonBody(req); + const { bytes, db, secrets } = await createBackup(passphrase); + const d = new Date(); + const p = n => String(n).padStart(2, '0'); + const naam = `assetapp-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}.assetbak`; + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${naam}"`, + 'Content-Length': bytes.length, + 'X-Backup-Db-Bytes': String(db), + 'X-Backup-Secrets-Bytes': String(secrets), + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }); + res.end(bytes); + } catch (e) { sendFout(res, 400, e); } + return true; + } + + if (path === '/api/db/import' && req.method === 'POST') { + try { + const bytes = await readRawBody(req); + const uit = isBackupBestand(bytes) + ? await restoreBackup(bytes, req.headers['x-backup-passphrase'] || '') + : await restoreDatabase(bytes); + sendJson(res, 200, { ...uit, versleuteld: isBackupBestand(bytes) }); + } catch (e) { sendFout(res, 400, e); } + return true; + } + + if (path === '/api/db/fresh' && req.method === 'POST') { + try { sendJson(res, 200, await startFreshDatabase()); } + catch (e) { sendFout(res, 400, e); } + return true; + } + + if (path === '/api/setup/currency' && req.method === 'POST') { + try { sendJson(res, 200, await chooseSetupCurrency((await readJsonBody(req)).baseCurrency)); } + catch (e) { sendFout(res, 400, e); } + return true; + } + + if (path === '/api/portfolios' && req.method === 'GET') { + try { sendJson(res, 200, await overviewPortfolios()); } + catch (e) { sendFout(res, 400, e); } + return true; + } + if (path === '/api/portfolios/activate' && req.method === 'POST') { + try { + const uit = activatePortfolio((await readJsonBody(req)).naam); + if (uit.gewisseld) res.on('finish', stopVoorWissel); + sendJson(res, 200, uit); + } catch (e) { sendFout(res, 400, e); } + return true; + } + if (path === '/api/portfolios/create' && req.method === 'POST') { + try { + const { naam, kopieVan, baseCurrency } = await readJsonBody(req); + sendJson(res, 200, await createPortfolio(naam, { kopieVan: kopieVan || null, baseCurrency: baseCurrency || null })); + } catch (e) { sendFout(res, 400, e); } + return true; + } + if (path === '/api/portfolios/rename' && req.method === 'POST') { + try { + const { van, naar } = await readJsonBody(req); + sendJson(res, 200, await renamePortfolio(van, naar)); + } catch (e) { sendFout(res, 400, e); } + return true; + } + if (path === '/api/portfolios/archive' && req.method === 'POST') { + try { sendJson(res, 200, await archivePortfolio((await readJsonBody(req)).naam)); } + catch (e) { sendFout(res, 400, e); } + return true; + } + if (path === '/api/portfolios/purge' && req.method === 'POST') { + try { sendJson(res, 200, await purgeArchived((await readJsonBody(req)).naam)); } + catch (e) { sendFout(res, 400, e); } + return true; + } + + if (path === '/api/auth/status' && req.method === 'GET') { + try { sendJson(res, 200, await credentialsStatus()); } + catch (e) { sendFout(res, 400, e); } + return true; + } + if (path === '/api/auth/set' && req.method === 'POST') { + try { + const { password } = await readJsonBody(req); + sendJson(res, 200, await setCredentials(password)); + } catch (e) { sendFout(res, 400, e); } + return true; + } + if (path === '/api/auth/clear' && req.method === 'POST') { + try { sendJson(res, 200, await clearCredentials()); } + catch (e) { sendFout(res, 400, e); } + return true; + } + + if (path === '/api/price/call' && req.method === 'POST') { + let provider = null, capability = null, symbol = null, creds = null; + const t0 = performance.now(); + try { + const body = await readJsonBody(req); + ({ provider, capability, symbol } = body); + const { date, with_raw } = body; + if (!provider || !provider.key) throw new Error('The provider config is missing.'); + creds = await getKeys(provider.key); + sendJson(res, 200, await custom.priceCall(provider, capability, symbol, date, creds, { withRaw: !!with_raw })); + } catch (e) { + if (provider?.key) { + try { + await recordIntegrationCall(custom.failureEntry(provider, capability, symbol, e, performance.now() - t0), + { secrets: Object.values(creds || {}).filter(v => typeof v === 'string') }); + } catch { } + } + sendFout(res, 502, e); + } + return true; + } + + if (path === '/api/keys/status' && req.method === 'GET') { + try { sendJson(res, 200, await keyStatus(q.get('provider'), q.get('authenticationKind'))); } + catch (e) { sendFout(res, 400, e); } + return true; + } + if (path === '/api/keys/set' && req.method === 'POST') { + try { + const { provider, authenticationKind, values } = await readJsonBody(req); + sendJson(res, 200, await setKeys(provider, authenticationKind, values)); + } catch (e) { sendFout(res, 400, e); } + return true; + } + + if (path === '/api/market/status' && req.method === 'GET') { + try { sendJson(res, 200, await market.marketServiceStatus()); } + catch (e) { sendFout(res, 400, e); } + return true; + } + if (path === '/api/market/test' && req.method === 'POST') { + try { sendJson(res, 200, await market.testMarketService()); } + catch (e) { sendFout(res, 502, e); } + return true; + } + + const route = apiRoutes.list().find(r => r.path === path && r.method === req.method); + if (route) { + try { + const body = req.method === 'POST' ? await readJsonBody(req) : {}; + sendJson(res, 200, await route.run({ q, body })); + } catch (e) { sendFout(res, route.errorStatus ?? 400, e); } + return true; + } + + return false; +} diff --git a/server/app.mjs b/server/app.mjs new file mode 100644 index 0000000..d534dfa --- /dev/null +++ b/server/app.mjs @@ -0,0 +1,167 @@ +// Generated, do not edit. sha256:29c149da1bd119d466012f8c80b13c9f6ba6ac924e6326dd2b0629b05ed6ce6e +import { readFile } from 'node:fs/promises'; +import { join, resolve, normalize, extname, dirname, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { handleApi, readJsonBody } from './api.mjs'; +import { + clearedSessionCookie, passwordMatches, sendUnauthorized, sessionCookie, sessionTokenOf, +} from './auth.mjs'; +import { createAttemptLimiter, createSessionStore } from './sessions.mjs'; +import { CONFIG } from '../shared/config.js'; +import { connectSrcOrigins } from '../shared/csp.js'; +import { listConnections } from './data/connections.js'; +import { errorPayload } from '../shared/app-error.js'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const CLIENT = join(ROOT, 'client'); +const SHARED = join(ROOT, 'shared'); + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.wasm': 'application/wasm', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.ttf': 'font/ttf', +}; + +function explorerUrls() { + try { return listConnections('btc_explorer').map(c => c.url); } catch { return []; } +} +const directive = (naam, ...bronnen) => [naam, ...bronnen.flat().filter(Boolean)].join(' '); +const csp = () => [ + directive('default-src', `'self'`), + directive('script-src', `'self'`), + directive('style-src', `'self'`, `'unsafe-inline'`), + directive('style-src-elem', `'self'`), + directive('style-src-attr', `'unsafe-inline'`), + directive('font-src', `'self'`), + directive('img-src', `'self'`, 'data:'), + directive('connect-src', `'self'`, connectSrcOrigins(explorerUrls())), + directive('object-src', `'none'`), + directive('base-uri', `'self'`), + directive('frame-ancestors', `'none'`), +].join('; '); + +const HTML_CACHE_CONTROL = 'no-store'; +const STATIC_CACHE_CONTROL = 'no-cache'; + +function resolveStatic(urlPath) { + let rel; + try { rel = decodeURIComponent(urlPath.split('?')[0]); } + catch { return null; } + if (rel === '/' || rel === '/index.html') return join(CLIENT, 'index.html'); + if (rel === CONFIG.AUTH.LOGIN_PATH) return join(CLIENT, 'login.html'); + const onder = (basis, pad) => { + const abs = normalize(join(basis, pad)); + return abs.startsWith(basis + sep) ? abs : null; + }; + if (rel.startsWith('/shared/')) return onder(SHARED, rel.slice('/shared'.length)); + return onder(CLIENT, rel); +} + +function send(res, status, body, headers = {}) { + res.writeHead(status, { 'X-Content-Type-Options': 'nosniff', ...headers }); + res.end(body); +} +function sendJson(res, status, obj, extra = {}) { + send(res, status, JSON.stringify(obj), { 'Content-Type': 'application/json; charset=utf-8', ...extra }); +} + +const LOGIN_ASSETS = new Set([ + '/login.js', + '/css/styles.css', + '/img/favicon.svg', + '/vendor/inter/inter.css', +]); +const LOGIN_ASSET_PREFIX = '/vendor/inter/files/'; + +const magZonderSessie = (pad) => + pad === CONFIG.AUTH.LOGIN_PATH || LOGIN_ASSETS.has(pad) || pad.startsWith(LOGIN_ASSET_PREFIX); + +export function createRequestListener({ credentials = null, secure = false } = {}) { + const sessies = createSessionStore(); + const limiet = createAttemptLimiter(); + + async function inloggen(req, res) { + const adres = req.socket?.remoteAddress || 'unknown'; + const wacht = limiet.retryAfterMs(adres); + if (wacht > 0) { + return sendJson(res, 429, { error: 'Too many attempts.', retryAfterMs: wacht }, + { 'Retry-After': String(Math.ceil(wacht / 1000)) }); + } + let body; + try { body = await readJsonBody(req); } + catch (e) { return sendJson(res, 400, errorPayload(e)); } + + if (!passwordMatches(body?.password, credentials)) { + return sendJson(res, 401, { error: 'Wrong password.', retryAfterMs: limiet.fail(adres) }); + } + limiet.succeed(adres); + return sendJson(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie(sessies.create(), { secure }) }); + } + + function uitloggen(req, res) { + const token = sessionTokenOf(req); + if (token) sessies.drop(token); + return sendJson(res, 200, { ok: true }, { 'Set-Cookie': clearedSessionCookie({ secure }) }); + } + + return async (req, res) => { + try { + const url = req.url || '/'; + const pad = url.split('?')[0]; + + if (credentials) { + if (pad === '/api/login') { + if (req.method !== 'POST') return sendJson(res, 405, { error: 'Method not allowed' }); + return await inloggen(req, res); + } + if (pad === '/api/logout') { + if (req.method !== 'POST') return sendJson(res, 405, { error: 'Method not allowed' }); + return uitloggen(req, res); + } + + const ingelogd = sessies.isValid(sessionTokenOf(req)); + if (ingelogd && pad === CONFIG.AUTH.LOGIN_PATH) { + return send(res, 302, '', { Location: '/', 'Cache-Control': 'no-store' }); + } + if (!ingelogd && !magZonderSessie(pad)) { + return sendUnauthorized(res, { document: !pad.startsWith('/api/') }); + } + } else if (pad === CONFIG.AUTH.LOGIN_PATH) { + return send(res, 302, '', { Location: '/', 'Cache-Control': 'no-store' }); + } + + if (url.startsWith('/api/')) { + const handled = await handleApi(req, res, url); + if (!handled) sendJson(res, 404, { error: 'Unknown endpoint' }); + return; + } + + if (req.method !== 'GET' && req.method !== 'HEAD') { + return sendJson(res, 405, { error: 'Method not allowed' }); + } + const file = resolveStatic(url); + if (!file) return send(res, 404, 'Not found'); + + const data = await readFile(file); + const ext = extname(file).toLowerCase(); + const type = MIME[ext] || 'application/octet-stream'; + const cacheControl = ext === '.html' ? HTML_CACHE_CONTROL : STATIC_CACHE_CONTROL; + return send(res, 200, req.method === 'HEAD' ? '' : data, { 'Content-Type': type, 'Content-Security-Policy': csp(), 'Cache-Control': cacheControl }); + } catch (err) { + if (err && err.code === 'ENOENT') return send(res, 404, 'Not found'); + console.error('[assetapp] requestfout:', err); + return send(res, 500, 'Interne fout'); + } + }; +} diff --git a/server/auth.mjs b/server/auth.mjs new file mode 100644 index 0000000..cc0a901 --- /dev/null +++ b/server/auth.mjs @@ -0,0 +1,126 @@ +// Generated, do not edit. sha256:b90a0342814d40cb5e6f51f946a82feca678a01b63d4cdb3e948bcbb7e125654 +import { createHash, timingSafeEqual } from 'node:crypto'; +import { CONFIG } from '../shared/config.js'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { readSecretsStore, writeSecretsStore } from './keys.mjs'; +import { installDir } from './portfolios.mjs'; +import { fail } from '../shared/app-error.js'; + +const AUTH_SLEUTEL = 'assetapp_auth'; + +const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']); + +const digest = (waarde) => createHash('sha256').update(String(waarde), 'utf-8').digest(); + +export function passwordMatches(presented, expected) { + if (!expected || !expected.password) return false; + if (typeof presented !== 'string' || !presented) return false; + return timingSafeEqual(digest(presented), digest(expected.password)); +} + +export function parseCookies(header) { + const uit = {}; + if (typeof header !== 'string') return uit; + for (const deel of header.split(';')) { + const i = deel.indexOf('='); + if (i < 0) continue; + const naam = deel.slice(0, i).trim(); + if (!naam || naam in uit) continue; + uit[naam] = deel.slice(i + 1).trim(); + } + return uit; +} + +const vlaggen = (secure) => `Path=/; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`; + +export function sessionCookie(token, { secure = false } = {}) { + const seconden = Math.floor(CONFIG.AUTH.SESSION_TTL_MS / 1000); + return `${CONFIG.AUTH.COOKIE_NAME}=${token}; Max-Age=${seconden}; ${vlaggen(secure)}`; +} + +export function clearedSessionCookie({ secure = false } = {}) { + return `${CONFIG.AUTH.COOKIE_NAME}=; Max-Age=0; ${vlaggen(secure)}`; +} + +export function sessionTokenOf(req) { + return parseCookies(req?.headers?.cookie)[CONFIG.AUTH.COOKIE_NAME] || null; +} + +export function sendUnauthorized(res, { document = false } = {}) { + if (document) { + res.writeHead(302, { + Location: CONFIG.AUTH.LOGIN_PATH, + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }); + return res.end(); + } + res.writeHead(401, { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'Not logged in.' })); +} + +export async function loadCredentials() { + const { ASSETAPP_PASSWORD: password } = process.env; + if (password) return { password }; + const opgeslagen = (await readSecretsStore())[AUTH_SLEUTEL]; + if (opgeslagen && opgeslagen.password) return { password: String(opgeslagen.password) }; + return null; +} + +export async function credentialsStatus() { + const { ASSETAPP_PASSWORD: password } = process.env; + if (password) return { ingesteld: true, bron: 'omgeving', wijzigbaar: false }; + const opgeslagen = (await readSecretsStore())[AUTH_SLEUTEL]; + const ingesteld = Boolean(opgeslagen && opgeslagen.password); + return { ingesteld, bron: ingesteld ? 'portefeuille' : 'geen', wijzigbaar: true }; +} + +export async function setCredentials(password) { + const w = String(password ?? ''); + if (!w) fail('portfolios.passwordRequired', null, 'A password is required.'); + const store = await readSecretsStore(); + await writeSecretsStore({ ...store, [AUTH_SLEUTEL]: { password: w } }); + return { ...await credentialsStatus(), naHerstart: true }; +} + +export async function clearCredentials() { + const store = await readSecretsStore(); + if (!(AUTH_SLEUTEL in store)) return { ...await credentialsStatus(), naHerstart: false }; + delete store[AUTH_SLEUTEL]; + await writeSecretsStore(store); + return { ...await credentialsStatus(), naHerstart: true }; +} + +const leesOfNull = async (pad) => { + try { return await readFile(pad); } + catch (e) { if (e.code === 'ENOENT') return null; throw e; } +}; + +export async function loadTlsOptions() { + const certPad = process.env.ASSETAPP_TLS_CERT || join(installDir(), 'tls', 'cert.pem'); + const keyPad = process.env.ASSETAPP_TLS_KEY || join(installDir(), 'tls', 'key.pem'); + const [cert, key] = await Promise.all([leesOfNull(certPad), leesOfNull(keyPad)]); + if (!cert && !key) return null; + if (!cert) throw new Error(`TLS half configured: key found (${keyPad}) but no certificate (${certPad}).`); + if (!key) throw new Error(`TLS half configured: certificate found (${certPad}) but no key (${keyPad}).`); + return { cert, key }; +} + +export function resolveBinding({ tls = false, credentials = null, host = null } = {}) { + const beschermd = Boolean(tls) && Boolean(credentials); + if (host) { + if (LOOPBACK.has(host) || beschermd) return { host, waarschuwing: null }; + const mist = [Boolean(tls) ? null : 'TLS', credentials ? null : 'inloggegevens'].filter(Boolean).join(' en '); + return { + host, + waarschuwing: `ASSETAPP_HOST=${host} stelt de app open voor het hele netwerk terwijl ${mist} ontbreekt. ` + + 'Iedereen die deze machine kan bereiken, kan de app openen.', + }; + } + return { host: beschermd ? '0.0.0.0' : '127.0.0.1', waarschuwing: null }; +} diff --git a/server/data-api.mjs b/server/data-api.mjs new file mode 100644 index 0000000..b562781 --- /dev/null +++ b/server/data-api.mjs @@ -0,0 +1,200 @@ +// Generated, do not edit. sha256:eca3b25957829a0a34656d92265e558efc50996dc58250a690f2dba7d2ab107a +import { AppError, errorPayload } from '../shared/app-error.js'; +import { dataModules } from './registers.mjs'; +import * as accountsMerge from './data/accounts-merge.js'; +import * as accounts from './data/accounts.js'; +import * as aiAudit from './data/ai-audit.js'; +import * as aiProposals from './data/ai-proposals.js'; +import * as apiMetrics from './data/api-metrics.js'; +import * as assetNotes from './data/asset-notes.js'; +import * as assets from './data/assets.js'; +import * as bus from './data/bus.js'; +import * as commands from './data/commands.js'; +import * as connections from './data/connections.js'; +import * as counts from './data/counts.js'; +import * as dashboard from './data/dashboard.js'; +import * as events from './data/events.js'; +import * as hygiene from './data/hygiene.js'; +import * as integrationLog from './data/integration-log.js'; +import * as interestCredits from './data/interest-credits.js'; +import * as interestSchedules from './data/interest-schedules.js'; +import * as labels from './data/labels.js'; +import * as licenseBuyer from './data/license-buyer.js'; +import * as licenseInstall from './data/license-install.js'; +import * as licenseStatus from './data/license-status.js'; +import * as support from './data/support.js'; +import * as market from './data/market.js'; +import * as metals from './data/metals.js'; +import * as notifications from './data/notifications.js'; +import * as onboarding from './data/onboarding.js'; +import * as positions from './data/positions.js'; +import * as preferences from './data/preferences.js'; +import * as priceHistory from './data/price-history.js'; +import * as priceSource from './data/price-source.js'; +import * as proposals from './data/proposals.js'; +import * as providers from './data/providers.js'; +import * as reservations from './data/reservations.js'; +import * as scanLogs from './data/scan-logs.js'; +import * as structure from './data/structure.js'; +import * as tableViewer from './data/table-viewer.js'; +import * as txLinks from './data/tx-links.js'; +import * as valuations from './data/valuations.js'; +import * as walletAddresses from './data/wallet-addresses.js'; +import * as walletSources from './data/wallet-sources.js'; +import * as walletTxs from './data/wallet-txs.js'; +import { flushChanges } from './data/bus.js'; + +const MODULES = Object.freeze({ + 'accounts': accounts, + 'accounts-merge': accountsMerge, + 'ai-audit': aiAudit, + 'ai-proposals': aiProposals, + 'api-metrics': apiMetrics, + 'asset-notes': assetNotes, + 'assets': assets, + 'bus': bus, + 'commands': commands, + 'connections': connections, + 'counts': counts, + 'dashboard': dashboard, + 'events': events, + 'hygiene': hygiene, + 'integration-log': integrationLog, + 'interest-credits': interestCredits, + 'interest-schedules': interestSchedules, + 'labels': labels, + 'license-buyer': licenseBuyer, + 'license-install': licenseInstall, + 'license-status': licenseStatus, + 'market': market, + 'metals': metals, + 'notifications': notifications, + 'onboarding': onboarding, + 'positions': positions, + 'preferences': preferences, + 'price-history': priceHistory, + 'price-source': priceSource, + 'proposals': proposals, + 'providers': providers, + 'reservations': reservations, + 'scan-logs': scanLogs, + 'structure': structure, + 'support': support, + 'table-viewer': tableViewer, + 'tx-links': txLinks, + 'valuations': valuations, + 'wallet-addresses': walletAddresses, + 'wallet-sources': walletSources, + 'wallet-txs': walletTxs, +}); + +const INTERN = Object.freeze({ + 'bus': ['flushChanges', 'pruneChanges'], + 'integration-log': ['recordIntegrationCall', 'countServiceCall'], + 'interest-schedules': ['realizedForSchedule'], + 'preferences': ['forgetPrefRow', 'tableViewCfg'], + 'providers': ['emitProvidersChanged'], + 'tx-links': ['linkProposalTxSoft'], + 'wallet-sources': ['purgeWalletSourceSecret', 'assertSourceMatchesAccount'], +}); + +function modules() { + const extra = dataModules.list(); + const dubbel = extra.find(m => Object.hasOwn(MODULES, m.id)); + if (dubbel) throw new Error(`data module '${dubbel.id}' is registered and also a core module`); + return { + all: { ...MODULES, ...Object.fromEntries(extra.map(m => [m.id, m.module])) }, + intern: { ...INTERN, ...Object.fromEntries(extra.filter(m => m.intern).map(m => [m.id, m.intern])) }, + }; +} + +function oppervlakVan(module, internals = []) { + const intern = new Set(internals); + return Object.freeze(Object.entries(module) + .filter(([naam, waarde]) => typeof waarde === 'function' && !intern.has(naam)) + .map(([naam]) => naam) + .sort()); +} + +let cache = { count: -1, surface: null, all: null }; +function current() { + const count = dataModules.list().length; + if (cache.count !== count) { + const { all, intern } = modules(); + const surface = Object.freeze(Object.fromEntries( + Object.keys(all).sort().map(mod => [mod, oppervlakVan(all[mod], intern[mod])]))); + cache = { count, surface, all }; + } + return cache; +} + +export const surface = () => current().surface; + +export function isAllowed(mod, fn) { + const s = surface(); + return Object.hasOwn(s, mod) && s[mod].includes(fn); +} + +export function codeer(waarde) { + if (waarde instanceof Map) return { __t: 'Map', v: [...waarde].map(([k, v]) => [k, codeer(v)]) }; + if (waarde instanceof Set) return { __t: 'Set', v: [...waarde].map(codeer) }; + if (Array.isArray(waarde)) return waarde.map(codeer); + if (waarde && typeof waarde === 'object' && Object.getPrototypeOf(waarde) === Object.prototype) { + return Object.fromEntries(Object.entries(waarde).map(([k, v]) => [k, codeer(v)])); + } + return waarde; +} + +function sendJson(res, status, obj) { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'X-Content-Type-Options': 'nosniff' }); + res.end(JSON.stringify(obj)); +} + +const MAX_BODY = 5 * 1024 * 1024; + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let len = 0; const chunks = []; + req.on('data', c => { + len += c.length; + if (len > MAX_BODY) { req.destroy(); reject(new AppError('common.requestTooLarge', null, 'Request too large.')); } + else chunks.push(c); + }); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf-8'); + if (!raw) return resolve({}); + try { resolve(JSON.parse(raw)); } catch { reject(new Error('Invalid JSON.')); } + }); + req.on('error', reject); + }); +} + +export async function handleDataApi(req, res, url) { + const pad = url.split('?')[0]; + if (!pad.startsWith('/api/data/')) return false; + if (req.method !== 'POST') { sendJson(res, 405, { error: 'Alleen POST.' }); return true; } + + const delen = pad.slice('/api/data/'.length).split('/'); + if (delen.length !== 2) { sendJson(res, 404, { error: 'Unknown endpoint.' }); return true; } + const [mod, fn] = delen; + + if (!isAllowed(mod, fn)) { + sendJson(res, 404, { error: `Unknown data-layer function '${mod}/${fn}'.` }); + return true; + } + + let args; + try { + const body = await readJsonBody(req); + args = Array.isArray(body.args) ? body.args : []; + } catch (e) { sendJson(res, 400, errorPayload(e)); return true; } + + try { + flushChanges(); + const result = await current().all[mod][fn](...args); + sendJson(res, 200, { result: codeer(result === undefined ? null : result), changes: flushChanges() }); + } catch (e) { + sendJson(res, 400, errorPayload(e)); + } + return true; +} diff --git a/server/data/_events.js b/server/data/_events.js new file mode 100644 index 0000000..88740f1 --- /dev/null +++ b/server/data/_events.js @@ -0,0 +1,11 @@ +// Generated, do not edit. sha256:25337d7bfffe1c04bcf94e07f2ea63c7184bfb60c25a933f7d8e83cbc634dc8c +const mandje = new Set(); + +export function emitChange(naam) { mandje.add(naam); } + +export function takeChanges() { + if (!mandje.size) return []; + const uit = [...mandje]; + mandje.clear(); + return uit; +} diff --git a/server/data/_limits.js b/server/data/_limits.js new file mode 100644 index 0000000..f85198a --- /dev/null +++ b/server/data/_limits.js @@ -0,0 +1,27 @@ +// Generated, do not edit. sha256:37f1d463b03d2b5d702238cb632740786fc53e2072e4691719327d700c59d56c +import { capacityReached } from '../license.mjs'; +import * as db from '../db.js'; +import { counts } from './counts.js'; +import { RATE_ONLY_ASSET } from './_shared.js'; +import { getBaseCurrency } from './portfolio.js'; + +function fiatAccountCount() { + return db.one(`SELECT COUNT(*) AS n FROM Account WHERE kind = 'bank'`).n; +} + +function assetCount() { + return db.one(`SELECT COUNT(*) AS n FROM Asset WHERE symbol <> ? AND NOT ${RATE_ONLY_ASSET}`, + [getBaseCurrency()]).n; +} + +export function currentCount(soort) { + const c = counts(); + if (soort === 'assets') return assetCount(); + if (soort === 'valuations') return c.valuations; + if (soort === 'fiatAccounts') return fiatAccountCount(); + throw new Error(`No counter for capacity '${soort}' yet.`); +} + +export function capacityReachedFor(soort) { + return capacityReached(soort, currentCount(soort)); +} diff --git a/server/data/_shared.js b/server/data/_shared.js new file mode 100644 index 0000000..f313941 --- /dev/null +++ b/server/data/_shared.js @@ -0,0 +1,71 @@ +// Generated, do not edit. sha256:945ee26961af9e68d689f5dc6bc4e4b542606ba5501cbaee8b17b7df9349fba6 +import { CONFIG, ENUMS } from '../../shared/config.js'; + +export function assertEnum(name, list, value) { + if (!list.includes(value)) { + throw new Error(`Invalid ${name}: '${value}'. Allowed: ${list.join(', ')}.`); + } +} + +export function assertTaxCategory(value) { + if (value == null || value === '') return null; + assertEnum('taxCategory', ENUMS.TAX_CATEGORY, value); + return value; +} + +export function assertTaxScope(value) { + if (value == null || value === '') return null; + assertEnum('taxScope', ENUMS.TAX_SCOPE, value); + return value; +} + +export function assertWealthScope(value) { + if (value == null || value === '') return null; + assertEnum('wealthScope', ENUMS.WEALTH_SCOPE, value); + return value; +} + +export const RATE_ONLY_ASSET = `(Asset.kind = 'fiat' AND Asset.symbol <> (SELECT baseCurrency FROM Portfolio) + AND NOT EXISTS (SELECT 1 FROM Event WHERE Event.assetId = Asset.id) + AND NOT EXISTS (SELECT 1 FROM Valuation WHERE Valuation.assetId = Asset.id) + AND NOT EXISTS (SELECT 1 FROM InterestSchedule + WHERE InterestSchedule.principalAssetId = Asset.id OR InterestSchedule.payoutAssetId = Asset.id))`; + +export function nowIso() { return new Date().toISOString(); } + +const TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,6})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/; + +export function assertTimestamp(field, value) { + const m = typeof value === 'string' ? value.match(TIMESTAMP) : null; + if (!m) { + throw new Error(`Invalid ${field}: '${value}'. A date is expected (2026-08-19), optionally with a time.`); + } + const [, jaar, maand, dag, uur = '0', minuut = '0', seconde = '0'] = m; + const d = new Date(Date.UTC(Number(jaar), Number(maand) - 1, Number(dag))); + const bestaat = d.getUTCFullYear() === Number(jaar) + && d.getUTCMonth() === Number(maand) - 1 + && d.getUTCDate() === Number(dag); + if (!bestaat) throw new Error(`Invalid ${field}: '${value}' is not an existing date.`); + if (Number(uur) > 23 || Number(minuut) > 59 || Number(seconde) > 59) { + throw new Error(`Invalid ${field}: '${value}' is not an existing time.`); + } + return value; +} + +export function assertName(field, value) { + const naam = typeof value === 'string' ? value.trim() : ''; + if (!naam) throw new Error(`${field} may not be empty.`); + return naam; +} + +export function todayIso() { + return new Date().toLocaleDateString('en-CA', { timeZone: CONFIG.PEILDATUM.TIMEZONE }); +} + +export function daysBetween(aIso, bIso) { return (Date.parse(bIso) - Date.parse(aIso)) / 86400000; } + +export function isoToDate(iso) { return new Date(`${iso}T00:00:00Z`); } +export function dateToIso(d) { return d.toISOString().slice(0, 10); } +export function addDaysIso(iso, n) { const d = isoToDate(iso); d.setUTCDate(d.getUTCDate() + n); return dateToIso(d); } +export function weekdayOf(iso) { return isoToDate(iso).getUTCDay(); } +export function clampDom(year, month0, dom) { const last = new Date(Date.UTC(year, month0 + 1, 0)).getUTCDate(); return Math.min(dom, last); } diff --git a/server/data/accounts-merge.js b/server/data/accounts-merge.js new file mode 100644 index 0000000..7262045 --- /dev/null +++ b/server/data/accounts-merge.js @@ -0,0 +1,86 @@ +// Generated, do not edit. sha256:ebad02dba29ae50bcbaf9c21bb59936cf43f6be868e86ac00d0e6055f404b26f +import * as db from '../db.js'; +import { ACCOUNT_SOURCE_KINDS } from '../../shared/config.js'; +import { fail } from '../../shared/app-error.js'; +import { eventsNetByAccount } from './events.js'; +import { trackUpdate, trackUpdateWhere, trackDelete, trackDeleteWhere, withOrigin } from './change-log.js'; + +function mergeCompatibility(survivor, victim) { + const S = ACCOUNT_SOURCE_KINDS[survivor.kind] || []; + const V = ACCOUNT_SOURCE_KINDS[victim.kind] || []; + if ((S.length === 0) !== (V.length === 0)) { + return { ok: false, reason: 'sourceMismatch' }; + } + if (V.some(k => !S.includes(k))) { + return { ok: false, reason: 'kindCannotCarry' }; + } + return { ok: true, reason: null }; +} + +export function mergeCheck(survivorId, victimId) { + if (!survivorId || !victimId) return { ok: false, reason: 'pickTwo', counts: null }; + if (Number(survivorId) === Number(victimId)) return { ok: false, reason: 'sameAccount', counts: null }; + const survivor = db.one(`SELECT * FROM Account WHERE id=?`, [survivorId]); + const victim = db.one(`SELECT * FROM Account WHERE id=?`, [victimId]); + if (!survivor || !victim) return { ok: false, reason: 'notFound', counts: null }; + const compat = mergeCompatibility(survivor, victim); + const counts = { + sources: db.one(`SELECT COUNT(*) AS n FROM WalletSource WHERE accountId=?`, [victimId]).n, + events: db.one(`SELECT COUNT(*) AS n FROM Event WHERE fromAccountId=?1 OR toAccountId=?1`, [victimId]).n, + schedules: db.one(`SELECT COUNT(*) AS n FROM InterestSchedule WHERE accountId=?`, [victimId]).n, + valuations: db.one(`SELECT COUNT(*) AS n FROM Valuation WHERE accountId =?`, [victimId]).n, + }; + return { ok: compat.ok, reason: compat.reason, counts, survivorName: survivor.name, victimName: victim.name }; +} + +export async function mergeAccounts(survivorId, victimId) { + survivorId = Number(survivorId); victimId = Number(victimId); + const chk = mergeCheck(survivorId, victimId); + if (!chk.ok) fail(`accounts.mergeReason_${chk.reason}`, null, `Merge not possible (${chk.reason}).`); + return withOrigin('merge', victimId, () => voerSamenvoegingUit(survivorId, victimId, chk)); +} + +async function voerSamenvoegingUit(survivorId, victimId, chk) { + + const preDiff = new Map(); + for (const v of db.all(`SELECT id, accountId, assetId, rateAt, balance FROM Valuation WHERE accountId = ?1 OR accountId = ?2`, [survivorId, victimId])) { + preDiff.set(v.id, v.balance - eventsNetByAccount(v.accountId, v.assetId, v.rateAt)); + } + + trackUpdateWhere('Event', `fromAccountId = ?`, [victimId], () => + db.run(`UPDATE Event SET fromAccountId=?1 WHERE fromAccountId=?2`, [survivorId, victimId])); + trackUpdateWhere('Event', `toAccountId = ?`, [victimId], () => + db.run(`UPDATE Event SET toAccountId=?1 WHERE toAccountId=?2`, [survivorId, victimId])); + trackUpdateWhere('WalletSource', `accountId = ?`, [victimId], () => + db.run(`UPDATE WalletSource SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId])); + db.run(`UPDATE WalletAddress SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]); + db.run(`UPDATE WalletTransaction SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]); + db.run(`UPDATE ScanLog SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]); + db.run(`UPDATE ReconcileProposal SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]); + db.run(`UPDATE ImportBatch SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]); + trackUpdateWhere('InterestSchedule', `accountId = ?`, [victimId], () => + db.run(`UPDATE InterestSchedule SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId])); + + trackDeleteWhere('Valuation', `accountId = ?2 + AND EXISTS (SELECT 1 FROM Valuation v2 WHERE v2.accountId = ?1 + AND v2.assetId=Valuation.assetId AND v2.valuedOn=Valuation.valuedOn)`, + [survivorId, victimId], () => + db.run(`DELETE FROM Valuation WHERE accountId =?2 + AND EXISTS (SELECT 1 FROM Valuation v2 WHERE v2.accountId =?1 + AND v2.assetId=Valuation.assetId AND v2.valuedOn=Valuation.valuedOn)`, + [survivorId, victimId])); + trackUpdateWhere('Valuation', `accountId = ?`, [victimId], () => + db.run(`UPDATE Valuation SET accountId =?1 WHERE accountId = ?2`, [survivorId, victimId])); + + for (const [id, diff] of preDiff) { + const v = db.one(`SELECT id, assetId, rateAt FROM Valuation WHERE id=?`, [id]); + if (!v) continue; + const newNet = eventsNetByAccount(survivorId, v.assetId, v.rateAt); + trackUpdate('Valuation', id, () => + db.run(`UPDATE Valuation SET balance=? WHERE id=?`, [newNet + diff, id])); + } + + trackDelete('Account', victimId, () => db.run(`DELETE FROM Account WHERE id=?`, [victimId])); + await db.persist(); + return chk.counts; +} diff --git a/server/data/accounts.js b/server/data/accounts.js new file mode 100644 index 0000000..b57ddf2 --- /dev/null +++ b/server/data/accounts.js @@ -0,0 +1,117 @@ +// Generated, do not edit. sha256:defdf4c970fac7940f2bd495e2911283709af51809bb6a2d4c4f9c43ed032b8f +import * as db from '../db.js'; +import { toSearchName } from '../../shared/search-name.js'; +import { nowUtcIso } from '../../shared/time.js'; +import { ENUMS } from '../../shared/config.js'; +import { assertEnum, assertName, assertTaxCategory, assertTaxScope, assertWealthScope } from './_shared.js'; +import { capacityReachedFor } from './_limits.js'; +import { fail } from '../../shared/app-error.js'; +import { purgeWalletSourceSecret } from './wallet-sources.js'; +import { accountPositions } from './positions.js'; +import { trackUpdate, trackDelete } from './change-log.js'; + +export function listAccounts() { + return db.all(`SELECT * FROM Account ORDER BY name`); +} + +export function listSelectableAccounts({ metGesloten = false, ookId = null } = {}) { + return metGesloten + ? db.all(`SELECT * FROM Account ORDER BY isActive DESC, name`) + : db.all(`SELECT * FROM Account WHERE isActive = 1 OR id = ?1 ORDER BY isActive DESC, name`, + [ookId ?? null]); +} +export async function createAccount({ name, kind, isOwnCustody, scanEnabled, taxCategory, taxScope, wealthScope }) { + assertEnum('kind', ENUMS.ACCOUNT_KIND, kind); + if (kind === 'bank') { + const max = capacityReachedFor('fiatAccounts'); + if (max !== null) { + fail('accounts.maxFreeBank', { max }, + `The free version holds ${max} fiat account(s). A licence lifts that; nothing is removed in the meantime.`); + } + } + const naam = assertName('Accountnaam', name); + const categorie = assertTaxCategory(taxCategory); + const scope = assertTaxScope(taxScope); + const wealth = assertWealthScope(wealthScope); + db.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt, taxCategory, taxScope, wealthScope) + VALUES (?,?,?,?,?,1,NULL,?,?,?)`, + [naam, toSearchName(naam), kind, isOwnCustody ? 1 : 0, scanEnabled ? 1 : 0, categorie, scope, wealth]); + const id = db.lastId(); + await db.persist(); + return id; +} +export async function updateAccount(id, a) { + assertEnum('kind', ENUMS.ACCOUNT_KIND, a.kind); + const naam = assertName('Accountnaam', a.name); + const categorie = assertTaxCategory(a.taxCategory); + const scopeGiven = Object.hasOwn(a, 'taxScope'); + const scope = scopeGiven ? assertTaxScope(a.taxScope) : null; + const wealthGiven = Object.hasOwn(a, 'wealthScope'); + const wealth = wealthGiven ? assertWealthScope(a.wealthScope) : null; + trackUpdate('Account', id, () => + db.run(`UPDATE Account SET name=?, searchName=?, kind=?, isOwnCustody=?, scanEnabled=?, taxCategory=?, + taxScope = CASE WHEN ? THEN ? ELSE taxScope END, + wealthScope = CASE WHEN ? THEN ? ELSE wealthScope END WHERE id=?`, + [naam, toSearchName(naam), a.kind, a.isOwnCustody ? 1 : 0, a.scanEnabled ? 1 : 0, categorie, + scopeGiven ? 1 : 0, scope, wealthGiven ? 1 : 0, wealth, id])); + await db.persist(); +} +export async function deleteAccount(id) { + const used = db.one(`SELECT (SELECT COUNT(*) FROM Event WHERE fromAccountId=?1 OR toAccountId=?1) + + (SELECT COUNT(*) FROM Valuation WHERE accountId =?1) + + (SELECT COUNT(*) FROM ScanLog WHERE accountId=?1) + + (SELECT COUNT(*) FROM ReconcileProposal WHERE accountId=?1) AS n`, [id]).n; + if (used > 0) fail('accounts.inUse', { n: used }, `Account is used by ${used} record(s); remove or relink those first.`); + for (const s of db.all(`SELECT id FROM WalletSource WHERE accountId=? AND kind='nwc'`, [id])) { + await purgeWalletSourceSecret(s.id); + } + trackDelete('Account', id, () => db.run(`DELETE FROM Account WHERE id=?`, [id])); + await db.persist(); +} + +export function deactivateCheck(accountId) { + const account = db.one(`SELECT * FROM Account WHERE id = ?`, [accountId]); + if (!account) return { found: false, findings: [] }; + + const n = (sql, params = [accountId]) => db.one(sql, params).n; + const findings = []; + + if (account.scanEnabled) { + findings.push({ kind: 'scanEnabled', count: 1 }); + } + const sources = n(`SELECT COUNT(*) AS n FROM WalletSource WHERE accountId = ?`); + if (sources) findings.push({ kind: 'walletSources', count: sources }); + + const vandaag = new Date().toISOString().slice(0, 10); + const schedules = n( + `SELECT COUNT(*) AS n FROM InterestSchedule + WHERE accountId = ? AND (endsOn IS NULL OR endsOn >= ?)`, [accountId, vandaag]); + if (schedules) findings.push({ kind: 'openSchedules', count: schedules }); + + const proposals = n(`SELECT COUNT(*) AS n FROM ReconcileProposal WHERE accountId = ? AND status = 'pending'`); + if (proposals) findings.push({ kind: 'pendingProposals', count: proposals }); + + const balances = accountPositions(accountId); + if (balances.length) findings.push({ kind: 'balance', count: balances.length, balances }); + + const events = n(`SELECT COUNT(*) AS n FROM Event WHERE fromAccountId = ?1 OR toAccountId = ?1`); + if (events) findings.push({ kind: 'events', count: events, informational: true }); + + return { found: true, isActive: !!account.isActive, name: account.name, findings }; +} + +export async function deactivateAccount(id, closedAt = nowUtcIso()) { + const account = db.one(`SELECT isActive FROM Account WHERE id = ?`, [id]); + if (!account) fail('accounts.unknownAccount', { id }, `Account #${id} does not exist.`); + trackUpdate('Account', id, () => + db.run(`UPDATE Account SET isActive = 0, closedAt = ?, scanEnabled = 0 WHERE id = ?`, [closedAt, id])); + await db.persist(); +} + +export async function reactivateAccount(id) { + const account = db.one(`SELECT isActive FROM Account WHERE id = ?`, [id]); + if (!account) fail('accounts.unknownAccount', { id }, `Account #${id} does not exist.`); + trackUpdate('Account', id, () => + db.run(`UPDATE Account SET isActive = 1, closedAt = NULL WHERE id = ?`, [id])); + await db.persist(); +} diff --git a/server/data/ai-audit.js b/server/data/ai-audit.js new file mode 100644 index 0000000..9a05c62 --- /dev/null +++ b/server/data/ai-audit.js @@ -0,0 +1,89 @@ +// Generated, do not edit. sha256:9505c124878c8b5af0571c1b4757d6fc332a4d6d342508d9c8ff717ce279c7db +import * as db from '../db.js'; +import { CONFIG, ENUMS } from '../../shared/config.js'; +import { assertEnum, nowIso } from './_shared.js'; +import { emitChange } from './_events.js'; +import { limitText } from '../ai/limits.js'; + +function uitRij(r) { + if (!r) return r; + let velden = null; + if (r.fieldsShared != null) { + try { velden = JSON.parse(r.fieldsShared); } catch { velden = []; } + if (!Array.isArray(velden)) velden = []; + } + return { ...r, fieldsShared: velden }; +} + +export async function logAiToolCall(entry) { + assertEnum('outcome', ENUMS.AI_CALL_OUTCOME, entry.outcome); + if (!entry.sessionId) throw new Error('An audit entry without a session cannot be traced.'); + if (!entry.tool) throw new Error('An audit entry without a tool name cannot be traced.'); + const momenten = entry.messages == null ? [] : [...entry.messages]; + for (const m of momenten) assertEnum('kind', ENUMS.AI_MESSAGE_KIND, m?.kind); + const velden = entry.fieldsShared == null ? null : JSON.stringify([...entry.fieldsShared]); + db.run(`INSERT INTO AiToolCall + (sessionId, client, tool, arguments, fieldsShared, charactersOut, outcome, detail, occurredAt, serverVersion) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + [entry.sessionId, entry.client ?? null, entry.tool, + entry.arguments != null ? JSON.stringify(entry.arguments) : null, + velden, entry.charactersOut ?? null, entry.outcome, entry.detail ?? null, + entry.occurredAt ?? nowIso(), entry.serverVersion ?? null]); + const id = db.lastId(); + for (const m of momenten) { + const { text } = limitText(String(m.text ?? ''), CONFIG.AI.LIMITS.MAX_LOG_CHARS); + db.run(`INSERT INTO AiToolCallMessage (callId, kind, text, occurredAt) VALUES (?,?,?,?)`, + [id, m.kind, text, m.occurredAt ?? nowIso()]); + } + await db.persist(); + emitChange('ai-activity-changed'); + return id; +} + +export function countAiToolCallsSince(sinds) { + const vanaf = Number.isInteger(sinds) && sinds >= 0 ? sinds : 0; + const r = db.one(`SELECT COUNT(*) AS n, MAX(id) AS laatste FROM AiToolCall WHERE id > ?`, [vanaf]); + const alles = db.one(`SELECT MAX(id) AS laatste FROM AiToolCall`); + return { + count: r ? r.n : 0, + newestId: (alles && alles.laatste != null) ? alles.laatste : 0, + }; +} + +export function aiToolCallMessages(callId) { + const n = Number(callId); + if (!Number.isInteger(n) || n <= 0) return []; + return db.all(`SELECT id, callId, kind, text, occurredAt FROM AiToolCallMessage + WHERE callId = ? ORDER BY id`, [n]); +} + +export function aiSessions(uren = 24) { + const grens = new Date(Date.now() - Math.max(1, Number(uren) || 24) * 3600_000).toISOString(); + return db.all( + `SELECT sessionId, client, serverVersion, + COUNT(*) AS calls, MIN(occurredAt) AS firstAt, MAX(occurredAt) AS lastAt + FROM AiToolCall + WHERE occurredAt >= ? + GROUP BY sessionId, client, serverVersion + ORDER BY lastAt DESC`, [grens]) + .map(r => ({ ...r, zonderVersie: r.serverVersion == null })); +} + +export function listAiToolCalls(limit = CONFIG.AI.AUDIT_PAGE_SIZE) { + const n = Math.min(Math.max(Number(limit) || CONFIG.AI.AUDIT_PAGE_SIZE, 1), 1000); + return db.all(`SELECT * FROM AiToolCall ORDER BY occurredAt DESC, id DESC LIMIT ?`, [n]).map(uitRij); +} + +export function countAiToolCalls() { + const totaal = db.one(`SELECT COUNT(*) AS n FROM AiToolCall`); + const perUitkomst = db.all(`SELECT outcome, COUNT(*) AS n FROM AiToolCall GROUP BY outcome`); + const uitkomsten = {}; + for (const k of ENUMS.AI_CALL_OUTCOME) uitkomsten[k] = 0; + for (const r of perUitkomst) uitkomsten[r.outcome] = r.n; + return { total: totaal ? totaal.n : 0, byOutcome: uitkomsten }; +} + +export function lastAiToolCallAt() { + const r = db.one(`SELECT occurredAt FROM AiToolCall ORDER BY occurredAt DESC, id DESC LIMIT 1`); + return r ? r.occurredAt : null; +} diff --git a/server/data/ai-proposals.js b/server/data/ai-proposals.js new file mode 100644 index 0000000..6a00bde --- /dev/null +++ b/server/data/ai-proposals.js @@ -0,0 +1,377 @@ +// Generated, do not edit. sha256:7c7ced19083e83a4f75c27b5aa8f303c04b02f88225373c3587d1d7f90e78248 +import * as db from '../db.js'; +import { CONFIG, ENUMS, PREFERENCES, SOURCE_CAPABILITIES, SOURCE_ENDPOINTS } from '../../shared/config.js'; +import { placePriceSourceFirst, clearPriceSourceCell } from './price-source.js'; +import { setPref } from './preferences.js'; +import { emitChange } from './_events.js'; +import { assertEnum, assertName, assertTimestamp, nowIso } from './_shared.js'; +import { fail, isAppError } from '../../shared/app-error.js'; +import { getPriceAt, upsertPriceHistory } from './price-history.js'; +import { createAccount, deleteAccount, listAccounts } from './accounts.js'; +import { createEvent, eventsCountByAccount } from './events.js'; +import { createValuation, listValuations } from './valuations.js'; +import { positionAt } from './positions.js'; +import { assertValidName as assertValidPortfolioName } from '../portfolios.mjs'; +import { createPortfolio } from '../portfolios-admin.mjs'; +import { isAiActive } from '../ai/gate.js'; + +const signaleerVoorstellen = () => emitChange('ai-proposals-changed'); + +const PROPOSAL_JOIN = ` + SELECT AiProposal.*, Asset.symbol AS asset_symbol, Asset.name AS asset_name, + Asset.unit AS asset_unit, Asset.subunit AS asset_subunit, + Asset.decimals AS asset_decimals, Asset.displayUnit AS asset_display_unit, + Asset.kind AS asset_kind, + fromAccount.name AS from_name, toAccount.name AS to_name, + targetAccount.name AS account_name, targetAccount.kind AS account_kind, + Provider.name AS provider_name + FROM AiProposal + LEFT JOIN Asset ON Asset.id = AiProposal.assetId + LEFT JOIN Provider ON AiProposal.kind = 'source_choice' AND Provider.key = AiProposal.key + LEFT JOIN Account AS fromAccount ON fromAccount.id = AiProposal.fromAccountId + LEFT JOIN Account AS toAccount ON toAccount.id = AiProposal.toAccountId + LEFT JOIN Account AS targetAccount ON targetAccount.id = AiProposal.accountId`; + +const RICHTING = Object.freeze({ + buy: Object.freeze({ van: false, naar: true }), + sell: Object.freeze({ van: true, naar: false }), + transfer: Object.freeze({ van: true, naar: true }), + interest: Object.freeze({ van: false, naar: true }), +}); + +function bronVoorKeuze(assetId, capability, providerKey) { + const asset = db.one(`SELECT symbol FROM Asset WHERE id = ?`, [assetId]); + if (!asset) throw new Error(`Asset ${assetId} does not exist.`); + const bron = db.one(`SELECT id, isSystem FROM Provider WHERE key = ?`, [String(providerKey ?? '')]); + if (!bron || bron.isSystem) throw new Error(`Unknown price source '${providerKey}'.`); + if (!db.one(`SELECT 1 AS x FROM ProviderCoverage WHERE providerId = ? AND symbol = ?`, [bron.id, asset.symbol])) { + throw new Error(`Price source '${providerKey}' does not cover ${asset.symbol}.`); + } + const eindpunten = SOURCE_ENDPOINTS[capability]; + if (!db.one(`SELECT 1 AS x FROM ProviderEndpoint WHERE providerId = ? AND capability IN (${eindpunten.map(() => '?').join(',')})`, + [bron.id, ...eindpunten])) { + throw new Error(`Price source '${providerKey}' delivers no ${capability}.`); + } + return bron.id; +} + +const CONTROLE = Object.freeze({ + price_point: (v) => { + if (v.assetId == null) throw new Error('A price-point proposal needs an asset.'); + if (v.fiatPrice == null) throw new Error('A price-point proposal needs a price in euro.'); + assertTimestamp('observedAt', v.observedAt); + }, + account: (v) => { + assertName('Accountnaam', v.name); + assertEnum('accountKind', ENUMS.ACCOUNT_KIND, v.accountKind); + if (v.isOwnCustody == null) throw new Error('An account proposal has to say whether it is own custody.'); + }, + account_delete: (v) => { + if (v.accountId == null) throw new Error('A deletion proposal has to say WHICH account.'); + if (!listAccounts().some(a => a.id === v.accountId)) { + throw new Error(`Account ${v.accountId} no longer exists.`); + } + }, + portfolio: (v) => { + assertName('Portefeuillenaam', v.name); + assertValidPortfolioName(v.name); + assertEnum('currency', CONFIG.PORTFOLIO.BASE_CURRENCIES, v.currency); + }, + preference: (v) => { + if (!CONFIG.AI.PROPOSABLE_PREF_SCOPES.includes(v.scope)) { + throw new Error(`A proposal may not set the preference scope '${v.scope}'.`); + } + if (!PREFERENCES[v.scope]?.[v.key]) { + throw new Error(`Unknown preference: ${v.scope}.${v.key}.`); + } + try { JSON.parse(v.value); } + catch { throw new Error(`The value of ${v.scope}.${v.key} is not valid JSON.`); } + }, + source_choice: (v) => { + if (v.assetId == null) throw new Error('A source-choice proposal needs an asset.'); + assertEnum('scope', SOURCE_CAPABILITIES, v.scope); + assertEnum('value', ENUMS.SOURCE_CHOICE_ACTION, v.value); + bronVoorKeuze(v.assetId, v.scope, v.key); + }, + valuation: (v) => { + if (v.assetId == null) throw new Error('A balance proposal needs an asset.'); + if (v.accountId == null) throw new Error('A balance proposal needs an account.'); + if (!Number.isInteger(v.balance) || v.balance < 0) { + throw new Error(`A balance proposal needs a balance >= 0 in the smallest unit (got ${v.balance}).`); + } + assertTimestamp('observedAt', v.observedAt); + }, + event: (v) => { + if (v.assetId == null) throw new Error('An event proposal needs an asset.'); + assertEnum('eventType', ENUMS.LEDGER_EVENT_TYPE, v.eventType); + if (!Number.isInteger(v.amount) || v.amount <= 0) { + throw new Error(`An event proposal needs an amount > 0 in the smallest unit (got ${v.amount}).`); + } + assertTimestamp('observedAt', v.observedAt); + const richting = RICHTING[v.eventType]; + if (richting.van && v.fromAccountId == null) { + throw new Error(`An event of kind '${v.eventType}' needs an origin account.`); + } + if (richting.naar && v.toAccountId == null) { + throw new Error(`An event of kind '${v.eventType}' needs a destination account.`); + } + if (!richting.van && v.fromAccountId != null) { + throw new Error(`An event of kind '${v.eventType}' has no origin account.`); + } + if (!richting.naar && v.toAccountId != null) { + throw new Error(`An event of kind '${v.eventType}' has no destination account.`); + } + if ((v.eventType === 'buy' || v.eventType === 'sell') && v.fiatPrice == null) { + throw new Error(`An event of kind '${v.eventType}' needs a price in euro.`); + } + }, +}); + +export async function createAiProposal({ + kind, assetId = null, accountId = null, fromAccountId = null, toAccountId = null, + name = null, accountKind = null, isOwnCustody = null, + eventType = null, amount = null, fiatPrice = null, fiatFee = null, unitFee = null, balance = null, + observedAt = null, scope = null, key = null, value = null, currency = null, note = null, +}) { + assertEnum('kind', ENUMS.AI_PROPOSAL_KIND, kind); + if (balance != null && kind !== 'valuation') { + throw new Error(`A balance belongs to a valuation; a proposal of kind '${kind}' has none.`); + } + if ((fiatFee != null || unitFee != null) && kind !== 'event') { + throw new Error(`Fees belong to an event; a proposal of kind '${kind}' has none.`); + } + if (unitFee != null && eventType !== 'sell' && eventType !== 'transfer') { + throw new Error(`A fee in units belongs to a sell or a transfer; an event of kind '${eventType}' has none.`); + } + if ((scope != null || key != null || value != null) && kind !== 'preference' && kind !== 'source_choice') { + throw new Error(`A preference belongs to a preference proposal; kind '${kind}' has none.`); + } + if (currency != null && kind !== 'portfolio') { + throw new Error(`A currency belongs to a portfolio proposal; kind '${kind}' has none.`); + } + const eigenBeheer = isOwnCustody == null ? null : (isOwnCustody ? 1 : 0); + CONTROLE[kind]({ assetId, accountId, fromAccountId, toAccountId, name, accountKind, isOwnCustody: eigenBeheer, eventType, amount, fiatPrice, balance, observedAt, scope, key, value, currency }); + + db.run(`INSERT INTO AiProposal + (assetId, accountId, fromAccountId, toAccountId, kind, name, accountKind, isOwnCustody, + eventType, amount, fiatPrice, fiatFee, unitFee, balance, observedAt, scope, key, value, currency, note, status, createdAt) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'pending',?)`, + [assetId, accountId, fromAccountId, toAccountId, kind, name, accountKind, eigenBeheer, + eventType, amount, fiatPrice, fiatFee, unitFee, balance, observedAt, scope, key, value, currency, note, nowIso()]); + const id = db.lastId(); + signaleerVoorstellen(); + await db.persist(); + return id; +} + +export function listPendingAiProposals() { + if (!isAiActive()) return []; + return db.all(`${PROPOSAL_JOIN} WHERE AiProposal.status = 'pending' ORDER BY AiProposal.id ASC`) + .map(verrijkBalans); +} + +function verrijkBalans(rij) { + if (rij.kind !== 'valuation') return rij; + return { + ...rij, + ledger_balance: positionAt(rij.accountId, rij.assetId, rij.observedAt), + events_before: eventsCountByAccount(rij.accountId, rij.assetId, rij.observedAt), + }; +} + +export function listAiProposals(limit = CONFIG.AI.PROPOSAL_PAGE_SIZE) { + if (!isAiActive()) return []; + const n = Number.isInteger(limit) && limit > 0 ? limit : CONFIG.AI.PROPOSAL_PAGE_SIZE; + return db.all(`${PROPOSAL_JOIN} ORDER BY AiProposal.id DESC LIMIT ?`, [n]); +} + +export function aiProposalsStatus() { + return { enabled: isAiActive(), pending: listPendingAiProposals() }; +} + +export function getAiProposal(id) { + return db.one(`${PROPOSAL_JOIN} WHERE AiProposal.id = ?`, [id]); +} + +const HERKOMST_VAN = Object.freeze({ + event: 'createdEventId', + account: 'createdAccountId', + valuation: 'createdValuationId', +}); + +export function rowOrigin(soort, rijId) { + const kolom = HERKOMST_VAN[soort]; + if (!kolom) throw new Error(`Unknown kind for an origin query: '${soort}'.`); + if (!Number.isInteger(Number(rijId))) return null; + const r = db.one( + `SELECT id, kind, note, createdAt, resolvedAt FROM AiProposal + WHERE ${kolom} = ? AND status = 'accepted' ORDER BY id DESC LIMIT 1`, [Number(rijId)]); + if (!r) return null; + return { + proposalId: r.id, kind: r.kind, note: r.note, + createdAt: r.createdAt, resolvedAt: r.resolvedAt, + }; +} + +const UITVOEREN = Object.freeze({ + price_point: async (voorstel) => { + const seconden = Math.floor(Date.parse(voorstel.observedAt) / 1000); + if (!Number.isFinite(seconden)) fail('aiProposals.unreadableMoment', { id: voorstel.id, moment: voorstel.observedAt }, `Unreadable moment in proposal ${voorstel.id}: ${voorstel.observedAt}.`); + await upsertPriceHistory(voorstel.assetId, [{ time: seconden, price: voorstel.fiatPrice }], 'manual', 'day'); + }, + + account: async (voorstel) => { + const id = await createAccount({ + name: voorstel.name, + kind: voorstel.accountKind, + isOwnCustody: !!voorstel.isOwnCustody, + scanEnabled: false, + taxCategory: null, + }); + return { kolom: 'createdAccountId', id }; + }, + + account_delete: async (voorstel) => { + await deleteAccount(voorstel.accountId); + }, + + event: async (voorstel) => { + const id = await createEvent({ + assetId: voorstel.assetId, + type: voorstel.eventType, + occurredAt: voorstel.observedAt, + amount: voorstel.amount, + fiatPrice: voorstel.fiatPrice ?? null, + fiatFee: voorstel.fiatFee ?? null, + unitFee: voorstel.unitFee ?? null, + fromAccountId: voorstel.fromAccountId, + toAccountId: voorstel.toAccountId, + source: 'manual', + note: voorstel.note, + }); + return { kolom: 'createdEventId', id }; + }, + + portfolio: async (voorstel) => { + await createPortfolio(voorstel.name, { baseCurrency: voorstel.currency }); + }, + + preference: async (voorstel) => { + if (!CONFIG.AI.PROPOSABLE_PREF_SCOPES.includes(voorstel.scope)) { + fail('aiProposals.prefScopeNotAllowed', { scope: voorstel.scope }, + `This proposal wants to set the preference scope '${voorstel.scope}', which is not allowed.`); + } + await setPref(voorstel.scope, voorstel.key, JSON.parse(voorstel.value)); + }, + + source_choice: async (voorstel) => { + const providerId = bronVoorKeuze(voorstel.assetId, voorstel.scope, voorstel.key); + if (voorstel.value === 'first') await placePriceSourceFirst(voorstel.assetId, voorstel.scope, providerId); + else await clearPriceSourceCell(voorstel.assetId, voorstel.scope, providerId); + }, + + valuation: async (voorstel) => { + const dag = voorstel.observedAt.slice(0, 10); + const dubbel = listValuations().some(v => + v.assetId === voorstel.assetId && v.accountId === voorstel.accountId && v.valuedOn === dag); + if (dubbel) { + fail('aiProposals.duplicateValuation', { dag }, `A balance for this account and asset on ${dag} already exists.`); + } + const seconden = Math.floor(Date.parse(voorstel.observedAt) / 1000); + if (!Number.isFinite(seconden)) fail('aiProposals.unreadableMoment', { id: voorstel.id, moment: voorstel.observedAt }, `Unreadable moment in proposal ${voorstel.id}: ${voorstel.observedAt}.`); + const punt = getPriceAt(voorstel.assetId, seconden); + if (!punt || punt.fiatPrice == null) { + fail('aiProposals.noPriceForBalance', { dag }, + `No price in the history for ${dag}, and a balance cannot do without one.`); + } + const id = await createValuation({ + assetId: voorstel.assetId, + accountId: voorstel.accountId, + valuedOn: dag, + balance: voorstel.balance, + fiatRate: punt.fiatPrice, + rateAt: voorstel.observedAt, + source: 'manual', + }); + return { kolom: 'createdValuationId', id }; + }, +}); + +const HERKOMST_KOLOMMEN = Object.freeze(['createdEventId', 'createdAccountId', 'createdValuationId']); + +const detailVan = (e) => (isAppError(e) + ? JSON.stringify({ key: e.key, params: e.params || undefined }) + : ((e && e.message) || String(e))); + +export async function acceptAiProposal(id) { + const voorstel = getAiProposal(id); + if (!voorstel) fail('aiProposals.unknownProposal', { id }, `Proposal ${id} no longer exists.`); + if (voorstel.status !== 'pending') { + fail(`aiProposals.handled_${voorstel.status}`, { id }, + `Proposal ${id} has already been handled (${voorstel.status}).`); + } + const uitvoeren = UITVOEREN[voorstel.kind]; + if (!uitvoeren) throw new Error(`No execution known for proposal kind '${voorstel.kind}'.`); + + let herkomst = null; + try { + herkomst = await uitvoeren(voorstel); + } catch (e) { + db.run(`UPDATE AiProposal SET detail = ? WHERE id = ?`, [detailVan(e), id]); + signaleerVoorstellen(); + await db.persist(); + throw e; + } + + const kolom = herkomst && HERKOMST_KOLOMMEN.includes(herkomst.kolom) ? herkomst.kolom : null; + if (kolom && Number.isInteger(Number(herkomst.id))) { + db.run(`UPDATE AiProposal SET status = 'accepted', detail = NULL, resolvedAt = ?, ${kolom} = ? + WHERE id = ?`, [nowIso(), Number(herkomst.id), id]); + } else { + db.run(`UPDATE AiProposal SET status = 'accepted', detail = NULL, resolvedAt = ? WHERE id = ?`, + [nowIso(), id]); + } + signaleerVoorstellen(); + await db.persist(); + return id; +} + +export async function acceptAiProposals(ids) { + const uitkomsten = []; + for (const id of ids) { + try { + await acceptAiProposal(id); + uitkomsten.push({ id, ok: true, reden: null }); + } catch (e) { + uitkomsten.push({ id, ok: false, reden: e.message, key: e.key || null, params: e.params || null }); + } + } + return uitkomsten; +} + +export async function rejectAiProposal(id, reden = null) { + const voorstel = getAiProposal(id); + if (!voorstel) fail('aiProposals.unknownProposal', { id }, `Proposal ${id} no longer exists.`); + if (voorstel.status !== 'pending') { + fail(`aiProposals.handled_${voorstel.status}`, { id }, + `Proposal ${id} has already been handled (${voorstel.status}).`); + } + db.run(`UPDATE AiProposal SET status = 'rejected', detail = ?, resolvedAt = ? WHERE id = ?`, + [reden, nowIso(), id]); + signaleerVoorstellen(); + await db.persist(); + return id; +} + +export async function rejectAiProposals(ids, reden = null) { + const uitkomsten = []; + for (const id of ids) { + try { + await rejectAiProposal(id, reden); + uitkomsten.push({ id, ok: true, reden: null }); + } catch (e) { + uitkomsten.push({ id, ok: false, reden: e.message, key: e.key || null, params: e.params || null }); + } + } + return uitkomsten; +} diff --git a/server/data/api-metrics.js b/server/data/api-metrics.js new file mode 100644 index 0000000..7bc8ed8 --- /dev/null +++ b/server/data/api-metrics.js @@ -0,0 +1,56 @@ +// Generated, do not edit. sha256:227dda965713b9b51597f6f128f59f6569f0a5729dc340b98c56d958390f053d +import * as db from '../db.js'; + +export async function recordApiCall({ providerId, capability, ok, latency_ms }) { + if (!Number.isInteger(providerId)) return; + const bucket = new Date().toISOString().slice(0, 16); + const lat = Number.isFinite(latency_ms) ? Math.max(0, Math.round(latency_ms)) : 0; + db.run(`INSERT INTO ApiCallMetric (providerId, capability, bucketMinute, okCount, failCount, sumLatencyMs, maxLatencyMs) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT(providerId, capability, bucketMinute) DO UPDATE SET + okCount = okCount + excluded.okCount, + failCount = failCount + excluded.failCount, + sumLatencyMs = sumLatencyMs + excluded.sumLatencyMs, + maxLatencyMs = MAX(maxLatencyMs, excluded.maxLatencyMs)`, + [providerId, capability, bucket, ok ? 1 : 0, ok ? 0 : 1, lat, lat]); + await db.persist(); +} + +export function apiMetricsTotals(providerId, sinceBucket) { + const r = db.one(`SELECT COALESCE(SUM(okCount),0) AS ok, COALESCE(SUM(failCount),0) AS fail, + COALESCE(SUM(sumLatencyMs),0) AS sum_lat, COALESCE(MAX(maxLatencyMs),0) AS max_lat + FROM ApiCallMetric WHERE providerId = ? AND bucketMinute >= ?`, [providerId, sinceBucket]); + const calls = (r.ok || 0) + (r.fail || 0); + return { ok: r.ok || 0, fail: r.fail || 0, calls, + avg_latency_ms: calls ? Math.round(r.sum_lat / calls) : 0, maxLatencyMs: r.max_lat || 0 }; +} + +export function apiMetricsGlobal(sinceBucket) { + const r = db.one(`SELECT COALESCE(SUM(okCount),0) AS ok, COALESCE(SUM(failCount),0) AS fail, + COALESCE(SUM(sumLatencyMs),0) AS sum_lat, COALESCE(MAX(maxLatencyMs),0) AS max_lat + FROM ApiCallMetric WHERE bucketMinute >= ?`, [sinceBucket]); + const calls = (r.ok || 0) + (r.fail || 0); + return { ok: r.ok || 0, fail: r.fail || 0, calls, + avg_latency_ms: calls ? Math.round(r.sum_lat / calls) : 0, maxLatencyMs: r.max_lat || 0 }; +} + +export function apiCallsByProvider(sinceBucket) { + return db.all(`SELECT providerId, SUM(okCount) + SUM(failCount) AS calls + FROM ApiCallMetric WHERE bucketMinute >= ? + GROUP BY providerId`, [sinceBucket]); +} + +export function apiMetricsByCapability(providerId, sinceBucket) { + return db.all(`SELECT capability, SUM(okCount) AS ok, SUM(failCount) AS fail, + SUM(sumLatencyMs) AS sum_lat, MAX(maxLatencyMs) AS max_lat + FROM ApiCallMetric WHERE providerId = ? AND bucketMinute >= ? + GROUP BY capability ORDER BY capability`, [providerId, sinceBucket]); +} + +export function apiMetricsSeries(providerId, sinceBucket, groupLen) { + return db.all(`SELECT substr(bucketMinute,1,?) AS b, + COALESCE(SUM(okCount),0) AS ok, COALESCE(SUM(failCount),0) AS fail, + COALESCE(MAX(maxLatencyMs),0) AS max_lat + FROM ApiCallMetric WHERE providerId = ? AND bucketMinute >= ? + GROUP BY b ORDER BY b`, [groupLen, providerId, sinceBucket]); +} diff --git a/server/data/asset-notes.js b/server/data/asset-notes.js new file mode 100644 index 0000000..12b0137 --- /dev/null +++ b/server/data/asset-notes.js @@ -0,0 +1,19 @@ +// Generated, do not edit. sha256:2a06cfff8bd22ab273abad66a7bff34ee21d692bd6b4215d67a43a86980cd81f +import * as db from '../db.js'; + +export function getAssetNote(assetId) { + return db.one(`SELECT body, createdAt FROM AssetNote WHERE assetId = ?`, [assetId]) || null; +} + +export async function setAssetNote(assetId, body) { + if (!db.one(`SELECT id FROM Asset WHERE id = ?`, [assetId])) throw new Error(`Asset ${assetId} does not exist.`); + const tekst = typeof body === 'string' ? body : ''; + if (!tekst.trim()) { + db.run(`DELETE FROM AssetNote WHERE assetId = ?`, [assetId]); + } else { + db.run(`INSERT INTO AssetNote (assetId, body, createdAt) VALUES (?, ?, ?) + ON CONFLICT(assetId) DO UPDATE SET body = excluded.body`, [assetId, tekst, new Date().toISOString()]); + } + await db.persist(); + return getAssetNote(assetId); +} diff --git a/server/data/assets.js b/server/data/assets.js new file mode 100644 index 0000000..42416d6 --- /dev/null +++ b/server/data/assets.js @@ -0,0 +1,84 @@ +// Generated, do not edit. sha256:ae0d48181a52fd75f129b890a9ec73664ed0e72770dbab20484d6647df3ef532 +import * as db from '../db.js'; +import { toSearchName } from '../../shared/search-name.js'; +import { CONFIG, ENUMS } from '../../shared/config.js'; +import { assertEnum, assertTaxCategory, assertTaxScope, RATE_ONLY_ASSET } from './_shared.js'; +import { capacityReachedFor } from './_limits.js'; +import { allowedAssets } from '../license.mjs'; +import { fail } from '../../shared/app-error.js'; +import { trackDelete, trackUpdate } from './change-log.js'; + +export function listAssets() { return db.all(`SELECT * FROM Asset ORDER BY symbol`); } + +export function listBookableAssets() { + return db.all(`SELECT * FROM Asset WHERE NOT ${RATE_ONLY_ASSET} ORDER BY symbol`); +} + +function assertDecimals(decimals) { + const leesbaar = typeof decimals === 'number' + || (typeof decimals === 'string' && decimals.trim() !== ''); + const n = leesbaar ? Number(decimals) : NaN; + if (!Number.isInteger(n) || n < 0 || n > CONFIG.MAX_ASSET_DECIMALS) { + fail('assets.decimalsRange', { max: CONFIG.MAX_ASSET_DECIMALS, waarde: decimals }, + `Decimals has to be a whole number from 0 to ${CONFIG.MAX_ASSET_DECIMALS} (got: '${decimals}'); ` + + 'above that an amount can no longer be read back.'); + } +} + +function assertBalanceCapture(value) { + if (value == null || value === '') return null; + const n = Number(value); + if (n !== 0 && n !== 1) throw new Error(`Invalid balanceCapture: '${value}'. Allowed: empty, 0, 1.`); + return n; +} + +export async function createAsset({ symbol, name, kind, unit, subunit, decimals, displayUnit, taxCategory, taxScope, balanceCapture }) { + const allowed = allowedAssets(); + if (allowed && kind !== 'fiat' && !allowed.includes(symbol)) { + fail('assets.freeOnly', { list: allowed.join(', ') }, + `The free version holds ${allowed.join(', ')} and currencies only. A licence lifts that.`); + } + const max = capacityReachedFor('assets'); + if (max !== null) { + fail('assets.maxFree', { max }, + `The free version holds ${max} asset(s). A licence lifts that; nothing is removed in the meantime.`); + } + assertEnum('kind', ENUMS.ASSET_KIND, kind); + assertEnum('displayUnit', ENUMS.ASSET_DISPLAY_UNIT, displayUnit); + assertDecimals(decimals); + const categorie = assertTaxCategory(taxCategory); + const capture = assertBalanceCapture(balanceCapture); + const scope = assertTaxScope(taxScope); + db.run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit, taxCategory, taxScope, balanceCapture) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + [symbol, name, toSearchName(name), kind, unit, subunit ?? null, decimals, displayUnit, categorie, scope, capture]); + const id = db.lastId(); + db.seedPriceSource(id, 'confirmed'); + await db.persist(); + return id; +} +export async function updateAsset(id, a) { + assertEnum('kind', ENUMS.ASSET_KIND, a.kind); + assertEnum('displayUnit', ENUMS.ASSET_DISPLAY_UNIT, a.displayUnit); + assertDecimals(a.decimals); + const categorie = assertTaxCategory(a.taxCategory); + const given = Object.hasOwn(a, 'balanceCapture'); + const capture = given ? assertBalanceCapture(a.balanceCapture) : null; + const scopeGiven = Object.hasOwn(a, 'taxScope'); + const scope = scopeGiven ? assertTaxScope(a.taxScope) : null; + trackUpdate('Asset', id, () => + db.run(`UPDATE Asset SET symbol=?, name=?, searchName=?, kind=?, unit=?, subunit=?, decimals=?, displayUnit=?, taxCategory=?, + taxScope = CASE WHEN ? THEN ? ELSE taxScope END, + balanceCapture = CASE WHEN ? THEN ? ELSE balanceCapture END WHERE id=?`, + [a.symbol, a.name, toSearchName(a.name), a.kind, a.unit, a.subunit ?? null, a.decimals, a.displayUnit, categorie, + scopeGiven ? 1 : 0, scope, given ? 1 : 0, capture, id])); + await db.persist(); +} +export async function deleteAsset(id) { + const used = db.one(`SELECT (SELECT COUNT(*) FROM Event WHERE assetId=?1) + + (SELECT COUNT(*) FROM Valuation WHERE assetId=?1) + + (SELECT COUNT(*) FROM ReconcileProposal WHERE assetId=?1) AS n`, [id]).n; + if (used > 0) fail('assets.inUse', { n: used }, `The asset is used by ${used} record(s).`); + trackDelete('Asset', id, () => db.run(`DELETE FROM Asset WHERE id=?`, [id])); + await db.persist(); +} diff --git a/server/data/bus.js b/server/data/bus.js new file mode 100644 index 0000000..88cf3bb --- /dev/null +++ b/server/data/bus.js @@ -0,0 +1,38 @@ +// Generated, do not edit. sha256:8bd40013f30173c4395fe482f251f8d822b2781e68ecf02d880e6748f3e5817a +import * as db from '../db.js'; +import { CONFIG } from '../../shared/config.js'; +import { nowIso } from './_shared.js'; +import { takeChanges } from './_events.js'; + +export function flushChanges() { + const namen = takeChanges(); + if (!namen.length) return []; + const at = nowIso(); + for (const naam of namen) { + db.run(`INSERT INTO BusSignal (name, occurredAt) VALUES (?,?)`, [naam, at]); + } + pruneChanges(); + return namen; +} + +export function pruneChanges(uren = CONFIG.BUS.RETENTION_HOURS) { + const grens = new Date(Date.now() - Math.max(1, Number(uren) || CONFIG.BUS.RETENTION_HOURS) * 3600_000) + .toISOString(); + const r = db.one(`SELECT COUNT(*) AS n FROM BusSignal WHERE occurredAt < ?`, [grens]); + const aantal = r ? r.n : 0; + if (aantal) db.run(`DELETE FROM BusSignal WHERE occurredAt < ?`, [grens]); + return aantal; +} + +export function changesSince(cursor) { + const vanaf = Number.isInteger(cursor) && cursor >= 0 ? cursor : null; + const grens = db.one(`SELECT MIN(id) AS eerste, MAX(id) AS laatste FROM BusSignal`); + const laatste = grens && grens.laatste != null ? grens.laatste : (vanaf ?? 0); + if (vanaf === null) return { cursor: laatste, names: [], reset: false }; + + const eerste = grens ? grens.eerste : null; + if (eerste != null && vanaf < eerste - 1) return { cursor: laatste, names: [], reset: true }; + + const rijen = db.all(`SELECT DISTINCT name FROM BusSignal WHERE id > ? ORDER BY name`, [vanaf]); + return { cursor: laatste, names: rijen.map(r => r.name), reset: false }; +} diff --git a/server/data/change-log.js b/server/data/change-log.js new file mode 100644 index 0000000..0fe33ab --- /dev/null +++ b/server/data/change-log.js @@ -0,0 +1,99 @@ +// Generated, do not edit. sha256:0ea473364503a4f8549c8bea4bc98c04b7236550cd8e91a8cd91945e7728d55a +import * as db from '../db.js'; +import { CONFIG, ENUMS } from '../../shared/config.js'; +import { nowUtcIso } from '../../shared/time.js'; + +const NIET_MELDEN = new Set(['updatedAt', 'lastStatus', 'lastCheckedAt', 'balanceSats', 'lastScannedAt']); + +let herkomst = { origin: 'manual', originId: null }; + +export function withOrigin(origin, originId, fn) { + if (!ENUMS.CHANGE_ORIGIN.includes(origin)) throw new Error(`Unknown origin: ${origin}.`); + const vorige = herkomst; + herkomst = { origin, originId: originId ?? null }; + try { return fn(); } finally { herkomst = vorige; } +} + +export function currentOrigin() { return { ...herkomst }; } + +const meedoet = (tabel) => CONFIG.DB_HISTORY.TRACKED.includes(tabel); +const leesRij = (tabel, id) => db.one(`SELECT * FROM ${tabel} WHERE id = ?`, [id]) || null; + +function verschil(voor, na) { + if (!voor || !na) return null; + const uit = {}; + for (const veld of Object.keys(voor)) { + if (NIET_MELDEN.has(veld)) continue; + if (voor[veld] !== na[veld]) uit[veld] = [voor[veld], na[veld]]; + } + return Object.keys(uit).length ? uit : null; +} + +function schrijf(tabel, rowId, operation, changes) { + db.run(`INSERT INTO ChangeLog (tableName, rowId, operation, origin, originId, changes, occurredAt) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [tabel, rowId, operation, herkomst.origin, herkomst.originId, JSON.stringify(changes), nowUtcIso()]); +} + +export function trackUpdate(tabel, id, doe) { + if (!meedoet(tabel)) return doe(); + const voor = leesRij(tabel, id); + const uit = doe(); + const na = leesRij(tabel, id); + const changes = verschil(voor, na); + if (changes) { + db.run(`UPDATE ${tabel} SET updatedAt = ? WHERE id = ?`, [nowUtcIso(), id]); + schrijf(tabel, id, 'update', changes); + } + return uit; +} + +export function trackUpdateField(tabel, id, veld, doe) { + if (!meedoet(tabel)) return doe(); + const voor = leesRij(tabel, id); + const uit = doe(); + const na = leesRij(tabel, id); + if (voor && na && voor[veld] !== na[veld]) schrijf(tabel, id, 'update', { [veld]: [voor[veld], na[veld]] }); + return uit; +} + +export function trackDelete(tabel, id, doe) { + if (!meedoet(tabel)) return doe(); + const rij = leesRij(tabel, id); + const uit = doe(); + if (rij) schrijf(tabel, id, 'delete', rij); + return uit; +} + +export function trackUpdateMany(tabel, ids, doe) { + if (!meedoet(tabel) || !ids.length) return doe(); + const voor = new Map(ids.map(id => [id, leesRij(tabel, id)])); + const uit = doe(); + for (const id of ids) { + const changes = verschil(voor.get(id), leesRij(tabel, id)); + if (!changes) continue; + db.run(`UPDATE ${tabel} SET updatedAt = ? WHERE id = ?`, [nowUtcIso(), id]); + schrijf(tabel, id, 'update', changes); + } + return uit; +} + +export function trackUpdateWhere(tabel, waar, params, doe) { + if (!meedoet(tabel)) return doe(); + const ids = db.all(`SELECT id FROM ${tabel} WHERE ${waar}`, params).map(r => r.id); + return trackUpdateMany(tabel, ids, doe); +} + +export function trackDeleteWhere(tabel, waar, params, doe) { + if (!meedoet(tabel)) return doe(); + const rijen = db.all(`SELECT * FROM ${tabel} WHERE ${waar}`, params); + const uit = doe(); + for (const rij of rijen) schrijf(tabel, rij.id, 'delete', rij); + return uit; +} + +export function rowHistory(tabel, rowId) { + return db.all(`SELECT id, operation, origin, originId, changes, occurredAt + FROM ChangeLog WHERE tableName = ? AND rowId = ? ORDER BY id DESC`, [tabel, rowId]) + .map(r => ({ ...r, changes: JSON.parse(r.changes) })); +} diff --git a/server/data/commands.js b/server/data/commands.js new file mode 100644 index 0000000..100b42d --- /dev/null +++ b/server/data/commands.js @@ -0,0 +1,71 @@ +// Generated, do not edit. sha256:d8bebac302697ff7428ee4125057b7ffc589ad6f6f375379f00af756b5b865fc +import * as db from '../db.js'; +import { CONFIG, ENUMS } from '../../shared/config.js'; +import { assertEnum, nowIso } from './_shared.js'; +import { emitChange } from './_events.js'; + +function uitRij(r) { + if (!r) return r; + let lading = null; + if (r.payload != null) { + try { lading = JSON.parse(r.payload); } catch { lading = null; } + } + return { ...r, payload: lading }; +} + +export async function createCommand({ kind, payload = null, expiresAt = null }) { + assertEnum('kind', ENUMS.COMMAND_KIND, kind); + db.run(`INSERT INTO Command (kind, payload, state, expiresAt, createdAt) VALUES (?,?,?,?,?)`, + [kind, payload == null ? null : JSON.stringify(payload), 'open', expiresAt, nowIso()]); + const id = db.lastId(); + pruneCommands(); + await db.persist(); + emitChange('commands-changed'); + return id; +} + +export async function claimNextCommand(tabblad, soorten = null) { + if (!tabblad) throw new Error('A claim without a tab cannot be assigned.'); + const lijst = soorten == null ? null : [...soorten]; + if (lijst) { + if (!lijst.length) throw new Error('A claim on zero kinds can never yield anything.'); + for (const soort of lijst) assertEnum('kind', ENUMS.COMMAND_KIND, soort); + } + const nu = nowIso(); + db.run(`UPDATE Command SET state = 'expired', detail = ?, resolvedAt = ? + WHERE state = 'open' AND expiresAt IS NOT NULL AND expiresAt < ?`, + ['Niemand heeft deze opdracht op tijd opgepakt.', nu, nu]); + const filter = lijst ? `AND kind IN (${lijst.map(() => '?').join(',')})` : ''; + db.run(`UPDATE Command SET state = 'claimed', claimedBy = ?, claimedAt = ? + WHERE id = (SELECT id FROM Command WHERE state = 'open' ${filter} ORDER BY id LIMIT 1)`, + [String(tabblad), nu, ...(lijst || [])]); + const rij = db.one(`SELECT * FROM Command WHERE state = 'claimed' AND claimedBy = ? AND resolvedAt IS NULL + ORDER BY id DESC LIMIT 1`, [String(tabblad)]); + await db.persist(); + return uitRij(rij) || null; +} + +export async function resolveCommand(id, tabblad, uitkomst, detail = null) { + assertEnum('state', ENUMS.COMMAND_STATE, uitkomst); + if (uitkomst === 'open' || uitkomst === 'claimed') { + throw new Error(`'${uitkomst}' is not a conclusion; a command closes as done, rejected or expired.`); + } + db.run(`UPDATE Command SET state = ?, detail = ?, resolvedAt = ? + WHERE id = ? AND claimedBy = ? AND resolvedAt IS NULL`, + [uitkomst, detail, nowIso(), Number(id), String(tabblad)]); + await db.persist(); + emitChange('commands-changed'); +} + +export function getCommand(id) { + return uitRij(db.one(`SELECT * FROM Command WHERE id = ?`, [Number(id)])) || null; +} + +export function pruneCommands(uren = CONFIG.COMMANDS.RETENTION_HOURS) { + const grens = new Date(Date.now() - Math.max(1, Number(uren) || CONFIG.COMMANDS.RETENTION_HOURS) * 3600_000) + .toISOString(); + const r = db.one(`SELECT COUNT(*) AS n FROM Command WHERE resolvedAt IS NOT NULL AND resolvedAt < ?`, [grens]); + const aantal = r ? r.n : 0; + if (aantal) db.run(`DELETE FROM Command WHERE resolvedAt IS NOT NULL AND resolvedAt < ?`, [grens]); + return aantal; +} diff --git a/server/data/connections.js b/server/data/connections.js new file mode 100644 index 0000000..c9ae548 --- /dev/null +++ b/server/data/connections.js @@ -0,0 +1,127 @@ +// Generated, do not edit. sha256:ddd8b3b7b2f9aadd1a75eeaa934fd9a7d05a36cbd2f06e3a6cc5379098c70228 +import * as db from '../db.js'; +import { toSearchName } from '../../shared/search-name.js'; +import { ENUMS, CONNECTION_PROVIDER } from '../../shared/config.js'; +import { assertEnum, nowIso } from './_shared.js'; +import { emitProvidersChanged } from './providers.js'; +import { emitChange } from './_events.js'; +import { trackUpdate, trackUpdateWhere, trackUpdateField, trackDelete } from './change-log.js'; +import { fail } from '../../shared/app-error.js'; +import { hasFeature } from '../license.mjs'; + +function emitConnectionsChanged() { + emitChange('connections-changed'); +} + +function syncConnectionProvider(conn) { + const providerKey = CONNECTION_PROVIDER[conn.kind]; + if (!providerKey) return; + const p = db.one(`SELECT id, baseUrl FROM Provider WHERE key = ?`, [providerKey]); + if (!p || p.baseUrl === conn.url) return; + db.run(`UPDATE Provider SET baseUrl = ? WHERE id = ?`, [conn.url, p.id]); + emitProvidersChanged(); +} + +export function listConnections(kind = null) { + if (kind != null) assertEnum('kind', ENUMS.CONNECTION_KIND, kind); + return kind + ? db.all(`SELECT * FROM Connection WHERE kind = ? ORDER BY isSystem DESC, name`, [kind]) + : db.all(`SELECT * FROM Connection ORDER BY kind, isSystem DESC, name`); +} + +export function activeConnection(kind) { + assertEnum('kind', ENUMS.CONNECTION_KIND, kind); + if (!CONNECTION_PROVIDER[kind] && kind !== 'btc_explorer') { + throw new Error(`Kind '${kind}' is linked per account; use listConnections('${kind}').`); + } + const row = db.one(`SELECT * FROM Connection WHERE kind = ? AND isActive = 1`, [kind]); + if (!row) fail('connections.noActive', { kind }, `No active '${kind}' connection.`); + return row; +} + +export async function saveConnection({ id = null, kind, name, url }) { + assertEnum('kind', ENUMS.CONNECTION_KIND, kind); + if ((kind === 'lnd' || kind === 'nwc') && !hasFeature('lightning')) { + fail('connections.lightningPaid', null, + 'Lightning connections belong to the paid version.'); + } + if (kind === 'btc_explorer' && !hasFeature('ownMempool')) { + fail('connections.ownExplorerPaid', null, + 'Your own explorer belongs to the paid version; the standard one keeps working.'); + } + name = (name || '').trim(); + url = (url || '').trim().replace(/\/+$/, ''); + if (!name) fail('connections.nameRequired', null, 'A name is required.'); + if (!/^(https?|wss?):\/\//.test(url)) fail('connections.urlScheme', null, 'A URL starts with http(s):// or ws(s)://.'); + const now = nowIso(); + if (id == null) { + if (db.one(`SELECT 1 AS x FROM Connection WHERE kind = ? AND name = ?`, [kind, name])) { + fail('connections.nameInUse', { kind, naam: name }, `A '${kind}' connection named '${name}' already exists.`); + } + db.run(`INSERT INTO Connection (name, searchName, url, kind, isActive, isSystem, lastStatus, lastCheckedAt, createdAt, updatedAt) + VALUES (?,?,?,?,0,0,NULL,NULL,?,?)`, [name, toSearchName(name), url, kind, now, now]); + id = db.lastId(); + } else { + const cur = db.one(`SELECT * FROM Connection WHERE id = ?`, [id]); + if (!cur) fail('connections.unknownConnection', { id }, `Connection #${id} does not exist.`); + if (cur.isSystem) throw new Error('This profile is a system row (the guaranteed fallback) and cannot be edited.'); + if (cur.kind !== kind) throw new Error('The kind of an existing connection cannot be changed.'); + const urlChanged = cur.url !== url; + trackUpdate('Connection', id, () => + db.run(`UPDATE Connection SET name = ?, searchName = ?, url = ?, lastStatus = ${urlChanged ? 'NULL' : 'lastStatus'}, + lastCheckedAt = ${urlChanged ? 'NULL' : 'lastCheckedAt'}, updatedAt = ? WHERE id = ?`, + [name, toSearchName(name), url, now, id])); + if (urlChanged && cur.isActive) syncConnectionProvider({ kind: cur.kind, url }); + } + await db.persist(); + emitConnectionsChanged(); + return id; +} + +export async function setActiveConnection(id) { + const c = db.one(`SELECT * FROM Connection WHERE id = ?`, [id]); + if (!c) fail('connections.unknownConnection', { id }, `Connection #${id} does not exist.`); + const now = nowIso(); + if (c.kind === 'btc_explorer') { + trackUpdateWhere('Connection', `kind = ? AND isActive = 1 AND id != ?`, [c.kind, id], () => + db.run(`UPDATE Connection SET isActive = 0, updatedAt = ? WHERE kind = ? AND isActive = 1 AND id != ?`, [now, c.kind, id])); + } + trackUpdate('Connection', id, () => + db.run(`UPDATE Connection SET isActive = 1, updatedAt = ? WHERE id = ?`, [now, id])); + if (c.kind === 'btc_explorer') syncConnectionProvider({ kind: c.kind, url: c.url }); + await db.persist(); + emitConnectionsChanged(); +} + +export async function deleteConnection(id) { + const c = db.one(`SELECT * FROM Connection WHERE id = ?`, [id]); + if (!c) fail('connections.unknownConnection', { id }, `Connection #${id} does not exist.`); + if (c.isSystem) throw new Error('This profile is a system row (the guaranteed fallback) and cannot be deleted.'); + const linked = db.one(`SELECT COUNT(*) AS n FROM WalletSource WHERE connectionId = ?`, [id]); + if (linked && linked.n > 0) { + fail('connections.linkedToSources', { n: linked.n }, + `This connection is still attached to ${linked.n} tracking source(s).`); + } + if (c.kind === 'btc_explorer' && c.isActive) { + const fallback = db.one(`SELECT * FROM Connection WHERE kind = 'btc_explorer' AND isSystem = 1`); + if (!fallback) throw new Error('No system fallback profile found; refusing to delete.'); + const now = nowIso(); + trackUpdate('Connection', c.id, () => + db.run(`UPDATE Connection SET isActive = 0, updatedAt = ? WHERE id = ?`, [now, c.id])); + trackUpdate('Connection', fallback.id, () => + db.run(`UPDATE Connection SET isActive = 1, updatedAt = ? WHERE id = ?`, [now, fallback.id])); + syncConnectionProvider({ kind: 'btc_explorer', url: fallback.url }); + } + trackDelete('Connection', id, () => db.run(`DELETE FROM Connection WHERE id = ?`, [id])); + await db.persist(); + emitConnectionsChanged(); +} + +export async function recordConnectionStatus(id, ok) { + const now = nowIso(); + trackUpdateField('Connection', id, 'lastStatus', () => + db.run(`UPDATE Connection SET lastStatus = ?, lastCheckedAt = ?, updatedAt = ? WHERE id = ?`, + [ok ? 'ok' : 'fail', now, now, id])); + await db.persist(); + emitConnectionsChanged(); +} diff --git a/server/data/counts.js b/server/data/counts.js new file mode 100644 index 0000000..81b3ef9 --- /dev/null +++ b/server/data/counts.js @@ -0,0 +1,18 @@ +// Generated, do not edit. sha256:416f49e0df1b9e917350543775b5f77f278748750a148f230f7651dff4cede7d +import * as db from '../db.js'; +import { CONFIG } from '../../shared/config.js'; + +export function counts() { + return { + assets: db.one(`SELECT COUNT(*) AS n FROM Asset`).n, + accounts: db.one(`SELECT COUNT(*) AS n FROM Account`).n, + events: db.one(`SELECT COUNT(*) AS n FROM Event`).n, + valuations: db.one(`SELECT COUNT(*) AS n FROM Valuation`).n, + }; +} +export function tableCounts() { + return CONFIG.DB_TABLES.map(name => ({ + name, + n: db.one(`SELECT COUNT(*) AS n FROM ${name}`).n, + })); +} diff --git a/server/data/dashboard.js b/server/data/dashboard.js new file mode 100644 index 0000000..f8428ef --- /dev/null +++ b/server/data/dashboard.js @@ -0,0 +1,124 @@ +// Generated, do not edit. sha256:ebca807e87ebabedc0e0ca214ca3bec781ace05d2be4dc4ed8d664c767fe1d63 +import * as db from '../db.js'; +import { DASHBOARD } from '../../shared/config.js'; +import { toSearchName } from '../../shared/search-name.js'; +import { nowUtcIso } from '../../shared/time.js'; +import { fail } from '../../shared/app-error.js'; + +export function listProfiles() { + return db.all(`SELECT * FROM DashboardProfile ORDER BY sortOrder, name`); +} + +export function activeProfile() { + const rij = db.one(`SELECT * FROM DashboardProfile WHERE isActive = 1`); + if (!rij) throw new Error('No active dashboard profile: this data layer is supposed to prevent that.'); + return rij; +} + +function schrijfStandaardIndeling(profileId) { + for (const w of DASHBOARD.DEFAULT_LAYOUT) { + const def = DASHBOARD.WIDGETS[w.type]; + if (!def) continue; + db.run(`INSERT INTO DashboardWidget (profileId, assetId, instanceKey, gridX, gridY, spanWidth, spanHeight, type, settings) + VALUES (?,NULL,?,?,0,?,NULL,?,NULL)`, [profileId, w.instId, w.x || 0, def.defaultSpan, w.type]); + } +} + +export async function createProfile({ name, description = null }) { + const naam = String(name || '').trim(); + if (!naam) fail('dashboard.nameRequired', null, 'A profile needs a name.'); + const nu = nowUtcIso(); + const volgend = db.one(`SELECT COALESCE(MAX(sortOrder), -1) + 1 AS n FROM DashboardProfile`).n; + db.run(`INSERT INTO DashboardProfile (name, searchName, description, sortOrder, isActive, isSystem, createdAt, updatedAt) + VALUES (?,?,?,?,0,0,?,?)`, + [naam, toSearchName(naam), description || null, volgend, nu, nu]); + const id = db.lastId(); + schrijfStandaardIndeling(id); + await db.persist(); + return id; +} + +export async function renameProfile(id, { name, description = null }) { + const naam = String(name || '').trim(); + if (!naam) fail('dashboard.nameRequired', null, 'A profile needs a name.'); + const rij = db.one(`SELECT id FROM DashboardProfile WHERE id = ?`, [id]); + if (!rij) fail('dashboard.unknownProfile', { id }, `Dashboard profile #${id} does not exist.`); + db.run(`UPDATE DashboardProfile SET name = ?, searchName = ?, description = ?, updatedAt = ? WHERE id = ?`, + [naam, toSearchName(naam), description || null, nowUtcIso(), id]); + await db.persist(); +} + +export async function activateProfile(id) { + const rij = db.one(`SELECT id FROM DashboardProfile WHERE id = ?`, [id]); + if (!rij) fail('dashboard.unknownProfile', { id }, `Dashboard profile #${id} does not exist.`); + db.run(`UPDATE DashboardProfile SET isActive = 0, updatedAt = ? WHERE isActive = 1`, [nowUtcIso()]); + db.run(`UPDATE DashboardProfile SET isActive = 1, updatedAt = ? WHERE id = ?`, [nowUtcIso(), id]); + await db.persist(); +} + +export async function deleteProfile(id) { + const rij = db.one(`SELECT * FROM DashboardProfile WHERE id = ?`, [id]); + if (!rij) fail('dashboard.unknownProfile', { id }, `Dashboard profile #${id} does not exist.`); + if (rij.isSystem) { + await saveWidgets(id, null); + return; + } + if (rij.isActive) { + const opvolger = db.one( + `SELECT id FROM DashboardProfile WHERE id <> ? ORDER BY isSystem DESC, sortOrder, name LIMIT 1`, [id]); + if (!opvolger) fail('dashboard.lastNotDeletable', null, 'The last dashboard profile cannot be deleted.'); + db.run(`UPDATE DashboardProfile SET isActive = 0, updatedAt = ? WHERE id = ?`, [nowUtcIso(), id]); + db.run(`UPDATE DashboardProfile SET isActive = 1, updatedAt = ? WHERE id = ?`, [nowUtcIso(), opvolger.id]); + } + db.run(`DELETE FROM DashboardProfile WHERE id = ?`, [id]); + await db.persist(); +} + +export function listWidgets(profileId) { + return db.all(`SELECT * FROM DashboardWidget WHERE profileId = ? ORDER BY gridY, gridX, instanceKey`, [profileId]); +} + +function assertWidgetType(type) { + if (!DASHBOARD.WIDGETS[type]) { + throw new Error(`Unknown widget type '${type}'. Allowed: ${Object.keys(DASHBOARD.WIDGETS).join(', ')}.`); + } +} + +export async function addWidget(profileId, { instanceKey, type, gridX = 0, gridY = 0, assetId = null, settings = null }) { + if (!db.one(`SELECT id FROM DashboardProfile WHERE id = ?`, [profileId])) { + fail('dashboard.unknownProfile', { id: profileId }, `Dashboard profile #${profileId} does not exist.`); + } + assertWidgetType(type); + const key = instanceKey || type; + if (db.one(`SELECT 1 AS x FROM DashboardWidget WHERE profileId = ? AND instanceKey = ?`, [profileId, key])) return false; + const json = settings && typeof settings === 'object' && Object.keys(settings).length ? JSON.stringify(settings) : null; + db.run(`INSERT INTO DashboardWidget (profileId, assetId, instanceKey, gridX, gridY, spanWidth, spanHeight, type, settings) + VALUES (?,?,?,?,?,?,NULL,?,?)`, [profileId, assetId, key, gridX, gridY, DASHBOARD.WIDGETS[type].defaultSpan, type, json]); + await db.persist(); + return true; +} + +export async function saveWidgets(profileId, widgets) { + const rij = db.one(`SELECT id FROM DashboardProfile WHERE id = ?`, [profileId]); + if (!rij) fail('dashboard.unknownProfile', { id: profileId }, `Dashboard profile #${profileId} does not exist.`); + if (widgets === null) { + db.run(`DELETE FROM DashboardWidget WHERE profileId = ?`, [profileId]); + schrijfStandaardIndeling(profileId); + await db.persist(); + return; + } + const lijst = Array.isArray(widgets) ? widgets : []; + for (const w of lijst) assertWidgetType(w.type); + + db.run(`DELETE FROM DashboardWidget WHERE profileId = ?`, [profileId]); + for (const w of lijst) { + const params = w.params && typeof w.params === 'object' ? { ...w.params } : {}; + const assetId = params.assetId == null ? null : Number(params.assetId); + delete params.assetId; + const settings = Object.keys(params).length ? JSON.stringify(params) : null; + db.run(`INSERT INTO DashboardWidget (profileId, assetId, instanceKey, gridX, gridY, spanWidth, spanHeight, type, settings) + VALUES (?,?,?,?,?,?,?,?,?)`, + [profileId, assetId, w.instanceKey, w.gridX, w.gridY, w.spanWidth, w.spanHeight ?? null, w.type, settings]); + } + await db.persist(); +} diff --git a/server/data/events.js b/server/data/events.js new file mode 100644 index 0000000..5e22595 --- /dev/null +++ b/server/data/events.js @@ -0,0 +1,261 @@ +// Generated, do not edit. sha256:68008a6906cce53854e3533a4a9207b146e9943ef96ac00c6c0dd8b1e47f76f3 +import * as db from '../db.js'; +import { ENUMS } from '../../shared/config.js'; +import { assertEnum, assertTimestamp, nowIso } from './_shared.js'; +import { fail } from '../../shared/app-error.js'; +import { trackDelete, trackUpdate } from './change-log.js'; +import { emitChange } from './_events.js'; + +export function listEvents() { + return db.all(`SELECT * FROM Event WHERE type <> 'reservation' ORDER BY occurredAt DESC, id DESC`); +} + +export const EVENT_JOIN = ` + SELECT e.*, a.symbol AS asset_symbol, a.name AS asset_name, a.decimals AS asset_decimals, + a.unit AS asset_unit, a.subunit AS asset_subunit, a.displayUnit AS asset_display_unit, + a.kind AS asset_kind, + lf.name AS from_name, lt.name AS to_name, + mp.name AS product_name, mp.form AS product_form + FROM Event e + JOIN Asset a ON a.id = e.assetId + LEFT JOIN Account lf ON lf.id = e.fromAccountId + LEFT JOIN Account lt ON lt.id = e.toAccountId + LEFT JOIN MetalProduct mp ON mp.id = e.productId`; + +export function listTrades() { + return db.all(`${EVENT_JOIN} WHERE e.type IN ('buy','sell') ORDER BY e.occurredAt DESC, e.id DESC`); +} +export function listTransfers() { + return db.all(`${EVENT_JOIN} WHERE e.type = 'transfer' ORDER BY e.occurredAt DESC, e.id DESC`); +} + +export function listAssetTrades(assetId) { + return db.all(`SELECT e.id, e.type, e.amount, e.fiatPrice, e.fiatFee, e.occurredAt, + e.fromAccountId, e.toAccountId, + lf.name AS from_name, lt.name AS to_name, + (SELECT GROUP_CONCAT(DISTINCT l.name) + FROM WalletTransactionEventLink wl + JOIN WalletTransaction wt ON wt.id = wl.walletTransactionId + JOIN Label l ON l.id = wt.labelId + WHERE wl.eventId = e.id) AS label_names + FROM Event e + LEFT JOIN Account lf ON lf.id = e.fromAccountId + LEFT JOIN Account lt ON lt.id = e.toAccountId + WHERE e.assetId = ? AND e.type IN ('buy','sell') + ORDER BY e.occurredAt ASC, e.id ASC`, [assetId]); +} + +function assertEventPayload(e) { + assertEnum('type', ENUMS.EVENT_TYPE, e.type); + assertEnum('source', ENUMS.EVENT_SOURCE, e.source); + if ((e.type === 'buy' || e.type === 'sell') && (e.fiatPrice === undefined || e.fiatPrice === null)) { + throw new Error(`fiatPrice is required for type '${e.type}'.`); + } + if (e.type === 'interest' && (Number(e.fiatFee) > 0 || Number(e.unitFee) > 0)) { + fail('trades.feeOnInterest', null, 'A fee on interest counts nowhere; it is refused.'); + } + if (e.type === 'buy' && Number(e.unitFee) > 0) { + fail('trades.unitFeeOnBuy', null, 'A fee in units on a purchase counts nowhere; lower the amount instead.'); + } + if (e.type === 'reservation') { + if ((e.fromAccountId == null) === (e.toAccountId == null)) throw new Error('A reservation sits on exactly one account.'); + if (e.fiatPrice != null || e.fiatFee != null || e.unitFee != null || e.productId != null + || e.priceOrigin != null || e.importReference != null || e.externalAddress != null) { + throw new Error('A reservation carries no price, fee, product, origin, import reference or address.'); + } + } + assertTimestamp('occurredAt', e.occurredAt); + assertProductAmount(e); +} + +function priceOriginOf(value) { + if (value == null || value === '') return null; + assertEnum('priceOrigin', ENUMS.EVENT_PRICE_ORIGIN, value); + return value; +} + +function assertProductAmount(e) { + if (e.productId == null) return; + const p = db.one(`SELECT fineGrams FROM MetalProduct WHERE id = ?`, [e.productId]); + if (!p) throw new Error(`Metal product ${e.productId} does not exist.`); + const verwacht = e.productCount * p.fineGrams; + if (e.amount !== verwacht) { + throw new Error(`amount (${e.amount}) should be ${e.productCount} × ${p.fineGrams} = ${verwacht} ` + + `for this product; adjust the count or the amount.`); + } +} +export async function createEvent(e) { + assertEventPayload(e); + db.run(`INSERT INTO Event + (assetId, type, occurredAt, amount, fiatPrice, fiatFee, unitFee, productId, productCount, fromAccountId, toAccountId, source, note, importReference, externalAddress, priceOrigin, createdAt) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [e.assetId, e.type, e.occurredAt, e.amount, + e.fiatPrice ?? null, e.fiatFee ?? null, e.unitFee ?? null, e.productId ?? null, e.productCount ?? null, + e.fromAccountId ?? null, e.toAccountId ?? null, e.source, e.note ?? null, e.importReference ?? null, e.externalAddress ?? null, + priceOriginOf(e.priceOrigin), nowIso()]); + const id = db.lastId(); + await db.persist(); + emitChange('ledger-changed'); + return id; +} +export function findEventByRef(ref) { + if (!ref) return null; + return db.one(`SELECT * FROM Event WHERE importReference = ?`, [ref]) || null; +} +export function eventsWithoutImportRef() { + return db.all(`SELECT * FROM Event WHERE importReference IS NULL AND type <> 'reservation'`); +} + +export async function updateEvent(id, e) { + assertEventPayload(e); + const origin = Object.hasOwn(e, 'priceOrigin') + ? priceOriginOf(e.priceOrigin) + : (db.one(`SELECT priceOrigin AS o FROM Event WHERE id = ?`, [id]) || {}).o ?? null; + trackUpdate('Event', id, () => + db.run(`UPDATE Event SET + assetId = ?, type = ?, occurredAt = ?, amount = ?, fiatPrice = ?, fiatFee = ?, + unitFee = ?, productId = ?, productCount = ?, fromAccountId = ?, toAccountId = ?, source = ?, note = ?, externalAddress = ?, + priceOrigin = ? + WHERE id = ?`, + [e.assetId, e.type, e.occurredAt, e.amount, + e.fiatPrice ?? null, e.fiatFee ?? null, e.unitFee ?? null, e.productId ?? null, e.productCount ?? null, + e.fromAccountId ?? null, e.toAccountId ?? null, e.source, e.note ?? null, e.externalAddress ?? null, + origin, id])); + await db.persist(); + emitChange('ledger-changed'); +} +export async function deleteEvent(id) { + trackDelete('Event', id, () => db.run(`DELETE FROM Event WHERE id = ?`, [id])); + await db.persist(); + emitChange('ledger-changed'); +} + +export function eventsNetByAccount(accountId, assetId, untilIso) { + const r = db.one(` + SELECT COALESCE(SUM( + CASE + WHEN type = 'buy' AND toAccountId = ?1 THEN amount + WHEN type = 'sell' AND fromAccountId = ?1 THEN -(amount + COALESCE(unitFee,0)) + WHEN type = 'transfer' AND toAccountId = ?1 THEN amount + WHEN type = 'transfer' AND fromAccountId = ?1 THEN -(amount + COALESCE(unitFee,0)) + WHEN type = 'interest' AND toAccountId = ?1 THEN amount + ELSE 0 + END + - (CASE WHEN type = 'transfer' AND fromAccountId = ?1 AND toAccountId = ?1 THEN amount + COALESCE(unitFee,0) ELSE 0 END) + ), 0) AS net + FROM Event WHERE assetId = ?2${untilIso ? ' AND occurredAt <= ?3' : ''}`, + untilIso ? [accountId, assetId, untilIso] : [accountId, assetId]); + return r.net; +} + +export function eventsCountByAccount(accountId, assetId, untilIso) { + const r = db.one( + `SELECT COUNT(*) AS n FROM Event + WHERE assetId = ?2 AND (fromAccountId = ?1 OR toAccountId = ?1) AND type <> 'reservation' + ${untilIso ? 'AND occurredAt <= ?3' : ''}`, + untilIso ? [accountId, assetId, untilIso] : [accountId, assetId]); + return r.n; +} + +export function eventAssetIdsByAccount(accountId) { + return db.all( + `SELECT DISTINCT assetId FROM Event WHERE (fromAccountId = ?1 OR toAccountId = ?1) AND type <> 'reservation' ORDER BY assetId`, + [accountId]).map(r => r.assetId); +} + +export function eventAccountIdsByAsset(assetId) { + return db.all( + `SELECT fromAccountId AS accountId FROM Event WHERE assetId = ?1 AND fromAccountId IS NOT NULL AND type <> 'reservation' + UNION + SELECT toAccountId AS accountId FROM Event WHERE assetId = ?1 AND toAccountId IS NOT NULL AND type <> 'reservation' + ORDER BY accountId`, + [assetId]).map(r => r.accountId); +} + +export function eventDaysByAccount(accountId, assetId, vanIso, totIso) { + return db.all( + `SELECT DISTINCT substr(occurredAt, 1, 10) AS dag FROM Event + WHERE assetId = ?2 AND (fromAccountId = ?1 OR toAccountId = ?1) AND type <> 'reservation' + AND substr(occurredAt, 1, 10) > ?3 AND substr(occurredAt, 1, 10) < ?4 + ORDER BY dag`, [accountId, assetId, vanIso, totIso]).map(r => r.dag); +} + +export function assetPositions(untilIso, { ownOnly = false } = {}) { + return db.all(` + SELECT a.id, a.symbol, a.name, a.kind, a.decimals, a.unit, a.subunit, + a.displayUnit, + COALESCE(SUM(CASE + WHEN e.type = 'buy' AND e.toIn THEN e.amount + WHEN e.type = 'sell' AND e.fromIn THEN -(e.amount + COALESCE(e.unitFee,0)) + WHEN e.type = 'interest' AND e.toIn THEN e.amount + WHEN e.type = 'transfer' THEN + (CASE WHEN e.toAccountId IS NOT NULL AND e.toIn THEN e.amount ELSE 0 END) + - (CASE WHEN e.fromAccountId IS NOT NULL AND e.fromIn THEN e.amount + COALESCE(e.unitFee,0) ELSE 0 END) + ELSE 0 END), 0) AS net, + COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN 1 ELSE 0 END), 0) AS buy_events, + COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN e.amount ELSE 0 END), 0) AS buy_amount, + COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN e.amount * e.fiatPrice ELSE 0 END), 0) AS buy_spot_raw, + COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN COALESCE(e.fiatFee,0) ELSE 0 END), 0) AS buy_fee_total, + COALESCE(SUM(CASE WHEN e.priceOrigin IS 'none' THEN + (CASE + WHEN e.type = 'buy' AND e.toIn THEN e.amount + WHEN e.type = 'sell' AND e.fromIn THEN -e.amount + -- A ONE-SIDED TRANSFER without consideration is a gift too, + -- as listTradeEvents already treats it; without this its units took the average + -- purchase price as cost. Two sides is internal and stays out. + WHEN e.type = 'transfer' AND NOT (e.fromAccountId IS NOT NULL AND e.fromIn) + AND e.toAccountId IS NOT NULL AND e.toIn THEN e.amount + WHEN e.type = 'transfer' AND NOT (e.toAccountId IS NOT NULL AND e.toIn) + AND e.fromAccountId IS NOT NULL AND e.fromIn THEN -e.amount + ELSE 0 END) + ELSE 0 END), 0) AS gift_amount, + COALESCE(SUM(CASE WHEN e.type = 'sell' AND e.fromIn THEN 1 ELSE 0 END), 0) AS sell_events + FROM Asset a + LEFT JOIN ( + SELECT Event.*, + (?2 = 0 OR toAccount.wealthScope IS NULL) AS toIn, + (?2 = 0 OR fromAccount.wealthScope IS NULL) AS fromIn + FROM Event + LEFT JOIN Account toAccount ON toAccount.id = Event.toAccountId + LEFT JOIN Account fromAccount ON fromAccount.id = Event.fromAccountId + WHERE ?1 IS NULL OR Event.occurredAt <= ?1 + ) e ON e.assetId = a.id + GROUP BY a.id + ORDER BY a.symbol`, [untilIso ?? null, ownOnly ? 1 : 0]); +} + +export function listTradeEvents(fromIso, untilIso) { + return db.all(`SELECT e.type, e.assetId, e.amount, e.fiatPrice, e.fiatFee, e.priceOrigin, e.occurredAt, + e.fromAccountId, e.toAccountId, + a.decimals + FROM Event e JOIN Asset a ON a.id = e.assetId + WHERE (e.type IN ('buy','sell') + -- EVERY one-sided transfer: value entering or leaving the books. With 'none' a + -- gift, without it a deposit or withdrawal at the day price. + OR (e.type = 'transfer' AND (e.fromAccountId IS NULL) <> (e.toAccountId IS NULL)) + -- A EURO FEE ON A TRANSFER is money in that is gone, as with a purchase (plan + -- Bewijzen B2): the caller counts the fee and not the units. + OR (e.type = 'transfer' AND e.fromAccountId IS NOT NULL AND e.fiatFee > 0)) + AND (?1 IS NULL OR e.occurredAt > ?1) AND e.occurredAt <= ?2`, + [fromIso, untilIso]); +} +export function listFlowLegs(fromIso, untilIso) { + const venster = `(?1 IS NULL OR Event.occurredAt > ?1) AND Event.occurredAt <= ?2`; + const been = (type, kolom, richting) => ` + SELECT Event.${kolom} AS accountId, Event.assetId, Asset.decimals, + Event.type, '${richting}' AS direction, Event.amount AS units, + Event.fiatPrice, Event.fiatFee, Event.priceOrigin, Event.occurredAt + FROM Event JOIN Asset ON Asset.id = Event.assetId + WHERE Event.type = '${type}' AND Event.${kolom} IS NOT NULL AND ${venster}`; + return db.all([ + been('buy', 'toAccountId', 'in'), + been('sell', 'fromAccountId', 'out'), + been('transfer', 'toAccountId', 'in'), + been('transfer', 'fromAccountId', 'out'), + ].join(' UNION ALL '), [fromIso, untilIso]); +} + +export function firstTaxEventDate() { + const r = db.one(`SELECT MIN(occurredAt) AS first FROM Event WHERE type <> 'reservation'`); + return r && r.first ? r.first : null; +} diff --git a/server/data/hygiene.js b/server/data/hygiene.js new file mode 100644 index 0000000..2caa71a --- /dev/null +++ b/server/data/hygiene.js @@ -0,0 +1,383 @@ +// Generated, do not edit. sha256:bbb7036ed8755194a6ae32362bf5faec1d944b2733c93e94b0d21804b434b99e +import { createHash } from 'node:crypto'; + +import * as db from '../db.js'; +import { CONFIG } from '../../shared/config.js'; +import { fail } from '../../shared/app-error.js'; +import { listOpenValuationDiffs } from './valuations.js'; +import { listPriceGaps, getPricePoint } from './price-history.js'; +import { trackUpdateMany } from './change-log.js'; +import { positionsByAsset } from './positions.js'; +import { daysBetween } from './_shared.js'; +import { healthChecks } from '../registers.mjs'; + +function rij(row, counts = {}) { + return { row, counts }; +} + +function dubbeleEvents() { + return db.all(`SELECT Event.assetId, Asset.symbol, Event.type, Event.occurredAt, + Event.productId, MetalProduct.name AS product_name, COUNT(*) AS n + FROM Event + JOIN Asset ON Asset.id = Event.assetId + LEFT JOIN MetalProduct ON MetalProduct.id = Event.productId + WHERE Event.type <> 'reservation' + GROUP BY Event.assetId, Event.type, Event.occurredAt, Event.amount, Event.productId + HAVING COUNT(*) > 1 + ORDER BY n DESC, Event.occurredAt DESC`) + .map(r => rij({ assetId: r.assetId, symbol: r.symbol, type: r.type, occurredAt: r.occurredAt, + productId: r.productId, product_name: r.product_name }, { keer: r.n })); +} + +function assetsZonderBalans() { + return db.all(`SELECT Asset.id AS assetId, Asset.symbol, COUNT(Event.id) AS events + FROM Asset + JOIN Event ON Event.assetId = Asset.id AND Event.type <> 'reservation' + WHERE NOT EXISTS (SELECT 1 FROM Valuation WHERE Valuation.assetId = Asset.id) + GROUP BY Asset.id, Asset.symbol + ORDER BY events DESC`) + .map(r => rij({ assetId: r.assetId, symbol: r.symbol }, { boekingen: r.events })); +} + +function onverzoendeBalansen() { + const symbolen = new Map(db.all(`SELECT id, symbol FROM Asset`).map(a => [a.id, a.symbol])); + return listOpenValuationDiffs() + .map(v => rij({ + assetId: v.assetId, symbol: symbolen.get(v.assetId) ?? null, + accountId: v.accountId, valuedOn: v.valuedOn, + })); +} + +const LAATSTE_KOERS = `(SELECT MAX(observedAt) FROM PriceHistory WHERE PriceHistory.assetId = Asset.id)`; +function achterlopendeKoersen() { + return db.all(`SELECT Asset.id AS assetId, Asset.symbol, + MAX(Event.occurredAt) AS occurredAt, + ${LAATSTE_KOERS} AS observedAt + FROM Asset + JOIN Event ON Event.assetId = Asset.id AND Event.type <> 'reservation' + GROUP BY Asset.id, Asset.symbol + HAVING ${LAATSTE_KOERS} IS NULL OR ${LAATSTE_KOERS} < MAX(Event.occurredAt) + ORDER BY Asset.symbol`) + .map(r => rij(r)); +} + +const EERSTE_KOERS = `(SELECT MIN(observedAt) FROM PriceHistory WHERE PriceHistory.assetId = Asset.id)`; +function koersenBeginnenTeLaat() { + const maandDag = `${String(CONFIG.PEILDATUM.MONTH).padStart(2, '0')}-${String(CONFIG.PEILDATUM.DAY).padStart(2, '0')}`; + const EERSTE_PEILDATUM = `(CAST(strftime('%Y', MIN(Event.occurredAt)) AS INTEGER) + 1) || '-${maandDag}'`; + return db.all(`SELECT Asset.id AS assetId, Asset.symbol, + MIN(Event.occurredAt) AS occurredAt, + ${EERSTE_KOERS} AS observedAt + FROM Asset + JOIN Event ON Event.assetId = Asset.id AND Event.type <> 'reservation' + GROUP BY Asset.id, Asset.symbol + HAVING ${EERSTE_KOERS} IS NOT NULL + AND substr(${EERSTE_KOERS}, 1, 10) > ${EERSTE_PEILDATUM} + ORDER BY Asset.symbol`) + .map(r => rij(r)); +} + +function gatenInKoershistorie() { + const perAsset = new Map(); + for (const g of listPriceGaps({ head: false, tail: false, limit: null }).rows) { + const a = perAsset.get(g.assetId) || { assetId: g.assetId, symbol: g.symbol, gaten: 0, grootste: 0 }; + a.gaten++; + a.grootste = Math.max(a.grootste, g.days); + perAsset.set(g.assetId, a); + } + return [...perAsset.values()].sort((x, y) => y.grootste - x.grootste) + .map(r => rij({ assetId: r.assetId, symbol: r.symbol }, { gaten: r.gaten, grootsteGatInDagen: r.grootste })); +} + +function koersGestopt() { + const vandaag = new Date().toISOString().slice(0, 10); + const uit = []; + for (const a of positionsByAsset()) { + const grens = CONFIG.PRICE_HISTORY.CARRY_FORWARD_DAYS[a.kind]; + if (!(a.net > 0) || grens == null) continue; + const laatste = db.one(`SELECT MAX(observedAt) AS observedAt FROM PriceHistory WHERE assetId = ? AND fiatPrice > 0`, [a.id]).observedAt; + if (!laatste) continue; + const dagen = daysBetween(laatste.slice(0, 10), vandaag); + if (dagen > grens) uit.push({ dagen, r: rij({ assetId: a.id, symbol: a.symbol, observedAt: laatste }, { dagenZonderKoers: dagen }) }); + } + return uit.sort((x, y) => y.dagen - x.dagen).map(x => x.r); +} + +function verplaatsingZonderTegenkant() { + return db.all(`SELECT Event.id AS eventId, Event.assetId, Asset.symbol, Event.occurredAt, + Event.fromAccountId, vanaf.name AS from_name, + Event.toAccountId, naar.name AS to_name + FROM Event + JOIN Asset ON Asset.id = Event.assetId + LEFT JOIN Account vanaf ON vanaf.id = Event.fromAccountId + LEFT JOIN Account naar ON naar.id = Event.toAccountId + WHERE Event.type = 'transfer' + AND (Event.fromAccountId IS NULL OR Event.toAccountId IS NULL) + AND (Event.externalAddress IS NULL OR Event.externalAddress = '') + AND Event.priceOrigin IS NOT 'none' + ORDER BY Event.occurredAt DESC`) + .map(r => rij(r)); +} + +function kostenZonderEffect() { + return db.all(`SELECT Event.id AS eventId, Event.assetId, Asset.symbol, Event.type, Event.occurredAt, + Event.source + FROM Event + JOIN Asset ON Asset.id = Event.assetId + WHERE (Event.type = 'interest' AND Event.fiatFee > 0) + OR (Event.type IN ('buy','interest') AND Event.unitFee > 0) + ORDER BY Event.occurredAt DESC`) + .map(r => rij(r)); +} + +function ongekoppeldeBewegingen() { + return db.all(`SELECT WalletTransaction.id AS walletTransactionId, WalletTransaction.accountId, + Account.name AS account_name, + WalletTransaction.direction, WalletTransaction.blockTime, + WalletTransaction.createdAt + FROM WalletTransaction + LEFT JOIN Account ON Account.id = WalletTransaction.accountId + -- Lightning only from the node, the same rule as syncTxProposals (proposals.js): an NWC + -- payment without its own event is not unlinked, it hangs on its LND twin; on an account + -- without a LND source the NWC wallet counts on its own. + WHERE (WalletTransaction.layer = 'onchain' + OR EXISTS (SELECT 1 FROM WalletSource + WHERE WalletSource.id = WalletTransaction.sourceId + AND (WalletSource.kind = 'lnd' + OR (WalletSource.kind = 'nwc' AND NOT EXISTS ( + SELECT 1 FROM WalletSource node + WHERE node.accountId = WalletTransaction.accountId + AND node.kind = 'lnd'))))) + AND NOT EXISTS (SELECT 1 FROM WalletTransactionEventLink + WHERE WalletTransactionEventLink.walletTransactionId = WalletTransaction.id) + AND NOT EXISTS (SELECT 1 FROM ReconcileProposal + WHERE ReconcileProposal.walletTransactionId = WalletTransaction.id + AND ReconcileProposal.status = 'pending') + ORDER BY WalletTransaction.blockTime DESC`) + .map(r => rij(r)); +} + +function dekkingZonderAsset() { + return db.all(`SELECT ProviderCoverage.providerId, ProviderCoverage.symbol + FROM ProviderCoverage + WHERE NOT EXISTS (SELECT 1 FROM Asset WHERE Asset.symbol = ProviderCoverage.symbol) + ORDER BY ProviderCoverage.symbol`) + .map(r => rij(r)); +} + +function koersRijen() { + return db.all(`SELECT PriceHistory.id AS priceHistoryId, PriceHistory.assetId, Asset.symbol, Asset.kind, + PriceHistory.providerId, Provider.name AS provider_name, PriceHistory.resolution, + PriceHistory.observedAt, PriceHistory.fiatPrice + FROM PriceHistory + JOIN Asset ON Asset.id = PriceHistory.assetId + JOIN Provider ON Provider.id = PriceHistory.providerId + ORDER BY PriceHistory.assetId, PriceHistory.observedAt, PriceHistory.id`); +} +const koersRij = (r, counts) => rij({ priceHistoryId: r.priceHistoryId, assetId: r.assetId, symbol: r.symbol, + observedAt: r.observedAt, providerId: r.providerId, provider_name: r.provider_name }, counts); +const factor = x => Math.round(x * 10) / 10; + +function reeksenPerAsset() { + const m = new Map(); + for (const r of koersRijen()) { + if (!(r.fiatPrice > 0)) continue; + if (!m.has(r.assetId)) m.set(r.assetId, []); + m.get(r.assetId).push(r); + } + return m; +} +const isOunceJump = ratio => { + const oz = CONFIG.METALS.UNITS.ozt, tol = CONFIG.HYGIENE.TROY_OUNCE_TOLERANCE; + const bij = (x, doel) => x >= doel * (1 - tol) && x <= doel * (1 + tol); + return bij(ratio, oz) || bij(ratio, 1 / oz); +}; + +function eenheidsverwisseling() { + const uit = []; + for (const reeks of reeksenPerAsset().values()) { + for (let i = 1; i < reeks.length; i++) { + const ratio = reeks[i].fiatPrice / reeks[i - 1].fiatPrice; + if (isOunceJump(ratio)) uit.push(koersRij(reeks[i], { factor: factor(ratio >= 1 ? ratio : 1 / ratio) })); + } + } + return uit; +} + +function uitschieters() { + const f = CONFIG.HYGIENE.OUTLIER_FACTOR; + const uit = []; + for (const reeks of reeksenPerAsset().values()) { + for (let i = 1; i < reeks.length - 1; i++) { + const voor = reeks[i].fiatPrice / reeks[i - 1].fiatPrice, na = reeks[i].fiatPrice / reeks[i + 1].fiatPrice; + if (isOunceJump(voor) || isOunceJump(na)) continue; + const omhoog = voor >= f && na >= f, omlaag = voor <= 1 / f && na <= 1 / f; + if (omhoog || omlaag) uit.push(koersRij(reeks[i], { factor: factor(omhoog ? Math.min(voor, na) : 1 / Math.max(voor, na)) })); + } + } + return uit; +} + +function bronnenOneens() { + const grens = CONFIG.HYGIENE.SOURCES_DISAGREE_SHARE; + const perDag = new Map(); + for (const r of koersRijen()) { + if (r.resolution !== 'day' || !(r.fiatPrice > 0)) continue; + const sleutel = `${r.assetId}|${r.observedAt.slice(0, 10)}`; + if (!perDag.has(sleutel)) perDag.set(sleutel, []); + perDag.get(sleutel).push(r); + } + const uit = []; + for (const rijen of perDag.values()) { + if (new Set(rijen.map(r => r.providerId)).size < 2) continue; + const laag = rijen.reduce((a, b) => (b.fiatPrice < a.fiatPrice ? b : a)); + const hoog = rijen.reduce((a, b) => (b.fiatPrice > a.fiatPrice ? b : a)); + const spreiding = hoog.fiatPrice / laag.fiatPrice - 1; + if (spreiding > grens && laag.providerId !== hoog.providerId) { + uit.push(rij({ assetId: laag.assetId, symbol: laag.symbol, observedAt: laag.observedAt, + low_provider_name: laag.provider_name, high_provider_name: hoog.provider_name }, + { verschilProcent: Math.round(spreiding * 1000) / 10 })); + } + } + return uit; +} + +function koersNietPositief() { + return koersRijen().filter(r => r.fiatPrice <= 0).map(r => koersRij(r, {})); +} + +function koersInToekomst() { + const nu = new Date().toISOString(); + return koersRijen().filter(r => r.observedAt > nu).map(r => koersRij(r, {})); +} + +function boekingenMetDagkoers({ origin } = {}) { + const rijen = db.all(`SELECT Event.id AS eventId, Event.assetId, Asset.symbol, Event.type, Event.occurredAt, + Event.priceOrigin, Event.fiatPrice + FROM Event + JOIN Asset ON Asset.id = Event.assetId + WHERE Event.type IN ('buy','sell') AND Event.fiatPrice > 0 + AND Event.priceOrigin IS NOT 'none' + AND (?1 = 0 OR Event.priceOrigin IS NULL) + ORDER BY Event.occurredAt DESC, Event.id DESC`, [origin === 'unknown' ? 1 : 0]); + const uit = []; + for (const r of rijen) { + const dag = getPricePoint(r.assetId, String(r.occurredAt).slice(0, 10)); + if (dag && dag.fiatPrice > 0) uit.push({ ...r, ratio: r.fiatPrice / dag.fiatPrice }); + } + return uit; +} +const boekingRij = (r, counts) => rij({ eventId: r.eventId, assetId: r.assetId, symbol: r.symbol, type: r.type, + occurredAt: r.occurredAt, priceOrigin: r.priceOrigin }, counts); + +function boekingsprijsWijktAf() { + const grens = CONFIG.HYGIENE.BOOKING_PRICE_DEVIATION; + const uit = []; + for (const r of boekingenMetDagkoers()) { + if (isOunceJump(r.ratio)) uit.push(boekingRij(r, { factor: factor(r.ratio >= 1 ? r.ratio : 1 / r.ratio) })); + else if (Math.abs(r.ratio - 1) > grens) uit.push(boekingRij(r, { afwijkingProcent: Math.round((r.ratio - 1) * 1000) / 10 })); + } + return uit; +} + +const SAME_PRICE_SHARE = 0.0005; +const afleidbaar = () => boekingenMetDagkoers({ origin: 'unknown' }) + .filter(r => Math.abs(r.ratio - 1) <= SAME_PRICE_SHARE); +function herkomstAfleidbaar() { + return afleidbaar().map(r => boekingRij(r, {})); +} + +export async function fillDerivablePriceOrigins() { + const ids = afleidbaar().map(r => r.eventId); + if (!ids.length) return 0; + trackUpdateMany('Event', ids, () => db.run( + `UPDATE Event SET priceOrigin = 'market_day' + WHERE priceOrigin IS NULL AND id IN (${ids.map(() => '?').join(',')})`, ids)); + await db.persist(); + return ids.length; +} + +const CHECKS = Object.freeze([ + { kind: 'duplicate_events', severity: 'warn', run: dubbeleEvents, countsFrom: ['id'] }, + { kind: 'assets_without_balance', severity: 'info', run: assetsZonderBalans, countsFrom: ['id'] }, + { kind: 'unreconciled_valuations', severity: 'warn', run: onverzoendeBalansen, countsFrom: [] }, + { kind: 'stale_prices', severity: 'warn', run: achterlopendeKoersen, countsFrom: [] }, + { kind: 'late_price_history', severity: 'warn', run: koersenBeginnenTeLaat, countsFrom: [] }, + { kind: 'price_gaps', severity: 'info', run: gatenInKoershistorie, countsFrom: ['observedAt'] }, + { kind: 'price_stopped', severity: 'warn', run: koersGestopt, countsFrom: ['observedAt'] }, + { kind: 'transfer_without_counterpart', severity: 'warn', run: verplaatsingZonderTegenkant, countsFrom: [] }, + { kind: 'fee_without_effect', severity: 'info', run: kostenZonderEffect, countsFrom: [] }, + { kind: 'unlinked_transactions', severity: 'warn', run: ongekoppeldeBewegingen, countsFrom: [] }, + { kind: 'coverage_without_asset', severity: 'info', run: dekkingZonderAsset, countsFrom: [] }, + { kind: 'price_unit_swap', severity: 'warn', run: eenheidsverwisseling, countsFrom: ['fiatPrice'] }, + { kind: 'price_outlier', severity: 'warn', run: uitschieters, countsFrom: ['fiatPrice'] }, + { kind: 'price_sources_disagree', severity: 'info', run: bronnenOneens, countsFrom: ['fiatPrice'] }, + { kind: 'price_not_positive', severity: 'warn', run: koersNietPositief, countsFrom: [] }, + { kind: 'price_in_future', severity: 'warn', run: koersInToekomst, countsFrom: [] }, + { kind: 'booking_price_deviates', severity: 'warn', run: boekingsprijsWijktAf, countsFrom: ['fiatPrice'] }, + { kind: 'price_origin_derivable', severity: 'info', run: herkomstAfleidbaar, countsFrom: ['fiatPrice'] }, +]); + +export function hygieneKinds() { + return checks().map(c => ({ kind: c.kind, severity: c.severity })); +} + +function checks() { + const extra = healthChecks.list().map(e => ({ kind: e.id, severity: e.severity, run: e.run, countsFrom: e.countsFrom })); + const dubbel = extra.find(e => CHECKS.some(c => c.kind === e.kind)); + if (dubbel) throw new Error(`health check '${dubbel.kind}' is registered and also a core check`); + return [...CHECKS, ...extra]; +} + +export function hygieneFingerprint(kind, row = {}, counts = {}) { + const vast = obj => JSON.stringify(Object.keys(obj).sort().map(k => [k, obj[k]])); + return createHash('sha256').update(`${kind}|${vast(row)}|${vast(counts)}`).digest('hex').slice(0, 32); +} + +export function listHygieneAcceptances() { + return db.all(`SELECT id, kind, fingerprint, summary, note, createdAt + FROM HygieneAcceptance ORDER BY createdAt DESC, id DESC`); +} + +export async function acceptHygieneFinding({ kind, row = {}, counts = {}, summary, note = null } = {}) { + if (!checks().some(c => c.kind === kind)) { + fail('health.acceptUnknownKind', {}, `Unknown hygiene finding kind '${kind}'.`); + } + if (!summary || !String(summary).trim()) { + fail('health.acceptNeedsSummary', {}, 'A hygiene acceptance needs a readable summary.'); + } + const fingerprint = hygieneFingerprint(kind, row, counts); + db.run(`INSERT INTO HygieneAcceptance (kind, fingerprint, summary, note, createdAt) + VALUES (?,?,?,?,?) + ON CONFLICT(fingerprint) DO UPDATE SET + summary = excluded.summary, note = excluded.note, createdAt = excluded.createdAt`, + [kind, fingerprint, String(summary).trim(), note ? String(note).trim() : null, new Date().toISOString()]); + await db.persist(); + return fingerprint; +} + +export async function unacceptHygieneFinding(id) { + db.run(`DELETE FROM HygieneAcceptance WHERE id = ?`, [id]); + await db.persist(); +} + +export function hygieneReport({ kinds = null } = {}) { + const alle = checks(); + const gekozen = kinds ? alle.filter(c => kinds.includes(c.kind)) : alle; + const grens = CONFIG.HYGIENE.MAX_ROWS_PER_FINDING; + const aanvaard = new Set(db.all(`SELECT fingerprint FROM HygieneAcceptance`).map(r => r.fingerprint)); + const bevindingen = gekozen.map(c => { + const alles = aanvaard.size + ? c.run().filter(r => !aanvaard.has(hygieneFingerprint(c.kind, r.row, r.counts))) + : c.run(); + return { + kind: c.kind, severity: c.severity, countsFrom: c.countsFrom, + total: alles.length, + rows: alles.length > grens ? alles.slice(0, grens) : alles, + }; + }); + return { + total: bevindingen.reduce((n, b) => n + b.total, 0), + findings: bevindingen, + }; +} diff --git a/server/data/integration-log.js b/server/data/integration-log.js new file mode 100644 index 0000000..069a61a --- /dev/null +++ b/server/data/integration-log.js @@ -0,0 +1,105 @@ +// Generated, do not edit. sha256:3c9603902fe01cc9593bf69315c4734fdbba824688512bed37af01059c22d688 +import * as db from '../db.js'; +import { recordApiCall } from './api-metrics.js'; +import { CONFIG, ENUMS } from '../../shared/config.js'; + +const LOG = CONFIG.INTEGRATION_LOG; + +const MIN_SECRET_LENGTH = 4; + +const AUTH_VALUE = /\b(Bearer|Basic)\s+\S+/gi; +const SECRET_PARAM = /\b((?:api[_-]?key|apikey|access[_-]?token|token|key|secret|signature|password)=)[^&\s"']+/gi; +const URL_TEXT = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi; + +function cleanDetail(text, secrets) { + if (text == null) return null; + let s = String(text); + for (const v of secrets) { + if (typeof v !== 'string' || v.length < MIN_SECRET_LENGTH) continue; + for (const form of new Set([v, encodeURIComponent(v)])) s = s.split(form).join('***'); + } + s = s.replace(URL_TEXT, '').replace(AUTH_VALUE, '$1 ***').replace(SECRET_PARAM, '$1***'); + s = s.replace(/\s+/g, ' ').trim(); + if (!s) return null; + return s.length > LOG.DETAIL_MAX_CHARS ? `${s.slice(0, LOG.DETAIL_MAX_CHARS - 1)}…` : s; +} + +const KEY_PART = /^[A-Za-z][\w.-]{0,79}$/; +const cleanKey = (key) => { + const parts = String(key ?? '').split(/\s+/).filter(p => KEY_PART.test(p)); + return parts.length ? parts.join(' ') : null; +}; + +const REQUEST_ID = /^[A-Za-z0-9-]{8,64}$/; + +function timeOf(at) { + if (at == null) return new Date().toISOString(); + const d = new Date(at); + if (Number.isNaN(d.getTime())) throw new Error(`Invalid occurredAt: '${at}'.`); + return d.toISOString(); +} + +function prune() { + const grens = new Date(Date.now() - LOG.RETENTION_DAYS * 86_400_000).toISOString(); + db.run(`DELETE FROM IntegrationCall WHERE occurredAt < ?`, [grens]); + db.run(`DELETE FROM IntegrationCall + WHERE id <= (SELECT id FROM IntegrationCall ORDER BY id DESC LIMIT 1 OFFSET ?)`, [LOG.MAX_ROWS]); +} + +export async function recordIntegrationCall(entry, { secrets = [] } = {}) { + const e = entry || {}; + if (!ENUMS.INTEGRATION_TARGET.includes(e.target)) throw new Error(`Unknown integration target: '${e.target}'.`); + if (!ENUMS.INTEGRATION_OUTCOME.includes(e.outcome)) throw new Error(`Unknown integration outcome: '${e.outcome}'.`); + const providerKey = e.target === 'provider' ? String(e.providerKey || '') : null; + if (e.target === 'provider' && !providerKey) throw new Error('A provider row needs a providerKey.'); + const operation = String(e.operation || ''); + if (!operation || /[?#]|:\/\//.test(operation)) throw new Error(`An operation is a path or a capability: '${operation}'.`); + const subject = e.subject == null ? null : String(e.subject).slice(0, 100); + const status = Number.isInteger(e.httpStatus) && e.httpStatus >= 100 && e.httpStatus <= 599 ? e.httpStatus : null; + const latency = Number.isFinite(e.latencyMs) && e.latencyMs >= 0 ? Math.round(e.latencyMs) : null; + const requestId = e.target === 'service' && REQUEST_ID.test(String(e.requestId ?? '')) ? e.requestId : null; + const clean = cleanDetail(e.detail, Array.isArray(secrets) ? secrets : []); + + db.run(`INSERT INTO IntegrationCall + (target, providerKey, operation, subject, outcome, httpStatus, errorKey, detail, latencyMs, requestId, occurredAt) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + [e.target, providerKey, operation, subject, e.outcome, status, cleanKey(e.errorKey), clean, latency, requestId, + timeOf(e.occurredAt)]); + prune(); + await db.persist(); +} + +export async function countServiceCall({ capability, ok, latencyMs }) { + const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [CONFIG.MARKET_SERVICE.PROVIDER_KEY]); + if (!p) return; + await recordApiCall({ providerId: p.id, capability, ok: !!ok, latency_ms: latencyMs }); +} + +export function listIntegrationCalls({ days = 7, target = null, providerKey = null, limit = 200 } = {}) { + const sinds = new Date(Date.now() - Math.max(0, Number(days) || 0) * 86_400_000).toISOString(); + const n = Math.min(Math.max(1, Math.trunc(Number(limit)) || 200), LOG.MAX_ROWS); + const t = target || null; + const p = providerKey || null; + return db.all(`SELECT * FROM IntegrationCall + WHERE occurredAt >= ? AND (? IS NULL OR target = ?) AND (? IS NULL OR providerKey = ?) + ORDER BY id DESC LIMIT ?`, [sinds, t, t, p, p, n]); +} + +export function serviceState() { + const lastFail = db.one(`SELECT * FROM IntegrationCall WHERE target = 'service' AND outcome = 'fail' + ORDER BY id DESC LIMIT 1`) || null; + const row = db.one(`SELECT MAX(bucketMinute) AS at FROM ApiCallMetric + JOIN Provider ON Provider.id = ApiCallMetric.providerId + WHERE Provider.key = ? AND okCount > 0`, [CONFIG.MARKET_SERVICE.PROVIDER_KEY]); + const lastOkAt = row?.at ?? null; + if (lastFail && (!lastOkAt || lastFail.occurredAt.slice(0, 16) >= lastOkAt)) { + return { ok: false, at: lastFail.occurredAt, lastFail, lastOkAt }; + } + return { ok: lastOkAt ? true : null, at: lastOkAt ? `${lastOkAt}:00.000Z` : null, lastFail, lastOkAt }; +} + +export function lastIntegrationFailures() { + return db.all(`SELECT * FROM IntegrationCall + WHERE id IN (SELECT MAX(id) FROM IntegrationCall GROUP BY target, providerKey) + ORDER BY id DESC`); +} diff --git a/server/data/interest-credits.js b/server/data/interest-credits.js new file mode 100644 index 0000000..d2a3afd --- /dev/null +++ b/server/data/interest-credits.js @@ -0,0 +1,233 @@ +// Generated, do not edit. sha256:c2a988d6416e302a0ad7224bc98a3cd265bc5cc9cadb0dce809af179af082299 +import * as db from '../db.js'; +import { nowIso, todayIso, daysBetween, isoToDate, dateToIso, addDaysIso, weekdayOf, clampDom } from './_shared.js'; +import { createEvent, eventDaysByAccount } from './events.js'; +import { listSelectableAccounts } from './accounts.js'; +import { positionAt } from './positions.js'; +import { listSchedulesForAccount, listPeriods, realizedForSchedule } from './interest-schedules.js'; +import { anchorDaysBetween } from './valuations.js'; +import { toUtcIso } from '../../shared/time.js'; +import { fail } from '../../shared/app-error.js'; + +export function realizedInterestSub(accountId, assetId, fromIso, toIso) { + const r = db.one(`SELECT COALESCE(SUM(amount),0) AS s FROM Event + WHERE type = 'interest' AND toAccountId = ? AND assetId = ? AND occurredAt >= ? AND occurredAt < ?`, + [accountId, assetId, fromIso, toIso]); + return r ? r.s : 0; +} + +function weeklyDates(startIso, endIso, weekday) { + const out = []; + let cur = addDaysIso(startIso, 1), guard = 0; + while (weekdayOf(cur) !== weekday && guard++ < 7) cur = addDaysIso(cur, 1); + for (; cur <= endIso; cur = addDaysIso(cur, 7)) out.push(cur); + return out; +} +function monthlyDates(startIso, endIso, dom) { + const out = []; + const s = isoToDate(startIso); + let y = s.getUTCFullYear(), m = s.getUTCMonth(); + for (;;) { + const iso = dateToIso(new Date(Date.UTC(y, m, clampDom(y, m, dom)))); + if (iso > endIso) break; + if (iso > startIso) out.push(iso); + if (++m > 11) { m = 0; y++; } + } + return out; +} +function buildPeriods(dates, startIso) { + const periods = []; + let prev = startIso; + for (const d of dates) { periods.push({ periodStart: prev, periodEnd: d }); prev = d; } + return periods; +} + +function allCreditingPeriods(s, horizonIso = todayIso()) { + const periods = cadencePeriods(s, horizonIso); + if (s.endsOn && s.creditCadence !== 'term_end') { + const last = periods.length ? periods[periods.length - 1].periodEnd : s.startsOn; + if (last < s.endsOn) periods.push({ periodStart: last, periodEnd: s.endsOn }); + } + return periods; +} + +function cadencePeriods(s, horizonIso) { + const eind = s.endsOn || horizonIso; + if (s.creditCadence === 'term_end') return s.endsOn ? [{ periodStart: s.startsOn, periodEnd: s.endsOn }] : []; + if (s.creditCadence === 'annual') { + const periods = []; + const startYear = Number(s.startsOn.slice(0, 4)); + for (let y = startYear; ; y++) { + const periodEnd = `${y + 1}-01-01`; + if (periodEnd > eind) break; + periods.push({ periodStart: y === startYear ? s.startsOn : `${y}-01-01`, periodEnd: periodEnd }); + } + return periods; + } + if (s.creditCadence === 'quarterly') { + const periods = []; + const s0 = isoToDate(s.startsOn); + let y = s0.getUTCFullYear(); + let m = Math.floor(s0.getUTCMonth() / 3) * 3; + let prev = s.startsOn; + for (;;) { + m += 3; + if (m > 11) { m -= 12; y++; } + const periodEnd = dateToIso(new Date(Date.UTC(y, m, 1))); + if (periodEnd > eind) break; + periods.push({ periodStart: prev, periodEnd }); + prev = periodEnd; + } + return periods; + } + const dates = s.creditCadence === 'weekly' + ? weeklyDates(s.startsOn, eind, s.creditWeekday) + : monthlyDates(s.startsOn, eind, s.creditDayOfMonth); + return buildPeriods(dates, s.startsOn); +} + +export function scheduleForecast(s, asofIso = todayIso()) { + const realizedSub = realizedForSchedule(s, asofIso); + const sameAsset = s.principalAssetId === s.payoutAssetId; + const periods = allCreditingPeriods(s, addDaysIso(asofIso, 400)); + const amount = periodAmounts(s, periods); + const later = periods.filter(p => p.periodEnd > asofIso); + const upcoming = (s.endsOn ? later : later.slice(0, 1)).map(p => ({ ...p, projectedSub: amount(p) })); + + if (!s.endsOn || s.principalAmount == null) { + return { termTotalSub: null, realizedSub, remainingSub: null, remainingCount: null, sameAsset, upcoming }; + } + + const termTotalSub = periods.reduce((n, p) => n + amount(p), 0); + const remainingSub = Math.max(0, termTotalSub - realizedSub); + return { termTotalSub, realizedSub, remainingSub, remainingCount: upcoming.length, sameAsset, upcoming }; +} + +function periodAmounts(s, periods) { + if (!s.compounding) return p => projectedSubForPeriod(s, p); + const byEnd = new Map(); + let accrued = 0; + for (const p of periods) { + const sub = projectedSubForPeriod(s, p, accrued); + byEnd.set(p.periodEnd, sub); + accrued += sub; + } + return p => byEnd.get(p.periodEnd); +} + +function creditingPeriods(s, asofIso) { + return allCreditingPeriods(s).filter(p => p.periodEnd <= asofIso); +} +function rateSlices(s, vanIso, totIso) { + const perioden = listPeriods(s.id); + const wissels = perioden.filter(p => p.startsOn > vanIso && p.startsOn < totIso); + const eerder = perioden.filter(p => p.startsOn <= vanIso); + let rate = eerder.length ? eerder[eerder.length - 1].annualRate : s.annualRate; + + const uit = []; + let van = vanIso; + for (const w of wissels) { + uit.push({ van, tot: w.startsOn, rate }); + van = w.startsOn; + rate = w.annualRate; + } + uit.push({ van, tot: totIso, rate }); + return uit; +} + +function principalMainVoor(s, opDag) { + if (s.principalAmount != null) return s.principalAmount / Math.pow(10, s.principal_decimals); + const moment = toUtcIso(`${opDag}T23:59:59`); + return positionAt(s.accountId, s.principalAssetId, moment) / Math.pow(10, s.principal_decimals); +} + +function saldoDagen(s, vanDag, totDag) { + const dagen = new Set([ + ...eventDaysByAccount(s.accountId, s.principalAssetId, vanDag, totDag), + ...anchorDaysBetween(s.accountId, s.principalAssetId, vanDag, totDag), + ]); + return [...dagen].sort(); +} + +function projectedSubForPeriod(s, p, accruedSub = 0) { + const stukken = []; + for (const deel of rateSlices(s, p.periodStart, p.periodEnd)) { + if (s.principalAmount != null) { stukken.push(deel); continue; } + let van = deel.van; + for (const dag of saldoDagen(s, deel.van, deel.tot)) { + stukken.push({ van, tot: dag, rate: deel.rate }); + van = dag; + } + stukken.push({ van, tot: deel.tot, rate: deel.rate }); + } + const accrued = accruedSub / Math.pow(10, s.payout_decimals); + const som = stukken.reduce((n, deel) => + n + (principalMainVoor(s, deel.van) + accrued) * deel.rate * Math.max(0, daysBetween(deel.van, deel.tot) / 365), 0); + return Math.round(som * Math.pow(10, s.payout_decimals)); +} +function getCredit(scheduleId, periodEnd) { + return db.one(`SELECT * FROM InterestCredit WHERE scheduleId = ? AND periodEnd = ?`, [scheduleId, periodEnd]) || null; +} + +export function dueCreditsForAccount(accountId) { + const today = todayIso(); + const out = []; + for (const s of listSchedulesForAccount(accountId)) { + if (s.rateKind !== 'fixed') continue; + const periods = creditingPeriods(s, today); + const amount = periodAmounts(s, periods); + for (const p of periods) { + if (getCredit(s.id, p.periodEnd)) continue; + if (realizedForSchedule(s, p.periodEnd, addDaysIso(p.periodStart, 1)) > 0) continue; + const projected = amount(p); + if (projected === 0) continue; + out.push({ + scheduleId: s.id, accountId: accountId, payoutAssetId: s.payoutAssetId, + payout_symbol: s.payout_symbol, payout_decimals: s.payout_decimals, + payout_unit: s.payout_unit, payout_subunit: s.payout_subunit, payout_display_unit: s.payout_display_unit, + periodStart: p.periodStart, periodEnd: p.periodEnd, projected_sub: projected, + }); + } + } + return out; +} + +export function dueCreditsAll() { + const out = []; + for (const a of listSelectableAccounts()) { + for (const d of dueCreditsForAccount(a.id)) out.push({ ...d, account_name: a.name }); + } + return out; +} + +function olderOpenDue(due) { + return dueCreditsForAccount(due.accountId) + .filter(d => d.periodEnd < due.periodEnd) + .sort((a, b) => a.periodEnd.localeCompare(b.periodEnd))[0] || null; +} + +export async function bookDueCredit(due, amountSub) { + const ouder = olderOpenDue(due); + if (ouder) { + fail('interest.bookOlderFirst', { start: ouder.periodStart, eind: ouder.periodEnd }, + `Book the credit of ${ouder.periodStart} to ${ouder.periodEnd} first: each credit raises the ` + + 'balance the next one is computed on.'); + } + const eventId = await createEvent({ + assetId: due.payoutAssetId, type: 'interest', occurredAt: toUtcIso(`${due.periodEnd}T00:00:00`), + amount: amountSub, fiatPrice: null, fiatFee: null, unitFee: null, + fromAccountId: null, toAccountId: due.accountId, source: 'manual', + importReference: null, externalAddress: null, note: null, + }); + db.run(`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, eventId, status, createdAt) + VALUES (?,?,?,?, 'booked', ?)`, + [due.scheduleId, due.periodStart, due.periodEnd, eventId, nowIso()]); + await db.persist(); + return eventId; +} +export async function dismissDueCredit(due) { + db.run(`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, eventId, status, createdAt) + VALUES (?,?,?, NULL, 'dismissed', ?)`, + [due.scheduleId, due.periodStart, due.periodEnd, nowIso()]); + await db.persist(); +} diff --git a/server/data/interest-schedules.js b/server/data/interest-schedules.js new file mode 100644 index 0000000..1169de3 --- /dev/null +++ b/server/data/interest-schedules.js @@ -0,0 +1,193 @@ +// Generated, do not edit. sha256:1f3b140e78bb63390abe586ae0f82dd70eb59c31c977547efd2e278b927a1a2f +import * as db from '../db.js'; +import { localDate } from '../../shared/time.js'; +import { ENUMS } from '../../shared/config.js'; +import { fail } from '../../shared/app-error.js'; +import { assertEnum, nowIso } from './_shared.js'; +import { EVENT_JOIN } from './events.js'; +import { trackDelete, trackUpdate } from './change-log.js'; +import { emitChange } from './_events.js'; + +export function listInterestForAccount(accountId) { + return db.all(`${EVENT_JOIN} WHERE e.type = 'interest' AND e.toAccountId = ? ORDER BY e.occurredAt DESC, e.id DESC`, [accountId]); +} +export function listInterest() { + const rows = db.all(`${EVENT_JOIN} WHERE e.type = 'interest' ORDER BY e.occurredAt DESC, e.id DESC`); + const schedules = listAllSchedules(); + const links = creditLinks(); + for (const r of rows) { + const s = attributeSchedule(r, schedules, links); + r.scheduleId = s ? s.id : null; + r.schedule_label = s ? scheduleLabel(s) : null; + } + return rows; +} + +export function listAllSchedules() { + return db.all(` + SELECT s.*, ac.name AS account_name, + pa.symbol AS principal_symbol, pa.decimals AS principal_decimals, + pa.unit AS principal_unit, pa.subunit AS principal_subunit, pa.displayUnit AS principal_display_unit, + po.symbol AS payout_symbol, po.decimals AS payout_decimals, + po.unit AS payout_unit, po.subunit AS payout_subunit, po.displayUnit AS payout_display_unit + FROM InterestSchedule s + JOIN Account ac ON ac.id = s.accountId + LEFT JOIN Asset pa ON pa.id = s.principalAssetId + JOIN Asset po ON po.id = s.payoutAssetId + ORDER BY s.startsOn DESC, s.id DESC`); +} + +export function scheduleLabel(s) { + const period = `${s.startsOn}–${s.endsOn || 'heden'}`; + const kind = s.rateKind === 'variable' ? 'variabel' : `${+(s.annualRate * 100).toFixed(4)}%`; + return `${s.account_name} · ${s.payout_symbol} · ${kind} · ${period}`; +} + +export function realizedForSchedule(s, asofIso, sinceIso = null) { + const stored = listSchedulesForAccount(s.accountId); + const schedules = stored.some(o => o.id === s.id) ? stored.map(o => (o.id === s.id ? s : o)) : [...stored, s]; + const links = creditLinks(); + return db.all(`SELECT id, occurredAt, toAccountId, assetId, amount FROM Event + WHERE type = 'interest' AND toAccountId = ? AND assetId = ? AND occurredAt >= ? AND occurredAt <= ?`, + [s.accountId, s.payoutAssetId, sinceIso || '', `${asofIso}T23:59:59`]) + .filter(ev => attributeSchedule(ev, schedules, links) === s) + .reduce((n, ev) => n + ev.amount, 0); +} + +function creditLinks() { + return new Map(db.all(`SELECT eventId, scheduleId FROM InterestCredit WHERE eventId IS NOT NULL`) + .map(c => [c.eventId, c.scheduleId])); +} + +function attributeSchedule(ev, schedules, links) { + const linkedId = links.get(ev.id); + const linked = linkedId != null ? schedules.find(s => s.id === linkedId) : null; + if (linked) return linked; + const date = localDate(ev.occurredAt); + let best = null; + for (const s of schedules) { + if (s.accountId !== ev.toAccountId || s.payoutAssetId !== ev.assetId) continue; + if (date < s.startsOn || (s.endsOn && date > s.endsOn)) continue; + if (!best || s.startsOn > best.startsOn) best = s; + } + return best; +} +export function listSchedulesForAccount(accountId) { + return db.all(` + SELECT s.*, + pa.symbol AS principal_symbol, pa.decimals AS principal_decimals, + pa.unit AS principal_unit, pa.subunit AS principal_subunit, pa.displayUnit AS principal_display_unit, + po.symbol AS payout_symbol, po.decimals AS payout_decimals, + po.unit AS payout_unit, po.subunit AS payout_subunit, po.displayUnit AS payout_display_unit + FROM InterestSchedule s + LEFT JOIN Asset pa ON pa.id = s.principalAssetId + JOIN Asset po ON po.id = s.payoutAssetId + WHERE s.accountId = ? + ORDER BY s.startsOn DESC, s.id DESC`, [accountId]); +} +function assertSchedule(s) { + assertEnum('rateKind', ENUMS.INTEREST_RATE_KIND, s.rateKind); + if (!s.payoutAssetId) fail('accounts.schPayoutRequired', null, 'A schedule needs a payout asset.'); + if (!s.startsOn) fail('accounts.schStartRequired', null, 'A schedule needs a start date.'); + if (s.endsOn && s.endsOn < s.startsOn) fail('accounts.schEndBeforeStart', null, 'The end date precedes the start date.'); + if (s.rateKind === 'variable') { + if (s.principalAssetId != null || s.principalAmount != null || s.annualRate != null) throw new Error('A schedule on "manual or import" has no principal or rate: the app computes nothing there.'); + if (s.creditCadence != null || s.creditWeekday != null || s.creditDayOfMonth != null) throw new Error('A schedule on "manual or import" has no credit cadence: the interest comes from outside.'); + if (s.compounding) throw new Error('A schedule on "manual or import" has no interest on interest: the app computes nothing there.'); + return; + } + if (s.compounding) { + if (s.principalAmount == null) fail('interest.compoundingNeedsPrincipal', null, 'Interest on interest needs a fixed principal; a principal that follows the balance already grows by every credit.'); + if (s.principalAssetId !== s.payoutAssetId) fail('interest.compoundingNeedsSameAsset', null, 'Interest on interest needs the payout in the principal asset.'); + } + if (!s.principalAssetId) fail('accounts.schAssetRequired', null, 'A schedule needs a principal asset.'); + if (s.principalAmount != null && !(s.principalAmount > 0)) { + fail('interest.principalPositive', null, 'The principal has to be greater than 0; leave it empty to follow the account balance.'); + } + if (!(s.annualRate >= 0)) fail('interest.rateNotNegative', null, 'The annual rate may not be negative.'); + assertEnum('creditCadence', ENUMS.INTEREST_CADENCE, s.creditCadence); + if (s.creditCadence === 'weekly') { + if (!Number.isInteger(s.creditWeekday) || s.creditWeekday < 0 || s.creditWeekday > 6) fail('interest.weekdayRequired', null, 'Pick a weekday for the weekly credit.'); + if (s.creditDayOfMonth != null) throw new Error('A weekly schedule has no day of the month.'); + } else if (s.creditCadence === 'monthly') { + if (!Number.isInteger(s.creditDayOfMonth) || s.creditDayOfMonth < 1 || s.creditDayOfMonth > 31) fail('interest.dayOfMonthRequired', null, 'Pick a day of the month for the monthly credit.'); + if (s.creditWeekday != null) throw new Error('A monthly schedule has no weekday.'); + } else if (s.creditWeekday != null || s.creditDayOfMonth != null) { + throw new Error('This cadence uses neither a weekday nor a day of the month.'); + } +} +export async function createSchedule(s) { + assertSchedule(s); + db.run(`INSERT INTO InterestSchedule + (accountId, rateKind, principalAssetId, payoutAssetId, principalAmount, annualRate, startsOn, endsOn, creditCadence, creditWeekday, creditDayOfMonth, compounding, note, createdAt) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [s.accountId, s.rateKind, s.principalAssetId ?? null, s.payoutAssetId, s.principalAmount ?? null, s.annualRate ?? null, + s.startsOn, s.endsOn ?? null, s.creditCadence ?? null, s.creditWeekday ?? null, s.creditDayOfMonth ?? null, + s.compounding ? 1 : 0, s.note ?? null, nowIso()]); + const id = db.lastId(); + await db.persist(); + emitChange('ledger-changed'); + return id; +} +export async function updateSchedule(id, s) { + assertSchedule(s); + if (!s.accountId) fail('interest.schAccountRequired', null, 'A schedule needs an account.'); + trackUpdate('InterestSchedule', id, () => + db.run(`UPDATE InterestSchedule SET + accountId = ?, rateKind = ?, principalAssetId = ?, payoutAssetId = ?, principalAmount = ?, annualRate = ?, + startsOn = ?, endsOn = ?, creditCadence = ?, creditWeekday = ?, creditDayOfMonth = ?, compounding = ?, note = ? + WHERE id = ?`, + [s.accountId, s.rateKind, s.principalAssetId ?? null, s.payoutAssetId, s.principalAmount ?? null, s.annualRate ?? null, + s.startsOn, s.endsOn ?? null, s.creditCadence ?? null, s.creditWeekday ?? null, s.creditDayOfMonth ?? null, + s.compounding ? 1 : 0, s.note ?? null, id])); + await db.persist(); + emitChange('ledger-changed'); +} +export async function deleteSchedule(id) { + trackDelete('InterestSchedule', id, () => db.run(`DELETE FROM InterestSchedule WHERE id = ?`, [id])); + await db.persist(); + emitChange('ledger-changed'); +} + +export function listPeriods(scheduleId) { + return db.all(`SELECT * FROM InterestPeriod WHERE scheduleId = ? ORDER BY startsOn ASC`, [scheduleId]); +} + +function assertPeriod(scheduleId, p) { + const s = db.one(`SELECT startsOn, endsOn, rateKind FROM InterestSchedule WHERE id = ?`, [scheduleId]); + if (!s) fail('interest.unknownSchedule', null, 'Unknown interest schedule.'); + if (s.rateKind !== 'fixed') throw new Error('A schedule on "manual or import" has no rate, and therefore no interest periods.'); + if (!p.startsOn) fail('interest.periodStartRequired', null, 'A period needs a start date.'); + if (p.startsOn <= s.startsOn) { + fail('interest.periodStartBeforeSchedule', { start: s.startsOn }, + `The start date has to be after the start of the schedule (${s.startsOn}); the rate from that day on is on the schedule itself.`); + } + if (s.endsOn && p.startsOn > s.endsOn) { + fail('interest.periodStartAfterEnd', { eind: s.endsOn }, + `The start date is after the end of the schedule (${s.endsOn}).`); + } + if (!(p.annualRate >= 0)) fail('interest.rateNotNegative', null, 'The annual rate may not be negative.'); +} + +export async function createPeriod(scheduleId, p) { + assertPeriod(scheduleId, p); + db.run(`INSERT INTO InterestPeriod (scheduleId, startsOn, annualRate) VALUES (?,?,?)`, + [scheduleId, p.startsOn, p.annualRate]); + const id = db.lastId(); + await db.persist(); + return id; +} + +export async function updatePeriod(id, p) { + const bestaand = db.one(`SELECT scheduleId FROM InterestPeriod WHERE id = ?`, [id]); + if (!bestaand) fail('interest.unknownPeriod', null, 'Unknown interest period.'); + assertPeriod(bestaand.scheduleId, p); + trackUpdate('InterestPeriod', id, () => + db.run(`UPDATE InterestPeriod SET startsOn = ?, annualRate = ? WHERE id = ?`, [p.startsOn, p.annualRate, id])); + await db.persist(); +} + +export async function deletePeriod(id) { + trackDelete('InterestPeriod', id, () => db.run(`DELETE FROM InterestPeriod WHERE id = ?`, [id])); + await db.persist(); +} diff --git a/server/data/labels.js b/server/data/labels.js new file mode 100644 index 0000000..2cb33a6 --- /dev/null +++ b/server/data/labels.js @@ -0,0 +1,29 @@ +// Generated, do not edit. sha256:85ed9f94869ff2f4ce59b8e785f0225bd901edca3d1e5c1790edb2c8a7b0b6fe +import * as db from '../db.js'; +import { toSearchName } from '../../shared/search-name.js'; +import { assertName } from './_shared.js'; +import { trackDelete } from './change-log.js'; +import { fail } from '../../shared/app-error.js'; + +export function listLabels() { return db.all(`SELECT * FROM Label ORDER BY name`); } + +export function labelUsage() { + return db.all(`SELECT Label.id AS labelId, COUNT(WalletTransaction.id) AS walletTransactions + FROM Label + LEFT JOIN WalletTransaction ON WalletTransaction.labelId = Label.id + GROUP BY Label.id + ORDER BY Label.id`); +} + +export async function createLabel({ name }) { + const naam = assertName('Labelnaam', name); + db.run(`INSERT INTO Label (name, searchName) VALUES (?,?)`, [naam, toSearchName(naam)]); + const id = db.lastId(); await db.persist(); return id; +} + +export async function deleteLabel(id) { + const used = db.one(`SELECT (SELECT COUNT(*) FROM WalletTransaction WHERE labelId=?1) AS n`, [id]).n; + if (used > 0) fail('labels.inUse', { n: used }, `The label is used by ${used} item(s).`); + trackDelete('Label', id, () => db.run(`DELETE FROM Label WHERE id=?`, [id])); + await db.persist(); +} diff --git a/server/data/license-buyer.js b/server/data/license-buyer.js new file mode 100644 index 0000000..aec929b --- /dev/null +++ b/server/data/license-buyer.js @@ -0,0 +1,84 @@ +// Generated, do not edit. sha256:1228a952eacb4d1d9d3f5acbdbe35af2071a6bcad5057d2b1aecfd3b41975c0e +import { readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { CONFIG } from '../../shared/config.js'; +import { parseLicense, verifySignature, coversVersion, appVersion, readLicense } from '../license.mjs'; +import { installDir } from '../portfolios.mjs'; +import { marketBuyerLicenses, marketServiceUrl, marketSale, marketPlaceOrder, marketOrderStatus } from '../market-service.mjs'; + +const HEX64 = /^[0-9a-f]{64}$/; +const buyerFile = () => join(installDir(), CONFIG.LICENSE.BUYER_FILE_NAME); + +function rememberedBuyer() { + try { + const key = JSON.parse(readFileSync(buyerFile(), 'utf8'))?.buyer; + return HEX64.test(String(key)) ? key : null; + } catch { + return null; + } +} + +function signerOf(signature) { + const m = /^Nostr ([A-Za-z0-9+/=]+)$/.exec(String(signature ?? '')); + if (!m) throw new Error('A sale call carries an "Authorization: Nostr " header.'); + let pubkey; + try { pubkey = JSON.parse(Buffer.from(m[1], 'base64').toString('utf8'))?.pubkey; } catch { pubkey = null; } + if (!HEX64.test(String(pubkey))) throw new Error('The signed event carries no 64-hex pubkey.'); + return pubkey; +} + +export async function licenseShop() { + return { serviceUrl: await marketServiceUrl(), buyer: rememberedBuyer() }; +} + +export async function buyerLicenses(signature) { + const signer = signerOf(signature); + const list = await marketBuyerLicenses(signature); + writeFileSync(buyerFile(), `${JSON.stringify({ buyer: signer }, null, 2)}\n`); + const judge = judgeAgainstInstalled(); + return list.map(judge); +} + +function judgeAgainstInstalled() { + const version = appVersion(); + const current = readLicense(); + const now = current.state === 'valid' ? current.license : null; + return (file) => { + const text = JSON.stringify(file); + try { + const parsed = parseLicense(text); + const l = parsed.license; + const installed = !!now && ['buyer', 'level', 'issued', 'majorFrom', 'majorTo'].every(k => String(now[k]) === String(l[k])); + return { + file: text, level: l.level, issued: l.issued, majorFrom: l.majorFrom, majorTo: l.majorTo, + valid: verifySignature(parsed), covers: coversVersion(l, version), installed, + }; + } catch { + return { file: text, valid: false, unreadable: true }; + } + }; +} + +const ORDER_ID = /^[0-9a-f]{32}$/; + +export async function saleInfo() { + return marketSale(); +} + +export async function placeOrder(signature, body = '{}') { + signerOf(signature); + if (typeof body !== 'string') throw new Error('The body of an order is the signed JSON text.'); + return marketPlaceOrder(signature, body); +} + +export async function orderStatus(orderId, readKey) { + if (!ORDER_ID.test(String(orderId))) throw new Error('An order id is 32 hex characters.'); + if (!HEX64.test(String(readKey))) throw new Error('A read key is 64 hex characters.'); + const { order, license } = await marketOrderStatus(orderId, readKey); + return { order, license: license ? judgeAgainstInstalled()(license) : null }; +} + +export async function forgetBuyer() { + rmSync(buyerFile(), { force: true }); + return { buyer: null }; +} diff --git a/server/data/license-install.js b/server/data/license-install.js new file mode 100644 index 0000000..c43a782 --- /dev/null +++ b/server/data/license-install.js @@ -0,0 +1,24 @@ +// Generated, do not edit. sha256:446976735f7157c5e0705294189119a2714fb95130e9b9374ed76421fa83e2f5 +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { CONFIG } from '../../shared/config.js'; +import { parseLicense, verifySignature, coversVersion, appVersion } from '../license.mjs'; +import { installDir } from '../portfolios.mjs'; +import { fail } from '../../shared/app-error.js'; + +export async function installLicense(text) { + let parsed; + try { + parsed = parseLicense(String(text ?? '')); + } catch (err) { + fail('license.installUnreadable', { reason: err.message }, `This is not a valid licence file: ${err.message}`); + } + if (!verifySignature(parsed)) { + fail('license.installBadSignature', null, 'The signature does not belong to this licence, or it was not issued for this app.'); + } + + writeFileSync(join(installDir(), CONFIG.LICENSE.FILE_NAME), `${JSON.stringify(parsed, null, 2)}\n`); + + const version = appVersion(); + return { license: parsed.license, version, covers: coversVersion(parsed.license, version) }; +} diff --git a/server/data/license-status.js b/server/data/license-status.js new file mode 100644 index 0000000..43445d4 --- /dev/null +++ b/server/data/license-status.js @@ -0,0 +1,24 @@ +// Generated, do not edit. sha256:a807f05aaf3a8f8a3367166282f6e35755004e775d3ad2970e654848cde4b032 +import { CONFIG } from '../../shared/config.js'; +import { licenseState, appVersion, readLicense, coversVersion } from '../license.mjs'; + +export function licenseStatus() { + const state = licenseState(); + const gratis = state.level !== 'full'; + + const bestand = readLicense(); + const installed = bestand.state === 'valid' ? bestand.license : null; + + return { + level: state.level, + why: state.why, + enforced: state.enforced, + version: appVersion(), + installed, + installedCovers: installed ? coversVersion(installed, appVersion()) : false, + buyer: state.license?.buyer ?? installed?.buyer ?? null, + issued: state.license?.issued ?? installed?.issued ?? null, + max: gratis ? { ...CONFIG.LICENSE.FREE.MAX } : null, + features: gratis ? { ...CONFIG.LICENSE.FREE.HAS } : null, + }; +} diff --git a/server/data/market.js b/server/data/market.js new file mode 100644 index 0000000..2bae4f4 --- /dev/null +++ b/server/data/market.js @@ -0,0 +1,142 @@ +// Generated, do not edit. sha256:5f58c2b253a83045479ff5ac37e60e082b447d2cdda1c0769094b6a2ac261676 +import * as db from '../db.js'; +import { getBaseCurrency } from './portfolio.js'; +import { marketConfigured, marketRegister, marketSeries, marketHistory, marketSpots, isRetryLater } from '../market-service.mjs'; +import { CONFIG } from '../../shared/config.js'; + +const DAY_MS = 86_400_000; + +const unitFactor = (entry) => (entry.unit == null ? 1 : CONFIG.METALS.UNITS[entry.unit] ?? null); +const perStorageUnit = (entry, price) => price / unitFactor(entry); + +const usable = register => (register || []).filter(i => i && typeof i.key === 'string' && unitFactor(i) != null); + +function registerEntry(asset, register) { + const key = `${asset.kind}:${String(asset.symbol || '').toUpperCase()}`; + return usable(register).find(i => i.key === key) || null; +} + +function minuteEntry(asset, register) { + const hit = registerEntry(asset, register); + return hit && Array.isArray(hit.resolutions) && hit.resolutions.includes('minute') ? hit : null; +} + +function unavailable(e) { + if (typeof e?.key === 'string' && e.key.startsWith('marketService.')) { + return { status: 'unavailable', retryLater: isRetryLater(e), key: e.key, params: e.params ?? null, + ...(e.requestId ? { requestId: e.requestId } : {}) }; + } + throw e; +} + +const assetRow = (assetId) => { + const asset = db.one(`SELECT id, kind, symbol FROM Asset WHERE id = ?`, [Number(assetId)]); + if (!asset) throw new Error(`Unknown asset id ${assetId}.`); + return asset; +}; + +export async function intradaySeries(assetId, sinceMs) { + const asset = assetRow(assetId); + const now = Date.now(); + const from = Number(sinceMs); + if (!Number.isFinite(from) || from >= now || now - from > CONFIG.MARKET_SERVICE.INTRADAY_MAX_DAYS * DAY_MS) { + throw new Error('sinceMs has to lie in the past, within the intraday limit.'); + } + if (!(await marketConfigured())) return { status: 'off' }; + try { + const entry = minuteEntry(asset, await marketRegister()); + if (!entry) return { status: 'notCovered' }; + const s = await marketSeries({ instrument: entry.key, quote: getBaseCurrency(), resolution: 'minute', from, to: now }); + const named = src => s.sourceLabels?.[src] ?? src; + return { status: 'ok', instrument: entry.key, currency: s.currency, source: s.source, sourceLabel: s.sourceLabel ?? null, + points: s.points.map(([t, p, src]) => (src ? [t, perStorageUnit(entry, p), named(src)] : [t, perStorageUnit(entry, p)])) }; + } catch (e) { + return unavailable(e); + } +} + +export async function priceAtMoment(assetId, atIso) { + const asset = assetRow(assetId); + const at = Date.parse(String(atIso)); + const now = Date.now(); + if (!Number.isFinite(at) || at > now) throw new Error(`A moment in the past is needed: '${atIso}'.`); + if (!(await marketConfigured())) return { status: 'off' }; + const window = CONFIG.MARKET_SERVICE.MOMENT_WINDOW_MS; + try { + const entry = minuteEntry(asset, await marketRegister()); + if (!entry) return { status: 'notCovered' }; + const s = await marketSeries({ instrument: entry.key, quote: getBaseCurrency(), resolution: 'minute', + from: at - window, to: Math.min(at + window, now) }); + let best = null; + for (const [t, p, src] of s.points || []) { + if (!Number.isFinite(t) || !Number.isFinite(p) || Math.abs(t - at) > window) continue; + if (!best || Math.abs(t - at) < Math.abs(best[0] - at)) best = [t, p, src]; + } + if (!best) return { status: 'missing' }; + return { status: 'ok', price: perStorageUnit(entry, best[1]), currency: s.currency, at: best[0], + source: best[2] || s.source }; + } catch (e) { + return unavailable(e); + } +} + +export async function serviceDayHistory(assetId, sinceMs) { + const asset = assetRow(assetId); + const now = Date.now(); + const from = Number(sinceMs); + if (!Number.isFinite(from) || from >= now || now - from > CONFIG.MARKET_SERVICE.HISTORY_MAX_DAYS * DAY_MS + DAY_MS) { + throw new Error('sinceMs has to lie in the past, within the history limit.'); + } + if (!(await marketConfigured())) return { status: 'off' }; + try { + const entry = registerEntry(asset, await marketRegister()); + if (!entry || !Array.isArray(entry.kinds) || !entry.kinds.includes('history')) return { status: 'notCovered' }; + const h = await marketHistory({ instrument: entry.key, quote: getBaseCurrency(), from, to: now }); + const today = Math.floor(now / DAY_MS) * DAY_MS; + const named = src => h.sourceLabels?.[src] ?? src; + const days = h.points.filter(([t, , resolution]) => resolution === 'day' && t < today) + .map(([t, p, , src]) => (src ? [Math.floor(t / DAY_MS) * DAY_MS, perStorageUnit(entry, p), named(src)] + : [Math.floor(t / DAY_MS) * DAY_MS, perStorageUnit(entry, p)])); + return { status: 'ok', instrument: entry.key, currency: h.currency, source: h.source, days }; + } catch (e) { + return unavailable(e); + } +} + +export async function serviceCoverage() { + if (!(await marketConfigured())) return { status: 'off' }; + try { + const register = await marketRegister(); + const assetIds = db.all(`SELECT id, kind, symbol FROM Asset ORDER BY id`) + .filter(a => registerEntry(a, register)) + .map(a => a.id); + return { status: 'ok', assetIds }; + } catch (e) { + return unavailable(e); + } +} + +export async function serviceSpot(assetId) { + const asset = assetRow(assetId); + if (!(await marketConfigured())) return { status: 'off' }; + const today = new Date().toISOString().slice(0, 10); + const manual = db.one( + `SELECT 1 AS x FROM PriceHistory JOIN Provider ON Provider.id = PriceHistory.providerId + WHERE PriceHistory.assetId = ? AND Provider.key = 'manual' AND substr(PriceHistory.observedAt, 1, 10) = ?`, + [asset.id, today]); + if (manual) return { status: 'local' }; + try { + const register = await marketRegister(); + const entry = registerEntry(asset, register); + if (!entry) return { status: 'notCovered' }; + const r = await marketSpots(usable(register).map(i => i.key), getBaseCurrency()); + const hit = r.spot.find(s => s.instrument === entry.key); + if (hit) return { status: 'ok', ...hit, price: perStorageUnit(entry, hit.price) }; + if (r.unreachable.includes(entry.key)) { + return { status: 'unavailable', retryLater: true, key: 'marketService.spotUnreachable', params: null }; + } + return { status: 'missing' }; + } catch (e) { + return unavailable(e); + } +} diff --git a/server/data/metals.js b/server/data/metals.js new file mode 100644 index 0000000..d7732db --- /dev/null +++ b/server/data/metals.js @@ -0,0 +1,88 @@ +// Generated, do not edit. sha256:8eb5f3330fcdfdbe9052fd4560a6ccd48f5f3aa7ef341ffd35df63e368fe3a71 +import * as db from '../db.js'; +import { toSearchName } from '../../shared/search-name.js'; +import { ENUMS } from '../../shared/config.js'; +import { assertEnum } from './_shared.js'; +import { fail } from '../../shared/app-error.js'; +import { trackDelete, trackUpdate } from './change-log.js'; + +export function hasMetalAssets() { + return db.one(`SELECT 1 AS x FROM Asset WHERE kind = 'metal' LIMIT 1`) != null; +} +export function listMetalAssets() { + return db.all(`SELECT * FROM Asset WHERE kind = 'metal' ORDER BY symbol`); +} +export function listMetalProducts() { + return db.all(`SELECT mp.*, a.symbol AS asset_symbol, a.name AS asset_name + FROM MetalProduct mp JOIN Asset a ON a.id = mp.assetId + ORDER BY a.symbol, mp.form, mp.fineGrams`); +} +export function listMetalProductsForAsset(assetId) { + return db.all(`SELECT * FROM MetalProduct WHERE assetId = ? ORDER BY form, fineGrams`, [assetId]); +} +function assertMetalProduct({ assetId, name, form, fineGrams }) { + assertEnum('form', ENUMS.METAL_FORM, form); + if (!Number.isInteger(assetId)) fail('metalProducts.assetRequired', null, 'Pick a valid metal asset.'); + if (!String(name || '').trim()) fail('metalProducts.nameRequired', null, 'The name is required.'); + if (!Number.isInteger(fineGrams) || fineGrams <= 0) fail('metalProducts.fineGramsPositive', null, 'The fine weight has to be greater than 0.'); + const a = db.one(`SELECT kind FROM Asset WHERE id = ?`, [assetId]); + if (!a || a.kind !== 'metal') fail('metalProducts.notMetal', null, 'The chosen asset is not a metal.'); +} +export async function createMetalProduct(p) { + assertMetalProduct(p); + db.run(`INSERT INTO MetalProduct (assetId, name, searchName, form, fineGrams) VALUES (?,?,?,?,?)`, + [p.assetId, p.name.trim(), toSearchName(p.name), p.form, p.fineGrams]); + const id = db.lastId(); + await db.persist(); + return id; +} +export async function updateMetalProduct(id, p) { + assertMetalProduct(p); + trackUpdate('MetalProduct', id, () => + db.run(`UPDATE MetalProduct SET assetId = ?, name = ?, searchName = ?, form = ?, fineGrams = ? WHERE id = ?`, + [p.assetId, p.name.trim(), toSearchName(p.name), p.form, p.fineGrams, id])); + await db.persist(); +} +export async function deleteMetalProduct(id) { + const used = db.one(`SELECT COUNT(*) AS n FROM Event WHERE productId = ?`, [id]).n; + if (used > 0) fail('metalProducts.inUse', { n: used }, `Product is used by ${used} event(s); remove those first.`); + trackDelete('MetalProduct', id, () => db.run(`DELETE FROM MetalProduct WHERE id = ?`, [id])); + await db.persist(); +} + +export function metalHoldings({ ownOnly = false } = {}) { + return db.all(` + SELECT mp.id, mp.name, mp.form, mp.fineGrams, mp.assetId, + a.symbol AS asset_symbol, a.name AS asset_name, + COALESCE(SUM(CASE + WHEN e.type = 'buy' AND e.toIn THEN e.productCount + WHEN e.type = 'sell' AND e.fromIn THEN -e.productCount + -- Only a transfer between two ACCOUNTS of which one is own: a transfer out of the + -- administration counts no pieces in either mode, so the two modes agree without a scope. + WHEN e.type = 'transfer' AND ?1 = 1 AND e.toAccountId IS NOT NULL AND e.fromAccountId IS NOT NULL THEN + (CASE WHEN e.toIn AND NOT e.fromIn THEN e.productCount ELSE 0 END) + - (CASE WHEN e.fromIn AND NOT e.toIn THEN e.productCount ELSE 0 END) + ELSE 0 END), 0) AS net_count, + COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn THEN e.productCount ELSE 0 END), 0) AS buy_count, + COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN 1 ELSE 0 END), 0) AS buy_events, + COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN COALESCE(e.fiatFee,0) ELSE 0 END), 0) AS buy_premium, + COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN e.amount ELSE 0 END), 0) AS buy_amount, + COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN e.amount * COALESCE(e.fiatPrice,0) ELSE 0 END), 0) AS buy_spot_raw, + COALESCE(SUM(CASE WHEN e.priceOrigin IS 'none' THEN + (CASE WHEN e.type = 'buy' AND e.toIn THEN e.amount WHEN e.type = 'sell' AND e.fromIn THEN -e.amount ELSE 0 END) + ELSE 0 END), 0) AS gift_amount + FROM MetalProduct mp + JOIN Asset a ON a.id = mp.assetId + LEFT JOIN ( + SELECT Event.*, + (?1 = 0 OR toAccount.wealthScope IS NULL) AS toIn, + (?1 = 0 OR fromAccount.wealthScope IS NULL) AS fromIn + FROM Event + LEFT JOIN Account toAccount ON toAccount.id = Event.toAccountId + LEFT JOIN Account fromAccount ON fromAccount.id = Event.fromAccountId + WHERE Event.productCount IS NOT NULL + ) e ON e.productId = mp.id + GROUP BY mp.id + HAVING net_count > 0 + ORDER BY a.symbol, mp.form, mp.fineGrams`, [ownOnly ? 1 : 0]); +} diff --git a/server/data/notifications.js b/server/data/notifications.js new file mode 100644 index 0000000..b1f9c66 --- /dev/null +++ b/server/data/notifications.js @@ -0,0 +1,98 @@ +// Generated, do not edit. sha256:2e1c42ef1941376d221fab1a61b5e60c9be06530764fa9486b752263252805ed +import * as db from '../db.js'; +import { ENUMS } from '../../shared/config.js'; +import { nowIso, todayIso } from './_shared.js'; +import { listPendingAiProposals } from './ai-proposals.js'; +import { listPendingProposals } from './proposals.js'; +import { dueCreditsAll } from './interest-credits.js'; +import { listOpenValuationDiffs } from './valuations.js'; +import { lightningBalanceDiffs } from './positions.js'; +import { listDueReservations } from './reservations.js'; + +const dateToInt = d => Number(String(d).slice(0, 10).replace(/-/g, '')); + +const NOTIFICATION_SOURCES = { + reconcile: { action: true, state() { + const p = listPendingProposals(); + return { count: p.length, highWater: p.reduce((m, x) => Math.max(m, x.id), 0) }; + } }, + interest: { action: true, state() { + const due = dueCreditsAll(); + return { count: due.length, highWater: due.reduce((m, d) => Math.max(m, dateToInt(d.periodEnd)), 0) }; + } }, + valuation: { action: true, state() { + const diffs = listOpenValuationDiffs(); + return { count: diffs.length + lightningBalanceDiffs().length, highWater: diffs.reduce((m, v) => Math.max(m, v.id), 0) }; + } }, + schedule_ended: { action: false, state() { + const rows = db.all(`SELECT id, endsOn FROM InterestSchedule WHERE endsOn < ?`, [todayIso()]); + return { count: rows.length, highWater: rows.reduce((m, r) => Math.max(m, dateToInt(r.endsOn)), 0) }; + } }, + source_proposal: { action: true, state() { + const rows = db.all(`SELECT assetId, MAX(id) AS mid FROM PriceSource WHERE status = 'proposed' GROUP BY assetId`); + return { count: rows.length, highWater: rows.reduce((m, r) => Math.max(m, r.mid), 0) }; + } }, + ai_proposal: { action: true, state() { + const rows = listPendingAiProposals(); + return { count: rows.length, highWater: rows.reduce((m, r) => Math.max(m, r.id), 0) }; + } }, + reservation: { action: true, state() { + const rows = listDueReservations(); + return { count: rows.length, highWater: rows.reduce((m, r) => Math.max(m, dateToInt(r.occurredAt)), 0) }; + } }, +}; + +export async function refreshNotifications() { + let changed = false; + for (const kind of ENUMS.NOTIFICATION_KIND) { + const src = NOTIFICATION_SOURCES[kind]; + if (!src) continue; + const { count, highWater } = src.state(); + const active = db.one(`SELECT id, count, highWater FROM Notification WHERE kind = ? AND dismissedAt IS NULL ORDER BY id DESC LIMIT 1`, [kind]); + + if (count === 0) { + if (src.action && active) { db.run(`DELETE FROM Notification WHERE id = ?`, [active.id]); changed = true; } + continue; + } + if (active) { + if (active.count !== count || active.highWater !== highWater) { + db.run(`UPDATE Notification SET count = ?, highWater = ? WHERE id = ?`, [count, highWater, active.id]); + changed = true; + } + continue; + } + const last = db.one(`SELECT highWater FROM Notification WHERE kind = ? ORDER BY id DESC LIMIT 1`, [kind]); + if (!last || highWater > last.highWater) { + db.run(`INSERT INTO Notification (kind, count, highWater, createdAt) VALUES (?,?,?,?)`, [kind, count, highWater, nowIso()]); + changed = true; + } + } + if (changed) await db.persist(); +} + +export function listActiveNotifications() { + return db.all(`SELECT id, kind, count, highWater, createdAt FROM Notification WHERE dismissedAt IS NULL ORDER BY id DESC`); +} + +export async function currentNotifications() { + await refreshNotifications(); + return listActiveNotifications(); +} + +export function notificationDetail(n) { + if (n.kind !== 'schedule_ended') return null; + const rows = db.all(` + SELECT s.accountId, ac.name AS account, po.symbol AS asset + FROM InterestSchedule s + JOIN Account ac ON ac.id = s.accountId + JOIN Asset po ON po.id = s.payoutAssetId + WHERE s.endsOn < ? + ORDER BY s.endsOn DESC, s.id DESC`, [todayIso()]); + if (!rows.length) return null; + return { accountId: rows[0].accountId, account: rows[0].account, asset: rows[0].asset, extra: rows.length - 1 }; +} + +export async function dismissNotification(id) { + db.run(`UPDATE Notification SET dismissedAt = ? WHERE id = ? AND dismissedAt IS NULL`, [nowIso(), id]); + await db.persist(); +} diff --git a/server/data/onboarding.js b/server/data/onboarding.js new file mode 100644 index 0000000..7e4fac4 --- /dev/null +++ b/server/data/onboarding.js @@ -0,0 +1,83 @@ +// Generated, do not edit. sha256:329990ff910102e84cb67048624a913db4948fa6ef673de3eb40c65c633864ba +import * as db from '../db.js'; +import { ASSET_DEFINITIONS, DASHBOARD } from '../../shared/config.js'; +import { toSearchName } from '../../shared/search-name.js'; +import { getPref, setPref } from './preferences.js'; +import { createAsset } from './assets.js'; +import { createAccount } from './accounts.js'; +import { activeProfile, addWidget, listWidgets } from './dashboard.js'; +import { currentCount, capacityReachedFor } from './_limits.js'; + +export function setupStatus() { + const done = Number(getPref('app', 'setup_done')) === 1; + const empty = currentCount('assets') === 0; + const own = db.one(`SELECT (SELECT COUNT(*) FROM Account) + (SELECT COUNT(*) FROM Event) + + (SELECT COUNT(*) FROM Valuation) AS n`).n; + return { needed: !done && empty, done, empty, persona: getPref('app', 'persona'), + currencyOpen: !done && empty && own === 0 }; +} + +function assertKnownAsset(symbol) { + if (!ASSET_DEFINITIONS[symbol]) { + throw new Error(`Unknown asset '${symbol}'. Allowed: ${Object.keys(ASSET_DEFINITIONS).join(', ')}.`); + } +} + +async function ensureAsset(symbol) { + const def = ASSET_DEFINITIONS[symbol]; + const existing = db.one(`SELECT id FROM Asset WHERE symbol = ?`, [def.symbol]); + if (existing) return existing.id; + return capacityReachedFor('assets') === null ? createAsset(def) : null; +} + +async function ensureAccount({ name, kind }) { + const existing = db.one(`SELECT id FROM Account WHERE searchName = ?`, [toSearchName(name)]); + if (existing) return existing.id; + if (kind === 'bank' && capacityReachedFor('fiatAccounts') !== null) return null; + const own = kind === 'wallet' || kind === 'vault'; + return createAccount({ name, kind, isOwnCustody: own, scanEnabled: false, taxCategory: null }); +} + +function placeInFreeSpace(rows, items) { + const cols = DASHBOARD.GRID_COLUMNS; + const heightOf = (type, h) => (h > 0 ? h : DASHBOARD.WIDGETS[type]?.defaultH || 1); + const bottom = new Array(cols).fill(0); + for (const r of rows) { + const end = r.gridY + heightOf(r.type, r.spanHeight); + for (let c = r.gridX; c < Math.min(cols, r.gridX + r.spanWidth); c++) bottom[c] = Math.max(bottom[c], end); + } + return items.map(({ prefer, ...w }) => { + const span = DASHBOARD.WIDGETS[w.type].defaultSpan; + let bestX = 0, bestY = Infinity; + for (let x = 0; x + span <= cols; x++) { + const y = Math.max(...bottom.slice(x, x + span)); + if (y < bestY || (y === bestY && x === prefer)) { bestY = y; bestX = x; } + } + for (let c = bestX; c < bestX + span; c++) bottom[c] = bestY + heightOf(w.type, null); + return { ...w, gridX: bestX, gridY: bestY }; + }); +} + +export async function completeSetup({ assets = [], persona = 'broad', accounts = [], widgets = [] } = {}) { + assets.forEach(assertKnownAsset); + await setPref('app', 'persona', persona); + + const assetIds = []; + for (const s of assets) { + const id = await ensureAsset(s); + if (id != null) assetIds.push(id); + } + let accountCount = 0; + for (const a of accounts) if ((await ensureAccount(a)) != null) accountCount++; + + const profileId = activeProfile().id; + const add = []; + if (widgets.includes('standing')) add.push({ type: 'standing', prefer: 4 }); + if (widgets.includes('signal') && persona === 'bitcoin') add.push({ type: 'signal', prefer: 4 }); + if (widgets.includes('spot')) assetIds.forEach(id => add.push({ type: 'spot', instanceKey: `spot-${id}`, assetId: id, prefer: 0, settings: { momentum: false } })); + let widgetCount = 0; + for (const w of placeInFreeSpace(listWidgets(profileId), add)) if (await addWidget(profileId, w)) widgetCount++; + + await setPref('app', 'setup_done', 1); + return { persona, assetIds, accounts: accountCount, widgets: widgetCount }; +} diff --git a/server/data/portfolio.js b/server/data/portfolio.js new file mode 100644 index 0000000..ceca6de --- /dev/null +++ b/server/data/portfolio.js @@ -0,0 +1,8 @@ +// Generated, do not edit. sha256:57354eb3f01496daabc5a7208eceb7ac61ff77a12ab41b4a5effdfd3d90f3cc1 +import * as db from '../db.js'; + +export function getBaseCurrency() { + const row = db.one(`SELECT baseCurrency FROM Portfolio WHERE id = 1`); + if (!row) throw new Error('Portfolio row missing: this database has no unit of account.'); + return row.baseCurrency; +} diff --git a/server/data/positions.js b/server/data/positions.js new file mode 100644 index 0000000..4d7f1f1 --- /dev/null +++ b/server/data/positions.js @@ -0,0 +1,143 @@ +// Generated, do not edit. sha256:c2c77c87420dd87960f52c5550a95a9491166b15f248300e49e13aae650240fc +import { nowUtcIso } from '../../shared/time.js'; +import { fail } from '../../shared/app-error.js'; +import { assetPositions, eventAccountIdsByAsset, eventAssetIdsByAccount, eventsCountByAccount, + eventsNetByAccount, firstTaxEventDate } from './events.js'; +import { anchorAt, anchorsBetween, createValuation, firstAnchorDate, latestAnchors, listValuations, + updateValuation, valuationDiff, valuationGap, valuationReconcilable } from './valuations.js'; +import { accountOnchainBalance } from './wallet-addresses.js'; +import { lightningBalances } from './wallet-sources.js'; +import { onchainAsset } from './tx-links.js'; + +export function positionAt(accountId, assetId, atIso) { + const anker = anchorAt(accountId, assetId, atIso); + if (!anker) return eventsNetByAccount(accountId, assetId, atIso); + + const naAnker = eventsNetByAccount(accountId, assetId, atIso) - eventsNetByAccount(accountId, assetId, anker.rateAt); + return anker.balance + naAnker; +} + +export function positionsByAsset(atIso, { ownOnly = false } = {}) { + const peil = atIso ?? nowUtcIso(); + + const correctie = new Map(); + for (const anker of latestAnchors(peil, { ownOnly })) { + const diff = valuationDiff(anker); + if (diff) correctie.set(anker.assetId, (correctie.get(anker.assetId) || 0) + diff); + } + const rijen = assetPositions(peil, { ownOnly }); + if (correctie.size === 0) return rijen; + return rijen.map(r => (correctie.has(r.id) ? { ...r, net: r.net + correctie.get(r.id) } : r)); +} + +export function unexplainedChange(accountId, assetId, fromIso, untilIso) { + const stand = positionAt(accountId, assetId, untilIso) - positionAt(accountId, assetId, fromIso); + const geboekt = eventsNetByAccount(accountId, assetId, untilIso) - eventsNetByAccount(accountId, assetId, fromIso); + return stand - geboekt; +} + +export function firstPositionDate() { + const gebeurtenis = firstTaxEventDate(); + const anker = firstAnchorDate(); + if (!gebeurtenis) return anker; + if (!anker) return gebeurtenis; + return gebeurtenis < anker ? gebeurtenis : anker; +} + +export function unexplainedByPair(fromIso, untilIso) { + const uit = []; + for (const anker of latestAnchors(untilIso)) { + const units = unexplainedChange(anker.accountId, anker.assetId, fromIso, untilIso); + if (!units) continue; + let openingUnits = 0; + for (const v of anchorsBetween(anker.accountId, anker.assetId, fromIso, untilIso)) { + if (valuationReconcilable(v) && !v.gapAcceptedAt) continue; + openingUnits += valuationGap(v) || 0; + } + uit.push({ + accountId: anker.accountId, assetId: anker.assetId, units, openingUnits, + hasLedger: eventsCountByAccount(anker.accountId, anker.assetId, untilIso) > 0, + }); + } + return uit; +} + +export function assetPositionsByAccount(assetId, atIso) { + const peil = atIso ?? nowUtcIso(); + const accounts = new Set(eventAccountIdsByAsset(assetId)); + for (const anker of latestAnchors(peil)) if (anker.assetId === assetId) accounts.add(anker.accountId); + + const uit = []; + for (const accountId of accounts) { + const saldo = positionAt(accountId, assetId, peil); + if (saldo !== 0) uit.push({ accountId, saldo }); + } + uit.sort((a, b) => b.saldo - a.saldo || a.accountId - b.accountId); + + const totaal = positionsByAsset(peil).find(r => r.id === assetId); + const rest = (totaal ? totaal.net : 0) - uit.reduce((s, r) => s + r.saldo, 0); + if (rest !== 0) uit.push({ accountId: null, saldo: rest }); + return uit; +} + +export function accountPositions(accountId, atIso) { + const peil = atIso ?? nowUtcIso(); + const assets = new Set(eventAssetIdsByAccount(accountId)); + for (const anker of latestAnchors(peil)) if (anker.accountId === accountId) assets.add(anker.assetId); + + const uit = []; + for (const assetId of [...assets].sort((a, b) => a - b)) { + const saldo = positionAt(accountId, assetId, peil); + if (saldo !== 0) uit.push({ assetId, saldo }); + } + return uit; +} + +export function accountHoldings(accountIds = [], atIso) { + const peil = atIso ?? nowUtcIso(); + const uit = []; + for (const accountId of accountIds) { + for (const { assetId, saldo } of accountPositions(accountId, peil)) { + const anker = anchorAt(accountId, assetId, peil); + uit.push({ accountId, assetId, saldo, + anchor: anker ? { valuedOn: anker.valuedOn, rateAt: anker.rateAt, balance: anker.balance } : null }); + } + } + return uit; +} + +function measuredNodeBalance(row) { + return accountOnchainBalance(row.accountId) + (Number(row.sats) || 0); +} + +export function lightningBalanceDiffs() { + const asset = onchainAsset(); + if (!asset) return []; + const uit = []; + for (const row of lightningBalances()) { + const measured = measuredNodeBalance(row); + const ledger = positionAt(row.accountId, asset.id, row.scannedAt); + if (measured !== ledger) { + uit.push({ accountId: row.accountId, account_name: row.account_name, assetId: asset.id, + measured, ledger, gap: measured - ledger, scannedAt: row.scannedAt }); + } + } + return uit; +} + +export async function recordLightningBalance(accountId, { valuedOn, fiatRate } = {}) { + const row = lightningBalances().find(r => r.accountId === Number(accountId)); + const asset = onchainAsset(); + if (!row || !asset) throw new Error(`Account ${accountId} has no updated lightning source.`); + if (!/^\d{4}-\d{2}-\d{2}$/.test(String(valuedOn))) throw new Error(`A balance needs its day: '${valuedOn}'.`); + if (!(Number(fiatRate) > 0)) throw new Error(`A balance needs a rate above zero: '${fiatRate}'.`); + const v = { assetId: asset.id, accountId: row.accountId, valuedOn, balance: measuredNodeBalance(row), + fiatRate: Number(fiatRate), rateAt: row.scannedAt, source: 'onchain' }; + const zelfdeDag = listValuations().find(x => x.accountId === v.accountId && x.assetId === v.assetId && x.valuedOn === valuedOn); + if (zelfdeDag && zelfdeDag.source !== 'onchain') { + fail('valuations.lightningSameDay', { date: valuedOn }, + `There is already a balance on ${valuedOn} for this account; it is not overwritten.`); + } + if (zelfdeDag) { await updateValuation(zelfdeDag.id, v); return zelfdeDag.id; } + return createValuation(v); +} diff --git a/server/data/preferences.js b/server/data/preferences.js new file mode 100644 index 0000000..013da1a --- /dev/null +++ b/server/data/preferences.js @@ -0,0 +1,74 @@ +// Generated, do not edit. sha256:816fe4fc80fac9d9927e815f090b57c44b134b1e4cc673a3ba8e1be8e308842d +import * as db from '../db.js'; +import { PREFERENCES } from '../../shared/config.js'; +import { assertEnum } from './_shared.js'; +import { getBaseCurrency } from './portfolio.js'; +import { fail } from '../../shared/app-error.js'; +import { installLocale, setInstallLocale } from '../installation.mjs'; + +function prefSpec(scope, key) { + const s = PREFERENCES[scope]; + if (!s) return undefined; + return s[key] || s['*']; +} + +export function getPref(scope, key, fallback = undefined) { + const spec = prefSpec(scope, key); + const row = db.one(`SELECT value FROM Preference WHERE scope = ? AND key = ?`, [scope, key]); + if (row) { + const v = JSON.parse(row.value); + if (spec && spec.type === 'enum' && Array.isArray(spec.allowed) && !spec.allowed.includes(v)) return spec.initial; + return v; + } + return spec ? spec.initial : fallback; +} + +export async function setPref(scope, key, value) { + const spec = prefSpec(scope, key); + if (spec) { + if (spec.type === 'enum') assertEnum(`${scope}.${key}`, spec.allowed, value); + else if (spec.type === 'bool') { + value = Number(value); + if (value !== 0 && value !== 1) fail('settings.prefBadValue', { scope, key, waarde: value }, `Invalid value for ${scope}.${key}: ${value}.`); + } + else if (spec.type === 'int') { + value = Number(value); + if (!Number.isInteger(value)) fail('settings.prefWantsInteger', { scope, key, waarde: value }, `Invalid value for ${scope}.${key}: ${value} (an integer was expected).`); + if (spec.min != null && value < spec.min) fail('settings.prefBelowMin', { scope, key, min: spec.min }, `${scope}.${key} may not be smaller than ${spec.min}.`); + if (spec.max != null && value > spec.max) fail('settings.prefAboveMax', { scope, key, max: spec.max }, `${scope}.${key} may not be greater than ${spec.max}.`); + } + } + db.run(`INSERT INTO Preference (scope, key, value) VALUES (?,?,?) + ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value`, + [scope, key, JSON.stringify(value)]); + await db.persist(); +} + +export function getSettings() { + const out = {}; + for (const [key] of Object.entries(PREFERENCES.app)) { + out[key] = getPref('app', key); + } + out.locale = installLocale(); + out.version = db.schemaVersion(); + out.baseCurrency = getBaseCurrency(); + return out; +} + +export async function updateSettings(patch) { + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + if (key === 'locale') { setInstallLocale(value); continue; } + if (!PREFERENCES.app[key]) fail('settings.unknownSetting', { key }, `Unknown setting: ${key}.`); + await setPref('app', key, value); + } +} + +export function forgetPrefRow(scope, key) { + db.run(`DELETE FROM Preference WHERE scope = ? AND key = ?`, [scope, key]); +} + +export async function deletePref(scope, key) { + forgetPrefRow(scope, key); + await db.persist(); +} diff --git a/server/data/price-history.js b/server/data/price-history.js new file mode 100644 index 0000000..da48dfd --- /dev/null +++ b/server/data/price-history.js @@ -0,0 +1,296 @@ +// Generated, do not edit. sha256:7aabe3b0d2c7bf04e76cf1fb822d83923d1b0287d69de575f4f58ac8cbd62381 +import * as db from '../db.js'; +import { ENUMS, CONFIG } from '../../shared/config.js'; +import { assertEnum, addDaysIso, daysBetween } from './_shared.js'; +import { fail } from '../../shared/app-error.js'; +import { isMetal, metalFactor } from '../../shared/units.js'; + +function providerIdVoor(key) { + const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [key]); + if (!p) throw new Error(`Unknown price source '${key}': there is no provider with that key.`); + return p.id; +} + +const positief = v => (v != null && Number(v) > 0 ? v : null); + +export async function upsertPriceHistory(assetId, points, source, resolution) { + assertEnum('resolution', ENUMS.PRICE_RESOLUTION, resolution); + const providerId = providerIdVoor(source); + const now = new Date().toISOString(); + for (const punt of points) { + const prijs = positief(punt.price); + if (prijs == null) continue; + const iso = new Date(punt.time * 1000).toISOString(); + const observedAt = resolution === 'day' ? `${iso.slice(0, 10)}T00:00:00Z` : iso; + const sourceName = typeof punt.sourceName === 'string' && punt.sourceName ? punt.sourceName : null; + db.run(`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, createdAt, updatedAt, sourceName) + VALUES (?,?,?,?,?,?,?,?) + ON CONFLICT(assetId, providerId, observedAt, resolution) DO UPDATE SET + fiatPrice = excluded.fiatPrice, updatedAt = excluded.updatedAt, sourceName = excluded.sourceName`, + [assetId, observedAt, resolution, prijs, providerId, now, now, sourceName]); + } + await db.persist(); +} + +export async function addManualPricePoint(payload = {}) { + const { assetId, observedAt, fiatPrice, resolution = 'day', unit = null } = payload; + assertEnum('resolution', ENUMS.PRICE_RESOLUTION, resolution); + const asset = db.one(`SELECT id, kind, unit, subunit, displayUnit FROM Asset WHERE id = ?`, [assetId]); + if (!asset) { + fail('priceHistory.manualUnknownAsset', {}, `Asset ${assetId} does not exist.`); + } + const ms = Date.parse(observedAt); + if (!Number.isFinite(ms)) { + fail('priceHistory.manualBadMoment', {}, `'${observedAt}' is not a valid moment.`); + } + if (ms > Date.now()) { + fail('priceHistory.manualFutureMoment', {}, `'${observedAt}' lies in the future.`); + } + const koers = Number(fiatPrice); + if (!Number.isFinite(koers) || koers <= 0) { + fail('priceHistory.manualBadPrice', {}, `A price must be a number above zero, got '${fiatPrice}'.`); + } + const eenheid = unit || asset.unit; + const naarBasis = p => (isMetal(asset) ? p / metalFactor(eenheid) : p); + await upsertPriceHistory(assetId, [{ time: Math.floor(ms / 1000), price: naarBasis(koers) }], 'manual', resolution); + const observed = resolution === 'day' + ? `${new Date(ms).toISOString().slice(0, 10)}T00:00:00Z` + : new Date(ms).toISOString(); + const rij = db.one( + `SELECT id FROM PriceHistory + WHERE assetId = ? AND providerId = ? AND observedAt = ? AND resolution = ?`, + [assetId, providerIdVoor('manual'), observed, resolution]); + return rij ? rij.id : null; +} + +const DEKKING = `AND EXISTS ( + SELECT 1 FROM Provider + WHERE Provider.id = PriceHistory.providerId + AND (Provider.isSystem = 1 + OR EXISTS (SELECT 1 FROM ProviderCoverage + JOIN Asset ON Asset.id = PriceHistory.assetId + WHERE ProviderCoverage.providerId = Provider.id + AND ProviderCoverage.symbol = Asset.symbol)))`; + +export function listPriceHistory(assetId, fromDate) { + return fromDate + ? db.all(`SELECT * FROM PriceHistory WHERE assetId = ? AND observedAt >= ? ${DEKKING} ORDER BY observedAt DESC`, [assetId, `${fromDate}T00:00:00Z`]) + : db.all(`SELECT * FROM PriceHistory WHERE assetId = ? ${DEKKING} ORDER BY observedAt DESC`, [assetId]); +} + +export function latestPricePoint(assetId) { + return db.one(`SELECT * FROM PriceHistory WHERE assetId = ? ${DEKKING} ORDER BY observedAt DESC, id DESC LIMIT 1`, + [assetId]) || null; +} + +export function latestSpotPoint(assetId, providerKey) { + const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [providerKey]); + if (!p) return null; + return db.one(`SELECT * FROM PriceHistory + WHERE assetId = ? AND providerId = ? AND resolution = 'timestamp' AND fiatPrice > 0 + ORDER BY observedAt DESC, id DESC LIMIT 1`, [assetId, p.id]) || null; +} + +export function priceSeriesExport() { + return db.all( + `SELECT Asset.kind AS kind, Asset.symbol AS symbol, Provider.key AS source, PriceHistory.resolution AS resolution, + PriceHistory.observedAt AS observedAt, PriceHistory.createdAt AS createdAt, PriceHistory.fiatPrice AS fiatPrice + FROM PriceHistory + JOIN Asset ON Asset.id = PriceHistory.assetId + JOIN Provider ON Provider.id = PriceHistory.providerId + WHERE Provider.key NOT IN ('manual', 'atreides_services', 'fixed') ${DEKKING} + ORDER BY Asset.kind, Asset.symbol, Provider.key, PriceHistory.resolution, PriceHistory.observedAt`); +} + +export function priceHistoryCount(assetId) { + return db.one(`SELECT COUNT(*) AS n FROM PriceHistory WHERE assetId = ?`, [assetId]).n; +} + +function laatsteSpotVanDag(assetId, dag, preferSource = null) { + const cands = db.all(`SELECT * FROM PriceHistory + WHERE assetId = ? AND resolution = 'timestamp' AND substr(observedAt, 1, 10) = ? + AND fiatPrice > 0 ${DEKKING}`, [assetId, dag]); + const r = pickPreferredRow(cands, preferSource, (a, b) => (a.observedAt < b.observedAt ? 1 : a.observedAt > b.observedAt ? -1 : 0)); + return r ? { ...r, derivedFrom: 'spot' } : null; +} + +export function getPricePoint(assetId, onDate, { derived = true } = {}) { + const dagrij = db.one(`SELECT * FROM PriceHistory + WHERE assetId = ? AND resolution = 'day' AND observedAt = ? ${DEKKING} + ORDER BY id DESC LIMIT 1`, [assetId, `${onDate}T00:00:00Z`]) || null; + if (dagrij || !derived) return dagrij; + return laatsteSpotVanDag(assetId, onDate); +} + +function pickPreferredRow(cands, preferSource, cmp) { + if (!cands.length) return null; + const sorted = [...cands].sort(cmp); + if (preferSource) { + const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [preferSource]); + const pref = p ? sorted.filter(r => r.providerId === p.id) : []; + if (pref.length) return pref[0]; + } + return sorted[0]; +} + +export function getPriceAt(assetId, tsUnix, preferSource = null) { + const iso = new Date(tsUnix * 1000).toISOString(); + const dayKey = iso.slice(0, 10); + const maxSec = CONFIG.PRICE_HISTORY.NEAREST_MAX_SECONDS; + const tsCand = [ + db.one(`SELECT * FROM PriceHistory WHERE assetId = ? AND resolution = 'timestamp' AND observedAt <= ? ${DEKKING} ORDER BY observedAt DESC LIMIT 1`, [assetId, iso]), + db.one(`SELECT * FROM PriceHistory WHERE assetId = ? AND resolution = 'timestamp' AND observedAt >= ? ${DEKKING} ORDER BY observedAt ASC LIMIT 1`, [assetId, iso]), + ].filter(Boolean) + .map(r => ({ ...r, dist_seconds: Math.abs(Date.parse(r.observedAt) / 1000 - tsUnix) })) + .filter(r => r.dist_seconds <= maxSec); + const ts = pickPreferredRow(tsCand, preferSource, (a, b) => a.dist_seconds - b.dist_seconds); + if (ts) return ts; + const dayCand = db.all(`SELECT * FROM PriceHistory WHERE assetId = ? AND resolution = 'day' AND substr(observedAt, 1, 10) = ? ${DEKKING}`, [assetId, dayKey]) + .map(r => ({ ...r, dist_seconds: null })); + return pickPreferredRow(dayCand, preferSource, (a, b) => b.id - a.id) || laatsteSpotVanDag(assetId, dayKey, preferSource); +} + +export function listPriceGaps({ today = new Date().toISOString().slice(0, 10), head = true, tail = true, + limit = CONFIG.HYGIENE.MAX_ROWS_PER_FINDING } = {}) { + const grens = CONFIG.HYGIENE.PRICE_GAP_DAYS; + const naGisteren = today; + const assets = db.all(`SELECT Asset.id, Asset.symbol, substr(MIN(Event.occurredAt), 1, 10) AS start + FROM Asset + JOIN Event ON Event.assetId = Asset.id AND Event.type <> 'reservation' + WHERE Asset.kind <> 'fiat' + GROUP BY Asset.id, Asset.symbol + ORDER BY Asset.symbol`); + const rows = []; + for (const a of assets) { + if (a.start >= today) continue; + const dagen = db.all(`SELECT DISTINCT substr(observedAt, 1, 10) AS dag FROM PriceHistory + WHERE assetId = ? AND observedAt >= ? AND fiatPrice > 0 ${DEKKING} + ORDER BY dag`, [a.id, `${a.start}T00:00:00Z`]).map(r => r.dag); + const genegeerd = db.all(`SELECT fromDay, untilDay FROM IgnoredPriceGap WHERE assetId = ? ORDER BY fromDay`, [a.id]); + let vorige = addDaysIso(a.start, -1); + if (!head) { + const ervoor = db.one(`SELECT 1 AS x FROM PriceHistory WHERE assetId = ? AND observedAt < ? AND fiatPrice > 0 ${DEKKING}`, + [a.id, `${a.start}T00:00:00Z`]); + if (!ervoor) { if (!dagen.length) continue; vorige = dagen[0]; } + } + for (const dag of [...dagen.filter(d => d < naGisteren), ...(tail ? [naGisteren] : [])]) { + if (daysBetween(vorige, dag) > grens) { + for (const stuk of zonderGenegeerd(addDaysIso(vorige, 1), addDaysIso(dag, -1), genegeerd)) { + const lengte = daysBetween(stuk.from, stuk.until) + 1; + if (lengte > grens - 1) rows.push({ assetId: a.id, symbol: a.symbol, from: stuk.from, until: stuk.until, days: lengte }); + } + } + vorige = dag; + } + } + return { total: rows.length, rows: limit == null ? rows : rows.slice(0, limit) }; +} + +function zonderGenegeerd(from, until, ranges) { + let stukken = [{ from, until }]; + for (const r of ranges) { + stukken = stukken.flatMap(s => { + if (r.untilDay < s.from || r.fromDay > s.until) return [s]; + const rest = []; + if (r.fromDay > s.from) rest.push({ from: s.from, until: addDaysIso(r.fromDay, -1) }); + if (r.untilDay < s.until) rest.push({ from: addDaysIso(r.untilDay, 1), until: s.until }); + return rest; + }); + } + return stukken; +} + +const DAG = /^\d{4}-\d{2}-\d{2}$/; + +export async function ignorePriceGap({ assetId, from, until } = {}) { + if (!db.one(`SELECT id FROM Asset WHERE id = ?`, [assetId])) throw new Error(`Asset ${assetId} does not exist.`); + if (!DAG.test(String(from)) || !DAG.test(String(until)) || from > until) { + throw new Error(`'${from}' to '${until}' is not a range of days.`); + } + db.run(`INSERT INTO IgnoredPriceGap (assetId, fromDay, untilDay, createdAt) VALUES (?, ?, ?, ?)`, + [assetId, from, until, new Date().toISOString()]); + const id = db.lastId(); + await db.persist(); + return id; +} + +export function listIgnoredPriceGaps() { + return db.all(`SELECT IgnoredPriceGap.*, Asset.symbol FROM IgnoredPriceGap + JOIN Asset ON Asset.id = IgnoredPriceGap.assetId + ORDER BY Asset.symbol, IgnoredPriceGap.fromDay`); +} + +export async function unignorePriceGap(id) { + db.run(`DELETE FROM IgnoredPriceGap WHERE id = ?`, [id]); + await db.persist(); +} + +export async function recordNoPriceDays(assetId, source, days) { + if (!db.one(`SELECT id FROM Asset WHERE id = ?`, [assetId])) throw new Error(`Asset ${assetId} does not exist.`); + const providerId = providerIdVoor(source); + if (!Array.isArray(days) || days.some(d => !DAG.test(String(d)))) throw new Error(`Not a list of days: ${JSON.stringify(days)}.`); + const now = new Date().toISOString(); + for (const day of days) { + db.run(`INSERT OR IGNORE INTO PriceAbsence (assetId, providerId, day, createdAt) VALUES (?, ?, ?, ?)`, + [assetId, providerId, day, now]); + } + await db.persist(); +} + +export function listPriceAbsences() { + const rijen = db.all(`SELECT PriceAbsence.assetId, PriceAbsence.providerId, Provider.name AS providerName, PriceAbsence.day + FROM PriceAbsence JOIN Provider ON Provider.id = PriceAbsence.providerId + ORDER BY PriceAbsence.assetId, Provider.name, PriceAbsence.day`); + const uit = []; + for (const r of rijen) { + const vorige = uit[uit.length - 1]; + if (vorige && vorige.assetId === r.assetId && vorige.providerId === r.providerId && addDaysIso(vorige.untilDay, 1) === r.day) { + vorige.untilDay = r.day; + vorige.days++; + } else { + uit.push({ assetId: r.assetId, providerId: r.providerId, providerName: r.providerName, fromDay: r.day, untilDay: r.day, days: 1 }); + } + } + return uit; +} + +export async function deletePriceAbsences({ assetId, providerId, from, until } = {}) { + if (!Number.isInteger(assetId) || !Number.isInteger(providerId)) throw new Error(`An asset id and a provider id are required.`); + if (!DAG.test(String(from)) || !DAG.test(String(until)) || from > until) throw new Error(`'${from}' to '${until}' is not a range of days.`); + db.run(`DELETE FROM PriceAbsence WHERE assetId = ? AND providerId = ? AND day BETWEEN ? AND ?`, [assetId, providerId, from, until]); + await db.persist(); +} + +export function listNoPriceDays(assetId, fromDay = '0000-01-01') { + return db.all(`SELECT providerId, day FROM PriceAbsence WHERE assetId = ? AND day >= ? ORDER BY day, providerId`, [assetId, fromDay]); +} + +export function listAllPriceHistory(filter = {}) { + const where = ['1=1'], args = []; + if (filter.assetId) { where.push('ph.assetId = ?'); args.push(filter.assetId); } + if (filter.fromDate) { where.push('ph.observedAt >= ?'); args.push(`${filter.fromDate}T00:00:00Z`); } + return db.all(`SELECT ph.*, a.symbol AS asset_symbol, + p.key AS provider_key, p.name AS provider_name + FROM PriceHistory ph + JOIN Asset a ON a.id = ph.assetId + JOIN Provider p ON p.id = ph.providerId + WHERE ${where.join(' AND ')} + ORDER BY ph.observedAt DESC, ph.id DESC`, args); +} + +export async function recordCurrentSpot(assetId, source, spot) { + if (!spot || !Number.isFinite(spot.price)) return; + const today = new Date().toISOString().slice(0, 10); + const existing = db.one( + `SELECT id FROM PriceHistory + WHERE assetId = ? AND providerId = ? AND resolution = 'timestamp' AND substr(observedAt, 1, 10) = ? + ORDER BY observedAt DESC, id DESC LIMIT 1`, + [assetId, providerIdVoor(source), today]); + if (existing) { + db.run(`UPDATE PriceHistory SET observedAt = ?, fiatPrice = ?, updatedAt = ? WHERE id = ?`, + [new Date().toISOString(), spot.price, new Date().toISOString(), existing.id]); + await db.persist(); + return; + } + await upsertPriceHistory(assetId, [{ time: Math.floor(Date.now() / 1000), price: spot.price }], source, 'timestamp'); +} diff --git a/server/data/price-source.js b/server/data/price-source.js new file mode 100644 index 0000000..9d8702e --- /dev/null +++ b/server/data/price-source.js @@ -0,0 +1,99 @@ +// Generated, do not edit. sha256:db61aab12929b00d557fdd117e7a5efb89fd1b2bb3eb23c37364a1d19f493bbc +import * as db from '../db.js'; +import { SOURCE_CAPABILITIES, SOURCE_ENDPOINTS } from '../../shared/config.js'; +import { assertEnum } from './_shared.js'; +import { hasFeature } from '../license.mjs'; +import { fail } from '../../shared/app-error.js'; +import { refreshNotifications } from './notifications.js'; + +export function priceSourceChain(assetId, capability) { + return db.all( + `SELECT Provider.key AS key + FROM PriceSource + JOIN Provider ON Provider.id = PriceSource.providerId + WHERE PriceSource.assetId = ? AND PriceSource.capability = ? AND PriceSource.rank IS NOT NULL + ORDER BY PriceSource.rank`, + [assetId, capability] + ).map(r => r.key); +} + +export function ownPriceSourceChain(assetId, capability) { + return db.all( + `SELECT Provider.key AS key + FROM PriceSource + JOIN Provider ON Provider.id = PriceSource.providerId + WHERE PriceSource.assetId = ? AND PriceSource.capability = ? AND PriceSource.rank IS NOT NULL + AND PriceSource.status = 'manual' + ORDER BY PriceSource.rank`, + [assetId, capability] + ).map(r => r.key); +} + +export function listPriceSource() { + return db.all(`SELECT assetId, capability, providerId, rank, status FROM PriceSource`); +} + +function assertMatrixAllowed() { + if (hasFeature('sourceMatrix')) return; + fail('integrations.matrixPaid', null, + 'The source matrix belongs to the paid version. The app keeps using the standard sources.'); +} + +function assertAnswers(providerId, capability) { + const eindpunten = SOURCE_ENDPOINTS[capability]; + if (!db.one(`SELECT 1 AS x FROM ProviderEndpoint WHERE providerId = ? AND capability IN (${eindpunten.map(() => '?').join(',')})`, + [providerId, ...eindpunten])) + throw new Error('This provider does not cover this capability.'); +} + +export async function setPriceSourceRank(assetId, capability, providerId) { + assertMatrixAllowed(); + assertEnum('capability', SOURCE_CAPABILITIES, capability); + assertAnswers(providerId, capability); + const used = db.all(`SELECT DISTINCT PriceSource.rank FROM PriceSource + JOIN Asset ON Asset.id = PriceSource.assetId + JOIN ProviderCoverage ON ProviderCoverage.providerId = PriceSource.providerId + AND ProviderCoverage.symbol = Asset.symbol + WHERE PriceSource.assetId = ? AND PriceSource.capability = ? + AND PriceSource.rank IS NOT NULL AND PriceSource.status IN ('confirmed','manual')`, + [assetId, capability]).map(r => r.rank); + let next = 1; + while (used.includes(next)) next += 1; + db.run(`DELETE FROM PriceSource WHERE assetId = ? AND capability = ? AND (providerId = ? OR rank = ?)`, + [assetId, capability, providerId, next]); + db.run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status, createdAt) VALUES (?,?,?,?,'manual',?)`, + [assetId, capability, providerId, next, new Date().toISOString()]); + await db.persist(); + return next; +} +export async function placePriceSourceFirst(assetId, capability, providerId) { + assertMatrixAllowed(); + assertEnum('capability', SOURCE_CAPABILITIES, capability); + assertAnswers(providerId, capability); + const rest = db.all(`SELECT providerId, status, createdAt FROM PriceSource + WHERE assetId = ? AND capability = ? AND rank IS NOT NULL AND providerId <> ? + ORDER BY rank`, [assetId, capability, providerId]); + db.run(`DELETE FROM PriceSource WHERE assetId = ? AND capability = ? AND (rank IS NOT NULL OR providerId = ?)`, + [assetId, capability, providerId]); + db.run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status, createdAt) VALUES (?,?,?,1,'manual',?)`, + [assetId, capability, providerId, new Date().toISOString()]); + rest.forEach((r, i) => db.run( + `INSERT INTO PriceSource (assetId, capability, providerId, rank, status, createdAt) VALUES (?,?,?,?,?,?)`, + [assetId, capability, r.providerId, i + 2, r.status, r.createdAt])); + await db.persist(); +} + +export async function clearPriceSourceCell(assetId, capability, providerId) { + assertMatrixAllowed(); + db.run(`DELETE FROM PriceSource WHERE assetId = ? AND capability = ? AND providerId = ?`, + [assetId, capability, providerId]); + await db.persist(); +} +export async function confirmPriceSourceCell(assetId, capability, providerId) { + assertMatrixAllowed(); + db.run(`UPDATE PriceSource SET status = 'confirmed' + WHERE assetId = ? AND capability = ? AND providerId = ? AND status = 'proposed'`, + [assetId, capability, providerId]); + await db.persist(); + await refreshNotifications(); +} diff --git a/server/data/proposals.js b/server/data/proposals.js new file mode 100644 index 0000000..58c01a7 --- /dev/null +++ b/server/data/proposals.js @@ -0,0 +1,224 @@ +// Generated, do not edit. sha256:b635cddd04e7b73f066847eaf0cbf8dfca213ceaa0e4c52ee3c3820f5df5cd3a +import * as db from '../db.js'; +import { ENUMS } from '../../shared/config.js'; +import { assertEnum, nowIso } from './_shared.js'; +import { fail } from '../../shared/app-error.js'; +import { createEvent, deleteEvent } from './events.js'; +import { linkProposalTxSoft } from './tx-links.js'; +import { memoNote } from '../../shared/zap.js'; + +const MEMO_OF = `COALESCE(w.memo, (SELECT twin.memo FROM WalletTransaction twin + WHERE w.layer = 'lightning' AND twin.layer = 'lightning' AND twin.accountId = w.accountId + AND twin.txid = w.txid AND twin.id <> w.id AND twin.memo IS NOT NULL + ORDER BY twin.id LIMIT 1))`; + +const NODE_ACCOUNT_OF = `(SELECT node.accountId FROM WalletSource onchain + JOIN WalletSource node ON node.connectionId = onchain.connectionId AND node.kind = 'lnd' + WHERE onchain.accountId = w.accountId AND onchain.kind IN ('xpub','address') + AND node.accountId <> w.accountId + ORDER BY node.id LIMIT 1)`; + +function bookingNote(p, prefix = 'reconcile tx', zonderHash = 'reconcile') { + const memo = p.walletTransactionId + ? db.one(`SELECT ${MEMO_OF} AS memo FROM WalletTransaction w WHERE w.id = ?`, [p.walletTransactionId])?.memo : null; + const basis = p.transactionHash ? `${prefix} ${p.transactionHash}` : zonderHash; + const context = memoNote(memo, p.direction); + return context ? `${context} · ${basis}` : basis; +} + +export function listPendingProposals() { + return db.all(` + SELECT p.*, ac.name AS account_name, a.symbol AS asset_symbol, a.name AS asset_name, + a.decimals AS asset_decimals, a.unit AS asset_unit, a.subunit AS asset_subunit, + w.blockTime AS tx_block_time, w.layer AS tx_layer, w.kind AS tx_kind, w.feeSats AS tx_fee_sats, + ${MEMO_OF} AS tx_memo, + CASE WHEN w.kind = 'channel' THEN (SELECT name FROM Account WHERE id = ${NODE_ACCOUNT_OF}) END AS tx_node_account, + -- The NWC wallet the payment went through (user, 19-10-2026): its row with the same hash. + (SELECT COALESCE(s.name, '') FROM WalletTransaction twin + JOIN WalletSource s ON s.id = twin.sourceId + WHERE w.layer = 'lightning' AND twin.layer = 'lightning' AND s.kind = 'nwc' + AND twin.accountId = w.accountId AND twin.txid = w.txid AND twin.id <> w.id + ORDER BY twin.id LIMIT 1) AS via_wallet + FROM ReconcileProposal p + JOIN Account ac ON ac.id = p.accountId + JOIN Asset a ON a.id = p.assetId + LEFT JOIN WalletTransaction w ON w.id = p.walletTransactionId + WHERE p.status = 'pending' + AND NOT EXISTS (SELECT 1 FROM WalletTransactionEventLink l WHERE l.walletTransactionId = p.walletTransactionId) + ORDER BY COALESCE(w.blockTime, p.detectedAt) DESC, p.id DESC`); +} +export async function syncTxProposals(accountId, assetId) { + db.run(`DELETE FROM ReconcileProposal WHERE status='pending' AND accountId=?1 AND assetId=?2`, [accountId, assetId]); + db.run(`INSERT INTO ReconcileProposal + (accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, resolvedEventId, detectedAt) + SELECT w.accountId, ?2, w.id, w.txid, w.valueSats, w.direction, + CASE WHEN ac.isOwnCustody THEN 'transfer' ELSE 'trade' END, 'pending', NULL, ?3 + FROM WalletTransaction w + JOIN Account ac ON ac.id = w.accountId + WHERE w.accountId = ?1 + -- LIGHTNING ONLY FROM THE NODE: an NWC wallet is a grouping + -- on top of LND, so its payment is booked once, from LND, and the NWC row takes the event + -- over by payment hash. On an account WITHOUT a LND source the NWC wallet counts on its own, + -- as in its total on Tracking. The same rule stands in hygiene.js. + AND (w.layer = 'onchain' + OR EXISTS (SELECT 1 FROM WalletSource s + WHERE s.id = w.sourceId + AND (s.kind = 'lnd' + OR (s.kind = 'nwc' AND NOT EXISTS ( + SELECT 1 FROM WalletSource node + WHERE node.accountId = w.accountId AND node.kind = 'lnd'))))) + AND NOT EXISTS (SELECT 1 FROM WalletTransactionEventLink l WHERE l.walletTransactionId = w.id) + AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p + WHERE p.status = 'ignored' + AND ( p.walletTransactionId = w.id + -- The amount match only for an ignored proposal WITHOUT a movement + -- (a gap proposal). One that hangs on a movement means "this one", + -- and matching it on amount suppressed every other movement of the + -- same size: one ignored 21-sat zap hid every 21-sat zap. + OR (p.walletTransactionId IS NULL + AND p.accountId = w.accountId AND p.assetId = ?2 + AND p.direction = w.direction AND p.amount = w.valueSats) ))`, + [accountId, assetId, nowIso()]); + const n = db.one(`SELECT changes() AS n`).n; + await db.persist(); + return n; +} +function getProposal(id) { + const p = db.one(`SELECT * FROM ReconcileProposal WHERE id = ?`, [id]); + if (!p) fail('home.recUnknownProposal', { id }, `Proposal ${id} no longer exists.`); + if (p.status !== 'pending') { + fail(`home.recHandled_${p.status}`, { id }, `Proposal ${id} has already been handled (${p.status}).`); + } + return p; +} +export async function resolveProposalAsTrade(id, { fiatPrice, fiatFee, occurredAt, priceOrigin }) { + const p = getProposal(id); + if (fiatPrice === undefined || fiatPrice === null) throw new Error('fiatPrice is required for a trade.'); + const wtx = p.walletTransactionId ? db.one(`SELECT blockTime, feeSats FROM WalletTransaction WHERE id=?`, [p.walletTransactionId]) : null; + const blockTime = wtx && wtx.blockTime ? wtx.blockTime : null; + const type = p.direction === 'in' ? 'buy' : 'sell'; + const txFee = type === 'sell' && wtx && wtx.feeSats ? wtx.feeSats : null; + const eventId = await createEvent({ + assetId: p.assetId, type, occurredAt: occurredAt ?? blockTime ?? p.detectedAt, + amount: p.amount, fiatPrice, fiatFee: fiatFee ?? null, unitFee: txFee, + priceOrigin: priceOrigin ?? null, + fromAccountId: type === 'sell' ? p.accountId : null, + toAccountId: type === 'buy' ? p.accountId : null, + source: 'reconcile', note: bookingNote(p), + }); + if (p.walletTransactionId) linkProposalTxSoft(p.walletTransactionId, eventId); + db.run(`UPDATE ReconcileProposal SET status='accepted_trade', resolvedEventId=? WHERE id=?`, [eventId, id]); + await db.persist(); + return eventId; +} +export async function resolveProposalAsTransfer(id, { other_account_id, unitFee, occurredAt }) { + const p = getProposal(id); + if (!other_account_id) { + fail('home.recNoCounter', null, 'A counter-account is required for a transfer.'); + } + const wtx = p.walletTransactionId ? db.one(`SELECT blockTime, feeSats FROM WalletTransaction WHERE id=?`, [p.walletTransactionId]) : null; + const blockTime = wtx && wtx.blockTime ? wtx.blockTime : null; + const txFee = p.direction === 'out' && wtx && wtx.feeSats != null ? wtx.feeSats : null; + const fromAccountId = p.direction === 'out' ? p.accountId : other_account_id; + const toAccountId = p.direction === 'out' ? other_account_id : p.accountId; + const eventId = await createEvent({ + assetId: p.assetId, type: 'transfer', occurredAt: occurredAt ?? blockTime ?? p.detectedAt, + amount: p.amount, fiatPrice: null, fiatFee: null, unitFee: unitFee ?? txFee, + fromAccountId, toAccountId, source: 'reconcile', + note: bookingNote(p), + }); + if (p.walletTransactionId) linkProposalTxSoft(p.walletTransactionId, eventId); + db.run(`UPDATE ReconcileProposal SET status='accepted_transfer', resolvedEventId=? WHERE id=?`, [eventId, id]); + await db.persist(); + return eventId; +} +export async function resolveProposalAsInternal(id) { + const p = getProposal(id); + const wtx = p.walletTransactionId + ? db.one(`SELECT w.blockTime, w.feeSats, w.kind, ${NODE_ACCOUNT_OF} AS nodeAccountId + FROM WalletTransaction w WHERE w.id = ?`, [p.walletTransactionId]) : null; + if (!wtx || wtx.kind == null) { + fail('home.recNotInternal', { id }, `Proposal ${id} is not a movement inside the account.`); + } + const ander = wtx.kind === 'channel' && wtx.nodeAccountId ? wtx.nodeAccountId : p.accountId; + const eventId = await createEvent({ + assetId: p.assetId, type: 'transfer', occurredAt: wtx.blockTime ?? p.detectedAt, + amount: p.amount, fiatPrice: null, fiatFee: null, + unitFee: p.direction === 'out' && wtx.feeSats ? wtx.feeSats : null, + fromAccountId: p.direction === 'out' ? p.accountId : ander, + toAccountId: p.direction === 'out' ? ander : p.accountId, source: 'reconcile', + note: bookingNote(p, `reconcile ${wtx.kind}`, `reconcile ${wtx.kind}`), + }); + linkProposalTxSoft(p.walletTransactionId, eventId); + db.run(`UPDATE ReconcileProposal SET status='accepted_transfer', resolvedEventId=? WHERE id=?`, [eventId, id]); + await db.persist(); + return eventId; +} +export async function resolveProposalAsInterest(id) { + const p = getProposal(id); + const wtx = p.walletTransactionId + ? db.one(`SELECT blockTime, layer, txid FROM WalletTransaction WHERE id = ?`, [p.walletTransactionId]) : null; + if (!wtx || wtx.layer !== 'lightning' || !String(wtx.txid).startsWith('fwd:') || p.direction !== 'in') { + fail('home.recNotForward', { id }, `Proposal ${id} is not a lightning forward.`); + } + const eventId = await createEvent({ + assetId: p.assetId, type: 'interest', occurredAt: wtx.blockTime ?? p.detectedAt, + amount: p.amount, fiatPrice: null, fiatFee: null, unitFee: null, + fromAccountId: null, toAccountId: p.accountId, source: 'reconcile', note: bookingNote(p, 'reconcile forward', 'reconcile forward'), + }); + linkProposalTxSoft(p.walletTransactionId, eventId); + db.run(`UPDATE ReconcileProposal SET status='accepted_interest', resolvedEventId=? WHERE id=?`, [eventId, id]); + await db.persist(); + return eventId; +} +export async function ignoreProposal(id) { + getProposal(id); + db.run(`UPDATE ReconcileProposal SET status='ignored' WHERE id=?`, [id]); + await db.persist(); +} +export async function deleteProposal(id) { + db.run(`DELETE FROM ReconcileProposal WHERE id=?`, [id]); + await db.persist(); +} +export async function ignoreAllPendingProposals() { + db.run(`UPDATE ReconcileProposal SET status='ignored' WHERE status='pending'`); + const n = db.one(`SELECT changes() AS n`).n; + if (n > 0) await db.persist(); + return n; +} +export async function deleteAllPendingProposals() { + db.run(`DELETE FROM ReconcileProposal WHERE status = 'pending'`); + const n = db.one(`SELECT changes() AS n`).n; + if (n > 0) await db.persist(); + return n; +} +export async function revertProposal(id) { + const p = db.one(`SELECT * FROM ReconcileProposal WHERE id = ?`, [id]); + if (!p) fail('home.recUnknownProposal', { id }, `Proposal ${id} no longer exists.`); + if (p.status === 'accepted_trade' || p.status === 'accepted_transfer' || p.status === 'accepted_interest') { + if (p.resolvedEventId) await deleteEvent(p.resolvedEventId); + db.run(`UPDATE ReconcileProposal SET status = 'pending', resolvedEventId = NULL WHERE id = ?`, [id]); + } else if (p.status === 'ignored') { + if (p.walletTransactionId) db.run(`DELETE FROM ReconcileProposal WHERE id = ?`, [id]); + else db.run(`UPDATE ReconcileProposal SET status = 'pending' WHERE id = ?`, [id]); + } else if (p.status === 'auto_linked') { + fail('home.recAutoLinked', { id }, `Proposal ${id} is auto-linked; unlink it through the match popover.`); + } else { + fail('home.recStillOpen', { id }, `Proposal ${id} (${p.status}) cannot be reverted.`); + } + await db.persist(); +} +export function listHandledProposals(limit = 50) { + return db.all(` + SELECT p.*, ac.name AS account_name, a.symbol AS asset_symbol, a.name AS asset_name, + a.decimals AS asset_decimals, a.unit AS asset_unit, a.subunit AS asset_subunit, + e.type AS event_type, w.blockTime AS tx_block_time + FROM ReconcileProposal p + JOIN Account ac ON ac.id = p.accountId + JOIN Asset a ON a.id = p.assetId + LEFT JOIN Event e ON e.id = p.resolvedEventId + LEFT JOIN WalletTransaction w ON w.id = p.walletTransactionId + WHERE p.status != 'pending' + ORDER BY COALESCE(w.blockTime, p.detectedAt) DESC, p.id DESC + LIMIT ?`, [limit]); +} diff --git a/server/data/providers.js b/server/data/providers.js new file mode 100644 index 0000000..9d8839e --- /dev/null +++ b/server/data/providers.js @@ -0,0 +1,280 @@ +// Generated, do not edit. sha256:1db816dc6af267a04206f23a388019cbe76689adb83e89843b65ed1a797537a8 +import * as db from '../db.js'; +import { toSearchName } from '../../shared/search-name.js'; +import { AUTH_KINDS, CAPABILITIES, ENUMS, HTTP_METHODS } from '../../shared/config.js'; +import { assertEnum, nowIso } from './_shared.js'; +import { emitChange } from './_events.js'; +import { fail } from '../../shared/app-error.js'; + +export function emitProvidersChanged() { + emitChange('providers-changed'); +} + +export function listProviders() { + return db.all(`SELECT * FROM Provider ORDER BY isSeed DESC, name`); +} + +export function getProvider(key) { + const p = db.one(`SELECT * FROM Provider WHERE key = ?`, [key]); + if (!p) return null; + return { + ...p, + coverage: db.all(`SELECT symbol FROM ProviderCoverage WHERE providerId = ? ORDER BY symbol`, [p.id]).map(r => r.symbol), + hmac: db.one(`SELECT * FROM ProviderAuthenticationHmac WHERE providerId = ?`, [p.id]) || null, + fixedRate: db.one(`SELECT fiatPrice FROM ProviderFixedRate WHERE providerId = ?`, [p.id]) || null, + callsPerMonth: db.one(`SELECT callsPerMonth FROM ProviderCallLimit WHERE providerId = ?`, [p.id])?.callsPerMonth ?? null, + endpoints: db.all(`SELECT id, capability, method, pathTemplate, queryTemplate, quoteCurrency, minIntervalSeconds FROM ProviderEndpoint WHERE providerId = ?`, [p.id]), + symbols: db.all(`SELECT id, symbol, externalId, quoteUnit, isCatchAll FROM ProviderInputMap WHERE providerId = ? + ORDER BY isCatchAll, symbol`, [p.id]), + outputs: db.all(`SELECT id, capability, internalField, jsonPath, transform FROM ProviderOutputMap WHERE providerId = ?`, [p.id]), + }; +} + +export function providerCapabilityMap() { + const m = new Map(); + for (const r of db.all(`SELECT providerId, capability FROM ProviderEndpoint`)) { + if (!m.has(r.providerId)) m.set(r.providerId, new Set()); + m.get(r.providerId).add(r.capability); + } + return m; +} + +function assertAuthKind(kind) { + if (!Object.keys(AUTH_KINDS).includes(kind)) + throw new Error(`Invalid authenticationKind: '${kind}'. Allowed: ${Object.keys(AUTH_KINDS).join(', ')}.`); +} +function bit01(v, name) { + const n = Number(v); + if (n !== 0 && n !== 1) throw new Error(`Invalid value for ${name}: ${v} (0 or 1 expected).`); + return n; +} +function toCoverageList(v) { + const arr = Array.isArray(v) ? v : String(v ?? '').split(','); + return [...new Set(arr.map(s => String(s).trim()).filter(Boolean))]; +} + +function schrijfCoverage(providerId, symbolen) { + db.run(`DELETE FROM ProviderCoverage WHERE providerId = ?`, [providerId]); + for (const symbool of symbolen) { + db.run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?,?)`, [providerId, symbool]); + } +} + +function keyFromName(name) { + const basis = String(name).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() + .replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'integratie'; + let key = basis; + for (let n = 2; db.one(`SELECT 1 AS x FROM Provider WHERE key = ?`, [key]); n++) key = `${basis}_${n}`; + return key; +} + +export async function createProvider(cfg) { + if (!String(cfg.name || '').trim()) fail('integrations.nameRequired', null, 'A provider name is required.'); + assertAuthKind(cfg.authenticationKind); + const key = String(cfg.key || '').trim() || keyFromName(cfg.name); + if (db.one(`SELECT 1 AS x FROM Provider WHERE key = ?`, [key])) { + fail('integrations.keyInUse', { key }, `Provider '${key}' already exists.`); + } + db.run(`INSERT INTO Provider + (key, name, searchName, baseUrl, authenticationHeaderName, authenticationParameterName, + authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES (?,?,?,?,?,?,?,?,?,0,0,?)`, + [key, cfg.name.trim(), toSearchName(cfg.name), cfg.baseUrl ?? '', + cfg.authenticationHeaderName ?? null, cfg.authenticationParameterName ?? null, + cfg.authenticationKind, bit01(cfg.authenticationRequired, 'authenticationRequired'), + bit01(cfg.isActive ?? 1, 'isActive'), nowIso()]); + const pid = db.lastId(); + schrijfCoverage(pid, toCoverageList(cfg.coverage)); + await db.persist(); + emitProvidersChanged(); + return pid; +} + +export async function updateProvider(key, cfg) { + const p = db.one(`SELECT * FROM Provider WHERE key = ?`, [key]); + if (!p) fail('integrations.unknownProvider', { key }, `Provider '${key}' does not exist.`); + assertAuthKind(cfg.authenticationKind); + db.run(`UPDATE Provider SET name=?, searchName=?, baseUrl=?, authenticationHeaderName=?, + authenticationParameterName=?, authenticationKind=?, authenticationRequired=?, isActive=? WHERE id=?`, + [cfg.name, toSearchName(cfg.name), cfg.baseUrl ?? '', + cfg.authenticationHeaderName ?? null, cfg.authenticationParameterName ?? null, + cfg.authenticationKind, bit01(cfg.authenticationRequired, 'authenticationRequired'), + bit01(cfg.isActive ?? p.isActive, 'isActive'), p.id]); + if (cfg.coverage !== undefined) schrijfCoverage(p.id, toCoverageList(cfg.coverage)); + await db.persist(); + emitProvidersChanged(); +} + +export async function setProviderActive(key, isActive) { + const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [key]); + if (!p) fail('integrations.unknownProvider', { key }, `Provider '${key}' does not exist.`); + db.run(`UPDATE Provider SET isActive = ? WHERE id = ?`, [bit01(isActive, 'isActive'), p.id]); + await db.persist(); + emitProvidersChanged(); +} + +export async function setProviderCallLimit(key, callsPerMonth) { + const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [key]); + if (!p) fail('integrations.unknownProvider', { key }, `Provider '${key}' does not exist.`); + if (callsPerMonth == null || String(callsPerMonth).trim() === '') { + db.run(`DELETE FROM ProviderCallLimit WHERE providerId = ?`, [p.id]); + } else { + const n = Number(callsPerMonth); + if (!Number.isInteger(n) || n <= 0) { + fail('integrations.callLimitInvalid', { waarde: callsPerMonth }, `A monthly limit must be a whole number above zero, got '${callsPerMonth}'.`); + } + db.run(`INSERT INTO ProviderCallLimit (providerId, callsPerMonth, createdAt) VALUES (?, ?, ?) + ON CONFLICT(providerId) DO UPDATE SET callsPerMonth = excluded.callsPerMonth`, + [p.id, n, new Date().toISOString()]); + } + await db.persist(); +} + +export async function deleteProvider(key) { + const p = db.one(`SELECT id, isSystem FROM Provider WHERE key = ?`, [key]); + if (!p) fail('integrations.unknownProvider', { key }, `Provider '${key}' does not exist.`); + if (p.isSystem) throw new Error(`Provider '${key}' is a system row and cannot be deleted.`); + const used = db.one(`SELECT COUNT(*) AS n FROM PriceSource WHERE providerId = ?`, [p.id]).n; + if (used > 0) { + fail('integrations.providerInMatrix', { key, n: used }, + `Provider '${key}' is still in the source matrix (${used} choice(s)).`); + } + const prijsrijen = db.one(`SELECT COUNT(*) AS n FROM PriceHistory WHERE providerId = ?`, [p.id]).n; + if (prijsrijen > 0) { + fail('integrations.providerHasPrices', { key, n: prijsrijen }, + `Provider '${key}' still has ${prijsrijen} price row(s) in the history.`); + } + db.run(`DELETE FROM Provider WHERE id = ?`, [p.id]); + await db.persist(); + emitProvidersChanged(); +} + +function assertProviderId(pid) { + if (!db.one(`SELECT 1 AS x FROM Provider WHERE id = ?`, [pid])) + throw new Error(`Provider ${pid} does not exist.`); +} +function reqStr(value, name) { + const s = String(value ?? '').trim(); + if (!s) fail('integrations.fieldRequired', { veld: name }, `${name} is required.`); + return s; +} + +function quoteCurrency(value) { + const s = String(value ?? '').trim().toUpperCase(); + if (!s) return null; + if (!/^[A-Z]{3}$/.test(s)) fail('integrations.quoteCurrencyInvalid', { waarde: value }, `Quote currency '${value}' is not a three-letter code.`); + return s; +} + +function minInterval(value) { + if (value == null || String(value).trim() === '') return null; + const n = Number(value); + if (!Number.isInteger(n) || n <= 0) fail('integrations.minIntervalInvalid', { waarde: value }, `Minimum interval '${value}' is not a whole number of seconds above zero.`); + return n; +} + +export async function createProviderEndpoint(providerId, e) { + assertProviderId(providerId); + assertEnum('capability', CAPABILITIES, e.capability); + assertEnum('method', HTTP_METHODS, e.method); reqStr(e.pathTemplate, 'pathTemplate'); + db.run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate, quoteCurrency, minIntervalSeconds, createdAt) + VALUES (?,?,?,?,?,?,?,?)`, + [providerId, e.capability, e.method, e.pathTemplate, e.queryTemplate ?? null, quoteCurrency(e.quoteCurrency), + minInterval(e.minIntervalSeconds), new Date().toISOString()]); + const id = db.lastId(); await db.persist(); return id; +} +export async function updateProviderEndpoint(id, e) { + assertEnum('capability', CAPABILITIES, e.capability); + assertEnum('method', HTTP_METHODS, e.method); reqStr(e.pathTemplate, 'pathTemplate'); + if (!db.one(`SELECT 1 AS x FROM ProviderEndpoint WHERE id = ?`, [id])) fail('integrations.unknownEndpoint', { id }, `Endpoint ${id} does not exist.`); + db.run(`UPDATE ProviderEndpoint SET capability=?, method=?, pathTemplate=?, queryTemplate=? WHERE id=?`, + [e.capability, e.method, e.pathTemplate, e.queryTemplate ?? null, id]); + if (Object.prototype.hasOwnProperty.call(e, 'quoteCurrency')) { + db.run(`UPDATE ProviderEndpoint SET quoteCurrency = ? WHERE id = ?`, [quoteCurrency(e.quoteCurrency), id]); + } + if (Object.prototype.hasOwnProperty.call(e, 'minIntervalSeconds')) { + db.run(`UPDATE ProviderEndpoint SET minIntervalSeconds = ? WHERE id = ?`, [minInterval(e.minIntervalSeconds), id]); + } + await db.persist(); +} +export async function deleteProviderEndpoint(id) { + db.run(`DELETE FROM ProviderEndpoint WHERE id = ?`, [id]); + await db.persist(); +} + +function quoteUnit(v) { + if (v == null || String(v).trim() === '') return null; + const u = String(v).trim(); + if (!ENUMS.QUOTE_UNIT.includes(u)) fail('integrations.quoteUnitInvalid', { waarde: u }, `Unknown quote unit '${u}'.`); + return u; +} + +function assertEenCatchAll(providerId, behalveId = null) { + const r = db.one(`SELECT id FROM ProviderInputMap WHERE providerId = ? AND isCatchAll = 1 AND id IS NOT ?`, [providerId, behalveId]); + if (r) fail('integrations.catchAllExists', null, 'This provider already has a catch-all row.'); +} + +export async function createProviderSymbol(providerId, s) { + assertProviderId(providerId); + const catchAll = !!s.isCatchAll; + if (catchAll) assertEenCatchAll(providerId); else reqStr(s.symbol, 'symbol'); + reqStr(s.externalId, 'externalId'); + db.run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, isCatchAll, createdAt) VALUES (?,?,?,?,?,?)`, + [providerId, catchAll ? null : s.symbol, s.externalId, quoteUnit(s.quoteUnit), catchAll ? 1 : 0, nowIso()]); + const id = db.lastId(); await db.persist(); return id; +} +export async function updateProviderSymbol(id, s) { + const rij = db.one(`SELECT providerId, isCatchAll FROM ProviderInputMap WHERE id = ?`, [id]); + if (!rij) fail('integrations.unknownSymbolMap', { id }, `Symbol map ${id} does not exist.`); + const catchAll = Object.hasOwn(s, 'isCatchAll') ? !!s.isCatchAll : !!rij.isCatchAll; + if (catchAll) assertEenCatchAll(rij.providerId, id); else reqStr(s.symbol, 'symbol'); + reqStr(s.externalId, 'externalId'); + db.run(`UPDATE ProviderInputMap SET symbol=?, externalId=?, isCatchAll=? WHERE id=?`, + [catchAll ? null : s.symbol, s.externalId, catchAll ? 1 : 0, id]); + if (Object.hasOwn(s, 'quoteUnit')) db.run(`UPDATE ProviderInputMap SET quoteUnit=? WHERE id=?`, [quoteUnit(s.quoteUnit), id]); + await db.persist(); +} +export async function deleteProviderSymbol(id) { + db.run(`DELETE FROM ProviderInputMap WHERE id = ?`, [id]); + await db.persist(); +} + +export async function createProviderOutput(providerId, o) { + assertProviderId(providerId); + assertEnum('capability', CAPABILITIES, o.capability); + reqStr(o.internalField, 'internalField'); reqStr(o.jsonPath, 'jsonPath'); reqStr(o.transform, 'transform'); + db.run(`INSERT INTO ProviderOutputMap (providerId, capability, internalField, jsonPath, transform) VALUES (?,?,?,?,?)`, + [providerId, o.capability, o.internalField, o.jsonPath, o.transform]); + const id = db.lastId(); await db.persist(); return id; +} +export async function updateProviderOutput(id, o) { + assertEnum('capability', CAPABILITIES, o.capability); + reqStr(o.internalField, 'internalField'); reqStr(o.jsonPath, 'jsonPath'); reqStr(o.transform, 'transform'); + if (!db.one(`SELECT 1 AS x FROM ProviderOutputMap WHERE id = ?`, [id])) fail('integrations.unknownOutputMap', { id }, `Output map ${id} does not exist.`); + db.run(`UPDATE ProviderOutputMap SET capability=?, internalField=?, jsonPath=?, transform=? WHERE id=?`, + [o.capability, o.internalField, o.jsonPath, o.transform, id]); + await db.persist(); +} +export async function deleteProviderOutput(id) { + db.run(`DELETE FROM ProviderOutputMap WHERE id = ?`, [id]); + await db.persist(); +} + +export async function setProviderHmac(providerId, h) { + assertProviderId(providerId); + reqStr(h.messageParts, 'messageParts'); reqStr(h.hashAlgorithm, 'hashAlgorithm'); reqStr(h.encoding, 'encoding'); + reqStr(h.keyHeader, 'keyHeader'); reqStr(h.signatureHeader, 'signatureHeader'); reqStr(h.timestampHeader, 'timestampHeader'); + db.run(`INSERT INTO ProviderAuthenticationHmac + (providerId, messageParts, hashAlgorithm, encoding, keyHeader, signatureHeader, timestampHeader, windowHeader, windowMs) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT(providerId) DO UPDATE SET + messageParts=excluded.messageParts, hashAlgorithm=excluded.hashAlgorithm, encoding=excluded.encoding, + keyHeader=excluded.keyHeader, signatureHeader=excluded.signatureHeader, + timestampHeader=excluded.timestampHeader, windowHeader=excluded.windowHeader, windowMs=excluded.windowMs`, + [providerId, h.messageParts, h.hashAlgorithm, h.encoding, h.keyHeader, h.signatureHeader, h.timestampHeader, h.windowHeader ?? null, h.windowMs ?? null]); + await db.persist(); +} +export async function clearProviderHmac(providerId) { + db.run(`DELETE FROM ProviderAuthenticationHmac WHERE providerId = ?`, [providerId]); + await db.persist(); +} diff --git a/server/data/reservations.js b/server/data/reservations.js new file mode 100644 index 0000000..992eff9 --- /dev/null +++ b/server/data/reservations.js @@ -0,0 +1,74 @@ +// Generated, do not edit. sha256:f8255fd8148a61f4a49cc0a84d161f8b1bc531a1a315349c9aef9328871b9d6f +import * as db from '../db.js'; +import { fail } from '../../shared/app-error.js'; +import { assertTimestamp, nowIso } from './_shared.js'; +import { createEvent, deleteEvent, EVENT_JOIN } from './events.js'; +import { trackUpdate } from './change-log.js'; +import { emitChange } from './_events.js'; + +export function listReservations() { + return db.all(`${EVENT_JOIN} WHERE e.type = 'reservation' ORDER BY e.occurredAt ASC, e.id ASC`); +} + +export function listReservationsForAccount(accountId) { + return db.all(`${EVENT_JOIN} WHERE e.type = 'reservation' AND (e.fromAccountId = ?1 OR e.toAccountId = ?1) + ORDER BY e.occurredAt ASC, e.id ASC`, [accountId]); +} + +export function listDueReservations(untilIso = nowIso()) { + return db.all(`${EVENT_JOIN} WHERE e.type = 'reservation' AND e.occurredAt <= ? + ORDER BY e.occurredAt ASC, e.id ASC`, [untilIso]); +} + +function rowOf(r) { + if (!r.accountId) throw new Error('A reservation needs an account.'); + if (r.direction !== 'in' && r.direction !== 'out') throw new Error(`Unknown direction '${r.direction}'; use 'in' or 'out'.`); + return { + assetId: r.assetId, type: 'reservation', occurredAt: r.occurredAt, amount: r.amount, + fromAccountId: r.direction === 'out' ? r.accountId : null, + toAccountId: r.direction === 'in' ? r.accountId : null, + source: 'manual', note: r.note ?? null, + }; +} + +function openReservation(id) { + const row = db.one(`SELECT * FROM Event WHERE id = ?`, [id]); + if (!row || row.type !== 'reservation') fail('reservations.notOpen', null, 'This reservation is no longer open.'); + return row; +} + +export async function createReservation(r) { + return createEvent(rowOf(r)); +} + +export async function updateReservation(id, r) { + openReservation(id); + const e = rowOf(r); + trackUpdate('Event', id, () => db.run( + `UPDATE Event SET assetId = ?, occurredAt = ?, amount = ?, fromAccountId = ?, toAccountId = ?, note = ? + WHERE id = ? AND type = 'reservation'`, + [e.assetId, e.occurredAt, e.amount, e.fromAccountId, e.toAccountId, e.note, id])); + await db.persist(); + emitChange('ledger-changed'); +} + +export async function deleteReservation(id) { + openReservation(id); + await deleteEvent(id); +} + +export async function bookReservation(id, { occurredAt, amount, otherAccountId = null, note }) { + const row = openReservation(id); + assertTimestamp('occurredAt', occurredAt); + if (!Number.isInteger(amount) || amount <= 0) throw new Error(`A booking needs an amount > 0 in the smallest unit (got ${amount}).`); + const out = row.fromAccountId != null; + const own = out ? row.fromAccountId : row.toAccountId; + if (otherAccountId != null && otherAccountId === own) throw new Error('The other side of a booking is another account.'); + const [from, to] = out ? [own, otherAccountId] : [otherAccountId, own]; + trackUpdate('Event', id, () => db.run( + `UPDATE Event SET type = 'transfer', occurredAt = ?, amount = ?, fromAccountId = ?, toAccountId = ?, note = ? + WHERE id = ? AND type = 'reservation'`, + [occurredAt, amount, from, to, note === undefined ? row.note : (note || null), id])); + await db.persist(); + emitChange('ledger-changed'); +} diff --git a/server/data/scan-logs.js b/server/data/scan-logs.js new file mode 100644 index 0000000..37be640 --- /dev/null +++ b/server/data/scan-logs.js @@ -0,0 +1,24 @@ +// Generated, do not edit. sha256:aba9f6d2144409570ffba1d05e453cb0ffd966afb4f0fa1cd5960bcb7a63805f +import * as db from '../db.js'; +import { ENUMS } from '../../shared/config.js'; +import { assertEnum, nowIso } from './_shared.js'; + +export async function createScanLog(s) { + assertEnum('status', ENUMS.SCAN_STATUS, s.status); + db.run(`INSERT INTO ScanLog (accountId, scannedAt, status, addressesFound, balanceFound, proposalsCreated, rawData) + VALUES (?,?,?,?,?,?,?)`, + [s.accountId, s.scannedAt ?? nowIso(), s.status, + s.addressesFound ?? null, s.balanceFound ?? null, s.proposalsCreated ?? null, + s.rawData != null ? JSON.stringify(s.rawData) : null]); + const id = db.lastId(); + await db.persist(); + return id; +} + +export function latestScanLog(accountId) { + return db.one(`SELECT * FROM ScanLog WHERE accountId = ? ORDER BY scannedAt DESC, id DESC LIMIT 1`, [accountId]); +} + +export function listScanLogs(accountId) { + return db.all(`SELECT * FROM ScanLog WHERE accountId = ? ORDER BY scannedAt DESC, id DESC`, [accountId]); +} diff --git a/server/data/structure.js b/server/data/structure.js new file mode 100644 index 0000000..0dd88b6 --- /dev/null +++ b/server/data/structure.js @@ -0,0 +1,93 @@ +// Generated, do not edit. sha256:8017fede3aae4d6eb73088d3b73b43810357285ec3b8acd0f4f14e52251b7e6a +import * as db from '../db.js'; + +function tel(sql, params) { + const r = db.one(sql, params); + return r ? Number(r.n) : 0; +} + +function idLijst(sql, kolom, params) { + return db.all(sql, params).map(r => r[kolom]); +} + +export function accountStructure(accountId) { + const row = db.one(`SELECT * FROM Account WHERE id = ?`, [accountId]); + if (!row) return null; + const beideKanten = [accountId, accountId]; + return { + row, + counts: { + events: tel(`SELECT COUNT(*) AS n FROM Event + WHERE fromAccountId = ? OR toAccountId = ?`, beideKanten), + assets: tel(`SELECT COUNT(DISTINCT assetId) AS n FROM Event + WHERE fromAccountId = ? OR toAccountId = ?`, beideKanten), + walletSources: tel(`SELECT COUNT(*) AS n FROM WalletSource WHERE accountId = ?`, [accountId]), + addresses: tel(`SELECT COUNT(*) AS n FROM WalletAddress WHERE accountId = ?`, [accountId]), + walletTransactions: tel(`SELECT COUNT(*) AS n FROM WalletTransaction WHERE accountId = ?`, [accountId]), + valuations: tel(`SELECT COUNT(*) AS n FROM Valuation WHERE accountId = ?`, [accountId]), + }, + refs: { + assetIds: idLijst(`SELECT DISTINCT assetId FROM Event + WHERE fromAccountId = ? OR toAccountId = ? + ORDER BY assetId`, 'assetId', beideKanten), + }, + }; +} + +export function assetStructure(assetId) { + const row = db.one(`SELECT * FROM Asset WHERE id = ?`, [assetId]); + if (!row) return null; + const REKENINGEN = `SELECT fromAccountId AS a FROM Event WHERE assetId = ? AND fromAccountId IS NOT NULL + UNION + SELECT toAccountId AS a FROM Event WHERE assetId = ? AND toAccountId IS NOT NULL`; + const beideKolommen = [assetId, assetId]; + return { + row, + counts: { + events: tel(`SELECT COUNT(*) AS n FROM Event WHERE assetId = ?`, [assetId]), + accounts: tel(`SELECT COUNT(*) AS n FROM (${REKENINGEN})`, beideKolommen), + pricePoints: tel(`SELECT COUNT(*) AS n FROM PriceHistory WHERE assetId = ?`, [assetId]), + valuations: tel(`SELECT COUNT(*) AS n FROM Valuation WHERE assetId = ?`, [assetId]), + products: tel(`SELECT COUNT(*) AS n FROM MetalProduct WHERE assetId = ?`, [assetId]), + coverage: tel(`SELECT COUNT(*) AS n FROM ProviderCoverage + WHERE symbol = (SELECT symbol FROM Asset WHERE id = ?)`, [assetId]), + }, + refs: { + accountIds: idLijst(`SELECT a FROM (${REKENINGEN}) ORDER BY a`, 'a', beideKolommen), + }, + }; +} + +export function eventRefs({ accountId = null, assetId = null } = {}, limiet = 50) { + if (accountId == null && assetId == null) { + throw new Error('eventRefs needs an account or an asset; without a filter this is a bulk read.'); + } + const n = Number.isInteger(limiet) && limiet > 0 ? limiet : 50; + const waar = []; + const params = []; + if (accountId != null) { + waar.push('(fromAccountId = ? OR toAccountId = ?)'); + params.push(accountId, accountId); + } + if (assetId != null) { + waar.push('assetId = ?'); + params.push(assetId); + } + return db.all( + `SELECT id, type, substr(occurredAt, 1, 7) AS month FROM Event + WHERE ${waar.join(' AND ')} ORDER BY occurredAt DESC, id DESC LIMIT ?`, + [...params, n], + ).map(r => ({ id: r.id, type: r.type, month: r.month })); +} + +export function labelStructure(labelId) { + const row = db.one(`SELECT * FROM Label WHERE id = ?`, [labelId]); + if (!row) return null; + return { + row, + counts: { + walletTransactions: tel(`SELECT COUNT(*) AS n FROM WalletTransaction WHERE labelId = ?`, [labelId]), + }, + refs: {}, + }; +} diff --git a/server/data/support.js b/server/data/support.js new file mode 100644 index 0000000..191ab2a --- /dev/null +++ b/server/data/support.js @@ -0,0 +1,35 @@ +// Generated, do not edit. sha256:e7b97bfa74b04b9522e509df409b4fa237f564c7b1fc8cdbe64c7eb87d1cb4dd +import { CONFIG } from '../../shared/config.js'; +import { marketServiceUrl, marketSupportMessages, marketSupportSend, marketSupportAttachment } from '../market-service.mjs'; + +const S = CONFIG.LICENSE.SUPPORT; +const ATTACHMENT_ID = /^[0-9a-f]{32}$/; + +function assertSignature(signature) { + if (!/^Nostr [A-Za-z0-9+/=]+$/.test(String(signature ?? ''))) throw new Error('A support call carries an "Authorization: Nostr " header.'); +} + +export async function supportInfo() { + return { + serviceUrl: await marketServiceUrl(), + maxText: S.MAX_TEXT, maxAttachmentBytes: S.MAX_ATTACHMENT_BYTES, attachmentTypes: [...S.ATTACHMENT_TYPES], + nostr: { pubkey: S.RECIPIENT, name: S.RECIPIENT_NAME }, + }; +} + +export async function supportMessages(signature) { + assertSignature(signature); + return marketSupportMessages(signature); +} + +export async function sendSupport(signature, body) { + assertSignature(signature); + if (typeof body !== 'string') throw new Error('The body of a support message is the signed JSON text.'); + return marketSupportSend(signature, body); +} + +export async function supportAttachment(signature, id) { + assertSignature(signature); + if (!ATTACHMENT_ID.test(String(id))) throw new Error('An attachment id is 32 hex characters.'); + return marketSupportAttachment(signature, id); +} diff --git a/server/data/table-viewer.js b/server/data/table-viewer.js new file mode 100644 index 0000000..8d557ce --- /dev/null +++ b/server/data/table-viewer.js @@ -0,0 +1,43 @@ +// Generated, do not edit. sha256:051b73ea4c90cd1dfd7110d9d56e91cb527361b375f6b09d6509f8db81b2835e +import * as db from '../db.js'; +import { CONFIG } from '../../shared/config.js'; +import { deleteValuation } from './valuations.js'; +import { deleteProposal } from './proposals.js'; +import { unlinkTxEvent } from './tx-links.js'; + +export function tableColumns(name) { + if (!CONFIG.DB_TABLES.includes(name)) throw new Error(`Unknown table: ${name}`); + return db.all(`PRAGMA table_info(${name})`).map(c => c.name); +} +export function listTableRaw(name) { + if (!CONFIG.DB_TABLES.includes(name)) throw new Error(`Unknown table: ${name}`); + return db.all(`SELECT * FROM ${name} ORDER BY rowid DESC`); +} + +const TABLE_ROW_DELETERS = { + valuations: (id) => deleteValuation(id), + reconcile_proposals: (id) => deleteProposal(id), + tx_event_link: (id) => { + const r = db.one(`SELECT walletTransactionId, eventId FROM WalletTransactionEventLink WHERE id=?`, [id]); + if (!r) throw new Error(`Link ${id} does not exist.`); + return unlinkTxEvent(r.walletTransactionId, r.eventId); + }, +}; +export async function deleteTableRow(name, id) { + if (!CONFIG.DB_DELETABLE_TABLES.includes(name)) throw new Error(`Table ${name} is not deletable.`); + const custom = TABLE_ROW_DELETERS[name]; + if (custom) { await custom(id); return; } + db.run(`DELETE FROM ${name} WHERE id=?`, [id]); + await db.persist(); +} + +export async function clearTable(name) { + if (!CONFIG.DB_DELETABLE_TABLES.includes(name)) throw new Error(`Table ${name} cannot be cleared.`); + const custom = TABLE_ROW_DELETERS[name]; + if (custom) { + for (const r of db.all(`SELECT id FROM ${name}`)) await custom(r.id); + return; + } + db.run(`DELETE FROM ${name}`); + await db.persist(); +} diff --git a/server/data/tx-links.js b/server/data/tx-links.js new file mode 100644 index 0000000..4f40d23 --- /dev/null +++ b/server/data/tx-links.js @@ -0,0 +1,157 @@ +// Generated, do not edit. sha256:a4f989c4fcb487fdb8c1ab80d536f8fa5a1a10a2f0357c22596ab060e7b38694 +import * as db from '../db.js'; +import { ENUMS } from '../../shared/config.js'; +import { assertEnum, nowIso } from './_shared.js'; + +export async function syncAddressLinks() { + const method = 'exact'; + assertEnum('method', ENUMS.LINK_METHOD, method); + db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt) + SELECT w.id, e.id, ?1, ?2 + FROM WalletTransaction w + JOIN Event e ON e.externalAddress = w.address + WHERE w.direction = 'in' AND w.address IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p + WHERE p.walletTransactionId = w.id AND p.resolvedEventId = e.id AND p.status = 'ignored')`, + [method, nowIso()]); + let n = db.one(`SELECT changes() AS n`).n; + db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt) + SELECT w.id, e.id, ?1, ?2 + FROM WalletTransaction w + JOIN Event e ON e.source = 'lightning' + AND (e.fromAccountId = w.accountId OR e.toAccountId = w.accountId) + AND (e.importReference = 'ln:' || w.txid + OR (w.txid LIKE 'fwd:%' AND e.importReference = 'lnfwd:' || w.blockTime)) + WHERE w.layer = 'lightning' + AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p + WHERE p.walletTransactionId = w.id AND p.resolvedEventId = e.id AND p.status = 'ignored')`, + [method, nowIso()]); + n += db.one(`SELECT changes() AS n`).n; + db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt) + SELECT w.id, l.eventId, ?1, ?2 + FROM WalletTransaction w + JOIN WalletTransaction twin ON twin.accountId = w.accountId AND twin.txid = w.txid + AND twin.layer = 'lightning' AND twin.id <> w.id + JOIN WalletTransactionEventLink l ON l.walletTransactionId = twin.id + WHERE w.layer = 'lightning' + AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p + WHERE p.walletTransactionId = w.id AND p.resolvedEventId = l.eventId AND p.status = 'ignored')`, + [method, nowIso()]); + n += db.one(`SELECT changes() AS n`).n; + const asset = onchainAsset(); + if (asset) { + db.run(`INSERT OR IGNORE INTO ReconcileProposal + (accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, resolvedEventId, detectedAt) + SELECT w.accountId, ?1, w.id, w.txid, w.valueSats, w.direction, + CASE WHEN e.type = 'transfer' THEN 'transfer' ELSE 'trade' END, + 'auto_linked', e.id, ?2 + FROM WalletTransactionEventLink l + JOIN WalletTransaction w ON w.id = l.walletTransactionId + JOIN Event e ON e.id = l.eventId + WHERE l.method = 'exact' + AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p + WHERE p.walletTransactionId = w.id AND p.resolvedEventId = e.id)`, + [asset.id, nowIso()]); + } + const changed = n + db.one(`SELECT changes() AS n`).n; + if (changed > 0) await db.persist(); + return n; +} +export function eventLinkMap() { + const m = new Map(); + for (const r of db.all(`SELECT eventId, method FROM WalletTransactionEventLink`)) m.set(r.eventId, r.method); + return m; +} +export function walletTxEventMap() { + const m = new Map(); + const rows = db.all(`SELECT l.walletTransactionId, l.method, e.id AS eventId, e.type, e.amount, e.unitFee, + e.fromAccountId, e.toAccountId, fa.name AS from_name, ta.name AS to_name + FROM WalletTransactionEventLink l + JOIN Event e ON e.id = l.eventId + LEFT JOIN Account fa ON fa.id = e.fromAccountId + LEFT JOIN Account ta ON ta.id = e.toAccountId`); + for (const r of rows) { + if (!m.has(r.walletTransactionId)) m.set(r.walletTransactionId, []); + m.get(r.walletTransactionId).push(r); + } + return m; +} + +export async function linkTxEvent(walletTxId, eventId, method) { + assertEnum('method', ENUMS.LINK_METHOD, method); + db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt) + VALUES (?,?,?,?)`, [walletTxId, eventId, method, nowIso()]); + await db.persist(); +} +export function linkProposalTxSoft(walletTxId, eventId) { + const method = 'soft'; + assertEnum('method', ENUMS.LINK_METHOD, method); + db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt) + VALUES (?,?,?,?)`, [walletTxId, eventId, method, nowIso()]); +} +export async function unlinkTxEvent(walletTxId, eventId) { + db.run(`DELETE FROM WalletTransactionEventLink WHERE walletTransactionId = ? AND eventId = ?`, [walletTxId, eventId]); + db.run(`UPDATE ReconcileProposal SET status = 'ignored' + WHERE walletTransactionId = ? AND resolvedEventId = ? AND status = 'auto_linked'`, [walletTxId, eventId]); + await db.persist(); +} + +export function onchainAsset() { + return db.one(`SELECT * FROM Asset WHERE symbol = 'BTC'`) + || db.one(`SELECT * FROM Asset WHERE kind = 'crypto' ORDER BY symbol LIMIT 1`) + || null; +} + +export function linksForTx(walletTxId) { + return db.all(` + SELECT tel.eventId AS id, tel.method, + e.type, e.occurredAt, e.amount, + a.symbol AS asset_symbol, lf.name AS from_name, lt.name AS to_name + FROM WalletTransactionEventLink tel + JOIN Event e ON e.id = tel.eventId + JOIN Asset a ON a.id = e.assetId + LEFT JOIN Account lf ON lf.id = e.fromAccountId + LEFT JOIN Account lt ON lt.id = e.toAccountId + WHERE tel.walletTransactionId = ? + ORDER BY e.occurredAt DESC, e.id DESC`, [walletTxId]); +} +export function linksForEvent(eventId) { + return db.all(` + SELECT tel.walletTransactionId AS id, tel.method, + t.direction, t.valueSats, t.blockTime, t.txid, t.address, ac.name AS account_name + FROM WalletTransactionEventLink tel + JOIN WalletTransaction t ON t.id = tel.walletTransactionId + LEFT JOIN Account ac ON ac.id = t.accountId + WHERE tel.eventId = ? + ORDER BY (t.blockTime IS NULL) DESC, t.blockTime DESC, t.id DESC`, [eventId]); +} + +export function eventCandidatesForTx(walletTxId, assetId) { + return db.all(` + SELECT e.id, e.type, e.occurredAt, e.amount, + a.symbol AS asset_symbol, lf.name AS from_name, lt.name AS to_name + FROM Event e + JOIN Asset a ON a.id = e.assetId + LEFT JOIN Account lf ON lf.id = e.fromAccountId + LEFT JOIN Account lt ON lt.id = e.toAccountId + WHERE e.assetId = ?2 AND e.type <> 'reservation' + AND e.id NOT IN (SELECT eventId FROM WalletTransactionEventLink WHERE walletTransactionId = ?1) + ORDER BY ABS(strftime('%s', e.occurredAt) - + strftime('%s', COALESCE((SELECT blockTime FROM WalletTransaction WHERE id = ?1), + (SELECT createdAt FROM WalletTransaction WHERE id = ?1)))) ASC, + e.id DESC + LIMIT 100`, [walletTxId, assetId]); +} +export function txCandidatesForEvent(eventId) { + return db.all(` + SELECT t.id, t.direction, t.valueSats, t.blockTime, t.txid, t.address, + ac.name AS account_name, l.name AS label_name + FROM WalletTransaction t + LEFT JOIN Account ac ON ac.id = t.accountId + LEFT JOIN Label l ON l.id = t.labelId + WHERE t.id NOT IN (SELECT walletTransactionId FROM WalletTransactionEventLink WHERE eventId = ?1) + ORDER BY ABS(strftime('%s', COALESCE(t.blockTime, t.createdAt)) - + strftime('%s', (SELECT occurredAt FROM Event WHERE id = ?1))) ASC, + t.id DESC + LIMIT 100`, [eventId]); +} diff --git a/server/data/valuations.js b/server/data/valuations.js new file mode 100644 index 0000000..aabc60b --- /dev/null +++ b/server/data/valuations.js @@ -0,0 +1,138 @@ +// Generated, do not edit. sha256:297c7a1d9ed86e15053cd37bb7126f5fb5b54d94b67e8da901fb66f370ca9301 +import * as db from '../db.js'; +import { ENUMS } from '../../shared/config.js'; +import { nowUtcIso } from '../../shared/time.js'; +import { assertEnum } from './_shared.js'; +import { capacityReachedFor } from './_limits.js'; +import { fail } from '../../shared/app-error.js'; +import { eventsCountByAccount, eventsNetByAccount } from './events.js'; +import { trackDelete, trackUpdate } from './change-log.js'; +import { emitChange } from './_events.js'; + +export function listValuations() { + return db.all(`SELECT v.*, ac.name AS account_name, a.symbol AS asset_symbol, a.name AS asset_name, + a.decimals AS asset_decimals, a.unit AS asset_unit, a.subunit AS asset_subunit, + a.displayUnit AS asset_display_unit, a.kind AS asset_kind + FROM Valuation v + LEFT JOIN Account ac ON ac.id = v.accountId + JOIN Asset a ON a.id = v.assetId + ORDER BY v.valuedOn DESC, v.rateAt DESC, v.id DESC`); +} + +export function anchorAt(accountId, assetId, atIso) { + return db.one( + `SELECT * FROM Valuation + WHERE accountId = ?1 AND assetId = ?2 ${atIso ? 'AND rateAt <= ?3' : ''} + ORDER BY rateAt DESC, id DESC LIMIT 1`, + atIso ? [accountId, assetId, atIso] : [accountId, assetId]) || null; +} + +export function latestAnchors(atIso, { ownOnly = false } = {}) { + return db.all( + `SELECT * FROM ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY accountId, assetId ORDER BY rateAt DESC, id DESC) AS rn + FROM Valuation WHERE accountId IS NOT NULL AND (?1 IS NULL OR rateAt <= ?1) + AND (?2 = 0 OR accountId IN (SELECT id FROM Account WHERE wealthScope IS NULL)) + ) WHERE rn = 1`, [atIso ?? null, ownOnly ? 1 : 0]); +} + +export function anchorsBetween(accountId, assetId, fromIso, untilIso) { + return db.all( + `SELECT * FROM Valuation + WHERE accountId = ?1 AND assetId = ?2 AND rateAt > ?3 AND rateAt <= ?4 + ORDER BY rateAt ASC, id ASC`, [accountId, assetId, fromIso, untilIso]); +} + +export function anchorDaysBetween(accountId, assetId, vanDag, totDag) { + return db.all( + `SELECT DISTINCT valuedOn FROM Valuation + WHERE accountId = ?1 AND assetId = ?2 AND valuedOn > ?3 AND valuedOn < ?4 + ORDER BY valuedOn`, [accountId, assetId, vanDag, totDag]).map(r => r.valuedOn); +} + +export function firstAnchorDate() { + const r = db.one(`SELECT MIN(rateAt) AS first FROM Valuation WHERE accountId IS NOT NULL`); + return r && r.first ? r.first : null; +} + +export function valuationDiff(v) { + if (!v.accountId) return null; + return v.balance - eventsNetByAccount(v.accountId, v.assetId, v.rateAt); +} + +export function anchorBefore(accountId, assetId, atIso) { + return db.one( + `SELECT * FROM Valuation + WHERE accountId = ?1 AND assetId = ?2 AND rateAt < ?3 + ORDER BY rateAt DESC, id DESC LIMIT 1`, [accountId, assetId, atIso]) || null; +} + +export function valuationGap(v) { + if (!v.accountId) return null; + const vorige = anchorBefore(v.accountId, v.assetId, v.rateAt); + if (!vorige) return valuationDiff(v); + const sindsVorige = eventsNetByAccount(v.accountId, v.assetId, v.rateAt) + - eventsNetByAccount(v.accountId, v.assetId, vorige.rateAt); + return v.balance - (vorige.balance + sindsVorige); +} + +export function valuationReconcilable(v) { + if (!v.accountId) return false; + return eventsCountByAccount(v.accountId, v.assetId, v.rateAt) > 0; +} + +function verrijkt() { + const nu = nowUtcIso(); + return listValuations().map(v => ({ + ...v, + diff: valuationDiff(v), + gap: valuationGap(v), + reconcilable: valuationReconcilable(v), + accepted: !!v.gapAcceptedAt, + future: v.rateAt > nu, + })); +} + +export function valuationDiffs() { + return verrijkt().map(({ id, diff, gap, reconcilable, accepted, future }) => + ({ id, diff, gap, reconcilable, accepted, future })); +} + +export function listOpenValuationDiffs() { + return verrijkt().filter(v => !v.future && !v.accepted && v.reconcilable && v.gap != null && v.gap !== 0); +} +export async function updateValuation(id, v) { + assertEnum('source', ENUMS.VALUATION_SOURCE, v.source); + trackUpdate('Valuation', id, () => + db.run(`UPDATE Valuation SET assetId=?, valuedOn=?, accountId =?, balance=?, fiatRate=?, rateAt=?, source=? WHERE id=?`, + [v.assetId, v.valuedOn, v.accountId ?? null, v.balance, v.fiatRate, v.rateAt, v.source, id])); + await db.persist(); + emitChange('ledger-changed'); +} +export async function acceptValuationGap(id, aan = true) { + trackUpdate('Valuation', id, () => + db.run(`UPDATE Valuation SET gapAcceptedAt = ? WHERE id = ?`, [aan ? nowUtcIso() : null, id])); + await db.persist(); + emitChange('ledger-changed'); +} + +export async function deleteValuation(id) { + trackDelete('Valuation', id, () => db.run(`DELETE FROM Valuation WHERE id=?`, [id])); + await db.persist(); + emitChange('ledger-changed'); +} +export async function createValuation(v) { + assertEnum('source', ENUMS.VALUATION_SOURCE, v.source); + const max = capacityReachedFor('valuations'); + if (max !== null) { + fail('valuations.maxFree', { max }, + `The free version holds ${max} balance(s). A licence lifts that; nothing is removed in the meantime.`); + } + db.run(`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source) + VALUES (?,?,?,?,?,?,?)`, + [v.assetId, v.valuedOn, v.accountId ?? null, v.balance, v.fiatRate, v.rateAt, v.source]); + const id = db.lastId(); + await db.persist(); + emitChange('ledger-changed'); + return id; +} diff --git a/server/data/wallet-addresses.js b/server/data/wallet-addresses.js new file mode 100644 index 0000000..ca8331f --- /dev/null +++ b/server/data/wallet-addresses.js @@ -0,0 +1,31 @@ +// Generated, do not edit. sha256:ef4dc1eb01e833a602dd00a17d235a583edc55c162bc923df89b4ecb9c82da16 +import * as db from '../db.js'; +import { nowIso } from './_shared.js'; +import { assertSourceMatchesAccount } from './wallet-sources.js'; + +export function listWalletAddresses(accountId) { + return db.all(`SELECT * FROM WalletAddress + WHERE accountId = ? ORDER BY chainIndex, derivationIndex, address`, [accountId]); +} +export async function upsertWalletAddresses(list) { + for (const a of list) { + assertSourceMatchesAccount('WalletAddress', a.accountId, a.sourceId); + db.run(`INSERT INTO WalletAddress + (accountId, sourceId, address, chainIndex, derivationIndex, automaticLabel, balanceSats, transactionCount, lastScannedAt) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT(address) DO UPDATE SET + accountId=excluded.accountId, sourceId=excluded.sourceId, chainIndex=excluded.chainIndex, + derivationIndex=excluded.derivationIndex, automaticLabel=excluded.automaticLabel, + balanceSats=excluded.balanceSats, transactionCount=excluded.transactionCount, lastScannedAt=excluded.lastScannedAt`, + [a.accountId, a.sourceId ?? null, a.address, a.chainIndex ?? null, a.derivationIndex ?? null, + a.automaticLabel ?? null, a.balanceSats ?? null, a.transactionCount ?? null, a.lastScannedAt ?? nowIso()]); + } + await db.persist(); +} +export function sourceBalance(sourceId) { + return db.one(`SELECT COALESCE(SUM(balanceSats),0) AS sats, COUNT(*) AS n, MAX(lastScannedAt) AS scanned + FROM WalletAddress WHERE sourceId=?`, [sourceId]); +} +export function accountOnchainBalance(accountId) { + return db.one(`SELECT COALESCE(SUM(balanceSats),0) AS sats FROM WalletAddress WHERE accountId=?`, [accountId]).sats; +} diff --git a/server/data/wallet-sources.js b/server/data/wallet-sources.js new file mode 100644 index 0000000..6bd7bc1 --- /dev/null +++ b/server/data/wallet-sources.js @@ -0,0 +1,111 @@ +// Generated, do not edit. sha256:1aab7b8b0f1a8c9ae297214b001ed051798627cb16552a7cd6582bdf219ea871 +import * as db from '../db.js'; +import { ENUMS } from '../../shared/config.js'; +import { assertEnum, nowIso } from './_shared.js'; +import { fail } from '../../shared/app-error.js'; +import { trackDelete, trackUpdate } from './change-log.js'; +import { hasFeature } from '../license.mjs'; +import { services } from '../registers.mjs'; + +const nwc = () => services.get('nwc'); + +export async function purgeWalletSourceSecret(id) { + await nwc()?.deleteNwcUri(id); +} + +export function assertSourceMatchesAccount(tabel, accountId, sourceId) { + if (sourceId == null) return; + const bron = db.one(`SELECT accountId FROM WalletSource WHERE id = ?`, [sourceId]); + if (!bron) throw new Error(`${tabel}: source ${sourceId} does not exist.`); + if (Number(bron.accountId) !== Number(accountId)) { + throw new Error(`${tabel}: source ${sourceId} belongs to account ${bron.accountId}, not to ${accountId}.`); + } +} + +export function listWalletSources(accountId) { + return db.all(`SELECT s.*, c.name AS connection_name, c.url AS connection_url + FROM WalletSource s + LEFT JOIN Connection c ON c.id = s.connectionId + WHERE s.accountId = ? ORDER BY s.id`, [accountId]); +} +export function listAllWalletSources() { + return db.all(`SELECT s.*, a.name AS account_name FROM WalletSource s + JOIN Account a ON a.id = s.accountId ORDER BY a.name, s.id`); +} +function assertLndConnection(connectionId) { + const c = db.one(`SELECT id, kind FROM Connection WHERE id = ?`, [connectionId]); + if (!c) fail('accounts.lndNoConnection', null, 'Pick an LND connection.'); + if (c.kind !== 'lnd') fail('accounts.sourceNotLnd', null, 'The chosen connection is not an LND connection.'); +} +function connectionFor(kind, connectionId) { + if (kind === 'lnd') { assertLndConnection(Number(connectionId)); return Number(connectionId); } + if (ENUMS.ONCHAIN_SOURCE_KIND.includes(kind) && connectionId != null && connectionId !== '') { + assertLndConnection(Number(connectionId)); + return Number(connectionId); + } + return null; +} +const LIGHTNING_SOURCE = new Set(['nwc', 'lnd']); +function assertLightningAllowed(kind) { + if (LIGHTNING_SOURCE.has(kind) && (!hasFeature('lightning') || (kind === 'nwc' && !nwc()))) { + fail('connections.lightningPaid', null, 'Lightning connections belong to the paid version.'); + } +} +export async function createWalletSource({ accountId, kind, value, name, scriptType, connectionId = null, secret = null }) { + assertEnum('kind', ENUMS.WALLET_SOURCE_KIND, kind); + assertLightningAllowed(kind); + if (kind === 'xpub') assertEnum('scriptType', ENUMS.SCRIPT_TYPE, scriptType); + if (kind === 'nwc' && !secret) fail('accounts.sourceUriMissing', null, 'The NWC URI is missing.'); + const verbinding = connectionFor(kind, connectionId); + db.run(`INSERT INTO WalletSource (accountId, kind, value, name, scriptType, connectionId, createdAt) VALUES (?,?,?,?,?,?,?)`, + [accountId, kind, kind === 'lnd' ? '' : value, name ?? null, kind === 'xpub' ? scriptType : null, + verbinding, nowIso()]); + const id = db.lastId(); + if (kind === 'nwc') await nwc().setNwcUri(id, secret); + await db.persist(); return id; +} +export async function updateWalletSource(id, { kind, value, name, scriptType, connectionId = null, secret = null }) { + assertEnum('kind', ENUMS.WALLET_SOURCE_KIND, kind); + if (kind === 'xpub') assertEnum('scriptType', ENUMS.SCRIPT_TYPE, scriptType); + const oud = db.one(`SELECT kind FROM WalletSource WHERE id = ?`, [id]); + if (oud?.kind !== kind) assertLightningAllowed(kind); + const nieuwValue = kind === 'lnd' ? '' : value; + const nieuwConnectie = connectionFor(kind, connectionId); + trackUpdate('WalletSource', id, () => + db.run(`UPDATE WalletSource SET kind=?, value=?, name=?, scriptType=?, connectionId=?, + balanceSats = CASE WHEN kind = ? AND value = ? AND connectionId IS ? THEN balanceSats END, + lastScannedAt = CASE WHEN kind = ? AND value = ? AND connectionId IS ? THEN lastScannedAt END + WHERE id=?`, + [kind, nieuwValue, name ?? null, kind === 'xpub' ? scriptType : null, nieuwConnectie, + kind, nieuwValue, nieuwConnectie, kind, nieuwValue, nieuwConnectie, id])); + if (kind === 'nwc' && secret) await nwc().setNwcUri(id, secret); + if (kind !== 'nwc') await nwc()?.deleteNwcUri(id); + await db.persist(); +} +export async function recordSourceBalance(id, sats) { + const bron = db.one(`SELECT kind FROM WalletSource WHERE id = ?`, [Number(id)]); + if (!bron) throw new Error(`Wallet source ${id} does not exist.`); + if (bron.kind !== 'lnd' && bron.kind !== 'nwc') throw new Error(`Wallet source ${id} is on-chain; its balance comes from its addresses.`); + if (!Number.isInteger(sats) || sats < 0) throw new Error(`A balance is a whole number of sats from zero: '${sats}'.`); + db.run(`UPDATE WalletSource SET balanceSats = ?, lastScannedAt = ? WHERE id = ?`, [sats, nowIso(), Number(id)]); + await db.persist(); +} +export function lightningBalances() { + return db.all(`SELECT WalletSource.accountId, Account.name AS account_name, + SUM(WalletSource.balanceSats) AS sats, MAX(WalletSource.lastScannedAt) AS scannedAt + FROM WalletSource + JOIN Account ON Account.id = WalletSource.accountId + WHERE WalletSource.lastScannedAt IS NOT NULL + AND (WalletSource.kind = 'lnd' + OR (WalletSource.kind = 'nwc' AND NOT EXISTS ( + SELECT 1 FROM WalletSource node + WHERE node.accountId = WalletSource.accountId AND node.kind = 'lnd'))) + GROUP BY WalletSource.accountId, Account.name + ORDER BY Account.name`); +} + +export async function deleteWalletSource(id) { + await purgeWalletSourceSecret(id); + trackDelete('WalletSource', id, () => db.run(`DELETE FROM WalletSource WHERE id=?`, [id])); + await db.persist(); +} diff --git a/server/data/wallet-txs.js b/server/data/wallet-txs.js new file mode 100644 index 0000000..4bae6b1 --- /dev/null +++ b/server/data/wallet-txs.js @@ -0,0 +1,103 @@ +// Generated, do not edit. sha256:28bf467843efd410b1dacbe64352649ff4b982578fe5215b9da5061ebec43621 +import * as db from '../db.js'; +import { nowIso } from './_shared.js'; +import { fail } from '../../shared/app-error.js'; +import { assertSourceMatchesAccount } from './wallet-sources.js'; +import { createLabel } from './labels.js'; +import { toSearchName } from '../../shared/search-name.js'; +import { CONFIG } from '../../shared/config.js'; +import { compactMemo } from '../../shared/zap.js'; + +const UPSERT = Object.freeze({ + onchain: `INSERT INTO WalletTransaction + (accountId, sourceId, layer, txid, direction, valueSats, feeSats, blockHeight, blockTime, address, createdAt) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (accountId, txid) WHERE layer = 'onchain' DO UPDATE SET + sourceId=excluded.sourceId, direction=excluded.direction, valueSats=excluded.valueSats, + feeSats=excluded.feeSats, blockHeight=excluded.blockHeight, blockTime=excluded.blockTime, + address=excluded.address`, + lightning: `INSERT INTO WalletTransaction + (accountId, sourceId, layer, txid, direction, valueSats, feeSats, blockHeight, blockTime, address, kind, memo, createdAt) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (sourceId, txid) WHERE layer = 'lightning' DO UPDATE SET + direction=excluded.direction, valueSats=excluded.valueSats, + feeSats=excluded.feeSats, blockHeight=excluded.blockHeight, blockTime=excluded.blockTime, + address=excluded.address, kind=excluded.kind, memo=excluded.memo`, +}); + +const TWIN_LABEL = `UPDATE WalletTransaction SET labelId = ( + SELECT twin.labelId FROM WalletTransaction twin + WHERE twin.layer = 'lightning' AND twin.accountId = WalletTransaction.accountId + AND twin.txid = WalletTransaction.txid AND twin.labelId IS NOT NULL + ORDER BY twin.id LIMIT 1) + WHERE layer = 'lightning' AND labelId IS NULL + AND EXISTS (SELECT 1 FROM WalletTransaction twin + WHERE twin.layer = 'lightning' AND twin.accountId = WalletTransaction.accountId + AND twin.txid = WalletTransaction.txid AND twin.labelId IS NOT NULL)`; + +function assertLayerShape(t) { + if (!Object.hasOwn(UPSERT, t.layer)) throw new Error(`Unknown wallet movement layer: '${t.layer}'.`); + if (t.layer !== 'lightning') return; + if (!Number.isInteger(t.sourceId)) throw new Error('A lightning movement needs its source.'); + if (!t.blockTime) throw new Error('A lightning movement needs the moment it settled.'); + if (t.blockHeight != null) throw new Error('A lightning movement has no block height.'); + if (t.kind != null && t.kind !== 'self') throw new Error(`A lightning movement is ordinary or 'self', not '${t.kind}'.`); +} + +export async function upsertWalletTxs(list) { + for (const given of list) { + const t = { ...given, layer: given.layer ?? 'onchain' }; + assertLayerShape(t); + assertSourceMatchesAccount('WalletTransaction', t.accountId, t.sourceId); + const velden = [t.accountId, t.sourceId ?? null, t.layer, t.txid, t.direction, t.valueSats, t.feeSats ?? null, + t.blockHeight ?? null, t.blockTime ?? null, t.address ?? null]; + db.run(UPSERT[t.layer], t.layer === 'lightning' + ? [...velden, t.kind ?? null, compactMemo(t.memo), nowIso()] : [...velden, nowIso()]); + } + if (list.some(t => t.layer === 'lightning')) db.run(TWIN_LABEL); + await db.persist(); +} +export function listSourceTxs(sourceId) { + return db.all(`SELECT t.*, l.name AS label_name + FROM WalletTransaction t + LEFT JOIN Label l ON l.id = t.labelId + WHERE t.sourceId = ? + ORDER BY (t.blockTime IS NULL) DESC, t.blockTime DESC, t.id DESC`, [sourceId]); +} +const CHANNEL_ROWS = `accountId IN (SELECT value FROM json_each(?1)) AND layer = 'onchain' + AND txid IN (SELECT value FROM json_each(?2))`; + +export async function labelChannelTxs(accountId, txids) { + if (!Number.isInteger(accountId)) throw new Error('labelChannelTxs needs an account id.'); + if (!Array.isArray(txids) || txids.some(x => typeof x !== 'string' || !/^[0-9a-f]{64}$/.test(x))) { + throw new Error('labelChannelTxs takes a list of transaction ids in lowercase hex.'); + } + if (!txids.length) return 0; + const lijst = JSON.stringify(txids); + const accounts = JSON.stringify([accountId, ...db.all( + `SELECT DISTINCT onchain.accountId FROM WalletSource onchain + JOIN WalletSource node ON node.connectionId = onchain.connectionId AND node.kind = 'lnd' + WHERE node.accountId = ? AND onchain.kind IN ('xpub','address') AND onchain.accountId <> ?`, + [accountId, accountId]).map(r => r.accountId)]); + db.run(`UPDATE WalletTransaction SET kind = 'channel' WHERE ${CHANNEL_ROWS} AND kind IS NULL`, [accounts, lijst]); + const kandidaten = db.one(`SELECT COUNT(*) AS n FROM WalletTransaction WHERE ${CHANNEL_ROWS} AND labelId IS NULL`, [accounts, lijst]).n; + if (!kandidaten) { await db.persist(); return 0; } + const naam = CONFIG.LND.CHANNEL_LABEL; + const label = db.one(`SELECT id FROM Label WHERE searchName = ?`, [toSearchName(naam)]) + || { id: await createLabel({ name: naam }) }; + db.run(`UPDATE WalletTransaction SET labelId = ?3 WHERE ${CHANNEL_ROWS} AND labelId IS NULL`, [accounts, lijst, label.id]); + await db.persist(); + return kandidaten; +} + +export async function setTxLabels(ids, labelId) { + if (!Array.isArray(ids) || ids.length === 0) fail('accounts.labelNoTxs', null, 'No transactions given for the label.'); + db.run(`UPDATE WalletTransaction SET labelId = ?1 + WHERE id IN (SELECT value FROM json_each(?2)) + OR (layer = 'lightning' AND EXISTS ( + SELECT 1 FROM WalletTransaction chosen + WHERE chosen.id IN (SELECT value FROM json_each(?2)) AND chosen.layer = 'lightning' + AND chosen.accountId = WalletTransaction.accountId AND chosen.txid = WalletTransaction.txid))`, + [labelId ?? null, JSON.stringify(ids)]); + await db.persist(); +} diff --git a/server/db-backup.mjs b/server/db-backup.mjs new file mode 100644 index 0000000..c958311 --- /dev/null +++ b/server/db-backup.mjs @@ -0,0 +1,154 @@ +// Generated, do not edit. sha256:9577dee0ed20ca611f2d96db3a931311a0d672a76d95d80dda4d00726259c4be +import { randomBytes, scrypt as scryptCb, createCipheriv, createDecipheriv, timingSafeEqual } from 'node:crypto'; +import { promisify } from 'node:util'; +import { readFile, writeFile, rm, mkdtemp, rename, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { secretsFile } from './keys.mjs'; +import { run } from './db/conn.mjs'; +import { initDatabase, dbFile } from './db.js'; +import { keurBestand, wisselDatabaseBestand } from './db-restore.mjs'; +import { fail } from '../shared/app-error.js'; + +const scrypt = promisify(scryptCb); + +const MAGIC = Buffer.from('ASSETAPP-BACKUP', 'latin1'); +const VERSIE = 1; +const SALT_LEN = 16; +const IV_LEN = 12; +const TAG_LEN = 16; +const KOP_LEN = MAGIC.length + 1 + SALT_LEN + IV_LEN + TAG_LEN; +const SCRYPT = Object.freeze({ N: 2 ** 15, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }); + +const sleutelUit = (wachtwoord, salt) => scrypt(wachtwoord, salt, 32, SCRYPT); + +function eisWachtwoord(wachtwoord) { + const w = String(wachtwoord ?? ''); + if (w.length < 8) fail('dbfile.passTooShort', null, 'Choose a password of at least 8 characters for the backup.'); + return w; +} + +const bestaat = async pad => { try { await access(pad); return true; } catch { return false; } }; + +async function databaseKopie() { + const dir = await mkdtemp(join(tmpdir(), 'assetapp-backup-')); + const uit = join(dir, 'db.sqlite'); + try { + run(`VACUUM INTO '${uit.replace(/'/g, "''")}'`, []); + return { bytes: await readFile(uit), opruimen: () => rm(dir, { recursive: true, force: true }) }; + } catch (e) { + await rm(dir, { recursive: true, force: true }); + throw e; + } +} + +export async function createBackup(wachtwoord) { + const w = eisWachtwoord(wachtwoord); + const kopie = await databaseKopie(); + try { + const geheimenPad = secretsFile(); + const geheimen = await bestaat(geheimenPad) ? await readFile(geheimenPad) : Buffer.from('{}', 'utf-8'); + const manifest = Buffer.from(JSON.stringify({ + v: 1, gemaakt: new Date().toISOString(), db: kopie.bytes.length, secrets: geheimen.length, + }) + '\n', 'utf-8'); + const plat = Buffer.concat([manifest, kopie.bytes, geheimen]); + + const salt = randomBytes(SALT_LEN); + const iv = randomBytes(IV_LEN); + const c = createCipheriv('aes-256-gcm', await sleutelUit(w, salt), iv); + const cijfer = Buffer.concat([c.update(plat), c.final()]); + const bytes = Buffer.concat([MAGIC, Buffer.of(VERSIE), salt, iv, c.getAuthTag(), cijfer]); + return { bytes, db: kopie.bytes.length, secrets: geheimen.length }; + } finally { + await kopie.opruimen(); + } +} + +async function openBackup(bytes, wachtwoord) { + if (!bytes || bytes.length < KOP_LEN) fail('dbfile.notABackup', null, 'This file is not an AssetApp backup (too short).'); + if (!bytes.subarray(0, MAGIC.length).equals(MAGIC)) { + fail('dbfile.notABackupUseRestore', null, 'This file is not an AssetApp backup; restore a loose .sqlite with "Terugzetten".'); + } + const versie = bytes[MAGIC.length]; + if (versie !== VERSIE) fail('dbfile.unknownBackupVersion', { versie, kent: VERSIE }, `Unknown backup version (${versie}); this app knows version ${VERSIE}.`); + + let p = MAGIC.length + 1; + const salt = bytes.subarray(p, p += SALT_LEN); + const iv = bytes.subarray(p, p += IV_LEN); + const tag = bytes.subarray(p, p += TAG_LEN); + const cijfer = bytes.subarray(p); + + const d = createDecipheriv('aes-256-gcm', await sleutelUit(String(wachtwoord ?? ''), salt), iv); + d.setAuthTag(tag); + let plat; + try { + plat = Buffer.concat([d.update(cijfer), d.final()]); + } catch { + fail('dbfile.decryptFailed', null, 'Decryption failed: wrong password, or the file is damaged.'); + } + + const nl = plat.indexOf(0x0a); + if (nl < 0) fail('dbfile.noManifest', null, 'Backup damaged: no manifest found.'); + let manifest; + try { manifest = JSON.parse(plat.subarray(0, nl).toString('utf-8')); } + catch { fail('dbfile.manifestUnreadable', null, 'Backup damaged: the manifest is unreadable.'); } + const dbLen = Number(manifest.db), secretsLen = Number(manifest.secrets); + if (!Number.isInteger(dbLen) || !Number.isInteger(secretsLen) + || nl + 1 + dbLen + secretsLen !== plat.length) { + fail('dbfile.manifestLengths', null, 'Backup damaged: the lengths in the manifest do not match the content.'); + } + return { + manifest, + db: plat.subarray(nl + 1, nl + 1 + dbLen), + secrets: plat.subarray(nl + 1 + dbLen), + }; +} + +export async function restoreBackup(bytes, wachtwoord) { + const { manifest, db, secrets } = await openBackup(bytes, wachtwoord); + + const doel = dbFile(); + const geheimenPad = secretsFile(); + const incoming = `${doel}.new`; + await writeFile(incoming, db, { mode: 0o600 }); + let keuring; + try { + keuring = keurBestand(incoming); + } catch (e) { + await rm(incoming, { force: true }); + throw e; + } + let aantalGeheimen; + try { aantalGeheimen = Object.keys(JSON.parse(secrets.toString('utf-8'))).length; } + catch { + await rm(incoming, { force: true }); + fail('dbfile.secretsUnreadable', null, 'Backup damaged: the secrets file is not readable JSON.'); + } + + const previousDb = `${doel}.previous`; + const previousSecrets = `${geheimenPad}.previous`; + await wisselDatabaseBestand(incoming, previousDb, async () => { + if (await bestaat(geheimenPad)) { + await rm(previousSecrets, { force: true }); + await rename(geheimenPad, previousSecrets); + } + await writeFile(geheimenPad, secrets, { mode: 0o600 }); + }); + + const regel = initDatabase(); + return { + gemaakt: manifest.gemaakt ?? null, + versie: keuring.versie, + wezen: keuring.wezen, + geheimen: aantalGeheimen, + previousDb, + previousSecrets, + regel, + }; +} + +export function isBackupBestand(bytes) { + return !!bytes && bytes.length >= MAGIC.length && bytes.subarray(0, MAGIC.length).equals(MAGIC); +} + +export const FORMAAT = Object.freeze({ MAGIC, VERSIE, SALT_LEN, IV_LEN, TAG_LEN, KOP_LEN, SCRYPT }); diff --git a/server/db-export.mjs b/server/db-export.mjs new file mode 100644 index 0000000..23c1c95 --- /dev/null +++ b/server/db-export.mjs @@ -0,0 +1,30 @@ +// Generated, do not edit. sha256:c5d56ec66e3cd8cb38447fbd3bde3d13f334c82cd570f61ee957f449d17935a3 +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { run } from './db/conn.mjs'; + +function bestandsnaam() { + const d = new Date(); + const p = n => String(n).padStart(2, '0'); + return `assetapp-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}.sqlite`; +} + +export async function exportDatabase(res) { + const dir = await mkdtemp(join(tmpdir(), 'assetapp-export-')); + const uit = join(dir, 'export.sqlite'); + try { + run(`VACUUM INTO '${uit.replace(/'/g, "''")}'`, []); + const bytes = await readFile(uit); + res.writeHead(200, { + 'Content-Type': 'application/vnd.sqlite3', + 'Content-Disposition': `attachment; filename="${bestandsnaam()}"`, + 'Content-Length': bytes.length, + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }); + res.end(bytes); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} diff --git a/server/db-fresh.mjs b/server/db-fresh.mjs new file mode 100644 index 0000000..dda87fe --- /dev/null +++ b/server/db-fresh.mjs @@ -0,0 +1,47 @@ +// Generated, do not edit. sha256:5bd22bc9def9eb9c8a4556854dfc31dcdfb39f064aeaa1b0b584f8b737953956 +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { closeDatabase, run } from './db/conn.mjs'; +import { initDatabase, dbFile } from './db.js'; +import { restoreDatabase } from './db-restore.mjs'; +import { getBaseCurrency } from './data/portfolio.js'; +import { setupStatus } from './data/onboarding.js'; + +export async function startFreshDatabase({ baseCurrency: chosen = null } = {}) { + const dir = await mkdtemp(join(tmpdir(), 'assetapp-fresh-')); + try { + const nieuw = join(dir, 'nieuw.sqlite'); + const baseCurrency = chosen ?? getBaseCurrency(); + closeDatabase(); + initDatabase(nieuw, { baseCurrency }); + + const kopie = join(dir, 'kopie.sqlite'); + run(`VACUUM INTO '${kopie.replace(/'/g, "''")}'`, []); + closeDatabase(); + + const bytes = await readFile(kopie); + const uit = await restoreDatabase(bytes); + return { + ...uit, + geheimenWaarschuwing: 'De geheimen (NWC-URI\'s, LND-macaroons, provider-sleutels) staan in ' + + 'secrets.json in de map van deze portefeuille en zijn NIET weggegooid: daar staat ook de sleutel van de toegangscontrole. ' + + 'Let op dat een geheim aan een bron-id hangt en die reeks in een lege administratie weer bij 1 ' + + 'begint, dus een nieuwe tracking-bron kan het geheim van een oude erven.', + }; + } catch (e) { + try { initDatabase(dbFile()); } catch { } + throw e; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +export async function chooseSetupCurrency(baseCurrency) { + if (getBaseCurrency() === baseCurrency) return { changed: false }; + if (!setupStatus().currencyOpen) { + throw new Error('The unit of account can only be chosen on a portfolio that holds nothing yet.'); + } + await startFreshDatabase({ baseCurrency }); + return { changed: true }; +} diff --git a/server/db-restore.mjs b/server/db-restore.mjs new file mode 100644 index 0000000..2392940 --- /dev/null +++ b/server/db-restore.mjs @@ -0,0 +1,127 @@ +// Generated, do not edit. sha256:e1360b8b92e9fdeccd0b88044df16a08eafd2bf7c084bf7549eeac1712ff09fb +import { DatabaseSync } from 'node:sqlite'; +import { writeFile, rename, rm, access } from 'node:fs/promises'; +import { CONFIG } from '../shared/config.js'; +import { BASELINE_VERSION } from './db/migrations.js'; +import { closeDatabase } from './db/conn.mjs'; +import { initDatabase, dbFile } from './db.js'; +import { fail } from '../shared/app-error.js'; + +const BUREN = ['-wal', '-shm']; + +const VERGRENDELD = new Set(['EBUSY', 'EPERM', 'EACCES']); +const WACHT_MS = [50, 150, 400]; + +async function hernoemVolhardend(van, naar) { + for (let poging = 0; ; poging++) { + try { return await rename(van, naar); } + catch (e) { + if (!VERGRENDELD.has(e.code) || poging >= WACHT_MS.length) throw vertaalVergrendeling(e, van); + await new Promise(r => setTimeout(r, WACHT_MS[poging])); + } + } +} + +function vertaalVergrendeling(e, pad) { + if (!VERGRENDELD.has(e.code)) return e; + return new Error( + `Het databasebestand is door een ander programma in gebruik (${e.code}), dus het kon niet worden ` + + `verplaatst: ${pad}. Er is niets gewijzigd. Sluit wat er nog aan deze datamap hangt en probeer het ` + + `opnieuw: een tweede exemplaar van de app, de MCP-server (npm run mcp), of een database-viewer.`); +} + +const bestaat = async pad => { try { await access(pad); return true; } catch { return false; } }; + +export function keurBestand(pad) { + let db; + try { + db = new DatabaseSync(pad, { readOnly: true }); + } catch (e) { + fail('dbfile.notReadableSqlite', { reden: e.message }, `This is not a readable SQLite file (${e.message}).`); + } + try { + const heeftSchema = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='Schema'`).get(); + if (!heeftSchema) { + fail('dbfile.noSchemaTable', null, + 'This file has no Schema table; a database from before the baseline can no longer be converted ' + + '(git log --all -- scripts/baseline-convert.mjs).'); + } + const rij = db.prepare(`SELECT version FROM Schema`).get(); + const versie = rij && Number(rij.version); + if (!Number.isInteger(versie)) fail('dbfile.noReadableVersion', null, 'The Schema table holds no readable version.'); + if (versie < BASELINE_VERSION) { + fail('dbfile.versionBeforeBaseline', { versie, baseline: BASELINE_VERSION }, + `This database is version ${versie}, from before the new layout (version ${BASELINE_VERSION}); ` + + 'see git log --all -- scripts/baseline-convert.mjs.'); + } + if (versie > CONFIG.SCHEMA_VERSION) { + fail('dbfile.versionNewerThanApp', { versie, app: CONFIG.SCHEMA_VERSION }, + `This database is version ${versie} and thus newer than the app (${CONFIG.SCHEMA_VERSION}).`); + } + const wezen = db.prepare(`PRAGMA foreign_key_check`).all(); + return { versie, wezen: wezen.length }; + } finally { + db.close(); + } +} + +export async function restoreDatabase(bytes) { + if (!bytes || bytes.length === 0) fail('dbfile.emptyFile', null, 'Received an empty file.'); + if (bytes.subarray(0, 15).toString('latin1') !== 'SQLite format 3') { + fail('dbfile.notSqlite', null, 'This is not a SQLite file (the file header does not match).'); + } + + const doel = dbFile(); + const incoming = `${doel}.new`; + const previous = `${doel}.previous`; + await writeFile(incoming, bytes, { mode: 0o600 }); + + let keuring; + try { + keuring = keurBestand(incoming); + } catch (e) { + await rm(incoming, { force: true }); + throw e; + } + + await wisselDatabaseBestand(incoming, previous); + + const regel = initDatabase(); + return { + versie: keuring.versie, + wezen: keuring.wezen, + previous, + regel, + geheimenWaarschuwing: 'De geheimen (NWC-URI\'s, LND-macaroons, provider-sleutels) staan in ' + + 'secrets.json in de map van deze portefeuille en zijn NIET meeverhuisd. Komt deze database uit een andere administratie, ' + + 'controleer dan je tracking-bronnen: een geheim hangt aan een bron-id en dat id kan nu bij een ' + + 'andere bron horen.', + }; +} + +export async function wisselDatabaseBestand(incoming, previous, naWissel = async () => {}) { + let opzijGezet = false; + const doel = dbFile(); + closeDatabase(); + try { + await rm(previous, { force: true }); + for (const b of BUREN) await rm(previous + b, { force: true }); + if (await bestaat(doel)) { + await hernoemVolhardend(doel, previous); + opzijGezet = true; + for (const b of BUREN) if (await bestaat(doel + b)) await hernoemVolhardend(doel + b, previous + b); + } + await hernoemVolhardend(incoming, doel); + await naWissel(); + } catch (e) { + if (opzijGezet && !(await bestaat(doel))) { + try { + await rename(previous, doel); + for (const b of BUREN) if (await bestaat(previous + b)) await rename(previous + b, doel + b); + } catch { } + } + await rm(incoming, { force: true }); + try { initDatabase(); } catch { } + throw e; + } +} diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..a1a6dc7 --- /dev/null +++ b/server/db.js @@ -0,0 +1,91 @@ +// Generated, do not edit. sha256:0c36b25c75165df3de3ba590bc3b8b5e5c39b5db442fe5f91547c9706dc403d0 +import { join } from 'node:path'; +import { CONFIG } from '../shared/config.js'; +import { activeDir } from './portfolios.mjs'; +import { openDatabase, closeDatabase, database, pragmaStand, withOtherDatabase, run, all, one } from './db/conn.mjs'; +import { SCHEMA } from './db/schema.js'; +import { + seedPreferences, seedProviders, seedConnections, seedAssets, + seedPriceSource, seedDashboardProfile, +} from './db/seeds.js'; +import { runMigrations, readVersion } from './db/migrations.js'; + +export { run, all, one, lastId } from './db/conn.mjs'; +export { SCHEMA } from './db/schema.js'; +export { seedPriceSource } from './db/seeds.js'; + +export function dbFile() { + return process.env.ASSETAPP_DB_FILE || join(activeDir(), 'db.sqlite'); +} + +export function initDatabase(pad = dbFile(), { baseCurrency = CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY } = {}) { + assertBaseCurrency(baseCurrency); + const bestaat = openBestaand(pad); + if (!bestaat) { + inrichten(baseCurrency); + } else { + runMigrations(); + seedDashboardProfile(); + } + return startregel(pad, bestaat); +} + +export function createDatabase(pad, { baseCurrency = CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY } = {}) { + assertBaseCurrency(baseCurrency); + withOtherDatabase(pad, () => { + if (one(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'Schema'`)) { + throw new Error(`createDatabase: ${pad} already holds a database.`); + } + inrichten(baseCurrency); + }); +} + +function assertBaseCurrency(code) { + if (!CONFIG.PORTFOLIO.BASE_CURRENCIES.includes(code)) { + throw new Error(`Unknown unit of account '${code}': not in CONFIG.PORTFOLIO.BASE_CURRENCIES.`); + } +} + +function inrichten(baseCurrency) { + run(SCHEMA); + run(`INSERT INTO Schema (version) VALUES (?)`, [CONFIG.SCHEMA_VERSION]); + run(`INSERT INTO Portfolio (id, baseCurrency, createdAt) VALUES (1, ?, ?)`, [baseCurrency, new Date().toISOString()]); + seedPreferences(); + seedProviders(); + seedConnections(); + seedAssets(); + seedPriceSource(null, 'confirmed'); + seedDashboardProfile(); +} + +function openBestaand(pad) { + openDatabase(pad); + return !!one(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'Schema'`); +} + +function startregel(pad, bestond) { + const p = pragmaStand(); + const restjes = one(`SELECT COUNT(*) AS n FROM Preference WHERE scope = 'nwc'`); + const waarschuwing = restjes && restjes.n > 0 + ? `\n[assetapp] LET OP: er staan nog ${restjes.n} versleutelde NWC-rij(en) in Preference, uit de tijd ` + + 'dat geheimen in de database leefden. Ze zijn niet meer te ontsleutelen en niemand leest ze; ' + + 'opruimen kan via Systeem → Data.' + : ''; + return `database: ${pad} (${bestond ? 'bestaand' : 'nieuw aangemaakt'}, versie ${readVersion()}) | ` + + `FK's: ${p.foreignKeys ? 'aan' : 'UIT'} | journal: ${p.journalMode}` + waarschuwing; +} + +export function schemaVersion() { return readVersion(); } + +export function databaseStatus() { + const ingericht = one(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'Schema'`); + if (!ingericht) return { klaar: false, reden: 'leeg', versie: null }; + const versie = readVersion(); + if (versie < CONFIG.SCHEMA_VERSION) return { klaar: false, reden: 'oud', versie }; + if (versie > CONFIG.SCHEMA_VERSION) return { klaar: false, reden: 'nieuwer', versie }; + return { klaar: true, reden: null, versie }; +} + +export async function persist() { } + +export { closeDatabase, database }; diff --git a/server/db/conn.mjs b/server/db/conn.mjs new file mode 100644 index 0000000..b1cf1c7 --- /dev/null +++ b/server/db/conn.mjs @@ -0,0 +1,73 @@ +// Generated, do not edit. sha256:95f28c26a77b79beac5f8c6818bd25243362641c1d8549949427a847e4f810f9 +import { DatabaseSync } from 'node:sqlite'; + +export const BUSY_TIMEOUT_MS = 5000; + +let db = null; + +export function openDatabase(pad) { + if (db) throw new Error('A database connection is already open; close it first with closeDatabase().'); + db = new DatabaseSync(pad, { enableForeignKeyConstraints: true }); + db.exec(`PRAGMA journal_mode = WAL`); + db.exec(`PRAGMA synchronous = NORMAL`); + db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`); + return db; +} + +export function database() { return db; } + +export function withOtherDatabase(pad, fn) { + const vorige = db; + db = null; + try { + openDatabase(pad); + const uit = fn(); + if (uit && typeof uit.then === 'function') throw new Error('withOtherDatabase: fn must be synchronous.'); + return uit; + } finally { + closeDatabase(); + db = vorige; + } +} + +export function closeDatabase() { + if (!db) return; + db.close(); + db = null; +} + +export function pragmaStand() { + const lees = (naam, veld) => database().prepare(`PRAGMA ${naam}`).get()[veld]; + return { + foreignKeys: lees('foreign_keys', 'foreign_keys') === 1, + journalMode: lees('journal_mode', 'journal_mode'), + synchronous: lees('synchronous', 'synchronous'), + busyTimeout: lees('busy_timeout', 'timeout'), + }; +} + +const bind = params => params.map(v => { + if (v === undefined) return null; + if (typeof v === 'boolean') return v ? 1n : 0n; + if (typeof v === 'number' && Number.isInteger(v)) return BigInt(v); + return v; +}); + +export function run(sql, params) { + if (params === undefined) db.exec(sql); + else db.prepare(sql).run(...bind(params)); +} + +export function all(sql, params = []) { + return db.prepare(sql).all(...bind(params)).map(rij => ({ ...rij })); +} + +export function one(sql, params = []) { + const rijen = all(sql, params); + return rijen.length ? rijen[0] : null; +} + +export function lastId() { + const r = one(`SELECT last_insert_rowid() AS id`); + return r ? r.id : null; +} diff --git a/server/db/migrations.js b/server/db/migrations.js new file mode 100644 index 0000000..f59b045 --- /dev/null +++ b/server/db/migrations.js @@ -0,0 +1,2768 @@ +// Generated, do not edit. sha256:40ff653075fba536c72875e2aaa7a896d17bbea56edc4438b0f2dcada32718e2 +import { CONFIG } from '../../shared/config.js'; +import { run, one, all } from './conn.mjs'; + +export const BASELINE_VERSION = 60; + +const MIGRATIONS = { + 61: () => { + run(`CREATE TABLE AiToolCall ( + id INTEGER PRIMARY KEY, + sessionId TEXT NOT NULL, + client TEXT, + tool TEXT NOT NULL, + arguments TEXT, + fieldsShared TEXT, + charactersOut INTEGER CHECK (charactersOut IS NULL OR charactersOut >= 0), + outcome TEXT NOT NULL CHECK (outcome IN ('ok','error','refused')), + detail TEXT, + occurredAt TEXT NOT NULL + ) STRICT;`); + run(`CREATE INDEX IxAiToolCallOccurredAt ON AiToolCall(occurredAt);`); + }, + + 62: () => { + run(`ALTER TABLE AiToolCall ADD COLUMN serverVersion TEXT`); + }, + + 63: () => { + run(`CREATE TABLE AiToolCallMessage ( + id INTEGER PRIMARY KEY, + callId INTEGER NOT NULL REFERENCES AiToolCall(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('request','response')), + text TEXT NOT NULL, + occurredAt TEXT NOT NULL + ) STRICT;`); + run(`CREATE INDEX IxAiToolCallMessageCallId ON AiToolCallMessage(callId);`); + }, + + 64: () => { + run(`CREATE TABLE BusSignal ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + occurredAt TEXT NOT NULL + ) STRICT;`); + }, + + 65: () => { + run(`CREATE TABLE Command ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('navigate')), + payload TEXT, + state TEXT NOT NULL CHECK (state IN ('open','claimed','done','rejected','expired')), + claimedBy TEXT, + claimedAt TEXT, + expiresAt TEXT, + detail TEXT, + resolvedAt TEXT, + createdAt TEXT NOT NULL + ) STRICT;`); + run(`UPDATE Preference SET value = '5' WHERE scope = 'timers' AND key = 'bus_poll_seconds' AND value = '15'`); + }, + + 66: () => { + run(`ALTER TABLE Command RENAME TO CommandOud`); + run(`CREATE TABLE Command ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('navigate','locate')), + payload TEXT, + state TEXT NOT NULL CHECK (state IN ('open','claimed','done','rejected','expired')), + claimedBy TEXT, + claimedAt TEXT, + expiresAt TEXT, + detail TEXT, + resolvedAt TEXT, + createdAt TEXT NOT NULL + ) STRICT;`); + run(`INSERT INTO Command (id, kind, payload, state, claimedBy, claimedAt, expiresAt, detail, resolvedAt, createdAt) + SELECT id, kind, payload, state, claimedBy, claimedAt, expiresAt, detail, resolvedAt, createdAt FROM CommandOud`); + run(`DROP TABLE CommandOud`); + + run(`ALTER TABLE Notification RENAME TO NotificationOud`); + run(`CREATE TABLE Notification ( + id INTEGER PRIMARY KEY, + count INTEGER NOT NULL CHECK (count >= 0), + highWater INTEGER NOT NULL CHECK (highWater >= 0), + kind TEXT NOT NULL CHECK (kind IN ('reconcile','interest','valuation','schedule_ended','source_proposal','ai_command')), + dismissedAt TEXT, + createdAt TEXT NOT NULL + ) STRICT;`); + run(`INSERT INTO Notification (id, count, highWater, kind, dismissedAt, createdAt) + SELECT id, count, highWater, kind, dismissedAt, createdAt FROM NotificationOud`); + run(`DROP TABLE NotificationOud`); + run(`CREATE INDEX IxNotificationKind ON Notification(kind)`); + }, + + 67: () => { + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, + assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('price_point')), + eur REAL CHECK (eur IS NULL OR eur > 0), + observedAt TEXT, + note TEXT, + status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')), + detail TEXT, + resolvedAt TEXT, + createdAt TEXT NOT NULL, + CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)) + ) STRICT;`); + run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`); + run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`); + }, + + 68: () => { + const telAsset = one(`SELECT COUNT(*) AS n FROM Asset WHERE decimals > 9`).n; + if (telAsset > 0) { + throw new Error( + `Migration 68 cannot run: ${telAsset === 1 ? 'there is 1 asset' : `there are ${telAsset} assets`} with ` + + `more than 9 decimals. Such an asset is unusable (an amount above 0.009 unit can no longer be read ` + + `back) and the new schema forbids it. Change the decimals of that asset to at most 9, or remove it, ` + + `and start again.`); + } + const telGeld = one(`SELECT COUNT(*) AS n FROM Event + WHERE eurPrice < 0 OR eurFee < 0 OR unitFee < 0`).n; + if (telGeld > 0) { + throw new Error( + `Migration 68 cannot run: ${telGeld === 1 ? 'there is 1 event' : `there are ${telGeld} events`} with a ` + + `negative eurPrice, eurFee or unitFee. The new schema forbids that, because a negative fee raises ` + + `the balance. Correct those events and start again.`); + } + + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + + run(`ALTER TABLE Asset RENAME TO AssetOud`); + run(`CREATE TABLE Asset ( + id INTEGER PRIMARY KEY, + symbol TEXT NOT NULL, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, + unit TEXT NOT NULL, + subunit TEXT, + displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')), + decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9), + kind TEXT NOT NULL CHECK (kind IN ('crypto','metal')), + UNIQUE (symbol), + CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL) + ) STRICT;`); + run(`INSERT INTO Asset (id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind) + SELECT id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind FROM AssetOud`); + run(`DROP TABLE AssetOud`); + run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`); + + run(`ALTER TABLE Event RENAME TO EventOud`); + run(`CREATE TABLE Event ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, + toAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, + productId INTEGER REFERENCES MetalProduct(id) ON DELETE NO ACTION, + productCount INTEGER CHECK (productCount IS NULL OR productCount > 0), + importReference TEXT, + externalAddress TEXT, + note TEXT, + amount INTEGER NOT NULL CHECK (amount > 0), + eurPrice REAL CHECK (eurPrice IS NULL OR eurPrice >= 0), + eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0), + unitFee INTEGER CHECK (unitFee IS NULL OR unitFee >= 0), + type TEXT NOT NULL CHECK (type IN ('buy','sell','transfer','interest')), + source TEXT NOT NULL CHECK (source IN ('manual','import','reconcile','lightning')), + occurredAt TEXT NOT NULL, + createdAt TEXT NOT NULL, + CHECK ((productId IS NULL) = (productCount IS NULL)), + CHECK (type NOT IN ('buy','sell') OR eurPrice IS NOT NULL) + ) STRICT;`); + run(`INSERT INTO Event (id, assetId, fromAccountId, toAccountId, productId, productCount, + importReference, externalAddress, note, amount, eurPrice, eurFee, unitFee, + type, source, occurredAt, createdAt) + SELECT id, assetId, fromAccountId, toAccountId, productId, productCount, + importReference, externalAddress, note, amount, eurPrice, eurFee, unitFee, + type, source, occurredAt, createdAt FROM EventOud`); + run(`DROP TABLE EventOud`); + run(`CREATE INDEX IxEventAssetId ON Event(assetId)`); + run(`CREATE INDEX IxEventFromAccountId ON Event(fromAccountId)`); + run(`CREATE INDEX IxEventToAccountId ON Event(toAccountId)`); + run(`CREATE INDEX IxEventProductId ON Event(productId)`); + run(`CREATE INDEX IxEventOccurredAt ON Event(occurredAt)`); + run(`CREATE INDEX IxEventType ON Event(type)`); + run(`CREATE INDEX IxEventExternalAddress ON Event(externalAddress)`); + run(`CREATE INDEX IxEventImportReference ON Event(importReference)`); + + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AssetOud%' OR sql LIKE '%EventOud%'`); + if (besmet.length) { + throw new Error( + `Migration 68 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The rebuild rewrote the foreign keys of other tables; the step was rolled back ` + + `and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 69: () => { + run(`DELETE FROM Notification WHERE kind = 'ai_command'`); + run(`ALTER TABLE Notification RENAME TO NotificationOud`); + run(`CREATE TABLE Notification ( + id INTEGER PRIMARY KEY, + count INTEGER NOT NULL CHECK (count >= 0), + highWater INTEGER NOT NULL CHECK (highWater >= 0), + kind TEXT NOT NULL CHECK (kind IN ('reconcile','interest','valuation','schedule_ended','source_proposal','ai_proposal')), + dismissedAt TEXT, + createdAt TEXT NOT NULL + ) STRICT;`); + run(`INSERT INTO Notification (id, count, highWater, kind, dismissedAt, createdAt) + SELECT id, count, highWater, kind, dismissedAt, createdAt FROM NotificationOud`); + run(`DROP TABLE NotificationOud`); + run(`CREATE INDEX IxNotificationKind ON Notification(kind)`); + }, + + 70: () => { + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE Asset RENAME TO AssetOud`); + run(`CREATE TABLE Asset ( + id INTEGER PRIMARY KEY, + symbol TEXT NOT NULL, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, + unit TEXT NOT NULL, + subunit TEXT, + displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')), + decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9), + kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')), + UNIQUE (symbol), + CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL) + ) STRICT;`); + run(`INSERT INTO Asset (id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind) + SELECT id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind FROM AssetOud`); + run(`DROP TABLE AssetOud`); + run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`); + + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AssetOud%'`); + if (besmet.length) { + throw new Error( + `Migration 70 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The rebuild rewrote the foreign keys of other tables; the step was rolled back ` + + `and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 71: () => { + run(`CREATE TABLE ProviderFixedRate ( + providerId INTEGER PRIMARY KEY REFERENCES Provider(id) ON DELETE CASCADE, + eur REAL NOT NULL CHECK (eur > 0) + ) STRICT;`); + const nu = new Date().toISOString(); + if (!one(`SELECT 1 AS x FROM Provider WHERE key = 'fixed'`)) { + run(`INSERT INTO Provider + (key, name, searchName, baseUrl, authenticationHeaderName, authenticationParameterName, + authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES ('fixed', 'Vaste koers', 'vaste koers', '', NULL, NULL, 'none', 0, 1, 1, 1, ?)`, [nu]); + const providerId = one(`SELECT id FROM Provider WHERE key = 'fixed'`).id; + run(`INSERT INTO ProviderFixedRate (providerId, eur) VALUES (?, 1.0)`, [providerId]); + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'EUR')`, [providerId]); + for (const capability of ['spot', 'history_point']) { + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate) + VALUES (?, ?, 'GET', '', NULL)`, [providerId, capability]); + } + } + if (!one(`SELECT 1 AS x FROM Asset WHERE symbol = 'EUR'`)) { + run(`INSERT INTO Asset (symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind) + VALUES ('EUR', 'Euro', 'euro', NULL, 'EUR', 'cent', 'unit', 2, 'fiat')`); + } + const assetId = one(`SELECT id FROM Asset WHERE symbol = 'EUR'`).id; + const providerId = one(`SELECT id FROM Provider WHERE key = 'fixed'`).id; + for (const [consumerKey, capability] of [ + ['converter', 'spot'], + ['fiat', 'spot'], ['fiat', 'history_point'], + ['tax', 'spot'], ['tax', 'history_point'], + ['valuations', 'history_point'], + ]) { + if (one(`SELECT 1 AS x FROM ConsumerSource WHERE consumerKey = ? AND assetId = ? AND capability = ?`, + [consumerKey, assetId, capability])) continue; + run(`INSERT INTO ConsumerSource (consumerKey, assetId, capability, providerId, rank, status) + VALUES (?, ?, ?, ?, 1, 'confirmed')`, [consumerKey, assetId, capability, providerId]); + } + }, + + 72: () => { + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE Asset RENAME TO AssetOud`); + run(`CREATE TABLE Asset ( + id INTEGER PRIMARY KEY, + symbol TEXT NOT NULL, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, + unit TEXT NOT NULL, + subunit TEXT, + displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')), + decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9), + kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')), + taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig','buiten')), + UNIQUE (symbol), + CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL) + ) STRICT;`); + run(`INSERT INTO Asset (id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind) + SELECT id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind FROM AssetOud`); + run(`DROP TABLE AssetOud`); + run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`); + + run(`ALTER TABLE Account RENAME TO AccountOud`); + run(`CREATE TABLE Account ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, + kind TEXT NOT NULL CHECK (kind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER NOT NULL CHECK (isOwnCustody IN (0,1)), + scanEnabled INTEGER NOT NULL CHECK (scanEnabled IN (0,1)), + isActive INTEGER NOT NULL CHECK (isActive IN (0,1)), + closedAt TEXT, + taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig','buiten')), + CHECK ((isActive = 1 AND closedAt IS NULL) OR (isActive = 0 AND closedAt IS NOT NULL)) + ) STRICT;`); + run(`INSERT INTO Account (id, name, searchName, description, kind, isOwnCustody, scanEnabled, isActive, closedAt) + SELECT id, name, searchName, description, kind, isOwnCustody, scanEnabled, isActive, closedAt FROM AccountOud`); + run(`DROP TABLE AccountOud`); + run(`CREATE INDEX IxAccountSearchName ON Account(searchName)`); + + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AssetOud%' OR sql LIKE '%AccountOud%'`); + if (besmet.length) { + throw new Error( + `Migration 72 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The rebuild rewrote the foreign keys of other tables; the step was rolled back ` + + `and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 73: () => { + run(`DELETE FROM DashboardWidget WHERE type = 'dbstatus'`); + }, + + 74: () => { + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE Valuation RENAME TO ValuationOud`); + run(`CREATE TABLE Valuation ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + accountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, + balance INTEGER NOT NULL, + eurRate REAL NOT NULL, + source TEXT NOT NULL CHECK (source IN ('onchain','manual','statement')), + valuedOn TEXT NOT NULL, + rateAt TEXT NOT NULL, + gapAcceptedAt TEXT, + UNIQUE (assetId, valuedOn, accountId) + ) STRICT;`); + run(`INSERT INTO Valuation (id, assetId, accountId, balance, eurRate, source, valuedOn, rateAt) + SELECT id, assetId, accountId, balance, eurRate, source, valuedOn, rateAt FROM ValuationOud`); + run(`DROP TABLE ValuationOud`); + run(`CREATE INDEX IxValuationAccountId ON Valuation(accountId)`); + + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%ValuationOud%'`); + if (besmet.length) { + throw new Error( + `Migration 74 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 75: () => { + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE InterestSchedule RENAME TO InterestScheduleOud`); + run(`CREATE TABLE InterestSchedule ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + principalAssetId INTEGER REFERENCES Asset(id) ON DELETE NO ACTION, + payoutAssetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + note TEXT, + principalAmount INTEGER CHECK (principalAmount IS NULL OR principalAmount > 0), + annualRate REAL CHECK (annualRate IS NULL OR annualRate >= 0), + rateKind TEXT NOT NULL CHECK (rateKind IN ('fixed','variable')), + creditCadence TEXT CHECK (creditCadence IS NULL OR creditCadence IN ('term_end','annual','quarterly','monthly','weekly')), + creditWeekday INTEGER CHECK (creditWeekday IS NULL OR creditWeekday BETWEEN 0 AND 6), + creditDayOfMonth INTEGER CHECK (creditDayOfMonth IS NULL OR creditDayOfMonth BETWEEN 1 AND 31), + startsOn TEXT NOT NULL, + endsOn TEXT, + createdAt TEXT NOT NULL, + CHECK (rateKind = 'fixed' OR (principalAmount IS NULL AND annualRate IS NULL)), + CHECK (creditWeekday IS NULL OR creditCadence = 'weekly'), + CHECK (creditDayOfMonth IS NULL OR creditCadence = 'monthly'), + CHECK (endsOn IS NULL OR endsOn >= startsOn) + ) STRICT;`); + run(`INSERT INTO InterestSchedule (id, accountId, principalAssetId, payoutAssetId, note, principalAmount, + annualRate, rateKind, creditCadence, creditWeekday, creditDayOfMonth, + startsOn, endsOn, createdAt) + SELECT id, accountId, principalAssetId, payoutAssetId, note, principalAmount, + annualRate, rateKind, creditCadence, creditWeekday, creditDayOfMonth, + startsOn, endsOn, createdAt FROM InterestScheduleOud`); + run(`DROP TABLE InterestScheduleOud`); + run(`CREATE INDEX IxInterestScheduleAccountId ON InterestSchedule(accountId)`); + run(`CREATE INDEX IxInterestSchedulePrincipalAssetId ON InterestSchedule(principalAssetId)`); + run(`CREATE INDEX IxInterestSchedulePayoutAssetId ON InterestSchedule(payoutAssetId)`); + + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%InterestScheduleOud%'`); + if (besmet.length) { + throw new Error( + `Migration 75 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 76: () => { + run(`CREATE TABLE InterestPeriod ( + id INTEGER PRIMARY KEY, + scheduleId INTEGER NOT NULL REFERENCES InterestSchedule(id) ON DELETE CASCADE, + startsOn TEXT NOT NULL, + annualRate REAL NOT NULL CHECK (annualRate >= 0), + UNIQUE (scheduleId, startsOn) + ) STRICT;`); + }, + + 77: () => { + if (!tableExists('Preference')) return; + const uit = one(`SELECT value AS v FROM Preference WHERE scope = 'ai' AND key = 'enabled'`); + run(`UPDATE Preference SET key = 'allow_read' WHERE scope = 'ai' AND key = 'enabled'`); + if (uit && Number(uit.v) === 0) { + for (const sleutel of ['allow_write', 'allow_control']) { + run(`INSERT INTO Preference (scope, key, value) VALUES ('ai', ?, '0') + ON CONFLICT (scope, key) DO UPDATE SET value = '0'`, [sleutel]); + } + } + }, + + 78: () => { + if (!tableExists('AiProposal')) return; + + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + + run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`); + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, + assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event')), + name TEXT, + accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)), + eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')), + amount INTEGER CHECK (amount IS NULL OR amount > 0), + eur REAL CHECK (eur IS NULL OR eur > 0), + observedAt TEXT, + note TEXT, + status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')), + detail TEXT, + resolvedAt TEXT, + createdAt TEXT NOT NULL, + CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)), + CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL + AND observedAt IS NOT NULL + AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))), + CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL) + ) STRICT;`); + run(`INSERT INTO AiProposal (id, assetId, kind, eur, observedAt, note, status, detail, resolvedAt, createdAt) + SELECT id, assetId, kind, eur, observedAt, note, status, detail, resolvedAt, createdAt FROM AiProposalOud`); + run(`DROP TABLE AiProposalOud`); + run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`); + run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`); + run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`); + run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`); + + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`); + if (besmet.length) { + throw new Error( + `Migration 78 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 79: () => { + if (!tableExists('AiProposal')) return; + + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + + run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`); + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, + assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, + accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete')), + name TEXT, + accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)), + eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')), + amount INTEGER CHECK (amount IS NULL OR amount > 0), + eur REAL CHECK (eur IS NULL OR eur > 0), + observedAt TEXT, + note TEXT, + status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')), + detail TEXT, + resolvedAt TEXT, + createdAt TEXT NOT NULL, + CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)), + CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL + AND observedAt IS NOT NULL + AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))), + CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL), + CHECK (kind <> 'account_delete' OR accountId IS NOT NULL) + ) STRICT;`); + run(`INSERT INTO AiProposal (id, assetId, fromAccountId, toAccountId, kind, name, accountKind, + isOwnCustody, eventType, amount, eur, observedAt, note, status, + detail, resolvedAt, createdAt) + SELECT id, assetId, fromAccountId, toAccountId, kind, name, accountKind, + isOwnCustody, eventType, amount, eur, observedAt, note, status, + detail, resolvedAt, createdAt FROM AiProposalOud`); + run(`DROP TABLE AiProposalOud`); + run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`); + run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`); + run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`); + run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`); + run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`); + + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`); + if (besmet.length) { + throw new Error( + `Migration 79 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 80: () => { + if (!tableExists('AiProposal')) return; + + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + + run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`); + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, + assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, + accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete')), + name TEXT, + accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)), + eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')), + amount INTEGER CHECK (amount IS NULL OR amount > 0), + eur REAL CHECK (eur IS NULL OR eur > 0), + eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0), + observedAt TEXT, + note TEXT, + status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')), + detail TEXT, + resolvedAt TEXT, + createdAt TEXT NOT NULL, + CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)), + CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL + AND observedAt IS NOT NULL + AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))), + CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL), + CHECK (kind <> 'account_delete' OR accountId IS NOT NULL) + ) STRICT;`); + run(`INSERT INTO AiProposal (id, assetId, accountId, fromAccountId, toAccountId, kind, name, + accountKind, isOwnCustody, eventType, amount, eur, observedAt, note, + status, detail, resolvedAt, createdAt) + SELECT id, assetId, accountId, fromAccountId, toAccountId, kind, name, + accountKind, isOwnCustody, eventType, amount, eur, observedAt, note, + status, detail, resolvedAt, createdAt FROM AiProposalOud`); + run(`DROP TABLE AiProposalOud`); + run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`); + run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`); + run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`); + run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`); + run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`); + + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`); + if (besmet.length) { + throw new Error( + `Migration 80 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 81: () => { + if (!tableExists('AiProposal')) return; + + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + + run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`); + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, + assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, + accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation')), + name TEXT, + accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)), + eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')), + amount INTEGER CHECK (amount IS NULL OR amount > 0), + eur REAL CHECK (eur IS NULL OR eur > 0), + eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0), + balance INTEGER CHECK (balance IS NULL OR balance >= 0), + observedAt TEXT, + note TEXT, + status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')), + detail TEXT, + resolvedAt TEXT, + createdAt TEXT NOT NULL, + CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)), + CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL + AND observedAt IS NOT NULL + AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))), + CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL), + CHECK (kind <> 'account_delete' OR accountId IS NOT NULL), + CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL + AND balance IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind = 'valuation' OR balance IS NULL) + ) STRICT;`); + run(`INSERT INTO AiProposal (id, assetId, accountId, fromAccountId, toAccountId, kind, name, + accountKind, isOwnCustody, eventType, amount, eur, eurFee, observedAt, + note, status, detail, resolvedAt, createdAt) + SELECT id, assetId, accountId, fromAccountId, toAccountId, kind, name, + accountKind, isOwnCustody, eventType, amount, eur, eurFee, observedAt, + note, status, detail, resolvedAt, createdAt FROM AiProposalOud`); + run(`DROP TABLE AiProposalOud`); + run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`); + run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`); + run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`); + run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`); + run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`); + + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`); + if (besmet.length) { + throw new Error( + `Migration 81 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 82: () => { + if (!tableExists('AiProposal')) return; + + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + + run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`); + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, + assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, + accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio')), + name TEXT, + accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)), + eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')), + amount INTEGER CHECK (amount IS NULL OR amount > 0), + eur REAL CHECK (eur IS NULL OR eur > 0), + eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0), + balance INTEGER CHECK (balance IS NULL OR balance >= 0), + observedAt TEXT, + note TEXT, + status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')), + detail TEXT, + resolvedAt TEXT, + createdAt TEXT NOT NULL, + CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)), + CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL + AND observedAt IS NOT NULL + AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))), + CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL), + CHECK (kind <> 'account_delete' OR accountId IS NOT NULL), + CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL + AND balance IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind = 'valuation' OR balance IS NULL), + CHECK (kind <> 'portfolio' OR name IS NOT NULL) + ) STRICT;`); + run(`INSERT INTO AiProposal (id, assetId, accountId, fromAccountId, toAccountId, kind, name, + accountKind, isOwnCustody, eventType, amount, eur, eurFee, balance, + observedAt, note, status, detail, resolvedAt, createdAt) + SELECT id, assetId, accountId, fromAccountId, toAccountId, kind, name, + accountKind, isOwnCustody, eventType, amount, eur, eurFee, balance, + observedAt, note, status, detail, resolvedAt, createdAt FROM AiProposalOud`); + run(`DROP TABLE AiProposalOud`); + run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`); + run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`); + run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`); + run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`); + run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`); + + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`); + if (besmet.length) { + throw new Error( + `Migration 82 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 83: () => { + if (!tableExists('AiProposal')) return; + + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + + run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`); + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, + assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, + accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio','preference')), + name TEXT, + accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)), + eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')), + amount INTEGER CHECK (amount IS NULL OR amount > 0), + eur REAL CHECK (eur IS NULL OR eur > 0), + eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0), + balance INTEGER CHECK (balance IS NULL OR balance >= 0), + observedAt TEXT, + scope TEXT, + key TEXT, + value TEXT, + note TEXT, + status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')), + detail TEXT, + resolvedAt TEXT, + createdAt TEXT NOT NULL, + CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)), + CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL + AND observedAt IS NOT NULL + AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))), + CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL), + CHECK (kind <> 'account_delete' OR accountId IS NOT NULL), + CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL + AND balance IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind = 'valuation' OR balance IS NULL), + CHECK (kind <> 'portfolio' OR name IS NOT NULL), + CHECK (kind <> 'preference' OR (scope IS NOT NULL AND key IS NOT NULL AND value IS NOT NULL)), + CHECK (kind = 'preference' OR (scope IS NULL AND key IS NULL AND value IS NULL)) + ) STRICT;`); + run(`INSERT INTO AiProposal (id, assetId, accountId, fromAccountId, toAccountId, kind, name, + accountKind, isOwnCustody, eventType, amount, eur, eurFee, balance, + observedAt, note, status, detail, resolvedAt, createdAt) + SELECT id, assetId, accountId, fromAccountId, toAccountId, kind, name, + accountKind, isOwnCustody, eventType, amount, eur, eurFee, balance, + observedAt, note, status, detail, resolvedAt, createdAt FROM AiProposalOud`); + run(`DROP TABLE AiProposalOud`); + run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`); + run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`); + run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`); + run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`); + run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`); + + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`); + if (besmet.length) { + throw new Error( + `Migration 83 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 84: () => { + if (!tableExists('AiProposal')) return; + + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + + run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`); + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, + assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, + accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + createdEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL, + createdAccountId INTEGER REFERENCES Account(id) ON DELETE SET NULL, + createdValuationId INTEGER REFERENCES Valuation(id) ON DELETE SET NULL, + kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio','preference')), + name TEXT, + accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)), + eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')), + amount INTEGER CHECK (amount IS NULL OR amount > 0), + eur REAL CHECK (eur IS NULL OR eur > 0), + eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0), + balance INTEGER CHECK (balance IS NULL OR balance >= 0), + observedAt TEXT, + scope TEXT, + key TEXT, + value TEXT, + note TEXT, + status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')), + detail TEXT, + resolvedAt TEXT, + createdAt TEXT NOT NULL, + CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)), + CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL + AND observedAt IS NOT NULL + AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))), + CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL), + CHECK (kind <> 'account_delete' OR accountId IS NOT NULL), + CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL + AND balance IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind = 'valuation' OR balance IS NULL), + CHECK (kind <> 'portfolio' OR name IS NOT NULL), + CHECK (kind <> 'preference' OR (scope IS NOT NULL AND key IS NOT NULL AND value IS NOT NULL)), + CHECK (kind = 'preference' OR (scope IS NULL AND key IS NULL AND value IS NULL)) + ) STRICT;`); + run(`INSERT INTO AiProposal (id, assetId, accountId, fromAccountId, toAccountId, kind, name, + accountKind, isOwnCustody, eventType, amount, eur, eurFee, balance, + observedAt, scope, key, value, note, status, detail, resolvedAt, createdAt) + SELECT id, assetId, accountId, fromAccountId, toAccountId, kind, name, + accountKind, isOwnCustody, eventType, amount, eur, eurFee, balance, + observedAt, scope, key, value, note, status, detail, resolvedAt, createdAt FROM AiProposalOud`); + run(`DROP TABLE AiProposalOud`); + run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`); + run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`); + run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`); + run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`); + run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`); + run(`CREATE INDEX IxAiProposalCreatedEventId ON AiProposal(createdEventId)`); + run(`CREATE INDEX IxAiProposalCreatedAccountId ON AiProposal(createdAccountId)`); + run(`CREATE INDEX IxAiProposalCreatedValuationId ON AiProposal(createdValuationId)`); + + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`); + if (besmet.length) { + throw new Error( + `Migration 84 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 85: () => { + const TIJDSTEMPELS = { + createdAt: [ + 'Asset', 'Account', 'Label', 'MetalProduct', 'Valuation', 'InterestPeriod', 'WalletAddress', + 'ConsumerSource', 'DashboardWidget', 'Preference', 'ProviderAuthenticationHmac', + 'ProviderEndpoint', 'ProviderFixedRate', 'ProviderInputMap', 'ProviderOutputMap', + 'ProviderCoverage', + ], + updatedAt: [ + 'Account', 'Asset', 'Event', 'Valuation', 'Label', 'MetalProduct', 'InterestSchedule', + 'InterestPeriod', 'InterestCredit', 'WalletSource', + ], + }; + for (const [kolom, tabellen] of Object.entries(TIJDSTEMPELS)) { + for (const tabel of tabellen) { + if (!tableExists(tabel)) continue; + if (columnExists(tabel, kolom)) continue; + run(`ALTER TABLE ${tabel} ADD COLUMN ${kolom} TEXT`); + } + } + }, + + 86: () => { + if (!tableExists('PriceHistory')) return; + if (columnExists('PriceHistory', 'createdAt')) return; + + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE PriceHistory RENAME TO PriceHistoryOud`); + run(`CREATE TABLE PriceHistory ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT, + eur REAL, + usd REAL, + resolution TEXT NOT NULL CHECK (resolution IN ('day','timestamp')), + observedAt TEXT NOT NULL, + createdAt TEXT, + updatedAt TEXT, + UNIQUE (assetId, providerId, observedAt, resolution), + CHECK (eur IS NOT NULL OR usd IS NOT NULL) + ) STRICT`); + run(`INSERT INTO PriceHistory (id, assetId, providerId, eur, usd, resolution, observedAt, updatedAt) + SELECT id, assetId, providerId, eur, usd, resolution, observedAt, updatedAt FROM PriceHistoryOud`); + run(`DROP TABLE PriceHistoryOud`); + run(`CREATE INDEX IxPriceHistoryProviderId ON PriceHistory(providerId)`); + run(`CREATE INDEX IxPriceHistoryAssetIdObservedAt ON PriceHistory(assetId, observedAt)`); + + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%PriceHistoryOud%'`); + if (besmet.length) { + throw new Error( + `Migration 86 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 87: () => { + if (tableExists('ChangeLog')) return; + run(`CREATE TABLE ChangeLog ( + id INTEGER PRIMARY KEY, + tableName TEXT NOT NULL, + rowId INTEGER NOT NULL, + operation TEXT NOT NULL CHECK (operation IN ('update','delete')), + origin TEXT NOT NULL CHECK (origin IN ('manual','import','reconcile','interest','lightning','ai_proposal','merge','system')), + originId INTEGER, + changes TEXT NOT NULL, + occurredAt TEXT NOT NULL + ) STRICT`); + run(`CREATE INDEX IxChangeLogTableNameRowId ON ChangeLog(tableName, rowId)`); + run(`CREATE INDEX IxChangeLogOccurredAt ON ChangeLog(occurredAt)`); + }, + + 88: () => { + if (!tableExists('Event')) return; + if (columnExists('Event', 'priceOrigin')) return; + + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE Event RENAME TO EventOud`); + run(`CREATE TABLE Event ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, + toAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, + productId INTEGER REFERENCES MetalProduct(id) ON DELETE NO ACTION, + productCount INTEGER CHECK (productCount IS NULL OR productCount > 0), + importReference TEXT, + externalAddress TEXT, + note TEXT, + amount INTEGER NOT NULL CHECK (amount > 0), + eurPrice REAL CHECK (eurPrice IS NULL OR eurPrice >= 0), + eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0), + unitFee INTEGER CHECK (unitFee IS NULL OR unitFee >= 0), + priceOrigin TEXT CHECK (priceOrigin IS NULL OR priceOrigin IN ('paid','market_day','market_moment','none')), + type TEXT NOT NULL CHECK (type IN ('buy','sell','transfer','interest')), + source TEXT NOT NULL CHECK (source IN ('manual','import','reconcile','lightning')), + occurredAt TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT, + CHECK ((productId IS NULL) = (productCount IS NULL)), + CHECK (type NOT IN ('buy','sell') OR eurPrice IS NOT NULL) + ) STRICT`); + run(`INSERT INTO Event (id, assetId, fromAccountId, toAccountId, productId, productCount, + importReference, externalAddress, note, amount, eurPrice, eurFee, unitFee, + type, source, occurredAt, createdAt, updatedAt) + SELECT id, assetId, fromAccountId, toAccountId, productId, productCount, + importReference, externalAddress, note, amount, eurPrice, eurFee, unitFee, + type, source, occurredAt, createdAt, updatedAt FROM EventOud`); + run(`DROP TABLE EventOud`); + run(`CREATE INDEX IxEventAssetId ON Event(assetId)`); + run(`CREATE INDEX IxEventFromAccountId ON Event(fromAccountId)`); + run(`CREATE INDEX IxEventToAccountId ON Event(toAccountId)`); + run(`CREATE INDEX IxEventProductId ON Event(productId)`); + run(`CREATE INDEX IxEventOccurredAt ON Event(occurredAt)`); + run(`CREATE INDEX IxEventType ON Event(type)`); + run(`CREATE INDEX IxEventExternalAddress ON Event(externalAddress)`); + run(`CREATE INDEX IxEventImportReference ON Event(importReference)`); + + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%EventOud%'`); + if (besmet.length) { + throw new Error( + `Migration 88 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 89: () => { + if (tableExists('HygieneAcceptance')) return; + run(`CREATE TABLE HygieneAcceptance ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL, + fingerprint TEXT NOT NULL, + summary TEXT NOT NULL, + note TEXT, + createdAt TEXT NOT NULL + ) STRICT`); + run(`CREATE UNIQUE INDEX UxHygieneAcceptanceFingerprint ON HygieneAcceptance(fingerprint)`); + run(`CREATE INDEX IxHygieneAcceptanceKind ON HygieneAcceptance(kind)`); + }, + + 90: () => { + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + if (tableExists('ProviderEndpoint') && !columnExists('ProviderEndpoint', 'quoteCurrency')) { + const metCreatedAt = columnExists('ProviderEndpoint', 'createdAt'); + run(`ALTER TABLE ProviderEndpoint RENAME TO ProviderEndpointOud`); + run(`CREATE TABLE ProviderEndpoint ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + pathTemplate TEXT NOT NULL, + queryTemplate TEXT, + capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')), + method TEXT NOT NULL CHECK (method IN ('GET','POST')), + quoteCurrency TEXT, + createdAt TEXT, + UNIQUE (providerId, capability) + ) STRICT`); + const kolommen = `id, providerId, pathTemplate, queryTemplate, capability, method${metCreatedAt ? ', createdAt' : ''}`; + run(`INSERT INTO ProviderEndpoint (${kolommen}) SELECT ${kolommen} FROM ProviderEndpointOud`); + run(`DROP TABLE ProviderEndpointOud`); + } + const outputMapSql = one(`SELECT sql AS s FROM sqlite_master WHERE type = 'table' AND name = 'ProviderOutputMap'`); + if (outputMapSql && !String(outputMapSql.s).includes('iso_date_to_s')) { + run(`ALTER TABLE ProviderOutputMap RENAME TO ProviderOutputMapOud`); + run(`CREATE TABLE ProviderOutputMap ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + jsonPath TEXT NOT NULL, + capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')), + internalField TEXT NOT NULL CHECK (internalField IN ('EUR','USD','time','price')), + transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram','iso_date_to_s')), + createdAt TEXT + ) STRICT`); + run(`INSERT INTO ProviderOutputMap (id, providerId, jsonPath, capability, internalField, transform, createdAt) + SELECT id, providerId, jsonPath, capability, internalField, transform, createdAt FROM ProviderOutputMapOud`); + run(`DROP TABLE ProviderOutputMapOud`); + run(`CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability)`); + } + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%ProviderEndpointOud%' OR sql LIKE '%ProviderOutputMapOud%'`); + if (besmet.length) { + throw new Error( + `Migration 90 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 91: () => { + for (const t of ['Provider', 'ProviderCoverage', 'ProviderEndpoint', 'ProviderOutputMap', 'Asset', 'ConsumerSource']) { + if (!tableExists(t)) return; + } + const nu = new Date().toISOString(); + if (!one(`SELECT 1 AS x FROM Provider WHERE key = 'frankfurter'`)) { + run(`INSERT INTO Provider + (key, name, searchName, baseUrl, authenticationHeaderName, authenticationParameterName, + authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES ('frankfurter', 'ECB-wisselkoersen (Frankfurter)', 'ecb-wisselkoersen (frankfurter)', + 'https://api.frankfurter.dev', NULL, NULL, 'none', 0, 1, 1, 0, ?)`, [nu]); + const providerId = one(`SELECT id FROM Provider WHERE key = 'frankfurter'`).id; + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'USD')`, [providerId]); + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate, quoteCurrency, createdAt) + VALUES (?, 'spot', 'GET', '/v1/latest', 'base={symbol}&symbols=EUR', NULL, ?)`, [providerId, nu]); + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate, quoteCurrency, createdAt) + VALUES (?, 'daily_history', 'GET', '/v1/1999-01-04..', 'base={symbol}&symbols=EUR', NULL, ?)`, [providerId, nu]); + for (const [capability, internalField, jsonPath, transform] of [ + ['spot', 'EUR', 'rates.EUR', 'to_number'], + ['daily_history', 'time', 'rates{}.@key', 'iso_date_to_s'], + ['daily_history', 'EUR', 'rates{}.EUR', 'to_number'], + ]) { + run(`INSERT INTO ProviderOutputMap (providerId, capability, internalField, jsonPath, transform, createdAt) + VALUES (?, ?, ?, ?, ?, ?)`, [providerId, capability, internalField, jsonPath, transform, nu]); + } + } + if (!one(`SELECT 1 AS x FROM Asset WHERE symbol = 'USD'`)) { + run(`INSERT INTO Asset (symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind) + VALUES ('USD', 'Amerikaanse dollar', 'amerikaanse dollar', NULL, 'USD', 'cent', 'unit', 2, 'fiat')`); + } + const assetId = one(`SELECT id FROM Asset WHERE symbol = 'USD'`).id; + const providerId = one(`SELECT id FROM Provider WHERE key = 'frankfurter'`).id; + for (const [consumerKey, capability] of [ + ['converter', 'spot'], + ['fiat', 'spot'], ['fiat', 'daily_history'], + ['signals', 'daily_history'], + ['tax', 'spot'], ['tax', 'daily_history'], + ['valuations', 'daily_history'], + ]) { + if (one(`SELECT 1 AS x FROM ConsumerSource WHERE consumerKey = ? AND assetId = ? AND capability = ?`, + [consumerKey, assetId, capability])) continue; + run(`INSERT INTO ConsumerSource (consumerKey, assetId, capability, providerId, rank, status) + VALUES (?, ?, ?, ?, 1, 'confirmed')`, [consumerKey, assetId, capability, providerId]); + } + }, + + 92: () => { + for (const t of ['Provider', 'ProviderEndpoint', 'ProviderOutputMap', 'ConsumerSource']) { + if (!tableExists(t)) return; + } + const nu = new Date().toISOString(); + for (const [key, path, usdTransform] of [ + ['eulerpool', '/api/1/crypto/quotes/{symbol}', 'last_per_day'], + ['eulerpool_metal', '/api/1/commodity/quotes/{symbol}', 'troy_ounce_to_gram'], + ]) { + const p = one(`SELECT id FROM Provider WHERE key = ?`, [key]); + if (!p) continue; + run(`DELETE FROM ConsumerSource WHERE providerId = ? AND capability <> 'daily_history'`, [p.id]); + run(`DELETE FROM ProviderEndpoint WHERE providerId = ?`, [p.id]); + run(`DELETE FROM ProviderOutputMap WHERE providerId = ?`, [p.id]); + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate, quoteCurrency, createdAt) + VALUES (?, 'daily_history', 'GET', ?, NULL, 'USD', ?)`, [p.id, path, nu]); + run(`INSERT INTO ProviderOutputMap (providerId, capability, internalField, jsonPath, transform, createdAt) + VALUES (?, 'daily_history', 'time', '[].timestamp', 'ms_to_s', ?)`, [p.id, nu]); + run(`INSERT INTO ProviderOutputMap (providerId, capability, internalField, jsonPath, transform, createdAt) + VALUES (?, 'daily_history', 'USD', '[].price', ?, ?)`, [p.id, usdTransform, nu]); + } + }, + + 93: () => { + if (!tableExists('ProviderCallLimit')) { + run(`CREATE TABLE ProviderCallLimit ( + providerId INTEGER PRIMARY KEY REFERENCES Provider(id) ON DELETE CASCADE, + callsPerMonth INTEGER NOT NULL CHECK (callsPerMonth > 0), + createdAt TEXT + ) STRICT`); + } + if (!tableExists('Provider') || !columnExists('Provider', 'key')) return; + const nu = new Date().toISOString(); + for (const [key, calls] of [['goldapi', 100], ['metalsdev', 100], ['eulerpool', 1000], ['eulerpool_metal', 1000], ['coingecko', 10000]]) { + const p = one(`SELECT id FROM Provider WHERE key = ?`, [key]); + if (!p || one(`SELECT 1 AS x FROM ProviderCallLimit WHERE providerId = ?`, [p.id])) continue; + run(`INSERT INTO ProviderCallLimit (providerId, callsPerMonth, createdAt) VALUES (?, ?, ?)`, [p.id, calls, nu]); + } + }, + + 94: () => { + if (tableExists('IgnoredPriceGap')) return; + run(`CREATE TABLE IgnoredPriceGap ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + fromDay TEXT NOT NULL, + untilDay TEXT NOT NULL, + createdAt TEXT NOT NULL, + CHECK (fromDay <= untilDay) + ) STRICT`); + run(`CREATE INDEX IxIgnoredPriceGapAssetId ON IgnoredPriceGap(assetId)`); + }, + + 95: () => { + if (!tableExists('ProviderCallLimit') || !tableExists('Provider') || !columnExists('Provider', 'key')) return; + run(`UPDATE ProviderCallLimit SET callsPerMonth = 100000 + WHERE callsPerMonth = 1000 + AND providerId IN (SELECT id FROM Provider WHERE key IN ('eulerpool', 'eulerpool_metal'))`); + }, + + 96: () => { + if (!tableExists('ProviderEndpoint') || columnExists('ProviderEndpoint', 'minIntervalSeconds')) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + const metCreatedAt = columnExists('ProviderEndpoint', 'createdAt'); + const metMunt = columnExists('ProviderEndpoint', 'quoteCurrency'); + run(`ALTER TABLE ProviderEndpoint RENAME TO ProviderEndpointOud`); + run(`CREATE TABLE ProviderEndpoint ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + pathTemplate TEXT NOT NULL, + queryTemplate TEXT, + capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')), + method TEXT NOT NULL CHECK (method IN ('GET','POST')), + quoteCurrency TEXT, + minIntervalSeconds INTEGER CHECK (minIntervalSeconds IS NULL OR minIntervalSeconds > 0), + createdAt TEXT, + UNIQUE (providerId, capability) + ) STRICT`); + const kolommen = `id, providerId, pathTemplate, queryTemplate, capability, method` + + `${metMunt ? ', quoteCurrency' : ''}${metCreatedAt ? ', createdAt' : ''}`; + run(`INSERT INTO ProviderEndpoint (${kolommen}) SELECT ${kolommen} FROM ProviderEndpointOud`); + run(`DROP TABLE ProviderEndpointOud`); + const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%ProviderEndpointOud%'`); + if (besmet.length) { + throw new Error( + `Migration 96 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 97: () => { + if (!tableExists('ConsumerSource') || tableExists('PriceSource')) return; + const KEUZE = { spot: 'spot', daily_history: 'history', history_point: 'history' }; + const GROEP = { spot: 0, daily_history: 0, history_point: 1 }; + const STERKTE = { proposed: 0, confirmed: 1, manual: 2 }; + try { + run(`BEGIN`); + run(`CREATE TABLE PriceSource ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT, + capability TEXT NOT NULL CHECK (capability IN ('spot','history')), + rank INTEGER CHECK (rank IS NULL OR rank > 0), + status TEXT NOT NULL CHECK (status IN ('proposed','confirmed','manual')), + createdAt TEXT, + UNIQUE (assetId, capability, providerId), + UNIQUE (assetId, capability, rank) + ) STRICT`); + run(`CREATE INDEX IxPriceSourceProviderId ON PriceSource(providerId)`); + const metCreatedAt = columnExists('ConsumerSource', 'createdAt'); + const rijen = all(`SELECT assetId, capability, providerId, rank, status${metCreatedAt ? ', createdAt' : ''} + FROM ConsumerSource ORDER BY id`); + const cellen = new Map(); + for (const r of rijen) { + const keuze = KEUZE[r.capability]; + if (!keuze) continue; + const k = `${r.assetId}|${keuze}`; + if (!cellen.has(k)) cellen.set(k, new Map()); + const perBron = cellen.get(k); + const c = perBron.get(r.providerId) + || { assetId: r.assetId, keuze, providerId: r.providerId, groep: GROEP[r.capability], rank: null, status: r.status, createdAt: null }; + const g = GROEP[r.capability]; + if (r.rank != null && (c.rank == null || g < c.groep || (g === c.groep && r.rank < c.rank))) { + c.groep = g; + c.rank = r.rank; + } + if (STERKTE[r.status] > STERKTE[c.status]) c.status = r.status; + const t = r.createdAt ?? null; + if (t && (!c.createdAt || t < c.createdAt)) c.createdAt = t; + perBron.set(r.providerId, c); + } + for (const perBron of cellen.values()) { + const gerangschikt = [...perBron.values()].filter(c => c.rank != null) + .sort((a, b) => a.groep - b.groep || a.rank - b.rank || STERKTE[b.status] - STERKTE[a.status] || a.providerId - b.providerId); + gerangschikt.forEach((c, i) => { c.rank = i + 1; }); + for (const c of perBron.values()) { + run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status, createdAt) VALUES (?, ?, ?, ?, ?, ?)`, + [c.assetId, c.keuze, c.providerId, c.rank, c.status, c.createdAt]); + } + } + run(`DROP TABLE ConsumerSource`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%ConsumerSource%'`) + .filter(r => /\bConsumerSource\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 97 aborted: ${besmet.map(r => r.name).join(', ')} still refers to ConsumerSource. ` + + `The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } + }, + + 98: () => { + if (!tableExists('AiProposal')) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`); + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, + assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, + accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + createdEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL, + createdAccountId INTEGER REFERENCES Account(id) ON DELETE SET NULL, + createdValuationId INTEGER REFERENCES Valuation(id) ON DELETE SET NULL, + kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio','preference','source_choice')), + name TEXT, + accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)), + eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')), + amount INTEGER CHECK (amount IS NULL OR amount > 0), + eur REAL CHECK (eur IS NULL OR eur > 0), + eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0), + balance INTEGER CHECK (balance IS NULL OR balance >= 0), + observedAt TEXT, + scope TEXT, + key TEXT, + value TEXT, + note TEXT, + status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')), + detail TEXT, + resolvedAt TEXT, + createdAt TEXT NOT NULL, + CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)), + CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL + AND observedAt IS NOT NULL + AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))), + CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL), + CHECK (kind <> 'account_delete' OR accountId IS NOT NULL), + CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL + AND balance IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind = 'valuation' OR balance IS NULL), + CHECK (kind <> 'portfolio' OR name IS NOT NULL), + CHECK (kind <> 'preference' OR (scope IS NOT NULL AND key IS NOT NULL AND value IS NOT NULL)), + CHECK (kind <> 'source_choice' OR (assetId IS NOT NULL AND scope IN ('spot','history') + AND key IS NOT NULL AND value IN ('first','remove'))), + CHECK (kind IN ('preference','source_choice') OR (scope IS NULL AND key IS NULL AND value IS NULL)) + ) STRICT;`); + const kolommen = `id, assetId, accountId, fromAccountId, toAccountId, createdEventId, createdAccountId, + createdValuationId, kind, name, accountKind, isOwnCustody, eventType, amount, eur, eurFee, balance, + observedAt, scope, key, value, note, status, detail, resolvedAt, createdAt`; + run(`INSERT INTO AiProposal (${kolommen}) SELECT ${kolommen} FROM AiProposalOud`); + run(`DROP TABLE AiProposalOud`); + run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`); + run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`); + run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`); + run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`); + run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`); + run(`CREATE INDEX IxAiProposalCreatedEventId ON AiProposal(createdEventId)`); + run(`CREATE INDEX IxAiProposalCreatedAccountId ON AiProposal(createdAccountId)`); + run(`CREATE INDEX IxAiProposalCreatedValuationId ON AiProposal(createdValuationId)`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`) + .filter(r => /\bAiProposalOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 98 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 99: () => { + if (!tableExists('ProviderInputMap')) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + if (!columnExists('ProviderInputMap', 'quoteUnit')) { + const metCreatedAt = columnExists('ProviderInputMap', 'createdAt'); + run(`ALTER TABLE ProviderInputMap RENAME TO ProviderInputMapOud`); + run(`CREATE TABLE ProviderInputMap ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + symbol TEXT NOT NULL, + externalId TEXT NOT NULL, + quoteUnit TEXT CHECK (quoteUnit IS NULL OR quoteUnit IN ('ozt')), + createdAt TEXT, + UNIQUE (providerId, symbol) + ) STRICT`); + const kolommen = `id, providerId, symbol, externalId${metCreatedAt ? ', createdAt' : ''}`; + run(`INSERT INTO ProviderInputMap (${kolommen}) SELECT ${kolommen} FROM ProviderInputMapOud`); + run(`DROP TABLE ProviderInputMapOud`); + } + const bron = key => (tableExists('Provider') ? one(`SELECT id FROM Provider WHERE key = ?`, [key]) : null); + const crypto = bron('eulerpool'), metaal = bron('eulerpool_metal'); + if (crypto && metaal) { + const e = crypto.id, m = metaal.id, nu = new Date().toISOString(); + run(`UPDATE ProviderEndpoint SET pathTemplate = '/api/1/{external_id}/quotes/{symbol}' + WHERE providerId = ? AND capability = 'daily_history'`, [e]); + for (const r of all(`SELECT symbol FROM ProviderCoverage WHERE providerId = ?`, [e])) { + run(`INSERT OR IGNORE INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, createdAt) + VALUES (?, ?, 'crypto', NULL, ?)`, [e, r.symbol, nu]); + } + for (const r of all(`SELECT symbol FROM ProviderCoverage WHERE providerId = ?`, [m])) { + run(`INSERT OR IGNORE INTO ProviderCoverage (providerId, symbol) VALUES (?, ?)`, [e, r.symbol]); + run(`INSERT OR IGNORE INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, createdAt) + VALUES (?, ?, 'commodity', 'ozt', ?)`, [e, r.symbol, nu]); + } + if (tableExists('PriceSource')) { + run(`UPDATE OR IGNORE PriceSource SET providerId = ? WHERE providerId = ?`, [e, m]); + run(`DELETE FROM PriceSource WHERE providerId = ?`, [m]); + } + if (tableExists('PriceHistory')) { + run(`UPDATE OR IGNORE PriceHistory SET providerId = ? WHERE providerId = ?`, [e, m]); + run(`DELETE FROM PriceHistory WHERE providerId = ?`, [m]); + } + if (tableExists('ApiCallMetric')) { + run(`INSERT INTO ApiCallMetric (providerId, capability, bucketMinute, okCount, failCount, sumLatencyMs, maxLatencyMs) + SELECT ?, capability, bucketMinute, okCount, failCount, sumLatencyMs, maxLatencyMs FROM ApiCallMetric WHERE providerId = ? + ON CONFLICT (providerId, capability, bucketMinute) DO UPDATE SET + okCount = okCount + excluded.okCount, failCount = failCount + excluded.failCount, + sumLatencyMs = sumLatencyMs + excluded.sumLatencyMs, + maxLatencyMs = MAX(maxLatencyMs, excluded.maxLatencyMs)`, [e, m]); + run(`DELETE FROM ApiCallMetric WHERE providerId = ?`, [m]); + } + for (const t of ['ProviderEndpoint', 'ProviderCoverage', 'ProviderInputMap', 'ProviderOutputMap', + 'ProviderCallLimit', 'ProviderAuthenticationHmac', 'ProviderFixedRate']) { + if (tableExists(t)) run(`DELETE FROM ${t} WHERE providerId = ?`, [m]); + } + run(`DELETE FROM Provider WHERE id = ?`, [m]); + } + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%ProviderInputMapOud%'`) + .filter(r => /\bProviderInputMapOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 99 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 100: () => { + if (!tableExists('PriceHistory')) return; + const usd = columnExists('PriceHistory', 'usd'); + const geenKoers = kolom => `(${kolom} IS NULL OR ${kolom} <= 0)`; + run(`DELETE FROM PriceHistory WHERE ${geenKoers('eur')}${usd ? ` AND ${geenKoers('usd')}` : ''}`); + run(`UPDATE PriceHistory SET eur = NULL WHERE eur <= 0`); + if (usd) run(`UPDATE PriceHistory SET usd = NULL WHERE usd <= 0`); + }, + + 101: () => { + if (tableExists('AssetNote')) return; + run(`CREATE TABLE AssetNote ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + body TEXT NOT NULL, + createdAt TEXT NOT NULL + ) STRICT`); + run(`CREATE UNIQUE INDEX UxAssetNoteAssetId ON AssetNote(assetId)`); + }, + + 102: () => { + if (!tableExists('ProviderInputMap')) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + if (!columnExists('ProviderInputMap', 'isCatchAll')) { + const metQuoteUnit = columnExists('ProviderInputMap', 'quoteUnit'); + const metCreatedAt = columnExists('ProviderInputMap', 'createdAt'); + run(`ALTER TABLE ProviderInputMap RENAME TO ProviderInputMapOud`); + run(`CREATE TABLE ProviderInputMap ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + symbol TEXT, + externalId TEXT NOT NULL, + quoteUnit TEXT CHECK (quoteUnit IS NULL OR quoteUnit IN ('ozt')), + isCatchAll INTEGER NOT NULL CHECK (isCatchAll IN (0,1)), + createdAt TEXT, + UNIQUE (providerId, symbol), + CHECK ((isCatchAll = 1) = (symbol IS NULL)) + ) STRICT`); + const kolommen = `id, providerId, symbol, externalId${metQuoteUnit ? ', quoteUnit' : ''}${metCreatedAt ? ', createdAt' : ''}`; + run(`INSERT INTO ProviderInputMap (${kolommen}, isCatchAll) SELECT ${kolommen}, 0 FROM ProviderInputMapOud`); + run(`DROP TABLE ProviderInputMapOud`); + run(`CREATE UNIQUE INDEX UxProviderInputMapCatchAll ON ProviderInputMap(providerId) WHERE isCatchAll = 1`); + } + const euler = tableExists('Provider') ? one(`SELECT id FROM Provider WHERE key = 'eulerpool'`) : null; + if (euler && !one(`SELECT id FROM ProviderInputMap WHERE providerId = ? AND isCatchAll = 1`, [euler.id])) { + const crypto = all(`SELECT id FROM ProviderInputMap + WHERE providerId = ? AND externalId = 'crypto' AND quoteUnit IS NULL`, [euler.id]); + if (crypto.length) { + run(`DELETE FROM ProviderInputMap WHERE providerId = ? AND externalId = 'crypto' AND quoteUnit IS NULL`, [euler.id]); + run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, isCatchAll, createdAt) + VALUES (?, NULL, 'crypto', NULL, 1, ?)`, [euler.id, new Date().toISOString()]); + } + } + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%ProviderInputMapOud%'`) + .filter(r => /\bProviderInputMapOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 102 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 103: () => { + const outputMapSql = one(`SELECT sql AS s FROM sqlite_master WHERE type = 'table' AND name = 'ProviderOutputMap'`); + if (!outputMapSql) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + if (columnExists('ProviderOutputMap', 'transform') && !String(outputMapSql.s).includes('ms_to_s_local_close')) { + run(`ALTER TABLE ProviderOutputMap RENAME TO ProviderOutputMapOud`); + run(`CREATE TABLE ProviderOutputMap ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + jsonPath TEXT NOT NULL, + capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')), + internalField TEXT NOT NULL CHECK (internalField IN ('EUR','USD','time','price')), + transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram','iso_date_to_s','ms_to_s_local_close')), + createdAt TEXT + ) STRICT`); + run(`INSERT INTO ProviderOutputMap (id, providerId, jsonPath, capability, internalField, transform, createdAt) + SELECT id, providerId, jsonPath, capability, internalField, transform, createdAt FROM ProviderOutputMapOud`); + run(`DROP TABLE ProviderOutputMapOud`); + run(`CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability)`); + } + const euler = tableExists('Provider') ? one(`SELECT id FROM Provider WHERE key = 'eulerpool'`) : null; + if (euler && columnExists('ProviderOutputMap', 'transform')) { + run(`UPDATE ProviderOutputMap SET transform = 'ms_to_s_local_close' + WHERE providerId = ? AND capability = 'daily_history' AND internalField = 'time' AND transform = 'ms_to_s'`, + [euler.id]); + if (tableExists('PriceHistory') && tableExists('Asset')) { + run(`DELETE FROM PriceHistory + WHERE providerId = ? AND assetId IN (SELECT id FROM Asset WHERE kind = 'metal')`, [euler.id]); + } + } + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%ProviderOutputMapOud%'`) + .filter(r => /\bProviderOutputMapOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 103 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 104: () => { + if (tableExists('Portfolio')) return; + run(`CREATE TABLE Portfolio ( + id INTEGER PRIMARY KEY CHECK (id = 1), + baseCurrency TEXT NOT NULL CHECK (baseCurrency GLOB '[A-Z][A-Z][A-Z]'), + createdAt TEXT + ) STRICT`); + run(`INSERT INTO Portfolio (id, baseCurrency, createdAt) VALUES (1, 'EUR', NULL)`); + run(`CREATE TRIGGER TrPortfolioUpdate BEFORE UPDATE ON Portfolio + BEGIN SELECT RAISE(ABORT, 'Portfolio is fixed after creation'); END`); + run(`CREATE TRIGGER TrPortfolioDelete BEFORE DELETE ON Portfolio + BEGIN SELECT RAISE(ABORT, 'Portfolio is fixed after creation'); END`); + }, + + 105: () => { + for (const [table, oud, nieuw] of [ + ['Event', 'eurPrice', 'fiatPrice'], ['Event', 'eurFee', 'fiatFee'], + ['Valuation', 'eurRate', 'fiatRate'], + ['PriceHistory', 'eur', 'fiatPrice'], + ['ProviderFixedRate', 'eur', 'fiatPrice'], + ['AiProposal', 'eur', 'fiatPrice'], ['AiProposal', 'eurFee', 'fiatFee'], + ]) { + if (columnExists(table, oud)) run(`ALTER TABLE ${table} RENAME COLUMN ${oud} TO ${nieuw}`); + } + if (!tableExists('Preference')) return; + for (const [scope, oud, nieuw] of [ + ['table.trades', 'eurPrice', 'fiatPrice'], ['table.trades', 'eurFee', 'fiatFee'], + ['table.valuations', 'eurRate', 'fiatRate'], + ['table.prijsinfo', 'eur', 'fiatPrice'], + ]) { + run(`UPDATE Preference SET value = json_set(value, '$.sort.key', ?) + WHERE scope = ? AND key = 'view' + AND CASE WHEN json_valid(value) THEN json_extract(value, '$.sort.key') END = ?`, + [nieuw, scope, oud]); + } + }, + + 106: () => { + if (tableExists('Preference')) run(`DELETE FROM Preference WHERE scope = 'app' AND key = 'currency'`); + if (tableExists('AiProposal')) { + run(`UPDATE AiProposal SET status = 'rejected', resolvedAt = ?, + detail = 'The preference app.currency no longer exists: the app shows the unit of account.' + WHERE kind = 'preference' AND scope = 'app' AND key = 'currency' AND status = 'pending'`, + [new Date().toISOString()]); + } + }, + + 107: () => { + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + if (tableExists('ProviderOutputMap') && columnExists('ProviderOutputMap', 'internalField') + && !one(`SELECT sql AS s FROM sqlite_master WHERE name = 'ProviderOutputMap'`).s.includes(`IN ('time','price')`)) { + const endpoints = tableExists('ProviderEndpoint') && columnExists('ProviderEndpoint', 'quoteCurrency'); + const paren = all(`SELECT DISTINCT providerId, capability FROM ProviderOutputMap + WHERE internalField IN ('EUR','USD')`); + for (const { providerId, capability } of paren) { + const e = endpoints ? one(`SELECT quoteCurrency AS q FROM ProviderEndpoint WHERE providerId = ? AND capability = ?`, + [providerId, capability]) : null; + const q = e ? e.q : null; + const veld = !q || q === 'EUR' ? 'EUR' : q === 'USD' ? 'USD' : 'price'; + const heeft = one(`SELECT id FROM ProviderOutputMap WHERE providerId = ? AND capability = ? AND internalField = ?`, + [providerId, capability, veld]); + if (heeft && veld !== 'price') { + run(`DELETE FROM ProviderOutputMap WHERE providerId = ? AND capability = ? AND internalField = 'price'`, + [providerId, capability]); + run(`UPDATE ProviderOutputMap SET internalField = 'price' WHERE id = ?`, [heeft.id]); + } + run(`DELETE FROM ProviderOutputMap WHERE providerId = ? AND capability = ? AND internalField IN ('EUR','USD')`, + [providerId, capability]); + } + const metCreatedAt = columnExists('ProviderOutputMap', 'createdAt'); + run(`ALTER TABLE ProviderOutputMap RENAME TO ProviderOutputMapOud`); + run(`CREATE TABLE ProviderOutputMap ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + jsonPath TEXT NOT NULL, + capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')), + internalField TEXT NOT NULL CHECK (internalField IN ('time','price')), + transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram','iso_date_to_s','ms_to_s_local_close')), + createdAt TEXT + ) STRICT`); + const kolommen = `id, providerId, jsonPath, capability, internalField, transform${metCreatedAt ? ', createdAt' : ''}`; + run(`INSERT INTO ProviderOutputMap (${kolommen}) SELECT ${kolommen} FROM ProviderOutputMapOud`); + run(`DROP TABLE ProviderOutputMapOud`); + run(`CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability)`); + } + const volledig = ['assetId', 'providerId', 'resolution', 'observedAt'].every(k => columnExists('PriceHistory', k)); + if (tableExists('PriceHistory') && columnExists('PriceHistory', 'usd') && volledig) { + run(`DELETE FROM PriceHistory WHERE fiatPrice IS NULL`); + const tijd = ['createdAt', 'updatedAt'].filter(k => columnExists('PriceHistory', k)); + run(`ALTER TABLE PriceHistory RENAME TO PriceHistoryOud`); + run(`CREATE TABLE PriceHistory ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT, + fiatPrice REAL NOT NULL, + resolution TEXT NOT NULL CHECK (resolution IN ('day','timestamp')), + observedAt TEXT NOT NULL, + createdAt TEXT, + updatedAt TEXT, + UNIQUE (assetId, providerId, observedAt, resolution) + ) STRICT`); + const kolommen = ['id', 'assetId', 'providerId', 'fiatPrice', 'resolution', 'observedAt', ...tijd].join(', '); + run(`INSERT INTO PriceHistory (${kolommen}) SELECT ${kolommen} FROM PriceHistoryOud`); + run(`DROP TABLE PriceHistoryOud`); + run(`CREATE INDEX IxPriceHistoryProviderId ON PriceHistory(providerId)`); + run(`CREATE INDEX IxPriceHistoryAssetIdObservedAt ON PriceHistory(assetId, observedAt)`); + } + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%Oud%'`) + .filter(r => /\b(ProviderOutputMapOud|PriceHistoryOud)\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 107 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 108: () => { + if (!tableExists('AiProposal') || columnExists('AiProposal', 'currency') + || !columnExists('AiProposal', 'fiatPrice')) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`); + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, + assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, + accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + createdEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL, + createdAccountId INTEGER REFERENCES Account(id) ON DELETE SET NULL, + createdValuationId INTEGER REFERENCES Valuation(id) ON DELETE SET NULL, + kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio','preference','source_choice')), + name TEXT, + accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)), + eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')), + amount INTEGER CHECK (amount IS NULL OR amount > 0), + fiatPrice REAL CHECK (fiatPrice IS NULL OR fiatPrice > 0), + fiatFee REAL CHECK (fiatFee IS NULL OR fiatFee >= 0), + balance INTEGER CHECK (balance IS NULL OR balance >= 0), + observedAt TEXT, + scope TEXT, + key TEXT, + value TEXT, + currency TEXT CHECK (currency IS NULL OR currency GLOB '[A-Z][A-Z][A-Z]'), + note TEXT, + status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')), + detail TEXT, + resolvedAt TEXT, + createdAt TEXT NOT NULL, + CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND fiatPrice IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)), + CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL + AND observedAt IS NOT NULL + AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))), + CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR fiatPrice IS NOT NULL), + CHECK (kind <> 'account_delete' OR accountId IS NOT NULL), + CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL + AND balance IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind = 'valuation' OR balance IS NULL), + CHECK (kind <> 'portfolio' OR (name IS NOT NULL AND currency IS NOT NULL)), + CHECK (kind = 'portfolio' OR currency IS NULL), + CHECK (kind <> 'preference' OR (scope IS NOT NULL AND key IS NOT NULL AND value IS NOT NULL)), + CHECK (kind <> 'source_choice' OR (assetId IS NOT NULL AND scope IN ('spot','history') + AND key IS NOT NULL AND value IN ('first','remove'))), + CHECK (kind IN ('preference','source_choice') OR (scope IS NULL AND key IS NULL AND value IS NULL)) + ) STRICT;`); + const kolommen = `id, assetId, accountId, fromAccountId, toAccountId, createdEventId, createdAccountId, + createdValuationId, kind, name, accountKind, isOwnCustody, eventType, amount, fiatPrice, fiatFee, balance, + observedAt, scope, key, value, note, status, detail, resolvedAt, createdAt`; + run(`INSERT INTO AiProposal (${kolommen}, currency) + SELECT ${kolommen}, CASE kind WHEN 'portfolio' THEN 'EUR' END FROM AiProposalOud`); + run(`DROP TABLE AiProposalOud`); + run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`); + run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`); + run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`); + run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`); + run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`); + run(`CREATE INDEX IxAiProposalCreatedEventId ON AiProposal(createdEventId)`); + run(`CREATE INDEX IxAiProposalCreatedAccountId ON AiProposal(createdAccountId)`); + run(`CREATE INDEX IxAiProposalCreatedValuationId ON AiProposal(createdValuationId)`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`) + .filter(r => /\bAiProposalOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 108 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 109: () => { + if (!tableExists('Preference')) return; + run(`UPDATE Preference SET value = json_set(value, '$.sort.key', 'fiatTotal') + WHERE scope = 'table.trades' AND key = 'view' + AND CASE WHEN json_valid(value) THEN json_extract(value, '$.sort.key') END = 'eurTotal'`); + }, + + 110: () => { + if (tableExists('PriceAbsence')) return; + run(`CREATE TABLE PriceAbsence ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + day TEXT NOT NULL, + createdAt TEXT NOT NULL + ) STRICT`); + run(`CREATE UNIQUE INDEX UxPriceAbsenceAssetIdProviderIdDay ON PriceAbsence(assetId, providerId, day)`); + run(`CREATE INDEX IxPriceAbsenceProviderId ON PriceAbsence(providerId)`); + }, + + 111: () => { + if (!tableExists('Asset') || columnExists('Asset', 'balanceCapture') + || !columnExists('Asset', 'taxCategory')) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE Asset RENAME TO AssetOud`); + run(`CREATE TABLE Asset ( + id INTEGER PRIMARY KEY, + symbol TEXT NOT NULL, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, + unit TEXT NOT NULL, + subunit TEXT, + displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')), + decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9), + kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')), + taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig','buiten')), + balanceCapture INTEGER CHECK (balanceCapture IS NULL OR balanceCapture IN (0,1)), + createdAt TEXT, + updatedAt TEXT, + UNIQUE (symbol), + CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL) + ) STRICT;`); + const kolommen = `id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind, + taxCategory, createdAt, updatedAt`; + run(`INSERT INTO Asset (${kolommen}) SELECT ${kolommen} FROM AssetOud`); + run(`DROP TABLE AssetOud`); + run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%AssetOud%'`) + .filter(r => /\bAssetOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 111 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 112: () => { + const asset = tableExists('Asset') && !columnExists('Asset', 'taxScope') && columnExists('Asset', 'balanceCapture'); + const account = tableExists('Account') && !columnExists('Account', 'taxScope') + && columnExists('Account', 'taxCategory') && columnExists('Account', 'createdAt'); + if (!asset && !account) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + if (asset) { + run(`ALTER TABLE Asset RENAME TO AssetOud`); + run(`CREATE TABLE Asset ( + id INTEGER PRIMARY KEY, + symbol TEXT NOT NULL, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, + unit TEXT NOT NULL, + subunit TEXT, + displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')), + decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9), + kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')), + taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig')), + taxScope TEXT CHECK (taxScope IS NULL OR taxScope IN ('all','category','none')), + balanceCapture INTEGER CHECK (balanceCapture IS NULL OR balanceCapture IN (0,1)), + createdAt TEXT, + updatedAt TEXT, + UNIQUE (symbol), + CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL) + ) STRICT;`); + const kolommen = 'id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind'; + run(`INSERT INTO Asset (${kolommen}, taxCategory, taxScope, balanceCapture, createdAt, updatedAt) + SELECT ${kolommen}, CASE WHEN taxCategory = 'buiten' THEN NULL ELSE taxCategory END, + CASE taxCategory WHEN 'buiten' THEN 'none' END, balanceCapture, createdAt, updatedAt FROM AssetOud`); + run(`DROP TABLE AssetOud`); + run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`); + } + if (account) { + run(`ALTER TABLE Account RENAME TO AccountOud`); + run(`CREATE TABLE Account ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, + kind TEXT NOT NULL CHECK (kind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER NOT NULL CHECK (isOwnCustody IN (0,1)), + scanEnabled INTEGER NOT NULL CHECK (scanEnabled IN (0,1)), + isActive INTEGER NOT NULL CHECK (isActive IN (0,1)), + closedAt TEXT, + taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig')), + taxScope TEXT CHECK (taxScope IS NULL OR taxScope IN ('all','category','none')), + createdAt TEXT, + updatedAt TEXT, + CHECK ((isActive = 1 AND closedAt IS NULL) OR (isActive = 0 AND closedAt IS NOT NULL)) + ) STRICT;`); + const kolommen = 'id, name, searchName, description, kind, isOwnCustody, scanEnabled, isActive, closedAt'; + run(`INSERT INTO Account (${kolommen}, taxCategory, taxScope, createdAt, updatedAt) + SELECT ${kolommen}, CASE WHEN taxCategory = 'buiten' THEN NULL ELSE taxCategory END, + CASE taxCategory WHEN 'buiten' THEN 'none' END, createdAt, updatedAt FROM AccountOud`); + run(`DROP TABLE AccountOud`); + run(`CREATE INDEX IxAccountSearchName ON Account(searchName)`); + } + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%AssetOud%' OR sql LIKE '%AccountOud%'`) + .filter(r => /\b(AssetOud|AccountOud)\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 112 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 113: () => { + if (!tableExists('InterestSchedule') || columnExists('InterestSchedule', 'compounding') + || !columnExists('InterestSchedule', 'updatedAt')) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE InterestSchedule RENAME TO InterestScheduleOud`); + run(`CREATE TABLE InterestSchedule ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + principalAssetId INTEGER REFERENCES Asset(id) ON DELETE NO ACTION, + payoutAssetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + note TEXT, + principalAmount INTEGER CHECK (principalAmount IS NULL OR principalAmount > 0), + annualRate REAL CHECK (annualRate IS NULL OR annualRate >= 0), + rateKind TEXT NOT NULL CHECK (rateKind IN ('fixed','variable')), + creditCadence TEXT CHECK (creditCadence IS NULL OR creditCadence IN ('term_end','annual','quarterly','monthly','weekly')), + creditWeekday INTEGER CHECK (creditWeekday IS NULL OR creditWeekday BETWEEN 0 AND 6), + creditDayOfMonth INTEGER CHECK (creditDayOfMonth IS NULL OR creditDayOfMonth BETWEEN 1 AND 31), + startsOn TEXT NOT NULL, + endsOn TEXT, + compounding INTEGER NOT NULL CHECK (compounding IN (0,1)), + createdAt TEXT NOT NULL, + updatedAt TEXT, + CHECK (rateKind = 'fixed' OR (principalAmount IS NULL AND annualRate IS NULL)), + CHECK (creditWeekday IS NULL OR creditCadence = 'weekly'), + CHECK (creditDayOfMonth IS NULL OR creditCadence = 'monthly'), + CHECK (endsOn IS NULL OR endsOn >= startsOn), + CHECK (compounding = 0 OR (rateKind = 'fixed' AND principalAmount IS NOT NULL AND principalAssetId = payoutAssetId)) + ) STRICT;`); + const kolommen = `id, accountId, principalAssetId, payoutAssetId, note, principalAmount, annualRate, rateKind, + creditCadence, creditWeekday, creditDayOfMonth, startsOn, endsOn`; + run(`INSERT INTO InterestSchedule (${kolommen}, compounding, createdAt, updatedAt) + SELECT ${kolommen}, 0, createdAt, updatedAt FROM InterestScheduleOud`); + run(`DROP TABLE InterestScheduleOud`); + run(`CREATE INDEX IxInterestScheduleAccountId ON InterestSchedule(accountId)`); + run(`CREATE INDEX IxInterestSchedulePrincipalAssetId ON InterestSchedule(principalAssetId)`); + run(`CREATE INDEX IxInterestSchedulePayoutAssetId ON InterestSchedule(payoutAssetId)`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%InterestScheduleOud%'`) + .filter(r => /\bInterestScheduleOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 113 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 114: () => { + if (!tableExists('Account') || columnExists('Account', 'wealthScope') + || !columnExists('Account', 'taxScope')) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE Account RENAME TO AccountOud`); + run(`CREATE TABLE Account ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, + kind TEXT NOT NULL CHECK (kind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER NOT NULL CHECK (isOwnCustody IN (0,1)), + scanEnabled INTEGER NOT NULL CHECK (scanEnabled IN (0,1)), + isActive INTEGER NOT NULL CHECK (isActive IN (0,1)), + closedAt TEXT, + taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig')), + taxScope TEXT CHECK (taxScope IS NULL OR taxScope IN ('all','category','none')), + wealthScope TEXT CHECK (wealthScope IS NULL OR wealthScope IN ('managed','none')), + createdAt TEXT, + updatedAt TEXT, + CHECK ((isActive = 1 AND closedAt IS NULL) OR (isActive = 0 AND closedAt IS NOT NULL)) + ) STRICT;`); + const kolommen = 'id, name, searchName, description, kind, isOwnCustody, scanEnabled, isActive, closedAt, taxCategory, taxScope'; + run(`INSERT INTO Account (${kolommen}, wealthScope, createdAt, updatedAt) + SELECT ${kolommen}, NULL, createdAt, updatedAt FROM AccountOud`); + run(`DROP TABLE AccountOud`); + run(`CREATE INDEX IxAccountSearchName ON Account(searchName)`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%AccountOud%'`) + .filter(r => /\bAccountOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 114 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 115: () => serviceProviderRow(), + + 116: () => serviceProviderRow(), + + 117: () => serviceProviderRow(), + + 118: () => { + if (tableExists('IntegrationCall')) return; + run(`CREATE TABLE IntegrationCall ( + id INTEGER PRIMARY KEY, + target TEXT NOT NULL CHECK (target IN ('service','provider')), + providerKey TEXT, + operation TEXT NOT NULL, + subject TEXT, + outcome TEXT NOT NULL CHECK (outcome IN ('fail','empty','refused')), + httpStatus INTEGER CHECK (httpStatus IS NULL OR httpStatus BETWEEN 100 AND 599), + errorKey TEXT, + detail TEXT CHECK (detail IS NULL OR length(detail) <= 300), + latencyMs INTEGER CHECK (latencyMs IS NULL OR latencyMs >= 0), + requestId TEXT, + occurredAt TEXT NOT NULL, + CHECK ((target = 'service') = (providerKey IS NULL)), + CHECK (requestId IS NULL OR target = 'service') + ) STRICT`); + run(`CREATE INDEX IxIntegrationCallOccurredAt ON IntegrationCall(occurredAt)`); + }, + + 119: () => { + if (!tableExists('WalletTransaction') || columnExists('WalletTransaction', 'layer') + || !columnExists('WalletTransaction', 'blockTime')) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE WalletTransaction RENAME TO WalletTransactionOud`); + run(`CREATE TABLE WalletTransaction ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE, + labelId INTEGER REFERENCES Label(id) ON DELETE SET NULL, + layer TEXT NOT NULL CHECK (layer IN ('onchain','lightning')), + txid TEXT NOT NULL, + address TEXT, + valueSats INTEGER NOT NULL, + feeSats INTEGER CHECK (feeSats IS NULL OR feeSats >= 0), + blockHeight INTEGER CHECK (blockHeight IS NULL OR blockHeight > 0), + blockTime TEXT, + direction TEXT NOT NULL CHECK (direction IN ('in','out')), + createdAt TEXT NOT NULL, + CHECK ((layer = 'onchain' AND (blockHeight IS NULL) = (blockTime IS NULL)) + OR (layer = 'lightning' AND blockHeight IS NULL AND blockTime IS NOT NULL AND sourceId IS NOT NULL)) + ) STRICT;`); + const kolommen = 'id, accountId, sourceId, labelId, txid, address, valueSats, feeSats, blockHeight, blockTime, direction, createdAt'; + run(`INSERT INTO WalletTransaction (${kolommen}, layer) + SELECT ${kolommen}, 'onchain' FROM WalletTransactionOud`); + run(`DROP TABLE WalletTransactionOud`); + run(`CREATE UNIQUE INDEX UxWalletTransactionAccountIdTxid ON WalletTransaction(accountId, txid) WHERE layer = 'onchain'`); + run(`CREATE UNIQUE INDEX UxWalletTransactionSourceIdTxid ON WalletTransaction(sourceId, txid) WHERE layer = 'lightning'`); + run(`CREATE INDEX IxWalletTransactionAccountId ON WalletTransaction(accountId)`); + run(`CREATE INDEX IxWalletTransactionSourceId ON WalletTransaction(sourceId)`); + run(`CREATE INDEX IxWalletTransactionLabelId ON WalletTransaction(labelId)`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%WalletTransactionOud%'`) + .filter(r => /\bWalletTransactionOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 119 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 120: () => { + if (!tableExists('WalletSource') || columnExists('WalletSource', 'balanceSats') + || !columnExists('WalletSource', 'scriptType')) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE WalletSource RENAME TO WalletSourceOud`); + run(`CREATE TABLE WalletSource ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + connectionId INTEGER REFERENCES Connection(id) ON DELETE RESTRICT, + value TEXT NOT NULL, + name TEXT, + kind TEXT NOT NULL CHECK (kind IN ('xpub','address','nwc','lnd')), + scriptType TEXT CHECK (scriptType IS NULL OR scriptType IN ('p2tr','p2wpkh','p2sh-p2wpkh','p2pkh')), + balanceSats INTEGER CHECK (balanceSats IS NULL OR balanceSats >= 0), + lastScannedAt TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT, + CHECK (scriptType IS NULL OR kind = 'xpub'), + CHECK ((kind = 'lnd') = (connectionId IS NOT NULL)), + CHECK ((balanceSats IS NULL) = (lastScannedAt IS NULL)), + CHECK (balanceSats IS NULL OR kind IN ('lnd','nwc')) + ) STRICT;`); + const kolommen = 'id, accountId, connectionId, value, name, kind, scriptType'; + run(`INSERT INTO WalletSource (${kolommen}, balanceSats, lastScannedAt, createdAt, updatedAt) + SELECT ${kolommen}, NULL, NULL, createdAt, updatedAt FROM WalletSourceOud`); + run(`DROP TABLE WalletSourceOud`); + run(`CREATE INDEX IxWalletSourceAccountId ON WalletSource(accountId)`); + run(`CREATE INDEX IxWalletSourceConnectionId ON WalletSource(connectionId)`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%WalletSourceOud%'`) + .filter(r => /\bWalletSourceOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 120 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 121: () => { + if (!tableExists('WalletTransaction') || columnExists('WalletTransaction', 'kind') + || !columnExists('WalletTransaction', 'layer')) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE WalletTransaction RENAME TO WalletTransactionOud`); + run(`CREATE TABLE WalletTransaction ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE, + labelId INTEGER REFERENCES Label(id) ON DELETE SET NULL, + layer TEXT NOT NULL CHECK (layer IN ('onchain','lightning')), + kind TEXT CHECK (kind IS NULL OR kind IN ('channel','self')), + txid TEXT NOT NULL, + address TEXT, + valueSats INTEGER NOT NULL, + feeSats INTEGER CHECK (feeSats IS NULL OR feeSats >= 0), + blockHeight INTEGER CHECK (blockHeight IS NULL OR blockHeight > 0), + blockTime TEXT, + direction TEXT NOT NULL CHECK (direction IN ('in','out')), + createdAt TEXT NOT NULL, + CHECK ((layer = 'onchain' AND (blockHeight IS NULL) = (blockTime IS NULL)) + OR (layer = 'lightning' AND blockHeight IS NULL AND blockTime IS NOT NULL AND sourceId IS NOT NULL)), + CHECK (kind IS NULL OR (kind = 'channel') = (layer = 'onchain')) + ) STRICT;`); + const kolommen = 'id, accountId, sourceId, labelId, layer, txid, address, valueSats, feeSats, blockHeight, blockTime, direction, createdAt'; + run(`INSERT INTO WalletTransaction (${kolommen}, kind) + SELECT ${kolommen}, NULL FROM WalletTransactionOud`); + run(`DROP TABLE WalletTransactionOud`); + run(`CREATE UNIQUE INDEX UxWalletTransactionAccountIdTxid ON WalletTransaction(accountId, txid) WHERE layer = 'onchain'`); + run(`CREATE UNIQUE INDEX UxWalletTransactionSourceIdTxid ON WalletTransaction(sourceId, txid) WHERE layer = 'lightning'`); + run(`CREATE INDEX IxWalletTransactionAccountId ON WalletTransaction(accountId)`); + run(`CREATE INDEX IxWalletTransactionSourceId ON WalletTransaction(sourceId)`); + run(`CREATE INDEX IxWalletTransactionLabelId ON WalletTransaction(labelId)`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%WalletTransactionOud%'`) + .filter(r => /\bWalletTransactionOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 121 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 122: () => { + if (!tableExists('ReconcileProposal')) return; + const ddl = one(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'ReconcileProposal'`)?.sql || ''; + if (ddl.includes('accepted_interest') || !ddl.includes('auto_linked')) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE ReconcileProposal RENAME TO ReconcileProposalOud`); + run(`CREATE TABLE ReconcileProposal ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + walletTransactionId INTEGER REFERENCES WalletTransaction(id) ON DELETE CASCADE, + resolvedEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL, + transactionHash TEXT, + amount INTEGER NOT NULL CHECK (amount > 0), + direction TEXT NOT NULL CHECK (direction IN ('in','out')), + suggestedType TEXT NOT NULL CHECK (suggestedType IN ('trade','transfer')), + status TEXT NOT NULL CHECK (status IN ('pending','accepted_trade','accepted_transfer','accepted_interest','ignored','auto_linked')), + detectedAt TEXT NOT NULL + ) STRICT;`); + const kolommen = 'id, accountId, assetId, walletTransactionId, resolvedEventId, transactionHash, amount, direction, suggestedType, status, detectedAt'; + run(`INSERT INTO ReconcileProposal (${kolommen}) SELECT ${kolommen} FROM ReconcileProposalOud`); + run(`DROP TABLE ReconcileProposalOud`); + run(`CREATE INDEX IxReconcileProposalAccountId ON ReconcileProposal(accountId)`); + run(`CREATE INDEX IxReconcileProposalAssetId ON ReconcileProposal(assetId)`); + run(`CREATE INDEX IxReconcileProposalWalletTransactionId ON ReconcileProposal(walletTransactionId)`); + run(`CREATE INDEX IxReconcileProposalResolvedEventId ON ReconcileProposal(resolvedEventId)`); + run(`CREATE INDEX IxReconcileProposalStatus ON ReconcileProposal(status)`); + run(`CREATE UNIQUE INDEX UxReconcileProposalLink ON ReconcileProposal(walletTransactionId, resolvedEventId) WHERE walletTransactionId IS NOT NULL`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%ReconcileProposalOud%'`) + .filter(r => /\bReconcileProposalOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 122 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 123: () => { + if (!tableExists('WalletTransaction') || columnExists('WalletTransaction', 'memo') + || !columnExists('WalletTransaction', 'kind')) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE WalletTransaction RENAME TO WalletTransactionOud`); + run(`CREATE TABLE WalletTransaction ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE, + labelId INTEGER REFERENCES Label(id) ON DELETE SET NULL, + layer TEXT NOT NULL CHECK (layer IN ('onchain','lightning')), + kind TEXT CHECK (kind IS NULL OR kind IN ('channel','self')), + txid TEXT NOT NULL, + address TEXT, + memo TEXT CHECK (memo IS NULL OR length(memo) <= 2000), + valueSats INTEGER NOT NULL, + feeSats INTEGER CHECK (feeSats IS NULL OR feeSats >= 0), + blockHeight INTEGER CHECK (blockHeight IS NULL OR blockHeight > 0), + blockTime TEXT, + direction TEXT NOT NULL CHECK (direction IN ('in','out')), + createdAt TEXT NOT NULL, + CHECK ((layer = 'onchain' AND (blockHeight IS NULL) = (blockTime IS NULL)) + OR (layer = 'lightning' AND blockHeight IS NULL AND blockTime IS NOT NULL AND sourceId IS NOT NULL)), + CHECK (kind IS NULL OR (kind = 'channel') = (layer = 'onchain')), + CHECK (memo IS NULL OR layer = 'lightning') + ) STRICT;`); + const kolommen = 'id, accountId, sourceId, labelId, layer, kind, txid, address, valueSats, feeSats, blockHeight, blockTime, direction, createdAt'; + run(`INSERT INTO WalletTransaction (${kolommen}, memo) + SELECT ${kolommen}, NULL FROM WalletTransactionOud`); + run(`DROP TABLE WalletTransactionOud`); + run(`CREATE UNIQUE INDEX UxWalletTransactionAccountIdTxid ON WalletTransaction(accountId, txid) WHERE layer = 'onchain'`); + run(`CREATE UNIQUE INDEX UxWalletTransactionSourceIdTxid ON WalletTransaction(sourceId, txid) WHERE layer = 'lightning'`); + run(`CREATE INDEX IxWalletTransactionAccountId ON WalletTransaction(accountId)`); + run(`CREATE INDEX IxWalletTransactionSourceId ON WalletTransaction(sourceId)`); + run(`CREATE INDEX IxWalletTransactionLabelId ON WalletTransaction(labelId)`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%WalletTransactionOud%'`) + .filter(r => /\bWalletTransactionOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 123 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 124: () => { + if (!tableExists('WalletSource') || !columnExists('WalletSource', 'balanceSats')) return; + const ddl = one(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'WalletSource'`)?.sql || ''; + if (!ddl.includes(`(kind = 'lnd') = (connectionId IS NOT NULL)`)) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE WalletSource RENAME TO WalletSourceOud`); + run(`CREATE TABLE WalletSource ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + connectionId INTEGER REFERENCES Connection(id) ON DELETE RESTRICT, + value TEXT NOT NULL, + name TEXT, + kind TEXT NOT NULL CHECK (kind IN ('xpub','address','nwc','lnd')), + scriptType TEXT CHECK (scriptType IS NULL OR scriptType IN ('p2tr','p2wpkh','p2sh-p2wpkh','p2pkh')), + balanceSats INTEGER CHECK (balanceSats IS NULL OR balanceSats >= 0), + lastScannedAt TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT, + CHECK (scriptType IS NULL OR kind = 'xpub'), + CHECK (kind <> 'lnd' OR connectionId IS NOT NULL), + CHECK (connectionId IS NULL OR kind IN ('lnd','xpub','address')), + CHECK ((balanceSats IS NULL) = (lastScannedAt IS NULL)), + CHECK (balanceSats IS NULL OR kind IN ('lnd','nwc')) + ) STRICT;`); + const kolommen = 'id, accountId, connectionId, value, name, kind, scriptType, balanceSats, lastScannedAt, createdAt, updatedAt'; + run(`INSERT INTO WalletSource (${kolommen}) SELECT ${kolommen} FROM WalletSourceOud`); + run(`DROP TABLE WalletSourceOud`); + run(`CREATE INDEX IxWalletSourceAccountId ON WalletSource(accountId)`); + run(`CREATE INDEX IxWalletSourceConnectionId ON WalletSource(connectionId)`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%WalletSourceOud%'`) + .filter(r => /\bWalletSourceOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 124 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 125: () => { + if (!tableExists('AiProposal') || columnExists('AiProposal', 'unitFee') + || !columnExists('AiProposal', 'currency')) return; + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`); + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, + assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, + accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + createdEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL, + createdAccountId INTEGER REFERENCES Account(id) ON DELETE SET NULL, + createdValuationId INTEGER REFERENCES Valuation(id) ON DELETE SET NULL, + kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio','preference','source_choice')), + name TEXT, + accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)), + eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')), + amount INTEGER CHECK (amount IS NULL OR amount > 0), + fiatPrice REAL CHECK (fiatPrice IS NULL OR fiatPrice > 0), + fiatFee REAL CHECK (fiatFee IS NULL OR fiatFee >= 0), + unitFee INTEGER CHECK (unitFee IS NULL OR unitFee >= 0), + balance INTEGER CHECK (balance IS NULL OR balance >= 0), + observedAt TEXT, + scope TEXT, + key TEXT, + value TEXT, + currency TEXT CHECK (currency IS NULL OR currency GLOB '[A-Z][A-Z][A-Z]'), + note TEXT, + status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')), + detail TEXT, + resolvedAt TEXT, + createdAt TEXT NOT NULL, + CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND fiatPrice IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)), + CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL + AND observedAt IS NOT NULL + AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))), + CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR fiatPrice IS NOT NULL), + CHECK (unitFee IS NULL OR (kind = 'event' AND eventType IN ('sell','transfer'))), + CHECK (kind <> 'account_delete' OR accountId IS NOT NULL), + CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL + AND balance IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind = 'valuation' OR balance IS NULL), + CHECK (kind <> 'portfolio' OR (name IS NOT NULL AND currency IS NOT NULL)), + CHECK (kind = 'portfolio' OR currency IS NULL), + CHECK (kind <> 'preference' OR (scope IS NOT NULL AND key IS NOT NULL AND value IS NOT NULL)), + CHECK (kind <> 'source_choice' OR (assetId IS NOT NULL AND scope IN ('spot','history') + AND key IS NOT NULL AND value IN ('first','remove'))), + CHECK (kind IN ('preference','source_choice') OR (scope IS NULL AND key IS NULL AND value IS NULL)) + ) STRICT;`); + const kolommen = `id, assetId, accountId, fromAccountId, toAccountId, createdEventId, createdAccountId, + createdValuationId, kind, name, accountKind, isOwnCustody, eventType, amount, fiatPrice, fiatFee, balance, + observedAt, scope, key, value, currency, note, status, detail, resolvedAt, createdAt`; + run(`INSERT INTO AiProposal (${kolommen}) SELECT ${kolommen} FROM AiProposalOud`); + run(`DROP TABLE AiProposalOud`); + run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`); + run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`); + run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`); + run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`); + run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`); + run(`CREATE INDEX IxAiProposalCreatedEventId ON AiProposal(createdEventId)`); + run(`CREATE INDEX IxAiProposalCreatedAccountId ON AiProposal(createdAccountId)`); + run(`CREATE INDEX IxAiProposalCreatedValuationId ON AiProposal(createdValuationId)`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`) + .filter(r => /\bAiProposalOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 125 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } + }, + + 126: () => { + if (tableExists('Event')) { + const ddl = one(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'Event'`)?.sql || ''; + if (!ddl.includes(`'reservation'`) && columnExists('Event', 'priceOrigin') && columnExists('Event', 'fiatPrice')) rebuildEvent126(); + } + if (tableExists('Notification')) { + const ddl = one(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'Notification'`)?.sql || ''; + if (!ddl.includes(`'reservation'`)) { + run(`ALTER TABLE Notification RENAME TO NotificationOud`); + run(`CREATE TABLE Notification ( + id INTEGER PRIMARY KEY, + count INTEGER NOT NULL CHECK (count >= 0), + highWater INTEGER NOT NULL CHECK (highWater >= 0), + kind TEXT NOT NULL CHECK (kind IN ('reconcile','interest','valuation','schedule_ended','source_proposal','ai_proposal','reservation')), + dismissedAt TEXT, + createdAt TEXT NOT NULL + ) STRICT;`); + run(`INSERT INTO Notification (id, count, highWater, kind, dismissedAt, createdAt) + SELECT id, count, highWater, kind, dismissedAt, createdAt FROM NotificationOud`); + run(`DROP TABLE NotificationOud`); + run(`CREATE INDEX IxNotificationKind ON Notification(kind)`); + } + } + }, + + 127: () => { + if (tableExists('Preference')) { + run(`INSERT INTO Preference (scope, key, value) VALUES ('app', 'locale', '"nl"') + ON CONFLICT (scope, key) DO NOTHING`); + } + }, + + 128: () => { + if (!tableExists('PriceHistory') || columnExists('PriceHistory', 'sourceName')) return; + const vorm = ['assetId', 'providerId', 'fiatPrice', 'resolution', 'observedAt'].every(k => columnExists('PriceHistory', k)); + if (vorm) rebuildPriceHistory128(); + else run(`ALTER TABLE PriceHistory ADD COLUMN sourceName TEXT`); + }, +}; + +function rebuildPriceHistory128() { + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE PriceHistory RENAME TO PriceHistoryOud`); + run(`CREATE TABLE PriceHistory ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT, + fiatPrice REAL NOT NULL, + resolution TEXT NOT NULL CHECK (resolution IN ('day','timestamp')), + observedAt TEXT NOT NULL, + sourceName TEXT, + createdAt TEXT, + updatedAt TEXT, + UNIQUE (assetId, providerId, observedAt, resolution) + ) STRICT`); + const oud = new Set(all(`PRAGMA table_info(PriceHistoryOud)`).map(c => c.name)); + const kolommen = ['id', 'assetId', 'providerId', 'fiatPrice', 'resolution', 'observedAt', 'createdAt', 'updatedAt'] + .filter(k => oud.has(k)).join(', '); + run(`INSERT INTO PriceHistory (${kolommen}) SELECT ${kolommen} FROM PriceHistoryOud`); + run(`DROP TABLE PriceHistoryOud`); + run(`CREATE INDEX IxPriceHistoryProviderId ON PriceHistory(providerId)`); + run(`CREATE INDEX IxPriceHistoryAssetIdObservedAt ON PriceHistory(assetId, observedAt)`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%PriceHistoryOud%'`) + .filter(r => /\bPriceHistoryOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 128 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } +} + +function rebuildEvent126() { + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + try { + run(`BEGIN`); + run(`ALTER TABLE Event RENAME TO EventOud`); + run(`CREATE TABLE Event ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, + toAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, + productId INTEGER REFERENCES MetalProduct(id) ON DELETE NO ACTION, + productCount INTEGER CHECK (productCount IS NULL OR productCount > 0), + importReference TEXT, + externalAddress TEXT, + note TEXT, + amount INTEGER NOT NULL CHECK (amount > 0), + fiatPrice REAL CHECK (fiatPrice IS NULL OR fiatPrice >= 0), + fiatFee REAL CHECK (fiatFee IS NULL OR fiatFee >= 0), + unitFee INTEGER CHECK (unitFee IS NULL OR unitFee >= 0), + priceOrigin TEXT CHECK (priceOrigin IS NULL OR priceOrigin IN ('paid','market_day','market_moment','none')), + type TEXT NOT NULL CHECK (type IN ('buy','sell','transfer','interest','reservation')), + source TEXT NOT NULL CHECK (source IN ('manual','import','reconcile','lightning')), + occurredAt TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT, + CHECK ((productId IS NULL) = (productCount IS NULL)), + CHECK (type NOT IN ('buy','sell') OR fiatPrice IS NOT NULL), + CHECK (type <> 'reservation' OR ((fromAccountId IS NULL) <> (toAccountId IS NULL) + AND fiatPrice IS NULL AND fiatFee IS NULL AND unitFee IS NULL + AND productId IS NULL AND priceOrigin IS NULL + AND importReference IS NULL AND externalAddress IS NULL)) + ) STRICT`); + const kolommen = `id, assetId, fromAccountId, toAccountId, productId, productCount, importReference, + externalAddress, note, amount, fiatPrice, fiatFee, unitFee, priceOrigin, type, source, occurredAt, + createdAt, updatedAt`; + run(`INSERT INTO Event (${kolommen}) SELECT ${kolommen} FROM EventOud`); + run(`DROP TABLE EventOud`); + run(`CREATE INDEX IxEventAssetId ON Event(assetId)`); + run(`CREATE INDEX IxEventFromAccountId ON Event(fromAccountId)`); + run(`CREATE INDEX IxEventToAccountId ON Event(toAccountId)`); + run(`CREATE INDEX IxEventProductId ON Event(productId)`); + run(`CREATE INDEX IxEventOccurredAt ON Event(occurredAt)`); + run(`CREATE INDEX IxEventType ON Event(type)`); + run(`CREATE INDEX IxEventExternalAddress ON Event(externalAddress)`); + run(`CREATE INDEX IxEventImportReference ON Event(importReference)`); + const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%EventOud%'`) + .filter(r => /\bEventOud\b/.test(String(r.sql).replace(/--[^\n]*/g, ''))); + if (besmet.length) { + throw new Error( + `Migration 126 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` + + `table name. The step was rolled back and the database is unchanged.`); + } + run(`COMMIT`); + } catch (e) { + try { run(`ROLLBACK`); } catch { } + throw e; + } finally { + run(`PRAGMA legacy_alter_table = OFF`); + run(`PRAGMA foreign_keys = ON`); + } +} + +function serviceProviderRow() { + const kolommen = ['key', 'name', 'searchName', 'baseUrl', 'authenticationKind', 'isSystem', 'createdAt']; + const aanwezig = new Set(tableExists('Provider') + ? all(`SELECT name FROM pragma_table_info('Provider')`).map(r => r.name) : []); + if (!kolommen.every(k => aanwezig.has(k))) return; + if (one(`SELECT 1 AS x FROM Provider WHERE key = 'atreides_services'`)) return; + run(`INSERT INTO Provider + (key, name, searchName, baseUrl, authenticationHeaderName, authenticationParameterName, + authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES ('atreides_services', 'Atreides Services', 'atreides services', '', NULL, NULL, 'none', 0, 1, 1, 1, ?)`, + [new Date().toISOString()]); +} + +function tableExists(name) { + return !!one(`SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = ?`, [name]); +} + +function columnExists(table, column) { + const r = one(`SELECT sql AS s FROM sqlite_master WHERE type = 'table' AND name = ?`, [table]); + return !!r && new RegExp(`[(,]\\s*["\`\\[]?${column}["\`\\]]?\\s`, 'i').test(String(r.s)); +} + +export function readVersion() { + if (!tableExists('Schema')) { + throw new Error( + 'This database is from before the new layout and cannot be opened with this version of the ' + + 'app. The conversion script was removed on 14-08-2026; it is still in the git history ' + + '(git log --all -- scripts/baseline-convert.mjs), as is a checkout from before the baseline ' + + 'commit.'); + } + const r = one(`SELECT version AS v FROM Schema`); + if (!r) throw new Error('Cannot determine the schema version. The database is inconsistent.'); + return r.v; +} + +function writeVersion(v) { + run(`UPDATE Schema SET version = ?`, [v]); +} + +export function runMigrations() { + let found = readVersion(); + if (found > CONFIG.SCHEMA_VERSION) { + throw new Error(`DB version ${found} is newer than the app (${CONFIG.SCHEMA_VERSION}).`); + } + if (found < BASELINE_VERSION) { + throw new Error( + `This database is at version ${found} and thus from before the new layout ` + + `(version ${BASELINE_VERSION}). The migrations that would update it step by step no longer ` + + `exist, and neither does the conversion script: see git log --all -- scripts/baseline-convert.mjs.`); + } + let migrated = false; + while (found < CONFIG.SCHEMA_VERSION) { + const next = found + 1; + const m = MIGRATIONS[next]; + if (!m) throw new Error(`The migration to version ${next} is missing.`); + m(); + writeVersion(next); + found = next; + migrated = true; + } + return migrated; +} diff --git a/server/db/schema.js b/server/db/schema.js new file mode 100644 index 0000000..e349eef --- /dev/null +++ b/server/db/schema.js @@ -0,0 +1,1239 @@ +// Generated, do not edit. sha256:da89e340dd3822fdda071264a61e9eb52014fccd9317846885a90d5b773c4e19 +import { AUTH_KINDS, CONFIG, ENUMS, CAPABILITIES, SOURCE_CAPABILITIES, OUTPUT_FIELDS, TRANSFORMS, HTTP_METHODS } from '../../shared/config.js'; + +const lijst = (waarden) => waarden.map(w => `'${w}'`).join(','); + +const ASSET_KIND = lijst(ENUMS.ASSET_KIND); +const ASSET_DISPLAY_UNIT = lijst(ENUMS.ASSET_DISPLAY_UNIT); +const ACCOUNT_KIND = lijst(ENUMS.ACCOUNT_KIND); +const TAX_CATEGORY = lijst(ENUMS.TAX_CATEGORY); +const TAX_SCOPE = lijst(ENUMS.TAX_SCOPE); +const WEALTH_SCOPE = lijst(ENUMS.WEALTH_SCOPE); +const METAL_FORM = lijst(ENUMS.METAL_FORM); +const EVENT_TYPE = lijst(ENUMS.EVENT_TYPE); +const LEDGER_EVENT_TYPE = lijst(ENUMS.LEDGER_EVENT_TYPE); +const EVENT_SOURCE = lijst(ENUMS.EVENT_SOURCE); +const PRICE_ORIGIN = lijst(ENUMS.EVENT_PRICE_ORIGIN); +const VALUATION_SOURCE = lijst(ENUMS.VALUATION_SOURCE); +const SCAN_STATUS = lijst(ENUMS.SCAN_STATUS); +const DIRECTION = lijst(ENUMS.RECONCILE_DIRECTION); +const WALLET_TX_LAYER = lijst(ENUMS.WALLET_TX_LAYER); +const WALLET_TX_KIND = lijst(ENUMS.WALLET_TX_KIND); +const SUGGESTED_TYPE = lijst(ENUMS.RECONCILE_SUGGESTED); +const PROPOSAL_STATUS = lijst(ENUMS.RECONCILE_STATUS); +const LINK_METHOD = lijst(ENUMS.LINK_METHOD); +const WALLET_SOURCE_KIND = lijst(ENUMS.WALLET_SOURCE_KIND); +const SCRIPT_TYPE = lijst(ENUMS.SCRIPT_TYPE); +const RATE_KIND = lijst(ENUMS.INTEREST_RATE_KIND); +const CREDIT_CADENCE = lijst(ENUMS.INTEREST_CADENCE); +const CREDIT_STATUS = lijst(ENUMS.INTEREST_CREDIT_STATUS); +const NOTIFICATION_KIND = lijst(ENUMS.NOTIFICATION_KIND); +const CONNECTION_KIND = lijst(ENUMS.CONNECTION_KIND); +const CONNECTION_STATUS = lijst(ENUMS.CONNECTION_STATUS); +const AI_CALL_OUTCOME = lijst(ENUMS.AI_CALL_OUTCOME); +const AI_MESSAGE_KIND = lijst(ENUMS.AI_MESSAGE_KIND); +const INTEGRATION_TARGET = lijst(ENUMS.INTEGRATION_TARGET); +const INTEGRATION_OUTCOME = lijst(ENUMS.INTEGRATION_OUTCOME); +const COMMAND_KIND = lijst(ENUMS.COMMAND_KIND); +const COMMAND_STATE = lijst(ENUMS.COMMAND_STATE); +const AI_PROPOSAL_KIND = lijst(ENUMS.AI_PROPOSAL_KIND); +const AI_PROPOSAL_STATUS = lijst(ENUMS.AI_PROPOSAL_STATUS); +const PRICE_RESOLUTION = lijst(ENUMS.PRICE_RESOLUTION); +const CHANGE_OPERATION = lijst(ENUMS.CHANGE_OPERATION); +const CHANGE_ORIGIN = lijst(ENUMS.CHANGE_ORIGIN); +const SOURCE_STATUS = lijst(ENUMS.PRICE_SOURCE_STATUS); +const HASH_ALGORITHM = lijst(ENUMS.HMAC_HASH_ALGORITHM); +const HMAC_ENCODING = lijst(ENUMS.HMAC_ENCODING); +const AUTH_KIND = lijst(Object.keys(AUTH_KINDS)); +const CAPABILITY = lijst(CAPABILITIES); +const SOURCE_CAPABILITY = lijst(SOURCE_CAPABILITIES); +const SOURCE_CHOICE_ACTION = lijst(ENUMS.SOURCE_CHOICE_ACTION); +const QUOTE_UNIT = lijst(ENUMS.QUOTE_UNIT); +const OUTPUT_FIELD = lijst(OUTPUT_FIELDS); +const TRANSFORM = lijst(TRANSFORMS); +const HTTP_METHOD = lijst(HTTP_METHODS); + +export const SCHEMA = ` +-- ═══ Core ══════════════════════════════════════════════════════════ +-- Asset: what everything hangs on. searchName is the normalised name (shared/search-name.js); symbol +-- is the hard key and it is really unique now, which it was not in the old schema even though the seed +-- deduplicates on it. +-- +-- THE UPPER BOUND ON decimals IS MEASURED and not a round-number choice. A quantity is stored as +-- value * 10^decimals in an INTEGER column, and node:sqlite THROWS when reading back above +-- Number.MAX_SAFE_INTEGER. At 18 decimals (ETH) that bound sits at 0.009 units: the asset can be +-- created and breaks as soon as you use it. At 9 decimals it sits at over nine million units, ample +-- for every asset this app knows. Consequence you must know: an ETH-like asset with 18 decimals is now +-- refused at creation. That is intended, because it was not usable anyway; what changes is that it is +-- now a readable refusal instead of a silent break. +CREATE TABLE Asset ( + id INTEGER PRIMARY KEY, + symbol TEXT NOT NULL, -- 'BTC', 'XAU'; uppercase, a code and not UI text + name TEXT NOT NULL, -- UI text and therefore Dutch ('Goud') + searchName TEXT NOT NULL, -- normalised name; no UNIQUE: two assets may carry the same name, symbol separates them + description TEXT, -- NULL = not filled in; the UI does not write this field yet + unit TEXT NOT NULL, -- main unit (BTC, ozt) + subunit TEXT, -- NULL = this asset has no subunit (ETH, LINK) + displayUnit TEXT NOT NULL CHECK (displayUnit IN (${ASSET_DISPLAY_UNIT})), + decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= ${CONFIG.MAX_ASSET_DECIMALS}), -- storage precision: amount = value * 10^decimals; the upper bound is measured, see the head + kind TEXT NOT NULL CHECK (kind IN (${ASSET_KIND})), -- discriminator: decides whether MetalProduct/subunit has meaning + -- The wealth category of this asset. NULL = derive from \`kind\`. The account goes first: see + -- \`categorieVoor\` in client/tax/constants.js for the order and why it is that way. + taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN (${TAX_CATEGORY})), + -- Where this asset counts on the tax page. NULL = in the table and the result. The account goes + -- first: see \`reikwijdteVoor\` in client/tax/constants.js. + taxScope TEXT CHECK (taxScope IS NULL OR taxScope IN (${TAX_SCOPE})), + -- Whether the account panel offers "Saldo vastleggen" for this asset. NULL = derive from \`kind\` (on + -- for fiat only), see \`balanceCaptureOn\` in shared/config.js. + balanceCapture INTEGER CHECK (balanceCapture IS NULL OR balanceCapture IN (0,1)), + createdAt TEXT, -- NULL = row predates migration 85 + updatedAt TEXT, -- NULL = never changed since it was created + UNIQUE (symbol), -- idempotency of seedAssets and of the import: one row per symbol + -- Displaying in the subunit is only possible when it exists. Stood nowhere and is exactly the kind of + -- silent inconsistency this baseline closes. + CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL) +) STRICT; +CREATE INDEX IxAssetSearchName ON Asset(searchName); -- query: searching by name in the asset picker + +-- Account: the place where an asset is held. isActive/closedAt: an inactive account disappears from the +-- pick lists but counts fully in the positions. That distinction lives in the read layer, not here. +CREATE TABLE Account ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + searchName TEXT NOT NULL, -- no UNIQUE: the user may name two accounts alike (different kind/custody) + description TEXT, -- NULL = not filled in + kind TEXT NOT NULL CHECK (kind IN (${ACCOUNT_KIND})), + isOwnCustody INTEGER NOT NULL CHECK (isOwnCustody IN (0,1)), + scanEnabled INTEGER NOT NULL CHECK (scanEnabled IN (0,1)), -- 0 = manual, 1 = automated through WalletSource + isActive INTEGER NOT NULL CHECK (isActive IN (0,1)), + closedAt TEXT, -- NULL = not closed; ISO UTC when isActive = 0 + -- The wealth category of what sits on this account. NULL = derive from the asset. + taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN (${TAX_CATEGORY})), + -- Where it counts on the tax page. NULL = as the asset says. This is where "mine but outside box 3" + -- belongs: a pension account simply holds euros, so the asset cannot tell. The account knows. + taxScope TEXT CHECK (taxScope IS NULL OR taxScope IN (${TAX_SCOPE})), + -- Whose it is. NULL = own wealth; 'managed' = held for someone else; 'none' = neither. + wealthScope TEXT CHECK (wealthScope IS NULL OR wealthScope IN (${WEALTH_SCOPE})), + createdAt TEXT, -- NULL = row predates migration 85 + updatedAt TEXT, -- NULL = never changed since it was created + -- The flag and the date cannot contradict each other. This CHECK may be immutable: it guards a + -- boolean relation that never extends. + CHECK ((isActive = 1 AND closedAt IS NULL) OR (isActive = 0 AND closedAt IS NOT NULL)) +) STRICT; +CREATE INDEX IxAccountSearchName ON Account(searchName); -- query: searching by name in the account picker + +-- Label: free tags on addresses and movements. +CREATE TABLE Label ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, -- NULL = not filled in + createdAt TEXT, -- NULL = row predates migration 85 + updatedAt TEXT, -- NULL = never changed since it was created + -- STRICTER than the old schema, where UNIQUE sat on \`name\`: 'Werk' and 'werk' were two labels there. + -- Uniqueness belongs on the normalised form, because that is what the combobox treats as "already + -- exists". Counted on the backup: zero collisions, so the conversion does not get stuck here. + UNIQUE (searchName) +) STRICT; + +-- MetalProduct: coins and bars per metal asset. fineGrams = fine weight per piece in the gram base +-- (x10^7); the name says "grams" and the unit is not that. +CREATE TABLE MetalProduct ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, -- without an asset a product has no meaning + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, -- NULL = not filled in + fineGrams INTEGER NOT NULL CHECK (fineGrams > 0), + form TEXT NOT NULL CHECK (form IN (${METAL_FORM})), + createdAt TEXT, -- NULL = row predates migration 85 + updatedAt TEXT, -- NULL = never changed since it was created + -- Idempotency of seedMetalProducts: one product per (asset, name). Note the key MUST contain assetId: + -- on the backup four product names ('Baar 1 kg', 'Maple Leaf 1 oz', …) carry the same name for gold + -- AND silver. UNIQUE(searchName) alone would topple the conversion; measured, not assumed. + UNIQUE (assetId, searchName) +) STRICT; + +-- ═══ Providers and their profile ═══════════════════════════════════ +-- Provider: a price source. \`key\` is the stable key for code and seeds, \`id\` is what the relations +-- point at; that is a natural key next to a surrogate and not redundancy. +-- The column \`coverage\` is GONE: it was a comma list and is now ProviderCoverage. +CREATE TABLE Provider ( + id INTEGER PRIMARY KEY, + key TEXT NOT NULL, -- 'mempool'; stable, renaming is a migration + name TEXT NOT NULL, -- UI text ('Handmatig'), stays Dutch + searchName TEXT NOT NULL, + description TEXT, -- NULL = not filled in + baseUrl TEXT NOT NULL, -- empty for the system provider 'manual' + authenticationHeaderName TEXT, -- NULL = this authenticationKind uses no header + authenticationParameterName TEXT, -- NULL = this authenticationKind uses no query parameter + authenticationKind TEXT NOT NULL CHECK (authenticationKind IN (${AUTH_KIND})), + authenticationRequired INTEGER NOT NULL CHECK (authenticationRequired IN (0,1)), + isActive INTEGER NOT NULL CHECK (isActive IN (0,1)), + isSeed INTEGER NOT NULL CHECK (isSeed IN (0,1)), + isSystem INTEGER NOT NULL CHECK (isSystem IN (0,1)), -- 1 = 'manual': not editable, not deletable + createdAt TEXT NOT NULL, + UNIQUE (key) -- idempotency of seedProviders: one row per key +) STRICT; +CREATE INDEX IxProviderSearchName ON Provider(searchName); -- query: searching the provider list in Integraties + +-- A 1:1 extension of Provider for the HMAC recipe. providerId IS the key; one of the three tables that +-- legitimately have no id of their own. +-- messageParts stays a comma list: it is read as a recipe in its entirety and never searched, unlike the +-- old Provider.coverage. +CREATE TABLE ProviderAuthenticationHmac ( + providerId INTEGER PRIMARY KEY REFERENCES Provider(id) ON DELETE CASCADE, + messageParts TEXT NOT NULL, -- 'timestamp,method,full_path,body' + keyHeader TEXT NOT NULL, + signatureHeader TEXT NOT NULL, + timestampHeader TEXT NOT NULL, + windowHeader TEXT, -- NULL = this provider knows no window header + windowMs INTEGER CHECK (windowMs IS NULL OR windowMs > 0),-- NULL = no window; belongs with windowHeader + hashAlgorithm TEXT NOT NULL CHECK (hashAlgorithm IN (${HASH_ALGORITHM})), -- was hash_algo: an abbreviation without gain + encoding TEXT NOT NULL CHECK (encoding IN (${HMAC_ENCODING})), + createdAt TEXT -- NULL = row predates migration 85 +) STRICT; + +-- A 1:1 extension of Provider for a FIXED rate. Same shape and the same reason as the HMAC recipe above: +-- it is a recipe and not a thing of its own. +-- +-- WHY THIS EXISTS. The euro is the unit of account of the app (PriceHistory.fiatPrice), so the rate of a euro +-- account is by definition 1.00. Without a rate there is no price row, and without a price row the +-- balances tab refuses to store a balance and the tax page leaves the amount out of the totals. So an +-- asset needs a source, and PriceHistory.providerId is NOT NULL: the source MUST be a provider. This +-- table makes "fixed rate" a kind of provider instead of a branch in every consumer. +-- +-- ONLY \`fiatPrice\`, in the unit of account: the one currency whose rate is fixed by definition. +-- +-- Coverage (which symbols) lives in ProviderCoverage and not here: one fixed rate per provider is enough +-- as long as a provider covers one symbol, and a second fixed rate is a second row in Provider. The same +-- trade-off as with the two Eulerpool rows (crypto and metal separately). +CREATE TABLE ProviderFixedRate ( + providerId INTEGER PRIMARY KEY REFERENCES Provider(id) ON DELETE CASCADE, + fiatPrice REAL NOT NULL CHECK (fiatPrice > 0), -- 1.0 for the system provider 'fixed'; zero or negative is no rate + createdAt TEXT -- NULL = row predates migration 85 +) STRICT; + +-- ProviderCallLimit: how many calls a month the plan of this provider allows. +-- A 1:1 row next to the provider, like ProviderFixedRate, so a provider without a monthly limit simply has +-- none; no NULL column on every provider. The COUNT is not here: ApiCallMetric already has it per minute. +-- A calendar month in UTC, which is how the app counts; a provider may reset on its billing date. +CREATE TABLE ProviderCallLimit ( + providerId INTEGER PRIMARY KEY REFERENCES Provider(id) ON DELETE CASCADE, + callsPerMonth INTEGER NOT NULL CHECK (callsPerMonth > 0), + createdAt TEXT +) STRICT; + +CREATE TABLE ProviderEndpoint ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + -- With {symbol}/{external_id}/{date}; the resolver fills them in. EMPTY for a fixed-rate provider: no + -- request goes out, and the row only says WHICH capability it can answer. The same idiom as the empty + -- Provider.baseUrl of 'manual'. + pathTemplate TEXT NOT NULL, + queryTemplate TEXT, -- NULL = no query string + capability TEXT NOT NULL CHECK (capability IN (${CAPABILITY})), + method TEXT NOT NULL CHECK (method IN (${HTTP_METHOD})), + -- The currency the ANSWER is in, as the symbol of a fiat asset ('USD'). NULL = the unit of account of + -- the portfolio. Not a CHECK list: a symbol, like ProviderCoverage. + quoteCurrency TEXT, + -- How often this endpoint may be asked, in seconds. NULL = the pace of the app. For a spot it is how + -- long an answer stays fresh; for the others the minimum spacing between two requests. + minIntervalSeconds INTEGER CHECK (minIntervalSeconds IS NULL OR minIntervalSeconds > 0), + createdAt TEXT, -- NULL = row predates migration 85 + UNIQUE (providerId, capability) -- one endpoint per capability: underpins the upsert in the builder UI +) STRICT; + +-- ProviderInputMap: our symbols to the provider's keys. Was called ProviderSymbolMap; the new name is +-- the UI's own ("Input-mapping" next to "Output-mapping" in the builder), and it makes the pair with +-- ProviderOutputMap visible. +CREATE TABLE ProviderInputMap ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + symbol TEXT, -- our symbol; NULL only on the catch-all row. No FK to Asset, see ProviderCoverage + externalId TEXT NOT NULL, -- what the provider calls it ('gold') + quoteUnit TEXT CHECK (quoteUnit IS NULL OR quoteUnit IN (${QUOTE_UNIT})), -- NULL = the storage base; 'ozt' = per troy ounce, converted to per gram (migration 99) + isCatchAll INTEGER NOT NULL CHECK (isCatchAll IN (0,1)), -- 1 = for every symbol without a row of its own (migration 102) + createdAt TEXT, -- NULL = row predates migration 85 + UNIQUE (providerId, symbol), -- one translation per symbol + CHECK ((isCatchAll = 1) = (symbol IS NULL)) -- the catch-all has no symbol, and only the catch-all +) STRICT; +CREATE UNIQUE INDEX UxProviderInputMapCatchAll ON ProviderInputMap(providerId) WHERE isCatchAll = 1; -- one catch-all per provider + +CREATE TABLE ProviderOutputMap ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + jsonPath TEXT NOT NULL, -- 'prices[].EUR'; may contain {external_id} + capability TEXT NOT NULL CHECK (capability IN (${CAPABILITY})), + internalField TEXT NOT NULL CHECK (internalField IN (${OUTPUT_FIELD})), + transform TEXT NOT NULL CHECK (transform IN (${TRANSFORM})), + createdAt TEXT -- NULL = row predates migration 85 +) STRICT; +CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability); -- query: the adapter fetches the mapping per (provider, capability) + +-- ProviderCoverage: which symbols a provider delivers. Was Provider.coverage, a comma list searched in +-- SQL with a LIKE trick. Deliberately NO FK to Asset: a provider may claim coverage on a symbol that is +-- not an asset here, and with an FK that coverage would disappear on saving. Keying on symbol text +-- mirrors ProviderInputMap. +CREATE TABLE ProviderCoverage ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + symbol TEXT NOT NULL, + createdAt TEXT, -- NULL = row predates migration 85 + UNIQUE (providerId, symbol) -- idempotency of syncing the coverage +) STRICT; +CREATE INDEX IxProviderCoverageSymbol ON ProviderCoverage(symbol); -- query: which providers cover this symbol (matrix columns) + +-- Measurement points per provider per minute. A composite PK, no surrogate id: the third of the three +-- exceptions. bucketMinute is a key at minute precision ('2026-06-25T08:17') and deliberately not a full +-- timestamp. +CREATE TABLE ApiCallMetric ( + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + capability TEXT NOT NULL CHECK (capability IN (${CAPABILITY})), + bucketMinute TEXT NOT NULL, + okCount INTEGER NOT NULL CHECK (okCount >= 0), + failCount INTEGER NOT NULL CHECK (failCount >= 0), + sumLatencyMs INTEGER NOT NULL CHECK (sumLatencyMs >= 0), + maxLatencyMs INTEGER NOT NULL CHECK (maxLatencyMs >= 0), + PRIMARY KEY (providerId, capability, bucketMinute) -- underpins the ON CONFLICT summing per minute +) STRICT; + +-- IntegrationCall: one row per call to Atreides Services or an own integration that went wrong (plan +-- Integratielog). A successful call gets no row: it is counted in ApiCallMetric, and a spot refresh every +-- minute would otherwise write over 1400 rows a day per source. +-- +-- \`providerKey\` IS TEXT AND NOT AN FK: a log has to outlive the deletion of what it is about. NULL for the +-- service, which is not a provider you configure. +-- +-- NO URL, NO HEADER, NO ANSWER. \`operation\` is the path of the service or the capability, never the built +-- URL (a query_key source carries its key in it). \`detail\` is cleaned of every secret the writer knows +-- before it lands here (server/data/integration-log.js). +-- +-- Cleared on every write: older than CONFIG.INTEGRATION_LOG.RETENTION_DAYS, and above MAX_ROWS the oldest. +CREATE TABLE IntegrationCall ( + id INTEGER PRIMARY KEY, + target TEXT NOT NULL CHECK (target IN (${INTEGRATION_TARGET})), + providerKey TEXT, -- NULL = the service (see above) + operation TEXT NOT NULL, -- '/v1/series' for the service, the capability for a provider + subject TEXT, -- NULL = not about one instrument; for /v1/spot the NUMBER of instruments, not the list + outcome TEXT NOT NULL CHECK (outcome IN (${INTEGRATION_OUTCOME})), + httpStatus INTEGER CHECK (httpStatus IS NULL OR httpStatus BETWEEN 100 AND 599), -- NULL = no answer came + errorKey TEXT, -- NULL = no key; the AppError key, plus the key of the service after a space + detail TEXT CHECK (detail IS NULL OR length(detail) <= ${CONFIG.INTEGRATION_LOG.DETAIL_MAX_CHARS}), -- NULL = nothing to add; cleaned + latencyMs INTEGER CHECK (latencyMs IS NULL OR latencyMs >= 0), -- NULL = not sent (refused) + requestId TEXT, -- NULL = not sent to the service; the X-Request-Id that went along + occurredAt TEXT NOT NULL, -- UTC to the millisecond, the moment of sending + CHECK ((target = 'service') = (providerKey IS NULL)), + CHECK (requestId IS NULL OR target = 'service') +) STRICT; +CREATE INDEX IxIntegrationCallOccurredAt ON IntegrationCall(occurredAt); -- query: the clearing by age and the window of recent failures + +-- ═══ Connections and tracking sources ══════════════════════════════ +-- Connection: endpoint profiles per kind. Only btc_explorer knows one app-wide active row; lnd/nwc may +-- have several active (those hang per account). Secrets (macaroon, NWC secret) are NOT here. +CREATE TABLE Connection ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, -- NULL = not filled in + url TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN (${CONNECTION_KIND})), + isActive INTEGER NOT NULL CHECK (isActive IN (0,1)), + isSystem INTEGER NOT NULL CHECK (isSystem IN (0,1)), -- 1 = mempool.space: a guaranteed fallback, not deletable + lastStatus TEXT CHECK (lastStatus IS NULL OR lastStatus IN (${CONNECTION_STATUS})), -- NULL = not tested yet + lastCheckedAt TEXT, -- NULL = not tested yet; belongs with lastStatus + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + UNIQUE (kind, searchName), -- idempotency of seedConnections: one profile per (kind, name) + -- An outcome without a moment (or the other way round) says nothing. Both or neither. + CHECK ((lastStatus IS NULL) = (lastCheckedAt IS NULL)) +) STRICT; +-- At most one active btc_explorer profile. Partial, so it does not cover the kind scan in general; nor +-- does it need to, because UNIQUE(kind, searchName) already has kind first. +CREATE UNIQUE INDEX UxConnectionActiveBtcExplorer ON Connection(kind) WHERE kind = 'btc_explorer' AND isActive = 1; + +-- WalletSource: where an account gets its on-chain picture from. The NWC secret lives server-side in the +-- secrets file; \`value\` holds only the wallet pubkey for nwc. +CREATE TABLE WalletSource ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + connectionId INTEGER REFERENCES Connection(id) ON DELETE RESTRICT, -- the LND node: required on lnd, optional on xpub/address (its on-chain layer, migration 124). RESTRICT: emptying it would break the CHECK below + value TEXT NOT NULL, -- xpub, address or wallet pubkey + name TEXT, -- NULL = no name of its own; the UI then shows the source itself + kind TEXT NOT NULL CHECK (kind IN (${WALLET_SOURCE_KIND})), + scriptType TEXT CHECK (scriptType IS NULL OR scriptType IN (${SCRIPT_TYPE})), -- NULL = not applicable or not determined yet + -- The balance of a LIGHTNING source as the last update saw it (migration 120): for lnd + -- the channel outbound, for nwc the wallet's own balance. An on-chain source has none here, its balance + -- is the sum of its addresses (WalletAddress.balanceSats). The same names as on WalletAddress. + balanceSats INTEGER CHECK (balanceSats IS NULL OR balanceSats >= 0), -- NULL = never updated, or an on-chain source + lastScannedAt TEXT, -- NULL = never updated; belongs with balanceSats + createdAt TEXT NOT NULL, + updatedAt TEXT, -- NULL = never changed since it was created + -- Conditional columns, until now only comments. Both counted on the backup: all five xpub sources have + -- a scriptType, the only lnd source has a connectionId, and no other kind carries one. + CHECK (scriptType IS NULL OR kind = 'xpub'), + CHECK (kind <> 'lnd' OR connectionId IS NOT NULL), + CHECK (connectionId IS NULL OR kind IN ('lnd','xpub','address')), + CHECK ((balanceSats IS NULL) = (lastScannedAt IS NULL)), + CHECK (balanceSats IS NULL OR kind IN ('lnd','nwc')) +) STRICT; +CREATE INDEX IxWalletSourceAccountId ON WalletSource(accountId); -- FK scan on deleting an account + query: sources per account +CREATE INDEX IxWalletSourceConnectionId ON WalletSource(connectionId); -- FK scan on deleting a connection + +-- WalletAddress. The column labelId was DROPPED at review: it stood in no INSERT, no UPDATE and no +-- SELECT, only in a comment at the upsert. A label on an address COULD therefore never exist. Should +-- that wish come after all, it is one migration. Labels on movements do stay: see +-- WalletTransaction.labelId. +CREATE TABLE WalletAddress ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE, -- NULL = a loose address without a source + address TEXT NOT NULL, + automaticLabel TEXT, -- NULL = not named automatically + chainIndex INTEGER CHECK (chainIndex IS NULL OR chainIndex IN (0,1)), -- BIP32 chain index: 0 = receive, 1 = change. NULL = loose address. Was called \`chain\`, which looked like it was about the blockchain + derivationIndex INTEGER CHECK (derivationIndex IS NULL OR derivationIndex >= 0), -- NULL = loose address; belongs with chainIndex + balanceSats INTEGER CHECK (balanceSats IS NULL OR balanceSats >= 0), -- snapshot of the last scan; NULL = never scanned + transactionCount INTEGER CHECK (transactionCount IS NULL OR transactionCount >= 0),-- idem + lastScannedAt TEXT, -- NULL = never scanned; belongs with balanceSats/transactionCount + createdAt TEXT, -- NULL = row predates migration 85 + UNIQUE (address) -- underpins the idempotent address upsert per scan +) STRICT; +CREATE INDEX IxWalletAddressAccountId ON WalletAddress(accountId); -- FK scan on account delete + query: addresses per account +CREATE INDEX IxWalletAddressSourceId ON WalletAddress(sourceId); -- FK scan on source delete + query: addresses per source + +-- WalletTransaction: a cache of the movements of a wallet, on-chain and on lightning (migration +-- 119). The two layers work the same everywhere else (the links to events, the proposals, +-- Tracking), and that is why they share a table. +-- +-- ON-CHAIN: one row per (account, txid), the net delta for that account; \`txid\` is the transaction id. +-- LIGHTNING: one row per (source, txid), where \`txid\` is the payment hash (hex). Per SOURCE and not per +-- account, because an NWC wallet and a LND node on the same account see the same payment, and each keeps +-- its own row; the link between the two is a proposal. +-- +-- \`blockTime\` IS THE MOMENT OF BOTH LAYERS, the time of the block or the time a payment settled. A new +-- column \`occurredAt\` would need a COALESCE in the 43 places that read \`blockTime\`, and one forgotten +-- reader would drop lightning without a sound. A lightning row has no height and always a time: only +-- settled payments come in, so it is never "unconfirmed". +CREATE TABLE WalletTransaction ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE, -- NULL = an on-chain movement without a traceable source; a lightning row always has one + labelId INTEGER REFERENCES Label(id) ON DELETE SET NULL, -- NULL = no label + layer TEXT NOT NULL CHECK (layer IN (${WALLET_TX_LAYER})), + kind TEXT CHECK (kind IS NULL OR kind IN (${WALLET_TX_KIND})), -- NULL = an ordinary movement; 'channel' opened or closed a channel (on-chain), 'self' paid yourself (lightning). Migration 121 + txid TEXT NOT NULL, -- the transaction id, or on lightning the payment hash. Stays txid: the term of every explorer and API + address TEXT, -- NULL = no address known; the match key with Event.externalAddress + memo TEXT CHECK (memo IS NULL OR length(memo) <= 2000), -- NULL = no memo; lightning only: the description of the invoice, for a zap the zap request (shared/zap.js). Migration 123 + valueSats INTEGER NOT NULL, + feeSats INTEGER CHECK (feeSats IS NULL OR feeSats >= 0), -- NULL = fee unknown (an incoming movement) + blockHeight INTEGER CHECK (blockHeight IS NULL OR blockHeight > 0), -- NULL = still unconfirmed, or a lightning row + blockTime TEXT, -- NULL = still unconfirmed; the moment on both layers (see above) + direction TEXT NOT NULL CHECK (direction IN (${DIRECTION})), + createdAt TEXT NOT NULL, + -- On-chain: height and time come together out of the confirmation; one of the two is a half-processed + -- row. Lightning: no height, always a time, and always a source (the key below rests on it). + CHECK ((layer = 'onchain' AND (blockHeight IS NULL) = (blockTime IS NULL)) + OR (layer = 'lightning' AND blockHeight IS NULL AND blockTime IS NOT NULL AND sourceId IS NOT NULL)), + -- A channel lives on the chain, a payment to yourself on lightning. + CHECK (kind IS NULL OR (kind = 'channel') = (layer = 'onchain')), + CHECK (memo IS NULL OR layer = 'lightning') +) STRICT; +CREATE UNIQUE INDEX UxWalletTransactionAccountIdTxid ON WalletTransaction(accountId, txid) WHERE layer = 'onchain'; -- the idempotent scan upsert, on-chain: one row per account per transaction +CREATE UNIQUE INDEX UxWalletTransactionSourceIdTxid ON WalletTransaction(sourceId, txid) WHERE layer = 'lightning'; -- the same on lightning, per source (see above) +CREATE INDEX IxWalletTransactionAccountId ON WalletTransaction(accountId); -- FK scan on account delete: the unique indexes above are partial and cover only one layer each +CREATE INDEX IxWalletTransactionSourceId ON WalletTransaction(sourceId); -- FK scan on source delete + query: movements per source +CREATE INDEX IxWalletTransactionLabelId ON WalletTransaction(labelId); -- FK scan on label delete + +-- ═══ The ledger ════════════════════════════════════════════════════ +-- Event: buy, sell, transfer and interest in one table. Everything that computes holdings, tax or +-- reconcile sums here. +-- +-- WHAT THE DATA SAID OTHER THAN THE PLAN. "fromAccountId and toAccountId belong to a transfer" was a +-- CHECK candidate. It is not true: on the backup EVERY buy has a toAccountId (where it arrives), EVERY +-- sell a fromAccountId, and interest a toAccountId. Such a CHECK would refuse 150 of the 172 rows. So +-- there is none; what COULD be done is here. +-- +-- THE THREE MONEY COLUMNS ARE NON-NEGATIVE. Before that the schema guarded \`amount > 0\` and nothing +-- else about money, and that was measurably wrong: \`eventsNetByAccount\` computes \`-(amount + unitFee)\` +-- on an outgoing transfer, so a negative \`unitFee\` RAISED the balance. Transferring one sat with a fee of +-- -50,000 made the balance 50,000 larger: money from nothing, without an error. +-- +-- WHAT IS DELIBERATELY ABSENT is \`unitFee <= amount\`. The fee does not come off the amount but on top of +-- it, see the formula above. Sending a thousand sats with two thousand sats of cost is therefore a valid +-- booking, and a CHECK refusing it would make a real transaction unbookable. +CREATE TABLE Event ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, -- NULL = no origin (buy, interest). NO ACTION: an account with history is not deletable + toAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, -- NULL = no destination (sell) + productId INTEGER REFERENCES MetalProduct(id) ON DELETE NO ACTION, -- NULL = no piece entry. NO ACTION and not RESTRICT: otherwise an Asset cascade fails on this + productCount INTEGER CHECK (productCount IS NULL OR productCount > 0), -- sits right behind productId because it only has meaning WITH it + importReference TEXT, -- NULL = entered manually; the dedup key of the import + externalAddress TEXT, -- NULL = no external address; the match key with WalletTransaction.address + note TEXT, -- NULL = no note + amount INTEGER NOT NULL CHECK (amount > 0), -- smallest unit; for product rows equal to productCount * MetalProduct.fineGrams + fiatPrice REAL CHECK (fiatPrice IS NULL OR fiatPrice >= 0), -- NULL = not applicable (transfer); required for buy/sell, see CHECK + fiatFee REAL CHECK (fiatFee IS NULL OR fiatFee >= 0), -- NULL = no cost recorded + unitFee INTEGER CHECK (unitFee IS NULL OR unitFee >= 0), -- NULL = no cost in the asset itself; NO upper bound on amount, see the head + -- WHERE THIS PRICE COMES FROM, and NULL is a value: "unknown", which every booking from before this + -- column carries. Beside the money columns because it describes fiatPrice, and before the + -- timestamps because those go last (§6.7 group 7). Migration 88 therefore rebuilds the table. + priceOrigin TEXT CHECK (priceOrigin IS NULL OR priceOrigin IN (${PRICE_ORIGIN})), + type TEXT NOT NULL CHECK (type IN (${EVENT_TYPE})), + source TEXT NOT NULL CHECK (source IN (${EVENT_SOURCE})), -- how the row came to be; one of the three meanings of 'source' + occurredAt TEXT NOT NULL, -- ISO UTC; the old schema held local time without a zone here + createdAt TEXT NOT NULL, + updatedAt TEXT, -- NULL = never changed since it was created + -- The product pair is all or nothing. Counted on the backup: zero deviations. + CHECK ((productId IS NULL) = (productCount IS NULL)), + -- This was an app guard in assertEventPayload and belongs in the schema: a trade without a price is no + -- trade. Counted on the backup: all 76 buy/sell rows have a fiatPrice. + CHECK (type NOT IN ('buy','sell') OR fiatPrice IS NOT NULL), + -- A reservation (migration 126) sits on exactly one account and carries nothing a booking prices or + -- matches on: booking it turns this row into a transfer, and only then may those columns fill. + CHECK (type <> 'reservation' OR ((fromAccountId IS NULL) <> (toAccountId IS NULL) + AND fiatPrice IS NULL AND fiatFee IS NULL AND unitFee IS NULL + AND productId IS NULL AND priceOrigin IS NULL + AND importReference IS NULL AND externalAddress IS NULL)) +) STRICT; +CREATE INDEX IxEventAssetId ON Event(assetId); -- FK scan on asset delete + query: positions per asset +CREATE INDEX IxEventFromAccountId ON Event(fromAccountId); -- FK scan on account delete + query: eventsNetByAccount +CREATE INDEX IxEventToAccountId ON Event(toAccountId); -- idem +CREATE INDEX IxEventProductId ON Event(productId); -- FK scan on product delete + query: metal holdings per product +CREATE INDEX IxEventOccurredAt ON Event(occurredAt); -- query: listEvents sorts on this +CREATE INDEX IxEventType ON Event(type); -- query: listTrades/listTransfers filter on this +CREATE INDEX IxEventExternalAddress ON Event(externalAddress); -- query: the address matching of the reconcile layer +CREATE INDEX IxEventImportReference ON Event(importReference); -- query: findEventByRef, the dedup of the import + +-- Valuation: a manual or scanned balance on a reference date. \`valuedOn\` was called \`onDate\` and follows +-- the date rule: a column on \`...On\` carries a CALENDAR DATE (YYYY-MM-DD), a column on \`...At\` a +-- timestamp in ISO UTC. Here they sit side by side, and that is exactly why that rule exists: +-- \`valuedOn\` is the reference date, \`rateAt\` the moment the rate applied. +CREATE TABLE Valuation ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + accountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, -- NULL = the whole portfolio; NO ACTION because SET NULL would silently make an account valuation portfolio-wide + balance INTEGER NOT NULL, + fiatRate REAL NOT NULL, + source TEXT NOT NULL CHECK (source IN (${VALUATION_SOURCE})), + valuedOn TEXT NOT NULL, -- calendar date + rateAt TEXT NOT NULL, -- timestamp + -- The gap with the ledger has been seen and accepted: no book button, no card, and it no longer counts + -- as unexplained. A timestamp and not a flag, after the model of Notification.dismissedAt: when you + -- accepted something is usable, "yes" is not. + gapAcceptedAt TEXT, + createdAt TEXT, -- NULL = row predates migration 85 + updatedAt TEXT, -- NULL = never changed since it was created + UNIQUE (assetId, valuedOn, accountId) -- one valuation per (asset, date, account); this is the collision mergeAccounts must resolve +) STRICT; +CREATE INDEX IxValuationAccountId ON Valuation(accountId); -- FK scan on account delete + query: valuations per account + +-- ═══ Reconcile layer ═══════════════════════════════════════════════ +-- ScanLog: the memory of every scan. rawData is an audit blob (JSON text) and is never searched; that is +-- the criterion for why it may stay a blob. +CREATE TABLE ScanLog ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + addressesFound INTEGER CHECK (addressesFound IS NULL OR addressesFound >= 0), -- NULL = the scan did not get that far + balanceFound INTEGER CHECK (balanceFound IS NULL OR balanceFound >= 0), -- a snapshot in sats; NULL = idem + proposalsCreated INTEGER CHECK (proposalsCreated IS NULL OR proposalsCreated >= 0), -- NULL = idem + rawData TEXT, -- NULL = no raw output kept + status TEXT NOT NULL CHECK (status IN (${SCAN_STATUS})), + scannedAt TEXT NOT NULL +) STRICT; +CREATE INDEX IxScanLogAccountIdScannedAt ON ScanLog(accountId, scannedAt); -- FK scan on account delete (accountId is first) + query: latestScanLog per account + +-- ReconcileProposal: one detected movement without a counterpart in Event. +-- +-- The column scanLogId was DROPPED at review. It was in both INSERTs (proposals.js and tx-links.js) but +-- both with a literal NULL, and nothing ever read it back: the column could never carry a value. That +-- also cleared the "second route to the same account" on this side, which ran through the scan log here. +CREATE TABLE ReconcileProposal ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + walletTransactionId INTEGER REFERENCES WalletTransaction(id) ON DELETE CASCADE, -- NULL = a scan proposal without a movement; without a movement a link proposal is pointless + resolvedEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL, -- NULL = not handled yet; SET NULL keeps the proposal as history + transactionHash TEXT, -- NULL = no hash known + amount INTEGER NOT NULL CHECK (amount > 0), -- smallest unit; the direction sits in \`direction\` + direction TEXT NOT NULL CHECK (direction IN (${DIRECTION})), + suggestedType TEXT NOT NULL CHECK (suggestedType IN (${SUGGESTED_TYPE})), + status TEXT NOT NULL CHECK (status IN (${PROPOSAL_STATUS})), + detectedAt TEXT NOT NULL +) STRICT; +CREATE INDEX IxReconcileProposalAccountId ON ReconcileProposal(accountId); -- FK scan on account delete + query: proposals per account +CREATE INDEX IxReconcileProposalAssetId ON ReconcileProposal(assetId); -- FK scan on asset delete +CREATE INDEX IxReconcileProposalWalletTransactionId ON ReconcileProposal(walletTransactionId); -- FK scan on movement delete +CREATE INDEX IxReconcileProposalResolvedEventId ON ReconcileProposal(resolvedEventId); -- FK scan on event delete +CREATE INDEX IxReconcileProposalStatus ON ReconcileProposal(status); -- query: the pending list of the reconcile widget +-- Idempotency of the link proposals: at most one row per (movement, event). Partial, because scan +-- proposals without a movement may exist several times. Note the NULL semantics: two rows (5, NULL) do +-- not collide, so this does not protect syncTxProposals against duplicates: the preceding DELETE does +-- that work. +CREATE UNIQUE INDEX UxReconcileProposalLink ON ReconcileProposal(walletTransactionId, resolvedEventId) WHERE walletTransactionId IS NOT NULL; + +-- WalletTransactionEventLink: a confirmed link between a movement and an event. Not 1:1: with address +-- reuse one movement may hang on several events. +CREATE TABLE WalletTransactionEventLink ( + id INTEGER PRIMARY KEY, + walletTransactionId INTEGER NOT NULL REFERENCES WalletTransaction(id) ON DELETE CASCADE, + eventId INTEGER NOT NULL REFERENCES Event(id) ON DELETE CASCADE, + method TEXT NOT NULL CHECK (method IN (${LINK_METHOD})), -- HOW it was linked; an established term, stays 'method' + createdAt TEXT NOT NULL, + UNIQUE (walletTransactionId, eventId) -- underpins INSERT OR IGNORE in syncAddressLinks +) STRICT; +CREATE INDEX IxWalletTransactionEventLinkEventId ON WalletTransactionEventLink(eventId); -- FK scan on event delete + query: links per event +-- No separate index on walletTransactionId: UNIQUE(walletTransactionId, eventId) already has it first. + +-- ═══ Import ════════════════════════════════════════════════════════ +-- ImportBatch: one CSV import. \`rawData\` was called \`raw\` and now has the same name as the audit blob of +-- ScanLog: two names for one concept was a reading trap. +-- +-- \`origin\` STAYS, a correction on the first review list. That called the column dead because it is NULL +-- in all ten rows on the backup, but that looked at the DATA and not at the code: the import page has an +-- input field "Herkomst" that fills it. Never used is something else than unusable. +CREATE TABLE ImportBatch ( + id INTEGER PRIMARY KEY, + accountId INTEGER REFERENCES Account(id) ON DELETE SET NULL, -- NULL = the batch does not concern one account; SET NULL because the import stays valid as history + filename TEXT NOT NULL, + rowCount INTEGER NOT NULL CHECK (rowCount >= 0), + rawData TEXT, -- NULL = the raw CSV was not kept + origin TEXT, -- NULL = origin not filled in by the user + importedAt TEXT NOT NULL +) STRICT; +CREATE INDEX IxImportBatchAccountId ON ImportBatch(accountId); -- FK scan on account delete + +-- ═══ Price history ═════════════════════════════════════════════════ +-- PriceHistory: the text source became a real FK. ONE price column, in the unit of account of the +-- portfolio; the second column \`usd\` went in migration 107: a second currency on +-- screen comes from the exchange-rate series, not from what a source happened to deliver in dollars. +CREATE TABLE PriceHistory ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT, -- was the text \`source\`; RESTRICT: a provider with price rows is not deletable + fiatPrice REAL NOT NULL, -- in the unit of account; a row without a price is no row + resolution TEXT NOT NULL CHECK (resolution IN (${PRICE_RESOLUTION})), -- had no CHECK at all in the old schema + observedAt TEXT NOT NULL, -- ISO UTC + sourceName TEXT, -- NULL = the provider itself; on a row of Atreides Services the source it named (its alias) + -- THREE MOMENTS THAT ARE NOT THE SAME, and that is exactly why this table was rebuilt instead of + -- exempted. observedAt is when the RATE APPLIED; createdAt is when this ROW came to be, and when + -- fetching history those lie years apart; updatedAt is when a better source overwrote it. Leaving one + -- out because there is already a timestamp would pass one moment off as another. + createdAt TEXT, -- NULL = row predates migration 86 + updatedAt TEXT, -- NULL = never overwritten since the first write + UNIQUE (assetId, providerId, observedAt, resolution) -- underpins the idempotent price upsert +) STRICT; +CREATE INDEX IxPriceHistoryProviderId ON PriceHistory(providerId); -- FK scan on provider delete +CREATE INDEX IxPriceHistoryAssetIdObservedAt ON PriceHistory(assetId, observedAt); -- query: the series per asset over time (signals, charts, box 3) + +-- ═══ Interest ══════════════════════════════════════════════════════ +-- InterestSchedule: a deposit-like agreement per account. rateKind decides whether principalAmount and +-- annualRate have meaning. +CREATE TABLE InterestSchedule ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + principalAssetId INTEGER REFERENCES Asset(id) ON DELETE NO ACTION, -- NULL = no fixed principal; NO ACTION so a cascade through payoutAssetId may clear it first + payoutAssetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, -- without a payout asset the agreement has no meaning + note TEXT, -- NULL = no note + principalAmount INTEGER CHECK (principalAmount IS NULL OR principalAmount > 0), -- NULL = not applicable (variable) + annualRate REAL CHECK (annualRate IS NULL OR annualRate >= 0), -- NULL = not applicable (variable) + rateKind TEXT NOT NULL CHECK (rateKind IN (${RATE_KIND})), + creditCadence TEXT CHECK (creditCadence IS NULL OR creditCadence IN (${CREDIT_CADENCE})), -- NULL = no crediting rhythm + creditWeekday INTEGER CHECK (creditWeekday IS NULL OR creditWeekday BETWEEN 0 AND 6), -- NULL = no weekly rhythm + creditDayOfMonth INTEGER CHECK (creditDayOfMonth IS NULL OR creditDayOfMonth BETWEEN 1 AND 31), -- NULL = no monthly rhythm + startsOn TEXT NOT NULL, -- calendar date + endsOn TEXT, -- NULL = running + compounding INTEGER NOT NULL CHECK (compounding IN (0,1)), -- 1 = each credit also earns over the credits before it + createdAt TEXT NOT NULL, + updatedAt TEXT, -- NULL = never changed since it was created + -- The three conditional rules that until now stood only in comments: a 'variable' agreement could + -- silently carry a principal AND a rate. All three counted on the backup: zero deviations. + CHECK (rateKind = 'fixed' OR (principalAmount IS NULL AND annualRate IS NULL)), + CHECK (creditWeekday IS NULL OR creditCadence = 'weekly'), + CHECK (creditDayOfMonth IS NULL OR creditCadence = 'monthly'), + CHECK (endsOn IS NULL OR endsOn >= startsOn), + -- Interest on interest needs a fixed principal in the payout asset: interest in another asset cannot + -- be added to it, and a balance-following principal already grows by every credit booked on it. + CHECK (compounding = 0 OR (rateKind = 'fixed' AND principalAmount IS NOT NULL AND principalAssetId = payoutAssetId)) +) STRICT; +CREATE INDEX IxInterestScheduleAccountId ON InterestSchedule(accountId); -- FK scan on account delete + query: agreements per account +CREATE INDEX IxInterestSchedulePrincipalAssetId ON InterestSchedule(principalAssetId); -- FK scan on asset delete +CREATE INDEX IxInterestSchedulePayoutAssetId ON InterestSchedule(payoutAssetId); -- FK scan on asset delete + +-- InterestPeriod: an INTEREST PERIOD within an agreement (the rate is fixed but changes per date). +-- +-- WHY ITS OWN TABLE AND NOT A SECOND AGREEMENT: a savings account with a changing rate is one agreement +-- with one principal and one rhythm; only the percentage shifts. Two agreements side by side would cut +-- the credits in two and make the forecast count twice. +-- +-- \`InterestSchedule.annualRate\` STAYS THE RATE FROM \`startsOn\`; these rows are the changes AFTER it. So +-- nothing changes for an agreement with one rate and no data migration is needed. The CHECK on startsOn +-- cannot see the start date of the agreement (that sits in another table), so that bound lives in the +-- data layer; what is here is what the table itself can guarantee. +CREATE TABLE InterestPeriod ( + id INTEGER PRIMARY KEY, + scheduleId INTEGER NOT NULL REFERENCES InterestSchedule(id) ON DELETE CASCADE, + startsOn TEXT NOT NULL, -- calendar date: this rate applies from this day; it runs to the next period or the end of the agreement + annualRate REAL NOT NULL CHECK (annualRate >= 0), + createdAt TEXT, -- NULL = row predates migration 85 + updatedAt TEXT, -- NULL = never changed since it was created + UNIQUE (scheduleId, startsOn) -- two rates on the same day is not a state +) STRICT; + +-- InterestCredit: the handled credits. 'due' is computed virtually; only the decisions are here. +CREATE TABLE InterestCredit ( + id INTEGER PRIMARY KEY, + scheduleId INTEGER NOT NULL REFERENCES InterestSchedule(id) ON DELETE CASCADE, + eventId INTEGER REFERENCES Event(id) ON DELETE CASCADE, -- NULL = not booked (dismissed); on rolling back an event the credit goes with it + status TEXT NOT NULL CHECK (status IN (${CREDIT_STATUS})), + periodStart TEXT NOT NULL, -- calendar date + periodEnd TEXT NOT NULL, -- calendar date + createdAt TEXT NOT NULL, + updatedAt TEXT, -- NULL = never changed since it was created + UNIQUE (scheduleId, periodEnd), -- one decision per period: makes booking and dismissing idempotent + CHECK (periodEnd >= periodStart) +) STRICT; +CREATE INDEX IxInterestCreditEventId ON InterestCredit(eventId); -- FK scan on event delete + +-- ═══ Notifications ═════════════════════════════════════════════════ +-- Notification: the high-water model. At most one active row per kind (dismissedAt NULL); highWater is +-- the monotone marker above which a new notification arises. +CREATE TABLE Notification ( + id INTEGER PRIMARY KEY, + count INTEGER NOT NULL CHECK (count >= 0), + highWater INTEGER NOT NULL CHECK (highWater >= 0), + kind TEXT NOT NULL CHECK (kind IN (${NOTIFICATION_KIND})), + dismissedAt TEXT, -- NULL = an active notification + createdAt TEXT NOT NULL -- sits AFTER dismissedAt: createdAt belongs last, even when the reading order feels different +) STRICT; +CREATE INDEX IxNotificationKind ON Notification(kind); -- query: looking up the active notification per kind + +-- ═══ Source matrix ═════════════════════════════════════════════════ +-- PriceSource: which provider delivers an asset per source choice (spot, history). rank 1..N is the +-- preference chain, rank NULL means explicitly excluded, no row means not filled in. One choice per asset +-- for every consumer (migration 97; it replaced ConsumerSource, which had a chain per consumer). +CREATE TABLE PriceSource ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT, -- RESTRICT: a provider still in a chain is not deletable + capability TEXT NOT NULL CHECK (capability IN (${SOURCE_CAPABILITY})), + rank INTEGER CHECK (rank IS NULL OR rank > 0), -- NULL = explicitly excluded + status TEXT NOT NULL CHECK (status IN (${SOURCE_STATUS})), + createdAt TEXT, -- NULL = row predates migration 85 + UNIQUE (assetId, capability, providerId), -- one row per cell per provider: underpins the idempotent seed + UNIQUE (assetId, capability, rank) -- one provider per rank within a chain +) STRICT; +CREATE INDEX IxPriceSourceProviderId ON PriceSource(providerId); -- FK scan on provider delete (RESTRICT must find the child rows); assetId leads both UNIQUEs, so it needs no index of its own + +-- ═══ Dashboard ═════════════════════════════════════════════════════ +-- DashboardProfile: a stored layout to switch between. The word 'profile' here is the stored layout; the +-- characterisation of the user is called 'persona' in shared/config.js, otherwise two concepts carry one +-- word. +CREATE TABLE DashboardProfile ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, -- NULL = not filled in + sortOrder INTEGER NOT NULL CHECK (sortOrder >= 0), -- the order in the picker; an ordering here, unlike with the widgets + isActive INTEGER NOT NULL CHECK (isActive IN (0,1)), + isSystem INTEGER NOT NULL CHECK (isSystem IN (0,1)), -- 1 = the system profile: not deletable, always a working dashboard + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + UNIQUE (searchName) -- two profiles with the same name is a mistake, not a wish +) STRICT; +-- At most one active profile. "Exactly one" is thereby NOT enforced (zero active profiles stays +-- possible) and cannot be, with an index or a CHECK: it is a statement about the whole table. That +-- invariant belongs in the data layer, with a test. +CREATE UNIQUE INDEX UxDashboardProfileActive ON DashboardProfile(isActive) WHERE isActive = 1; + +-- DashboardWidget: one widget instance within a profile. Deliberately NO sortOrder: gridX/gridY are +-- authoritative and a second ordering would drift from them. +-- +-- TWO DEVIATIONS FROM THE FIRST DDL, both because the shape that stood there could not express two +-- things: +-- +-- 1. \`spanHeight\` became nullable. The engine knows a state "height not measured yet" (on restore +-- default, a new widget, a width change) and wrote 0 for it. That 0 could not pass +-- \`CHECK (spanHeight > 0)\`, and a 0 in a grid measure is moreover a second meaning in a column where +-- every other value is a real measure. NULL says it properly. +-- 2. A column \`settings\` was added for the widget-specific parameters that are NOT a reference. Reason: +-- the clocks widget stores a city list and a checkbox per instance, and those fitted nowhere; on the +-- conversion that setting would have vanished silently. The criterion: a blob that is never queried +-- is storage, a blob the app REFERS through is a missed table. This one is queried by no query and +-- goes as a whole to one widget. +-- **\`assetId\` DOES stay a real column with an FK**, for exactly the stated reason: in JSON the +-- database guards nothing, and then a profile keeps a price widget pointing at a deleted asset. The +-- boundary is therefore: a parameter that points at another table is a column, the rest is storage. +-- And \`assetId\` is NOT also in \`settings\`: two places asserting the same is derived duplication. +CREATE TABLE DashboardWidget ( + id INTEGER PRIMARY KEY, + profileId INTEGER NOT NULL REFERENCES DashboardProfile(id) ON DELETE CASCADE, + assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, -- NULL = this widget does not hang on one asset; CASCADE so a deleted asset takes its price widget with it + instanceKey TEXT NOT NULL, -- the stable key of this instance within the profile + gridX INTEGER NOT NULL CHECK (gridX >= 0), + gridY INTEGER NOT NULL CHECK (gridY >= 0), + spanWidth INTEGER NOT NULL CHECK (spanWidth > 0), -- a width is chosen, so always known + spanHeight INTEGER CHECK (spanHeight IS NULL OR spanHeight > 0),-- NULL = not measured yet (a height is measured, not chosen) + type TEXT NOT NULL, -- DELIBERATELY NO CHECK: the widget list grows and a CHECK is immutable, so every new widget would cost a table rebuild. Validation in the data layer + settings TEXT, -- NULL = none; otherwise JSON with the parameters that are NOT a reference (clocks: cities/showExchanges). assetId is deliberately not in here + createdAt TEXT, -- NULL = row predates migration 85 + UNIQUE (profileId, instanceKey) -- one instance per key within a profile +) STRICT; +CREATE INDEX IxDashboardWidgetAssetId ON DashboardWidget(assetId); -- FK scan on asset delete + +-- ═══ System ════════════════════════════════════════════════════════ +-- Preference: named preferences (app settings and UI state). scope and key are the primary key; the +-- second of the three tables without a surrogate id. The registry in shared/config.js is the SSOT for +-- allowed AND initial values. Scope and key VALUES fall outside the naming convention: that is data. +CREATE TABLE Preference ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, -- the JSON serialisation of one value + createdAt TEXT, -- NULL = row predates migration 85 + PRIMARY KEY (scope, key) -- underpins setPref with ON CONFLICT +) STRICT; + +-- AiToolCall: the audit log of the MCP server. One row per tool call, a REFUSED one included: a refused +-- call in particular proves the boundary works, and the user must be able to read that. Append-only; the +-- data layer has no update and no delete path. +-- +-- WHAT IS DELIBERATELY ABSENT is the outgoing payload itself. Storing that in full would put the +-- sensitive composition on disk after all, only somewhere else, and this is precisely the file you want +-- to be able to show someone. What IS in it is enough to account: which tool, which fields, how many +-- characters and with what outcome. +-- +-- \`fieldsShared\` is the column that makes the promise checkable instead of promised: a JSON array with +-- the FIELD NAMES the translator let through, not their values. If a name is in there that should not be, +-- that can be read without opening the code. +-- +-- \`serverVersion\` SITS LAST AND NOT NEXT TO \`client\`, where it belongs by content. Reason: it came in +-- with migration 62 and SQLite can only add a column at the end. A table rebuild purely for the reading +-- order is not worth that price. +-- +-- WHY THIS COLUMN EXISTS, and it is not bookkeeping for its own sake. MCP over stdio means one server +-- process PER CLIENT, each with the code of the moment that client started. Two connected clients are +-- therefore two servers on this database, possibly with different versions. A process from before the +-- on/off gate (server/ai/gate.js) does not read that switch, and without this column that cannot be seen +-- afterwards: such a call stands as 'Uitgevoerd' in the log while the user thought the door was closed. +-- So NULL means something here: "handled by a server that did not know the switch yet". +-- +-- \`sessionId\` and \`client\` come from the MCP handshake and are free text without an FK: a session exists +-- only as long as the process lives and has no table. \`client\` is what the client says about itself (a +-- desktop app, an agent CLI), and that is exactly what the user wants to see in the UI now that the +-- server can be connected to an arbitrary client. +CREATE TABLE AiToolCall ( + id INTEGER PRIMARY KEY, + sessionId TEXT NOT NULL, + client TEXT, -- NULL = the client did not name itself at the handshake + tool TEXT NOT NULL, -- the name as the client called it; an UNKNOWN name is therefore logged too + arguments TEXT, -- NULL = without arguments; JSON, and meant for reading back the KEYS + fieldsShared TEXT, -- NULL = nothing went out; otherwise a JSON array of field names + charactersOut INTEGER CHECK (charactersOut IS NULL OR charactersOut >= 0), -- NULL = nothing sent + outcome TEXT NOT NULL CHECK (outcome IN (${AI_CALL_OUTCOME})), + detail TEXT, -- NULL = nothing special; on error/refused the reason in user language + occurredAt TEXT NOT NULL, + serverVersion TEXT -- NULL = handled by an MCP server from before migration 62; see above +) STRICT; +CREATE INDEX IxAiToolCallOccurredAt ON AiToolCall(occurredAt); -- query: the last N rows for the AI tab, newest first + +-- AiToolCallMessage: the conversation of one call. One row per MOMENT, and that is why it is a table and +-- not a couple of extra columns: the user wanted to see WHAT went over the line WHEN, and one row per +-- call cannot express that. +-- +-- THE OUTGOING TEXT IS HERE, and that does not contradict the head of AiToolCall above but refines it. +-- The argument there was that the sensitive composition would otherwise end up on disk after all; that +-- does not hold for this text, because it has already been through the translator. What lies here holds +-- handles and bucketed amounts and is therefore LESS sensitive than the database underneath. And without +-- this text \`fieldsShared\` is a claim; with it, it is the evidence. +-- +-- WHAT IS STILL ABSENT is the translation table (\`account:a1\` to which account). That mapping was not +-- part of the event, and an audit trail that supplements the file with what did not belong in it has +-- become a reconstruction. The way back runs through a secret outside the database. +-- +-- \`text\` IS NOT NULL AND MAY BE EMPTY, a deliberate distinction: a moment without text exists (a call +-- without arguments), but a moment whose content we do not know does not. The text is truncated at +-- CONFIG.AI.LIMITS.MAX_LOG_CHARS with a visible marker in it; truncating silently would let a half +-- answer read as a whole one. +-- +-- ON DELETE CASCADE is not a cleanup path here but a shape requirement: AiToolCall has no delete +-- function, so this rule cannot fire. RESTRICT would suggest a deletion is to be expected somewhere. +-- CASCADE says: this conversation exists ONLY with that call. +CREATE TABLE AiToolCallMessage ( + id INTEGER PRIMARY KEY, + callId INTEGER NOT NULL REFERENCES AiToolCall(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN (${AI_MESSAGE_KIND})), + text TEXT NOT NULL, -- empty is allowed; NULL is not (see above) + occurredAt TEXT NOT NULL +) STRICT; +CREATE INDEX IxAiToolCallMessageCallId ON AiToolCallMessage(callId); -- FK index + the query of the expander: the conversation of one call + +-- BusSignal: the signal bus. One row is one nudge: something changed, this was its name, that was when. +-- Contentless and idempotent. +-- +-- WHY THIS TABLE EXISTS. The data layer reports a change with \`emitChange(name)\`, and +-- \`server/data/_events.js\` collects those names in a Set in the memory of one process; the RPC layer +-- passes them along in the response. That works exactly as long as the browser made the request ITSELF. +-- With a second process (the MCP server) that no longer holds: it writes into a basket nobody looks at. +-- This table is where a nudge DOES cross a process boundary. +-- +-- \`name\` IS THE EVENT NAME THE BROWSER DISPATCHES ('providers-changed'), so not free text but what the +-- existing listeners already expect. No call site of \`emitChange\` changes because of this. +-- +-- THERE IS DELIBERATELY NO COLUMN FOR A PAYLOAD. As soon as a nudge carries an id or an amount, the three +-- properties the whole setup rests on lapse: merging is allowed, receiving twice is harmless, and +-- discarding is allowed. What carries meaning is not a nudge but a command, and that gets its own table +-- WITH a state. +-- +-- THIS TABLE IS CLEANED UP, unlike AiToolCall above. That is not an inconsistency but the difference +-- between a nudge and an accounting: a discarded nudge loses nothing, a discarded audit row is a gap in +-- the trail. +-- +-- NO INDEX, a choice with two halves. The read question ("everything with an id greater than N") runs +-- over the primary key, and in SQLite that IS the rowid; nothing to gain there. The cleanup DOES look at +-- \`occurredAt\` and therefore scans, and that is fine because this table stays small precisely BY that +-- cleanup: building an index to speed up the cleanup that keeps the table small costs write work on +-- EVERY insert to save a scan over a handful of rows. +-- +-- THE CURSOR IS THE ID AND NOT THE TIME, independent of the above: two nudges in the same millisecond +-- cannot be ordered by time. That has been repaired three times in this project (scan logs, the audit +-- log, the conversation log). The cleanup MAY work on time, because there the order is not the question +-- but the age. +CREATE TABLE BusSignal ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, -- the event name the browser dispatches + occurredAt TEXT NOT NULL -- only to be able to clean up and to read what was when +) STRICT; + +-- Command: the command inbox. One row is one command from outside the browser: now only navigation, +-- later also a write proposal from the AI. +-- +-- WHY THIS IS NOT A \`BusSignal\`. A nudge may be lost, may arrive twice and is cleaned up; a command may +-- do none of those three. It carries meaning (where should you go), it may fire only once, and it has an +-- outcome. That is a life cycle, and a life cycle belongs in its own table with a state. The bus reports +-- ONLY that something is ready. +-- +-- \`payload\` CARRIES THE MEANING, unlike BusSignal, which deliberately has no payload column. For +-- \`navigate\` it holds a target (route, tab, subtab), in the form of the target registry. JSON and not +-- columns per field: the second kind (a write proposal) carries something entirely different, and then +-- half the columns would always be empty. +-- +-- THE CLAIM MODEL SITS IN \`state\` + \`claimedBy\`, and it is there because two open tabs both poll. A +-- nudge may be picked up by both; a command that fires twice jumps your screen twice. The tab that claims +-- it first executes it; the other sees 'claimed' and leaves it. +-- +-- \`expiresAt\` COVERS TWO LIFETIMES WITH ONE COLUMN. A navigation command is volatile: with no tab open it +-- is pointless within a minute. A write proposal is durable and belongs on the action list; that will get +-- NULL. Only a command WITHOUT an expiry yields a card. +-- +-- NO INDEX: the same trade-off as with BusSignal. The only query is "what is open and not yet expired", +-- and this table stays small because handled rows are cleaned up. +CREATE TABLE Command ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN (${COMMAND_KIND})), + payload TEXT, -- NULL = no payload; otherwise JSON, different per kind + state TEXT NOT NULL CHECK (state IN (${COMMAND_STATE})), + claimedBy TEXT, -- NULL = not picked up yet; otherwise the key of the tab + claimedAt TEXT, + expiresAt TEXT, -- NULL = does not expire (a proposal on the action list) + detail TEXT, -- NULL = nothing special; otherwise the reason for rejected/expired + resolvedAt TEXT, -- NULL = not handled yet + createdAt TEXT NOT NULL -- last, as the column order prescribes +) STRICT; + +-- AiProposal: what an AI PROPOSES, waiting for a human. +-- +-- WHY ITS OWN TABLE AND NOT ReconcileProposal. That table is firmly typed on its own domain: accountId +-- and assetId are NOT NULL, there is an amount > 0, a direction in/out, and two CHECK enums with +-- reconcile values. A price-point proposal has no account, no direction and no quantity. Six constraints +-- would have to give way, and those are exactly the constraints that make a wrong proposal impossible +-- there. +-- +-- WHY ALSO NOT AS A THIRD Command.kind. That table is an inbox with a life cycle of seconds: claim, +-- expire, exactly one tab executes. A proposal is truth that stands until the user has an opinion about +-- it. One table with two life cycles means EVERY query has to keep watching expiresAt. +-- +-- NO GENERIC PAYLOAD, and that is the most important choice here. There is no payload blob with "run this +-- data-layer function": the card must show the concrete consequences in real values, and that is only +-- possible when the app knows what kind of proposal it is. Such a blob would also put one confirmation +-- over an unbounded write surface. So the columns are NAMED (assetId, fiatPrice and observedAt are called the +-- same as in PriceHistory, where they end up) and made required per kind with a CHECK. +-- +-- Adding a kind is therefore a visible decision: a row in ENUMS.AI_PROPOSAL_KIND, the columns it needs, a +-- CHECK branch, and a line in the card. That is more work than a blob and that is intended. +-- +-- ── SEVEN KINDS, AND THE PRICE OF THE RULE ABOVE IS PAID ── +-- \`account\` and \`event\` were added because one kind is not enough to propose anything meaningful: a +-- price point can only exist on an asset that already has bookings. With an account and an event a series +-- of proposals can build an administration from nothing, which is what test and demonstration data needs. +-- +-- COLUMNS ADDED AND NO BLOB, because the reason above holds in full: the card shows the concrete +-- consequences in real values, and that is only possible when the app knows the fields. What IS reused +-- are \`assetId\`, \`fiatPrice\` and \`observedAt\`: on an event those are the price per unit and the moment, the +-- same meaning as on a price point. What got a column of its own is what has a meaning of its own. +-- +-- \`eventType\` AND \`accountKind\` ARE NOT CALLED \`type\` AND \`kind\`, and that is not taste: \`kind\` is +-- already the kind of the PROPOSAL here. Two things called \`kind\` in one row makes every query about it +-- ambiguous. +-- +-- \`accountId\` IS A FOURTH ACCOUNT COLUMN AND NOT A REUSE OF \`toAccountId\`, the only real choice in that +-- extension. Those two existing columns carry a DIRECTION (where does it come from, where does it go); +-- this one carries a POINTER (which row is this proposal about). Were the proposal to ride on +-- \`toAccountId\`, that column would mean two things and every query would have to keep watching the kind +-- to know which of the two. +-- +-- THE CHECK BRANCHES PER KIND ARE NOT THE SAME AS THOSE OF THE DESTINATION, and that is deliberately +-- stricter: \`Event.fiatPrice\` may be 0, \`fiatPrice\` here may not (that column also carries the rate of a price +-- point, and a rate of zero is an empty row there). A purchase for zero euro is therefore not proposable; +-- that is a gift and not a trade, and it should be booked by hand. +CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, + assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, -- NULL = a kind that points at no asset + accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, -- NULL = a kind that POINTS AT no account; see below + fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, -- NULL = no origin (buy, interest, and every kind without an account) + toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, -- NULL = no destination (sell) + -- ── WHAT AROSE OUT OF THIS PROPOSAL ── + -- Of every row it must be readable how it came to be. That was impossible before: an approved proposal + -- landed as an ordinary row and this table only knew THAT it was accepted, not WHAT it became. The + -- chain could be walked in neither direction. + -- + -- NAMED COLUMNS AND NOT A POLYMORPHIC PAIR (tableName plus rowId). The same choice as with the kind + -- fields below: the app should know what is there. It also yields a real FK, and therefore ON DELETE + -- SET NULL: if the row disappears, the reference disappears and the proposal stays as history. CASCADE + -- would erase the proposal, exactly the trail this is meant to keep. + -- + -- THEY SIT IN THE FK BLOCK AND NOT LAST, and that was a correction: the first version added them with + -- ALTER TABLE (three lines instead of a rebuild) and that always puts a column last. That broke the + -- column-order rule, and that rule is worth it: references belong as a block directly behind id. So + -- the migration is a rebuild after all. + -- + -- THERE IS NO createdPricePointId, AND THAT IS A DECISION. A price point goes through + -- upsertPriceHistory, and that does not necessarily create a row: if a point already exists on that + -- day from that source, it is updated. A column named "this proposal made this row" would assert + -- something untrue there, and a half-true trail is worse than no trail. + createdEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL, + createdAccountId INTEGER REFERENCES Account(id) ON DELETE SET NULL, + createdValuationId INTEGER REFERENCES Valuation(id) ON DELETE SET NULL, + kind TEXT NOT NULL CHECK (kind IN (${AI_PROPOSAL_KIND})), + name TEXT, -- NULL = a kind that names nothing; otherwise the name of the new row + accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN (${ACCOUNT_KIND})), + isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)), + eventType TEXT CHECK (eventType IS NULL OR eventType IN (${LEDGER_EVENT_TYPE})), -- no reservation: the AI proposes bookings only + amount INTEGER CHECK (amount IS NULL OR amount > 0), -- smallest unit, like Event.amount + fiatPrice REAL CHECK (fiatPrice IS NULL OR fiatPrice > 0), -- NULL = a kind without an amount + -- The cost of a trade, in euros. NULL = no cost recorded, exactly as with Event.fiatFee. + fiatFee REAL CHECK (fiatFee IS NULL OR fiatFee >= 0), + -- The cost in the asset itself, smallest unit, on top of amount, as Event.unitFee (migration 125, plan + -- Bewijzen B6). The ledger subtracts it on a sale and on an outgoing transfer; without it an AI-proposed + -- send left the balance too high by the fee. See the CHECK below for where it may stand. + unitFee INTEGER CHECK (unitFee IS NULL OR unitFee >= 0), + -- The position of a balance proposal, in the smallest unit. NULL = a kind without a balance. ITS OWN + -- COLUMN AND NOT amount, and that is the whole reason it exists: amount requires > 0 because a booking + -- of zero is an error, while a BALANCE of zero is meaningful ("this account is empty now", and + -- Valuation.balance therefore allows it too). Giving the same column two meanings would require the + -- CHECK to read differently per kind. + balance INTEGER CHECK (balance IS NULL OR balance >= 0), + observedAt TEXT, -- NULL = a kind without a moment + -- The preference kind. Three columns named exactly as where they end up (\`Preference.scope\`, \`.key\`, + -- \`.value\`), just as \`assetId\`, \`fiatPrice\` and \`observedAt\` do for the other kinds. \`value\` carries JSON, + -- because that is what that column in Preference carries; the registry in shared/config.js validates + -- at execution. + -- + -- THIS IS THE FIRST KIND THAT CREATES NOTHING BUT ADJUSTS SOMETHING, and that is exactly why the bound + -- sits in the code: CONFIG.AI.PROPOSABLE_PREF_SCOPES excludes the ai scope. Without that rule a + -- proposal can widen its own rights or switch the translator off, and nobody reads a card saying "set a + -- preference" as "give me more rights". + scope TEXT, -- NULL = a kind that sets no preference + key TEXT, + value TEXT, + -- The unit of account of a proposed portfolio (migration 108). Its own column and not a + -- reuse of \`value\`: that carries JSON for a preference, and this is fixed once the portfolio exists. + currency TEXT CHECK (currency IS NULL OR currency GLOB '[A-Z][A-Z][A-Z]'), -- NULL = a kind that creates no portfolio + note TEXT, -- NULL = no note; otherwise WHY the AI proposes this + status TEXT NOT NULL CHECK (status IN (${AI_PROPOSAL_STATUS})), + detail TEXT, -- NULL = nothing special; otherwise why it failed + resolvedAt TEXT, -- NULL = not handled yet + createdAt TEXT NOT NULL, -- last, as the column order prescribes + -- Per kind the fields it NEEDS. Without this CHECK a half proposal is a valid row, and then it only + -- topples at execution time: precisely too late. + CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND fiatPrice IS NOT NULL AND observedAt IS NOT NULL)), + CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)), + -- An event without an account is impossible: nothing would change balance. WHICH of the two it is + -- depends on the kind, and the data layer knows that; here it only says there must be one. + CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL + AND observedAt IS NOT NULL + AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))), + -- The same rule as in Event: a trade without a price is no trade. + CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR fiatPrice IS NOT NULL), + -- A fee in units only where the ledger reads one: on a sale or a transfer. On a purchase or interest it + -- would be stored and count nowhere. + CHECK (unitFee IS NULL OR (kind = 'event' AND eventType IN ('sell','transfer'))), + -- A deletion proposal points at exactly one row. See the paragraph about accountId above. + CHECK (kind <> 'account_delete' OR accountId IS NOT NULL), + -- A balance belongs to ONE account, a hard requirement here while Valuation.accountId allows NULL (= the + -- whole portfolio). Reason: valuationReconcilable returns false without an account, so a + -- portfolio-wide anchor is one no check ever passes. A balance the AI proposes should always be + -- checkable. + CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL + AND balance IS NOT NULL AND observedAt IS NOT NULL)), + -- And the other way: only a balance proposal carries a balance. Without this rule a booking WITH a + -- balance is a valid row, and then there is a number in it that nobody executes any more. + CHECK (kind = 'valuation' OR balance IS NULL), + -- A portfolio proposal carries a name and a currency, both required: a portfolio without a name is a + -- directory without a name, and one without a currency cannot be set up. The name reuses the column of + -- the account proposal; what it may NOT carry sits in the data layer, because that is a list of fields + -- and not a shape. Only this kind carries a currency. + CHECK (kind <> 'portfolio' OR (name IS NOT NULL AND currency IS NOT NULL)), + CHECK (kind = 'portfolio' OR currency IS NULL), + -- A preference proposal carries all three fields or it is no preference proposal. And the other way + -- with it, as with \`balance\`: without that second rule a booking with a scope is a valid row, and then + -- there is a setting in it that nobody executes any more. + CHECK (kind <> 'preference' OR (scope IS NOT NULL AND key IS NOT NULL AND value IS NOT NULL)), + -- A source-choice proposal (migration 98) reuses those three: the choice, the provider key, the action. + -- A provider KEY and not an id, because the key is what the AI reads and it is stable. + CHECK (kind <> 'source_choice' OR (assetId IS NOT NULL AND scope IN (${SOURCE_CAPABILITY}) + AND key IS NOT NULL AND value IN (${SOURCE_CHOICE_ACTION}))), + CHECK (kind IN ('preference','source_choice') OR (scope IS NULL AND key IS NULL AND value IS NULL)) +) STRICT; +CREATE INDEX IxAiProposalStatus ON AiProposal(status); -- query: the open list for the card +CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId); -- FK scan on asset delete +CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId); -- FK scan on account delete +CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId); -- idem +CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId); -- idem +-- The three origin columns. They carry two loads: the FK scan on a delete (every FK should have an index, +-- and baseline.test.mjs checks that), and the question they exist for: "which proposal made this row" is +-- a lookup ON this column. +CREATE INDEX IxAiProposalCreatedEventId ON AiProposal(createdEventId); +CREATE INDEX IxAiProposalCreatedAccountId ON AiProposal(createdAccountId); +CREATE INDEX IxAiProposalCreatedValuationId ON AiProposal(createdValuationId); + +-- Schema: the version bookkeeping of the migration runner. Was called schema_meta, and 'meta' is an +-- abbreviation of metadata, exactly the word the architecture rules forbid as a column name. +-- The table carries one row with one number, and \`Schema.version\` says that in full. +-- Two more things are new. The id column with CHECK (id = 1): with it there cannot be more than one +-- version row, and readVersion() need not guess which one it takes. And the column is called \`version\` +-- and not \`schemaVersion\`: the latter repeats the name of its own table. +CREATE TABLE Schema ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL CHECK (version > 0) +) STRICT; + +-- Portfolio: the facts of THIS administration that hold for its whole life. One row, like \`Schema\`. +-- +-- \`baseCurrency\` IS THE UNIT OF ACCOUNT: every stored amount (\`Event.fiatPrice\`, \`Valuation.fiatRate\`, +-- \`PriceHistory.fiatPrice\`, ...) is in this currency. An ISO 4217 code; which codes the app offers is a list in +-- the config, the CHECK only guards the shape, so extending that list needs no rebuild. +-- +-- FIXED AFTER CREATION, and the two triggers make that a guard instead of an agreement. Changing the unit +-- of account would mean converting every stored amount at the rate of its own date, and that is no +-- UPDATE of one row. DELETE is refused for the same reason: without the row the administration has no +-- unit of account. +CREATE TABLE Portfolio ( + id INTEGER PRIMARY KEY CHECK (id = 1), + baseCurrency TEXT NOT NULL CHECK (baseCurrency GLOB '[A-Z][A-Z][A-Z]'), + createdAt TEXT -- NULL = portfolio predates migration 104 +) STRICT; +CREATE TRIGGER TrPortfolioUpdate BEFORE UPDATE ON Portfolio +BEGIN SELECT RAISE(ABORT, 'Portfolio is fixed after creation'); END; +CREATE TRIGGER TrPortfolioDelete BEFORE DELETE ON Portfolio +BEGIN SELECT RAISE(ABORT, 'Portfolio is fixed after creation'); END; + +-- ChangeLog: the change log. One row is one change to one row of the administration. Which tables take +-- part sits in \`CONFIG.DB_HISTORY.TRACKED\`, with a test saying a new table requires a choice. +-- +-- NO INSERT ROW, a decision of the user: a new record has its own \`createdAt\` and that never changes +-- again. That saves one row per row in the whole administration. It is also why migrations 85 and 86 had +-- to come first: that reasoning only has a floor if EVERY participating table has that timestamp too. +-- +-- \`tableName\` AND \`rowId\` WITHOUT AN FK, and that is the price of one log instead of eleven. An FK +-- cannot be polymorphic. What it costs is known: a DELETE row points at a row that no longer exists, and +-- that is exactly the intention here: the answer to "where did that booking go" is precisely that row. +-- +-- \`changes\` IS A JSON BLOB, the exception in this project (generic fields are forbidden). The reason +-- \`AiProposal\` has none does not hold here: that is a proposal the app must be able to execute, this is a +-- log a human reads. Only the CHANGED fields go in and not the whole row, otherwise the log is a second +-- database and sensitive values sit on disk twice. One exception: on a delete the whole row goes in, +-- because there is no "new" there. +-- +-- APPEND-ONLY, like \`AiToolCall\`: no update path, no delete path, and not in +-- \`CONFIG.DB_DELETABLE_TABLES\`. A log from which one row can be clicked away accounts for nothing. +CREATE TABLE ChangeLog ( + id INTEGER PRIMARY KEY, + tableName TEXT NOT NULL, -- polymorphic: no FK possible + rowId INTEGER NOT NULL, + operation TEXT NOT NULL CHECK (operation IN (${CHANGE_OPERATION})), + origin TEXT NOT NULL CHECK (origin IN (${CHANGE_ORIGIN})), + originId INTEGER, -- NULL = that origin has no row of its own + changes TEXT NOT NULL, -- JSON: { field: [old, new] }, on delete the whole row + occurredAt TEXT NOT NULL +) STRICT; +CREATE INDEX IxChangeLogTableNameRowId ON ChangeLog(tableName, rowId); -- the question: what happened to THIS record +CREATE INDEX IxChangeLogOccurredAt ON ChangeLog(occurredAt); -- cleanup, and the timeline per period + +-- HygieneAcceptance: a finding of the health report you have looked at and judged to be right. +-- +-- WHY THIS IS A TABLE AND NOT A COLUMN. A valuation gap is accepted on the row itself +-- (\`Valuation.gapAcceptedAt\`), and that works because that finding IS a row. A hygiene finding is not: +-- it is a kind plus whatever the query grouped on, and for half the kinds that is an aggregate with no +-- row of its own (duplicate bookings, gaps in a price history). There is nothing to hang a column on. +-- +-- \`fingerprint\` IS THE FINDING, CANONICALLY. It is a hash over the kind plus the finding's \`row\` and +-- \`counts\` exactly as the report produced them, so the acceptance matches again next time only when the +-- situation is IDENTICAL. That the counts are in it is the deciding choice: accept thirteen unlinked +-- movements and a fourteenth appears, and the finding returns with all fourteen. You judged a situation, +-- not a subject. +-- +-- \`kind\` AND \`summary\` ARE THERE TO BE READ, not to match on. Without them a list of acceptances is a +-- page of hashes, and then there is no way back from "I no longer agree" to which finding it was. +-- +-- NO FK ANYWHERE, for the same reason as \`ChangeLog\`: a finding can point at an event, an asset, a +-- wallet movement or at nothing at all, and an FK cannot be polymorphic. +CREATE TABLE HygieneAcceptance ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL, -- the finding kind, as \`hygieneReport\` names it + fingerprint TEXT NOT NULL, -- hash over kind + row + counts + summary TEXT NOT NULL, -- readable, so the list of acceptances says something + note TEXT, -- NULL = accepted without a reason + createdAt TEXT NOT NULL -- and NOT \`acceptedAt\`: the row exists BECAUSE it was accepted, so that would be two names for one moment +) STRICT; +CREATE UNIQUE INDEX UxHygieneAcceptanceFingerprint ON HygieneAcceptance(fingerprint); -- accepting twice is one row +CREATE INDEX IxHygieneAcceptanceKind ON HygieneAcceptance(kind); -- query: the acceptances of one kind + +-- IgnoredPriceGap: a stretch of days without a price that the user chose to leave: +-- no source has it, and the manual price is not worth entering. A RANGE and not a fingerprint +-- like HygieneAcceptance, because a gap at the end of the history grows a day every day: a fingerprint +-- would change daily and bring it back. \`listPriceGaps\` counts these days as covered. +CREATE TABLE IgnoredPriceGap ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + fromDay TEXT NOT NULL, -- YYYY-MM-DD, the first day left alone + untilDay TEXT NOT NULL, -- YYYY-MM-DD, the last one + createdAt TEXT NOT NULL, -- and no \`ignoredAt\`: the row exists BECAUSE it was ignored + CHECK (fromDay <= untilDay) +) STRICT; +CREATE INDEX IxIgnoredPriceGapAssetId ON IgnoredPriceGap(assetId); -- query: the ignored ranges of one asset + +-- PriceAbsence: a day a per-date source answered without a price (a holiday, a date before its history), +-- so the close round does not spend quota on it again. Per source, because +-- another source may have that day. Deleting a row makes the day be asked again. +CREATE TABLE PriceAbsence ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + day TEXT NOT NULL, -- YYYY-MM-DD + createdAt TEXT NOT NULL +) STRICT; +CREATE UNIQUE INDEX UxPriceAbsenceAssetIdProviderIdDay ON PriceAbsence(assetId, providerId, day); -- once per day; also the lookup per asset +CREATE INDEX IxPriceAbsenceProviderId ON PriceAbsence(providerId); -- FK: deleting a provider + +-- AssetNote: the user's own notes on one asset (26-09-2026), shown in the detail dialogs of Vermogen and +-- Prijsgeschiedenis alike. ONE TEXT PER ASSET and not a row per line: the dialog offers one free field +-- that can be edited anywhere; a stamped line is only a quick way to add to it. A table of its own and +-- not a column on Asset, so Asset needs no rebuild. Empty is no row. +CREATE TABLE AssetNote ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + body TEXT NOT NULL, -- the text; not \`note\`, which would repeat the table name + createdAt TEXT NOT NULL -- no \`updatedAt\`: the table is not in the change log +) STRICT; +CREATE UNIQUE INDEX UxAssetNoteAssetId ON AssetNote(assetId); -- one note per asset +`; diff --git a/server/db/seeds.js b/server/db/seeds.js new file mode 100644 index 0000000..5333933 --- /dev/null +++ b/server/db/seeds.js @@ -0,0 +1,189 @@ +// Generated, do not edit. sha256:52135637bc4779b2b5f755ec50fedf41d2fa7b65d90a35f911c4e68f0b093874 +import { CONFIG, PREFERENCES, SOURCE_CAPABILITIES, SOURCE_ENDPOINTS, DASHBOARD } from '../../shared/config.js'; +import { toSearchName } from '../../shared/search-name.js'; +import { run, one, all, lastId } from './conn.mjs'; + +function basisMunt() { + const r = one(`SELECT baseCurrency FROM Portfolio WHERE id = 1`); + if (!r) throw new Error('Seeds need the Portfolio row: write the unit of account first.'); + return r.baseCurrency; +} + +function fiatAssets(basis) { + const codes = [...new Set([basis, 'EUR', 'USD'])]; + const namen = new Intl.DisplayNames(['nl-NL'], { type: 'currency' }); + return codes.map(code => { + const naam = namen.of(code); + const decimals = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: code }).resolvedOptions().maximumFractionDigits; + return { symbol: code, name: naam.charAt(0).toUpperCase() + naam.slice(1), kind: 'fiat', unit: code, + subunit: code === 'EUR' || code === 'USD' ? 'cent' : null, decimals, displayUnit: 'unit' }; + }); +} + +function startBron(symbol, basis) { + if (symbol === basis) return 'fixed'; + if (symbol === 'EUR' || symbol === 'USD') return 'frankfurter'; + return { BTC: 'mempool', ETH: 'bitvavo', XAU: 'goldapi', XAG: 'goldapi' }[symbol] ?? null; +} + +export function seedAssets() { + for (const a of fiatAssets(basisMunt())) { + if (one(`SELECT 1 AS x FROM Asset WHERE symbol = ?`, [a.symbol])) continue; + run( + `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES (?,?,?,?,?,?,?,?)`, + [a.symbol, a.name, toSearchName(a.name), a.kind, a.unit, a.subunit, a.decimals, a.displayUnit] + ); + } +} + +const SEED_METAL_PRODUCTS = [ + { symbol: 'XAU', name: 'Krugerrand 1 oz', form: 'coin', grams: CONFIG.METALS.GRAMS_PER_TROY_OUNCE }, + { symbol: 'XAU', name: 'Maple Leaf 1 oz', form: 'coin', grams: CONFIG.METALS.GRAMS_PER_TROY_OUNCE }, + { symbol: 'XAU', name: 'Wiener Philharmoniker 1 oz', form: 'coin', grams: CONFIG.METALS.GRAMS_PER_TROY_OUNCE }, + { symbol: 'XAU', name: 'Baar 1 g', form: 'bar', grams: 1 }, + { symbol: 'XAU', name: 'Baar 5 g', form: 'bar', grams: 5 }, + { symbol: 'XAU', name: 'Baar 10 g', form: 'bar', grams: 10 }, + { symbol: 'XAU', name: 'Baar 100 g', form: 'bar', grams: 100 }, + { symbol: 'XAU', name: 'Baar 1 kg', form: 'bar', grams: 1000 }, + { symbol: 'XAG', name: 'Maple Leaf 1 oz', form: 'coin', grams: CONFIG.METALS.GRAMS_PER_TROY_OUNCE }, + { symbol: 'XAG', name: 'Wiener Philharmoniker 1 oz', form: 'coin', grams: CONFIG.METALS.GRAMS_PER_TROY_OUNCE }, + { symbol: 'XAG', name: 'Baar 100 g', form: 'bar', grams: 100 }, + { symbol: 'XAG', name: 'Baar 1 kg', form: 'bar', grams: 1000 }, +]; +export function seedMetalProducts() { + const factor = Math.pow(10, CONFIG.METALS.BASE_DECIMALS); + for (const p of SEED_METAL_PRODUCTS) { + const a = one(`SELECT id FROM Asset WHERE symbol = ?`, [p.symbol]); + if (!a) continue; + if (one(`SELECT 1 AS x FROM MetalProduct WHERE assetId = ? AND name = ?`, [a.id, p.name])) continue; + run(`INSERT INTO MetalProduct (assetId, name, searchName, form, fineGrams) VALUES (?,?,?,?,?)`, + [a.id, p.name, toSearchName(p.name), p.form, Math.round(p.grams * factor)]); + } +} + +const SEED_CONNECTIONS = [ + { kind: 'btc_explorer', name: 'mempool.space (publiek)', url: CONFIG.PRICE_SOURCES.MEMPOOL_PUBLIC_BASE, isActive: 1, isSystem: 1 }, +]; +export function seedConnections() { + const now = new Date().toISOString(); + for (const c of SEED_CONNECTIONS) { + if (one(`SELECT 1 AS x FROM Connection WHERE kind = ? AND name = ?`, [c.kind, c.name])) continue; + const hasActive = !!one(`SELECT 1 AS x FROM Connection WHERE kind = ? AND isActive = 1`, [c.kind]); + run(`INSERT INTO Connection (name, searchName, url, kind, isActive, isSystem, lastStatus, lastCheckedAt, createdAt, updatedAt) + VALUES (?,?,?,?,?,?,NULL,NULL,?,?)`, + [c.name, toSearchName(c.name), c.url, c.kind, hasActive ? 0 : c.isActive, c.isSystem, now, now]); + } +} + +function seedProviderList(basis) { + return [ + { key: 'manual', name: 'Handmatig', baseUrl: '', authenticationKind: 'none', authenticationRequired: 0, isSystem: true, + authenticationHeaderName: null, authenticationParameterName: null, hmac: null, + endpoints: [], symbols: [], coverage: [], outputs: [] }, + + { key: CONFIG.MARKET_SERVICE.PROVIDER_KEY, name: 'Atreides Services', baseUrl: '', authenticationKind: 'none', + authenticationRequired: 0, isSystem: true, authenticationHeaderName: null, authenticationParameterName: null, + hmac: null, endpoints: [], symbols: [], coverage: [], outputs: [] }, + + { key: 'fixed', name: 'Vaste koers', baseUrl: '', authenticationKind: 'none', authenticationRequired: 0, isSystem: true, + authenticationHeaderName: null, authenticationParameterName: null, hmac: null, + fixedRate: { fiatPrice: 1 }, + endpoints: [ + { capability: 'spot', method: 'GET', pathTemplate: '', queryTemplate: null }, + { capability: 'history_point', method: 'GET', pathTemplate: '', queryTemplate: null }, + ], + symbols: [], coverage: [basis], outputs: [] }, + ]; +} + +export function seedProviders() { + for (const p of seedProviderList(basisMunt())) { + if (one(`SELECT 1 AS x FROM Provider WHERE key = ?`, [p.key])) continue; + const isActive = p.authenticationRequired ? 0 : 1; + run(`INSERT INTO Provider + (key, name, searchName, baseUrl, authenticationHeaderName, authenticationParameterName, + authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES (?,?,?,?,?,?,?,?,?,1,?,?)`, + [p.key, p.name, toSearchName(p.name), p.baseUrl, p.authenticationHeaderName, p.authenticationParameterName, + p.authenticationKind, p.authenticationRequired, isActive, p.isSystem ? 1 : 0, new Date().toISOString()]); + const pid = lastId(); + for (const symbool of p.coverage || []) { + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?,?)`, [pid, symbool]); + } + if (p.fixedRate) { + run(`INSERT INTO ProviderFixedRate (providerId, fiatPrice) VALUES (?,?)`, [pid, p.fixedRate.fiatPrice]); + } + if (p.callsPerMonth) { + run(`INSERT INTO ProviderCallLimit (providerId, callsPerMonth, createdAt) VALUES (?,?,?)`, + [pid, p.callsPerMonth, new Date().toISOString()]); + } + if (p.hmac) { + const h = p.hmac; + run(`INSERT INTO ProviderAuthenticationHmac + (providerId, messageParts, hashAlgorithm, encoding, keyHeader, signatureHeader, timestampHeader, windowHeader, windowMs) + VALUES (?,?,?,?,?,?,?,?,?)`, + [pid, h.messageParts, h.hashAlgorithm, h.encoding, h.keyHeader, h.signatureHeader, h.timestampHeader, h.windowHeader, h.windowMs]); + } + for (const e of p.endpoints) + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate, quoteCurrency) VALUES (?,?,?,?,?,?)`, + [pid, e.capability, e.method, e.pathTemplate, e.queryTemplate, e.quoteCurrency ?? null]); + for (const s of p.symbols) + run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, isCatchAll) VALUES (?,?,?,?,?)`, + [pid, s.isCatchAll ? null : s.symbol, s.externalId, s.quoteUnit ?? null, s.isCatchAll ? 1 : 0]); + for (const o of p.outputs) + run(`INSERT INTO ProviderOutputMap (providerId, capability, internalField, jsonPath, transform) VALUES (?,?,?,?,?)`, + [pid, o.capability, o.internalField, o.jsonPath, o.transform]); + } +} + +export function seedPriceSource(assetId, status) { + const assets = assetId + ? all(`SELECT id, symbol FROM Asset WHERE id = ?`, [assetId]) + : all(`SELECT id, symbol FROM Asset`); + const basis = basisMunt(); + for (const asset of assets) { + const srcKey = startBron(asset.symbol, basis); + if (!srcKey) continue; + const provider = one(`SELECT id FROM Provider WHERE key = ?`, [srcKey]); + if (!provider) continue; + for (const capability of SOURCE_CAPABILITIES) { + if (one(`SELECT 1 AS x FROM PriceSource WHERE assetId = ? AND capability = ?`, + [asset.id, capability])) continue; + const eindpunten = SOURCE_ENDPOINTS[capability]; + if (!one(`SELECT 1 AS x FROM ProviderEndpoint WHERE providerId = ? AND capability IN (${eindpunten.map(() => '?').join(',')})`, + [provider.id, ...eindpunten])) continue; + run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) VALUES (?,?,?,1,?)`, + [asset.id, capability, provider.id, status]); + } + } +} + +export function seedDashboardProfile() { + if (one(`SELECT 1 AS x FROM DashboardProfile WHERE isSystem = 1`)) return false; + const now = new Date().toISOString(); + const hasActive = !!one(`SELECT 1 AS x FROM DashboardProfile WHERE isActive = 1`); + run(`INSERT INTO DashboardProfile (name, searchName, description, sortOrder, isActive, isSystem, createdAt, updatedAt) + VALUES (?,?,NULL,0,?,1,?,?)`, + ['Standaard', toSearchName('Standaard'), hasActive ? 0 : 1, now, now]); + const profileId = lastId(); + for (const w of DASHBOARD.DEFAULT_LAYOUT) { + const def = DASHBOARD.WIDGETS[w.type]; + if (!def) continue; + run(`INSERT INTO DashboardWidget (profileId, assetId, instanceKey, gridX, gridY, spanWidth, spanHeight, type, settings) + VALUES (?,NULL,?,?,0,?,NULL,?,NULL)`, [profileId, w.instId, w.x || 0, def.defaultSpan, w.type]); + } + return true; +} + +const FRESH_VALUES = Object.freeze({ app: Object.freeze({ market_spot: 'first' }) }); + +export function seedPreferences() { + for (const [scope, keys] of Object.entries(PREFERENCES)) { + for (const [key, spec] of Object.entries(keys)) { + if (spec.secret) continue; + run(`INSERT INTO Preference (scope, key, value) VALUES (?,?,?)`, + [scope, key, JSON.stringify(FRESH_VALUES[scope]?.[key] ?? spec.initial)]); + } + } +} diff --git a/server/edition.mjs b/server/edition.mjs new file mode 100644 index 0000000..5e8c66f --- /dev/null +++ b/server/edition.mjs @@ -0,0 +1 @@ +// Generated, do not edit. sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 diff --git a/server/free-license.json b/server/free-license.json new file mode 100644 index 0000000..76ab3cc --- /dev/null +++ b/server/free-license.json @@ -0,0 +1,11 @@ +{ + "format": 1, + "license": { + "buyer": "72304ea4d273d6c685aaa76342975e7a001190c9f15369ed3d3b303b79c8c74c", + "level": "free", + "issued": "2026-10-27", + "majorFrom": 1, + "majorTo": "unlimited" + }, + "signature": "29a3844dd0e6dbfc4a0dbe451e223797935f8db4acd1bb5ccc21d95fe0eff77cfc3134a4f7a00e9e09c53a805b6b6a90efd69942643b2dd0db77a09b7d9d660f" +} diff --git a/server/index.mjs b/server/index.mjs new file mode 100644 index 0000000..124a32e --- /dev/null +++ b/server/index.mjs @@ -0,0 +1,78 @@ +// Generated, do not edit. sha256:b9a3a6b8f1e1fdbee54a4d9e67411a1659c1ea24437a706bbf1a46f443c3b26d +import { createServer as createHttpServer } from 'node:http'; +import { createServer as createHttpsServer } from 'node:https'; +import { spawn } from 'node:child_process'; +import { createRequestListener, CLIENT } from './app.mjs'; +import { loadCredentials, loadTlsOptions, resolveBinding } from './auth.mjs'; +import { initDatabase, one } from './db.js'; +import { getProvider } from './data/providers.js'; +import { moveProviderKeys } from './keys.mjs'; +import { prepareLayout } from './portfolios.mjs'; +import { licenseState, appVersion, hasFeature } from './license.mjs'; +import { getPref } from './data/preferences.js'; +import { adoptInstallLocale } from './installation.mjs'; +import { services } from './registers.mjs'; +import './edition.mjs'; + +const PORT = Number(process.env.PORT) || 8080; + +function openInBrowser(url) { + const [cmd, args] = process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] + : process.platform === 'darwin' ? ['open', [url]] + : ['xdg-open', [url]]; + try { + spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); + } catch (e) { + console.error('[assetapp] browser openen mislukt:', e.message); + } +} + +let credentials, tls, binding, dbRegel, portefeuilleRegel; +try { + portefeuilleRegel = prepareLayout().regel; + credentials = await loadCredentials(); + tls = await loadTlsOptions(); + binding = resolveBinding({ tls: Boolean(tls), credentials, host: process.env.ASSETAPP_HOST || null }); + dbRegel = initDatabase(); + if (!getProvider('eulerpool_metal') && getProvider('eulerpool')) await moveProviderKeys('eulerpool_metal', 'eulerpool'); + const oldLocale = one(`SELECT value FROM Preference WHERE scope = 'app' AND key = 'locale'`); + adoptInstallLocale(oldLocale ? JSON.parse(oldLocale.value) : undefined); +} catch (e) { + console.error(`[assetapp] kan niet starten: ${e.message}`); + process.exit(1); +} + +if (process.channel) { + process.on('disconnect', () => { + console.log('[assetapp] de supervisor is weg; de server stopt.'); + process.exit(0); + }); +} + +const server = tls + ? createHttpsServer(tls, createRequestListener({ credentials, secure: true })) + : createHttpServer(createRequestListener({ credentials, secure: false })); + +server.listen(PORT, binding.host, () => { + const schema = tls ? 'https' : 'http'; + const url = `${schema}://localhost:${PORT}`; + console.log(`[assetapp] serveert ${CLIENT} op ${url}`); + const bron = credentials ? (process.env.ASSETAPP_PASSWORD ? 'omgeving' : 'portefeuille') : null; + console.log(`[assetapp] TLS: ${tls ? 'aan' : 'uit'} | toegangscontrole: ${credentials ? `aan (${bron})` : 'uit'}` + + ` | luistert op: ${binding.host}`); + console.log(`[assetapp] ${portefeuilleRegel}`); + console.log(`[assetapp] ${dbRegel}`); + const lic = licenseState(); + console.log(`[assetapp] licentie: ${lic.level === 'full' ? 'volledig' : 'gratis stand'}` + + ` (${lic.why}) | versie: ${appVersion()}`); + if (binding.waarschuwing) console.warn(`[assetapp] LET OP: ${binding.waarschuwing}`); + else if (binding.host === '127.0.0.1') { + console.log('[assetapp] Alleen bereikbaar vanaf deze machine. Van een ander apparaat erbij kunnen' + + ' vergt TLS én inloggegevens.'); + } + if (process.env.ASSETAPP_OPEN === '1') { + const kiosk = services.get('kiosk'); + kiosk?.forget(); + if (!(kiosk && hasFeature('kiosk') && getPref('app', 'kiosk') && kiosk.start(url))) openInBrowser(url); + } +}); diff --git a/server/installation.mjs b/server/installation.mjs new file mode 100644 index 0000000..14aa61a --- /dev/null +++ b/server/installation.mjs @@ -0,0 +1,34 @@ +// Generated, do not edit. sha256:6cbe401d306ec892f1f72668153c40a7ef9e20306ce9e0d40df746e713359910 +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { CONFIG } from '../shared/config.js'; +import { installDir } from './portfolios.mjs'; + +const file = () => join(installDir(), CONFIG.INSTALLATION.SETTINGS_FILE_NAME); + +function read() { + try { return JSON.parse(readFileSync(file(), 'utf8')); } + catch (e) { if (e.code === 'ENOENT') return {}; throw e; } +} + +function write(settings) { + mkdirSync(installDir(), { recursive: true }); + writeFileSync(file(), `${JSON.stringify(settings, null, 2)}\n`); +} + +const known = code => Object.hasOwn(CONFIG.LOCALE_TAGS, code); + +export function installLocale() { + const { locale } = read(); + return known(locale) ? locale : CONFIG.INSTALLATION.DEFAULT_LOCALE; +} + +export function setInstallLocale(code) { + if (!known(code)) throw new Error(`Unknown locale: '${code}'.`); + write({ ...read(), locale: code }); +} + +export function adoptInstallLocale(fromPortfolio) { + if (known(read().locale)) return; + setInstallLocale(known(fromPortfolio) ? fromPortfolio : CONFIG.INSTALLATION.DEFAULT_LOCALE); +} diff --git a/server/integrations-export.mjs b/server/integrations-export.mjs new file mode 100644 index 0000000..6917247 --- /dev/null +++ b/server/integrations-export.mjs @@ -0,0 +1,74 @@ +// Generated, do not edit. sha256:f749bbb8aa6d30896aca521c750c1da8d88aef00151138c594cf54a5f6889b26 +import { listProviders, getProvider } from './data/providers.js'; +import { listAssets } from './data/assets.js'; +import { getBaseCurrency } from './data/portfolio.js'; + +export const INTEGRATIONS_FORMAT = 'atreides-assets/integrations'; +export const INTEGRATIONS_VERSION = 1; + +const byKeys = (...keys) => (a, b) => { + for (const k of keys) { + const x = a[k] ?? ''; + const y = b[k] ?? ''; + if (x < y) return -1; + if (x > y) return 1; + } + return 0; +}; + +export function integrationsJson(providers, assets, baseCurrency) { + const kindOf = new Map(assets.map(a => [a.symbol, a.kind])); + return { + format: INTEGRATIONS_FORMAT, + version: INTEGRATIONS_VERSION, + baseCurrency, + providers: providers.filter(p => !p.isSystem).sort(byKeys('key')).map(p => ({ + key: p.key, + name: p.name, + baseUrl: p.baseUrl, + isActive: p.isActive === 1, + callsPerMonth: p.callsPerMonth ?? null, + auth: { + kind: p.authenticationKind, + required: p.authenticationRequired === 1, + headerName: p.authenticationHeaderName ?? null, + parameterName: p.authenticationParameterName ?? null, + hmac: p.hmac ? { + messageParts: p.hmac.messageParts, keyHeader: p.hmac.keyHeader, signatureHeader: p.hmac.signatureHeader, + timestampHeader: p.hmac.timestampHeader, windowHeader: p.hmac.windowHeader ?? null, windowMs: p.hmac.windowMs ?? null, + hashAlgorithm: p.hmac.hashAlgorithm, encoding: p.hmac.encoding, + } : null, + }, + endpoints: [...p.endpoints].sort(byKeys('capability')).map(e => ({ + capability: e.capability, method: e.method, pathTemplate: e.pathTemplate, queryTemplate: e.queryTemplate ?? null, + quote: e.quoteCurrency ?? baseCurrency, minIntervalSeconds: e.minIntervalSeconds ?? null, + })), + inputMap: [...p.symbols].sort(byKeys('isCatchAll', 'symbol')).map(s => ({ + symbol: s.symbol ?? null, externalId: s.externalId, quoteUnit: s.quoteUnit ?? null, isCatchAll: s.isCatchAll === 1, + })), + outputs: [...p.outputs].sort(byKeys('capability', 'internalField')).map(o => ({ + capability: o.capability, internalField: o.internalField, jsonPath: o.jsonPath, transform: o.transform, + })), + coverage: [...p.coverage].sort().map(symbol => ({ symbol, kind: kindOf.get(symbol) ?? null })), + })), + }; +} + +function fileName() { + const d = new Date(); + const p = n => String(n).padStart(2, '0'); + return `integraties-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}.json`; +} + +export function exportIntegrations(res) { + const providers = listProviders().map(p => getProvider(p.key)); + const body = Buffer.from(JSON.stringify(integrationsJson(providers, listAssets(), getBaseCurrency()), null, 2) + '\n', 'utf-8'); + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Disposition': `attachment; filename="${fileName()}"`, + 'Content-Length': body.length, + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }); + res.end(body); +} diff --git a/server/keys.mjs b/server/keys.mjs new file mode 100644 index 0000000..654a2f3 --- /dev/null +++ b/server/keys.mjs @@ -0,0 +1,66 @@ +// Generated, do not edit. sha256:a1315d2d14082c64a18aae0cc7fe7a0348020a2be792c5953b7fb3ab918f8978 +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { AUTH_KINDS } from '../shared/config.js'; +import { activeDir } from './portfolios.mjs'; +import { hasFeature } from './license.mjs'; +import { fail } from '../shared/app-error.js'; + +export function secretsFile() { + return join(activeDir(), 'secrets.json'); +} + +export async function readSecretsStore() { + try { return JSON.parse(await readFile(secretsFile(), 'utf-8')); } + catch (e) { if (e.code === 'ENOENT') return {}; throw e; } +} +export async function writeSecretsStore(obj) { + const pad = secretsFile(); + await mkdir(dirname(pad), { recursive: true }); + await writeFile(pad, JSON.stringify(obj, null, 2), { mode: 0o600 }); +} + +function allowedFields(authKind) { + const k = AUTH_KINDS[authKind]; + return k ? k.secret_fields : []; +} + +export async function getKeys(provider) { + const all = await readSecretsStore(); + return all[provider] || {}; +} + +export async function moveProviderKeys(from, to) { + const all = await readSecretsStore(); + if (!all[from]) return false; + all[to] = { ...all[from], ...(all[to] || {}) }; + delete all[from]; + await writeSecretsStore(all); + return true; +} + +export async function keyStatus(provider, authKind) { + const fields = allowedFields(authKind); + const stored = await getKeys(provider); + const configured = {}; + for (const f of fields) configured[f] = String(stored[f] || '').length > 0; + return { provider, fields, configured, complete: fields.length > 0 && fields.every(f => configured[f]) }; +} + +export async function setKeys(provider, authKind, incoming) { + if (!hasFeature('providerKeys')) { + fail('integrations.keysPaid', null, + 'Your own provider keys belong to the paid version; the standard sources keep working.'); + } + const fields = allowedFields(authKind); + if (!fields.length) throw new Error(`Provider '${provider}' (auth '${authKind}') has no key fields.`); + const all = await readSecretsStore(); + const cur = all[provider] || {}; + for (const f of fields) { + const v = incoming && typeof incoming[f] === 'string' ? incoming[f].trim() : ''; + if (v.length) cur[f] = v; + } + all[provider] = cur; + await writeSecretsStore(all); + return keyStatus(provider, authKind); +} diff --git a/server/license.mjs b/server/license.mjs new file mode 100644 index 0000000..c2bc828 --- /dev/null +++ b/server/license.mjs @@ -0,0 +1,173 @@ +// Generated, do not edit. sha256:0a4af87a128e6b903b5ce63c00bf6f500061dd0feb2e18ca3a3d4791c738eff9 +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { schnorr } from '@noble/curves/secp256k1.js'; +import { CONFIG } from '../shared/config.js'; +import { installDir } from './portfolios.mjs'; +import { services } from './registers.mjs'; + +const L = CONFIG.LICENSE; +const HEX64 = /^[0-9a-f]{64}$/; +const HEX128 = /^[0-9a-f]{128}$/; +const DATE = /^\d{4}-\d{2}-\d{2}$/; + +const FIELDS = ['buyer', 'level', 'issued', 'majorFrom', 'majorTo']; + +export function licenseMessage(body) { + const lines = FIELDS.map((f) => `${f}=${body[f]}`); + return [L.MESSAGE_PREFIX, ...lines].join('\n'); +} + +function messageHash(body) { + return createHash('sha256').update(licenseMessage(body), 'utf-8').digest(); +} + +const ISSUER_FIELDS = ['key', 'validFrom', 'validTo']; + +export function issuerMessage(issuer) { + return [L.ISSUER_PREFIX, ...ISSUER_FIELDS.map((f) => `${f}=${issuer[f]}`)].join('\n'); +} + +const sha256 = (text) => createHash('sha256').update(text, 'utf-8').digest(); + +const schnorrOk = (sig, hash, key) => { + try { + return schnorr.verify(Buffer.from(sig, 'hex'), hash, Buffer.from(key, 'hex')); + } catch { + return false; + } +}; + +class LicenseError extends Error {} + +function refuse(reason) { + throw new LicenseError(reason); +} + +export function parseLicense(text) { + let raw; + try { + raw = JSON.parse(text); + } catch { + refuse('The licence file is not valid JSON.'); + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) refuse('The licence file is not an object.'); + + if (!L.FORMATS.includes(raw.format)) refuse(`The licence has format ${JSON.stringify(raw.format)}, this app reads format ${L.FORMATS.join(' and ')}.`); + if (typeof raw.signature !== 'string' || !HEX128.test(raw.signature)) refuse('The signature is missing or is not 128 hex characters.'); + + const body = raw.license; + if (!body || typeof body !== 'object' || Array.isArray(body)) refuse('The licence file has no `license` object.'); + + const extra = Object.keys(body).filter((k) => !FIELDS.includes(k)); + if (extra.length) refuse(`The licence carries unknown fields: ${extra.join(', ')}.`); + const missing = FIELDS.filter((f) => !(f in body)); + if (missing.length) refuse(`The licence is missing fields: ${missing.join(', ')}.`); + + if (typeof body.buyer !== 'string' || !HEX64.test(body.buyer)) refuse('The buyer key is not 64 hex characters.'); + if (!L.LEVELS.includes(body.level)) refuse(`Unknown level ${JSON.stringify(body.level)}.`); + if (typeof body.issued !== 'string' || !DATE.test(body.issued)) refuse('The issue date is not a YYYY-MM-DD date.'); + if (!Number.isInteger(body.majorFrom) || body.majorFrom < 1) refuse('`majorFrom` is not a major version of 1 or higher.'); + + const unlimited = body.majorTo === L.UNLIMITED; + if (!unlimited && (!Number.isInteger(body.majorTo) || body.majorTo < body.majorFrom)) { + refuse(`\`majorTo\` is neither '${L.UNLIMITED}' nor a whole number from \`majorFrom\` up.`); + } + + if (raw.format === 1) return { format: raw.format, license: body, signature: raw.signature }; + + const issuer = raw.issuer; + if (!issuer || typeof issuer !== 'object' || Array.isArray(issuer)) refuse('A format 2 licence has no `issuer` object.'); + const issuerExtra = Object.keys(issuer).filter((k) => ![...ISSUER_FIELDS, 'signature'].includes(k)); + if (issuerExtra.length) refuse(`The issuer carries unknown fields: ${issuerExtra.join(', ')}.`); + if (typeof issuer.key !== 'string' || !HEX64.test(issuer.key)) refuse('The issuer key is not 64 hex characters.'); + if (typeof issuer.validFrom !== 'string' || !DATE.test(issuer.validFrom)) refuse('`validFrom` of the issuer is not a YYYY-MM-DD date.'); + if (typeof issuer.validTo !== 'string' || !DATE.test(issuer.validTo)) refuse('`validTo` of the issuer is not a YYYY-MM-DD date.'); + if (issuer.validTo < issuer.validFrom) refuse('The issuer is valid until before it is valid from.'); + if (typeof issuer.signature !== 'string' || !HEX128.test(issuer.signature)) refuse('The signature of the issuer is missing or is not 128 hex characters.'); + if (body.issued < issuer.validFrom || body.issued > issuer.validTo) refuse('The licence was issued outside the period of its issuer.'); + return { format: raw.format, license: body, signature: raw.signature, issuer }; +} + +export function verifySignature(parsed, sellerKey = L.SELLER_KEY) { + if (!HEX64.test(sellerKey)) return false; + if (parsed.format === 2) { + if (!schnorrOk(parsed.issuer.signature, sha256(issuerMessage(parsed.issuer)), sellerKey)) return false; + return schnorrOk(parsed.signature, messageHash(parsed.license), parsed.issuer.key); + } + return schnorrOk(parsed.signature, messageHash(parsed.license), sellerKey); +} + +export function coversVersion(license, appVersion) { + const m = /^(\d+)\.\d+\.\d+$/.exec(String(appVersion)); + if (!m) return false; + const major = Number(m[1]); + if (major < 1) return false; + if (major < license.majorFrom) return false; + return license.majorTo === L.UNLIMITED ? true : major <= license.majorTo; +} + +export function appVersion() { + const pad = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); + return JSON.parse(readFileSync(pad, 'utf8')).version; +} + +export function licenseState(version = appVersion(), dir = undefined) { + const major = Number(/^(\d+)\./.exec(String(version))?.[1]); + if (!Number.isInteger(major)) return { level: 'free', enforced: true, why: 'unknown-version' }; + + const forced = process.env.ASSETAPP_LICENSE_STATE; + if (forced === 'free') return { level: 'free', enforced: true, why: 'forced' }; + const develop = major < 1 && services.get('develop') !== null; + if (forced === 'full' && develop) return { level: 'full', enforced: false, why: 'forced-develop' }; + + if (develop) return { level: 'full', enforced: false, why: 'develop' }; + + const uitkomst = dir === undefined ? readLicense() : readLicense(dir); + if (uitkomst.state === 'valid' && coversVersion(uitkomst.license, version)) { + return { level: uitkomst.license.level, enforced: true, why: 'licensed', license: uitkomst.license }; + } + return { level: 'free', enforced: true, why: uitkomst.state === 'valid' ? 'out-of-range' : uitkomst.state }; +} + +export function maxFor(soort, state = licenseState()) { + const tabel = L.FREE.MAX; + if (!(soort in tabel)) throw new Error(`Unknown capacity: ${soort}. Known: ${Object.keys(tabel).join(', ')}.`); + return state.level === 'full' ? null : tabel[soort]; +} + +export function capacityReached(soort, huidig, state = licenseState()) { + const max = maxFor(soort, state); + if (max === null) return null; + return huidig >= max ? max : null; +} + +export function allowedAssets(state = licenseState()) { + return state.level === 'full' ? null : L.FREE.ASSETS; +} + +export function hasFeature(naam, state = licenseState()) { + const tabel = L.FREE.HAS; + if (!(naam in tabel)) throw new Error(`Unknown feature: ${naam}. Known: ${Object.keys(tabel).join(', ')}.`); + return state.level === 'full' ? true : tabel[naam]; +} + +export function readLicense(dir = installDir()) { + let text; + try { + text = readFileSync(join(dir, L.FILE_NAME), 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') return { state: 'none' }; + return { state: 'unreadable', reason: err.message }; + } + try { + const parsed = parseLicense(text); + if (!verifySignature(parsed)) return { state: 'invalid', reason: 'The signature does not belong to this licence.' }; + return { state: 'valid', license: parsed.license }; + } catch (err) { + if (err instanceof LicenseError) return { state: 'invalid', reason: err.message }; + throw err; + } +} diff --git a/server/market-service.mjs b/server/market-service.mjs new file mode 100644 index 0000000..ddbd063 --- /dev/null +++ b/server/market-service.mjs @@ -0,0 +1,499 @@ +// Generated, do not edit. sha256:31a0448818ec43a013271278ccfca034b12a6cd78e023ce301c87ce6d267b944 +import { randomUUID, createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readSecretsStore, writeSecretsStore } from './keys.mjs'; +import { installDir } from './portfolios.mjs'; +import { parseLicense, verifySignature } from './license.mjs'; +import { recordIntegrationCall, countServiceCall } from './data/integration-log.js'; +import { CONFIG } from '../shared/config.js'; +import { AppError, fail } from '../shared/app-error.js'; + +const LEGACY_STORE_KEY = 'market_service'; + +const RETRY_LATER = new Set([ + 'marketService.unreachable', 'marketService.timeout', 'marketService.unavailable', + 'marketService.monthUnreachable', 'marketService.tooManyRequests', +]); + +export const isRetryLater = (e) => !!e && RETRY_LATER.has(e.key); + +function normalizeUrl(input) { + const url = String(input || '').trim().replace(/\/+$/, ''); + if (!/^https?:\/\/[^/\s]+/i.test(url)) { + fail('marketService.urlScheme', null, 'The address of the service has to start with http:// or https://.'); + } + return url; +} + +const settingsFile = () => join(installDir(), CONFIG.MARKET_SERVICE.SETTINGS_FILE_NAME); + +async function writeSettings(settings) { + await mkdir(installDir(), { recursive: true }); + await writeFile(settingsFile(), `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 }); +} + +async function readSettings() { + let own = null; + try { own = JSON.parse(await readFile(settingsFile(), 'utf8')); } + catch (e) { if (e.code !== 'ENOENT') throw e; } + const secrets = await readSecretsStore(); + const old = secrets[LEGACY_STORE_KEY]; + if (old) { + if (!own) { + own = old.token ? { token: old.token } : {}; + await writeSettings(own); + } + delete secrets[LEGACY_STORE_KEY]; + await writeSecretsStore(secrets); + } + return own ?? {}; +} + +function serviceUrl() { + const override = process.env.ASSETAPP_SERVICE_URL; + return override ? normalizeUrl(override) : CONFIG.MARKET_SERVICE.URL; +} + +const FREE_LICENSE = join(dirname(fileURLToPath(import.meta.url)), CONFIG.LICENSE.FREE_FILE_NAME); + +function registrationLicense() { + for (const file of [join(installDir(), CONFIG.LICENSE.FILE_NAME), FREE_LICENSE]) { + let text; + try { text = readFileSync(file, 'utf8'); } catch { continue; } + try { if (verifySignature(parseLicense(text))) return text; } catch { } + } + return null; +} + +const hashOf = (text) => createHash('sha256').update(text, 'utf8').digest('hex'); + +export async function marketServiceStatus() { + const cur = await readSettings(); + return { + url: serviceUrl(), + override: !!process.env.ASSETAPP_SERVICE_URL, + token_set: !!cur.token, + token_source: !cur.token ? null : cur.licenseHash ? 'license' : 'manual', + tier: cur.tier ?? null, + can_register: registrationLicense() !== null, + }; +} + +const SUPPORT_REFUSALS = { + 'support.empty': 'marketService.supportEmpty', + 'support.tooLong': 'marketService.supportTooLong', + 'support.attachmentEmpty': 'marketService.supportAttachmentType', + 'support.attachmentType': 'marketService.supportAttachmentType', + 'support.attachmentTooLarge': 'marketService.supportAttachmentTooLarge', + 'support.tooMany': 'marketService.supportTooMany', +}; +const SUPPORT_PARAMS = { + 'marketService.supportTooLong': { max: CONFIG.LICENSE.SUPPORT.MAX_TEXT }, + 'marketService.supportAttachmentTooLarge': { mb: CONFIG.LICENSE.SUPPORT.MAX_ATTACHMENT_BYTES / 1024 / 1024 }, +}; + +function serviceError(status, json, headers) { + const key = json && typeof json.key === 'string' ? json.key : null; + const reason = (json && typeof json.error === 'string' && json.error) || `HTTP ${status}`; + if (status === 401 && key === 'api.badAuth') { + return new AppError('marketService.signatureRefused', { reason }, `The service refuses the signature: ${reason}`); + } + if (status === 401) { + return new AppError('marketService.unauthorized', null, 'The service refuses the token: it is missing, wrong or revoked.'); + } + if (status === 503 && key === 'api.salesClosed') { + return new AppError('marketService.salesClosed', null, 'Nothing is for sale right now.'); + } + if (status === 503 && key === 'api.checkoutUnavailable') { + return new AppError('marketService.checkoutUnavailable', null, 'The checkout of the service cannot make an invoice now; try again later.'); + } + if (status === 429 && key === 'api.tooManyOrders') { + return new AppError('marketService.tooManyOrders', { reason }, `The service takes no more orders for this key now: ${reason}`); + } + if (status === 403 && key === 'api.licenseRevoked') { + return new AppError('marketService.licenseRevoked', null, 'The service has revoked this licence and gives it no token.'); + } + if (status === 400 && key === 'api.badLicense') { + return new AppError('marketService.badLicense', { reason }, `The service does not accept this licence: ${reason}`); + } + if (status === 404 && key === 'api.unknownOrder') { + return new AppError('marketService.unknownOrder', null, 'The service does not know this order (any more).'); + } + const support = SUPPORT_REFUSALS[key]; + if (support) return new AppError(support, SUPPORT_PARAMS[support] ?? null, `The service refuses the message: ${reason}`); + if (status === 404 && key === 'api.unknownAttachment') { + return new AppError('marketService.unknownAttachment', null, 'The service does not know this screenshot.'); + } + if (status === 429) { + const s = Number(headers?.get?.('retry-after')) || 60; + return new AppError('marketService.tooManyRequests', { s }, `Too many requests to the service; try again in ${s}s.`); + } + if (status === 503 && key === 'api.monthUnreachable') { + const month = (json && json.month) || '?'; + return new AppError('marketService.monthUnreachable', { month }, + `The service cannot read month ${month} right now; try again later.`); + } + if (status === 503) { + return new AppError('marketService.unavailable', null, 'The service is not ready (it may be starting); try again later.'); + } + return new AppError('marketService.rejected', { status, key: key || '-', reason }, + `The service refused the request (HTTP ${status}, ${key || 'no key'}): ${reason}`); +} + +function describeBody(res, text) { + const type = String(res.headers?.get?.('content-type') || 'no type').split(';')[0].trim(); + return `no JSON (${type}, ${Buffer.byteLength(text || '', 'utf8')} bytes)`; +} + +const appJournal = { + failure: (entry, secrets) => recordIntegrationCall(entry, { secrets }), + count: (c) => countServiceCall(c), +}; + +const COUNTED = { '/v1/spot': 'spot', '/v1/series': 'daily_history' }; + +export function createMarketClient({ + fetch: doFetch = (...a) => globalThis.fetch(...a), + config = readConfig, + renewOn401 = false, + timeoutMs = CONFIG.MARKET_SERVICE.REQUEST_TIMEOUT_MS, + now = Date.now, + journal = appJournal, + requestId: newRequestId = randomUUID, +} = {}) { + async function note(fn) { + if (!journal) return; + try { await fn(); } catch { } + } + + const subjectOf = (params) => (Array.isArray(params?.instruments) ? String(params.instruments.length) + : typeof params?.instrument === 'string' ? params.instrument : null); + + async function exchange(path, params = null, opts = {}) { + try { + return await exchangeOnce(path, params, opts, false); + } catch (e) { + const tokenCall = (opts.auth ?? true) && !opts.signature; + if (!(renewOn401 && tokenCall && e?.key === 'marketService.unauthorized')) throw e; + return exchangeOnce(path, params, opts, true); + } + } + + async function exchangeOnce(path, params, { auth = true, signature = null, rawBody = null } = {}, renew) { + if (signature) auth = false; + const cfg = await config({ needToken: auth, renew }); + const id = newRequestId(); + const entry = { target: 'service', operation: path, subject: subjectOf(params), requestId: id, occurredAt: now() }; + if (auth && !cfg.token) { + await note(() => journal.failure({ ...entry, outcome: 'refused', errorKey: 'marketService.noToken', requestId: null }, [])); + fail('marketService.noToken', null, 'No token set for the market data service.'); + } + const u = new URL(cfg.url + path); + const body = Object.fromEntries(Object.entries(params || {}).filter(([, v]) => v !== null && v !== undefined)); + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), timeoutMs); + const t0 = performance.now(); + let status = null; + let detail = null; + try { + let res; + try { + res = await doFetch(u.toString(), auth + ? { + method: 'POST', + headers: { + Authorization: `Bearer ${cfg.token}`, Accept: 'application/json', 'Content-Type': 'application/json', + 'X-Request-Id': id, + }, + body: JSON.stringify(body), + signal: ctl.signal, + } + : { + method: rawBody !== null ? 'POST' : 'GET', + headers: { + Accept: 'application/json', 'X-Request-Id': id, + ...(signature ? { Authorization: signature } : {}), + ...(rawBody !== null ? { 'Content-Type': 'application/json' } : {}), + }, + ...(rawBody !== null ? { body: rawBody } : {}), + signal: ctl.signal, + }); + } catch (e) { + if (ctl.signal.aborted) { + const s = timeoutMs / 1000; + throw new AppError('marketService.timeout', { s }, `The service did not answer within ${s}s.`); + } + const reason = e?.cause?.code || e?.cause?.message || e?.message || String(e); + detail = reason; + throw new AppError('marketService.unreachable', { url: cfg.url, reason }, + `The service at ${cfg.url} is unreachable: ${reason}.`); + } + status = res.status; + const text = await res.text().catch(() => ''); + let json = null; + try { json = JSON.parse(text); } catch { detail = describeBody(res, text); } + const back = res.headers?.get?.('x-request-id'); + if (back && back !== id) detail = [detail, `service request id ${back}`].filter(Boolean).join('; '); + if (!res.ok) { + if (json && typeof json.error === 'string') detail = [json.error, detail].filter(Boolean).join('; '); + throw serviceError(res.status, json, res.headers); + } + if (!json || typeof json !== 'object') { + detail ??= 'not an object'; + fail('marketService.badAnswer', { url: cfg.url }, `The answer from ${cfg.url} is not the API of the service.`); + } + if (COUNTED[path]) await note(() => journal.count({ capability: COUNTED[path], ok: true, latencyMs: performance.now() - t0 })); + return { json, requestId: id }; + } catch (e) { + e.requestId ??= id; + const latencyMs = performance.now() - t0; + const serviceKey = e?.params?.key && e.params.key !== '-' ? ` ${e.params.key}` : ''; + await note(() => journal.failure({ + ...entry, outcome: 'fail', httpStatus: status, errorKey: `${e?.key || 'unknown'}${serviceKey}`, + detail: detail ?? e?.params?.reason ?? null, latencyMs, + }, [cfg.token, signature].filter(Boolean))); + if (COUNTED[path]) await note(() => journal.count({ capability: COUNTED[path], ok: false, latencyMs })); + throw e; + } finally { + clearTimeout(timer); + } + } + + const request = async (...a) => (await exchange(...a)).json; + + const noteEmpty = (path, params, requestId) => note(() => journal.failure({ + target: 'service', operation: path, subject: subjectOf(params), outcome: 'empty', httpStatus: 200, + requestId, occurredAt: now(), + }, [])); + + async function instruments() { + const json = await request('/v1/instruments'); + if (!Array.isArray(json.instruments)) { + fail('marketService.badAnswer', { url: (await config()).url }, 'The register in the answer is not a list.'); + } + return json.instruments; + } + + async function testConnection() { + const { url } = await config(); + const health = await request('/v1/health', null, { auth: false }); + if (health.ok !== true || health.app !== CONFIG.MARKET_SERVICE.APP_NAME) { + fail('marketService.badAnswer', { url }, `The answer from ${url} is not the API of the service.`); + } + const list = await instruments(); + return { url, app: health.app, version: health.version || null, instruments: list.length }; + } + + let cached = null; + async function register() { + const { url } = await config(); + if (cached && cached.url === url && now() - cached.at < CONFIG.MARKET_SERVICE.REGISTER_TTL_MS) return cached.list; + const list = await instruments(); + cached = { url, at: now(), list }; + return list; + } + + async function series({ instrument, quote, resolution = 'minute', from, to }) { + const points = []; + let meta = null; + let cursor = from; + let rid = null; + for (let page = 0; page < CONFIG.MARKET_SERVICE.MAX_SERIES_PAGES; page++) { + const { json, requestId } = await exchange('/v1/series', { instrument, quote, resolution, from: cursor, to }); + rid = requestId; + const cols = Array.isArray(json.columns) ? json.columns : []; + const ti = cols.indexOf('t'); + const pi = cols.includes('price') ? cols.indexOf('price') : cols.indexOf('close'); + const si = cols.indexOf('source'); + if (ti < 0 || pi < 0 || !Array.isArray(json.rows) || typeof json.currency !== 'string' || !json.currency) { + fail('marketService.badAnswer', { url: (await config()).url }, 'The series in the answer has no time, price or currency.'); + } + if (meta && json.currency !== meta.currency) { + fail('marketService.badAnswer', { url: (await config()).url }, 'The currency changed between two pages of one series.'); + } + meta ??= { instrument, currency: json.currency, source: json.source || null, sourceLabel: json.sourceLabel || null, + resolution: json.resolution || resolution, sourceLabels: {} }; + if (json.sourceLabels && typeof json.sourceLabels === 'object') { + for (const [key, label] of Object.entries(json.sourceLabels)) if (typeof label === 'string' && label) meta.sourceLabels[key] = label; + } + for (const r of json.rows) { + const t = Number(r?.[ti]); + const p = Number(r?.[pi]); + if (!(Number.isFinite(t) && t > 0 && Number.isFinite(p) && p > 0)) continue; + const src = si >= 0 && typeof r[si] === 'string' && r[si] ? r[si] : null; + points.push(src ? [t, p, src] : [t, p]); + } + if (json.next == null || !(Number(json.next) > cursor)) break; + cursor = Number(json.next); + } + if (!points.length) await noteEmpty('/v1/series', { instrument }, rid); + return { ...meta, points }; + } + + async function history({ instrument, quote, from, to }) { + const points = []; + let meta = null; + let cursor = from; + let rid = null; + for (let page = 0; page < CONFIG.MARKET_SERVICE.MAX_SERIES_PAGES; page++) { + const { json, requestId } = await exchange('/v1/series', { instrument, quote, kind: 'history', from: cursor, to }); + rid = requestId; + const cols = Array.isArray(json.columns) ? json.columns : []; + const [ti, pi, ri] = ['t', 'price', 'resolution'].map(c => cols.indexOf(c)); + if (ti < 0 || pi < 0 || ri < 0 || !Array.isArray(json.rows) || typeof json.currency !== 'string' || !json.currency) { + fail('marketService.badAnswer', { url: (await config()).url }, 'The history in the answer has no time, price, resolution or currency.'); + } + if (meta && json.currency !== meta.currency) { + fail('marketService.badAnswer', { url: (await config()).url }, 'The currency changed between two pages of one series.'); + } + meta ??= { instrument, currency: json.currency, source: json.source || null }; + for (const r of json.rows) { + const t = Number(r?.[ti]); + const p = Number(r?.[pi]); + if (Number.isFinite(t) && t > 0 && Number.isFinite(p) && p > 0) points.push([t, p, r[ri]]); + } + if (json.next == null || !(Number(json.next) > cursor)) break; + cursor = Number(json.next); + } + if (!points.length) await noteEmpty('/v1/series', { instrument }, rid); + return { ...meta, points }; + } + + let spotCache = null; + async function spots(keys, quote) { + const { url } = await config(); + const pakket = [...new Set(keys)].sort(); + const sleutel = `${url}|${quote}|${pakket.join(',')}`; + if (spotCache && spotCache.sleutel === sleutel && now() - spotCache.at < CONFIG.MARKET_SERVICE.SPOT_TTL_MS) { + return spotCache.result; + } + const result = { spot: [], missing: [], unreachable: [] }; + const max = CONFIG.MARKET_SERVICE.MAX_INSTRUMENTS_PER_SPOT; + let rid = null; + for (let i = 0; i < pakket.length; i += max) { + const { json, requestId } = await exchange('/v1/spot', { instruments: pakket.slice(i, i + max), quote }); + rid = requestId; + if (!Array.isArray(json.spot)) { + fail('marketService.badAnswer', { url }, 'The spot answer has no list of prices.'); + } + for (const s of json.spot) { + const price = Number(s?.price); + if (typeof s?.instrument !== 'string' || !Number.isFinite(price) || price <= 0 || typeof s.currency !== 'string' || !s.currency) continue; + result.spot.push({ + instrument: s.instrument, price, currency: s.currency, + observedAt: Number(s.observedAt) || null, source: s.source || null, sourceLabel: s.sourceLabel || null, + }); + } + if (Array.isArray(json.missing)) result.missing.push(...json.missing); + if (Array.isArray(json.unreachable)) result.unreachable.push(...json.unreachable); + } + if (pakket.length && !result.spot.length) await noteEmpty('/v1/spot', { instruments: pakket }, rid); + spotCache = { sleutel, at: now(), result }; + return result; + } + + async function buyerLicenses(signature) { + const json = await request('/v1/license', null, { signature }); + if (!Array.isArray(json.licenses)) { + fail('marketService.badAnswer', { url: (await config()).url }, 'The licences in the answer are not a list.'); + } + return json.licenses; + } + + async function sale() { + const json = await request('/v1/sale', null, { auth: false }); + if (typeof json.open !== 'boolean') fail('marketService.badAnswer', { url: (await config()).url }, 'The sale in the answer has no open state.'); + return { open: json.open, priceSat: Number.isInteger(json.priceSat) ? json.priceSat : null, major: json.major ?? null }; + } + + async function placeOrder(signature, rawBody) { + const json = await request('/v1/orders', null, { signature, rawBody }); + if (!json.order || typeof json.order.id !== 'string') fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no order.'); + return { order: json.order, readKey: typeof json.readKey === 'string' ? json.readKey : null }; + } + + async function orderStatus(orderId, readKey) { + const json = await request(`/v1/orders/${orderId}`, null, { signature: `Order ${readKey}` }); + if (!json.order) fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no order.'); + return { order: json.order, license: json.license ?? null }; + } + + async function supportMessages(signature) { + const json = await request('/v1/support/messages', null, { signature }); + if (!Array.isArray(json.messages)) fail('marketService.badAnswer', { url: (await config()).url }, 'The messages in the answer are not a list.'); + return json.messages; + } + + async function supportSend(signature, rawBody) { + const json = await request('/v1/support/messages', null, { signature, rawBody }); + if (!json.message || typeof json.message !== 'object') fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no message.'); + return json.message; + } + + async function supportAttachment(signature, id) { + const json = await request(`/v1/support/attachments/${id}`, null, { signature }); + if (typeof json.mime !== 'string' || typeof json.data !== 'string') { + fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no screenshot.'); + } + return { mime: json.mime, data: json.data }; + } + + async function registerLicense(text) { + const json = await request('/v1/license/register', null, { auth: false, rawBody: text }); + if (typeof json.token !== 'string' || !json.token) fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no token.'); + return { token: json.token, tier: typeof json.tier === 'string' ? json.tier : null }; + } + + return { + instruments, register, series, history, spots, testConnection, buyerLicenses, sale, placeOrder, orderStatus, + supportMessages, supportSend, supportAttachment, registerLicense, + }; +} + +let registering = null; +async function tokenFor({ renew = false } = {}) { + const cur = await readSettings(); + const license = registrationLicense(); + const hash = license ? hashOf(license) : null; + if (cur.token && !renew && (!cur.licenseHash || !license || cur.licenseHash === hash)) return cur.token; + if (!license) return renew ? '' : (cur.token || ''); + registering ??= (async () => { + try { + const { token, tier } = await appClient.registerLicense(license); + await writeSettings({ token, tier, licenseHash: hash }); + return token; + } finally { + registering = null; + } + })(); + return registering; +} + +async function readConfig({ needToken = false, renew = false } = {}) { + return { url: serviceUrl(), token: needToken ? await tokenFor({ renew }) : '' }; +} + +const appClient = createMarketClient({ renewOn401: true }); + +export const testMarketService = () => appClient.testConnection(); +export const marketRegister = () => appClient.register(); +export const marketSeries = (opts) => appClient.series(opts); +export const marketHistory = (opts) => appClient.history(opts); +export const marketSpots = (keys, quote) => appClient.spots(keys, quote); +export const marketBuyerLicenses = (signature) => appClient.buyerLicenses(signature); +export const marketSale = () => appClient.sale(); +export const marketPlaceOrder = (signature, rawBody) => appClient.placeOrder(signature, rawBody); +export const marketOrderStatus = (orderId, readKey) => appClient.orderStatus(orderId, readKey); +export const marketSupportMessages = (signature) => appClient.supportMessages(signature); +export const marketSupportSend = (signature, rawBody) => appClient.supportSend(signature, rawBody); +export const marketSupportAttachment = (signature, id) => appClient.supportAttachment(signature, id); + +export const marketServiceUrl = async () => (await readConfig()).url; + +export const marketConfigured = async () => { + const st = await marketServiceStatus(); + return st.token_set || st.can_register; +}; diff --git a/server/mcp/client-config.mjs b/server/mcp/client-config.mjs new file mode 100644 index 0000000..40fccee --- /dev/null +++ b/server/mcp/client-config.mjs @@ -0,0 +1,19 @@ +// Generated, do not edit. sha256:35230db92740b73c0985590b9f5ef4f49deba0d1a3c63e124e62b21c8b219a63 +import { fileURLToPath } from 'node:url'; + +import { CONFIG } from '../../shared/config.js'; + +const STDIO_PAD = fileURLToPath(new URL('./stdio.mjs', import.meta.url)); + +export function mcpClientConfig() { + const document = { + mcpServers: { + [CONFIG.AI.SERVER_NAME]: { command: 'node', args: [STDIO_PAD] }, + }, + }; + return { + serverKey: CONFIG.AI.SERVER_NAME, + path: STDIO_PAD, + snippet: JSON.stringify(document, null, 2), + }; +} diff --git a/server/mcp/control-tools.mjs b/server/mcp/control-tools.mjs new file mode 100644 index 0000000..c4f1b0f --- /dev/null +++ b/server/mcp/control-tools.mjs @@ -0,0 +1,106 @@ +// Generated, do not edit. sha256:588c505552ce949b88f55a05209c95d02e3be8d37929538a9fb8ef9f58643cd2 +import { CONFIG, TARGETS, targetHash } from '../../shared/config.js'; +import { placeLabel } from '../../shared/places.js'; +import { installLocale } from '../installation.mjs'; +import { assertScope } from '../ai/gate.js'; +import { createCommand, getCommand } from '../data/commands.js'; +import { flushChanges } from '../data/bus.js'; + +const DOELEN = Object.keys(TARGETS); + +const doelenTekst = () => DOELEN + .map(naam => `${naam} - ${placeLabel(naam, installLocale())} (${targetHash(TARGETS[naam])})`).join(', '); + +const KIJK_INTERVAL_MS = 400; + +const wacht = (ms) => new Promise(r => setTimeout(r, ms)); + +async function wachtOpUitkomst(id, seconden) { + const einde = Date.now() + seconden * 1000; + let c = getCommand(id); + while (c && c.resolvedAt == null && Date.now() < einde) { + await wacht(KIJK_INTERVAL_MS); + c = getCommand(id); + } + return c; +} + +async function stuurOpdracht(kind, payload = null) { + const verloopt = new Date(Date.now() + CONFIG.COMMANDS.CLAIM_SECONDS * 1000).toISOString(); + const id = await createCommand({ kind, payload, expiresAt: verloopt }); + + flushChanges(); + + return wachtOpUitkomst(id, CONFIG.COMMANDS.CLAIM_SECONDS); +} + +const NIET_OPEN = + 'De app lijkt niet open te staan. Vraag de gebruiker of hij AssetApp opent en probeer het daarna ' + + 'opnieuw.'; + +const navigeerNaar = Object.freeze({ + name: 'navigate_to', + title: 'Naar een plek in de app gaan, of zien waar de gebruiker is', + description: + 'Brengt de gebruiker naar een plek in AssetApp; zonder target zegt hij waar de gebruiker nu kijkt, ' + + 'met de naam van die plek. Een sprong ziet de gebruiker aankomen en kan hij tegenhouden. Leest en ' + + 'wijzigt de administratie niet. Staat de app niet open, dan zegt het antwoord dat.', + inputSchema: Object.freeze({ + type: 'object', + properties: { + target: { + type: 'string', + enum: DOELEN, + description: 'De plek. Weglaten om te horen waar de gebruiker is.', + }, + }, + additionalProperties: false, + }), + scope: 'control', + + async run(args) { + assertScope('control'); + if (!args || args.target === undefined) return waarKijktDeGebruiker(); + const naam = typeof args.target === 'string' ? args.target : ''; + if (!Object.hasOwn(TARGETS, naam)) { + return { + text: `'${naam}' is geen bekende plek. Kies er een uit: ${doelenTekst()}.`, + fieldsShared: [], + }; + } + + const doel = TARGETS[naam]; + const uit = await stuurOpdracht('navigate', + { route: doel.route, tab: doel.tab, sub: doel.sub, hash: targetHash(doel) }); + const stand = uit ? uit.state : 'expired'; + if (stand === 'done') { + return { text: `Gedaan. De gebruiker kijkt nu naar ${targetHash(doel)}.`, fieldsShared: [] }; + } + if (stand === 'rejected') { + return { + text: `Niet uitgevoerd: ${uit.detail || 'de opdracht is afgewezen.'} Er is niet genavigeerd.`, + fieldsShared: [], + }; + } + return { text: `Er is niet genavigeerd. ${NIET_OPEN}`, fieldsShared: [] }; + }, +}); + +const NAAM_PER_HASH = new Map(DOELEN.map(naam => [targetHash(TARGETS[naam]), naam])); + +async function waarKijktDeGebruiker() { + const uit = await stuurOpdracht('locate'); + if (!uit || uit.state !== 'done') return { text: NIET_OPEN, fieldsShared: [] }; + + const hash = typeof uit.detail === 'string' ? uit.detail : ''; + const naam = NAAM_PER_HASH.get(hash); + if (naam) return { text: `De gebruiker kijkt naar ${naam}: ${placeLabel(naam, installLocale())} (${hash}).`, fieldsShared: [] }; + return { + text: hash + ? `De gebruiker kijkt naar ${hash}. Dat is geen van de plekken die navigate_to kent.` + : 'De app staat open, maar liet niet weten waar de gebruiker kijkt.', + fieldsShared: [], + }; +} + +export const CONTROL_TOOLS = Object.freeze([navigeerNaar]); diff --git a/server/mcp/detail-tool.mjs b/server/mcp/detail-tool.mjs new file mode 100644 index 0000000..56200e1 --- /dev/null +++ b/server/mcp/detail-tool.mjs @@ -0,0 +1,179 @@ +// Generated, do not edit. sha256:c8d6a3abd48730edcf0689606e46d90e911be6fb20a947fc1829093ad98e9800 +import { listAccounts } from '../data/accounts.js'; +import { listAssets } from '../data/assets.js'; +import { listLabels } from '../data/labels.js'; +import { accountStructure, assetStructure, labelStructure, eventRefs } from '../data/structure.js'; +import { rowOrigin } from '../data/ai-proposals.js'; +import { assertScope } from '../ai/gate.js'; +import { limitedList } from '../ai/limits.js'; +import { vertalerVoor } from './session.mjs'; +import { HANDLE_VORM, idVoorHandle } from './handles.mjs'; + +function assetVoorSymbool(gevraagd) { + const gezocht = String(gevraagd ?? '').trim().toUpperCase(); + if (!gezocht || gezocht.includes(':')) return null; + return listAssets().find(a => String(a.symbol).toUpperCase() === gezocht) || null; +} + +const SOORTEN = Object.freeze({ + account: Object.freeze({ ids: () => listAccounts().map(r => r.id), structuur: accountStructure }), + asset: Object.freeze({ ids: () => listAssets().map(r => r.id), structuur: assetStructure }), + label: Object.freeze({ ids: () => listLabels().map(r => r.id), structuur: labelStructure }), +}); + +const SOORT_NAMEN = Object.keys(SOORTEN); + +const telRegel = (aantal, enkelvoud, meervoud) => + `${aantal} ${aantal === 1 ? enkelvoud : meervoud}`; + +const TELLING_TEKST = Object.freeze({ + events: n => telRegel(n, 'gebeurtenis', 'gebeurtenissen'), + assets: n => telRegel(n, 'asset waarin geboekt is', 'assets waarin geboekt is'), + accounts: n => telRegel(n, 'account waarop hij voorkomt', 'accounts waarop hij voorkomt'), + walletSources: n => telRegel(n, 'wallet-bron', 'wallet-bronnen'), + addresses: n => telRegel(n, 'adres', 'adressen'), + walletTransactions: n => telRegel(n, 'on-chain beweging', 'on-chain bewegingen'), + valuations: n => telRegel(n, 'balans', 'balansen'), + pricePoints: n => telRegel(n, 'koerspunt', 'koerspunten'), + products: n => telRegel(n, 'metaalproduct', 'metaalproducten'), + coverage: n => telRegel(n, 'koersbron die dit symbool zegt te dekken', 'koersbronnen die dit symbool zeggen te dekken'), +}); + +function rijRegels(veilig) { + return Object.entries(veilig) + .filter(([naam]) => naam !== 'id') + .map(([naam, waarde]) => ` ${naam} = ${waarde === null ? '(leeg)' : waarde}`); +} + +const recordDetail = Object.freeze({ + name: 'record_detail', + title: 'Eén rij uitdiepen', + description: + 'Geeft van één rij uit de administratie van AssetApp terug waar hij aan vasthangt: zijn eigen ' + + 'kenmerken en hoeveel gebeurtenissen, adressen, balansen en koerspunten eraan hangen. Bedoeld als ' + + 'vervolgvraag op een verwijzing die admin_overview of data_health heeft gegeven. Werkt op een ' + + 'account (account:...) of een label (label:...), ook als die verwijzing uit een eerder gesprek ' + + 'komt, en op een asset bij zijn SYMBOOL (BTC). Bij die twee ook de gebeurtenissen met hun ' + + 'verwijzing, soort en maand. Alleen kenmerken, aantallen en verwijzingen; geen bedragen, geen ' + + 'exacte datums en geen adressen, en wijzigt niets.', + inputSchema: Object.freeze({ + type: 'object', + properties: { + handle: { + type: 'string', + maxLength: 64, + description: + 'De verwijzing zoals een andere tool hem gaf (account:7f3a9c, label:9e8f7a), of het symbool ' + + 'van een asset (BTC).', + }, + }, + required: ['handle'], + additionalProperties: false, + }), + scope: 'read', + + async run(args, context) { + assertScope(recordDetail.scope); + const t = await vertalerVoor(context); + t.beginCall(); + + const gevraagd = typeof args?.handle === 'string' ? args.handle.trim() : ''; + + const symbool = assetVoorSymbool(gevraagd); + if (symbool) return detailVan('asset', symbool.id, gevraagd, t); + + const vorm = HANDLE_VORM.exec(gevraagd); + if (!vorm) { + return { + text: `'${gevraagd}' is geen verwijzing zoals deze app ze uitdeelt en geen asset-symbool. ` + + `Verwijzingen zien eruit als account:7f3a9c: een soort, een dubbele punt en zes tekens. ` + + `Staat onbewerkt delen aan, dan is het de soort met het rij-id erachter, zoals account:3. ` + + `Een asset wijs je aan met zijn symbool, zoals BTC.`, + fieldsShared: [], + }; + } + + const soort = vorm[1]; + if (!Object.hasOwn(SOORTEN, soort)) { + return { + text: `Een verwijzing van de soort '${soort}' kan deze tool niet uitdiepen. Dat kan voor: ` + + `${SOORT_NAMEN.join(', ')}.`, + fieldsShared: [], + }; + } + + const kandidaten = SOORTEN[soort].ids(); + const id = idVoorHandle(t, soort, gevraagd, kandidaten) ?? idVoorHandle(t, soort, vorm[2], kandidaten); + if (id === null) { + return { + text: `De verwijzing ${gevraagd} hoort bij geen enkele ${soort} in deze administratie. ` + + `Mogelijk is de rij verwijderd.`, + fieldsShared: [], + }; + } + + return detailVan(soort, id, gevraagd, t); + }, +}); + +function detailVan(soort, id, gevraagd, t) { + { + const structuur = SOORTEN[soort].structuur(id); + if (!structuur) { + return { text: `De ${soort} achter ${gevraagd} bestaat niet meer.`, fieldsShared: [] }; + } + + const veilig = t.toAi(structuur.row, { entity: soort }); + const tellingen = limitedList( + Object.entries(structuur.counts), + ([naam, n]) => ` ${TELLING_TEKST[naam] ? TELLING_TEKST[naam](n) : `${naam} = ${n}`}`, + ).lines; + + const verwijzingen = Object.entries(structuur.refs) + .filter(([, ids]) => Array.isArray(ids) && ids.length) + .map(([naam, ids]) => { + const soortVanRef = naam === 'assetIds' ? 'asset' : 'account'; + const { lines } = limitedList(ids, refId => ` ${t.toAi({ id: refId }, { entity: soortVanRef }).id}`); + return [`${naam === 'assetIds' ? 'Assets' : 'Accounts'}:`, ...lines].join('\n'); + }); + + const gebeurtenissen = (soort === 'account' || soort === 'asset') + ? limitedList( + eventRefs(soort === 'account' ? { accountId: id } : { assetId: id }), + (e) => { + const uit = rowOrigin('event', e.id); + const bron = uit ? ` · uit ${t.toAi({ id: uit.proposalId }, { entity: 'proposal' }).id}` : ''; + return ` ${t.toAi({ id: e.id }, { entity: 'event' }).id} · ${e.type} · ${e.month}${bron}`; + }, + ).lines + : []; + + const herkomst = soort === 'account' ? rowOrigin('account', id) : null; + const herkomstRegels = herkomst + ? ['', `Ontstaan uit ${t.toAi({ id: herkomst.proposalId }, { entity: 'proposal' }).id}, ` + + `goedgekeurd op ${String(herkomst.resolvedAt || '').slice(0, 10)}.`] + : []; + + return { + text: [ + `${soort} ${gevraagd}:`, + ...rijRegels(veilig), + '', + 'Hangt eraan:', + ...tellingen, + ...herkomstRegels, + ...(gebeurtenissen.length ? ['', 'Gebeurtenissen (nieuwste eerst, maand):', ...gebeurtenissen] : []), + ...(verwijzingen.length ? ['', ...verwijzingen] : []), + ].join('\n'), + fieldsShared: [...new Set([ + ...t.fieldsShared(), + 'assetId', 'fromAccountId', 'toAccountId', 'accountId', 'labelId', 'providerId', + ...(gebeurtenissen.length ? ['type', 'occurredAt'] : []), + ...(gebeurtenissen.some(r => r.includes(' · uit ')) ? ['createdEventId'] : []), + ...(herkomst ? ['createdAccountId'] : []), + ])].sort(), + }; + } +} + +export const DETAIL_TOOLS = Object.freeze([recordDetail]); diff --git a/server/mcp/dry-run.mjs b/server/mcp/dry-run.mjs new file mode 100644 index 0000000..5e2f775 --- /dev/null +++ b/server/mcp/dry-run.mjs @@ -0,0 +1,33 @@ +// Generated, do not edit. sha256:a80e6dd97d8d48819bc999d4057ab86d1dddc182a1d9038097a88796139bb3df +import { logAiToolCall } from '../data/ai-audit.js'; +import { createMcpServer } from './protocol.mjs'; +import { allTools } from './tools.mjs'; + +let server = null; + +function ensureServer() { + if (!server) { + server = createMcpServer({ + tools: allTools(), + logCall: logAiToolCall, + onLogError: (e) => console.error('[assetapp] een MCP-aanroep is NIET vastgelegd:', e.message), + }); + } + return server; +} + +export function dryRunTools() { + return allTools().map(t => ({ + name: t.name, + title: t.title, + description: t.description, + scope: t.scope, + inputSchema: t.inputSchema, + })); +} + +export function dryRunTool(naam, args) { + return ensureServer().dryRun(naam, args); +} + +export function _resetDryRunServer() { server = null; } diff --git a/server/mcp/handles.mjs b/server/mcp/handles.mjs new file mode 100644 index 0000000..dca945c --- /dev/null +++ b/server/mcp/handles.mjs @@ -0,0 +1,12 @@ +// Generated, do not edit. sha256:934f10111106ef1ed676c3715e4aeefd364cfa02688bedd903fe7320f775ae01 +export const HANDLE_VORM = /^([a-zA-Z]+):([0-9a-f]{6}|[1-9][0-9]*)$/; + +export function idVoorHandle(t, soort, gevraagd, ids) { + const gezocht = String(gevraagd ?? '').trim(); + if (!gezocht) return null; + for (const id of ids) { + const uit = String(t.toAi({ id }, { entity: soort }).id); + if (uit === gezocht || `${soort}:${uit}` === gezocht) return id; + } + return null; +} diff --git a/server/mcp/places-tool.mjs b/server/mcp/places-tool.mjs new file mode 100644 index 0000000..1a12864 --- /dev/null +++ b/server/mcp/places-tool.mjs @@ -0,0 +1,35 @@ +// Generated, do not edit. sha256:8dc9633b14245eff1f7c9bee179cd3a162123ee1e24f229791775df14dfa3934 +import { TARGETS, targetHash } from '../../shared/config.js'; +import { placeActions } from '../../shared/places.js'; +import { installLocale } from '../installation.mjs'; +import { assertScope } from '../ai/gate.js'; + +const regels = () => { + const acties = placeActions(installLocale()); + return Object.keys(TARGETS) + .map(naam => `- ${naam} (${targetHash(TARGETS[naam])}): ${acties[naam] || '(geen beschrijving)'}`) + .join('\n'); +}; + +const appPlaces = Object.freeze({ + name: 'app_places', + title: 'Wat u waar in de app kunt doen', + description: + 'Geeft de lijst van plekken in AssetApp met per plek wat de gebruiker daar kan doen: instellen, ' + + 'boeken, beoordelen of alleen bekijken. Bedoeld om te kunnen antwoorden op vragen als "waar vind ik ' + + 'dit" of "waar stel ik dat in", en om daarna de juiste plek te kiezen: de namen in het antwoord zijn ' + + 'dezelfde die navigate_to als plek aanvaardt. Leest de administratie niet en wijzigt niets; het ' + + 'antwoord is voor elke installatie gelijk.', + inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }), + scope: 'none', + + async run() { + assertScope(appPlaces.scope); + return { + text: `De plekken in AssetApp en wat u er kunt doen:\n${regels()}`, + fieldsShared: [], + }; + }, +}); + +export const PLACES_TOOLS = Object.freeze([appPlaces]); diff --git a/server/mcp/price-tool.mjs b/server/mcp/price-tool.mjs new file mode 100644 index 0000000..e6f3b41 --- /dev/null +++ b/server/mcp/price-tool.mjs @@ -0,0 +1,217 @@ +// Generated, do not edit. sha256:d62e710a5bda41c8a7b1a7ff304738dfd9331056f2936a826970a6092995f68b +import { CONFIG, SOURCE_CAPABILITIES } from '../../shared/config.js'; +import { listAssets } from '../data/assets.js'; +import { listPriceHistory, listPriceGaps, listIgnoredPriceGaps } from '../data/price-history.js'; +import { listProviders, getProvider } from '../data/providers.js'; +import { listPriceSource } from '../data/price-source.js'; +import { apiMetricsTotals } from '../data/api-metrics.js'; +import { dailyProvenance } from '../../shared/price-provenance.js'; +import { diagnoseSources } from '../../shared/source-diagnosis.js'; +import { serviceCoveredAssets } from './service-cover.mjs'; +import { priceFromBase, priceUnitLabel } from '../../shared/units.js'; +import { assertScope } from '../ai/gate.js'; +import { limitedList } from '../ai/limits.js'; +import { vertalerVoor } from './session.mjs'; +import { getBaseCurrency } from '../data/portfolio.js'; + +const DAG = /^\d{4}-\d{2}-\d{2}$/; +const DAG_MS = 86400000; +const plusDagen = (dag, n) => new Date(Date.parse(`${dag}T00:00:00Z`) + n * DAG_MS).toISOString().slice(0, 10); +const dagenTussen = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / DAG_MS); + +const STANDAARD_DAGEN = 14; +const MAX_DAGEN = 92; + +const SOORT_TEKST = Object.freeze({ + close: 'slotkoers', manual: 'met de hand', live: 'vandaag, beweegt nog', spot: 'afgeleid uit een spot', + carried: 'doorgetrokken', none: 'geen koers', +}); + +function getal(n) { + if (n == null || !Number.isFinite(n)) return '?'; + const d = Math.abs(n) >= 100 ? 2 : Math.abs(n) >= 1 ? 4 : 6; + return n.toLocaleString('nl-NL', { maximumFractionDigits: d, useGrouping: false }); +} + +function assetVoorSymbool(gevraagd) { + const gezocht = String(gevraagd ?? '').trim().toUpperCase(); + return gezocht ? listAssets().find(a => String(a.symbol).toUpperCase() === gezocht) || null : null; +} + +const priceData = Object.freeze({ + name: 'price_data', + title: 'Koersen van een asset', + description: + 'Alle koersinformatie van één asset: per dag de gebruikte koers met herkomst en de koers per bron ' + + '(days), de gaten (gaps), of de bronnen met keuze, munt, limiet en verbruik (sources). ' + + 'Wijzigt niets.', + inputSchema: Object.freeze({ + type: 'object', + properties: { + asset: { type: 'string', maxLength: 16, description: 'Symbool, zoals XAG.' }, + view: { type: 'string', enum: ['days', 'gaps', 'sources'] }, + from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, + until: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, + }, + required: ['asset'], + additionalProperties: false, + }), + scope: 'read', + + async run(args, context) { + assertScope(priceData.scope); + const t = await vertalerVoor(context); + t.beginCall(); + const asset = assetVoorSymbool(args?.asset); + if (!asset) { + const symbolen = listAssets().map(a => a.symbol).join(', '); + return { text: `Geen asset met symbool '${String(args?.asset ?? '')}'. Er zijn: ${symbolen}.`, fieldsShared: ['symbol'] }; + } + const view = args?.view || 'days'; + const sleutelVan = providerSleutels(t); + if (view === 'gaps') return gatenAntwoord(asset, t); + if (view === 'sources') return bronnenAntwoord(asset, t, sleutelVan, (await serviceCoveredAssets()).has(asset.id)); + return dagenAntwoord(asset, args, t, sleutelVan); + }, +}); + +function providerSleutels(t) { + const m = new Map(listProviders().map(p => [p.id, t.toAi({ key: p.key }, { entity: 'provider' }).key])); + return id => m.get(id) ?? `bron ${id}`; +} + +function dagenAntwoord(asset, args, t, sleutelVan) { + const vandaag = new Date().toISOString().slice(0, 10); + let until = DAG.test(String(args?.until ?? '')) ? args.until : vandaag; + let from = DAG.test(String(args?.from ?? '')) ? args.from : plusDagen(until, -(STANDAARD_DAGEN - 1)); + if (from > until) [from, until] = [until, from]; + let ingekort = false; + if (dagenTussen(from, until) + 1 > MAX_DAGEN) { from = plusDagen(until, -(MAX_DAGEN - 1)); ingekort = true; } + + const rijen = listPriceHistory(asset.id, plusDagen(from, -31)).filter(r => r.observedAt.slice(0, 10) <= until); + const manual = listProviders().find(p => p.key === 'manual'); + const dagen = dailyProvenance(rijen, { assetKind: asset.kind, manualProviderId: manual ? manual.id : null, + today: vandaag, from, until }); + const eenheid = priceUnitLabel(asset); + const prijs = (fiatPrice) => getal(priceFromBase(t.toAi({ fiatPrice },{ entity: 'priceHistory' }).fiatPrice, asset)); + const grens = CONFIG.HYGIENE.SOURCES_DISAGREE_SHARE; + + const perDag = new Map(); + for (const r of rijen) { + const dag = r.observedAt.slice(0, 10); + if (dag < from) continue; + if (!perDag.has(dag)) perDag.set(dag, []); + perDag.get(dag).push(r); + } + + const regels = limitedList(dagen.length ? dagen : [], d => { + const gebruikt = d.kind === 'none' + ? 'geen koers' + : `${prijs(d.fiatPrice)} (${SOORT_TEKST[d.kind] || d.kind}${d.row ? `, ${sleutelVan(d.row.providerId)}` : ''}` + + `${d.kind === 'carried' ? ` van ${d.row.observedAt.slice(0, 10)}, ${d.ageDays} dagen` : ''})`; + const bronnen = (perDag.get(d.day) || []).map(r => { + const prijsTekst = r.fiatPrice != null && r.fiatPrice > 0 ? prijs(r.fiatPrice) : 'leeg'; + const moment = r.resolution === 'timestamp' ? ` spot ${r.observedAt.slice(11, 16)} UTC` : ''; + return `${sleutelVan(r.providerId)}${moment} ${prijsTekst}`; + }); + const dagrijen = (perDag.get(d.day) || []).filter(r => r.resolution === 'day' && r.fiatPrice > 0); + let oneens = ''; + if (new Set(dagrijen.map(r => r.providerId)).size > 1) { + const laag = Math.min(...dagrijen.map(r => r.fiatPrice)), hoog = Math.max(...dagrijen.map(r => r.fiatPrice)); + const spreiding = hoog / laag - 1; + if (spreiding > grens) oneens = ` · BRONNEN ONEENS: ${getal(Math.round(spreiding * 1000) / 10)}%`; + } + return `${d.day}: ${gebruikt}${bronnen.length ? ` · ${bronnen.join('; ')}` : ''}${oneens}`; + }).lines; + + return { + text: [ + `Koersen van ${t.toAi(asset, { entity: 'asset' }).symbol} in ${getBaseCurrency()} per ${eenheid}, ${from} t/m ${until}` + + `${ingekort ? ` (ingekort tot ${MAX_DAGEN} dagen)` : ''}. Per dag: de koers die de app gebruikt met` + + ' herkomst en bron, daarna elke opgeslagen koers per bron. Een spot is een moment, geen slot.', + ...(regels.length ? regels : ['Geen koersen in dit venster, en geen die ervoor doorgetrokken kan worden.']), + ].join('\n'), + fieldsShared: [...new Set([...t.fieldsShared(), 'baseCurrency', 'symbol', 'kind', 'unit', 'observedAt', 'fiatPrice', + 'resolution', 'key', 'updatedAt'])].sort(), + }; +} + +function gatenAntwoord(asset, t) { + const open = listPriceGaps().rows.filter(g => g.assetId === asset.id); + const genegeerd = listIgnoredPriceGaps().filter(g => g.assetId === asset.id); + const symbool = t.toAi(asset, { entity: 'asset' }).symbol; + return { + text: [ + `Gaten in de koershistorie van ${symbool} (dagen zonder koers, vanaf de eerste boeking t/m gisteren,` + + ` langer dan ${CONFIG.HYGIENE.PRICE_GAP_DAYS - 1} dagen):`, + ...(open.length ? limitedList(open, g => `- ${g.from} t/m ${g.until}, ${g.days} dagen`).lines : ['- geen']), + 'Door de gebruiker genegeerd (telt als gedekt):', + ...(genegeerd.length ? limitedList(genegeerd, g => `- ${g.fromDay} t/m ${g.untilDay}`).lines : ['- geen']), + ].join('\n'), + fieldsShared: ['symbol', 'observedAt', 'fromDay', 'untilDay'], + }; +} + +function bronnenAntwoord(asset, t, sleutelVan, uitDienst = false) { + const maand = `${new Date().toISOString().slice(0, 7)}-01T00:00`; + const etmaal = new Date(Date.now() - DAG_MS).toISOString().slice(0, 16); + const providers = listProviders().map(p => getProvider(p.key)).filter(Boolean); + const usage = {}; + for (const p of providers) if (p.callsPerMonth) usage[p.id] = apiMetricsTotals(p.id, maand).calls; + const cellen = listPriceSource().filter(c => c.assetId === asset.id); + const rijen = listPriceHistory(asset.id); + + const keten = SOURCE_CAPABILITIES.map(cap => { + const k = cellen.filter(c => c.capability === cap && c.rank != null).sort((a, b) => a.rank - b.rank) + .map(c => { const v = t.toAi({ rank: c.rank, status: c.status }, { entity: 'priceSource' }); + return `${v.rank}. ${sleutelVan(c.providerId)} (${v.status})`; }); + return `${cap === 'spot' ? 'Spot' : 'Historie'}: ${k.length ? k.join(', ') : 'geen bron gekozen'}`; + }); + + const dekkend = providers.filter(p => (p.coverage || []).includes(asset.symbol) || p.key === 'manual'); + const regels = limitedList(dekkend, p => { + const rauw = (p.symbols || []).find(s => !s.isCatchAll && s.symbol === asset.symbol) + || (p.symbols || []).find(s => s.isCatchAll) || null; + const kaart = rauw ? t.toAi({ externalId: rauw.externalId, quoteUnit: rauw.quoteUnit }, { entity: 'provider' }) : null; + const eindpunten = (p.endpoints || []).map(e => { + const v = t.toAi({ capability: e.capability, quoteCurrency: e.quoteCurrency }, { entity: 'provider' }); + return `${v.capability}${v.quoteCurrency ? ` in ${v.quoteCurrency}` : ''}`; + }); + const eigen = rijen.filter(r => r.providerId === p.id); + const laatste = eigen[0] ? eigen[0].observedAt.slice(0, 10) : null; + const eerste = eigen.length ? eigen[eigen.length - 1].observedAt.slice(0, 10) : null; + const m = apiMetricsTotals(p.id, etmaal); + const delen = [ + p.isActive ? 'actief' : 'uit', + eindpunten.length ? eindpunten.join(', ') : 'geen eindpunten', + ...(kaart ? [`invoer ${kaart.externalId}${kaart.quoteUnit ? `, per ${kaart.quoteUnit}` : ''}`] : []), + ...(p.callsPerMonth ? [`${usage[p.id] ?? 0} van ${p.callsPerMonth} verzoeken deze maand`] : []), + `${m.calls} aanroepen in 24 uur, ${m.fail} mislukt`, + eigen.length ? `${eigen.length} koersen, ${eerste} t/m ${laatste}` : 'geen koersen opgeslagen', + ]; + return `- ${sleutelVan(p.id)}: ${delen.join('; ')}`; + }).lines; + + const historyChain = cellen.filter(c => c.capability === 'history' && c.rank != null) + .sort((a, b) => a.rank - b.rank).map(c => providers.find(p => p.id === c.providerId)?.key).filter(Boolean); + const bevindingen = diagnoseSources(asset, { providers, historyChain, usage, serviceCovers: uitDienst }) + .map(b => `- ${b.code}${(b.names || []).length ? ` (${b.names.join(', ')})` : ''}` + + `${b.proposal ? `, voorstel: ${b.proposal.providerKey}` : ''}`); + + return { + text: [ + `Bronnen van ${t.toAi(asset, { entity: 'asset' }).symbol} (koersen worden per gram opgeslagen bij metaal):`, + ...keten, + ...(uitDienst ? ['Dienst: historie uit Atreides Services (stand niet uit, symbool gedekt).'] : []), + 'Bronnen die dit symbool dekken:', + ...(regels.length ? regels : ['- geen']), + 'Diagnose van de bronkeuze:', + ...(bevindingen.length ? bevindingen : ['- in orde']), + ].join('\n'), + fieldsShared: [...new Set([...t.fieldsShared(), 'symbol', 'key', 'isActive', 'capability', 'rank', 'status', + 'quoteCurrency', 'quoteUnit', 'externalId', 'callsPerMonth', 'okCount', 'failCount', 'observedAt', + ...(uitDienst ? ['value'] : [])])].sort(), + }; +} + +export const PRICE_TOOLS = Object.freeze([priceData]); diff --git a/server/mcp/protocol.mjs b/server/mcp/protocol.mjs new file mode 100644 index 0000000..ff78bd5 --- /dev/null +++ b/server/mcp/protocol.mjs @@ -0,0 +1,159 @@ +// Generated, do not edit. sha256:6e59cd9bc5c34f22b316c129925de56d6e800bca08debd1308bf90cb9f449baa +import { randomUUID } from 'node:crypto'; + +import { CONFIG } from '../../shared/config.js'; +import { AI_REFUSALS } from '../ai/gate.js'; +import { limitText } from '../ai/limits.js'; +import { ENUMS } from '../../shared/config.js'; + +const PARSE_ERROR = -32700; +const INVALID_REQUEST = -32600; +const METHOD_NOT_FOUND = -32601; +const INVALID_PARAMS = -32602; + +const fout = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } }); +const goed = (id, result) => ({ jsonrpc: '2.0', id, result }); + +export function createMcpServer({ tools, logCall, onLogError } = {}) { + if (typeof logCall !== 'function') { + throw new Error('An MCP server without an audit log is not allowed in this project.'); + } + if (!Array.isArray(tools) || tools.length === 0) { + throw new Error('An MCP server without tools is meaningless.'); + } + const zonderScope = tools.filter(t => !ENUMS.AI_SCOPE.includes(t?.scope)); + if (zonderScope.length) { + const namen = zonderScope.map(t => t?.name ?? '(naamloos)').join(', '); + throw new Error(`Tool without a valid scope: ${namen}. Allowed is ${ENUMS.AI_SCOPE.join(', ')}.`); + } + const perNaam = new Map(tools.map(t => [t.name, t])); + + const sessie = { id: randomUUID(), client: null, initialized: false }; + + async function log(regel, messages) { + try { + await logCall({ + sessionId: sessie.id, client: sessie.client, + serverVersion: CONFIG.AI.SERVER_VERSION, ...regel, + ...(messages ? { messages } : {}), + }); + } catch (e) { + if (onLogError) onLogError(e); + else throw e; + } + } + + const gesprek = (binnenAt, args, uit) => [ + { kind: 'request', text: JSON.stringify(args ?? {}), occurredAt: binnenAt }, + { kind: 'response', text: String(uit ?? ''), occurredAt: new Date().toISOString() }, + ]; + + async function voerUit(naam, args, droog) { + const binnenAt = new Date().toISOString(); + const tool = perNaam.get(naam); + if (!tool) { + const regel = { tool: naam, arguments: args, outcome: 'refused', detail: 'Deze tool bestaat niet.' }; + if (!droog) await log(regel, gesprek(binnenAt, args, regel.detail)); + return { regel, text: regel.detail, onbekend: true }; + } + try { + const { text, fieldsShared } = await tool.run(args, { session: sessie }); + const { text: uitvoer, capped } = limitText(String(text ?? '')); + const regel = { + tool: naam, arguments: args, fieldsShared: fieldsShared ?? [], + charactersOut: uitvoer.length, outcome: 'ok', + detail: capped ? 'Antwoord liep tegen de tekengrens en is ingekort.' : undefined, + }; + if (!droog) await log(regel, gesprek(binnenAt, args, uitvoer)); + return { regel, text: uitvoer }; + } catch (e) { + const melding = e && e.message ? e.message : String(e); + const soort = e && AI_REFUSALS.includes(e.code) ? 'refused' : 'error'; + const regel = { tool: naam, arguments: args, outcome: soort, detail: melding }; + if (!droog) await log(regel, gesprek(binnenAt, args, melding)); + return { regel, text: melding, mislukt: true }; + } + } + + async function toolsCall(id, params) { + const naam = params && typeof params.name === 'string' ? params.name : null; + const args = params && params.arguments && typeof params.arguments === 'object' ? params.arguments : {}; + if (!naam) { + await log({ tool: '(geen)', outcome: 'refused', detail: 'Verzoek zonder toolnaam.' }); + return fout(id, INVALID_PARAMS, 'Een tools/call-verzoek noemt geen tool.'); + } + const r = await voerUit(naam, args, false); + if (r.onbekend) return fout(id, INVALID_PARAMS, `Onbekende tool '${naam}'.`); + if (r.mislukt) return goed(id, { content: [{ type: 'text', text: r.text }], isError: true }); + return goed(id, { content: [{ type: 'text', text: r.text }] }); + } + + async function dryRun(naam, args = {}) { + const veiligeArgs = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; + const r = await voerUit(String(naam ?? ''), veiligeArgs, true); + return { + ...r.regel, + fieldsShared: r.regel.fieldsShared ?? [], + charactersOut: r.regel.charactersOut ?? null, + detail: r.regel.detail ?? null, + text: r.text, + }; + } + + async function handle(bericht) { + if (!bericht || typeof bericht !== 'object' || Array.isArray(bericht)) { + return fout(null, INVALID_REQUEST, 'Geen JSON-RPC-object.'); + } + const { id, method, params } = bericht; + const isNotificatie = id === undefined || id === null; + if (typeof method !== 'string') { + return isNotificatie ? null : fout(id, INVALID_REQUEST, 'Verzoek zonder methode.'); + } + + switch (method) { + case 'initialize': { + sessie.client = params?.clientInfo?.name ? String(params.clientInfo.name) : null; + sessie.initialized = true; + return goed(id, { + protocolVersion: typeof params?.protocolVersion === 'string' + ? params.protocolVersion : CONFIG.AI.PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: CONFIG.AI.SERVER_NAME, version: CONFIG.AI.SERVER_VERSION }, + }); + } + case 'notifications/initialized': + case 'initialized': + return null; + + case 'ping': + return isNotificatie ? null : goed(id, {}); + + case 'tools/list': { + if (isNotificatie) return null; + return goed(id, { + tools: tools.map(t => ({ + name: t.name, + title: t.title, + description: t.description, + inputSchema: t.inputSchema, + })), + }); + } + + case 'tools/call': + if (isNotificatie) return null; + return toolsCall(id, params); + + default: + return isNotificatie ? null : fout(id, METHOD_NOT_FOUND, `Onbekende methode '${method}'.`); + } + } + + async function handleRegel(regel) { + let bericht; + try { bericht = JSON.parse(regel); } catch { return fout(null, PARSE_ERROR, 'Ongeldige JSON.'); } + return handle(bericht); + } + + return { handle, handleRegel, dryRun, get session() { return { ...sessie }; } }; +} diff --git a/server/mcp/read-tools.mjs b/server/mcp/read-tools.mjs new file mode 100644 index 0000000..504abc7 --- /dev/null +++ b/server/mcp/read-tools.mjs @@ -0,0 +1,414 @@ +// Generated, do not edit. sha256:df37b3da57b8535420153ba9f7331337454235c42435f0fd9deff4f9829eb96b +import { listProviders, getProvider } from '../data/providers.js'; +import { listPriceSource } from '../data/price-source.js'; +import { diagnoseSources } from '../../shared/source-diagnosis.js'; +import { serviceCoveredAssets } from './service-cover.mjs'; +import { apiMetricsTotals } from '../data/api-metrics.js'; +import { listConnections } from '../data/connections.js'; +import { listAssets } from '../data/assets.js'; +import { getBaseCurrency } from '../data/portfolio.js'; +import { listAccounts } from '../data/accounts.js'; +import { listLabels, labelUsage } from '../data/labels.js'; +import { counts } from '../data/counts.js'; +import { listMetalProducts } from '../data/metals.js'; +import { listProfiles, listWidgets } from '../data/dashboard.js'; +import { getPref } from '../data/preferences.js'; +import { hygieneReport } from '../data/hygiene.js'; +import { listScanLogs } from '../data/scan-logs.js'; +import { listIntegrationCalls } from '../data/integration-log.js'; +import { assertScope, mayScope } from '../ai/gate.js'; +import { limitedList, limitRows, omittedNote } from '../ai/limits.js'; +import { vertalerVoor } from './session.mjs'; +import { CONFIG, PREFERENCES, SOURCE_CAPABILITIES, TAX_CATEGORY_BY_ASSET_KIND, balanceCaptureOn } from '../../shared/config.js'; +import { fromBaseByFactor } from '../../shared/units.js'; + +const GEEN_BEDRAGEN = 'Zonder bedragen, saldi of adressen, en wijzigt niets.'; + +function sindsUren(uren) { + return new Date(Date.now() - uren * 3600_000).toISOString().slice(0, 16); +} + +function pct(deel, geheel) { + return geheel ? `${Math.round((deel / geheel) * 1000) / 10}%` : '0%'; +} + +const VENSTER_UREN = CONFIG.AI.LIMITS.METRICS_WINDOW_HOURS; + +function scanBlok(t) { + const regels = []; + for (const rekening of listAccounts()) { + const logs = listScanLogs(rekening.id); + if (!logs.length && !rekening.scanEnabled) continue; + const veiligeRekening = t.toAi(rekening, { entity: 'account' }); + if (!logs.length) { + regels.push(`- ${veiligeRekening.id}: staat op automatisch scannen maar is nog nooit gescand.`); + continue; + } + const laatste = t.toAi({ + status: logs[0].status, + scannedAt: logs[0].scannedAt, + addressesFound: logs[0].addressesFound, + proposalsCreated: logs[0].proposalsCreated, + }, { entity: 'scanLog' }); + const mislukt = logs.filter(l => l.status === 'failed').length; + regels.push( + `- ${veiligeRekening.id}: laatste scan ${laatste.status} op ${laatste.scannedAt}` + + `, ${laatste.addressesFound ?? 0} adressen, ${laatste.proposalsCreated ?? 0} voorstellen` + + ` (${logs.length} scans in totaal, ${mislukt} mislukt).`); + } + return limitedList(regels, r => r).lines; +} + +const DIAGNOSE_TEKST = Object.freeze({ + noCoverage: () => 'geen enkele bron levert hier historie voor', + allInactive: n => `de dekkende bronnen staan uit (${n})`, + noChoice: () => 'geen bron gekozen voor de historie', + choiceUnusable: n => `de gekozen bron werkt niet (${n})`, + limitReached: n => `maandlimiet bereikt (${n})`, + choiceInactive: n => `gekozen maar uitgezet (${n})`, +}); +function bronkeuzeBlok(t, uitDienst = new Set()) { + const maand = `${new Date().toISOString().slice(0, 7)}-01T00:00`; + const providers = listProviders().map(p => getProvider(p.key)).filter(Boolean); + const perId = new Map(providers.map(p => [p.id, p])); + const usage = {}; + for (const p of providers) if (p.callsPerMonth) usage[p.id] = apiMetricsTotals(p.id, maand).calls; + const cellen = listPriceSource().filter(c => c.rank != null).sort((a, b) => a.rank - b.rank); + const regels = []; + const basis = getBaseCurrency(); + for (const asset of listAssets()) { + if (asset.symbol === basis) continue; + const veilig = t.toAi(asset, { entity: 'asset' }); + const keten = cap => cellen.filter(c => c.assetId === asset.id && c.capability === cap) + .map(c => perId.get(c.providerId)).filter(Boolean); + const naam = p => `${t.toAi({ key: p.key }, { entity: 'provider' }).key}${p.isActive ? '' : ' (uit)'}`; + const delen = SOURCE_CAPABILITIES.map(cap => { + const k = keten(cap); + return `${cap === 'spot' ? 'spot' : 'historie'}: ${k.length ? k.map(naam).join(', ') : 'geen'}`; + }); + const gekozen = new Set(cellen.filter(c => c.assetId === asset.id).map(c => c.providerId)); + const vrij = providers.filter(p => !p.isSystem && (p.coverage || []).includes(asset.symbol) && !gekozen.has(p.id)); + if (vrij.length) delen.push(`dekt ook: ${vrij.map(naam).join(', ')}`); + const dienst = uitDienst.has(asset.id); + if (dienst) delen.push('historie uit Atreides Services'); + const bevindingen = diagnoseSources(asset, { providers, historyChain: keten('history').map(p => p.key), usage, + serviceCovers: dienst }); + for (const b of bevindingen) { + const namen = (b.names || []).join(', '); + const tekst = (DIAGNOSE_TEKST[b.code] || (() => b.code))(namen); + delen.push(b.proposal ? `${tekst}, voorstel: ${b.proposal.providerKey}` : tekst); + } + regels.push(`- ${veilig.symbol} (${veilig.kind}): ${delen.join('; ')}.`); + } + return limitedList(regels, r => r).lines; +} + +const ILOG = CONFIG.INTEGRATION_LOG; +function foutenBlok(t, magAdministratie) { + const rijen = listIntegrationCalls({ days: ILOG.WINDOW_DAYS, limit: ILOG.MAX_ROWS }); + const regels = rijen.slice(0, ILOG.AI_MAX_ROWS).map(r => { + const v = t.toAi({ + occurredAt: r.occurredAt, providerKey: r.providerKey, operation: r.operation, outcome: r.outcome, + httpStatus: r.httpStatus, errorKey: r.errorKey, requestId: r.requestId, + ...(magAdministratie ? { subject: r.subject } : {}), + }, { entity: 'integrationCall' }); + const bron = r.target === 'service' ? 'atreides_services' : v.providerKey; + return `- ${v.occurredAt} ${bron} ${v.operation}${v.subject ? ` ${v.subject}` : ''}: ${v.outcome}` + + `${v.httpStatus ? ` ${v.httpStatus}` : ''}${v.errorKey ? ` ${v.errorKey}` : ''}` + + `${v.requestId ? ` (request-id ${v.requestId})` : ''}`; + }); + if (rijen.length > regels.length) regels.push(`- en nog ${rijen.length - regels.length} oudere.`); + return regels; +} + +const integrationHealth = Object.freeze({ + name: 'integration_health', + title: 'Gezondheid van de koppelingen', + description: + 'Geeft de technische toestand van de externe koppelingen van AssetApp: per koersbron de geslaagde ' + + 'en mislukte aanroepen van de afgelopen 24 uur met de gemiddelde en langste reactietijd, per ' + + 'verbinding de laatst gemeten status, per automatisch gescand account de uitslag van de ' + + 'laatste wallet-scan, per asset de bronkeuze voor spot en historie met wat eraan schort, en de recente ' + + 'fouten met request-id. Zegt dus of een koersbron onbetrouwbaar is, een verbinding stuk staat of een ' + + 'scan niet meer doorkomt. Verbindingen en accounts worden aangeduid met een verwijzing zoals ' + + 'connection:7f3a9c, zodat twee koppelingen van dezelfde soort te onderscheiden zijn. ' + + GEEN_BEDRAGEN, + inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }), + scope: 'none', + + async run(_args, context) { + assertScope('none'); + const t = await vertalerVoor(context); + const sinds = sindsUren(VENSTER_UREN); + + const regels = limitedList(listProviders(), p => { + const veilig = t.toAi(p, { entity: 'provider' }); + const m = apiMetricsTotals(p.id, sinds); + const status = veilig.isActive ? 'actief' : 'uitgeschakeld'; + return m.calls === 0 + ? `- ${veilig.key} (${status}): geen aanroepen in de afgelopen ${VENSTER_UREN} uur.` + : `- ${veilig.key} (${status}): ${m.calls} aanroepen, ${m.fail} mislukt (${pct(m.fail, m.calls)}), ` + + `gemiddeld ${m.avg_latency_ms} ms, langste ${m.maxLatencyMs} ms.`; + }).lines; + + const verbindingen = limitedList(listConnections(), c => { + const veilig = t.toAi(c, { entity: 'connection' }); + const uitslag = veilig.lastStatus + ? `${veilig.lastStatus} (gemeten ${veilig.lastCheckedAt})` + : 'nog nooit getest'; + return `- ${veilig.id} (${veilig.kind}${veilig.isActive ? ', actief' : ''}): ${uitslag}`; + }).lines; + + const magAdministratie = mayScope('read'); + const scans = magAdministratie ? scanBlok(t) : []; + const uitDienst = magAdministratie ? await serviceCoveredAssets() : new Set(); + const bronkeuze = magAdministratie ? bronkeuzeBlok(t, uitDienst) : []; + const fouten = foutenBlok(t, magAdministratie); + + return { + text: [ + `Koersbronnen, afgelopen ${VENSTER_UREN} uur:`, + regels.length ? regels.join('\n') : '- geen koersbronnen ingesteld.', + '', + 'Verbindingen:', + verbindingen.length ? verbindingen.join('\n') : '- geen verbindingen ingesteld.', + '', + 'Wallet-scans:', + !magAdministratie + ? '- niet opgevraagd: de AI-koppeling van AssetApp staat uit, en scans horen bij een account.' + : (scans.length ? scans.join('\n') : '- geen account staat op automatisch scannen.'), + '', + 'Bronkeuze per asset (in volgorde; wijzigen doet de gebruiker op Integraties, Bronkeuze):', + !magAdministratie + ? '- niet opgevraagd: de AI-koppeling van AssetApp staat uit, en de assets horen bij de administratie.' + : (bronkeuze.length ? bronkeuze.join('\n') : '- geen assets.'), + '', + `Fouten, afgelopen ${ILOG.WINDOW_DAYS} dagen (nieuwste eerst):`, + fouten.length ? fouten.join('\n') : '- geen.', + ].join('\n'), + fieldsShared: [ + 'key', 'isActive', 'okCount', 'failCount', 'sumLatencyMs', 'maxLatencyMs', + 'kind', 'lastStatus', 'lastCheckedAt', 'id', + ...(magAdministratie ? ['scanEnabled', 'status', 'scannedAt', 'addressesFound', 'proposalsCreated', + 'symbol', 'capability', 'rank', 'callsPerMonth'] : []), + ...(uitDienst.size ? ['value'] : []), + ...(fouten.length ? ['occurredAt', 'providerKey', 'operation', 'outcome', 'httpStatus', 'errorKey', 'requestId', + ...(magAdministratie ? ['subject'] : [])] : []), + ], + }; + }, +}); + +const adminOverview = Object.freeze({ + name: 'admin_overview', + title: 'Overzicht van de inrichting', + description: + 'Geeft de inrichting van AssetApp: welke assets er zijn (met hun symbool, soort en eenheid), ' + + 'hoeveel accounts er zijn en van welke soort, welke labels bestaan en op hoeveel on-chain ' + + 'bewegingen elk label gebruikt wordt, en hoeveel gebeurtenissen ' + + 'en balansen er zijn vastgelegd. Bedoeld voor vragen over de opzet en om ' + + 'te zien wat er ontbreekt. Plus de configuratie: metaalproducten, dashboardprofielen, ' + + 'de app-instellingen en de timers. ' + + 'Accounts en labels krijgen een verwijzing zoals account:7f3a9c, ' + + 'ook in een later gesprek dezelfde; met onbewerkt delen aan is dat het rij-id met de naam ' + + 'erachter. ' + GEEN_BEDRAGEN, + inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }), + scope: 'read', + + async run(_args, context) { + assertScope(adminOverview.scope); + const t = await vertalerVoor(context); + + const alleAssets = listAssets(); + const alleRekeningen = listAccounts(); + const alleLabels = listLabels(); + + const gekozen = (waarde, afgeleid, bron) => (waarde != null ? `${waarde} (gekozen)` : `${afgeleid} (${bron})`); + const assets = limitedList(alleAssets, a => { + const v = t.toAi(a, { entity: 'asset' }); + return `- ${v.symbol} (${v.kind}, eenheid ${v.unit}${v.subunit ? `, ondereenheid ${v.subunit}` : ''}; ` + + `categorie ${gekozen(v.taxCategory, TAX_CATEGORY_BY_ASSET_KIND[v.kind] ?? 'onbekend', 'volgt soort')}, ` + + `belastingen ${gekozen(v.taxScope, 'all', 'standaard')}, ` + + `saldo vastleggen ${gekozen(v.balanceCapture == null ? null : (v.balanceCapture ? 'aan' : 'uit'), + balanceCaptureOn({ kind: v.kind }) ? 'aan' : 'uit', 'volgt soort')})`; + }).lines; + + const onbewerkt = t.precision.rawValues; + const naamErbij = v => (onbewerkt && v.name ? ` (${v.name})` : ''); + + const rekeningen = limitedList(alleRekeningen, a => { + const v = t.toAi(a, { entity: 'account' }); + const eigen = v.isOwnCustody ? 'zelf in bewaring' : 'bij een derde partij'; + return `- ${v.id}${naamErbij(v)}: soort ${v.kind}, ${eigen}, ${v.isActive ? 'actief' : 'afgesloten'}` + + `${v.scanEnabled ? ', wordt automatisch gescand' : ''}` + + `; categorie ${v.taxCategory ?? 'volgt asset'}, belastingen ${v.taxScope ?? 'volgt asset'}` + + `, vermogen ${v.wealthScope ?? 'eigen'}`; + }).lines; + + const gebruik = new Map(labelUsage().map(r => [r.labelId, Number(r.walletTransactions)])); + const labels = limitedList(alleLabels, l => { + const n = gebruik.get(l.id) ?? 0; + const hoe = n === 0 ? 'nergens gebruikt' + : n === 1 ? 'gebruikt op 1 beweging' + : `gebruikt op ${n} bewegingen`; + const v = t.toAi(l, { entity: 'label' }); + return `- ${v.id}${naamErbij(v)}: ${hoe}`; + }).lines; + const c = counts(); + + const alleProducten = listMetalProducts(); + const perAsset = new Map(); + for (const p of alleProducten) { + const key = p.asset_symbol; + if (!perAsset.has(key)) perAsset.set(key, new Map()); + const vormen = perAsset.get(key); + vormen.set(p.form, (vormen.get(p.form) ?? 0) + 1); + } + const producten = onbewerkt + ? limitedList(alleProducten, p => + `- ${p.asset_symbol} (${p.name}): ${p.form}, ${fromBaseByFactor(p.fineGrams, 'g')} g fijn`).lines + : [...perAsset].map(([symbool, vormen]) => + `- ${symbool}: ${[...vormen].map(([vorm, n]) => `${n}× ${vorm}`).join(', ')}`); + + const profielen = listProfiles().map(p => + `- ${onbewerkt && p.name ? p.name : `profiel ${p.sortOrder}`}: ` + + `${p.isActive ? 'actief' : 'niet actief'}, ${listWidgets(p.id).length} widgets`); + + const instellingen = []; + for (const scope of CONFIG.AI.SHARED_PREF_SCOPES) { + for (const key of Object.keys(PREFERENCES[scope] ?? {})) { + if (key === '*') continue; + const v = t.toAi({ scope, key, value: getPref(scope, key) }, { table: 'Preference' }); + if (v.value === undefined) continue; + const waarde = v.value === null || v.value === '' ? 'leeg' : v.value; + instellingen.push(`- ${v.scope}.${v.key} = ${waarde}`); + } + } + + return { + text: [ + `Rekenvaluta: ${getBaseCurrency()}. Elke koers en elk bedrag in AssetApp staat in deze munt.`, + '', + `Assets (${alleAssets.length}):`, + assets.length ? assets.join('\n') : '- geen.', + '', + `Accounts (${alleRekeningen.length}):`, + rekeningen.length ? rekeningen.join('\n') : '- geen.', + '', + `Labels (${alleLabels.length}):`, + labels.length ? labels.join('\n') : '- geen.', + '', + `Vastgelegd: ${c.events} gebeurtenissen en ${c.valuations} balansen.`, + '', + `Metaalproducten (${alleProducten.length}):`, + producten.length ? producten.join('\n') : '- geen.', + '', + `Dashboardprofielen (${profielen.length}):`, + profielen.length ? profielen.join('\n') : '- geen.', + '', + `Instellingen (${instellingen.length}):`, + instellingen.length ? instellingen.join('\n') : '- geen.', + ].join('\n'), + fieldsShared: ['baseCurrency', 'id', 'symbol', 'kind', 'unit', 'subunit', 'isOwnCustody', 'isActive', 'scanEnabled', + 'taxCategory', 'taxScope', 'wealthScope', 'balanceCapture', + 'labelId', 'form', 'sortOrder', 'profileId', 'scope', 'key', 'Preference.value', + ...(onbewerkt ? ['name', 'fineGrams'] : [])], + }; + }, +}); + +const BEVINDING_TEKST = Object.freeze({ + duplicate_events: 'gebeurtenissen die er twee of meer keer identiek in staan', + assets_without_balance: 'assets waarin geboekt is maar waarvoor nooit een balans is vastgelegd', + unreconciled_valuations: 'balansen die niet aansluiten op de boekingen eronder, en die nog niet zijn afgehandeld', + stale_prices: 'assets waarvan de koershistorie achterloopt op de laatste boeking, of ontbreekt', + unlinked_transactions: 'on-chain bewegingen zonder koppeling en zonder openstaand voorstel', + late_price_history: 'assets waarvan de koershistorie later begint dan de eerste boeking', + price_gaps: 'assets met een gat tussen twee koersen (dagen zonder koers, vanaf de eerste boeking), waardoor daar een oude koers wordt meegesleept', + price_stopped: 'assets in bezit waarvan de laatste koers ouder is dan de doortrekgrens van hun soort, dus nu niet te waarderen', + transfer_without_counterpart: 'verplaatsingen waarvan één kant ontbreekt zonder extern adres', + fee_without_effect: 'boekingen met kosten die geen berekening leest (geld bij rente, eenheden bij aankoop of rente)', + coverage_without_asset: 'koersbronnen die dekking claimen op een symbool dat hier geen asset is', + price_unit_swap: 'koersrijen die een factor 31 (gram tegen troy ounce) van de rij ervoor liggen', + price_outlier: 'koersrijen die een factor drie of meer van beide buren liggen, zoals een tikfout', + price_sources_disagree: 'dagen waarop twee bronnen een koers geven die ver uit elkaar ligt', + price_not_positive: 'koersrijen met een koers van nul of lager, die de app niet meetelt', + price_in_future: 'koersrijen met een moment in de toekomst', + booking_price_deviates: 'aan- en verkopen waarvan de prijs ver van de koers van die dag ligt (tikfout, datum of eenheid)', + price_origin_derivable: 'boekingen zonder herkomst waarvan de prijs de dagkoers is, in te vullen op Gezondheid', +}); + +const DAG_VELDEN = Object.freeze({ + stale_prices: Object.freeze(['occurredAt', 'observedAt']), + unlinked_transactions: Object.freeze(['createdAt']), + price_unit_swap: Object.freeze(['observedAt']), + price_outlier: Object.freeze(['observedAt']), + price_sources_disagree: Object.freeze(['observedAt']), + price_not_positive: Object.freeze(['observedAt']), + price_in_future: Object.freeze(['observedAt']), + booking_price_deviates: Object.freeze(['occurredAt']), + price_origin_derivable: Object.freeze(['occurredAt']), +}); + +function opDag(waarde) { + return typeof waarde === 'string' && waarde.length >= 10 ? waarde.slice(0, 10) : waarde; +} + +function grofPerBevinding(soort, rij) { + const velden = DAG_VELDEN[soort]; + if (!velden) return rij; + const uit = { ...rij }; + for (const veld of velden) if (veld in uit) uit[veld] = opDag(uit[veld]); + return uit; +} + +const dataHealth = Object.freeze({ + name: 'data_health', + title: 'Controle op de eigen administratie', + description: + 'Zoekt inconsistenties in de administratie van AssetApp en geeft per soort het aantal en de ' + + 'betrokken rijen terug: dubbele boekingen, assets zonder vastgelegde balans, balansen die niet ' + + 'aansluiten op de boekingen eronder, koershistorie die achterloopt, on-chain bewegingen die ' + + 'nergens aan hangen, en verwijzingen die nergens heen wijzen. Elke bevinding is een aantal of ' + + 'een verwijzing zoals account:a1, stabiel binnen dit gesprek. ' + GEEN_BEDRAGEN, + inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }), + scope: 'read', + + async run(_args, context) { + assertScope(dataHealth.scope); + const t = await vertalerVoor(context); + t.beginCall(); + const rapport = hygieneReport(); + + const blokken = rapport.findings.map(f => { + const kop = `${BEVINDING_TEKST[f.kind] || f.kind} (${f.kind}, ${f.severity}): ${f.total}`; + if (!f.total) return `${kop}.`; + const { rows: zichtbaar, omitted, capped } = limitRows(f.rows); + const grof = zichtbaar.map(r => grofPerBevinding(f.kind, r.row)); + const veilig = t.toAi(grof); + const rijen = veilig.map((r, i) => { + const delen = Object.entries(r).map(([k, v]) => `${k}=${v}`); + for (const [k, v] of Object.entries(zichtbaar[i].counts || {})) delen.push(`${k}=${v}`); + return ` - ${delen.join(', ')}`; + }); + if (capped) rijen.push(omittedNote(omitted, CONFIG.AI.LIMITS.MAX_ROWS_PER_LIST)); + return `${kop}:\n${rijen.join('\n')}`; + }); + + return { + text: [ + rapport.total === 0 + ? 'Er zijn geen inconsistenties gevonden.' + : `Er zijn ${rapport.total} bevindingen, verdeeld over ${rapport.findings.filter(f => f.total).length} soorten.`, + '', + ...blokken, + ].join('\n'), + fieldsShared: [...new Set([ + ...t.fieldsShared(), + ...rapport.findings.filter(f => f.rows.length).flatMap(f => f.countsFrom || []), + ])].sort(), + }; + }, +}); + +export const READ_TOOLS = Object.freeze([dataHealth, integrationHealth, adminOverview]); diff --git a/server/mcp/service-cover.mjs b/server/mcp/service-cover.mjs new file mode 100644 index 0000000..f277189 --- /dev/null +++ b/server/mcp/service-cover.mjs @@ -0,0 +1,13 @@ +// Generated, do not edit. sha256:2e72a438d7feb6f942a8e08f41e0d5f4748ae7098b8f0f7cfcffb93bd8fcf9e0 +import { getPref } from '../data/preferences.js'; +import { serviceCoverage } from '../data/market.js'; + +export async function serviceCoveredAssets() { + let mode = 'off'; + try { mode = getPref('app', 'market_spot') ?? 'off'; } catch { } + if (mode === 'off') return new Set(); + try { + const r = await serviceCoverage(); + return new Set(r.status === 'ok' ? r.assetIds : []); + } catch { return new Set(); } +} diff --git a/server/mcp/session.mjs b/server/mcp/session.mjs new file mode 100644 index 0000000..3420bfd --- /dev/null +++ b/server/mcp/session.mjs @@ -0,0 +1,18 @@ +// Generated, do not edit. sha256:a952ea9dd0f828339960be62a36693504bb931d6c638e950608c1e1b185bd27c +import { createTranslator } from '../ai/translate.js'; +import { handleSecret } from '../ai/handle-secret.mjs'; +import { activeName } from '../portfolios.mjs'; +import { precisionSettings, precisionKey } from '../ai/precision.js'; + +const vertalers = new Map(); + +export async function vertalerVoor(context) { + const precision = precisionSettings(); + const id = `${context?.session?.id ?? 'los'}::${activeName()}::${precisionKey(precision)}`; + if (!vertalers.has(id)) { + vertalers.set(id, createTranslator({ secret: await handleSecret(), precision })); + } + return vertalers.get(id); +} + +export function _resetVertalers() { vertalers.clear(); } diff --git a/server/mcp/stdio.mjs b/server/mcp/stdio.mjs new file mode 100644 index 0000000..15a8244 --- /dev/null +++ b/server/mcp/stdio.mjs @@ -0,0 +1,119 @@ +// Generated, do not edit. sha256:b0dd28dca0d6350eaa3cdbb0db8611b54c802d92405f31a10da8e494e94fbaf6 +import { createInterface } from 'node:readline'; + +import { CONFIG } from '../../shared/config.js'; +import { initDatabase, closeDatabase, dbFile, databaseStatus } from '../db.js'; +import { prepareLayout, activeName } from '../portfolios.mjs'; +import { openDatabase } from '../db/conn.mjs'; +import { logAiToolCall } from '../data/ai-audit.js'; +import { isAiActive, AI_INACTIVE_TEXT } from '../ai/gate.js'; +import { flushChanges } from '../data/bus.js'; +import { createMcpServer } from './protocol.mjs'; +import { allTools } from './tools.mjs'; +import '../edition.mjs'; + +const naarGebruiker = (regel) => process.stderr.write(`[assetapp-mcp] ${regel}\n`); + +function stopAlsUit() { + if (isAiActive()) return false; + naarGebruiker('de AI-koppeling staat uit (schuif "Actief" onder Systeem → AI-activiteit). ' + + 'Deze server doet niets en stopt; zet de schuif aan en start uw AI-programma opnieuw.'); + return true; +} + +try { + naarGebruiker(prepareLayout().regel); + naarGebruiker(initDatabase()); + const uit = stopAlsUit(); + closeDatabase(); + if (uit) process.exit(0); +} catch (e) { + naarGebruiker(`kan de database niet openen: ${e.message}`); + process.exit(1); +} + +const tools = allTools(); +const server = createMcpServer({ + tools, + logCall: logAiToolCall, + onLogError: (e) => naarGebruiker(`LET OP: een tool-aanroep is NIET vastgelegd: ${e.message}`), +}); + +naarGebruiker(`gereed als '${CONFIG.AI.SERVER_NAME}', ${tools.length} tool(s), sessie ${server.session.id}`); + +const lezer = createInterface({ input: process.stdin, crlfDelay: Infinity }); + +function kijkIn(regel) { + try { + const bericht = JSON.parse(regel); + return { + id: Object.hasOwn(bericht, 'id') ? bericht.id : undefined, + method: typeof bericht.method === 'string' ? bericht.method : null, + }; + } catch { return { id: undefined, method: null }; } +} + +const HEEFT_DATABASE_NODIG = 'tools/call'; + +const INTERNE_FOUT = -32603; + +function stuurFout(regel, melding) { + const { id } = kijkIn(regel); + if (id === undefined) return; + process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code: INTERNE_FOUT, message: melding } })}\n`); +} + +function uitlegBijStand(stand) { + const naam = activeName(); + if (stand.reden === 'leeg') { + return `De portefeuille '${naam}' heeft nog geen ingerichte database. Dat gebeurt zodra de app hem ` + + 'een keer opent (Systeem → Portefeuilles → Openen). Tot dan is er hier niets te lezen.'; + } + if (stand.reden === 'oud') { + return `De database van portefeuille '${naam}' staat op versie ${stand.versie} en deze app verwacht ` + + `${CONFIG.SCHEMA_VERSION}. De migraties draaien als de app die portefeuille opent; doe dat eerst.`; + } + return `De database van portefeuille '${naam}' staat op versie ${stand.versie}, en deze MCP-server kent ` + + `tot en met ${CONFIG.SCHEMA_VERSION}. Is de app net bijgewerkt, herstart dan de AI-koppeling in je ` + + 'AI-client: dit proces laadt nieuwe code pas bij een herstart. Anders is de app zelf ouder dan de ' + + 'database; werk hem dan bij.'; +} + +let wachtrij = Promise.resolve(); + +lezer.on('line', (regel) => { + const tekst = regel.trim(); + if (!tekst) return; + wachtrij = wachtrij.then(async () => { + try { openDatabase(dbFile()); } + catch (e) { + naarGebruiker(`kan de database niet openen voor dit bericht: ${e.message}`); + stuurFout(tekst, `De database van de actieve portefeuille is nu niet te openen: ${e.message}`); + return; + } + const stand = databaseStatus(); + if (stand.klaar && !isAiActive()) { + stuurFout(tekst, AI_INACTIVE_TEXT); + naarGebruiker('de AI-koppeling is uitgezet; deze server stopt.'); + try { closeDatabase(); } catch { } + process.exit(0); + } + if (!stand.klaar && kijkIn(tekst).method === HEEFT_DATABASE_NODIG) { + naarGebruiker(`de database van deze portefeuille is niet bruikbaar (${stand.reden}); bericht geweigerd`); + stuurFout(tekst, uitlegBijStand(stand)); + try { closeDatabase(); } catch { } + return; + } + try { + const antwoord = await server.handleRegel(tekst); + if (antwoord) process.stdout.write(`${JSON.stringify(antwoord)}\n`); + } catch (e) { + naarGebruiker(`onverwachte fout bij het afhandelen van een bericht: ${e.message}`); + } finally { + try { flushChanges(); } catch (e) { naarGebruiker(`signalen wegschrijven mislukte: ${e.message}`); } + try { closeDatabase(); } catch (e) { naarGebruiker(`database sluiten mislukte: ${e.message}`); } + } + }); +}); + +lezer.on('close', () => { naarGebruiker('verbinding gesloten door de client'); process.exit(0); }); diff --git a/server/mcp/tools.mjs b/server/mcp/tools.mjs new file mode 100644 index 0000000..a283a6b --- /dev/null +++ b/server/mcp/tools.mjs @@ -0,0 +1,74 @@ +// Generated, do not edit. sha256:31e7aeb1ea48c942e3f7c7a1b1a04f9c89a81975a5bc92fbc88810da09fb4d6c +import { CONFIG } from '../../shared/config.js'; +import { assertScope } from '../ai/gate.js'; +import { precisionSettings } from '../ai/precision.js'; +import { activeName } from '../portfolios.mjs'; +import { READ_TOOLS } from './read-tools.mjs'; +import { DETAIL_TOOLS } from './detail-tool.mjs'; +import { PLACES_TOOLS } from './places-tool.mjs'; +import { CONTROL_TOOLS } from './control-tools.mjs'; +import { WRITE_TOOLS } from './write-tools.mjs'; +import { WEALTH_TOOLS } from './wealth-tool.mjs'; +import { PRICE_TOOLS } from './price-tool.mjs'; +import { mcpTools } from '../registers.mjs'; + +const NOTE_MAX = 200; + +export const TOOLS = Object.freeze([ + ...READ_TOOLS, + ...DETAIL_TOOLS, + ...PLACES_TOOLS, + ...WEALTH_TOOLS, + ...PRICE_TOOLS, + ...CONTROL_TOOLS, + ...WRITE_TOOLS, + Object.freeze({ + name: 'connection_test', + title: 'Verbinding testen', + description: + 'Controleert of de verbinding met AssetApp werkt. Deze tool leest geen gegevens uit het ' + + 'portfolio en verandert er niets aan: hij bevestigt alleen dat het bericht is aangekomen en ' + + 'legt de aanroep vast in het activiteitenlogboek van de app, waar de eigenaar hem terugvindt.', + inputSchema: Object.freeze({ + type: 'object', + properties: { + note: { + type: 'string', + maxLength: NOTE_MAX, + description: 'Optionele korte notitie die in het logboek van de app komt te staan.', + }, + }, + additionalProperties: false, + }), + scope: 'none', + async run(args) { + assertScope('none'); + const note = typeof args?.note === 'string' ? args.note.slice(0, NOTE_MAX) : null; + const portefeuille = activeName(); + const onbewerkt = precisionSettings().rawValues; + return { + text: [ + `Verbinding met ${CONFIG.AI.SERVER_NAME} werkt.`, + `Open portefeuille: ${portefeuille}.`, + onbewerkt + ? 'Onbewerkt delen staat AAN voor deze portefeuille: namen en bedragen komen ongefilterd ' + + 'terug, zonder verwijzingen en marges. Dat hoort alleen zo te zijn in een portefeuille ' + + 'zonder echte gegevens.' + : 'Namen en bedragen komen als verwijzing en marge terug, niet als echte waarde.', + 'Er zijn geen portfolio-gegevens opgevraagd of gewijzigd.', + note + ? `De notitie "${note}" staat nu in het activiteitenlogboek van de app.` + : 'Deze aanroep staat nu in het activiteitenlogboek van de app, zonder notitie.', + ].join('\n'), + fieldsShared: ['portfolio'], + }; + }, + }), +]); + +export function allTools() { + const extra = mcpTools.list().map(e => e.tool); + const dubbel = extra.find(t => TOOLS.some(c => c.name === t.name)); + if (dubbel) throw new Error(`mcp tool '${dubbel.name}' is registered and also a core tool`); + return Object.freeze([...TOOLS, ...extra]); +} diff --git a/server/mcp/wealth-tool.mjs b/server/mcp/wealth-tool.mjs new file mode 100644 index 0000000..42f32b3 --- /dev/null +++ b/server/mcp/wealth-tool.mjs @@ -0,0 +1,164 @@ +// Generated, do not edit. sha256:a35de9757194e9058e50a8d62faae8adcfd1b35f42f9b9b14cece1c6cab04e85 +import { accountHoldings, positionsByAsset } from '../data/positions.js'; +import { listAccounts } from '../data/accounts.js'; +import { isOwnWealth } from '../../shared/wealth-scope.js'; +import { latestPricePoint } from '../data/price-history.js'; +import { assertScope } from '../ai/gate.js'; +import { limitedList } from '../ai/limits.js'; +import { shareBucket, shareLabel } from '../ai/buckets.js'; +import { shareDisclosure } from '../ai/precision.js'; +import { vertalerVoor } from './session.mjs'; +import { assetMetrics } from '../../shared/wealth-metrics.js'; +import { isMetal, metalFactor } from '../../shared/units.js'; +import { getBaseCurrency } from '../data/portfolio.js'; + +function verdelingsblok(rijen, totaal, t) { + if (!(totaal > 0)) return []; + + const vorm = shareDisclosure(t.precision); + if (vorm === 'omit') { + return [ + 'De verdeling over de assets komt niet mee: met een exact totaal én exacte percentages ligt de ' + + 'waarde per asset vast, en de bedragen per regel zijn juist wél in marges. Zet één van beide ' + + 'terug op marges (of zet onbewerkt delen aan) om hem te zien.', + '', + ]; + } + const pctUit = pct => (vorm === 'exact' ? `${pct.toFixed(1)}%` : shareLabel(pct)); + + const posten = []; + let overigPct = 0; + let overigN = 0; + let zonderKoers = 0; + + for (const { p, m } of rijen) { + if (m.value == null) { zonderKoers += 1; continue; } + const pct = (m.value / totaal) * 100; + const b = shareBucket(pct); + if (!b || b.below) { overigPct += pct; overigN += 1; continue; } + posten.push({ symbool: t.toAi(p, { entity: 'asset' }).symbol, pct }); + } + if (!posten.length && !overigN) return []; + + posten.sort((a, b) => b.pct - a.pct); + const regels = limitedList(posten, x => `- ${x.symbool}: ${pctUit(x.pct)}`).lines; + if (overigN) { + regels.push(`- overig (${overigN} ${overigN === 1 ? 'asset' : 'assets'}): ${pctUit(overigPct)}`); + } + + return [ + vorm === 'exact' + ? 'Verdeling over de portefeuille (samen dekken ze het geheel):' + : 'Verdeling over de portefeuille (marges; samen dekken ze het geheel):', + ...regels, + ...(zonderKoers ? [`(${zonderKoers} zonder koers, dus niet in deze verdeling.)`] : []), + '', + ]; +} + +function buitenTotalen(decimalen) { + const buiten = listAccounts().filter(a => !isOwnWealth(a)); + if (!buiten.length) return []; + const soort = new Map(buiten.map(a => [a.id, a.wealthScope])); + const som = new Map([...new Set(buiten.map(a => a.wealthScope))].map(s => [s, { waarde: 0, zonderKoers: 0 }])); + for (const r of accountHoldings(buiten.map(a => a.id))) { + const punt = latestPricePoint(r.assetId); + const post = som.get(soort.get(r.accountId)); + if (!punt || !decimalen.has(r.assetId)) { post.zonderKoers += 1; continue; } + post.waarde += (r.saldo / Math.pow(10, decimalen.get(r.assetId))) * punt.fiatPrice; + } + return ['managed', 'none'].filter(s => som.has(s)).map(s => ({ soort: s, ...som.get(s) })); +} +const BUITEN_LABEL = { managed: 'vermogen van derden', none: 'telt niet mee' }; + +const wealthSummary = Object.freeze({ + name: 'wealth_summary', + title: 'Vermogen in cijfers', + description: + 'Geeft wat de portefeuille van AssetApp nu waard is: het totaal, en per asset de hoeveelheid, ' + + 'koers, waarde, inleg en het ongerealiseerde resultaat. Staat onbewerkt delen uit, dan komen ' + + 'het totaal en de verdeling per asset terug als marges, zonder bedrag per asset. De koers is ' + + 'het laatst opgeslagen punt en geen verse marktkoers. Wijzigt niets.', + inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }), + scope: 'read', + + async run(_args, context) { + assertScope(wealthSummary.scope); + const t = await vertalerVoor(context); + t.beginCall(); + + const onbewerkt = t.precision.rawValues; + const posities = positionsByAsset(undefined, { ownOnly: true }); + const cur = getBaseCurrency(); + const money = n => `${cur} ${n.toFixed(2)}`; + const totaalTekst = n => { + const uit = t.toAi({ fiatValue: n }, { entity: 'wealth', role: 'total' }).fiatValue; + return typeof uit === 'number' ? money(uit) : `${cur} ${uit}`; + }; + const buiten = buitenTotalen(new Map(posities.map(p => [p.id, p.decimals]))); + const buitenRegel = !buiten.length ? [] : [ + `Niet in dit totaal (Vermogen op het account): ${buiten.map(b => `${BUITEN_LABEL[b.soort]} ${totaalTekst(b.waarde)}` + + (b.zonderKoers ? ` (${b.zonderKoers} zonder koers)` : '')).join('; ')}.`, + '', + ]; + const buitenVelden = buiten.length ? ['wealthScope'] : []; + + let totaal = 0; + let zonderKoers = 0; + const rijen = []; + for (const p of posities) { + if (p.net === 0 && p.buy_events === 0) continue; + const punt = latestPricePoint(p.id); + const m = assetMetrics(p, punt ? punt.fiatPrice : null); + if (m.value != null) totaal += m.value; else zonderKoers += 1; + rijen.push({ p, m, punt }); + } + + if (!rijen.length) { + if (buiten.length) { + return { text: ['Deze portefeuille heeft geen eigen bezit.', '', ...buitenRegel].join('\n').trim(), + fieldsShared: ['baseCurrency', 'fiatValue', ...buitenVelden] }; + } + return { text: 'Deze portefeuille heeft nog geen bezit: er is niets om een waarde van te geven.', + fieldsShared: [] }; + } + + const kop = `Totale waarde nu: ${totaalTekst(totaal)}` + + ` over ${rijen.length} ${rijen.length === 1 ? 'asset' : 'assets'}` + + (zonderKoers ? `, waarvan ${zonderKoers} zonder koers en dus zonder waarde` : '') + + '.'; + + if (!onbewerkt) { + return { + text: [kop, '', ...buitenRegel, ...verdelingsblok(rijen, totaal, t), + 'Per asset komen de BEDRAGEN niet mee zolang onbewerkt delen uit staat: een waarde per asset ' + + 'met de koers erbij legt de hoeveelheid vast. Zet die schuif aan voor een portefeuille ' + + 'zonder echte gegevens.', + ].join('\n'), + fieldsShared: ['baseCurrency', 'fiatValue', 'symbol', ...buitenVelden], + }; + } + + const regels = limitedList(rijen, ({ p, m, punt }) => { + const v = t.toAi(p, { entity: 'asset' }); + const f = isMetal(p) ? metalFactor(p.unit) : 1; + const stuk = [`${v.symbol}: ${+(m.units / f).toFixed(8)} ${v.unit}`]; + if (m.value != null) stuk.push(`waarde ${money(m.value)} tegen ${money(punt.fiatPrice * f)} per ${v.unit}`); + else stuk.push('geen koers, dus geen waarde'); + stuk.push(`ingelegd ${money(m.invested)}`); + if (m.unrealized != null) stuk.push(`ongerealiseerd ${money(m.unrealized)}`); + if (m.avgPrice != null) stuk.push(`gemiddelde aankoopkoers ${money(m.avgPrice * f)}`); + return `- ${stuk.join(', ')}`; + }).lines; + + return { + text: [kop, '', ...buitenRegel, 'Per asset:', ...regels, '', + 'De koers is het laatst opgeslagen koerspunt; dit proces haalt geen verse koersen op.', + ].join('\n'), + fieldsShared: ['baseCurrency', 'symbol', 'unit', 'decimals', 'amount', 'balance', 'fiatPrice', 'fiatFee', + 'fiatValue', ...buitenVelden], + }; + }, +}); + +export const WEALTH_TOOLS = Object.freeze([wealthSummary]); diff --git a/server/mcp/write-tools.mjs b/server/mcp/write-tools.mjs new file mode 100644 index 0000000..ae43ef3 --- /dev/null +++ b/server/mcp/write-tools.mjs @@ -0,0 +1,540 @@ +// Generated, do not edit. sha256:46e628fbaa9521b9e3fa070e20e19f0d90e6d2eab65f98990ca26652e15c420e +import { CONFIG, ENUMS } from '../../shared/config.js'; +import { unitToSubunit, unitPriceToBase } from '../../shared/units.js'; +import { assertScope } from '../ai/gate.js'; +import { limitedList } from '../ai/limits.js'; +import { listAssets } from '../data/assets.js'; +import { listAccounts } from '../data/accounts.js'; +import { getBaseCurrency } from '../data/portfolio.js'; +import { createAiProposal, listAiProposals } from '../data/ai-proposals.js'; +import { vertalerVoor } from './session.mjs'; +import { idVoorHandle } from './handles.mjs'; + +const NOTE_MAX = 500; + +const AFHANDELEN = 'De gebruiker vindt het voorstel in AssetApp onder Gebeurtenissen, "Af te handelen", ' + + 'en keurt het daar goed of wijst het af. Er is nu nog niets gewijzigd.'; + +function onbekend(soort, gevraagd) { + return { + text: `De verwijzing '${gevraagd}' hoort bij geen ${soort} in deze administratie. ` + + `Vraag de huidige verwijzingen op met admin_overview; mogelijk is de rij verwijderd of komt de ` + + `verwijzing uit een andere installatie.`, + fieldsShared: [], + }; +} + +function klaargezet(t, id, omschrijving) { + const handle = t.toAi({ id }, { entity: 'proposal' }).id; + return { + text: [`Voorstel ${handle} staat klaar: ${omschrijving}`, AFHANDELEN].join('\n'), + fieldsShared: t.fieldsShared(), + }; +} + +const BELOFTE = + 'Wijzigt niets: legt een voorstel vast dat de eigenaar van de app goedkeurt of afwijst.'; + +const NOTE_SCHEMA = Object.freeze({ + type: 'string', + maxLength: NOTE_MAX, + description: 'Waarom je dit voorstelt; de gebruiker leest het op de kaart.', +}); + +const verwijzing = (soort, voorbeeld) => Object.freeze({ + type: 'string', + maxLength: 64, + description: `De verwijzing naar ${soort} (${voorbeeld}).`, +}); + +const MOMENT_SCHEMA = (wat) => Object.freeze({ + type: 'string', + maxLength: 32, + description: `${wat}: 2026-03-01 of een ISO-tijdstempel.`, +}); + +const SYMBOOL_SCHEMA = Object.freeze({ + type: 'string', + maxLength: 16, + description: 'Het symbool van het asset zoals admin_overview het gaf: BTC, XAU, EUR.', +}); + +function assetVoorSymbool(gevraagd) { + const gezocht = String(gevraagd ?? '').trim().toUpperCase(); + if (!gezocht) return null; + return listAssets().find(a => String(a.symbol).toUpperCase() === gezocht) || null; +} + +function onbekendSymbool(gevraagd) { + const bestaat = listAssets().map(a => a.symbol).join(', '); + return { + text: `'${gevraagd}' is geen asset in deze administratie. Deze bestaan wel: ${bestaat}.`, + fieldsShared: ['symbol'], + }; +} + +const stelBronkeuzeVoor = Object.freeze({ + name: 'propose_source_choice', + title: 'Een bronkeuze voorstellen', + description: + 'Stelt voor een koersbron van één asset als eerste te zetten voor spot of historie, of eruit te ' + + 'halen; zie integration_health. ' + BELOFTE, + inputSchema: Object.freeze({ + type: 'object', + properties: { + symbol: SYMBOOL_SCHEMA, + choice: { type: 'string', enum: ['spot', 'history'] }, + provider: { type: 'string', maxLength: 60, description: 'De sleutel van de bron.' }, + action: { type: 'string', enum: [...ENUMS.SOURCE_CHOICE_ACTION] }, + note: NOTE_SCHEMA, + }, + required: ['symbol', 'choice', 'provider', 'action'], + additionalProperties: false, + }), + scope: 'write', + + async run(args, context) { + assertScope('write'); + const t = await vertalerVoor(context); + t.beginCall(); + const asset = assetVoorSymbool(args?.symbol); + if (!asset) return onbekendSymbool(args?.symbol); + const id = await createAiProposal({ + kind: 'source_choice', + assetId: asset.id, + scope: String(args?.choice ?? ''), + key: String(args?.provider ?? ''), + value: String(args?.action ?? ''), + note: notitie(args?.note), + }); + const wat = args.action === 'first' ? 'als eerste zetten voor' : 'weghalen uit'; + return klaargezet(t, id, `${args.provider} ${wat} de ${args.choice === 'spot' ? 'spot' : 'historie'} van ${asset.symbol}.`); + }, +}); + +const stelKoerspuntVoor = Object.freeze({ + name: 'propose_price_point', + title: 'Een koerspunt voorstellen', + description: + 'Stelt voor om één koers van één asset op één moment vast te leggen in AssetApp, voor een koers die ' + + 'de app zelf niet kan ophalen. Wordt bij goedkeuring een handmatige koers op dagniveau. ' + BELOFTE, + inputSchema: Object.freeze({ + type: 'object', + properties: { + symbol: SYMBOOL_SCHEMA, + fiatPrice: { + type: 'number', + exclusiveMinimum: 0, + description: 'De koers in de rekenvaluta per hele eenheid (per BTC, per ozt goud).', + }, + observedAt: MOMENT_SCHEMA('Wanneer deze koers gold'), + note: NOTE_SCHEMA, + }, + required: ['symbol', 'fiatPrice', 'observedAt'], + additionalProperties: false, + }), + scope: 'write', + + async run(args, context) { + assertScope('write'); + const t = await vertalerVoor(context); + t.beginCall(); + + const asset = assetVoorSymbool(args?.symbol); + if (!asset) return onbekendSymbool(args?.symbol); + + const id = await createAiProposal({ + kind: 'price_point', + assetId: asset.id, + fiatPrice: unitPriceToBase(args.fiatPrice, asset), + observedAt: String(args.observedAt), + note: notitie(args?.note), + }); + return klaargezet(t, id, `een koers voor ${asset.symbol} op ${args.observedAt}.`); + }, +}); + +const stelRekeningVoor = Object.freeze({ + name: 'propose_account', + title: 'Een account voorstellen', + description: + 'Stelt voor om een nieuw account aan te maken in AssetApp: de plek waar bezit wordt aangehouden. ' + + BELOFTE + ' Een boeking kan pas naar dit account verwijzen nádat het voorstel is goedgekeurd; ' + + 'vraag daarna opnieuw admin_overview op om de verwijzing te krijgen.', + inputSchema: Object.freeze({ + type: 'object', + properties: { + name: { + type: 'string', + maxLength: 100, + description: 'De naam zoals de gebruiker hem in de app wil zien, bijvoorbeeld "Kluis thuis".', + }, + accountKind: { + type: 'string', + enum: [...ENUMS.ACCOUNT_KIND], + description: 'vault is een kluis voor edelmetaal.', + }, + ownCustody: { + type: 'boolean', + description: 'True als de gebruiker het bezit zelf beheert, false als een derde het voor hem houdt.', + }, + note: NOTE_SCHEMA, + }, + required: ['name', 'accountKind', 'ownCustody'], + additionalProperties: false, + }), + scope: 'write', + + async run(args, context) { + assertScope('write'); + const t = await vertalerVoor(context); + t.beginCall(); + + const id = await createAiProposal({ + kind: 'account', + name: String(args?.name ?? ''), + accountKind: String(args?.accountKind ?? ''), + isOwnCustody: !!args?.ownCustody, + note: notitie(args?.note), + }); + return klaargezet(t, id, `een nieuw account van de soort ${args.accountKind}.`); + }, +}); + +const stelBoekingVoor = Object.freeze({ + name: 'propose_event', + title: 'Een boeking voorstellen', + description: + 'Stelt één boeking voor. buy en interest: alleen toAccount; sell: alleen fromAccount; transfer: ' + + 'allebei. buy en sell vragen fiatPrice. ' + BELOFTE, + inputSchema: Object.freeze({ + type: 'object', + properties: { + symbol: SYMBOOL_SCHEMA, + eventType: { + type: 'string', + enum: [...ENUMS.LEDGER_EVENT_TYPE], + description: 'transfer is tussen twee eigen accounts.', + }, + amount: { + type: 'number', + exclusiveMinimum: 0, + description: 'De hoeveelheid in de HELE eenheid (0.25 = een kwart bitcoin), niet in de kleinste.', + }, + occurredAt: MOMENT_SCHEMA('Wanneer de boeking plaatsvond'), + fromAccount: verwijzing('het account waar het vandaan komt', 'account:7f3a9c'), + toAccount: verwijzing('het account waar het heen gaat', 'account:7f3a9c'), + fiatPrice: { + type: 'number', + exclusiveMinimum: 0, + description: 'De koers in de rekenvaluta per hele eenheid op het moment van de boeking.', + }, + fiatFee: { + type: 'number', + minimum: 0, + description: 'Kosten als bedrag in de rekenvaluta (niet per eenheid).', + }, + unitFee: { + type: 'number', + minimum: 0, + description: 'Kosten in het asset zelf (hele eenheid), bovenop amount. Alleen bij sell en transfer.', + }, + note: NOTE_SCHEMA, + }, + required: ['symbol', 'eventType', 'amount', 'occurredAt'], + additionalProperties: false, + }), + scope: 'write', + + async run(args, context) { + assertScope('write'); + const t = await vertalerVoor(context); + t.beginCall(); + + const asset = assetVoorSymbool(args?.symbol); + if (!asset) return onbekendSymbool(args?.symbol); + + const rekeningIds = listAccounts().map(a => a.id); + let fromAccountId = null; + let toAccountId = null; + if (args?.fromAccount != null) { + fromAccountId = idVoorHandle(t, 'account', args.fromAccount, rekeningIds); + if (fromAccountId === null) return onbekend('account', args.fromAccount); + } + if (args?.toAccount != null) { + toAccountId = idVoorHandle(t, 'account', args.toAccount, rekeningIds); + if (toAccountId === null) return onbekend('account', args.toAccount); + } + + const amount = unitToSubunit(args.amount, asset); + + const id = await createAiProposal({ + kind: 'event', + assetId: asset.id, + eventType: String(args?.eventType ?? ''), + amount, + fiatPrice: args?.fiatPrice == null ? null : unitPriceToBase(args.fiatPrice, asset), + fiatFee: args?.fiatFee == null ? null : Number(args.fiatFee), + unitFee: args?.unitFee == null ? null : unitToSubunit(args.unitFee, asset), + observedAt: String(args.occurredAt), + fromAccountId, + toAccountId, + note: notitie(args?.note), + }); + return klaargezet(t, id, `een boeking van de soort ${args.eventType} op ${args.occurredAt}.`); + }, +}); + +function notitie(waarde) { + return typeof waarde === 'string' && waarde.trim() ? waarde.slice(0, NOTE_MAX) : null; +} + +const stelVerwijderingVoor = Object.freeze({ + name: 'propose_account_deletion', + title: 'Een account laten verwijderen', + description: + 'Stelt voor om een bestaand account uit AssetApp te verwijderen. Dit is ONOMKEERBAAR zodra de ' + + 'eigenaar het goedkeurt, dus de app vraagt daar een aparte bevestiging voor en neemt het niet mee ' + + 'in "alles goedkeuren". Een account waar boekingen, balansen of scans aan hangen kan niet weg; het ' + + 'voorstel weigert dan bij het goedkeuren en zegt hoeveel er in de weg staat. ' + BELOFTE, + inputSchema: Object.freeze({ + type: 'object', + properties: { + account: verwijzing('het account dat weg mag', 'account:7f3a9c'), + note: NOTE_SCHEMA, + }, + required: ['account'], + additionalProperties: false, + }), + scope: 'write', + + async run(args, context) { + assertScope('write'); + const t = await vertalerVoor(context); + t.beginCall(); + + const accountId = idVoorHandle(t, 'account', args?.account, listAccounts().map(a => a.id)); + if (accountId === null) return onbekend('account', args?.account); + + const id = await createAiProposal({ + kind: 'account_delete', + accountId, + note: notitie(args?.note), + }); + return klaargezet(t, id, `${args.account} verwijderen. Dit is onomkeerbaar.`); + }, +}); + +const stelPortefeuilleVoor = Object.freeze({ + name: 'propose_portfolio', + title: 'Een portefeuille voorstellen', + description: + 'Stelt voor om een nieuwe, LEGE portefeuille (administratie) aan te maken naast de huidige. Bedoeld ' + + 'om een demonstratie- of testadministratie op te bouwen zonder de echte aan te raken. Kopieert ' + + 'niets: er komt geen data en geen sleutel in. Wisselt ook niet, want dat is een herstart van de ' + + 'app; die keuze blijft bij de eigenaar, en jouw voorstellen landen tot dan in de huidige ' + + 'administratie. ' + BELOFTE, + inputSchema: Object.freeze({ + type: 'object', + properties: { + name: { + type: 'string', + maxLength: 60, + description: 'De naam van de nieuwe portefeuille, zoals de eigenaar hem in de app ziet.', + }, + currency: { + type: 'string', + description: 'ECB-code van de rekenvaluta (USD); ligt daarna vast. Standaard die van de huidige.', + }, + note: NOTE_SCHEMA, + }, + required: ['name'], + additionalProperties: false, + }), + scope: 'write', + + async run(args, context) { + assertScope('write'); + const t = await vertalerVoor(context); + t.beginCall(); + + const currency = args?.currency ? String(args.currency).toUpperCase() : getBaseCurrency(); + const id = await createAiProposal({ + kind: 'portfolio', + name: String(args?.name ?? ''), + currency, + note: notitie(args?.note), + }); + return klaargezet(t, id, `een nieuwe lege portefeuille met de naam '${args.name}', in ${currency}.`); + }, +}); + +const stelVoorkeurVoor = Object.freeze({ + name: 'propose_preference', + title: 'Een instelling voorstellen', + description: + 'Stelt voor één instelling van deze administratie te wijzigen: elke instelling die admin_overview ' + + 'toont, dus ook thema, accent, de vormgeving (glas, gloed) en de timers. Niet de AI-schuiven zelf. Een ongeldige waarde wordt ' + + 'bij het goedkeuren geweigerd. ' + BELOFTE, + inputSchema: Object.freeze({ + type: 'object', + properties: { + scope: { + type: 'string', + enum: [...CONFIG.AI.PROPOSABLE_PREF_SCOPES], + description: 'De groep van de instelling.', + }, + key: { + type: 'string', + maxLength: 60, + description: 'De sleutel zoals admin_overview hem toont, bijvoorbeeld currency.', + }, + value: { + type: ['string', 'number', 'boolean'], + description: 'De nieuwe waarde.', + }, + note: NOTE_SCHEMA, + }, + required: ['scope', 'key', 'value'], + additionalProperties: false, + }), + scope: 'write', + + async run(args, context) { + assertScope('write'); + const t = await vertalerVoor(context); + t.beginCall(); + + const id = await createAiProposal({ + kind: 'preference', + scope: String(args?.scope ?? ''), + key: String(args?.key ?? ''), + value: JSON.stringify(args?.value ?? null), + note: notitie(args?.note), + }); + return klaargezet(t, id, + `de instelling ${args.scope}.${args.key} op ${JSON.stringify(args.value)} zetten.`); + }, +}); + +const stelBalansVoor = Object.freeze({ + name: 'propose_valuation', + title: 'Een balans voorstellen', + description: + 'Stelt de STAND van één account in één asset op een peildatum voor. Dat is een ANKER: na goedkeuring ' + + 'IS de stand deze balans plus de boekingen erna, dus een verkeerde balans corrigeert het grootboek ' + + 'stil. Gebruik alleen een onafhankelijke bron (afschrift, handelsplatform, telling); een stand die ' + + 'je uit je eigen voorstellen optelt, klopt per constructie en controleert niets. Vergt een koers in ' + + 'de historie op die datum, anders blijft het voorstel staan met die reden. ' + BELOFTE, + inputSchema: Object.freeze({ + type: 'object', + properties: { + symbol: SYMBOOL_SCHEMA, + account: verwijzing('het account waarvan dit de stand is', 'account:7f3a9c'), + balance: { + type: 'number', + minimum: 0, + description: 'De stand in de HELE eenheid (0.25 = een kwart bitcoin). Nul mag: leeg op die datum.', + }, + valuedOn: MOMENT_SCHEMA('De peildatum'), + note: NOTE_SCHEMA, + }, + required: ['symbol', 'account', 'balance', 'valuedOn'], + additionalProperties: false, + }), + scope: 'write', + + async run(args, context) { + assertScope('write'); + const t = await vertalerVoor(context); + t.beginCall(); + + const asset = assetVoorSymbool(args?.symbol); + if (!asset) return onbekendSymbool(args?.symbol); + + const accountId = idVoorHandle(t, 'account', args?.account, listAccounts().map(a => a.id)); + if (accountId === null) return onbekend('account', args?.account); + + const balance = unitToSubunit(args.balance, asset); + + const id = await createAiProposal({ + kind: 'valuation', + assetId: asset.id, + accountId, + balance, + observedAt: String(args.valuedOn), + note: notitie(args?.note), + }); + return klaargezet(t, id, `de stand van ${args.account} in ${asset.symbol} op ${args.valuedOn}.`); + }, +}); + +const voorstelToestand = Object.freeze({ + name: 'proposal_status', + title: 'De toestand van een voorstel', + description: + 'Zegt of een voorstel is goedgekeurd, afgewezen of nog wacht, en waarom een uitvoering mislukte. ' + + 'Verandert niets. Vraag dit vóór je een boeking voorstelt naar een pas voorgesteld account.', + inputSchema: Object.freeze({ + type: 'object', + properties: { + proposal: { + type: 'string', + maxLength: 32, + description: 'De verwijzing die een propose_-tool teruggaf (proposal:7f3a9c). Weglaten voor alles wat nog openstaat.', + }, + }, + additionalProperties: false, + }), + scope: 'write', + + async run(args, context) { + assertScope('write'); + const t = await vertalerVoor(context); + t.beginCall(); + + const alle = listAiProposals(); + const gevraagd = String(args?.proposal ?? '').trim(); + + if (!gevraagd) { + const open = alle.filter(p => p.status === 'pending'); + if (!open.length) { + return { text: 'Er staat geen voorstel meer open; alles is afgehandeld.', fieldsShared: t.fieldsShared() }; + } + const { lines } = limitedList(open, p => `- ${voorstelHandle(t, p.id)} (${p.kind}): wacht op een oordeel.`); + return { + text: [`${open.length} voorstel(len) wachten nog op een oordeel:`, ...lines].join('\n'), + fieldsShared: [...t.fieldsShared(), 'kind', 'status'], + }; + } + + const rij = alle.find(p => voorstelHandle(t, p.id) === gevraagd); + if (!rij) { + return { + text: `'${gevraagd}' is geen voorstel dat ik terugvind. Ik kijk ${CONFIG.AI.PROPOSAL_PAGE_SIZE} voorstellen terug.`, + fieldsShared: [], + }; + } + + const uitleg = { + pending: 'wacht nog op een oordeel van de eigenaar.', + accepted: 'is goedgekeurd en uitgevoerd.', + rejected: 'is afgewezen en niet uitgevoerd.', + }[rij.status] ?? `heeft een onbekende status (${rij.status}).`; + const reden = rij.status === 'pending' && rij.detail + ? ` Een eerdere poging om hem uit te voeren mislukte: ${rij.detail}` + : ''; + + return { + text: `Voorstel ${gevraagd} (${rij.kind}) ${uitleg}${reden}`, + fieldsShared: [...t.fieldsShared(), 'kind', 'status', ...(reden ? ['detail'] : [])], + }; + }, +}); + +const voorstelHandle = (t, id) => String(t.toAi({ id }, { entity: 'proposal' }).id); + +export const WRITE_TOOLS = Object.freeze([ + stelKoerspuntVoor, stelRekeningVoor, stelBoekingVoor, stelBalansVoor, stelVerwijderingVoor, + stelPortefeuilleVoor, stelVoorkeurVoor, stelBronkeuzeVoor, voorstelToestand, +]); diff --git a/server/portfolios-admin.mjs b/server/portfolios-admin.mjs new file mode 100644 index 0000000..018b239 --- /dev/null +++ b/server/portfolios-admin.mjs @@ -0,0 +1,165 @@ +// Generated, do not edit. sha256:1c5123afde8b42e8b0c7944a90a9a3952b38ed44748eb715f09bb8aa35f593d5 +import { mkdir, readdir, rename, rm, readFile, writeFile, copyFile, stat } from 'node:fs/promises'; +import { join, sep, resolve } from 'node:path'; + +import { + installDir, portfolioDir, archiveDir, assertValidName, sameName, + activeName, writeActiveName, listPortfolios, +} from './portfolios.mjs'; +import { run } from './db/conn.mjs'; +import { createDatabase } from './db.js'; +import { CONFIG } from '../shared/config.js'; +import { fail } from '../shared/app-error.js'; +import { capacityReached } from './license.mjs'; + +const BUREN = ['-wal', '-shm']; + +const HANDLE_SLEUTEL = 'ai_handle_secret'; + +const bestaat = async (pad) => { try { await stat(pad); return true; } catch { return false; } }; + +function eisNietActief(naam, sleutel) { + if (sameName(naam, activeName())) { + fail(sleutel, { naam }, + `'${naam}' is the portfolio that is open now; switch to another one first.`); + } +} + +function eisBestaat(naam) { + const gevonden = listPortfolios().find(p => sameName(p.naam, naam)); + if (!gevonden) fail('portfolios.unknownPortfolio', { naam }, `There is no portfolio '${naam}'.`); + return gevonden; +} + +function eisVrijeNaam(naam) { + assertValidName(naam); + const botsing = listPortfolios().find(p => sameName(p.naam, naam)); + if (botsing) { + fail('portfolios.nameInUse', { naam: botsing.naam }, + `A portfolio '${botsing.naam}' already exists; names that differ only in case count as the same.`); + } + return naam; +} + +export async function createPortfolio(naam, { kopieVan = null, baseCurrency = null } = {}) { + if (kopieVan && baseCurrency) { + throw new Error('A copy keeps the unit of account of its source; do not pass baseCurrency with kopieVan.'); + } + const max = capacityReached('portfolios', listPortfolios().length); + if (max !== null) { + fail('portfolios.maxFree', { max }, + `The free version holds ${max} portfolio(s). A licence lifts that; nothing is removed in the meantime.`); + } + eisVrijeNaam(naam); + const bron = kopieVan ? eisBestaat(kopieVan) : null; + const doel = portfolioDir(naam); + await mkdir(doel, { recursive: true }); + + try { + if (!bron) { + createDatabase(join(doel, 'db.sqlite'), { baseCurrency: baseCurrency ?? CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY }); + return { naam, gekopieerdVan: null, database: true, geheimen: false }; + } + const database = await kopieerDatabase(bron.naam, doel); + const geheimen = await kopieerGeheimen(bron.naam, doel); + return { naam, gekopieerdVan: bron.naam, database, geheimen }; + } catch (e) { + await rm(doel, { recursive: true, force: true }); + throw e; + } +} + +async function kopieerDatabase(bronNaam, doelMap) { + const doel = join(doelMap, 'db.sqlite'); + if (sameName(bronNaam, activeName())) { + run(`VACUUM INTO '${doel.replace(/'/g, "''")}'`, []); + return true; + } + const bron = join(portfolioDir(bronNaam), 'db.sqlite'); + if (!await bestaat(bron)) return false; + await copyFile(bron, doel); + for (const b of BUREN) if (await bestaat(bron + b)) await copyFile(bron + b, doel + b); + return true; +} + +async function kopieerGeheimen(bronNaam, doelMap) { + const bron = join(portfolioDir(bronNaam), 'secrets.json'); + if (!await bestaat(bron)) return false; + const store = JSON.parse(await readFile(bron, 'utf-8')); + delete store[HANDLE_SLEUTEL]; + await writeFile(join(doelMap, 'secrets.json'), JSON.stringify(store, null, 2), { mode: 0o600 }); + return true; +} + +export function activatePortfolio(naam) { + const doel = eisBestaat(naam); + if (sameName(doel.naam, activeName())) { + return { naam: doel.naam, gewisseld: false, supervised: isSupervised() }; + } + writeActiveName(doel.naam); + return { naam: doel.naam, gewisseld: true, supervised: isSupervised() }; +} + +export const isSupervised = () => process.env.ASSETAPP_SUPERVISED === '1'; + +export async function renamePortfolio(vanNaam, naarNaam) { + const bron = eisBestaat(vanNaam); + eisNietActief(bron.naam, 'portfolios.activeCannotRename'); + if (!sameName(bron.naam, naarNaam)) eisVrijeNaam(naarNaam); + else assertValidName(naarNaam); + await rename(portfolioDir(bron.naam), portfolioDir(naarNaam)); + return { van: bron.naam, naar: naarNaam }; +} + +export async function archivePortfolio(naam) { + const bron = eisBestaat(naam); + eisNietActief(bron.naam, 'portfolios.activeCannotArchive'); + await mkdir(archiveDir(), { recursive: true }); + const doelNaam = await vrijeArchiefNaam(bron.naam); + await rename(portfolioDir(bron.naam), join(archiveDir(), doelNaam)); + return { naam: bron.naam, archiefNaam: doelNaam, map: join(archiveDir(), doelNaam) }; +} + +async function vrijeArchiefNaam(naam) { + if (!await bestaat(join(archiveDir(), naam))) return naam; + for (let n = 2; n < 1000; n++) { + const kandidaat = `${naam} (${n})`; + if (!await bestaat(join(archiveDir(), kandidaat))) return kandidaat; + } + fail('portfolios.archiveFull', { naam }, `The archive is full of directories named '${naam}'.`); +} + +export async function listArchived() { + let entries; + try { entries = await readdir(archiveDir(), { withFileTypes: true }); } + catch (e) { if (e.code === 'ENOENT') return []; throw e; } + const uit = []; + for (const e of entries) { + if (!e.isDirectory()) continue; + const map = join(archiveDir(), e.name); + let gearchiveerd = null; + try { gearchiveerd = (await stat(map)).mtime.toISOString(); } catch { } + uit.push({ naam: e.name, map, gearchiveerd }); + } + return uit.sort((a, b) => String(b.gearchiveerd).localeCompare(String(a.gearchiveerd))); +} + +export async function purgeArchived(naam) { + assertValidName(naam.replace(/ \(\d+\)$/, '')); + const map = resolve(join(archiveDir(), naam)); + if (!map.startsWith(resolve(archiveDir()) + sep)) { + throw new Error(`'${naam}' points outside the archive.`); + } + if (!await bestaat(map)) fail('portfolios.notInArchive', { naam }, `There is no '${naam}' in the archive.`); + await rm(map, { recursive: true, force: true }); + return { naam, map }; +} + +export async function overviewPortfolios() { + return { + actief: activeName(), + installatie: installDir(), + portefeuilles: listPortfolios(), + archief: await listArchived(), + }; +} diff --git a/server/portfolios.mjs b/server/portfolios.mjs new file mode 100644 index 0000000..ca86628 --- /dev/null +++ b/server/portfolios.mjs @@ -0,0 +1,206 @@ +// Generated, do not edit. sha256:82917722010d150a76bf3e84051a5132d98df9cd1713d95fe6238971925d39e8 +import { mkdirSync, readFileSync, writeFileSync, readdirSync, renameSync, statSync, rmSync } from 'node:fs'; +import { join, resolve, dirname, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { fail } from '../shared/app-error.js'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +const POINTER_NAME = 'active.json'; +const LEGACY_POINTER_NAME = 'actief.json'; +const POINTER_FIELD = 'portfolio'; +const LEGACY_POINTER_FIELD = 'portefeuille'; + +export const RESERVED_DIRS = Object.freeze(['tls', 'Archive']); + +const ARCHIVE_NAME = 'Archive'; + +export const DEFAULT_NAME = 'MyAssets'; + +export const MIGRATION_NAME = 'Test'; + +const PORTFOLIO_FILE_PREFIXES = ['db.sqlite', 'secrets.json']; + +export function installDir() { + return process.env.ASSETAPP_DATA_DIR || join(ROOT, 'data'); +} + +export function pointerFile() { + return join(installDir(), POINTER_NAME); +} + +export function archiveDir() { + return join(installDir(), ARCHIVE_NAME); +} + +export function portfolioDir(name) { + assertValidName(name); + const basis = resolve(installDir()); + const doel = resolve(join(installDir(), name)); + if (!doel.startsWith(basis + sep)) { + throw new Error(`Portfolio '${name}' would land outside the data directory.`); + } + return doel; +} + +const FORBIDDEN_CHARS = /[<>:"/\\|?*]/; + +const RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + +const MAX_LENGTH = 64; + +const hasControlChar = (name) => [...name].some(teken => teken.codePointAt(0) < 0x20); + +export function assertValidName(name) { + if (typeof name !== 'string' || name.length === 0) { + fail('portfolios.nameRequired', null, 'A portfolio needs a name.'); + } + if (name.length > MAX_LENGTH) { + fail('portfolios.nameTooLong', { max: MAX_LENGTH }, `A portfolio name is at most ${MAX_LENGTH} characters long.`); + } + if (name === '.' || name === '..') { + fail('portfolios.nameIsDirRef', { naam: name }, `'${name}' is not a name but a reference to a directory.`); + } + if (FORBIDDEN_CHARS.test(name) || hasControlChar(name)) { + fail('portfolios.nameForbiddenChars', null, 'A portfolio name may not contain any of these characters: < > : " / \\ | ? *'); + } + if (/[ .]$/.test(name)) { + fail('portfolios.nameTrailing', null, 'A portfolio name may not end in a space or a dot.'); + } + if (/^ /.test(name)) { + fail('portfolios.nameLeadingSpace', null, 'A portfolio name may not start with a space.'); + } + if (RESERVED_NAMES.test(name.split('.')[0])) { + fail('portfolios.nameReserved', { naam: name }, `'${name}' is a reserved name on Windows and cannot become a directory.`); + } + if (RESERVED_DIRS.some(gereserveerd => normalizeName(gereserveerd) === normalizeName(name))) { + fail('portfolios.nameTaken', { naam: name }, `'${name}' is a name the app uses itself.`); + } + return name; +} + +export function normalizeName(name) { + if (typeof name !== 'string') { + throw new TypeError(`Een portefeuillenaam is tekst; gekregen: ${typeof name}.`); + } + return name.normalize('NFC').toLocaleLowerCase('nl'); +} + +export function sameName(a, b) { + if (a == null || b == null) return false; + return normalizeName(a) === normalizeName(b); +} + +export function readActiveName() { + let rauw; + try { rauw = readFileSync(pointerFile(), 'utf-8'); } + catch (e) { + if (e.code !== 'ENOENT') throw e; + try { rauw = readFileSync(join(installDir(), LEGACY_POINTER_NAME), 'utf-8'); } + catch (e2) { if (e2.code === 'ENOENT') return null; throw e2; } + } + let data; + try { data = JSON.parse(rauw); } + catch { throw new Error(`${pointerFile()} is not valid JSON; repair or remove the file.`); } + const naam = data && (data[POINTER_FIELD] ?? data[LEGACY_POINTER_FIELD]); + if (typeof naam !== 'string' || naam.length === 0) { + throw new Error(`${pointerFile()} names no portfolio (field '${POINTER_FIELD}').`); + } + return assertValidName(naam); +} + +export function listPortfolios() { + let entries; + try { entries = readdirSync(installDir(), { withFileTypes: true }); } + catch (e) { if (e.code === 'ENOENT') return []; throw e; } + const actief = readActiveName(); + return entries + .filter(e => e.isDirectory()) + .map(e => e.name) + .filter(naam => !RESERVED_DIRS.some(g => normalizeName(g) === normalizeName(naam))) + .map(naam => { + const map = join(installDir(), naam); + const db = join(map, 'db.sqlite'); + return { + naam, + actief: actief !== null && sameName(naam, actief), + heeftDatabase: bestaat(db), + heeftGeheimen: bestaat(join(map, 'secrets.json')), + laatstGebruikt: mtimeOf(db), + }; + }) + .sort((a, b) => a.naam.localeCompare(b.naam, 'nl')); +} + +export function writeActiveName(name) { + assertValidName(name); + mkdirSync(installDir(), { recursive: true }); + writeFileSync(pointerFile(), `${JSON.stringify({ [POINTER_FIELD]: name }, null, 2)}\n`, 'utf-8'); + return name; +} + +export function activeName() { + return readActiveName() || DEFAULT_NAME; +} + +export function activeDir() { + return portfolioDir(activeName()); +} + +const bestaat = (pad) => { try { statSync(pad); return true; } catch { return false; } }; + +const mtimeOf = (pad) => { try { return statSync(pad).mtime.toISOString(); } catch { return null; } }; + +function looseFiles() { + let entries; + try { entries = readdirSync(installDir(), { withFileTypes: true }); } + catch (e) { if (e.code === 'ENOENT') return []; throw e; } + return entries + .filter(e => e.isFile()) + .map(e => e.name) + .filter(naam => PORTFOLIO_FILE_PREFIXES.some(prefix => naam.startsWith(prefix))); +} + +export function migrateLooseLayout() { + const losse = looseFiles(); + if (!losse.length) return null; + + const doelMap = portfolioDir(MIGRATION_NAME); + if (bestaat(join(doelMap, 'db.sqlite'))) { + const losseNamen = losse.join(', '); + throw new Error( + `There are loose database files in ${installDir()} (${losseNamen}) and there is already a database ` + + `in ${doelMap}. The app does not guess which one is the right one.\n` + + 'Almost always a process from before this layout is still running: an MCP server or an old app ' + + 'server then recreates the loose file every time. Close those processes (and restart your AI ' + + 'client), check which file is the largest, and remove or rename the loose one so it no longer ' + + 'starts with "db.sqlite" or "secrets.json".'); + } + mkdirSync(doelMap, { recursive: true }); + for (const naam of losse) renameSync(join(installDir(), naam), join(doelMap, naam)); + return MIGRATION_NAME; +} + +function hernoemOudeWijzer() { + const oud = join(installDir(), LEGACY_POINTER_NAME); + if (!bestaat(oud) || bestaat(pointerFile())) return; + const naam = readActiveName(); + if (naam) writeActiveName(naam); + rmSync(oud, { force: true }); +} + +export function prepareLayout() { + mkdirSync(installDir(), { recursive: true }); + hernoemOudeWijzer(); + const migrated = migrateLooseLayout(); + let naam = readActiveName(); + let herkomst = `uit ${POINTER_NAME}`; + if (!naam) { + naam = writeActiveName(migrated || DEFAULT_NAME); + herkomst = migrated ? `losse bestanden verhuisd naar '${migrated}'` : 'nieuw aangemaakt'; + } else if (migrated) { + herkomst = `uit ${POINTER_NAME}; losse bestanden verhuisd naar '${migrated}'`; + } + mkdirSync(portfolioDir(naam), { recursive: true }); + return { name: naam, migrated, regel: `portefeuille: ${naam} (${herkomst}) | map: ${portfolioDir(naam)}` }; +} diff --git a/server/price-export.mjs b/server/price-export.mjs new file mode 100644 index 0000000..562e4db --- /dev/null +++ b/server/price-export.mjs @@ -0,0 +1,47 @@ +// Generated, do not edit. sha256:2f3082db0160d4179616e98a296a8ab4a5647a67cfd48f2cc38d5f27a7ead0fa +import { priceSeriesExport } from './data/price-history.js'; +import { getBaseCurrency } from './data/portfolio.js'; + +export const CSV_COLUMNS = ['instrument', 'source', 'quote', 'unit', 'resolution', 'observed_at_utc', 'fetched_at_utc', 'price']; + +const iso = (v) => { + if (!v) return ''; + const ms = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(v) || v.length === 10 ? v : `${v}Z`); + return Number.isFinite(ms) ? new Date(ms).toISOString() : ''; +}; +const cel = (v) => { + const s = v == null ? '' : String(v); + return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +}; + +export function priceHistoryCsv(rows, quote) { + const regels = [CSV_COLUMNS.join(',')]; + for (const r of rows) { + const observed = iso(r.observedAt); + const price = Number(r.fiatPrice); + if (!observed || !Number.isFinite(price) || price <= 0) continue; + regels.push([ + `${r.kind}:${String(r.symbol || '').toUpperCase()}`, r.source, quote, r.kind === 'metal' ? 'g' : '', + r.resolution, observed, iso(r.createdAt), price, + ].map(cel).join(',')); + } + return regels.join('\n') + '\n'; +} + +function bestandsnaam() { + const d = new Date(); + const p = n => String(n).padStart(2, '0'); + return `koershistorie-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}.csv`; +} + +export function exportPriceHistory(res) { + const body = Buffer.from(priceHistoryCsv(priceSeriesExport(), getBaseCurrency()), 'utf-8'); + res.writeHead(200, { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${bestandsnaam()}"`, + 'Content-Length': body.length, + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }); + res.end(body); +} diff --git a/server/price/custom.js b/server/price/custom.js new file mode 100644 index 0000000..d586a5f --- /dev/null +++ b/server/price/custom.js @@ -0,0 +1,267 @@ +// Generated, do not edit. sha256:e3489a6085d47798380dafd4c4992473c5fdddb26fbfb6bf3b1a4ff191ab8437 +import { AUTH_KINDS, CONFIG } from '../../shared/config.js'; +import { AppError, fail } from '../../shared/app-error.js'; +import { toLocalIso } from '../../shared/time.js'; + +const TIMEOUT_MS = 12000; +const CAPABILITIES = ['spot', 'daily_history', 'history_point']; + +function tokenize(path, externalId) { + return path.split('.').map(seg => { + if (seg === '{external_id}') { + if (externalId == null) fail('integrations.price.externalIdMissing', null, 'The output path uses {external_id} but there is no external id.'); + return externalId; + } + return /^\d+$/.test(seg) ? Number(seg) : seg; + }); +} +export function resolveScalar(obj, path, externalId) { + let cur = obj; + for (const key of tokenize(path, externalId)) { + if (cur == null) return undefined; + cur = cur[key]; + } + return cur; +} +export function resolveColumn(obj, path, externalId) { + const o = path.indexOf('{}'); + if (o >= 0 && (path.indexOf('[]') < 0 || o < path.indexOf('[]'))) { + const before = path.slice(0, o); + const after = path.slice(o + 2).replace(/^\./, ''); + const map = before ? resolveScalar(obj, before, externalId) : obj; + if (map == null || typeof map !== 'object' || Array.isArray(map)) { + fail('integrations.price.expectedObject', { pad: before || '(root)' }, `Expected an object at '${before || '(root)'}'.`); + } + return Object.entries(map).map(([key, value]) => + (after === '@key' ? key : after ? resolveScalar(value, after, externalId) : value)); + } + const i = path.indexOf('[]'); + if (i < 0) fail('integrations.price.listPathWithoutBrackets', { pad: path }, `List path without []: '${path}'.`); + const before = path.slice(0, i); + const after = path.slice(i + 2).replace(/^\./, ''); + const arr = before ? resolveScalar(obj, before, externalId) : obj; + if (!Array.isArray(arr)) fail('integrations.price.expectedArray', { pad: before || '(root)' }, `Expected an array at '${before || '(root)'}'.`); + return arr.map(el => (after ? resolveScalar(el, after, externalId) : el)); +} +function applyScalarTransform(v, transform) { + switch (transform) { + case 'none': return v; + case 'to_number': { const n = Number(v); return Number.isFinite(n) ? n : null; } + case 'ms_to_s': { const n = Number(v); return Number.isFinite(n) ? Math.floor(n / 1000) : null; } + case 'last_per_day': return v; + case 'troy_ounce_to_gram': { const n = Number(v); const f = CONFIG.METALS.GRAMS_PER_TROY_OUNCE; + return (Number.isFinite(n) && f) ? n / f : null; } + case 'iso_date_to_s': { + if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return null; + const ms = Date.parse(`${v}T00:00:00Z`); + return Number.isFinite(ms) ? Math.floor(ms / 1000) : null; + } + case 'ms_to_s_local_close': { + const n = Number(v); + if (!Number.isFinite(n)) return null; + const utc = new Date(n); + if (n % 86400000 === 0 && utc.getUTCDate() === 1) return null; + const lokaal = toLocalIso(utc.toISOString()); + if (/T00:00:00(\.000)?$/.test(lokaal)) return Date.parse(`${lokaal.slice(0, 10)}T00:00:00Z`) / 1000; + return Math.floor(n / 1000); + } + default: fail('integrations.price.unknownTransform', { transform }, `Unknown transform: '${transform}'.`); + } +} + +function outputsFor(provider, capability) { + const rows = provider.outputs.filter(o => o.capability === capability); + if (!rows.length) fail('integrations.price.noOutputMapping', { provider: provider.key, capability }, `Provider ${provider.key}: no output mapping for '${capability}'.`); + return rows; +} +function assertPriced(value, provider) { + if (Number.isFinite(value)) return; + fail('integrations.price.noValidPrice', { provider: provider.key }, `Provider ${provider.key}: no valid price in the response.`); +} + +export function mapSpot(parsed, provider, externalId) { + const out = {}; + for (const o of outputsFor(provider, 'spot')) + out[o.internalField] = applyScalarTransform(resolveScalar(parsed, o.jsonPath, externalId), o.transform); + assertPriced(out.price, provider); + return { price: out.price }; +} +export function mapHistoryPoint(parsed, provider, externalId, dateIso) { + const out = {}; + for (const o of outputsFor(provider, 'history_point')) + out[o.internalField] = applyScalarTransform(resolveScalar(parsed, o.jsonPath, externalId), o.transform); + const time = Number.isFinite(out.time) ? out.time : Math.floor(Date.parse(`${dateIso}T00:00:00Z`) / 1000); + if (!Number.isFinite(time)) fail('integrations.price.invalidDate', { provider: provider.key, datum: dateIso }, `Provider ${provider.key}: invalid date '${dateIso}'.`); + assertPriced(out.price, provider); + return { time, price: out.price }; +} +export function mapDailyHistory(parsed, provider, externalId) { + const cols = {}; let len = null; let lastPerDay = false; + for (const o of outputsFor(provider, 'daily_history')) { + if (o.transform === 'last_per_day') lastPerDay = true; + const vals = resolveColumn(parsed, o.jsonPath, externalId) + .map(v => applyScalarTransform(v, o.transform === 'last_per_day' ? 'none' : o.transform)); + cols[o.internalField] = vals; + if (len == null) len = vals.length; + else if (vals.length !== len) fail('integrations.price.columnsUneven', { provider: provider.key }, `Provider ${provider.key}: the output columns have unequal length.`); + } + let rows = []; + for (let i = 0; i < (len || 0); i++) { + const time = cols.time ? cols.time[i] : null; + const price = cols.price?.[i]; + if (Number.isFinite(time) && Number.isFinite(price)) rows.push({ time, price }); + } + rows.sort((a, b) => a.time - b.time); + if (lastPerDay) { + const byDay = new Map(); + for (const r of rows) byDay.set(new Date(r.time * 1000).toISOString().slice(0, 10), r); + rows = [...byDay.values()].sort((a, b) => a.time - b.time); + } + return rows; +} +function mapResult(parsed, provider, capability, externalId, dateIso) { + if (capability === 'spot') return mapSpot(parsed, provider, externalId); + if (capability === 'history_point') return mapHistoryPoint(parsed, provider, externalId, dateIso); + return mapDailyHistory(parsed, provider, externalId); +} + +function endpointFor(provider, capability) { + const e = provider.endpoints.find(ep => ep.capability === capability); + if (!e) fail('integrations.price.capabilityNotConfigured', { provider: provider.key, capability }, `Provider ${provider.key}: capability '${capability}' is not configured.`); + return e; +} +function mapRowFor(provider, symbol) { + const rows = provider.symbols || []; + return rows.find(s => !s.isCatchAll && s.symbol === symbol) || rows.find(s => s.isCatchAll) || null; +} +function externalIdFor(provider, symbol) { + const row = mapRowFor(provider, symbol); + return row ? row.externalId : (symbol != null ? symbol : null); +} +function subst(tpl, externalId, symbol, dateIso, providerKey) { + if (tpl == null) return tpl; + let s = tpl; + if (s.includes('{external_id}')) { + if (externalId == null) fail('integrations.price.symbolMissing', { provider: providerKey }, `Provider ${providerKey}: {external_id} is used but no symbol was given.`); + s = s.replaceAll('{external_id}', encodeURIComponent(externalId)); + } + if (s.includes('{symbol}')) s = s.replaceAll('{symbol}', encodeURIComponent(symbol ?? '')); + if (s.includes('{date}')) { + const ymd = String(dateIso || '').slice(0, 10).replace(/-/g, ''); + if (ymd.length !== 8) fail('integrations.price.templateDateInvalid', { provider: providerKey, datum: dateIso }, `Provider ${providerKey}: the template uses {date} but date '${dateIso}' is invalid.`); + s = s.replaceAll('{date}', ymd); + } + return s; +} +const toHex = buf => [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join(''); +export async function hmacHeaders(hmac, method, fullPath, body, creds) { + const ts = Date.now(); + const tokens = { timestamp: String(ts), method, full_path: fullPath, body: body || '' }; + const message = hmac.messageParts.split(',').map(tok => { + if (!(tok in tokens)) fail('integrations.price.unknownHmacToken', { token: tok }, `Unknown hmac message token: '${tok}'.`); + return tokens[tok]; + }).join(''); + const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(creds.api_secret), + { name: 'HMAC', hash: hmac.hashAlgorithm }, false, ['sign']); + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message)); + const signature = hmac.encoding === 'hex' ? toHex(sig) : Buffer.from(sig).toString('base64'); + const headers = { [hmac.keyHeader]: creds.api_key, [hmac.signatureHeader]: signature, [hmac.timestampHeader]: String(ts) }; + if (hmac.windowHeader && hmac.windowMs != null) headers[hmac.windowHeader] = String(hmac.windowMs); + return headers; +} +export async function buildRequest(provider, capability, symbol, dateIso, creds) { + if (!CAPABILITIES.includes(capability)) fail('integrations.price.unknownCapability', { capability }, `Unknown capability: '${capability}'.`); + const e = endpointFor(provider, capability); + if (e.method !== 'GET') fail('integrations.price.onlyGet', { provider: provider.key }, `Provider ${provider.key}: only GET is supported.`); + const externalId = externalIdFor(provider, symbol); + + const kind = provider.authenticationKind; + if (kind !== 'none' && !AUTH_KINDS[kind]) fail('integrations.price.unknownAuthKind', { soort: kind }, `Unknown authenticationKind: '${kind}'.`); + const fields = kind === 'none' ? [] : AUTH_KINDS[kind].secret_fields; + const hasCreds = kind !== 'none' && fields.every(f => creds && creds[f]); + if (provider.authenticationRequired && !hasCreds) + fail('integrations.price.keyMissing', { provider: provider.key }, `Provider ${provider.key}: a key is required but missing or incomplete.`); + + const path = subst(e.pathTemplate, externalId, symbol, dateIso, provider.key); + let query = subst(e.queryTemplate, externalId, symbol, dateIso, provider.key) || ''; + if (kind === 'query_key' && hasCreds) { + if (!provider.authenticationParameterName) fail('integrations.price.parameterNameMissing', { provider: provider.key }, `Provider ${provider.key}: authenticationParameterName is missing for query_key.`); + const qp = `${encodeURIComponent(provider.authenticationParameterName)}=${encodeURIComponent(creds.api_key)}`; + query = query ? `${query}&${qp}` : qp; + } + const fullPath = path + (query ? `?${query}` : ''); + + const headers = {}; + if (hasCreds) { + if (kind === 'header_key') { + if (!provider.authenticationHeaderName) fail('integrations.price.headerNameMissing', { provider: provider.key }, `Provider ${provider.key}: authenticationHeaderName is missing for header_key.`); + headers[provider.authenticationHeaderName] = creds.api_key; + } else if (kind === 'bearer') { + headers['Authorization'] = `Bearer ${creds.api_key}`; + } else if (kind === 'basic') { + headers['Authorization'] = `Basic ${Buffer.from(`${creds.api_key}:${creds.api_secret}`).toString('base64')}`; + } else if (kind === 'hmac') { + if (!provider.hmac) fail('integrations.price.hmacConfigMissing', { provider: provider.key }, `Provider ${provider.key}: the hmac config is missing.`); + Object.assign(headers, await hmacHeaders(provider.hmac, e.method, fullPath, '', creds)); + } + } + return { url: provider.baseUrl + fullPath, method: e.method, headers, externalId }; +} + +function describeBody(res, text) { + const type = String(res.headers?.get?.('content-type') || 'no type').split(';')[0].trim(); + return `no JSON (${type}, ${Buffer.byteLength(text || '', 'utf8')} bytes)`; +} + +function tagged(e, extra) { return Object.assign(e, extra); } + +export async function priceCall(provider, capability, symbol, dateIso, creds, { withRaw = false } = {}) { + let built; + try { built = await buildRequest(provider, capability, symbol, dateIso, creds); } catch (e) { throw tagged(e, { notSent: true }); } + const { url, method, headers, externalId } = built; + let res; + try { + res = await fetch(url, { method, headers, signal: AbortSignal.timeout(TIMEOUT_MS) }); + } catch (err) { + fail('integrations.price.networkError', { provider: provider.key, capability, reden: err.name === 'TimeoutError' ? 'timeout' : err.message }, + `${provider.key} ${capability}: network error (${err.name === 'TimeoutError' ? 'timeout' : err.message}).`); + } + const text = await res.text().catch(() => ''); + let parsed; + let notJson = null; + try { parsed = JSON.parse(text); } catch { notJson = describeBody(res, text); } + const httpStatus = res.status; + if (!res.ok) { + const m = parsed && (parsed.error || parsed.message); + const detail = typeof m === 'string' && m ? ` ${m}` : notJson ? ` ${notJson}` : ''; + throw tagged(new AppError('integrations.price.httpFailed', { provider: provider.key, capability, status: res.status, detail }, + `${provider.key} ${capability} failed (${res.status})${detail}.`), { httpStatus }); + } + if (notJson) { + throw tagged(new AppError('integrations.price.notJson', { provider: provider.key, capability, detail: notJson }, + `${provider.key} ${capability}: the answer is ${notJson}.`), { httpStatus }); + } + let result; + try { + result = inStorageUnit(mapResult(parsed, provider, capability, externalId, dateIso), provider, symbol); + } catch (e) { throw tagged(e, { httpStatus }); } + return withRaw ? { result, raw: parsed } : result; +} + +export function failureEntry(provider, capability, symbol, e, latencyMs) { + const outcome = e?.notSent ? 'refused' : e?.key === 'integrations.price.noValidPrice' ? 'empty' : 'fail'; + return { + target: 'provider', providerKey: provider?.key, operation: capability, + subject: typeof symbol === 'string' && symbol ? symbol : null, outcome, + httpStatus: e?.httpStatus ?? null, errorKey: e?.key ?? null, detail: e?.message ?? String(e), + latencyMs: e?.notSent ? null : latencyMs, + }; +} + +function inStorageUnit(result, provider, symbol) { + const row = mapRowFor(provider, symbol); + if (!row || row.quoteUnit !== 'ozt') return result; + const f = CONFIG.METALS.GRAMS_PER_TROY_OUNCE; + const perGram = r => (Number.isFinite(r.price) ? { ...r, price: r.price / f } : r); + return Array.isArray(result) ? result.map(perGram) : perGram(result); +} diff --git a/server/registers.mjs b/server/registers.mjs new file mode 100644 index 0000000..f20c877 --- /dev/null +++ b/server/registers.mjs @@ -0,0 +1,21 @@ +// Generated, do not edit. sha256:6d030f08e2df0805a10aaaf982941c7fff895ab138d87d0dfa035a84ed9a5d86 +import { createRegistry } from '../shared/registry.js'; + +const needs = (keys) => (entry) => { + for (const k of keys) if (entry[k] === undefined) throw new Error(`'${entry.id}' lacks '${k}'`); +}; + +export const apiRoutes = createRegistry('api route', { validate: needs(['method', 'path', 'run']) }); + +export const dataModules = createRegistry('data module', { validate: needs(['module']) }); + +export const services = createRegistry('service'); + +export const mcpTools = createRegistry('mcp tool', { + validate: (e) => { + needs(['tool'])(e); + if (e.tool.name !== e.id) throw new Error(`mcp tool '${e.id}' carries the name '${e.tool.name}'`); + }, +}); + +export const healthChecks = createRegistry('health check', { validate: needs(['severity', 'run', 'countsFrom']) }); diff --git a/server/sessions.mjs b/server/sessions.mjs new file mode 100644 index 0000000..09bd4b3 --- /dev/null +++ b/server/sessions.mjs @@ -0,0 +1,79 @@ +// Generated, do not edit. sha256:267314982d9469bd348aa537982dfb5f7b94157d526aea6cbeeb8cefdfe06595 +import { randomBytes } from 'node:crypto'; +import { CONFIG } from '../shared/config.js'; + +const nieuwToken = () => randomBytes(32).toString('hex'); + +export function createSessionStore({ ttlMs = CONFIG.AUTH.SESSION_TTL_MS, now = Date.now } = {}) { + const sessies = new Map(); + + const verlopen = (aangemaakt) => now() - aangemaakt >= ttlMs; + + return { + create() { + for (const [token, aangemaakt] of sessies) if (verlopen(aangemaakt)) sessies.delete(token); + const token = nieuwToken(); + sessies.set(token, now()); + return token; + }, + + isValid(token) { + if (typeof token !== 'string' || !token) return false; + const aangemaakt = sessies.get(token); + if (aangemaakt === undefined) return false; + if (verlopen(aangemaakt)) { sessies.delete(token); return false; } + return true; + }, + + drop(token) { + return sessies.delete(token); + }, + + size() { + return sessies.size; + }, + }; +} + +export function createAttemptLimiter({ + free = CONFIG.AUTH.LOGIN_ATTEMPTS.FREE, + baseLockMs = CONFIG.AUTH.LOGIN_ATTEMPTS.BASE_LOCK_MS, + maxLockMs = CONFIG.AUTH.LOGIN_ATTEMPTS.MAX_LOCK_MS, + now = Date.now, +} = {}) { + const pogingen = new Map(); + + const wachttijd = (missers) => { + if (missers <= free) return 0; + return Math.min(maxLockMs, baseLockMs * 2 ** (missers - free - 1)); + }; + + return { + retryAfterMs(adres) { + const staat = pogingen.get(adres); + if (!staat) return 0; + const over = staat.laatste + wachttijd(staat.missers) - now(); + if (over <= 0) { + if (over < -maxLockMs) pogingen.delete(adres); + return 0; + } + return over; + }, + + fail(adres) { + const staat = pogingen.get(adres) || { missers: 0, laatste: 0 }; + staat.missers += 1; + staat.laatste = now(); + pogingen.set(adres, staat); + return wachttijd(staat.missers); + }, + + succeed(adres) { + pogingen.delete(adres); + }, + + size() { + return pogingen.size; + }, + }; +} diff --git a/server/supervisor.mjs b/server/supervisor.mjs new file mode 100644 index 0000000..87ccd63 --- /dev/null +++ b/server/supervisor.mjs @@ -0,0 +1,67 @@ +// Generated, do not edit. sha256:9c19f153125a535056932eee985b7b6abf166ab4af48fe08f3cd2fb0dbf6edfd +import { spawn } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { CONFIG } from '../shared/config.js'; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const SERVER = join(HIER, 'index.mjs'); + +const STOPSIGNALEN = ['SIGINT', 'SIGTERM', 'SIGHUP']; + +let kind = null; +let stoppen = false; +let eersteStart = true; + +function startServer() { + const env = { ...process.env, ASSETAPP_SUPERVISED: '1' }; + if (!eersteStart) delete env.ASSETAPP_OPEN; + eersteStart = false; + + kind = spawn(process.execPath, [SERVER], { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + env, + }); + + kind.on('exit', (code, signal) => { + kind = null; + if (stoppen) return; + if (code === CONFIG.PORTFOLIO.RESTART_EXIT_CODE) { + console.log('[supervisor] portefeuille gewisseld; de server start opnieuw.'); + startServer(); + return; + } + if (signal) { + console.log(`[supervisor] de server stopte op signaal ${signal}.`); + process.exit(1); + } + process.exit(code ?? 0); + }); + + kind.on('error', (e) => { + console.error(`[supervisor] kan de server niet starten: ${e.message}`); + process.exit(1); + }); +} + +for (const signaal of STOPSIGNALEN) { + process.on(signaal, () => { + stoppen = true; + if (!kind) process.exit(0); + kind.kill(signaal); + const hard = setTimeout(() => { if (kind) kind.kill('SIGKILL'); }, 10_000); + hard.unref(); + }); +} + +process.on('exit', () => { if (kind) kind.kill('SIGKILL'); }); +process.on('uncaughtException', (e) => { + console.error(`[supervisor] onverwachte fout: ${e.message}`); + if (kind) kind.kill('SIGKILL'); + process.exit(1); +}); + +if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) startServer(); + +export { STOPSIGNALEN, SERVER }; diff --git a/shared/app-error.js b/shared/app-error.js new file mode 100644 index 0000000..d4d60b7 --- /dev/null +++ b/shared/app-error.js @@ -0,0 +1,24 @@ +// Generated, do not edit. sha256:312aacc2f42215e3ffe54763edfbe29f7affc0f2ae41d522eaebbe359f2b2ffa +export class AppError extends Error { + constructor(key, params = null, fallback = null) { + super(fallback || key); + this.name = 'AppError'; + this.key = key; + this.params = params; + } +} + +export const isAppError = (e) => !!e && typeof e === 'object' && typeof e.key === 'string'; + +export function fail(key, params = null, fallback = null) { + throw new AppError(key, params, fallback); +} + +export function errorPayload(e) { + const uit = { error: (e && e.message) || String(e) }; + if (isAppError(e)) { + uit.key = e.key; + if (e.params) uit.params = e.params; + } + return uit; +} diff --git a/shared/config.js b/shared/config.js new file mode 100644 index 0000000..fc67939 --- /dev/null +++ b/shared/config.js @@ -0,0 +1,871 @@ +// Generated, do not edit. sha256:79db5d8cc5706ad987b207d98e1c67ca389284a24121f65b199acbe48bce76ec +const GRAMS_PER_TROY_OUNCE = 31.1034768; + +export const CONFIG = Object.freeze({ + PRICE_SOURCES: Object.freeze({ + MEMPOOL_HISTORICAL: '/api/v1/historical-price', + MEMPOOL_PRICES: '/api/v1/prices', + MEMPOOL_PUBLIC_BASE: 'https://mempool.space', + BITVAVO_BASE: 'https://api.bitvavo.com', + BITVAVO_CANDLES: '/v2/{market}/candles', + BITVAVO_TICKER: '/v2/ticker/price', + BITVAVO_ACCOUNT: '/v2/account', + COINGECKO_BASE: 'https://api.coingecko.com/api/v3', + COINGECKO_PRICE: '/simple/price', + COINGECKO_CHART: '/coins/{id}/market_chart', + COINGECKO_PING: '/ping', + GOLDAPI_BASE: 'https://www.goldapi.io', + GOLDAPI_SPOT: '/api/{metal}/{currency}', + GOLDAPI_HISTORICAL: '/api/{metal}/{currency}/{date}', + EULERPOOL_BASE: 'https://api.eulerpool.com', + EULERPOOL_QUOTES: '/api/1/{external_id}/quotes/{symbol}', + METALSDEV_BASE: 'https://api.metals.dev', + METALSDEV_SPOT: '/v1/metal/spot', + METALSDEV_TIMESERIES: '/v1/timeseries', + FRANKFURTER_BASE: 'https://api.frankfurter.dev', + FRANKFURTER_LATEST: '/v1/latest', + FRANKFURTER_SERIES: '/v1/1999-01-04..', + }), + + CHART_LIBS: Object.freeze({ + CHARTJS: 'vendor/chartjs/chart.umd.js', + HAMMER: 'vendor/hammerjs/hammer.min.js', + ZOOM: 'vendor/chartjs-plugin-zoom/chartjs-plugin-zoom.min.js', + }), + + SCHEMA_VERSION: 128, + + MAX_ASSET_DECIMALS: 9, + + LOCALE_TAGS: Object.freeze({ nl: 'nl-NL', en: 'en-US' }), + + INSTALLATION: Object.freeze({ SETTINGS_FILE_NAME: 'installation.json', DEFAULT_LOCALE: 'en' }), + + METALS: Object.freeze({ + BASE_UNIT: 'g', + BASE_DECIMALS: 7, + GRAMS_PER_TROY_OUNCE, + DISPLAY_DECIMALS: 4, + UNITS: Object.freeze({ g: 1, kg: 1000, ozt: GRAMS_PER_TROY_OUNCE }), + SPOT_DEVIATION_WARN: 0.10, + }), + + DB_TABLES: Object.freeze([ + 'Asset', 'Account', 'Event', 'Valuation', 'Label', + 'WalletSource', 'WalletAddress', 'WalletTransaction', 'ScanLog', + 'ReconcileProposal', 'ImportBatch', 'PriceHistory', 'WalletTransactionEventLink', + 'InterestSchedule', 'InterestPeriod', 'InterestCredit', 'Notification', + 'MetalProduct', + 'Provider', 'ProviderAuthenticationHmac', 'ProviderEndpoint', 'ProviderFixedRate', 'ProviderCallLimit', + 'ProviderInputMap', 'ProviderOutputMap', 'ProviderCoverage', 'ApiCallMetric', + 'PriceSource', 'Connection', + 'DashboardProfile', 'DashboardWidget', + 'AiToolCall', 'AiToolCallMessage', 'BusSignal', + 'Command', 'AiProposal', + 'Preference', 'Schema', 'ChangeLog', 'HygieneAcceptance', 'IgnoredPriceGap', 'AssetNote', + 'Portfolio', 'PriceAbsence', 'IntegrationCall', + ]), + + DB_DELETABLE_TABLES: Object.freeze([ + 'ImportBatch', 'PriceHistory', 'ReconcileProposal', 'WalletTransactionEventLink', + 'Valuation', 'WalletAddress', 'InterestCredit', 'Notification', + 'PriceSource', 'ProviderCoverage', 'Preference', 'BusSignal', + 'PriceAbsence', + 'IntegrationCall', + ]), + + DB_HISTORY: Object.freeze({ + TRACKED: Object.freeze([ + 'Account', 'Asset', 'Event', 'Valuation', 'Label', 'MetalProduct', + 'InterestSchedule', 'InterestPeriod', 'InterestCredit', + 'WalletSource', 'Connection', + ]), + UNTRACKED: Object.freeze({ + AiToolCall: 'logboek', AiToolCallMessage: 'logboek', BusSignal: 'logboek', + Command: 'logboek', ScanLog: 'logboek', ApiCallMetric: 'logboek', Notification: 'logboek', + WalletTransaction: 'scanketen', WalletAddress: 'scanketen', WalletTransactionEventLink: 'scanketen', + PriceHistory: 'volume', PriceAbsence: 'volume', + AiProposal: 'herkomst', ReconcileProposal: 'herkomst', ImportBatch: 'herkomst', + Provider: 'configuratie', ProviderAuthenticationHmac: 'configuratie', + ProviderEndpoint: 'configuratie', ProviderFixedRate: 'configuratie', ProviderCallLimit: 'configuratie', + ProviderInputMap: 'configuratie', ProviderOutputMap: 'configuratie', + ProviderCoverage: 'configuratie', PriceSource: 'configuratie', + DashboardProfile: 'weergave', DashboardWidget: 'weergave', Preference: 'weergave', + Schema: 'systeem', Portfolio: 'systeem', + ChangeLog: 'logboek', + IntegrationCall: 'logboek', + HygieneAcceptance: 'weergave', + IgnoredPriceGap: 'weergave', + AssetNote: 'weergave', + }), + }), + + PEILDATUM: Object.freeze({ + MONTH: 1, DAY: 1, HOUR: 0, MINUTE: 0, + TIMEZONE: 'Europe/Amsterdam', + }), + + START_YEAR: Object.freeze({ + AUTO: 0, + MIN: 1900, + MAX: 2200, + }), + + WEALTH_RETURN: Object.freeze({ DUST_SHARE: 0.005, REOPEN_MULTIPLE: 10, ROLLING_DAYS: Object.freeze([1, 7, 30, 365]) }), + + AUTO_BLUR: Object.freeze({ + PRESETS_MIN: Object.freeze([5, 15, 30]), + NEVER_MIN: 525600, + }), + + GLOW_TEMPO: Object.freeze({ + brisk: Object.freeze({ MOVE_S: 11, PAUSE_MIN_S: 18, PAUSE_MAX_S: 40, ALWAYS_MULT: 1 }), + calm: Object.freeze({ MOVE_S: 24, PAUSE_MIN_S: 45, PAUSE_MAX_S: 90, ALWAYS_MULT: 2 }), + slow: Object.freeze({ MOVE_S: 36, PAUSE_MIN_S: 80, PAUSE_MAX_S: 140, ALWAYS_MULT: 3 }), + }), + GLOW_CORE_CHANCE: 0.35, + ATTENTION_RING: Object.freeze({ ROUND_MS: 3500, ROUNDS: 3 }), + + TIMERS: Object.freeze({ + OFF_SECONDS: 31536000, + SPOT_REFRESH: Object.freeze({ + MIN_SECONDS: 60, + MAX_SECONDS: 3600, + PRESETS_SECONDS: Object.freeze([60, 300, 900]), + }), + BUS_POLL: Object.freeze({ + MIN_SECONDS: 5, + MAX_SECONDS: 300, + PRESETS_SECONDS: Object.freeze([5, 15, 60]), + }), + }), + + PRICE_HISTORY: Object.freeze({ + NEAREST_MAX_SECONDS: 60, + CARRY_FORWARD_DAYS: Object.freeze({ crypto: 7, metal: 14, fiat: null }), + CLOSED_MARKET_DAYS: Object.freeze({ crypto: 0, metal: 4, fiat: 4 }), + WEEKEND_PRICE: Object.freeze({ crypto: true, metal: false, fiat: false }), + RECENT_CLOSE_DAYS: 14, + FX_MAX_AGE_DAYS: 7, + }), + + API_USAGE: Object.freeze({ WARN_SHARE: 0.8 }), + + HYGIENE: Object.freeze({ + PRICE_GAP_DAYS: 7, + MAX_ROWS_PER_FINDING: 200, + OUTLIER_FACTOR: 3, + TROY_OUNCE_TOLERANCE: 0.2, + SOURCES_DISAGREE_SHARE: 0.08, + BOOKING_PRICE_DEVIATION: 0.2, + }), + + BUS: Object.freeze({ + RETENTION_HOURS: 24, + }), + + INTEGRATION_LOG: Object.freeze({ + RETENTION_DAYS: 90, + MAX_ROWS: 10_000, + DETAIL_MAX_CHARS: 300, + WINDOW_DAYS: 7, + AI_MAX_ROWS: 20, + }), + + COMMANDS: Object.freeze({ + CLAIM_SECONDS: 20, + COUNTDOWN_SECONDS: 5, + NOTICE_SECONDS: 3, + RETENTION_HOURS: 24, + }), + + BACKUP: Object.freeze({ + REMIND_AFTER_DAYS: 14, + }), + + HEALTH_CHECK: Object.freeze({ + REMIND_AFTER_DAYS: 30, + }), + + AUTH: Object.freeze({ + SESSION_TTL_MS: 12 * 60 * 60 * 1000, + COOKIE_NAME: 'assetapp_session', + LOGIN_PATH: '/login', + LOGIN_ATTEMPTS: Object.freeze({ + FREE: 3, + BASE_LOCK_MS: 2000, + MAX_LOCK_MS: 5 * 60 * 1000, + }), + }), + + ATTENTION: Object.freeze({ + FADE_AFTER_DAYS: 1, + HIDE_AFTER_DAYS: 2, + }), + + FOCUS: Object.freeze({ + INTEREST_ACCOUNT: 'interest.focusAccount', + ROW: 'focus.row', + ROW_TTL_MS: 30_000, + }), + + LICENSE: Object.freeze({ + FILE_NAME: 'license.json', + FREE_FILE_NAME: 'free-license.json', + BUYER_FILE_NAME: 'buyer.json', + SUPPORT: Object.freeze({ + ATTACHMENT_TYPES: Object.freeze(['image/png', 'image/jpeg', 'image/webp']), + MAX_ATTACHMENT_BYTES: 2 * 1024 * 1024, + RECIPIENT: '72304ea4d273d6c685aaa76342975e7a001190c9f15369ed3d3b303b79c8c74c', + RECIPIENT_NAME: 'Atreides Assets', + RELAYS: Object.freeze(['wss://nos.lol']), + MAX_TEXT: 4000, + MAX_EVENT_BYTES: 64 * 1024, + PUBLISH_TIMEOUT_MS: 10_000, + }), + INFO_LINKS: Object.freeze([ + Object.freeze({ id: 'nostrHow', url: 'https://nostr.how/' }), + Object.freeze({ id: 'nostrOrg', url: 'https://nostr.org/' }), + Object.freeze({ id: 'alby', url: 'https://getalby.com/alby-extension' }), + Object.freeze({ id: 'nos2x', url: 'https://github.com/fiatjaf/nos2x' }), + Object.freeze({ id: 'lightning', url: 'https://lightning.network/' }), + ]), + PAY_APPS: Object.freeze([ + Object.freeze({ id: 'phoenix', name: 'Phoenix', group: 'easy', url: 'https://phoenix.acinq.co/' }), + Object.freeze({ id: 'pocket', name: 'Pocket', group: 'easy', url: 'https://pocketbitcoin.com/lightning' }), + Object.freeze({ id: 'strike', name: 'Strike', group: 'other', url: 'https://strike.me/' }), + Object.freeze({ id: 'zeus', name: 'Zeus', group: 'other', url: 'https://zeusln.com/' }), + Object.freeze({ id: 'bringin', name: 'Bringin', group: 'other', url: 'https://bringin.app/' }), + ]), + FEATURE_IMAGES: 'img/features', + FEATURE_GROUPS: Object.freeze(['overview', 'records', 'prices', 'app', 'bitcoin']), + FEATURES: Object.freeze([ + { id: 'dashboard', group: 'overview', free: null }, { id: 'wealth', group: 'overview', free: null }, + { id: 'returns', group: 'overview', free: null }, { id: 'taxes', group: 'overview', has: 'tax' }, + { id: 'balances', group: 'records', free: null }, { id: 'interest', group: 'records', free: null }, + { id: 'import', group: 'records', has: 'import' }, { id: 'data', group: 'records', has: 'data' }, + { id: 'health', group: 'records', has: 'health' }, { id: 'metals', group: 'records', free: false }, + { id: 'priceHistory', group: 'prices', free: null }, + { id: 'sourceChoice', group: 'prices', has: 'sourceMatrix' }, { id: 'providerKeys', group: 'prices', has: 'providerKeys' }, + { id: 'portfolios', group: 'app', has: 'portfolios' }, { id: 'ai', group: 'app', has: 'ai' }, + { id: 'kiosk', group: 'app', has: 'kiosk' }, + { id: 'tracking', group: 'bitcoin', free: null }, { id: 'lightning', group: 'bitcoin', has: 'lightning' }, + { id: 'ownMempool', group: 'bitcoin', has: 'ownMempool' }, { id: 'signals', group: 'bitcoin', has: 'signals' }, + { id: 'netshare', group: 'bitcoin', has: 'netshare' }, + ].map(f => Object.freeze(f))), + SELLER_KEY: 'cfc6e67daaf080abbdcbacdbca587d54a3c18b119f2f05d7b087b859caa96a2f', + FORMAT: 1, + FORMATS: Object.freeze([1, 2]), + MESSAGE_PREFIX: 'assetapp-license-v1', + ISSUER_PREFIX: 'assetapp-issuer-v1', + UNLIMITED: 'unlimited', + LEVELS: Object.freeze(['full', 'free']), + + FREE: Object.freeze({ + MAX: Object.freeze({ + assets: 1, + fiatAccounts: 1, + portfolios: 1, + valuations: 1, + }), + ASSETS: Object.freeze(['BTC']), + HAS: Object.freeze({ + ai: true, + sourceMatrix: false, + lightning: false, + tax: false, + providerKeys: false, + ownMempool: false, + signals: false, + netshare: false, + portfolios: false, + import: false, + data: false, + health: false, + kiosk: false, + }), + }), + }), + + EDITION: Object.freeze({ + CLIENT: Object.freeze({ + entry: 'client/edition.js', + starts: Object.freeze(['client/app.js', 'client/boot-gate.js', 'client/login.js']), + }), + SERVER: Object.freeze({ + entry: 'server/edition.mjs', + starts: Object.freeze(['server/index.mjs', 'server/supervisor.mjs', 'server/mcp/stdio.mjs']), + }), + }), + + PORTFOLIO: Object.freeze({ + RESTART_EXIT_CODE: 75, + DEFAULT_BASE_CURRENCY: 'EUR', + BASE_CURRENCIES: Object.freeze([ + 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HKD', 'HUF', 'IDR', 'ILS', 'INR', 'ISK', + 'JPY', 'KRW', 'MXN', 'MYR', 'NOK', 'NZD', 'PHP', 'PLN', 'RON', 'SEK', 'SGD', 'THB', 'TRY', 'USD', 'ZAR', + ]), + ACTIVE_KEY: 'portfolio_active', + SWITCH_KEY: 'portfolio_switching', + }), + + SPLASH: Object.freeze({ + MIN_MS: 3200, + FADE_MS: 900, + QUOTE_START_MS: 1800, + QUOTE_CHAR_MS: 40, + QUOTE_WORD_PAUSE_MS: 110, + }), + + CONNECTIONS: Object.freeze({ + TEST_TIMEOUT_MS: 8000, + RESULT_HOLD_MS: 6000, + RESULT_FADE_MS: 600, + }), + + NWC: Object.freeze({ + REQUEST_TIMEOUT_MS: 15000, + CLIENT_TIMEOUT_MS: 20000, + }), + + LND: Object.freeze({ + REQUEST_TIMEOUT_MS: 15000, + CLIENT_TIMEOUT_MS: 20000, + CHANNEL_LABEL: 'Lightning-kanaal', + }), + + MARKET_SERVICE: Object.freeze({ + SETTINGS_FILE_NAME: 'market-service.json', + URL: 'https://atreides.kamenier-hamer.nl', + APP_NAME: 'Atreides Services', + REQUEST_TIMEOUT_MS: 10000, + CLIENT_TIMEOUT_MS: 15000, + REGISTER_TTL_MS: 10 * 60_000, + MAX_SERIES_PAGES: 10, + INTRADAY_MAX_DAYS: 7, + MOMENT_WINDOW_MS: 10 * 60_000, + HISTORY_MAX_DAYS: 3650, + PROVIDER_KEY: 'atreides_services', + SPOT_TTL_MS: 30_000, + RETRY_PAUSE_MS: 5 * 60_000, + MAX_INSTRUMENTS_PER_SPOT: 100, + }), + + AI: Object.freeze({ + SERVER_NAME: 'assetapp', + SERVER_VERSION: '3', + PROTOCOL_VERSION: '2025-06-18', + AUDIT_PAGE_SIZE: 100, + PROPOSAL_PAGE_SIZE: 50, + + DESTRUCTIVE_PROPOSAL_KINDS: Object.freeze(['account_delete']), + + FIELD_CLASSES: Object.freeze(['plain', 'rate', 'quantityFiat', 'quantity', 'handle', 'chain', 'drop']), + + BUCKET_LADDERS: Object.freeze({ + COARSE: Object.freeze([1, 1.5, 2, 3, 5, 7]), + FINE: Object.freeze([1, 1.25, 1.6, 2, 2.5, 3.15, 4, 5, 6.3, 8]), + }), + + BUCKET_MIN_VALUES: Object.freeze({ COARSE: 1000, FINE: 0 }), + + SHARE_BUCKETS: Object.freeze([2, 4, 6, 8, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, + 80, 85, 90, 95, 100]), + + SHARED_PREF_SCOPES: Object.freeze(['app', 'timers']), + + PROPOSABLE_PREF_SCOPES: Object.freeze(['app', 'timers']), + + LIMITS: Object.freeze({ + MAX_ROWS_PER_LIST: 50, + MAX_TEXT_CHARS: 10000, + MAX_LOG_CHARS: 10000, + METRICS_WINDOW_HOURS: 24, + }), + }), + + ROW_GUARD: false, +}); + +export const AUTH_KINDS = Object.freeze({ + none: Object.freeze({ secret_fields: Object.freeze([]) }), + header_key: Object.freeze({ secret_fields: Object.freeze(['api_key']) }), + query_key: Object.freeze({ secret_fields: Object.freeze(['api_key']) }), + bearer: Object.freeze({ secret_fields: Object.freeze(['api_key']) }), + basic: Object.freeze({ secret_fields: Object.freeze(['api_key', 'api_secret']) }), + hmac: Object.freeze({ secret_fields: Object.freeze(['api_key', 'api_secret']) }), +}); + +export const ENUMS = Object.freeze({ + ASSET_KIND: Object.freeze(['crypto', 'metal', 'fiat']), + ASSET_DISPLAY_UNIT: Object.freeze(['unit', 'subunit']), + METAL_FORM: Object.freeze(['coin', 'bar', 'other']), + ACCOUNT_KIND: Object.freeze(['exchange', 'wallet', 'lightning', 'card', 'bank', 'vault']), + TAX_CATEGORY: Object.freeze(['bank', 'overig']), + TAX_SCOPE: Object.freeze(['all', 'category', 'none']), + WEALTH_SCOPE: Object.freeze(['managed', 'none']), + EVENT_TYPE: Object.freeze(['buy', 'sell', 'transfer', 'interest', 'reservation']), + LEDGER_EVENT_TYPE: Object.freeze(['buy', 'sell', 'transfer', 'interest']), + EVENT_SOURCE: Object.freeze(['manual', 'import', 'reconcile', 'lightning']), + EVENT_PRICE_ORIGIN: Object.freeze(['paid', 'market_day', 'market_moment', 'none']), + VALUATION_SOURCE: Object.freeze(['onchain', 'manual', 'statement']), + CHANGE_OPERATION: Object.freeze(['update', 'delete']), + CHANGE_ORIGIN: Object.freeze(['manual', 'import', 'reconcile', 'interest', 'lightning', + 'ai_proposal', 'merge', 'system']), + INTEREST_CADENCE: Object.freeze(['term_end', 'annual', 'quarterly', 'monthly', 'weekly']), + INTEREST_RATE_KIND: Object.freeze(['fixed', 'variable']), + INTEREST_CREDIT_STATUS: Object.freeze(['due', 'booked', 'dismissed']), + WALLET_SOURCE_KIND: Object.freeze(['xpub', 'address', 'nwc', 'lnd']), + ONCHAIN_SOURCE_KIND: Object.freeze(['xpub', 'address']), + SCRIPT_TYPE: Object.freeze(['p2tr', 'p2wpkh', 'p2sh-p2wpkh', 'p2pkh']), + SCAN_STATUS: Object.freeze(['success', 'partial', 'failed']), + RECONCILE_DIRECTION: Object.freeze(['in', 'out']), + WALLET_TX_LAYER: Object.freeze(['onchain', 'lightning']), + WALLET_TX_KIND: Object.freeze(['channel', 'self']), + RECONCILE_SUGGESTED: Object.freeze(['trade', 'transfer']), + RECONCILE_STATUS: Object.freeze(['pending', 'accepted_trade', 'accepted_transfer', 'accepted_interest', 'ignored', 'auto_linked']), + LINK_METHOD: Object.freeze(['exact', 'soft', 'manual']), + NOTIFICATION_KIND: Object.freeze(['reconcile', 'interest', 'valuation', 'schedule_ended', 'source_proposal', 'ai_proposal', 'reservation']), + CONNECTION_KIND: Object.freeze(['btc_explorer', 'lnd', 'nwc']), + THEME: Object.freeze(['dark', 'light']), + ACCENT: Object.freeze(['orange', 'magenta', 'lila', 'indigo', 'blue', 'cyan', 'teal', 'slate', 'sand']), + DEPTH: Object.freeze(['flat', 'deep']), + PROFILE: Object.freeze(['business', 'glass']), + GLOW: Object.freeze(['off', 'on']), + GLOW_SHAPE: Object.freeze(['blobs', 'bands']), + MOTION: Object.freeze(['off', 'step', 'always']), + GLOW_TEMPO: Object.freeze(['brisk', 'calm', 'slow']), + ROW_SELECTION: Object.freeze(['light', 'tint', 'pill']), + GLASS_RENDER: Object.freeze(['real', 'mimic']), + MWR_METHOD: Object.freeze(['dietz', 'irr']), + PERSONA: Object.freeze(['bitcoin', 'broad']), + MARKET_SPOT: Object.freeze(['off', 'fallback', 'first']), + REPEAT_ENTRY_KEY: Object.freeze(['ctrl_enter', 'enter']), + RESULT_SIGNS: Object.freeze(['none', 'negative', 'both']), + PRICE_RESOLUTION: Object.freeze(['day', 'timestamp']), + CONNECTION_STATUS: Object.freeze(['ok', 'fail']), + PRICE_SOURCE_STATUS: Object.freeze(['proposed', 'confirmed', 'manual']), + HMAC_HASH_ALGORITHM: Object.freeze(['SHA-256']), + HMAC_ENCODING: Object.freeze(['hex', 'base64']), + AI_CALL_OUTCOME: Object.freeze(['ok', 'error', 'refused']), + INTEGRATION_TARGET: Object.freeze(['service', 'provider']), + INTEGRATION_OUTCOME: Object.freeze(['fail', 'empty', 'refused']), + AI_MESSAGE_KIND: Object.freeze(['request', 'response']), + AI_SCOPE: Object.freeze(['none', 'read', 'write', 'control']), + COMMAND_KIND: Object.freeze(['navigate', 'locate']), + + AI_PROPOSAL_KIND: Object.freeze(['price_point', 'account', 'event', 'account_delete', 'valuation', 'portfolio', 'preference', 'source_choice']), + SOURCE_CHOICE_ACTION: Object.freeze(['first', 'remove']), + QUOTE_UNIT: Object.freeze(['ozt']), + AI_PROPOSAL_STATUS: Object.freeze(['pending', 'accepted', 'rejected']), + COMMAND_STATE: Object.freeze(['open', 'claimed', 'done', 'rejected', 'expired']), +}); + +export const SYSTEM_PAGE = Object.freeze({ + ROUTE: 'system', + TABS: Object.freeze({ + PORTFOLIOS: 'portfolios', + SETTINGS: 'settings', + ASSETS: 'assets', + ACCOUNTS: 'accounts', + INTEGRATIONS: 'integrations', + IMPORT: 'import', + DATA: 'data', + HEALTH: 'health', + AI: 'ai', + LICENSE: 'license', + }), +}); + +export const EVENTS_PAGE = Object.freeze({ + ROUTE: 'events', + TABS: Object.freeze({ + ACCOUNTS: 'accounts', + VALUATIONS: 'valuations', + TRADES: 'trades', + TRANSFERS: 'transfers', + INTEREST: 'interest', + RESERVATIONS: 'reservations', + }), +}); + +export const CHARTS_PAGE = Object.freeze({ + ROUTE: 'charts', + TABS: Object.freeze({ + PRICE: 'price', + POWERLAW: 'pl', + WMA: 'wma', + MAYER: 'mayer', + }), +}); + +export const ROUTE_KEYS = Object.freeze({ + HOME: 'home', + ACTIONS: 'actions', + EVENTS: EVENTS_PAGE.ROUTE, + WEALTH: 'wealth', + TAXES: 'taxes', + VALIDATION: 'validation', + TRACKING: 'tracking', + CONVERSION_BITCOIN: 'conversion/bitcoin', + PRICE_HISTORY: 'price-history', + CHARTS: CHARTS_PAGE.ROUTE, + SYSTEM: SYSTEM_PAGE.ROUTE, + GUIDE: 'guide', +}); + +const doel = (route, tab, sub) => Object.freeze({ route, tab: tab ?? null, sub: sub ?? null, element: null }); +export const TARGETS = Object.freeze({ + HOME: doel(ROUTE_KEYS.HOME), + ACTIONS: doel(ROUTE_KEYS.ACTIONS), + CHARTS: doel(ROUTE_KEYS.CHARTS), + TAXES: doel(ROUTE_KEYS.TAXES), + WEALTH: doel(ROUTE_KEYS.WEALTH), + TRACKING: doel(ROUTE_KEYS.TRACKING), + PRICE_HISTORY: doel(ROUTE_KEYS.PRICE_HISTORY), + CONVERSION: doel(ROUTE_KEYS.CONVERSION_BITCOIN), + GUIDE: doel(ROUTE_KEYS.GUIDE), + PORTFOLIOS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.PORTFOLIOS), + ASSETS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.ASSETS), + ACCOUNTS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.ACCOUNTS), + INTEGRATIONS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.INTEGRATIONS), + AI_ACTIVITY: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.AI), + SETTINGS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.SETTINGS), + LICENSE: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.LICENSE), + IMPORT: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.IMPORT), + DATA: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.DATA), + HEALTH: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.HEALTH), + PROVIDER_KEYS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.INTEGRATIONS, 'providers'), + SOURCE_PROPOSALS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.INTEGRATIONS, 'sources'), + CONNECTIONS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.INTEGRATIONS, 'connections'), + CHART_PRICE: doel(ROUTE_KEYS.CHARTS, CHARTS_PAGE.TABS.PRICE), + CHART_POWERLAW: doel(ROUTE_KEYS.CHARTS, CHARTS_PAGE.TABS.POWERLAW), + CHART_WMA: doel(ROUTE_KEYS.CHARTS, CHARTS_PAGE.TABS.WMA), + CHART_MAYER: doel(ROUTE_KEYS.CHARTS, CHARTS_PAGE.TABS.MAYER), + EVENT_ACCOUNTS: doel(ROUTE_KEYS.EVENTS, EVENTS_PAGE.TABS.ACCOUNTS), + VALUATIONS: doel(ROUTE_KEYS.EVENTS, EVENTS_PAGE.TABS.VALUATIONS), + TRADES: doel(ROUTE_KEYS.EVENTS, EVENTS_PAGE.TABS.TRADES), + TRANSFERS: doel(ROUTE_KEYS.EVENTS, EVENTS_PAGE.TABS.TRANSFERS), + INTEREST: doel(ROUTE_KEYS.EVENTS, EVENTS_PAGE.TABS.INTEREST), + RESERVATIONS: doel(ROUTE_KEYS.EVENTS, EVENTS_PAGE.TABS.RESERVATIONS), +}); + +export const NOTIFICATION_TARGETS = Object.freeze({ + reconcile: TARGETS.ACTIONS, + interest: TARGETS.ACTIONS, + valuation: TARGETS.ACTIONS, + schedule_ended: TARGETS.ACCOUNTS, + source_proposal: TARGETS.SOURCE_PROPOSALS, + ai_proposal: TARGETS.ACTIONS, + reservation: TARGETS.ACTIONS, +}); + +export const NOTIFICATION_PRIORITY = Object.freeze([ + 'reconcile', 'valuation', 'reservation', 'interest', 'ai_proposal', 'source_proposal', 'schedule_ended', +]); + +export const PLACE_LABELS = Object.freeze({ + providers: 'integrations.tabProviders', + sources: 'integrations.tabSources', + connections: 'integrations.tabConnections', + [CHARTS_PAGE.TABS.PRICE]: 'signals.tabPrice', + [CHARTS_PAGE.TABS.POWERLAW]: 'signals.tabPl', + [CHARTS_PAGE.TABS.WMA]: 'signals.tabWma', + [CHARTS_PAGE.TABS.MAYER]: 'signals.tabMayer', +}); + +export function targetHash(target) { + if (!target || !target.route) throw new Error('A target without a route has no address.'); + return `#/${[target.route, target.tab, target.sub].filter(Boolean).join('/')}`; +} + +export function hashFromLocation(hash) { + const pad = String(hash || '').replace(/^#\/?/, ''); + return pad ? `#/${pad}` : targetHash(TARGETS.HOME); +} + +export function commandTooLate(expiresAt, nu = Date.now()) { + if (!expiresAt) return false; + const einde = Date.parse(expiresAt); + if (Number.isNaN(einde)) return false; + return einde - nu < CONFIG.COMMANDS.COUNTDOWN_SECONDS * 1000; +} + +export function targetTab(target) { + if (!target || !target.tab) return null; + return [target.tab, target.sub].filter(Boolean).join('/'); +} + +export const CAPABILITIES = Object.freeze(['spot', 'daily_history', 'history_point']); + +export const SOURCE_CAPABILITIES = Object.freeze(['spot', 'history']); +export const SOURCE_ENDPOINTS = Object.freeze({ + spot: Object.freeze(['spot']), + history: Object.freeze(['daily_history', 'history_point']), +}); +export function sourceCapabilityOf(capability) { + return SOURCE_CAPABILITIES.find(c => SOURCE_ENDPOINTS[c].includes(capability)) ?? null; +} + +export const OUTPUT_FIELDS = Object.freeze(['time', 'price']); +export const TRANSFORMS = Object.freeze(['none', 'ms_to_s', 'to_number', 'last_per_day', 'troy_ounce_to_gram', 'iso_date_to_s', 'ms_to_s_local_close']); +export const HTTP_METHODS = Object.freeze(['GET', 'POST']); + +export const ACCOUNT_SOURCE_KINDS = Object.freeze({ + exchange: Object.freeze([]), + wallet: Object.freeze(['xpub', 'address']), + lightning: Object.freeze(['xpub', 'address', 'nwc', 'lnd']), + card: Object.freeze([]), + bank: Object.freeze([]), + vault: Object.freeze([]), +}); + +export const ACCOUNT_INTEREST_KINDS = Object.freeze(['exchange', 'card', 'bank']); + +export const ACCOUNT_ASSET_KINDS = Object.freeze({ + exchange: Object.freeze(['crypto', 'fiat']), + wallet: Object.freeze(['crypto']), + lightning: Object.freeze(['crypto']), + card: Object.freeze(['fiat']), + bank: Object.freeze(['fiat']), + vault: Object.freeze(['metal']), +}); + +export const TAX_CATEGORY_BY_ASSET_KIND = Object.freeze({ crypto: 'overig', metal: 'overig', fiat: 'bank' }); + +export const BALANCE_CAPTURE_KINDS = Object.freeze(['fiat']); + +export function balanceCaptureOn(asset) { + if (!asset) return false; + if (asset.balanceCapture === 0 || asset.balanceCapture === 1) return asset.balanceCapture === 1; + return BALANCE_CAPTURE_KINDS.includes(asset.kind); +} + +export const CONNECTION_PROVIDER = Object.freeze({ btc_explorer: 'mempool' }); + +export const ASSET_DEFINITIONS = Object.freeze({ + BTC: Object.freeze({ symbol: 'BTC', name: 'Bitcoin', kind: 'crypto', unit: 'BTC', subunit: 'sat', decimals: 8, displayUnit: 'subunit' }), + ETH: Object.freeze({ symbol: 'ETH', name: 'Ethereum', kind: 'crypto', unit: 'ETH', subunit: null, decimals: 8, displayUnit: 'unit' }), + XAU: Object.freeze({ symbol: 'XAU', name: 'Goud', kind: 'metal', unit: 'ozt', subunit: 'g', decimals: 7, displayUnit: 'unit' }), + XAG: Object.freeze({ symbol: 'XAG', name: 'Zilver', kind: 'metal', unit: 'ozt', subunit: 'g', decimals: 7, displayUnit: 'unit' }), +}); + +export const PREFERENCES = Object.freeze({ + app: Object.freeze({ + theme: Object.freeze({ type: 'enum', allowed: ENUMS.THEME, initial: 'dark' }), + accent: Object.freeze({ type: 'enum', allowed: ENUMS.ACCENT, initial: 'blue' }), + depth: Object.freeze({ type: 'enum', allowed: ENUMS.DEPTH, initial: 'deep' }), + profile: Object.freeze({ type: 'enum', allowed: ENUMS.PROFILE, initial: 'business' }), + glow: Object.freeze({ type: 'enum', allowed: ENUMS.GLOW, initial: 'on' }), + glowshape: Object.freeze({ type: 'enum', allowed: ENUMS.GLOW_SHAPE, initial: 'blobs' }), + motion: Object.freeze({ type: 'enum', allowed: ENUMS.MOTION, initial: 'step' }), + rowsel: Object.freeze({ type: 'enum', allowed: ENUMS.ROW_SELECTION, initial: 'light' }), + glow_tempo: Object.freeze({ type: 'enum', allowed: ENUMS.GLOW_TEMPO, initial: 'calm' }), + glass_transparency: Object.freeze({ type: 'int', allowed: null, initial: 65, min: 0, max: 100 }), + glass_frost: Object.freeze({ type: 'int', allowed: null, initial: 3, min: 0, max: 50 }), + glass_blur: Object.freeze({ type: 'int', allowed: null, initial: 30, min: 0, max: 100 }), + glass_render: Object.freeze({ type: 'enum', allowed: ENUMS.GLASS_RENDER, initial: 'mimic' }), + blur: Object.freeze({ type: 'bool', allowed: null, initial: 0 }), + auto_blur_min: Object.freeze({ type: 'int', allowed: null, initial: 5, min: 1, max: CONFIG.AUTO_BLUR.NEVER_MIN }), + asset_show_name: Object.freeze({ type: 'bool', allowed: null, initial: 1 }), + asset_show_symbol: Object.freeze({ type: 'bool', allowed: null, initial: 1 }), + show_page_title: Object.freeze({ type: 'bool', allowed: null, initial: 1 }), + show_page_hint: Object.freeze({ type: 'bool', allowed: null, initial: 1 }), + kiosk: Object.freeze({ type: 'bool', allowed: null, initial: 0 }), + last_backup_at: Object.freeze({ type: 'string', allowed: null, initial: '' }), + last_health_check_at: Object.freeze({ type: 'string', allowed: null, initial: '' }), + start_year: Object.freeze({ type: 'int', allowed: null, initial: CONFIG.START_YEAR.AUTO, min: CONFIG.START_YEAR.AUTO, max: CONFIG.START_YEAR.MAX }), + mwr_method: Object.freeze({ type: 'enum', allowed: ENUMS.MWR_METHOD, initial: 'dietz' }), + repeat_entry_key: Object.freeze({ type: 'enum', allowed: ENUMS.REPEAT_ENTRY_KEY, initial: 'ctrl_enter' }), + persona: Object.freeze({ type: 'enum', allowed: ENUMS.PERSONA, initial: 'bitcoin' }), + setup_done: Object.freeze({ type: 'bool', allowed: null, initial: 0 }), + market_spot: Object.freeze({ type: 'enum', allowed: ENUMS.MARKET_SPOT, initial: 'off' }), + result_signs: Object.freeze({ type: 'enum', allowed: ENUMS.RESULT_SIGNS, initial: 'negative' }), + }), + + timers: Object.freeze({ + spot_refresh_seconds: Object.freeze({ type: 'int', allowed: null, initial: CONFIG.TIMERS.SPOT_REFRESH.MIN_SECONDS, min: CONFIG.TIMERS.SPOT_REFRESH.MIN_SECONDS, max: CONFIG.TIMERS.OFF_SECONDS }), + bus_poll_seconds: Object.freeze({ type: 'int', allowed: null, initial: CONFIG.TIMERS.BUS_POLL.MIN_SECONDS, min: CONFIG.TIMERS.BUS_POLL.MIN_SECONDS, max: CONFIG.TIMERS.OFF_SECONDS }), + }), + + ai: Object.freeze({ + enabled: Object.freeze({ type: 'bool', allowed: null, initial: 1 }), + allow_read: Object.freeze({ type: 'bool', allowed: null, initial: 1 }), + allow_raw: Object.freeze({ type: 'bool', allowed: null, initial: 0 }), + allow_write: Object.freeze({ type: 'bool', allowed: null, initial: 0 }), + seen_call_id: Object.freeze({ type: 'int', allowed: null, initial: 0, min: 0 }), + allow_control: Object.freeze({ type: 'bool', allowed: null, initial: 0 }), + + bucket_shares: Object.freeze({ type: 'bool', allowed: null, initial: 1 }), + bucket_totals: Object.freeze({ type: 'bool', allowed: null, initial: 1 }), + bucket_amounts: Object.freeze({ type: 'bool', allowed: null, initial: 1 }), + bucket_quantities: Object.freeze({ type: 'bool', allowed: null, initial: 1 }), + }), + +}); + +export const NETWORK = Object.freeze({ MAX_SUPPLY_BTC: 21_000_000 }); + +export const METRICS = Object.freeze({ + WINDOW_MINUTES: 60, + FAIL_WARN: 0.05, + FAIL_BAD: 0.20, +}); + +export const CLOCKS = Object.freeze({ + LIST_MAX: 4, + OPENING_WINDOW_MINUTES: 15, + CITIES: Object.freeze([ + Object.freeze({ key: 'amsterdam', name: 'Amsterdam', tz: 'Europe/Amsterdam' }), + Object.freeze({ key: 'london', name: 'Londen', tz: 'Europe/London' }), + Object.freeze({ key: 'frankfurt', name: 'Frankfurt', tz: 'Europe/Berlin' }), + Object.freeze({ key: 'newyork', name: 'New York', tz: 'America/New_York' }), + Object.freeze({ key: 'losangeles', name: 'Los Angeles', tz: 'America/Los_Angeles' }), + Object.freeze({ key: 'saopaulo', name: 'São Paulo', tz: 'America/Sao_Paulo' }), + Object.freeze({ key: 'tokyo', name: 'Tokyo', tz: 'Asia/Tokyo' }), + Object.freeze({ key: 'hongkong', name: 'Hongkong', tz: 'Asia/Hong_Kong' }), + Object.freeze({ key: 'shanghai', name: 'Shanghai', tz: 'Asia/Shanghai' }), + Object.freeze({ key: 'singapore', name: 'Singapore', tz: 'Asia/Singapore' }), + Object.freeze({ key: 'dubai', name: 'Dubai', tz: 'Asia/Dubai' }), + Object.freeze({ key: 'mumbai', name: 'Mumbai', tz: 'Asia/Kolkata' }), + Object.freeze({ key: 'sydney', name: 'Sydney', tz: 'Australia/Sydney' }), + ]), + EXCHANGES: Object.freeze([ + Object.freeze({ key: 'tse', name: 'Tokyo (TSE)', tz: 'Asia/Tokyo', open: '09:00', close: '15:00', days: [1, 2, 3, 4, 5] }), + Object.freeze({ key: 'hkex', name: 'Hongkong (HKEX)', tz: 'Asia/Hong_Kong', open: '09:30', close: '16:00', days: [1, 2, 3, 4, 5] }), + Object.freeze({ key: 'sse', name: 'Shanghai (SSE)', tz: 'Asia/Shanghai', open: '09:30', close: '15:00', days: [1, 2, 3, 4, 5] }), + Object.freeze({ key: 'lse', name: 'Londen (LSE)', tz: 'Europe/London', open: '08:00', close: '16:30', days: [1, 2, 3, 4, 5] }), + Object.freeze({ key: 'euronext', name: 'Euronext (AMS)', tz: 'Europe/Amsterdam', open: '09:00', close: '17:30', days: [1, 2, 3, 4, 5] }), + Object.freeze({ key: 'xetra', name: 'Xetra (FRA)', tz: 'Europe/Berlin', open: '09:00', close: '17:30', days: [1, 2, 3, 4, 5] }), + Object.freeze({ key: 'nyse', name: 'New York (NYSE)', tz: 'America/New_York', open: '09:30', close: '16:00', days: [1, 2, 3, 4, 5] }), + Object.freeze({ key: 'nasdaq', name: 'Nasdaq', tz: 'America/New_York', open: '09:30', close: '16:00', days: [1, 2, 3, 4, 5] }), + ]), +}); + +export const DASHBOARD = Object.freeze({ + GRID_COLUMNS: 12, + MIN_SPAN: 1, + MAX_SPAN: 12, + MIN_H: 1, + MAX_H: 16, + HEIGHT_STEP_REM: 3, + SETTLE_MS: 250, + LONG_PRESS_MS: 500, + LONG_PRESS_SLOP_PX: 6, + HEIGHT_MODEL: 6, + LAYOUT_MODEL: 1, + SKELETON_STEPS: 2, + REVEAL_MAX_MS: 4000, + SIZE_SPAN: Object.freeze({ s: 3, m: 4, l: 6, xl: 12 }), + WIDGETS: Object.freeze({ + signal: Object.freeze({ defaultSpan: 4, instanceable: false, persona: 'bitcoin', label_key: 'dashboard.widget.signal', defaultH: 4 }), + notifications: Object.freeze({ defaultSpan: 4, instanceable: false, persona: null, label_key: 'dashboard.widget.notifications', defaultH: 3 }), + standing: Object.freeze({ defaultSpan: 4, instanceable: false, persona: null, label_key: 'dashboard.widget.standing', defaultH: 3 }), + metrics: Object.freeze({ defaultSpan: 3, instanceable: false, persona: null, label_key: 'dashboard.widget.metrics', defaultH: 1 }), + netshare: Object.freeze({ defaultSpan: 3, instanceable: false, persona: 'bitcoin', label_key: 'dashboard.widget.netshare', defaultH: 2 }), + spot: Object.freeze({ defaultSpan: 2, instanceable: true, persona: null, label_key: 'dashboard.widget.spot', defaultH: 2 }), + clocks: Object.freeze({ defaultSpan: 4, instanceable: false, persona: null, label_key: 'dashboard.widget.clocks', defaultH: 5 }), + return: Object.freeze({ defaultSpan: 4, instanceable: true, persona: null, label_key: 'dashboard.widget.return', defaultH: 4 }), + }), + DEFAULT_LAYOUT: Object.freeze([ + Object.freeze({ instId: 'notifications', type: 'notifications', x: 8 }), + ]), +}); + +export function assertConfig() { + for (const cap of CAPABILITIES) { + if (SOURCE_CAPABILITIES.filter(c => SOURCE_ENDPOINTS[c].includes(cap)).length !== 1) { + throw new Error(`Capability '${cap}' must fall under exactly one source choice. Startup aborted.`); + } + } + for (const kind of ENUMS.ONCHAIN_SOURCE_KIND) { + if (!ENUMS.WALLET_SOURCE_KIND.includes(kind)) { + throw new Error(`ONCHAIN_SOURCE_KIND '${kind}' is not in WALLET_SOURCE_KIND. Startup aborted.`); + } + } + for (const kind of ACCOUNT_INTEREST_KINDS) { + if (!ENUMS.ACCOUNT_KIND.includes(kind)) { + throw new Error(`ACCOUNT_INTEREST_KINDS '${kind}' is not an account kind. Startup aborted.`); + } + } + for (const kind of Object.keys(ACCOUNT_SOURCE_KINDS)) { + if (!ENUMS.ACCOUNT_KIND.includes(kind)) { + throw new Error(`ACCOUNT_SOURCE_KINDS '${kind}' is not an account kind. Startup aborted.`); + } + } + for (const kind of ENUMS.ACCOUNT_KIND) { + const soorten = ACCOUNT_ASSET_KINDS[kind]; + if (!soorten || !soorten.length || soorten.some(s => !ENUMS.ASSET_KIND.includes(s))) { + throw new Error(`ACCOUNT_ASSET_KINDS '${kind}' is missing or names an unknown asset kind. Startup aborted.`); + } + } + if (!CONFIG.PORTFOLIO.BASE_CURRENCIES.includes(CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY)) { + throw new Error(`PORTFOLIO.DEFAULT_BASE_CURRENCY '${CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY}' is not in BASE_CURRENCIES. Startup aborted.`); + } + const essential = [ + ['PRICE_SOURCES.MEMPOOL_PUBLIC_BASE', CONFIG.PRICE_SOURCES.MEMPOOL_PUBLIC_BASE], + ['PRICE_SOURCES.BITVAVO_BASE', CONFIG.PRICE_SOURCES.BITVAVO_BASE], + ['PRICE_SOURCES.COINGECKO_BASE', CONFIG.PRICE_SOURCES.COINGECKO_BASE], + ['PRICE_SOURCES.GOLDAPI_BASE', CONFIG.PRICE_SOURCES.GOLDAPI_BASE], + ['PRICE_SOURCES.EULERPOOL_BASE', CONFIG.PRICE_SOURCES.EULERPOOL_BASE], + ['PRICE_SOURCES.METALSDEV_BASE', CONFIG.PRICE_SOURCES.METALSDEV_BASE], + ['PRICE_SOURCES.FRANKFURTER_BASE', CONFIG.PRICE_SOURCES.FRANKFURTER_BASE], + ['METALS.GRAMS_PER_TROY_OUNCE', CONFIG.METALS.GRAMS_PER_TROY_OUNCE], + ['METALS.SPOT_DEVIATION_WARN', CONFIG.METALS.SPOT_DEVIATION_WARN], + ['CHART_LIBS.CHARTJS', CONFIG.CHART_LIBS.CHARTJS], + ['CHART_LIBS.HAMMER', CONFIG.CHART_LIBS.HAMMER], + ['CHART_LIBS.ZOOM', CONFIG.CHART_LIBS.ZOOM], + ['SCHEMA_VERSION', CONFIG.SCHEMA_VERSION], + ['MAX_ASSET_DECIMALS', CONFIG.MAX_ASSET_DECIMALS], + ['PRICE_HISTORY.NEAREST_MAX_SECONDS', CONFIG.PRICE_HISTORY.NEAREST_MAX_SECONDS], + ['PRICE_HISTORY.CARRY_FORWARD_DAYS.crypto', CONFIG.PRICE_HISTORY.CARRY_FORWARD_DAYS.crypto], + ['PRICE_HISTORY.RECENT_CLOSE_DAYS', CONFIG.PRICE_HISTORY.RECENT_CLOSE_DAYS], + ['PRICE_HISTORY.FX_MAX_AGE_DAYS', CONFIG.PRICE_HISTORY.FX_MAX_AGE_DAYS], + ['API_USAGE.WARN_SHARE', CONFIG.API_USAGE.WARN_SHARE], + ['START_YEAR.MIN', CONFIG.START_YEAR.MIN], + ['START_YEAR.MAX', CONFIG.START_YEAR.MAX], + ['TIMERS.SPOT_REFRESH.MIN_SECONDS', CONFIG.TIMERS.SPOT_REFRESH.MIN_SECONDS], + ['TIMERS.BUS_POLL.MIN_SECONDS', CONFIG.TIMERS.BUS_POLL.MIN_SECONDS], + ['BUS.RETENTION_HOURS', CONFIG.BUS.RETENTION_HOURS], + ['COMMANDS.CLAIM_SECONDS', CONFIG.COMMANDS.CLAIM_SECONDS], + ['COMMANDS.COUNTDOWN_SECONDS', CONFIG.COMMANDS.COUNTDOWN_SECONDS], + ['TIMERS.OFF_SECONDS', CONFIG.TIMERS.OFF_SECONDS], + ['LND.REQUEST_TIMEOUT_MS', CONFIG.LND.REQUEST_TIMEOUT_MS], + ['LND.CLIENT_TIMEOUT_MS', CONFIG.LND.CLIENT_TIMEOUT_MS], + ['NWC.REQUEST_TIMEOUT_MS', CONFIG.NWC.REQUEST_TIMEOUT_MS], + ['NWC.CLIENT_TIMEOUT_MS', CONFIG.NWC.CLIENT_TIMEOUT_MS], + ['MARKET_SERVICE.SETTINGS_FILE_NAME', CONFIG.MARKET_SERVICE.SETTINGS_FILE_NAME], + ['MARKET_SERVICE.URL', CONFIG.MARKET_SERVICE.URL], + ['LICENSE.FREE_FILE_NAME', CONFIG.LICENSE.FREE_FILE_NAME], + ['MARKET_SERVICE.APP_NAME', CONFIG.MARKET_SERVICE.APP_NAME], + ['MARKET_SERVICE.REQUEST_TIMEOUT_MS', CONFIG.MARKET_SERVICE.REQUEST_TIMEOUT_MS], + ['MARKET_SERVICE.CLIENT_TIMEOUT_MS', CONFIG.MARKET_SERVICE.CLIENT_TIMEOUT_MS], + ['MARKET_SERVICE.REGISTER_TTL_MS', CONFIG.MARKET_SERVICE.REGISTER_TTL_MS], + ['MARKET_SERVICE.MAX_SERIES_PAGES', CONFIG.MARKET_SERVICE.MAX_SERIES_PAGES], + ['MARKET_SERVICE.INTRADAY_MAX_DAYS', CONFIG.MARKET_SERVICE.INTRADAY_MAX_DAYS], + ['MARKET_SERVICE.HISTORY_MAX_DAYS', CONFIG.MARKET_SERVICE.HISTORY_MAX_DAYS], + ['MARKET_SERVICE.PROVIDER_KEY', CONFIG.MARKET_SERVICE.PROVIDER_KEY], + ['MARKET_SERVICE.SPOT_TTL_MS', CONFIG.MARKET_SERVICE.SPOT_TTL_MS], + ['MARKET_SERVICE.MAX_INSTRUMENTS_PER_SPOT', CONFIG.MARKET_SERVICE.MAX_INSTRUMENTS_PER_SPOT], + ['AI.SERVER_NAME', CONFIG.AI.SERVER_NAME], + ['AI.SERVER_VERSION', CONFIG.AI.SERVER_VERSION], + ['AI.PROTOCOL_VERSION', CONFIG.AI.PROTOCOL_VERSION], + ['AI.AUDIT_PAGE_SIZE', CONFIG.AI.AUDIT_PAGE_SIZE], + ['AI.PROPOSAL_PAGE_SIZE', CONFIG.AI.PROPOSAL_PAGE_SIZE], + ['AI.LIMITS.MAX_ROWS_PER_LIST', CONFIG.AI.LIMITS.MAX_ROWS_PER_LIST], + ['AI.LIMITS.MAX_TEXT_CHARS', CONFIG.AI.LIMITS.MAX_TEXT_CHARS], + ['AI.LIMITS.MAX_LOG_CHARS', CONFIG.AI.LIMITS.MAX_LOG_CHARS], + ['AI.LIMITS.METRICS_WINDOW_HOURS', CONFIG.AI.LIMITS.METRICS_WINDOW_HOURS], + ['AUTH.SESSION_TTL_MS', CONFIG.AUTH.SESSION_TTL_MS], + ['AUTH.COOKIE_NAME', CONFIG.AUTH.COOKIE_NAME], + ['AUTH.LOGIN_PATH', CONFIG.AUTH.LOGIN_PATH], + ['AUTH.LOGIN_ATTEMPTS.FREE', CONFIG.AUTH.LOGIN_ATTEMPTS.FREE], + ['AUTH.LOGIN_ATTEMPTS.BASE_LOCK_MS', CONFIG.AUTH.LOGIN_ATTEMPTS.BASE_LOCK_MS], + ['AUTH.LOGIN_ATTEMPTS.MAX_LOCK_MS', CONFIG.AUTH.LOGIN_ATTEMPTS.MAX_LOCK_MS], + ]; + for (const [name, val] of essential) { + if (val === undefined || val === null || val === '') { + throw new Error(`Essential config missing: ${name}. Startup aborted.`); + } + } +} diff --git a/shared/csp.js b/shared/csp.js new file mode 100644 index 0000000..48b0fec --- /dev/null +++ b/shared/csp.js @@ -0,0 +1,24 @@ +// Generated, do not edit. sha256:ae316d9b957c83f15f4beedd3c718d48727175efe4b7a269554f9584d612e439 +import { CONFIG } from './config.js'; + +export function originOf(url) { + try { return new URL(url).origin; } catch { return null; } +} + +const NETWORK_ORIGIN = /^(https?|wss?):\/\/[^\s;,'"]+$/; +const networkOriginOf = (url) => { + const o = originOf(url); + return o && NETWORK_ORIGIN.test(o) ? o : null; +}; + +export function connectSrcOrigins(explorerUrls = []) { + return [...new Set([CONFIG.PRICE_SOURCES.MEMPOOL_PUBLIC_BASE, ...explorerUrls] + .map(networkOriginOf).filter(Boolean))]; +} + +export function originAllowedByCsp(url, eigenOrigin = null, explorerUrls = []) { + const origin = originOf(url); + if (!origin) return false; + if (eigenOrigin && origin === originOf(eigenOrigin)) return true; + return connectSrcOrigins(explorerUrls).includes(origin); +} diff --git a/shared/guide.js b/shared/guide.js new file mode 100644 index 0000000..6d116a3 --- /dev/null +++ b/shared/guide.js @@ -0,0 +1,64 @@ +// Generated, do not edit. sha256:26b76ae49e193597112630c3ad94ab2a26c04d90427102a41f017d09ef2fec9a +import { sharedText } from './i18n/index.js'; + +const TOKEN = /\{([A-Z][A-Z0-9_]*)\}|\{icon:([a-z][a-z0-9-]*)\}/g; + +export const GUIDE_ICONS = Object.freeze(['adjust']); + +export const GUIDE_FIGURES = Object.freeze(['stand-period', 'account-example', 'result-sum']); + +export function splitPlaceTokens(text) { + const parts = []; + let last = 0; + for (const m of String(text).matchAll(TOKEN)) { + if (m.index > last) parts.push(text.slice(last, m.index)); + parts.push(m[1] ? { place: m[1] } : { icon: m[2] }); + last = m.index + m[0].length; + } + if (last < text.length) parts.push(text.slice(last)); + return parts; +} + +const section = (s) => Object.freeze({ place: null, related: [], ...s }); + +export const GUIDE_GROUPS = Object.freeze([ + Object.freeze({ id: 'concepts', + sections: ['overview', 'bookings', 'anchors', 'free-value', 'returns', 'account-returns', + 'two-balances'] }), + Object.freeze({ id: 'pages', + sections: ['actions', 'wealth', 'taxes', 'tracking', 'interest', 'price-history', 'charts', + 'conversion'] }), + Object.freeze({ id: 'setup', + sections: ['dashboard', 'accounts', 'assets', 'price-sources', 'connections', 'lnd', 'ai', 'health', 'license', + 'portfolios'] }), +]); + +export const GUIDE_SECTIONS = Object.freeze([ + section({ id: 'overview', related: ['bookings', 'two-balances', 'price-sources'] }), + section({ id: 'bookings', place: 'EVENT_ACCOUNTS', related: ['anchors', 'free-value', 'two-balances'] }), + section({ id: 'anchors', place: 'VALUATIONS', related: ['bookings', 'actions'] }), + section({ id: 'free-value', related: ['bookings', 'wealth'] }), + section({ id: 'returns', place: 'WEALTH', related: ['wealth', 'account-returns', 'free-value', 'taxes'] }), + section({ id: 'account-returns', place: 'WEALTH', related: ['returns', 'wealth', 'accounts'] }), + section({ id: 'two-balances', place: 'TRACKING', related: ['overview', 'tracking'] }), + section({ id: 'wealth', place: 'WEALTH', related: ['returns', 'account-returns', 'free-value', 'taxes'] }), + section({ id: 'taxes', place: 'TAXES', related: ['anchors', 'wealth'] }), + section({ id: 'actions', place: 'ACTIONS', related: ['anchors', 'two-balances'] }), + section({ id: 'price-sources', place: 'SOURCE_PROPOSALS', related: ['overview', 'price-history'] }), + section({ id: 'tracking', place: 'TRACKING', related: ['two-balances', 'connections', 'actions'] }), + section({ id: 'connections', place: 'CONNECTIONS', related: ['tracking', 'lnd', 'overview'] }), + section({ id: 'lnd', place: 'CONNECTIONS', related: ['connections'] }), + section({ id: 'ai', place: 'AI_ACTIVITY', related: ['actions'] }), + section({ id: 'interest', place: 'INTEREST', related: ['bookings', 'free-value', 'actions'] }), + section({ id: 'health', place: 'HEALTH', related: ['anchors', 'price-sources'] }), + section({ id: 'dashboard', place: 'HOME', related: ['overview'] }), + section({ id: 'price-history', place: 'PRICE_HISTORY', related: ['price-sources', 'health'] }), + section({ id: 'charts', place: 'CHARTS', related: ['price-history'] }), + section({ id: 'conversion', place: 'CONVERSION', related: ['charts'] }), + section({ id: 'assets', place: 'ASSETS', related: ['overview', 'price-sources'] }), + section({ id: 'accounts', place: 'ACCOUNTS', related: ['bookings', 'tracking', 'wealth', 'account-returns'] }), + section({ id: 'license', place: 'LICENSE', related: ['overview'] }), + section({ id: 'portfolios', place: 'PORTFOLIOS', related: ['overview'] }), +]); + +export const guideText = (code) => sharedText(code).guide; diff --git a/shared/i18n/en.js b/shared/i18n/en.js new file mode 100644 index 0000000..f84a850 --- /dev/null +++ b/shared/i18n/en.js @@ -0,0 +1,405 @@ +// Generated, do not edit. sha256:e2490264df449b0893dfa0c9153c9a28823fc105b7143f0c7281166235765818 +export const en = { + places: { + HOME: 'The dashboard. You build the layout yourself from widgets (add, drag, adjust width, restore the default layout) and see balances, prices and signals at a glance.', + ACTIONS: 'To do. Everything waiting for you is collected here: reconcile proposals from a wallet scan, interest that can be credited, and differences between a balance and your records. You handle them here or set them aside.', + CHARTS: 'Signals & Charts for Bitcoin. You read the three traffic lights (Power Law, 200-week average, Mayer Multiple) and view the four charts below them; per chart you can zoom, switch the scale and go full screen.', + CHART_PRICE: 'The Bitcoin price chart. You switch between linear and logarithmic scale and zoom in on a period.', + CHART_POWERLAW: 'The Power Law corridor. You adjust the lower and upper band to see where the price stands relative to the long-term trend.', + CHART_WMA: 'The 200-week average with its signal threshold. Meant to assess the historical bottom zone; you switch the scale and zoom.', + CHART_MAYER: 'The Mayer Multiple: the price divided by the 200-day average, with the zones for overvaluation and undervaluation.', + TAXES: 'Taxes. Per account and asset you see the value on each reference date and the gain/loss per year, and you can point out which valuations are still missing. Nothing is recorded here: the figures follow from your records.', + WEALTH: 'Wealth. Per asset your position, the current value, the average purchase price and the gain/loss. An overview to read; you record entries under Events.', + TRACKING: 'On-chain tracking. Per tracking source the balance and the transactions, grouped by date, with a filter per account. You start a scan here and label transactions.', + PRICE_HISTORY: 'Price history. The full price history per source and asset in one management table: search, filter, add or delete a point, and compare sources side by side.', + GUIDE: 'Help. You read how the app is put together and where its figures come from; nothing changes here.', + CONVERSION: 'Bitcoin conversions. You convert live between euro, dollar and BTC, mBTC, bit, sat and msat; each field updates the others. Nothing is recorded.', + EVENT_ACCOUNTS: 'Accounts under Events. Per account you see the current balance per asset and the date of the last balance, and you open an account to view and edit its balances, buys and sells, transfers and interest together.', + VALUATIONS: 'Balances. You record what an account actually held on a date, so that a difference with your records becomes visible. You can add, edit and delete them here.', + TRADES: 'Buy & Sell. You record a purchase or sale with quantity, price and fees, and edit or delete an existing entry.', + TRANSFERS: 'Transfers. You record a transfer between two of your own accounts or to an external address, with any fees in the asset itself.', + INTEREST: 'Interest & Rewards. You maintain interest schedules per account (rate, frequency, term) and record the credits that follow from them.', + RESERVATIONS: 'Reservations. Per account you set aside an amount that may later be debited or credited; it counts nowhere until you record it or settle it without an entry.', + ACCOUNTS: 'Accounts. You create an account, close or reopen it, turn on automatic scanning, and link tracking sources to it (an xpub or a single address). Merging is also possible here.', + ASSETS: 'Assets. You add or edit an asset (symbol, unit, subunit, precision, display), and manage the metal products under it.', + PORTFOLIOS: 'Portfolios. Each portfolio is a separate set of records, with its own database and its own secrets; exactly one is always open. You create one (empty or as a copy of an existing one), switch to another (the app restarts for this), rename or delete one, and set who may access this portfolio. Deleting moves it to the archive; from there it can be removed permanently. This is also where you handle your records as a file: make a backup, export or restore the database, and start empty.', + SETTINGS: 'Settings. Theme and accent color, currency, language, the privacy blur, which name an asset gets in tables, and the intervals of the background tasks.', + LICENSE: 'License. Which version this installation runs, who the license is for and for which major versions, and where you put a license file. Every lock elsewhere in the app points here.', + IMPORT: 'Import. You read in events from a CSV file, map the columns to the fields and see before saving what would be added.', + DATA: 'Data. A read-only look at the raw contents of the tables. Meant for checking, not for editing; the backup is under Portfolios.', + HEALTH: 'Health. Ten checks on your records themselves, with for each finding what is going on: duplicate entries, balances that are not reconciled, lag and gaps in the price history, transfers without a counterpart. You read here; you handle things on To do.', + AI_ACTIVITY: 'AI activity. You turn the AI connection and the navigation permission on or off, review per call what happened and which fields left the app, and preview what an action would share.', + INTEGRATIONS: 'Integrations. Managing everything outside the app: price sources, their API keys, the connections to an explorer or node, and proposals for new sources.', + PROVIDER_KEYS: 'Integrations, Providers tab. Per price source the endpoints, the mapping, the API key, a test and the measurements; add, enable or delete a source.', + SOURCE_PROPOSALS: 'Integrations, Source choice tab. Per asset and use the ranking of price sources, with fallback; confirm or adjust an open proposal.', + CONNECTIONS: 'Integrations, Connections tab. Add, test and activate a Bitcoin explorer, LND node or NWC wallet.', + }, + guide: { + groups: { + concepts: 'Concepts', + pages: 'Pages', + setup: 'Setup', + }, + sections: { + overview: { + title: 'What the app consists of', + body: [ + 'Two groups. The first describes what you own, the second where the app gets its data.', + { heading: 'Your records' }, + { list: [ + ['Asset', 'What you own: bitcoin, euro, gold. The asset determines the unit used in calculations.'], + ['Account', 'Where it is held: a wallet, an exchange, a vault.'], + ['Entry', 'One event on an account: a purchase, a sale, a transfer or interest.'], + ['Balance', 'A recorded position on a date. From that position the app continues with the entries after it.'], + ['Label', 'A short name for an on-chain transaction, reusable.'], + ['Metal product', 'A coin or bar under a metal asset, with its fine weight in grams.'], + ] }, + { heading: 'The three sources' }, + 'All three are called a source. They answer different questions.', + { list: [ + ['Tracking source', 'Which wallet. An xpub, an address or a Lightning connection the app uses to read the balance of one account. An exchange or vault has none; that balance comes from the entries.'], + ['Price source', 'Which price. The service that supplies the price of an asset, with a ranking and fallback per asset and use.'], + ['Connection', 'Which address. Where the app requests its data: your own node or a public service. One connection serves multiple sources.'], + ] }, + ], + }, + bookings: { + title: 'Four types of entries', + body: [ + 'Every entry belongs to an account and an asset. There are four types, each with its own tab.', + { list: [ + ['Buy and sell', 'Money in exchange for an asset or the other way around, with quantity, price and fees. On {TRADES}.'], + ['Transfer', 'The same asset from one account to another, or to an address outside the app. The fees are deducted in the asset itself. On {TRANSFERS}.'], + ['Interest and rewards', 'What is added because something is held: interest, staking, cashback. A single credit is possible, or a schedule from which the app proposes the credits. On {INTEREST}.'], + ['Balance', 'Not a movement but a recorded position on a date. On {VALUATIONS}.'], + ] }, + 'On {EVENT_ACCOUNTS} each account shows its current balance. Clicking an account opens all four together, to view and edit.', + ], + }, + anchors: { + title: 'A balance is an anchor', + body: [ + 'The position of an account is the latest balance plus the entries after it. Entries from before that balance no longer count toward the current position.', + 'This way you can keep an account up to date without recording every movement, for example a savings account or a vault tracked by balance.', + { heading: 'A difference' }, + 'If there are also entries before the balance, the app checks whether they arrive at the same position. If not, the difference appears on {ACTIONS}, with two choices:', + { list: [ + ['Record', 'The difference becomes one purchase or sale at the price of that moment.'], + ['Accept', 'The difference counts as money that entered or left your records, not as return. Can be undone.'], + ] }, + 'A balance without entries before it is an opening balance: there is nothing to compare it with.', + { term: 'Record balance', text: 'in the window of an account records a balance at this moment, not at the start of the day. By default the button is only shown for money; per asset you can turn it on at {ASSETS}.' }, + ], + }, + 'free-value': { + title: 'Received for free: income or gift', + body: [ + 'Value that came in without payment is recorded by its reason, not by the way it arrived.', + { list: [ + ['Income', 'Received because you were a customer or held something: interest, staking, cashback, a welcome bonus. Record it on {INTEREST}; it counts as gain/loss.'], + ['Gift', 'Someone else gave it: an inheritance, a present. Record it as a purchase or transfer, with the Origin set to Gift. The app deducts the value at the daily price from the gain/loss.'], + ] }, + { note: 'A wrong choice cannot be seen in the figures afterward.' }, + 'A transfer between two of your own accounts is neither: the total does not change.', + ], + }, + returns: { + title: 'What is return, what is gain/loss?', + body: [ + 'The figures on {WEALTH} look alike but measure different things. Unrealized and Invested are a current position; Return, Gain/loss and Realized cover the selected period.', + { figure: 'stand-period', caption: 'Unrealized and Invested count since the purchase; Return, Gain/loss and Realized only over the selected period.' }, + { heading: 'What is return?' }, + 'A percentage that measures only the price. It is time-weighted: how much was bought and when does not count. It follows the year selection. The money-weighted return, which does include deposits, is on {TAXES}.', + { heading: 'What is gain/loss?' }, + 'What the holdings yielded, as an amount over the selected period: the change in value minus the net amount invested (purchases in, sales out). Price gains, the outcome of sales and interest count. A gift counts as a contribution, not as gain/loss.', + { heading: 'What is unrealized?' }, + 'The gain or loss on what you hold now, if it were sold now: the current value minus what those units cost, at the average cost basis including fees and premium. A current position: always since the purchase, regardless of the year selection. What has already been sold does not count.', + { heading: 'What is realized?' }, + 'The gain or loss on what was sold in the selected period: the proceeds minus fees and the cost basis of the units sold.', + { heading: 'What is invested?' }, + 'Per asset all purchases plus fees, including what has already been sold. After a sale, value minus invested is therefore no longer the same as unrealized. The annual overview shows Invested (net): per year the purchases plus fees, minus what sales brought in.', + { heading: 'What is contributed (net)?' }, + 'The same for an account: money in plus every other flow, including a transfer from another of your own accounts at the daily price of that day. Interest does not count as a contribution. The value minus this amount is the gain/loss of the account.', + { heading: 'How they relate' }, + { figure: 'result-sum', caption: 'The gain/loss over all years, split into its three parts. The proportions are an example.' }, + 'Over all years together, the gain/loss is roughly unrealized plus realized plus interest. A gift is the exception: it has a cost basis of zero, so Unrealized includes its full value, while the gain/loss deducts it as a contribution at the daily price.', + { note: 'Unrealized and Gain/loss can have a different sign and both still be correct: the first measures against the purchase, the second over the selected period. This is common per account; see By account: contributed and gain/loss.' }, + ], + }, + 'account-returns': { + title: 'By account: contributed and gain/loss', + body: [ + 'On an account card, Unrealized can be negative while Gain/loss is positive. That is not an error: the two measure from a different moment.', + { list: [ + ['Unrealized', 'For your own account, in proportion to the units, at the average cost basis of the asset: within own wealth a coin costs the same everywhere. A third-party account has its own cost basis, see below.'], + ['Contributed (net)', 'Money in plus every other flow, including a transfer from another of your own accounts at the daily price of that day. Interest does not count as a contribution.'], + ['Gain/loss', 'The change in value minus what was contributed. Over all years, that is the space between the two lines on the card: the value and what was contributed.'], + ['Return', 'A transfer between your own accounts counts as a withdrawal from one account and a deposit to the other, at the daily price. Only the price and the fees count.'], + ] }, + { heading: 'An example' }, + { figure: 'account-example', caption: 'The current value is 4.77 below the cost basis of the asset, but 31.51 above what came in with the transfer. The green area is the gain/loss.' }, + 'Coins bought on an exchange for 191.20 in total, transferred to a cold wallet when they were worth 154.92, and now worth 186.43. Unrealized is -4.77: below what was paid for them. Contributed (net) is 154.92 and the gain/loss 31.51: the rise since the coins have been on the cold wallet.', + 'Invested, the average purchase price and realized cannot be split per account. Those are shown with the asset.', + { heading: 'Third-party wealth' }, + 'An account of someone else, for example a child\'s savings account, does not share the cost basis with own wealth. A transfer to it is a withdrawal at the daily price, and that value is the cost basis of that account. If the cold wallet above belonged to someone else, Unrealized there would be 31.51: measured from 154.92.', + 'What the price did before the transfer stays in the gain/loss of own wealth. Even with Show all, the two stay separate: in the window of an asset, third-party wealth is on its own tab, with its own key figures.', + ], + }, + 'two-balances': { + title: 'Two sources for one balance', + body: [ + 'The app has two answers to the question of how much there is. For holdings with an on-chain address they are equal.', + { list: [ + ['Scanned balance', 'Shown on {TRACKING}. The app reads the addresses of the wallet and adds up what the chain reports there.'], + ['Ledger', 'Shown on {WEALTH} and on {TAXES}. The sum of the entries: purchases, sales, transfers and interest.'], + ] }, + { heading: 'Why they can differ' }, + { list: [ + ['No address', 'An account at an exchange or a custodian has no xpub, so there is nothing to read. Those holdings are only in the ledger.'], + ['Fee not recorded', 'The chain deducts the transaction fee, the ledger does not.'], + ['Entry without address', 'Counts on {WEALTH}, not on {TRACKING}.'], + ] }, + { heading: 'What you can do about it' }, + { list: [ + 'On-chain transactions without an entry appear as a proposal on {ACTIONS}. After approval the two match.', + 'If the cause cannot be found, record a balance. The app takes that position as the truth and adds the entries after it.', + ] }, + ], + }, + wealth: { + title: 'Position, gain/loss and return', + body: [ + 'Per asset or per account the current value, the gain/loss and the return. Nothing is recorded here; that happens on the entry pages.', + { heading: 'What is where' }, + { list: [ + ['Asset card', 'Quantity, Value, Share of total, Unrealized, Gain/loss and Return, and Unexplained if a balance differs from the entries.'], + ['Account card', 'The same without Quantity. The account cards add up to the total.'], + ['Asset window', 'On the Own wealth tab, Avg. purchase price, Avg. premium and Invested, per account. With third-party wealth in view, the same key figures for third parties are on the Third-party wealth tab. On the Return tab, the return, the gain/loss and realized per year.'], + ['Page column', 'Invested, Unrealized, Gain/loss and Return of the whole.'], + ] }, + { term: 'Plus and minus', text: 'a green or red number has no plus sign; the minus sign is always shown. Without color, as on the dashboard\'s return widget, both plus and minus are shown.' }, + { list: [ + ['Avg. purchase price', 'What was paid per unit, without fees.'], + ['Avg. premium', 'The fees per purchase, including the premium for metal.'], + ] }, + 'What Unrealized, Gain/loss, Return and Invested measure exactly is explained under the concepts in See also.', + { heading: 'Own wealth and third-party wealth' }, + 'An account of someone else or an account that does not count is marked as such in the Wealth column on {ACCOUNTS}. Wealth and the dashboard then show only own wealth; the Show all switch at the total shows everything.', + ], + }, + taxes: { + title: 'Taxes: key figures, but no tax return', + body: [ + 'The page provides the figures for a tax return. The app does not calculate the tax itself; that is up to the country of the tax return.', + { list: [ + ['Value per reference date', 'Per account and asset on January 1 of each year, with a copy button per amount.'], + ['Category', 'Bank balances and other assets separately, for a tax return that asks for this. Appears only if a category has been chosen on an account or asset.'], + ['Gain/loss', 'Per year the start, end, deposits, withdrawals and return. This return is money-weighted: it takes into account when money went in or out.'], + ] }, + 'What counts is set in the Taxes column on {ACCOUNTS} and {ASSETS}. A choice on the account takes precedence over the one on the asset.', + 'If the oldest entry goes back further than needed, set the start year on {PORTFOLIOS}. That removes columns, not entries: the balance in the first column stays complete.', + ], + }, + actions: { + title: 'What awaits a decision', + body: [ + 'Everything that awaits a decision is in one place. The column on the left shows per block how many items are open.', + { list: [ + ['AI proposals', 'What the AI wants to record. It only happens after approval.'], + ['Transactions', 'A transaction on the chain without an entry, as a proposal: purchase, transfer or ignore.'], + ['Interest to record', 'Credits that follow from an interest schedule.'], + ['Differences', 'A balance that does not match the entries before it.'], + ] }, + ], + }, + 'price-sources': { + title: 'Price sources and the source choice', + body: [ + 'A price source is the service that provides the price of an asset. Which source that is, is set per asset and per use on {SOURCE_PROPOSALS}: a ranking, and if the first one drops out, the next one takes over.', + 'Use means what the price is for. The price on a reference date for {TAXES} can come from a different source than the current price.', + { list: [ + ['New asset', 'Gets its default source right away, if there is one. You make a different choice on {SOURCE_PROPOSALS}.'], + ['API keys', 'A source that requires an API key gets it on {PROVIDER_KEYS}. The key stays on the server; the browser does not see it.'], + ['Coverage', 'A source only provides the assets it knows. For an asset outside its coverage, the app skips it.'], + ['Manual', 'A price you entered yourself, for something without a market or a day a source is missing.'], + ] }, + 'On {PRICE_HISTORY} you see per asset which prices exist and which source they come from.', + ], + }, + tracking: { + title: 'Tracking wallets on-chain', + body: [ + 'A tracking source is an xpub or a single address under an account. With it, the app reads what happens on the chain. You link it on {ACCOUNTS}.', + { list: [ + ['Update', 'On {TRACKING} the app reads the addresses again. Each transaction is listed per wallet, as received or sent, with the fee.'], + ['Proposal', 'A transaction without an entry becomes a proposal on {ACTIONS}. After approval it shows that it has also been registered as an event, and it counts toward Linked.'], + ['Label', 'A short name for a transaction, reusable.'], + ] }, + 'An account at an exchange or in a vault has no tracking source. Its balance comes from the entries.', + ], + }, + connections: { + title: 'Connections: where the app requests data', + body: [ + 'A connection is an address where the app requests data. One is active per type; Test checks whether the address responds.', + { list: [ + ['Bitcoin explorer', 'Reads addresses and transactions for {TRACKING} and provides the price from mempool. A public service by default. With your own node, the addresses do not go to a third party, and an xpub scan does not run into a limit.'], + ['Lightning node', 'LND. Reads the Lightning side of your own node. The app talks to the node through its own server; macaroon and certificate stay on that server.'], + ] }, + 'Your own explorer and Lightning are part of the full version. The default explorer always works.', + ], + }, + lnd: { + title: 'Connecting an LND node', + body: [ + 'Use a read-only macaroon. You create it on the node with:', + { code: 'lncli bakemacaroon info:read offchain:read invoices:read onchain:read peers:read' }, + 'Paste the output under Macaroon. Hex is fine, base64 too.', + { note: 'Not the macaroon from an lndconnect URL: that is usually the admin macaroon, which can make payments from the node.' }, + { list: [ + ['tls.cert', 'With the certificate, the app verifies that it is talking to your own node. Without it, the connection is encrypted but not verified; acceptable over a tunnel or your own network.'], + ['Address', 'A bare IP address with port is fine; the app turns it into an https address.'], + ['Timeout', 'The node cannot be reached. First check whether the tunnel is on.'], + ] }, + ], + }, + ai: { + title: 'The AI connection', + body: [ + 'An AI program on this computer can read the app and make proposals. The program starts the connection itself; no web address or password is needed. The connection is part of the full version.', + { heading: 'What the AI may do' }, + { list: [ + ['Active', 'The connection itself. When off, every call is rejected and a running AI program stops.'], + ['Read', 'View your records.'], + ['Write', 'Add or change something, always as a proposal on {ACTIONS} that must be approved first.'], + ['Navigation', 'Move the screen to another page, after a countdown with a stop button.'], + ['Share raw', 'When off, the AI sees references and ranges instead of names and amounts.'], + ] }, + { note: 'Only turn on Share raw in a portfolio without real data.' }, + 'Below that, four sliders set how precisely the AI sees the figures: percentages, totals, amounts and quantities, as a range or exact.', + 'Every call is listed on {AI_ACTIVITY}, with the data that left the app. The settings apply per portfolio and immediately, without a restart.', + ], + }, + interest: { + title: 'Interest schedules', + body: [ + 'An interest schedule records what an account earns: rate, frequency and term. From that, the app calculates the credits and prepares them on {ACTIONS}.', + { list: [ + ['Principal', 'When empty, it follows the account balance; that suits a savings account. For a term deposit, enter the starting amount here.'], + ['Compound interest', 'Only with a fixed principal: each period then also calculates over the earlier credits. A principal that follows the balance already does this by itself.'], + ['Rate periods', 'If the rate changes, record a period with the start date.'], + ['Order', 'Credits are recorded in order: each credit increases the balance the next one is calculated on.'], + ] }, + 'A single credit without a schedule is also possible, on {INTEREST}.', + ], + }, + health: { + title: 'Health: checks on your records', + body: [ + 'Run check goes through ten checks on the records themselves. This only happens on request, because it can take a while.', + { list: [ + 'Duplicate entries, and transfers without a counterpart.', + 'Balances that are not reconciled.', + 'Gaps, stale data and suspicious prices in the price history.', + ] }, + 'A finding is marked as probably unintended or for information. If it is intended, This is correct removes it; it comes back as soon as something changes.', + 'Fixing happens elsewhere: in the entries, on {ACTIONS} or on {PRICE_HISTORY}.', + ], + }, + dashboard: { + title: 'Setting up the dashboard', + body: [ + 'The dashboard consists of widgets you put together yourself, in one or more layouts.', + { list: [ + ['Edit', 'Long-press a widget, or use the {icon:adjust} button next to Dashboard in the menu; it appears when the mouse is over it. Escape, a click outside the grid or the same button turns edit mode off again.'], + ['Add', 'In edit mode, the empty slot opens the menu with widgets.'], + ['Arrange', 'Drag a widget, or adjust its width in the menu in its header.'], + ['Layouts', 'Several saved dashboards to switch between. In edit mode they are shown as a list, with buttons for a new layout, renaming and deleting.'], + ] }, + 'Deleting Default starts it over, with the layout the app started with.', + ], + }, + 'price-history': { + title: 'Price history: where each price comes from', + body: [ + 'Per asset, a card with the origin of each daily price over the past year. A click opens the window with, among other things, the origin, the gaps and the prices themselves.', + { heading: 'What the app knows about a daily price, from strong to weak' }, + { list: [ + ['Closing price', 'The price from the source at the end of that day.'], + ['Manual', 'A price you entered yourself.'], + ['Today', 'The current price, which is still moving.'], + ['Derived', 'From a spot price of the source, not a closing price.'], + ['Carried forward', 'An earlier price, with how many days old it is.'], + ['No price', 'Counts as zero. An amount affected by this gets a yellow i.'], + ] }, + { heading: 'Gaps' }, + 'A gap is a series of days without a price. Update requests the missing prices from the source. A day that no source has can be ignored or filled in manually.', + { note: 'With a source that has a monthly limit, updating costs one request per day.' }, + ], + }, + charts: { + title: 'Signals and charts', + body: [ + 'Three signals for bitcoin, each as a light in the column. Green, yellow and red are a judgment here; a click on a light opens that chart.', + { list: [ + ['Power Law', 'The price against the long-term trend, in log-log. The line is a fit on the history, not a prediction; the bands around it are a fixed factor.'], + ['Weekly averages', 'The 200-week average is the slow bottom zone of the cycle. The 50-week average is the dividing line between a rising and a falling market.'], + ['Mayer Multiple', 'The price divided by the 200-day average. Below 1.0 is historically a rare low zone, above 2.4 overheated.'], + ] }, + 'Each chart can be linear or logarithmic, zoomed in and shown full screen.', + ], + }, + conversion: { + title: 'Conversions', + body: [ + 'A calculator between money and the units of bitcoin: BTC, mBTC, bit, sat and msat. Each field updates the others live. Nothing is recorded.', + ], + }, + assets: { + title: 'Assets and metals', + body: [ + 'An asset is something you own whose price the app tracks, with symbol, name, type, main unit, subunit and decimals.', + { list: [ + ['Type', 'Crypto, metal or currency. The type determines the default category on {TAXES} and whether Record balance is on; this can be set differently per asset.'], + ['Metal', 'Calculates in grams. The app converts a price per troy ounce to grams.'], + ['Coins and bars', 'Products under a metal asset, with their fine weight. A purchase of a product records the number of pieces; the app calculates the weight.'], + ['Delete', 'Only possible once no entry refers to the asset anymore.'], + ] }, + ], + }, + accounts: { + title: 'Accounts', + body: [ + 'An account is where something is held: a wallet, an exchange, a card, a bank, a vault or a Lightning wallet.', + { list: [ + ['Tracking source', 'An xpub or address under the account lets the app read the balance on the chain.'], + ['Wealth', 'Whether it is own wealth, managed for someone else, or does not count.'], + ['Taxes', 'Whether it counts in the tax return. Separate from Wealth.'], + ['Close', 'A closed account disappears from the dropdowns, but still counts in balances, positions and taxes.'], + ['Merge', 'Two accounts that are the same become one.'], + ] }, + ], + }, + license: { + title: 'Free and full version', + body: [ + 'The free version has no time limit, with small records: one asset, one fiat account, one balance and one portfolio.', + 'Integrations, the source choice, your own API keys and the tax page are part of the full version. Reading and exporting always keep working; nothing is ever taken away.', + 'The license file goes on {LICENSE} and takes effect immediately. Every lock elsewhere in the app points here.', + ], + }, + portfolios: { + title: 'Portfolios', + body: [ + 'Each portfolio is a separate set of records, with its own database and its own secrets. One is always open.', + { list: [ + ['Base currency', 'Chosen at creation and fixed after that. All amounts are in that currency.'], + ['Switch', 'The app restarts to do so.'], + ['Delete', 'Moves the portfolio to the archive. Only from there can it be removed permanently.'], + ] }, + { note: 'A backup contains the database and secrets together: they only match together.' }, + ], + }, + }, + }, +}; diff --git a/shared/i18n/index.js b/shared/i18n/index.js new file mode 100644 index 0000000..a7495e1 --- /dev/null +++ b/shared/i18n/index.js @@ -0,0 +1,8 @@ +// Generated, do not edit. sha256:a416595fbf1033b4f4a08083413027258e72584eb9e1cac5688d5eae76866b95 +import { CONFIG } from '../config.js'; +import { nl } from './nl.js'; +import { en } from './en.js'; + +export const SHARED_TEXT = Object.freeze({ nl, en }); + +export const sharedText = (code) => SHARED_TEXT[code] ?? SHARED_TEXT[CONFIG.INSTALLATION.DEFAULT_LOCALE]; diff --git a/shared/i18n/nl.js b/shared/i18n/nl.js new file mode 100644 index 0000000..4bfa49d --- /dev/null +++ b/shared/i18n/nl.js @@ -0,0 +1,405 @@ +// Generated, do not edit. sha256:88c3787099bdacec2cc7f0323d565d172c1d53d814c3d3c14973885706e0e046 +export const nl = { + places: { + HOME: 'Het dashboard. U stelt de indeling zelf samen uit widgets (toevoegen, verslepen, breedte aanpassen, standaardindeling herstellen) en ziet in één oogopslag saldi, koersen en signalen.', + ACTIONS: 'Af te handelen. Alles wat op u wacht staat hier bij elkaar: reconcile-voorstellen van een wallet-scan, rente die bijgeschreven kan worden, en verschillen tussen een balans en de administratie. U handelt ze hier af of legt ze weg.', + CHARTS: 'Signalen & Grafieken voor Bitcoin. U leest de drie stoplichten (Power Law, 200-weeks gemiddelde, Mayer Multiple) en bekijkt daaronder de vier grafieken; per grafiek kunt u zoomen, de schaal wisselen en op volledig scherm zetten.', + CHART_PRICE: 'De prijsgrafiek van Bitcoin. U wisselt tussen lineaire en logaritmische schaal en zoomt in op een periode.', + CHART_POWERLAW: 'De Power Law-corridor. U schuift de onder- en bovenband bij om te zien waar de koers ten opzichte van de langetermijntrend staat.', + CHART_WMA: 'Het 200-weeks gemiddelde met zijn signaaldrempel. Bedoeld om de historische bodemzone te beoordelen; u wisselt de schaal en zoomt.', + CHART_MAYER: 'De Mayer Multiple: de koers gedeeld door het 200-daags gemiddelde, met de zones voor over- en onderwaardering.', + TAXES: 'Belastingen. U ziet per account en asset de waarde op elke peildatum en het resultaat per jaar, en u kunt aanwijzen welke waarderingen nog ontbreken. Hier wordt niets geboekt: de cijfers volgen uit de administratie.', + WEALTH: 'Vermogen. Per asset uw positie, de actuele waarde, de gemiddelde aankoopkoers en het resultaat. Een overzicht om te lezen; boeken doet u bij Gebeurtenissen.', + TRACKING: 'On-chain analyse. Per wallet-bron het saldo en de bewegingen, gegroepeerd per datum, met een filter per account. U start hier een scan en labelt bewegingen.', + PRICE_HISTORY: 'Prijsgeschiedenis. De volledige koershistorie per bron en asset in één beheertabel: zoeken, filteren, een punt toevoegen of verwijderen, en bronnen naast elkaar vergelijken.', + GUIDE: 'Hulp. U leest hoe de app in elkaar zit en waar zijn cijfers vandaan komen; hier wijzigt niets.', + CONVERSION: 'Bitcoin-conversies. U rekent live tussen euro, dollar en BTC, mBTC, bit, sat en msat; elk veld rekent de andere door. Er wordt niets vastgelegd.', + EVENT_ACCOUNTS: 'Accounts onder Gebeurtenissen. U ziet per account het saldo van nu per asset en de datum van de laatste balans, en opent een account om zijn balansen, aan- en verkopen, verplaatsingen en rente samen te bekijken en te bewerken.', + VALUATIONS: 'Balansen. U legt vast wat een account op een datum werkelijk aanhield, zodat een verschil met de administratie zichtbaar wordt. Toevoegen, wijzigen en verwijderen kan hier.', + TRADES: 'Aankoop & Verkoop. U boekt een aankoop of verkoop met hoeveelheid, koers en kosten, en wijzigt of verwijdert een bestaande boeking.', + TRANSFERS: 'Verplaatsingen. U boekt een overboeking tussen twee eigen accounts of naar een extern adres, met eventuele kosten in de asset zelf.', + INTEREST: 'Rente & Beloning. U onderhoudt renteafspraken per account (percentage, frequentie, looptijd) en boekt de bijschrijvingen die daaruit volgen.', + RESERVATIONS: 'Reserveringen. U legt per account een bedrag vast dat later mogelijk wordt afgeschreven of bijgeboekt; het telt nergens mee tot u het boekt of zonder boeking afhandelt.', + ACCOUNTS: 'Accounts. U maakt een account aan, sluit het af of heropent het, zet automatisch scannen aan, en koppelt er wallet-bronnen aan (een xpub of los adres). Ook samenvoegen kan hier.', + ASSETS: 'Assets. U voegt een asset toe of wijzigt hem (symbool, eenheid, ondereenheid, precisie, weergave), en beheert de metaalproducten die eronder hangen.', + PORTFOLIOS: 'Portefeuilles. Elke portefeuille is een eigen administratie, met een eigen database en eigen geheimen; er is er altijd precies één open. U maakt er een aan (leeg of als kopie van een bestaande), schakelt over naar een andere (de app start daarvoor opnieuw), hernoemt of verwijdert er een, en stelt in wie er bij deze portefeuille mag. Verwijderen verplaatst naar het archief; daar kan het definitief weg. Hier staan ook de handelingen op de administratie als bestand: een back-up maken, de database exporteren of terugzetten, en leeg beginnen.', + SETTINGS: 'Instellingen. Thema en accentkleur, valuta, taal, de privacy-blur, welke naam een asset in tabellen krijgt, en de intervallen van de achtergrondtaken.', + LICENSE: 'Licentie. Welke versie deze installatie draait, voor wie de licentie is en voor welke hoofdversies, en waar u een licentiebestand neerzet. Elk slot elders in de app verwijst hierheen.', + IMPORT: 'Importeren. U leest gebeurtenissen in uit een CSV-bestand, koppelt de kolommen aan de velden en ziet vóór het opslaan wat er zou worden toegevoegd.', + DATA: 'Data. Een read-only kijkje in de rauwe inhoud van de tabellen. Bedoeld om te controleren, niet om te wijzigen; de back-up staat bij Portefeuilles.', + HEALTH: 'Gezondheid. Tien controles op de administratie zelf, met per bevinding wat er aan de hand is: dubbele boekingen, balansen die niet verzoend zijn, achterstand en gaten in de koershistorie, verplaatsingen zonder tegenkant. U leest hier, u handelt af op Af te handelen.', + AI_ACTIVITY: 'AI-activiteit. U zet de AI-koppeling en het navigatierecht aan of uit, leest per aanroep na wat er gebeurde en welke velden de app verlieten, en bekijkt vooraf wat een handeling zou delen.', + INTEGRATIONS: 'Integraties. Het beheer van alles wat buiten de app zit: koersbronnen, hun sleutels, de verbindingen met een explorer of node, en voorstellen voor nieuwe bronnen.', + PROVIDER_KEYS: 'Integraties, tabblad Providers. Per koersbron de endpoints, de mapping, de API-sleutel, een test en de metingen; een bron toevoegen, aanzetten of verwijderen.', + SOURCE_PROPOSALS: 'Integraties, tabblad Bronkeuze. Per asset en gebruik de rangorde van koersbronnen, met terugval; een openstaand voorstel bevestigen of aanpassen.', + CONNECTIONS: 'Integraties, tabblad Verbindingen. Een Bitcoin-explorer, LND-node of NWC-wallet toevoegen, testen en actief maken.', + }, + guide: { + groups: { + concepts: 'Begrippen', + pages: 'Pagina\'s', + setup: 'Inrichten', + }, + sections: { + overview: { + title: 'Waar de app uit bestaat', + body: [ + 'Twee groepen. De eerste beschrijft wat je bezit, de tweede waar de app zijn gegevens haalt.', + { heading: 'De administratie' }, + { list: [ + ['Asset', 'Wat je bezit: bitcoin, euro, goud. De asset bepaalt de eenheid waarin gerekend wordt.'], + ['Account', 'Waar het staat: een wallet, een exchange, een kluis.'], + ['Boeking', 'Eén gebeurtenis op een account: een aankoop, een verkoop, een overboeking of rente.'], + ['Balans', 'Een vastgelegde stand op een datum. De app rekent vanaf die stand verder met de boekingen erna.'], + ['Label', 'Een korte naam bij een beweging op de keten, opnieuw te gebruiken.'], + ['Metaalproduct', 'Een munt of baar onder een metaal-asset, met het fijngewicht in gram.'], + ] }, + { heading: 'De drie bronnen' }, + 'Alle drie heten ze bron. Ze beantwoorden verschillende vragen.', + { list: [ + ['Tracking-bron', 'Welke wallet. Een xpub, een adres of een lightning-koppeling waarmee de app het saldo van één account uitleest. Een exchange of kluis heeft er geen; dat saldo komt uit de boekingen.'], + ['Koersbron', 'Welke prijs. De dienst die de koers van een asset levert, met per asset en gebruik een rangorde en terugval.'], + ['Verbinding', 'Welk adres. Waar de app zijn gegevens opvraagt: een eigen node of een publieke dienst. Eén verbinding bedient meerdere bronnen.'], + ] }, + ], + }, + bookings: { + title: 'Vier soorten boekingen', + body: [ + 'Elke boeking hoort bij een account en een asset. Er zijn vier soorten, elk met een eigen tabblad.', + { list: [ + ['Aankoop en verkoop', 'Geld in ruil voor een asset of andersom, met hoeveelheid, prijs en kosten. Op {TRADES}.'], + ['Verplaatsing', 'Hetzelfde asset van het ene account naar het andere, of naar een adres buiten de app. De kosten gaan af in het asset zelf. Op {TRANSFERS}.'], + ['Rente en beloning', 'Wat erbij komt doordat iets wordt aangehouden: rente, staking, cashback. Een losse bijschrijving kan, of een afspraak waaruit de app de bijschrijvingen voorstelt. Op {INTEREST}.'], + ['Balans', 'Geen beweging maar een vastgelegde stand op een datum. Op {VALUATIONS}.'], + ] }, + 'Op {EVENT_ACCOUNTS} staat per account het saldo van nu. Een klik op een account opent alle vier samen, om te bekijken en te bewerken.', + ], + }, + anchors: { + title: 'Een balans is een anker', + body: [ + 'De stand van een account is de nieuwste balans plus de boekingen daarna. Boekingen van vóór die balans tellen voor de stand van nu niet meer mee.', + 'Zo is een account bij te houden zonder elke beweging te boeken, bijvoorbeeld een spaarrekening of een kluis op saldo.', + { heading: 'Een verschil' }, + 'Staan er ook boekingen vóór de balans, dan rekent de app na of die op dezelfde stand uitkomen. Is dat niet zo, dan staat het verschil op {ACTIONS}, met twee keuzes:', + { list: [ + ['Boek', 'Het verschil wordt één aankoop of verkoop tegen de koers van dat moment.'], + ['Aanvaard', 'Het verschil telt als geld dat de administratie in- of uitging, en niet als rendement. Terug te draaien.'], + ] }, + 'Een balans zonder boekingen ervóór is een beginbalans: er is niets om tegen te vergelijken.', + { term: 'Saldo vastleggen', text: 'in het venster van een account legt een balans vast op dit moment en niet op het begin van de dag. De knop staat standaard alleen bij geld; per asset is hij aan te zetten op {ASSETS}.' }, + ], + }, + 'free-value': { + title: 'Gratis binnengekomen: opbrengst of gift', + body: [ + 'Waarde die zonder betaling binnenkwam, boek je naar de reden en niet naar de manier waarop.', + { list: [ + ['Opbrengst', 'Gekregen omdat je klant was of iets aanhield: rente, staking, cashback, een welkomstbonus. Boeken op {INTEREST}; het telt als resultaat.'], + ['Gift', 'Iemand anders gaf het: een erfenis, een cadeau. Boeken als aankoop of verplaatsing, met de Herkomst op Gift. De app trekt de waarde op de dagkoers van het resultaat af.'], + ] }, + { note: 'Een verkeerde keuze is achteraf niet aan de cijfers te zien.' }, + 'Een verplaatsing tussen twee eigen accounts is geen van beide: het totaal verandert niet.', + ], + }, + returns: { + title: 'Wat is rendement, wat is resultaat?', + body: [ + 'De getallen op {WEALTH} lijken op elkaar en meten iets anders. Ongerealiseerd en Ingelegd zijn een stand van nu; Rendement, Resultaat en Gerealiseerd gaan over de gekozen periode.', + { figure: 'stand-period', caption: 'Ongerealiseerd en Ingelegd tellen sinds de aankoop; Rendement, Resultaat en Gerealiseerd alleen over de gekozen periode.' }, + { heading: 'Wat is rendement?' }, + 'Een percentage dat alleen de koers meet. Het is tijdgewogen: hoeveel en wanneer er gekocht is, telt niet mee. Het volgt de jaarkeuze. Het geldgewogen rendement, dat de stortingen wel meeweegt, staat op {TAXES}.', + { heading: 'Wat is resultaat?' }, + 'Wat het bezit opleverde, als bedrag over de gekozen periode: de waardeverandering min wat er netto is ingelegd (aankopen erin, verkopen eruit). Koerswinst, de uitkomst van verkopen en rente tellen mee. Een gift telt als inbreng en niet als resultaat.', + { heading: 'Wat is ongerealiseerd?' }, + 'De winst of het verlies op wat er nu is, als het nu verkocht werd: de waarde van nu min wat die eenheden gekost hebben, tegen de gemiddelde kostprijs inclusief kosten en premie. Een stand van nu: altijd sinds de aankoop, los van de jaarkeuze. Wat al verkocht is, telt niet mee.', + { heading: 'Wat is gerealiseerd?' }, + 'De winst of het verlies op wat er in de gekozen periode verkocht is: de opbrengst min kosten en de kostprijs van de verkochte eenheden.', + { heading: 'Wat is ingelegd?' }, + 'Per asset alle aankopen plus kosten, ook van wat al verkocht is. Na een verkoop is waarde min ingelegd dus niet meer hetzelfde als ongerealiseerd. In het jaaroverzicht staat Ingelegd (netto): per jaar de aankopen plus kosten, min wat verkopen opbrachten.', + { heading: 'Wat is ingebracht (netto)?' }, + 'Hetzelfde voor een account: geld erin plus elke andere stroom, waaronder een verplaatsing vanaf een ander eigen account tegen de dagkoers van die dag. Rente telt niet als inbreng. De waarde min dit bedrag is het resultaat van het account.', + { heading: 'Hoe ze samenhangen' }, + { figure: 'result-sum', caption: 'Het resultaat over alle jaren, verdeeld in zijn drie delen. De verhouding is een voorbeeld.' }, + 'Over alle jaren samen is het resultaat ongeveer ongerealiseerd plus gerealiseerd plus rente. Een gift is de uitzondering: die heeft kostprijs nul, dus bij Ongerealiseerd telt de hele waarde mee, terwijl het resultaat hem als inbreng tegen de dagkoers aftrekt.', + { note: 'Ongerealiseerd en Resultaat kunnen een ander teken hebben en toch allebei kloppen: het eerste meet tegen de aankoop, het tweede over de gekozen periode. Per account komt dat vaak voor; zie Per account: ingebracht en resultaat.' }, + ], + }, + 'account-returns': { + title: 'Per account: ingebracht en resultaat', + body: [ + 'Op een accountkaart kan Ongerealiseerd negatief zijn terwijl het Resultaat positief is. Dat is geen fout: de twee meten vanaf een ander moment.', + { list: [ + ['Ongerealiseerd', 'Bij een eigen account naar rato van de eenheden, tegen de gemiddelde kostprijs van het asset: binnen eigen vermogen kost een munt overal evenveel. Een account van derden heeft een eigen kostprijs, zie hieronder.'], + ['Ingebracht (netto)', 'Geld erin plus elke andere stroom, waaronder een verplaatsing vanaf een ander eigen account tegen de dagkoers van die dag. Rente telt niet als inbreng.'], + ['Resultaat', 'De waardeverandering min wat er is ingebracht. Over alle jaren is dat de ruimte tussen de twee lijnen op de kaart: de waarde en wat er is ingebracht.'], + ['Rendement', 'Een verplaatsing tussen eigen accounts telt als opname op het ene en storting op het andere account, tegen de dagkoers. Alleen de koers en de kosten tellen.'], + ] }, + { heading: 'Een voorbeeld' }, + { figure: 'account-example', caption: 'De waarde van nu ligt 4,77 onder de kostprijs van het asset, maar 31,51 boven wat er bij de verplaatsing binnenkwam. Het groene vlak is het resultaat.' }, + 'Munten op een exchange gekocht voor 191,20 in totaal, verplaatst naar een cold wallet toen ze 154,92 waard waren, en nu 186,43 waard. Ongerealiseerd is -4,77: onder wat ervoor betaald is. Ingebracht (netto) is 154,92 en het resultaat 31,51: gestegen sinds de munten op de cold wallet staan.', + 'Ingelegd, de gemiddelde aankoopkoers en gerealiseerd zijn niet per account te verdelen. Die staan bij het asset.', + { heading: 'Vermogen van derden' }, + 'Een account van een ander, bijvoorbeeld de spaarrekening van een kind, deelt de kostprijs niet met eigen vermogen. Een verplaatsing ernaartoe is een onttrekking tegen de dagkoers, en die waarde is de kostprijs van dat account. Zou de cold wallet hierboven van een ander zijn, dan is Ongerealiseerd daar 31,51: gemeten vanaf 154,92.', + 'Wat de koers deed vóór de verplaatsing, blijft in het resultaat van eigen vermogen. Ook met Alles tonen blijven de twee gescheiden: in het venster van een asset staat het vermogen van derden op een eigen tab, met zijn eigen kengetallen.', + ], + }, + 'two-balances': { + title: 'Twee bronnen voor één saldo', + body: [ + 'De app kent twee antwoorden op de vraag hoeveel er is. Voor bezit met een adres op de keten zijn ze gelijk.', + { list: [ + ['Gescand saldo', 'Staat op {TRACKING}. De app leest de adressen van de wallet uit en telt op wat de keten daar meldt.'], + ['Grootboek', 'Staat op {WEALTH} en op {TAXES}. De som van de boekingen: aankopen, verkopen, overboekingen en rente.'], + ] }, + { heading: 'Waarom ze kunnen verschillen' }, + { list: [ + ['Geen adres', 'Een account bij een exchange of een bewaarder heeft geen xpub, dus er is niets om uit te lezen. Dat bezit staat alleen in het grootboek.'], + ['Fee niet geboekt', 'De keten trekt de transactiekosten af, het grootboek niet.'], + ['Boeking zonder adres', 'Telt mee op {WEALTH}, niet op {TRACKING}.'], + ] }, + { heading: 'Wat eraan te doen is' }, + { list: [ + 'Bewegingen op de keten zonder boeking komen als voorstel op {ACTIONS}. Na goedkeuring lopen de twee gelijk.', + 'Is de oorzaak niet te vinden, leg dan een balans vast. De app neemt die stand als waarheid en telt de boekingen daarna erbij op.', + ] }, + ], + }, + wealth: { + title: 'Positie, resultaat en rendement', + body: [ + 'Per asset of per account de waarde van nu, het resultaat en het rendement. Hier wordt niets geboekt; dat gebeurt bij de boekingen.', + { heading: 'Wat waar staat' }, + { list: [ + ['Assetkaart', 'Hoeveelheid, Waarde, Aandeel, Ongerealiseerd, Resultaat en Rendement, en Niet verklaard als een balans afwijkt van de boekingen.'], + ['Accountkaart', 'Hetzelfde zonder Hoeveelheid. De accountkaarten tellen op tot het totaal.'], + ['Venster van een asset', 'Op de tab Eigen vermogen Gem. aankoopkoers, Gem. premie en Ingelegd, per account. Met vermogen van derden in beeld staan dezelfde kengetallen van de derden op de tab Vermogen van derden. Op het tabblad Rendement het rendement, het resultaat en gerealiseerd per jaar.'], + ['Paginakolom', 'Ingelegd, Ongerealiseerd, Resultaat en Rendement van het geheel.'], + ] }, + { term: 'Plus en min', text: 'een groen of rood getal heeft geen plusteken; het minteken staat er altijd. Zonder kleur, zoals op het rendementswidget van het dashboard, staan plus en min er allebei.' }, + { list: [ + ['Gem. aankoopkoers', 'Wat er per eenheid betaald is, zonder kosten.'], + ['Gem. premie', 'De kosten per aankoop, bij metaal inclusief de premie.'], + ] }, + 'Wat Ongerealiseerd, Resultaat, Rendement en Ingelegd precies meten, staat bij de begrippen onder Zie ook.', + { heading: 'Eigen vermogen en vermogen van derden' }, + 'Een account van een ander of een account dat niet meetelt, staat zo in de kolom Vermogen op {ACCOUNTS}. Vermogen en het dashboard tonen dan alleen eigen vermogen; de schakelaar Alles tonen bij het totaal toont alles.', + ], + }, + taxes: { + title: 'Belastingen: kengetallen, maar geen aangifte', + body: [ + 'De pagina levert de getallen voor een aangifte. De belasting zelf rekent de app niet uit; dat doet het land van de aangifte.', + { list: [ + ['Waarde per peildatum', 'Per account en asset op 1 januari van elk jaar, met een kopieerknop per bedrag.'], + ['Categorie', 'Banktegoeden en overige bezittingen apart, voor een aangifte die daarom vraagt. Verschijnt alleen als er op een account of asset een categorie gekozen is.'], + ['Resultaat', 'Per jaar begin, eind, stortingen, onttrekkingen en rendement. Dit rendement is geldgewogen: het weegt mee wanneer er geld in- of uitging.'], + ] }, + 'Wat meetelt, staat in de kolom Belastingen op {ACCOUNTS} en {ASSETS}. Een keuze op het account gaat voor die op het asset.', + 'Gaat de oudste boeking verder terug dan nodig, zet dan het startjaar op {PORTFOLIOS}. Dat haalt kolommen weg en geen boekingen: de stand in de eerste kolom blijft volledig.', + ], + }, + actions: { + title: 'Wat op een beslissing wacht', + body: [ + 'Alles wat op een beslissing wacht, staat op één plek. De kolom links zegt per blok hoeveel er openstaat.', + { list: [ + ['AI-voorstellen', 'Wat de AI wil vastleggen. Het gebeurt pas na goedkeuring.'], + ['Bewegingen', 'Een beweging op de keten zonder boeking, als voorstel: aankoop, verplaatsing of negeren.'], + ['Rente te boeken', 'Bijschrijvingen die uit een rente-afspraak volgen.'], + ['Verschillen', 'Een balans die niet uitkomt op de boekingen ervóór.'], + ] }, + ], + }, + 'price-sources': { + title: 'Koersbronnen en de bronkeuze', + body: [ + 'Een koersbron is de dienst die de koers van een asset levert. Welke bron dat is, staat per asset en per gebruik op {SOURCE_PROPOSALS}: een rangorde, en valt de eerste weg, dan neemt de volgende het over.', + 'Gebruik betekent waar de koers voor dient. De koers op een peildatum voor {TAXES} kan uit een andere bron komen dan de koers van nu.', + { list: [ + ['Nieuw asset', 'Krijgt meteen zijn standaardbron, als die er is. Een andere keuze maak je op {SOURCE_PROPOSALS}.'], + ['Sleutels', 'Een bron die een API-sleutel vraagt, krijgt die op {PROVIDER_KEYS}. De sleutel blijft op de server; de browser ziet hem niet.'], + ['Dekking', 'Een bron levert alleen de assets die hij kent. Voor een asset buiten zijn dekking slaat de app hem over.'], + ['Met de hand', 'Een zelf ingevoerde koers, voor iets zonder markt of een dag die een bron mist.'], + ] }, + 'Op {PRICE_HISTORY} staat per asset welke koersen er zijn en uit welke bron ze komen.', + ], + }, + tracking: { + title: 'Wallets volgen on-chain', + body: [ + 'Een tracking-bron is een xpub of een los adres onder een account. De app leest daarmee uit wat er op de keten gebeurt. Koppelen gaat op {ACCOUNTS}.', + { list: [ + ['Bijwerken', 'Op {TRACKING} leest de app de adressen opnieuw uit. Elke beweging staat per wallet, als ontvangen of verzonden, met de fee.'], + ['Voorstel', 'Een beweging zonder boeking wordt een voorstel op {ACTIONS}. Na goedkeuring staat erbij dat hij ook als gebeurtenis geregistreerd is, en telt hij mee bij Gekoppeld.'], + ['Label', 'Een korte naam bij een beweging, opnieuw te gebruiken.'], + ] }, + 'Een account bij een exchange of in een kluis heeft geen tracking-bron. Het saldo daarvan komt uit de boekingen.', + ], + }, + connections: { + title: 'Verbindingen: waar de app gegevens opvraagt', + body: [ + 'Een verbinding is een adres waar de app gegevens opvraagt. Per soort staat er één actief; Test controleert of het adres antwoordt.', + { list: [ + ['Bitcoin-explorer', 'Leest adressen en transacties voor {TRACKING} en levert de koers van mempool. Standaard een publieke dienst. Met een eigen node gaan de adressen niet naar een derde, en loopt een xpub-scan niet tegen een limiet aan.'], + ['Lightning-node', 'LND. Leest de lightning-kant van een eigen node. De app praat via zijn eigen server met de node; macaroon en certificaat blijven op die server.'], + ] }, + 'Een eigen explorer en lightning horen bij de volledige versie. De standaard-explorer werkt altijd.', + ], + }, + lnd: { + title: 'Een LND-node koppelen', + body: [ + 'Gebruik een macaroon die alleen mag lezen. Die maak je op de node met:', + { code: 'lncli bakemacaroon info:read offchain:read invoices:read onchain:read peers:read' }, + 'Plak de uitkomst bij Macaroon. Hex mag, base64 ook.', + { note: 'Niet de macaroon uit een lndconnect-URL: dat is doorgaans de admin-macaroon, en daarmee kan betaald worden vanaf de node.' }, + { list: [ + ['tls.cert', 'Met het certificaat erbij controleert de app dat hij met de eigen node praat. Zonder is de verbinding versleuteld maar niet gecontroleerd; aanvaardbaar over een tunnel of het eigen netwerk.'], + ['Adres', 'Een kaal ip-adres met poort mag; de app maakt er een https-adres van.'], + ['Timeout', 'De node is niet bereikbaar. Controleer eerst of de tunnel aanstaat.'], + ] }, + ], + }, + ai: { + title: 'De AI-koppeling', + body: [ + 'Een AI-programma op deze computer kan de app lezen en voorstellen doen. Het programma start de koppeling zelf; er is geen webadres en geen wachtwoord nodig. De koppeling hoort bij de volledige versie.', + { heading: 'Wat de AI mag' }, + { list: [ + ['Actief', 'De koppeling zelf. Uit wordt elke aanroep geweigerd en stopt een gestart AI-programma.'], + ['Lezen', 'De administratie bekijken.'], + ['Schrijven', 'Iets toevoegen of wijzigen, altijd als voorstel op {ACTIONS} dat eerst goedgekeurd moet worden.'], + ['Navigatie', 'Het scherm naar een andere pagina brengen, na een aftelling met een stopknop.'], + ['Onbewerkt delen', 'Uit ziet de AI verwijzingen en marges in plaats van namen en bedragen.'], + ] }, + { note: 'Onbewerkt delen alleen aanzetten in een portefeuille zonder echte gegevens.' }, + 'Daaronder bepalen vier schuiven hoe precies de AI de cijfers ziet: percentages, totalen, bedragen en hoeveelheden, als marge of exact.', + 'Elke aanroep staat op {AI_ACTIVITY}, met de gegevens die daarbij de app verlieten. De instellingen gelden per portefeuille en meteen, zonder herstart.', + ], + }, + interest: { + title: 'Rente-afspraken', + body: [ + 'Een rente-afspraak legt vast wat een account oplevert: percentage, ritme en looptijd. De app rekent daaruit de bijschrijvingen en zet ze klaar op {ACTIONS}.', + { list: [ + ['Hoofdsom', 'Leeg volgt het saldo van het account; dat past bij een spaarrekening. Bij een deposito hoort hier het startbedrag.'], + ['Rente op rente', 'Alleen bij een vaste hoofdsom: elke periode rekent dan ook over de eerdere bijschrijvingen. Een hoofdsom die het saldo volgt, doet dat al vanzelf.'], + ['Renteperioden', 'Verandert het percentage, leg dan een periode vast met de ingangsdatum.'], + ['Volgorde', 'Bijschrijvingen worden op volgorde geboekt: elke bijschrijving verhoogt het saldo waarover de volgende rekent.'], + ] }, + 'Een losse bijschrijving zonder afspraak kan ook, op {INTEREST}.', + ], + }, + health: { + title: 'Gezondheid: controles op de administratie', + body: [ + 'Controle uitvoeren loopt tien controles na op de administratie zelf. Dat gebeurt alleen op verzoek, omdat het even kan duren.', + { list: [ + 'Dubbele boekingen, en verplaatsingen zonder tegenkant.', + 'Balansen die niet verzoend zijn.', + 'Gaten, achterstand en verdachte koersen in de koershistorie.', + ] }, + 'Een bevinding staat als waarschijnlijk niet bedoeld of als ter kennisgeving. Is hij wel bedoeld, dan haalt Dit klopt zo hem weg; hij komt terug zodra er iets verandert.', + 'Herstellen gebeurt elders: bij de boekingen, op {ACTIONS} of op {PRICE_HISTORY}.', + ], + }, + dashboard: { + title: 'Het dashboard inrichten', + body: [ + 'Het dashboard bestaat uit widgets, zelf samen te stellen, in één of meer indelingen.', + { list: [ + ['Bewerken', 'Lang indrukken op een widget, of het knopje {icon:adjust} naast Dashboard in het menu; dat verschijnt als de muis erboven staat. Escape, een klik buiten het raster of hetzelfde knopje zet de bewerkmodus weer uit.'], + ['Toevoegen', 'In de bewerkmodus opent het lege vak het menu met widgets.'], + ['Schikken', 'Een widget verslepen, of zijn breedte aanpassen in het menu in zijn kop.'], + ['Indelingen', 'Meerdere bewaarde dashboards om tussen te wisselen. In de bewerkmodus staan ze als lijst, met knoppen voor een nieuwe indeling, een andere naam en verwijderen.'], + ] }, + 'Standaard verwijderen begint hem opnieuw, met de indeling waarmee de app begon.', + ], + }, + 'price-history': { + title: 'Prijsgeschiedenis: waar elke koers vandaan komt', + body: [ + 'Per asset een kaart met de herkomst van elke dagkoers over het afgelopen jaar. Een klik opent het venster met onder meer de herkomst, de gaten en de koersen zelf.', + { heading: 'Wat de app over een dagkoers weet, van sterk naar zwak' }, + { list: [ + ['Slotkoers', 'De koers van de bron aan het eind van die dag.'], + ['Met de hand', 'Een zelf ingevoerde koers.'], + ['Vandaag', 'De actuele koers, die nog beweegt.'], + ['Afgeleid', 'Uit een spot van de bron, geen slotkoers.'], + ['Doorgetrokken', 'Een eerdere koers, met erbij hoeveel dagen oud.'], + ['Geen koers', 'Telt als nul mee. Een bedrag waarin dat speelt, krijgt een gele i.'], + ] }, + { heading: 'Gaten' }, + 'Een gat is een reeks dagen zonder koers. Bijwerken vraagt de ontbrekende koersen op bij de bron. Een dag die geen enkele bron heeft, is te negeren of met de hand in te vullen.', + { note: 'Bij een bron met een maandlimiet kost bijwerken een verzoek per dag.' }, + ], + }, + charts: { + title: 'Signalen en grafieken', + body: [ + 'Drie signalen voor bitcoin, elk als lampje in de kolom. Groen, geel en rood zijn hier een oordeel; een klik op een lampje opent die grafiek.', + { list: [ + ['Power Law', 'De koers tegen de langetermijntrend, in log-log. De lijn is een fit op de historie, geen voorspelling; de banden erom zijn een vaste factor.'], + ['Weeks-gemiddelden', 'Het 200-weeks gemiddelde is de trage bodemzone van de cyclus. Het 50-weeks gemiddelde is de scheidslijn tussen een stijgende en een dalende markt.'], + ['Mayer Multiple', 'De koers gedeeld door het 200-daags gemiddelde. Onder 1,0 is historisch een zeldzame lage zone, boven 2,4 oververhit.'], + ] }, + 'Elke grafiek kan lineair of logaritmisch, inzoomen en op volledig scherm.', + ], + }, + conversion: { + title: 'Conversies', + body: [ + 'Een rekenblad tussen geld en de eenheden van bitcoin: BTC, mBTC, bit, sat en msat. Elk veld rekent de andere live door. Er wordt niets vastgelegd.', + ], + }, + assets: { + title: 'Assets en metalen', + body: [ + 'Een asset is iets in bezit waarvan de app een koers bijhoudt, met symbool, naam, soort, hoofdeenheid, ondereenheid en decimalen.', + { list: [ + ['Soort', 'Crypto, metaal of valuta. De soort bepaalt de standaardcategorie op {TAXES} en of Saldo vastleggen aanstaat; per asset is dat anders in te stellen.'], + ['Metaal', 'Rekent in gram. Een koers per troy ounce rekent de app om naar gram.'], + ['Munten en baren', 'Producten onder een metaal-asset, met hun fijn gewicht. Een aankoop van een product boekt het aantal stuks; het gewicht rekent de app uit.'], + ['Verwijderen', 'Kan pas als geen boeking meer naar het asset verwijst.'], + ] }, + ], + }, + accounts: { + title: 'Accounts', + body: [ + 'Een account is waar iets staat: een wallet, een exchange, een kaart, een bank, een kluis of een lightning-wallet.', + { list: [ + ['Tracking-bron', 'Een xpub of adres onder het account laat de app het saldo op de keten uitlezen.'], + ['Vermogen', 'Of het eigen vermogen is, beheerd voor een ander, of niet meetelt.'], + ['Belastingen', 'Of het in de aangifte meetelt. Staat los van Vermogen.'], + ['Afsluiten', 'Een afgesloten account verdwijnt uit de keuzelijsten, maar telt in saldi, standen en belastingen gewoon mee.'], + ['Samenvoegen', 'Twee accounts die hetzelfde zijn, worden er één.'], + ] }, + ], + }, + license: { + title: 'Gratis en volledige versie', + body: [ + 'De gratis versie is onbeperkt in tijd, met een kleine administratie: één asset, één fiatrekening, één balans en één portefeuille.', + 'Koppelingen, de bronkeuze, eigen sleutels en de belastingpagina horen bij de volledige versie. Lezen en exporteren blijven altijd werken; er wordt nooit iets weggehaald.', + 'Het licentiebestand gaat op {LICENSE} en geldt meteen. Elk slot elders in de app verwijst hierheen.', + ], + }, + portfolios: { + title: 'Portefeuilles', + body: [ + 'Elke portefeuille is een aparte administratie, met een eigen database en eigen geheimen. Er is er altijd één open.', + { list: [ + ['Rekenvaluta', 'Gekozen bij het aanmaken en daarna vast. Alle bedragen staan in die munt.'], + ['Wisselen', 'De app start daarvoor opnieuw.'], + ['Verwijderen', 'Verplaatst de portefeuille naar het archief. Pas daar kan hij definitief weg.'], + ] }, + { note: 'Een back-up bevat database en geheimen samen: alleen samen kloppen ze.' }, + ], + }, + }, + }, +}; diff --git a/shared/places.js b/shared/places.js new file mode 100644 index 0000000..9582d9e --- /dev/null +++ b/shared/places.js @@ -0,0 +1,11 @@ +// Generated, do not edit. sha256:9378a9bd304180d5b6df45747d1f56b526c6fa63df0636a0d2d53e119fdedfff +import { sharedText } from './i18n/index.js'; + +export function placeLabel(naam, code) { + const tekst = placeActions(code)[naam]; + if (!tekst) return naam; + const eind = tekst.search(/[.:]/); + return (eind > 0 ? tekst.slice(0, eind) : tekst).trim(); +} + +export const placeActions = (code) => sharedText(code).places; diff --git a/shared/price-provenance.js b/shared/price-provenance.js new file mode 100644 index 0000000..76b1324 --- /dev/null +++ b/shared/price-provenance.js @@ -0,0 +1,152 @@ +// Generated, do not edit. sha256:4249f91eb8232d410f76bd1c74b9d6d6491317d71d4cb8ae4ce4ed0b5bdfa51b +import { CONFIG } from './config.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export const PROVENANCE_KINDS = Object.freeze(['close', 'manual', 'live', 'spot', 'carried', 'none']); + +const dayOf = iso => String(iso).slice(0, 10); +const msOf = day => Date.parse(`${day}T00:00:00Z`); +const addDays = (day, n) => new Date(msOf(day) + n * DAY_MS).toISOString().slice(0, 10); + +export function dailyProvenance(rows, opts = {}) { + const { assetKind = 'crypto', manualProviderId = null } = opts; + const today = opts.today || new Date().toISOString().slice(0, 10); + const perDay = new Map(); + for (const r of rows || []) { + if (r == null || r.fiatPrice == null || !(r.fiatPrice > 0)) continue; + const day = dayOf(r.observedAt); + const cur = perDay.get(day); + perDay.set(day, cur && beats(cur, r, day === today) ? cur : r); + } + if (perDay.size === 0) return []; + const days = [...perDay.keys()].sort(); + const from = opts.from || days[0]; + const until = opts.until || today; + if (from > until) return []; + + const kindOf = (r, day) => kindFor(r, day, manualProviderId, today); + const limit = CONFIG.PRICE_HISTORY.CARRY_FORWARD_DAYS[assetKind]; + const out = []; + let last = null; + for (const d of days) if (d < from) last = { day: d, row: perDay.get(d) }; + for (let day = from; day <= until; day = addDays(day, 1)) { + const row = perDay.get(day); + if (row) { + last = { day, row }; + out.push({ day, fiatPrice:row.fiatPrice, kind: kindOf(row, day), row, ageDays: 0, deviation: null }); + continue; + } + const age = last ? Math.round((msOf(day) - msOf(last.day)) / DAY_MS) : null; + if (last && (limit == null || age <= limit)) { + out.push({ day, fiatPrice:last.row.fiatPrice, kind: 'carried', row: last.row, ageDays: age, deviation: null }); + } else { + out.push({ day, fiatPrice:null, kind: 'none', row: null, ageDays: age, deviation: null }); + } + } + addDeviations(out); + return out; +} + +function kindFor(r, day, manualProviderId, today) { + if (manualProviderId != null && r.providerId === manualProviderId) return 'manual'; + if (day === today) return 'live'; + return r.resolution !== 'timestamp' && !isProvisional(r) ? 'close' : 'spot'; +} + +export function provenanceOfRow(row, opts = {}) { + if (!row || !(row.fiatPrice > 0)) return null; + const today = opts.today || new Date().toISOString().slice(0, 10); + const voor = opts.day || today; + const dag = dayOf(row.observedAt); + const ageDays = Math.max(0, Math.round((msOf(voor) - msOf(dag)) / DAY_MS)); + const kind = ageDays > 0 ? 'carried' + : (row.derivedFrom === 'spot' && dag !== today ? 'spot' : kindFor(row, dag, opts.manualProviderId ?? null, today)); + return { day: voor, fiatPrice:row.fiatPrice, kind, row, ageDays, deviation: null }; +} + +export function provenanceOn(rows, day, opts = {}) { + const today = opts.today || new Date().toISOString().slice(0, 10); + const tot = addDays(day, 31) < today ? addDays(day, 31) : today; + const d = dailyProvenance(rows, { ...opts, today, from: addDays(day, -31), until: tot < day ? day : tot }) + .find(x => x.day === day) || null; + const used = opts.usedPrice; + if (d && d.fiatPrice == null && used != null) { + const eerder = (rows || []).filter(r => r && r.fiatPrice > 0 && dayOf(r.observedAt) <= day && gelijk(r.fiatPrice, used)) + .sort((a, b) => (a.observedAt < b.observedAt ? 1 : -1))[0]; + if (!eerder) return d; + return { day, fiatPrice:eerder.fiatPrice, kind: 'carried', row: eerder, deviation: null, + ageDays: Math.round((msOf(day) - msOf(dayOf(eerder.observedAt))) / DAY_MS) }; + } + if (!d || used == null || d.fiatPrice == null || gelijk(d.fiatPrice, used)) return d; + const bronDag = dayOf(d.row.observedAt); + const alt = (rows || []).find(r => r && r.fiatPrice > 0 && dayOf(r.observedAt) === bronDag && gelijk(r.fiatPrice, used)); + if (!alt) return d; + const kind = d.kind === 'carried' ? 'carried' : kindFor(alt, day, opts.manualProviderId ?? null, today); + return { ...d, fiatPrice:alt.fiatPrice, row: alt, kind }; +} +const gelijk = (a, b) => Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b)); + +function isProvisional(r) { + const written = r.updatedAt || r.createdAt; + return !!written && dayOf(written) <= dayOf(r.observedAt); +} + +const seenAt = r => (r.resolution === 'timestamp' ? r.observedAt : (r.updatedAt || r.createdAt || '')); +function beats(cur, cand, isToday = false) { + if (isToday) return seenAt(cur) >= seenAt(cand); + const curDay = cur.resolution !== 'timestamp', candDay = cand.resolution !== 'timestamp'; + if (curDay !== candDay) return curDay; + if (curDay) return (cur.id ?? 0) >= (cand.id ?? 0); + return cur.observedAt >= cand.observedAt; +} + +function addDeviations(out) { + const real = k => k === 'close' || k === 'manual'; + let before = null; + const pct = (v, ref) => (ref ? Math.round(((v - ref) / ref) * 10000) / 100 : null); + for (const d of out) { + if (real(d.kind)) before = d.fiatPrice; + else if (d.kind === 'spot') d.deviation = { before: pct(d.fiatPrice, before), after: null }; + } + let after = null; + for (let i = out.length - 1; i >= 0; i--) { + const d = out[i]; + if (real(d.kind)) after = d.fiatPrice; + else if (d.kind === 'spot') d.deviation.after = pct(d.fiatPrice, after); + } +} + +export function provenanceRuns(days) { + const runs = []; + for (const d of days || []) { + const last = runs[runs.length - 1]; + if (last && last.kind === d.kind) { last.until = d.day; last.days += 1; } + else runs.push({ kind: d.kind, from: d.day, until: d.day, days: 1 }); + } + return runs; +} + +export function originFrom(d, names = new Map()) { + if (!d || d.fiatPrice == null || !d.row) return null; + const r = d.row; + return { + day: d.day, kind: d.kind, fiatPrice:d.fiatPrice, ageDays: d.ageDays, deviation: d.deviation, + source: names.get(r.providerId) || null, + sourceDay: dayOf(r.observedAt), + moment: d.kind === 'spot' || d.kind === 'live' + ? (r.resolution === 'timestamp' ? r.observedAt : (r.updatedAt || r.createdAt || null)) : null, + }; +} + +export function liveOrigin(fiatPrice, source = null, now = new Date()) { + if (fiatPrice == null) return null; + const nu = now.toISOString(); + return { day: nu.slice(0, 10), kind: 'live', fiatPrice, ageDays: 0, deviation: null, source, sourceDay: null, moment: nu }; +} + +export function provenanceCounts(days) { + const counts = Object.fromEntries(PROVENANCE_KINDS.map(k => [k, 0])); + for (const d of days || []) counts[d.kind] += 1; + return counts; +} diff --git a/shared/registry.js b/shared/registry.js new file mode 100644 index 0000000..2674303 --- /dev/null +++ b/shared/registry.js @@ -0,0 +1,15 @@ +// Generated, do not edit. sha256:afb8f908930f6e36cf200b71f053da053bdc63adc393178a3cdc212216d2ca98 +export function createRegistry(name, { validate } = {}) { + const items = new Map(); + return Object.freeze({ + name, + register(entry) { + if (!entry || typeof entry.id !== 'string' || !entry.id) throw new Error(`${name}: an entry needs an id`); + if (items.has(entry.id)) throw new Error(`${name}: '${entry.id}' is registered twice`); + validate?.(entry); + items.set(entry.id, Object.freeze({ feature: null, ...entry })); + }, + get: id => items.get(id) ?? null, + list: () => [...items.values()], + }); +} diff --git a/shared/search-name.js b/shared/search-name.js new file mode 100644 index 0000000..fab5b0d --- /dev/null +++ b/shared/search-name.js @@ -0,0 +1,7 @@ +// Generated, do not edit. sha256:2f66375ae83caf2754cb28e585e1e0f78a5f72502aadfc7743433f4d0f72c85c +const DIAKRITISCH = /\p{Diacritic}/gu; + +export function toSearchName(value) { + if (value === null || value === undefined) return ''; + return String(value).normalize('NFD').replace(DIAKRITISCH, '').toLowerCase().trim(); +} diff --git a/shared/source-diagnosis.js b/shared/source-diagnosis.js new file mode 100644 index 0000000..60f91bc --- /dev/null +++ b/shared/source-diagnosis.js @@ -0,0 +1,47 @@ +// Generated, do not edit. sha256:e34176fb1b8095153c2f85bcad4807088cce9195b029b64bb8721d0f3610d90d +export const HISTORY_CAPABILITIES = Object.freeze(['daily_history', 'history_point']); + +const has = (p, cap) => Array.isArray(p.endpoints) && p.endpoints.some(e => e.capability === cap); +const covers = (p, symbol) => Array.isArray(p.coverage) && p.coverage.includes(symbol); +const deliversHistory = p => HISTORY_CAPABILITIES.some(cap => has(p, cap)); + +export function diagnoseSources(asset, { providers = [], historyChain = [], usage = {}, serviceCovers = false } = {}) { + if (!asset) return []; + const byKey = new Map(providers.map(p => [p.key, p])); + const candidates = providers.filter(p => !p.isSystem && !p.fixedRate && covers(p, asset.symbol) && deliversHistory(p)); + if (!candidates.length) return serviceCovers ? [] : [{ severity: 'problem', code: 'noCoverage' }]; + const active = candidates.filter(p => p.isActive); + if (!active.length) return serviceCovers ? [] : [{ severity: 'problem', code: 'allInactive', names: candidates.map(p => p.name) }]; + + const chosen = [], unusable = []; + for (const key of historyChain) { + const p = byKey.get(key); + if (!p) continue; + const usable = p.isActive && deliversHistory(p) && covers(p, asset.symbol); + (usable ? chosen : unusable).push(p); + } + const unique = list => [...new Map(list.map(p => [p.id, p])).values()]; + if (!chosen.length) { + if (serviceCovers) return []; + const proposal = proposalFrom(active); + return [{ severity: 'problem', code: unusable.length ? 'choiceUnusable' : 'noChoice', + names: unique(unusable).map(p => p.name), proposal }]; + } + const out = []; + const atLimit = unique(chosen).filter(p => p.callsPerMonth && (usage[p.id] ?? 0) >= p.callsPerMonth); + if (atLimit.length) { + out.push({ severity: atLimit.length === unique(chosen).length && !serviceCovers ? 'problem' : 'attention', + code: 'limitReached', names: atLimit.map(p => p.name) }); + } + const off = unique(unusable).filter(p => !p.isActive); + if (off.length) out.push({ severity: 'attention', code: 'choiceInactive', names: off.map(p => p.name) }); + return out; +} + +function proposalFrom(active) { + for (const cap of HISTORY_CAPABILITIES) { + const p = active.find(x => has(x, cap)); + if (p) return { providerKey: p.key, providerId: p.id, providerName: p.name, capability: 'history' }; + } + return null; +} diff --git a/shared/time.js b/shared/time.js new file mode 100644 index 0000000..6ed5a46 --- /dev/null +++ b/shared/time.js @@ -0,0 +1,77 @@ +// Generated, do not edit. sha256:ca177447c22d0d66351f016a9d6a55b9929893b9ec7e4207164d9f1592938144 +export const ZONE = 'Europe/Amsterdam'; + +function offsetMinutes(momentMs) { + const fmt = new Intl.DateTimeFormat('en-US', { + timeZone: ZONE, hour12: false, + year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', + }); + const d = Object.fromEntries(fmt.formatToParts(new Date(momentMs)) + .filter(p => p.type !== 'literal').map(p => [p.type, p.value])); + const uur = d.hour === '24' ? '00' : d.hour; + return (Date.UTC(+d.year, +d.month - 1, +d.day, +uur, +d.minute, +d.second) - momentMs) / 60000; +} + +export function toUtcIso(text) { + if (text === null || text === undefined || text === '') return text; + if (/Z$/.test(text)) return text; + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(text)) return text; + const alsofUtc = Date.parse(text + 'Z'); + if (Number.isNaN(alsofUtc)) throw new Error(`Onleesbaar tijdstempel: ${text}`); + const schatting = alsofUtc - offsetMinutes(alsofUtc) * 60000; + return new Date(alsofUtc - offsetMinutes(schatting) * 60000).toISOString().replace(/\.000Z$/, 'Z'); +} + +export function toLocalIso(iso) { + if (iso === null || iso === undefined || iso === '') return iso; + const ms = Date.parse(iso); + if (Number.isNaN(ms)) throw new Error(`Onleesbaar tijdstempel: ${iso}`); + return new Date(ms + offsetMinutes(ms) * 60000).toISOString() + .replace(/\.000Z$/, '').replace(/Z$/, ''); +} + +export function localDate(iso) { + const lokaal = toLocalIso(iso); + return lokaal ? String(lokaal).slice(0, 10) : ''; +} + +export function localDateNl(iso) { + return dayNl(localDate(iso)); +} + +let dateTag = 'nl-NL'; +let dateFmt = null; +export function setDateLocale(tag) { + if (tag !== dateTag) { dateTag = tag; dateFmt = null; } +} + +export function dayNl(day) { + if (!day) return ''; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(day)); + if (!m) return String(day); + dateFmt ??= new Intl.DateTimeFormat(dateTag, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }); + return dateFmt.format(Date.UTC(+m[1], +m[2] - 1, +m[3])); +} + +export function localDateTimeNl(iso) { + const lokaal = toLocalIso(iso); + return lokaal ? `${dayNl(String(lokaal).slice(0, 10))} ${String(lokaal).slice(11, 16)}` : ''; +} + +export function localTime(iso) { + const lokaal = toLocalIso(iso); + return lokaal ? String(lokaal).slice(11, 16) : ''; +} + +export function localDateTime(iso) { + const lokaal = toLocalIso(iso); + return lokaal ? `${String(lokaal).slice(0, 10)} ${String(lokaal).slice(11, 16)}` : ''; +} + +export function nowUtcIso() { + return new Date().toISOString(); +} + +export function nowLocalIso() { + return toLocalIso(new Date().toISOString()); +} diff --git a/shared/units.js b/shared/units.js new file mode 100644 index 0000000..f3b13f4 --- /dev/null +++ b/shared/units.js @@ -0,0 +1,117 @@ +// Generated, do not edit. sha256:20efe63e56f8374f20930043f99fa07bae78df1e6fc48a984d3f6364d25853bf +import { CONFIG } from './config.js'; +import { fail } from './app-error.js'; + +const M = CONFIG.METALS; + +export const isMetal = asset => !!asset && asset.kind === 'metal'; + +export function metalFactor(code) { + const f = M.UNITS[code]; + if (!f) throw new Error(`Unknown metal unit: '${code}'.`); + return f; +} + +export function metalPrimary(asset) { + return asset.displayUnit === 'subunit' && asset.subunit ? asset.subunit : asset.unit; +} +export function metalSecondary(asset) { + return asset.displayUnit === 'subunit' && asset.subunit ? asset.unit : asset.subunit; +} + +export function toBaseByFactor(value, code) { + const n = Number(value); + if (!Number.isFinite(n)) fail('common.invalidAmount', null, 'Invalid amount.'); + return Math.round(n * metalFactor(code) * Math.pow(10, M.BASE_DECIMALS)); +} +export function fromBaseByFactor(amount, code) { + return (Number(amount) / Math.pow(10, M.BASE_DECIMALS)) / metalFactor(code); +} + +export function mainToSub(main, decimals) { + return Math.round(Number(main) * Math.pow(10, decimals || 0)); +} +export function subToMain(sub, decimals) { + const d = decimals || 0; + return Number((Number(sub) / Math.pow(10, d)).toFixed(d)); +} + +export function toSubunit(value, asset) { + if (isMetal(asset)) return toBaseByFactor(value, metalPrimary(asset)); + const n = Number(value); + if (!Number.isFinite(n)) fail('common.invalidAmount', null, 'Invalid amount.'); + const dec = asset.decimals ?? 0; + return asset.displayUnit === 'subunit' ? Math.round(n) : mainToSub(n, dec); +} + +export function fromSubunit(amount, asset) { + if (isMetal(asset)) return fromBaseByFactor(amount, metalPrimary(asset)); + const dec = asset.decimals ?? 0; + if (asset.displayUnit === 'subunit') return amount; + return subToMain(amount, dec); +} + +export function unitToSubunit(value, asset) { + if (isMetal(asset)) return toBaseByFactor(value, asset.unit); + const n = Number(value); + if (!Number.isFinite(n)) fail('common.invalidAmount', null, 'Invalid amount.'); + return mainToSub(n, asset.decimals ?? 0); +} + +export function priceToBase(price, asset) { + const n = Number(price); + return isMetal(asset) ? n / metalFactor(metalPrimary(asset)) : n; +} +export function priceFromBase(stored, asset) { + const n = Number(stored); + return isMetal(asset) ? n * metalFactor(metalPrimary(asset)) : n; +} + +export function unitPriceToBase(price, asset) { + const n = Number(price); + if (!Number.isFinite(n)) throw new Error('Invalid price.'); + return isMetal(asset) ? n / metalFactor(asset.unit) : n; +} + +function getal(v) { + if (v === null || v === undefined) return null; + if (typeof v === 'string' && v.trim() === '') return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +function feeSigned(fee, type) { + const f = getal(fee) ?? 0; + return type === 'sell' ? -f : f; +} + +export function totalFromPrice(amountBase, decimals, priceBase, fee = 0, type = 'buy') { + const a = getal(amountBase), p = getal(priceBase); + if (a === null || p === null) return null; + return (a / Math.pow(10, decimals ?? 0)) * p + feeSigned(fee, type); +} + +export function priceFromTotal(total, amountBase, decimals, fee = 0, type = 'buy') { + const tot = getal(total), a = getal(amountBase); + if (tot === null || a === null) return null; + const eenheden = a / Math.pow(10, decimals ?? 0); + const netto = tot - feeSigned(fee, type); + if (netto < 0) return null; + return eenheden > 0 ? netto / eenheden : null; +} + +export function amountFromTotal(total, priceBase, decimals, fee = 0, type = 'buy') { + const tot = getal(total), p = getal(priceBase); + if (tot === null || p === null || p <= 0) return null; + const netto = tot - feeSigned(fee, type); + if (netto < 0) return null; + return (netto / p) * Math.pow(10, decimals ?? 0); +} + +export function priceUnitLabel(asset) { + return isMetal(asset) ? metalPrimary(asset) : asset?.unit; +} + +export function hasSubunit(asset) { + return !!(asset && typeof asset.subunit === 'string' && asset.subunit.trim() !== ''); +} diff --git a/shared/wealth-metrics.js b/shared/wealth-metrics.js new file mode 100644 index 0000000..933afde --- /dev/null +++ b/shared/wealth-metrics.js @@ -0,0 +1,53 @@ +// Generated, do not edit. sha256:d8050520cd6294fe603e71def64a9f6a61433bedfd9e055fc63f536c99905b1a +export function assetMetrics(positie, price) { + const div = Math.pow(10, positie.decimals); + const units = positie.net / div; + const value = price != null ? units * price : null; + const avgPrice = positie.buy_amount ? positie.buy_spot_raw / positie.buy_amount : null; + const avgCost = avgCostOf(positie); + const paidUnits = paidUnitsOf(positie); + const costAtAvg = avgCost != null ? paidUnits * avgCost : (paidUnits > 0 ? null : 0); + return { + units, + value, + avgPrice, + avgCost, + unrealized: (value != null && costAtAvg != null) ? value - costAtAvg : null, + avgPremium: positie.buy_events ? positie.buy_fee_total / positie.buy_events : null, + invested: (positie.buy_spot_raw / div) + positie.buy_fee_total, + }; +} + +export function saleResult(positie, sale) { + const div = Math.pow(10, positie.decimals); + const verkocht = (Number(sale.amount) || 0) / div; + const opbrengst = verkocht * (Number(sale.fiatPrice) || 0) - (Number(sale.fiatFee) || 0); + const weg = verkocht + (Number(sale.unitFee) || 0) / div; + const kostEenheden = Math.min(weg, paidUnitsOf(positie)); + const avgCost = avgCostOf(positie); + if (avgCost == null) return kostEenheden > 0 ? null : opbrengst; + return opbrengst - kostEenheden * avgCost; +} + +export function feeResult(positie, unitFee) { + return saleResult(positie, { amount: 0, fiatPrice: 0, fiatFee: 0, unitFee }); +} + +export function combinePositions(rows) { + const som = { decimals: rows[0]?.decimals ?? 0, net: 0, buy_amount: 0, buy_spot_raw: 0, buy_fee_total: 0, buy_events: 0, gift_amount: 0 }; + for (const r of rows) { + for (const k of ['net', 'buy_amount', 'buy_spot_raw', 'buy_fee_total', 'buy_events', 'gift_amount']) som[k] += Number(r[k]) || 0; + } + return som; +} + +function avgCostOf(positie) { + const div = Math.pow(10, positie.decimals); + return positie.buy_amount + ? (positie.buy_spot_raw + (positie.buy_fee_total || 0) * div) / positie.buy_amount : null; +} + +function paidUnitsOf(positie) { + const div = Math.pow(10, positie.decimals); + return Math.max(0, positie.net / div - (positie.gift_amount || 0) / div); +} diff --git a/shared/wealth-scope.js b/shared/wealth-scope.js new file mode 100644 index 0000000..ae4e0d8 --- /dev/null +++ b/shared/wealth-scope.js @@ -0,0 +1,4 @@ +// Generated, do not edit. sha256:ee03a242f108848e1606070a4714dcb411b1cda3ff0ab24df06aa28fabc71f72 +export function isOwnWealth(account) { + return !account || account.wealthScope == null; +} diff --git a/shared/zap.js b/shared/zap.js new file mode 100644 index 0000000..33edc17 --- /dev/null +++ b/shared/zap.js @@ -0,0 +1,87 @@ +// Generated, do not edit. sha256:e8c40c7f89911d6db87ea8c48a3cd675bb64d66a36f01a1148bfcf8fc28f29e5 +export const MEMO_MAX = 2000; + +const HEX64 = /^[0-9a-f]{64}$/; +const tag = (tags, name) => (Array.isArray(tags) ? tags.find(t => Array.isArray(t) && t[0] === name)?.[1] : undefined); + +function zapRequest(memo) { + let ev = memo; + if (typeof memo === 'string') { + const s = memo.trim(); + if (!s.startsWith('{')) return null; + try { ev = JSON.parse(s); } catch { return null; } + } + if (!ev || typeof ev !== 'object' || Number(ev.kind) !== 9734) return null; + return ev; +} + +export function zapContext(memo) { + const ev = zapRequest(memo); + if (!ev) return null; + const hex = v => (typeof v === 'string' && HEX64.test(v.toLowerCase()) ? v.toLowerCase() : null); + const message = typeof ev.content === 'string' ? ev.content.trim() : ''; + return { sender: hex(ev.pubkey), recipient: hex(tag(ev.tags, 'p')), message, note: hex(tag(ev.tags, 'e')) }; +} + +export function compactMemo(memo) { + const ev = zapRequest(memo); + if (ev) { + const tags = (Array.isArray(ev.tags) ? ev.tags : []).filter(t => Array.isArray(t) && (t[0] === 'p' || t[0] === 'e')) + .map(t => [t[0], String(t[1] ?? '')]); + const klein = { kind: 9734, pubkey: String(ev.pubkey ?? ''), content: String(ev.content ?? '').slice(0, 1000), tags }; + return JSON.stringify(klein).slice(0, MEMO_MAX); + } + if (typeof memo !== 'string') return null; + const s = memo.trim(); + return s ? s.slice(0, MEMO_MAX) : null; +} + +const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; +function polymod(values) { + const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + let chk = 1; + for (const v of values) { + const top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (let i = 0; i < 5; i++) if ((top >> i) & 1) chk ^= GEN[i]; + } + return chk; +} +function toWords(bytes) { + const out = []; + let acc = 0, bits = 0; + for (const b of bytes) { + acc = (acc << 8) | b; + bits += 8; + while (bits >= 5) { bits -= 5; out.push((acc >> bits) & 31); } + } + if (bits > 0) out.push((acc << (5 - bits)) & 31); + return out; +} + +export function npub(hex) { + if (typeof hex !== 'string' || !HEX64.test(hex)) return null; + const bytes = hex.match(/../g).map(h => parseInt(h, 16)); + const hrp = 'npub'; + const data = toWords(bytes); + const expand = [...[...hrp].map(c => c.charCodeAt(0) >> 5), 0, ...[...hrp].map(c => c.charCodeAt(0) & 31)]; + const mod = polymod([...expand, ...data, 0, 0, 0, 0, 0, 0]) ^ 1; + const checksum = Array.from({ length: 6 }, (_, i) => (mod >> (5 * (5 - i))) & 31); + return `${hrp}1${[...data, ...checksum].map(w => CHARSET[w]).join('')}`; +} + +export function npubShort(hex) { + const n = npub(hex); + return n ? `${n.slice(0, 9)}…${n.slice(-4)}` : null; +} + +export function memoNote(memo, direction) { + const z = zapContext(memo); + if (z) { + const wie = npubShort(direction === 'in' ? z.sender : z.recipient); + const kop = `zap ${direction === 'in' ? 'from' : 'to'}${wie ? ` ${wie}` : ''}`; + return z.message ? `${kop}: "${z.message}"` : kop; + } + const s = compactMemo(memo); + return s ? s.slice(0, 200) : null; +} diff --git a/tests/db/baseline.test.mjs b/tests/db/baseline.test.mjs new file mode 100644 index 0000000..1d0a72c --- /dev/null +++ b/tests/db/baseline.test.mjs @@ -0,0 +1,627 @@ +// Generated, do not edit. sha256:ae56ded82e34b23f4f7847c4b9b685f528acb7506919a3b47bd8b4b374a57bed +import { describe, it, before } from 'node:test'; +import assert from 'node:assert/strict'; + +import { SCHEMA as BASELINE_SCHEMA } from '../../server/db/schema.js'; +import { openBaselineDb, foreignKeysAan, vulFixture, FIXTURE_TABELLEN } from '../helpers/baseline-db.mjs'; +import { CONFIG, ENUMS, CAPABILITIES, SOURCE_CAPABILITIES, AUTH_KINDS, OUTPUT_FIELDS, TRANSFORMS, HTTP_METHODS } from '../../shared/config.js'; + +let db, TABELLEN, KOLOMMEN, INDEXEN, FKS; + +before(() => { + db = openBaselineDb(); + TABELLEN = db.prepare( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name` + ).all().map(r => r.name); + + KOLOMMEN = new Map(); + INDEXEN = new Map(); + FKS = new Map(); + for (const t of TABELLEN) { + KOLOMMEN.set(t, db.prepare(`PRAGMA table_info(${t})`).all()); + FKS.set(t, db.prepare(`PRAGMA foreign_key_list(${t})`).all()); + INDEXEN.set(t, db.prepare(`PRAGMA index_list(${t})`).all().map(ix => ({ + ...ix, + kolommen: db.prepare(`PRAGMA index_info(${ix.name})`).all().map(k => k.name), + }))); + } +}); + +const woorden = (naam) => naam.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(' '); + +describe('baseline: het schema laadt en is compleet', () => { + it('maakt achtendertig tabellen aan', () => { + assert.equal(TABELLEN.length, 45, `gevonden: ${TABELLEN.join(', ')}`); + }); + + it('heeft voor elke tabel een fixture-rij', () => { + assert.deepEqual([...FIXTURE_TABELLEN].sort(), [...TABELLEN].sort()); + }); + + it('noemt elke tabel in de data-viewer, en geen tabel die niet bestaat', () => { + assert.deepEqual([...CONFIG.DB_TABLES].sort(), [...TABELLEN].sort()); + }); + + it('kan elke tabel vullen', () => { + const eigen = openBaselineDb(); + vulFixture(eigen); + for (const t of TABELLEN) { + assert.equal(eigen.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n, 1, `${t} is leeg`); + } + }); +}); + +describe('baseline: de naamconventie', () => { + const MEERVOUD = /(?:s|ies|es)$/i; + const MEERVOUD_UITZONDERING = new Set(['address', 'status']); + + const GERESERVEERD = new Set(['abort', 'action', 'add', 'after', 'all', 'alter', 'and', 'as', 'asc', + 'attach', 'autoincrement', 'before', 'begin', 'between', 'by', 'cascade', 'case', 'cast', 'check', + 'collate', 'column', 'commit', 'conflict', 'constraint', 'create', 'cross', 'default', 'deferrable', + 'deferred', 'delete', 'desc', 'detach', 'distinct', 'drop', 'each', 'else', 'end', 'escape', 'except', + 'exclusive', 'exists', 'explain', 'fail', 'for', 'foreign', 'from', 'full', 'group', 'having', 'if', + 'ignore', 'immediate', 'in', 'index', 'indexed', 'initially', 'inner', 'insert', 'instead', 'intersect', + 'into', 'is', 'isnull', 'join', 'left', 'like', 'limit', 'match', 'natural', 'no', 'not', 'notnull', + 'null', 'of', 'offset', 'on', 'or', 'order', 'outer', 'plan', 'pragma', 'primary', 'query', 'raise', + 'references', 'reindex', 'release', 'rename', 'replace', 'restrict', 'right', 'rollback', 'row', + 'savepoint', 'select', 'set', 'table', 'temp', 'temporary', 'then', 'to', 'transaction', 'trigger', + 'union', 'unique', 'update', 'using', 'vacuum', 'values', 'view', 'virtual', 'when', 'where']); + + const NEDERLANDS = new Set(['naam', 'datum', 'bedrag', 'waarde', 'soort', 'aantal', 'bron', 'rekening', + 'saldo', 'koers', 'prijs', 'weergave', 'gebruiker', 'instelling', 'actief', 'verwijderd', + 'omschrijving', 'hoeveelheid']); + + it('geeft elke tabel een PascalCase-enkelvoud zonder underscore', () => { + const fout = TABELLEN.filter(t => !/^[A-Z][A-Za-z0-9]*$/.test(t)); + assert.deepEqual(fout, [], 'tabelnaam buiten het patroon ^[A-Z][A-Za-z0-9]*$'); + }); + + it('gebruikt nergens een meervoud als tabelnaam', () => { + const fout = TABELLEN.filter(t => { + const laatste = woorden(t).at(-1); + return MEERVOUD.test(laatste) && !MEERVOUD_UITZONDERING.has(laatste); + }); + assert.deepEqual(fout, [], 'de tabel heet naar de rij die hij bevat, dus enkelvoud'); + }); + + it('geeft elke kolom een camelCase-naam zonder underscore', () => { + const fout = []; + for (const t of TABELLEN) { + for (const k of KOLOMMEN.get(t)) { + if (!/^[a-z][A-Za-z0-9]*$/.test(k.name)) fout.push(`${t}.${k.name}`); + } + } + assert.deepEqual(fout, [], 'kolomnaam buiten het patroon ^[a-z][A-Za-z0-9]*$'); + }); + + it('geeft elke zelfgemaakte index een Ix- of Ux-naam', () => { + const fout = []; + for (const t of TABELLEN) { + for (const ix of INDEXEN.get(t)) { + if (ix.origin !== 'c') continue; + if (!/^(Ix|Ux)[A-Z][A-Za-z0-9]*$/.test(ix.name)) fout.push(`${t}: ${ix.name}`); + if (ix.unique && !ix.name.startsWith('Ux')) fout.push(`${t}: ${ix.name} is uniek maar heet Ix`); + if (!ix.unique && !ix.name.startsWith('Ix')) fout.push(`${t}: ${ix.name} is niet uniek maar heet Ux`); + } + } + assert.deepEqual(fout, [], 'indexnaam buiten Ix/Ux + PascalCase'); + }); + + it('geeft elke trigger een Tr-naam met zijn tabel erin', () => { + const fout = db.prepare(`SELECT name, tbl_name FROM sqlite_master WHERE type = 'trigger'`).all() + .filter(r => !new RegExp(`^Tr${r.tbl_name}[A-Z][A-Za-z0-9]*$`).test(r.name)) + .map(r => `${r.tbl_name}: ${r.name}`); + assert.deepEqual(fout, [], 'triggernaam buiten Tr + tabel + PascalCase'); + }); + + it('gebruikt geen gereserveerd SQLite-woord als identifier', () => { + const fout = []; + for (const t of TABELLEN) { + if (GERESERVEERD.has(t.toLowerCase())) fout.push(t); + for (const k of KOLOMMEN.get(t)) { + if (GERESERVEERD.has(k.name.toLowerCase())) fout.push(`${t}.${k.name}`); + } + } + assert.deepEqual(fout, [], 'een gereserveerd woord zou quoting afdwingen, en niets wordt gequote'); + }); + + it('gebruikt nergens een Nederlands woorddeel in een identifier', () => { + const fout = []; + for (const t of TABELLEN) { + for (const w of woorden(t)) if (NEDERLANDS.has(w)) fout.push(`${t} (${w})`); + for (const k of KOLOMMEN.get(t)) { + for (const w of woorden(k.name)) if (NEDERLANDS.has(w)) fout.push(`${t}.${k.name} (${w})`); + } + } + assert.deepEqual(fout, [], 'identifiers zijn Engels; de wáárden mogen Nederlands zijn'); + }); + + it('herhaalt de naam van de tabel niet in een kolom', () => { + const UITZONDERING = new Set([ + 'WalletAddress.address', + 'AiToolCall.tool', + 'PriceHistory.fiatPrice', + ]); + const fout = []; + for (const t of TABELLEN) { + const fkKolommen = new Set(FKS.get(t).map(f => f.from)); + const tabelWoorden = new Set(woorden(t)); + for (const k of KOLOMMEN.get(t)) { + if (fkKolommen.has(k.name) || UITZONDERING.has(`${t}.${k.name}`)) continue; + const overlap = woorden(k.name).filter(w => tabelWoorden.has(w)); + if (overlap.length) fout.push(`${t}.${k.name} (herhaalt '${overlap.join("','")}')`); + } + } + assert.deepEqual(fout, [], 'de gekwalificeerde naam Tabel.kolom is de identifier; de tabelnaam hoort er niet nog eens in'); + }); + + it('quotet geen enkele identifier in de DDL', () => { + const zonderCommentaar = BASELINE_SCHEMA.replace(/--[^\n]*/g, '').replace(/'[^']*'/g, "''"); + assert.equal(/"[A-Za-z_]/.test(zonderCommentaar), false, 'dubbele aanhalingstekens rond een identifier'); + assert.equal(/\[[A-Za-z_]/.test(zonderCommentaar), false, 'blokhaken rond een identifier'); + }); + + it('gebruikt geen DEFAULT in de DDL', () => { + for (const t of TABELLEN) { + for (const k of KOLOMMEN.get(t)) { + assert.equal(k.dflt_value, null, `${t}.${k.name} heeft een DEFAULT`); + } + } + }); +}); + +describe('baseline: de kolomvolgorde', () => { + it('zet id vooraan', () => { + const fout = []; + for (const t of TABELLEN) { + const kolommen = KOLOMMEN.get(t); + const idIndex = kolommen.findIndex(k => k.name === 'id'); + if (idIndex > 0) fout.push(`${t} (id op positie ${idIndex})`); + } + assert.deepEqual(fout, [], 'id hoort de eerste kolom te zijn'); + }); + + it('zet geen FK-kolom ná een waardekolom', () => { + const LIJM = new Set(['Event.productCount']); + const fout = []; + for (const t of TABELLEN) { + const kolommen = KOLOMMEN.get(t).map(k => k.name); + const fkKolommen = new Set(FKS.get(t).map(f => f.from)); + if (!fkKolommen.size) continue; + const laatsteFk = kolommen.reduce((acc, naam, i) => (fkKolommen.has(naam) ? i : acc), -1); + for (let i = 0; i < laatsteFk; i++) { + const naam = kolommen[i]; + if (fkKolommen.has(naam) || naam === 'id' || LIJM.has(`${t}.${naam}`)) continue; + fout.push(`${t}: ${naam} staat vóór FK-kolom ${kolommen[laatsteFk]}`); + } + } + assert.deepEqual(fout, [], 'verwijzingen horen als blok direct achter id (§6.7 groep 2)'); + }); + + it('zet createdAt en updatedAt achteraan', () => { + const fout = []; + for (const t of TABELLEN) { + const kolommen = KOLOMMEN.get(t).map(k => k.name); + const heeftCreated = kolommen.includes('createdAt'); + const heeftUpdated = kolommen.includes('updatedAt'); + if (heeftCreated && heeftUpdated) { + if (kolommen.at(-2) !== 'createdAt' || kolommen.at(-1) !== 'updatedAt') { + fout.push(`${t}: verwachtte …, createdAt, updatedAt`); + } + } else if (heeftCreated && kolommen.at(-1) !== 'createdAt') { + fout.push(`${t}: createdAt staat niet achteraan`); + } else if (heeftUpdated && kolommen.at(-1) !== 'updatedAt') { + fout.push(`${t}: updatedAt staat niet achteraan`); + } + } + assert.deepEqual(fout, [], 'de boekhoud-tijdstempels staan altijd op het eind (§6.7 groep 7)'); + }); +}); + +describe('baseline: STRICT dwingt types af', () => { + it('zet STRICT op elke tabel', () => { + const zonder = TABELLEN.filter(t => { + const sql = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name = ?`).get(t).sql; + return !/\)\s*STRICT\s*$/i.test(sql.trim()); + }); + assert.deepEqual(zonder, [], 'zonder STRICT accepteert SQLite tekst in een INTEGER-kolom'); + }); + + it('weigert tekst in een INTEGER-kolom', () => { + const eigen = openBaselineDb(); + vulFixture(eigen); + assert.throws( + () => eigen.exec(`UPDATE Asset SET decimals = 'acht' WHERE id = 1`), + /cannot store TEXT value in INTEGER column/, + 'dit is precies wat een niet-STRICT tabel wél zou slikken'); + }); + + it('houdt NOT NULL overeind', () => { + const eigen = openBaselineDb(); + vulFixture(eigen); + assert.throws(() => eigen.exec(`UPDATE Asset SET symbol = NULL WHERE id = 1`), /NOT NULL/); + }); +}); + +describe('baseline: FK-handhaving', () => { + it('staat aan op een verbinding die via de helper is geopend', () => { + assert.equal(foreignKeysAan(openBaselineDb()), true); + }); + + it('weigert een kindrij zonder ouder', () => { + const eigen = openBaselineDb(); + vulFixture(eigen); + assert.throws(() => eigen.exec(`INSERT INTO MetalProduct (assetId, name, searchName, fineGrams, form) + VALUES (999,'Wees','wees',1,'bar')`), + /FOREIGN KEY constraint failed/); + }); + + it('oefent élke FK één keer echt uit', () => { + const MEE = { 'WalletSource.connectionId': `, kind = 'lnd', scriptType = NULL` }; + const eigen = openBaselineDb(); + vulFixture(eigen); + let geteld = 0; + for (const t of TABELLEN) { + for (const fk of FKS.get(t)) { + geteld++; + let melding = ''; + try { + eigen.exec(`UPDATE ${t} SET ${fk.from} = 999999${MEE[`${t}.${fk.from}`] ?? ''} + WHERE rowid = (SELECT MIN(rowid) FROM ${t})`); + } catch (e) { + melding = e.message; + } + assert.match(melding, /FOREIGN KEY constraint failed/, + `${t}.${fk.from} → ${fk.table}(${fk.to}) gaf "${melding || 'geen fout'}". ` + + `Een mismatch betekent dat de ouderzijde geen PK of UNIQUE is.`); + } + } + assert.ok(geteld >= 35, `verwacht tientallen FK's, gevonden ${geteld}`); + }); + + it('geeft elke FK-kolom een bruikbare index', () => { + const fout = []; + for (const t of TABELLEN) { + const kolommen = KOLOMMEN.get(t); + for (const fk of FKS.get(t)) { + const isRowidPk = kolommen.some(k => k.name === fk.from && k.pk === 1 && k.type === 'INTEGER'); + const gedekt = isRowidPk || INDEXEN.get(t).some(ix => !ix.partial && ix.kolommen[0] === fk.from); + if (!gedekt) fout.push(`${t}.${fk.from}`); + } + } + assert.deepEqual(fout, [], 'FK-kolom zonder index: elke ouder-delete wordt hier een volledige scan'); + }); + + it('legt bij elke FK expliciet vast wat er bij verwijderen gebeurt', () => { + const TOEGESTAAN = new Set(['CASCADE', 'RESTRICT', 'SET NULL', 'NO ACTION']); + const fout = []; + for (const t of TABELLEN) { + for (const fk of FKS.get(t)) { + if (!TOEGESTAAN.has(fk.on_delete)) fout.push(`${t}.${fk.from}: ${fk.on_delete}`); + const inDdl = new RegExp(`REFERENCES ${fk.table}\\(${fk.to}\\) ON DELETE`, 'i'); + if (!inDdl.test(BASELINE_SCHEMA)) fout.push(`${t}.${fk.from}: geen expliciete ON DELETE in de DDL`); + } + } + assert.deepEqual(fout, []); + }); + + it('ruimt bij CASCADE de kinddata op, en weigert bij NO ACTION', () => { + const eigen = openBaselineDb(); + vulFixture(eigen); + assert.throws(() => eigen.exec(`DELETE FROM Account WHERE id = 1`), /FOREIGN KEY constraint failed/); + eigen.exec(`DELETE FROM WalletTransactionEventLink`); + eigen.exec(`DELETE FROM InterestCredit`); + eigen.exec(`DELETE FROM ReconcileProposal`); + eigen.exec(`DELETE FROM Event`); + eigen.exec(`DELETE FROM Valuation`); + eigen.exec(`DELETE FROM Account WHERE id = 1`); + for (const t of ['WalletSource', 'WalletAddress', 'WalletTransaction', 'ScanLog', 'InterestSchedule']) { + assert.equal(eigen.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n, 0, `${t} had mee moeten cascaden`); + } + assert.equal(eigen.prepare(`SELECT accountId FROM ImportBatch`).get().accountId, null); + }); + + it('weigert een provider te verwijderen die nog in gebruik is (RESTRICT)', () => { + const eigen = openBaselineDb(); + vulFixture(eigen); + assert.throws(() => eigen.exec(`DELETE FROM Provider WHERE id = 1`), /FOREIGN KEY constraint failed/); + }); + + it('laat een asset-cascade niet stuklopen op een NO ACTION eronder', () => { + const eigen = openBaselineDb(); + vulFixture(eigen); + eigen.exec(`DELETE FROM WalletTransactionEventLink`); + eigen.exec(`DELETE FROM InterestCredit`); + eigen.exec(`DELETE FROM InterestSchedule`); + eigen.exec(`UPDATE Event SET fromAccountId = NULL, toAccountId = NULL`); + eigen.exec(`UPDATE Valuation SET accountId = NULL`); + eigen.exec(`DELETE FROM PriceSource`); + eigen.exec(`DELETE FROM PriceHistory`); + eigen.exec(`DELETE FROM Asset WHERE id = 1`); + assert.equal(eigen.prepare(`SELECT COUNT(*) AS n FROM Event`).get().n, 0); + assert.equal(eigen.prepare(`SELECT COUNT(*) AS n FROM MetalProduct`).get().n, 0); + }); +}); + +describe('baseline: de voorwaardelijke CHECKs (§6.12 gebrek 4)', () => { + let eigen; + before(() => { eigen = openBaselineDb(); vulFixture(eigen); }); + + it('laat een variable-renteafspraak geen hoofdsom dragen', () => { + assert.throws(() => eigen.exec(`UPDATE InterestSchedule SET rateKind = 'variable' WHERE id = 1`), /CHECK/); + }); + + it('laat een weekdag alleen bij een weekritme toe', () => { + assert.throws(() => eigen.exec(`UPDATE InterestSchedule SET creditCadence = 'monthly' WHERE id = 1`), /CHECK/); + }); + + it('laat een maanddag alleen bij een maandritme toe', () => { + assert.throws(() => eigen.exec(`UPDATE InterestSchedule SET creditDayOfMonth = 3 WHERE id = 1`), /CHECK/); + }); + + it('laat een einddatum niet vóór de startdatum liggen', () => { + assert.throws(() => eigen.exec(`UPDATE InterestSchedule SET endsOn = '2020-01-01' WHERE id = 1`), /CHECK/); + }); + + it('laat rente op rente alleen toe op een vaste hoofdsom in het uitkeringsasset', () => { + assert.doesNotThrow(() => eigen.exec(`UPDATE InterestSchedule SET compounding = 1 WHERE id = 1`)); + assert.throws(() => eigen.exec(`UPDATE InterestSchedule SET principalAmount = NULL WHERE id = 1`), /CHECK/); + eigen.exec(`UPDATE InterestSchedule SET compounding = 0 WHERE id = 1`); + }); + + it('houdt het productpaar van een event bij elkaar', () => { + assert.throws(() => eigen.exec(`UPDATE Event SET productCount = NULL WHERE id = 1`), /CHECK/); + }); + + it('eist een prijs bij een handel', () => { + assert.throws(() => eigen.exec(`UPDATE Event SET fiatPrice = NULL WHERE id = 1`), /CHECK/); + }); + + it('laat geen negatief geld toe op de drie geldkolommen van een event', () => { + for (const kolom of ['fiatPrice', 'fiatFee', 'unitFee']) { + assert.throws(() => eigen.exec(`UPDATE Event SET ${kolom} = -1 WHERE id = 1`), /CHECK/, + `${kolom} hoort niet negatief te kunnen`); + } + }); + + it('laat nul wél toe op die drie, want gratis verkregen is geen fout', () => { + const voor = eigen.prepare(`SELECT fiatFee, unitFee FROM Event WHERE id = 1`).get(); + assert.doesNotThrow(() => eigen.exec(`UPDATE Event SET fiatFee = 0, unitFee = 0 WHERE id = 1`)); + eigen.prepare(`UPDATE Event SET fiatFee = ?, unitFee = ? WHERE id = 1`).run(voor.fiatFee, voor.unitFee); + }); + + it('houdt de bovengrens van decimals in de DDL gelijk aan de config', () => { + const sql = eigen.prepare(`SELECT sql FROM sqlite_master WHERE name='Asset'`).get().sql; + assert.match(sql, new RegExp(`decimals <= ${CONFIG.MAX_ASSET_DECIMALS}\\b`)); + }); + + it('laat een asset niet meer decimalen dragen dan de opslag kan teruglezen', () => { + const voor = eigen.prepare(`SELECT decimals FROM Asset WHERE id = 1`).get().decimals; + assert.throws(() => eigen.exec(`UPDATE Asset SET decimals = 18 WHERE id = 1`), /CHECK/); + assert.doesNotThrow(() => eigen.exec(`UPDATE Asset SET decimals = 9 WHERE id = 1`)); + eigen.prepare(`UPDATE Asset SET decimals = ? WHERE id = 1`).run(voor); + }); + + it('laat een scriptType alleen bij een xpub-bron toe', () => { + assert.throws(() => eigen.exec(`UPDATE WalletSource SET kind = 'address' WHERE id = 1`), /CHECK/); + }); + + it('eist een verbinding bij een lnd-bron, staat hem toe op een on-chain bron en verbiedt hem bij nwc', () => { + assert.doesNotThrow(() => eigen.exec(`UPDATE WalletSource SET connectionId = 1 WHERE id = 1`)); + const tweede = openBaselineDb(); + vulFixture(tweede); + assert.throws(() => tweede.exec(`UPDATE WalletSource SET kind = 'lnd', scriptType = NULL WHERE id = 1`), /CHECK/); + assert.throws(() => tweede.exec(`UPDATE WalletSource SET kind = 'nwc', scriptType = NULL, connectionId = 1 WHERE id = 1`), /CHECK/); + }); + + it('laat een account niet tegelijk actief en afgesloten zijn', () => { + assert.throws(() => eigen.exec(`UPDATE Account SET isActive = 0 WHERE id = 1`), /CHECK/, + 'op non-actief zetten zonder closedAt hoort niet te kunnen'); + assert.throws(() => eigen.exec(`UPDATE Account SET closedAt = '2026-01-01T00:00:00Z' WHERE id = 1`), /CHECK/, + 'een afsluitdatum op een actief account evenmin'); + }); + + it('weigert weergave in een ondereenheid die niet bestaat', () => { + assert.throws(() => eigen.exec(`UPDATE Asset SET subunit = NULL WHERE id = 1`), /CHECK/); + }); + + it('weigert een prijsrij zonder koers', () => { + assert.throws(() => eigen.exec(`UPDATE PriceHistory SET fiatPrice = NULL WHERE id = 1`), /NOT NULL/); + }); + + it('houdt blokhoogte en blokstijd bij elkaar', () => { + assert.throws(() => eigen.exec(`UPDATE WalletTransaction SET blockTime = NULL WHERE id = 1`), /CHECK/); + }); + + it('houdt de verbindingsuitslag en zijn moment bij elkaar', () => { + assert.throws(() => eigen.exec(`UPDATE Connection SET lastStatus = 'ok' WHERE id = 1`), /CHECK/); + }); + + it('laat maar één versierij toe', () => { + assert.throws(() => eigen.exec(`INSERT INTO Schema (version) VALUES (61)`), /CHECK/); + }); + + it('laat de rekenvaluta niet wijzigen of weghalen', () => { + assert.throws(() => eigen.exec(`UPDATE Portfolio SET baseCurrency = 'USD'`), /fixed after creation/); + assert.throws(() => eigen.exec(`UPDATE Portfolio SET createdAt = NULL`), /fixed after creation/); + assert.throws(() => eigen.exec(`DELETE FROM Portfolio`), /fixed after creation/); + assert.equal(eigen.prepare(`SELECT baseCurrency FROM Portfolio`).get().baseCurrency, 'EUR'); + }); + + it('laat maar één portefeuillerij toe', () => { + assert.throws(() => eigen.exec(`INSERT INTO Portfolio (id, baseCurrency) VALUES (2, 'USD')`), /CHECK/); + }); + + it('eist een muntcode van drie hoofdletters', () => { + for (const code of ['eur', 'EU', 'EURO', 'E1R', '']) { + const leeg = openBaselineDb(); + assert.throws(() => leeg.prepare(`INSERT INTO Portfolio (id, baseCurrency) VALUES (1, ?)`).run(code), /CHECK/, + `'${code}' hoort geweigerd te worden`); + } + }); + + it('weigert een chain-index buiten 0 en 1', () => { + assert.throws(() => eigen.exec(`UPDATE WalletAddress SET chainIndex = 2 WHERE id = 1`), /CHECK/); + }); +}); + +describe('baseline: unieke sleutels', () => { + let eigen; + before(() => { eigen = openBaselineDb(); vulFixture(eigen); }); + + it('Asset: symbol is uniek, en dat was het in het oude schema NIET', () => { + assert.throws(() => eigen.exec(`INSERT INTO Asset (symbol, name, searchName, unit, displayUnit, decimals, kind) + VALUES ('BTC','Bitcoin nogmaals','bitcoin nogmaals','BTC','unit',8,'crypto')`), /UNIQUE/); + }); + + it('Label: de genormaliseerde naam is uniek, dus Werk en werk botsen', () => { + assert.throws(() => eigen.exec(`INSERT INTO Label (name, searchName) VALUES ('WERK','werk')`), /UNIQUE/); + }); + + it('MetalProduct: dezelfde naam mag bij een ánder asset', () => { + eigen.exec(`INSERT INTO Asset (symbol, name, searchName, unit, displayUnit, decimals, kind) + VALUES ('XAG','Zilver','zilver','ozt','unit',7,'metal')`); + const zilver = eigen.prepare(`SELECT id FROM Asset WHERE symbol='XAG'`).get().id; + eigen.exec(`INSERT INTO MetalProduct (assetId, name, searchName, fineGrams, form) + VALUES (${zilver},'Baar 1 kg','baar 1 kg',10000000000,'bar')`); + assert.throws(() => eigen.exec(`INSERT INTO MetalProduct (assetId, name, searchName, fineGrams, form) + VALUES (${zilver},'BAAR 1 KG','baar 1 kg',1,'bar')`), /UNIQUE/); + }); + + it('WalletTransaction: on-chain is (account, txid) uniek, en onderbouwt de idempotente scan-upsert', () => { + assert.throws(() => eigen.exec(`INSERT INTO WalletTransaction (accountId, layer, txid, valueSats, direction, createdAt) + VALUES (1,'onchain','aabbcc',1,'in','2026-01-01T00:00:00Z')`), /UNIQUE/); + }); + + it('WalletTransaction: lightning is (bron, hash) uniek, los van on-chain en van een andere bron', () => { + const ln = (bron, hash) => `INSERT INTO WalletTransaction (accountId, sourceId, layer, txid, valueSats, blockTime, direction, createdAt) + VALUES (1,${bron},'lightning','${hash}',21,'2026-01-01T00:00:00Z','in','2026-01-01T00:00:00Z')`; + eigen.exec(ln(1, 'aabbcc')); + assert.throws(() => eigen.exec(ln(1, 'aabbcc')), /UNIQUE/); + eigen.exec(`INSERT INTO WalletSource (accountId, kind, value, createdAt) VALUES (1,'nwc','pubkey-twee','2026-01-01T00:00:00Z')`); + const tweede = eigen.prepare(`SELECT MAX(id) AS id FROM WalletSource`).get().id; + eigen.exec(ln(tweede, 'aabbcc')); + }); + + it('WalletTransaction: een lightning-rij heeft een bron en een tijd, en geen blokhoogte', () => { + const rij = (kolommen, waarden) => () => eigen.exec(`INSERT INTO WalletTransaction (accountId, layer, txid, valueSats, direction, createdAt${kolommen}) + VALUES (1,'lightning','ff00',1,'in','2026-01-01T00:00:00Z'${waarden})`); + assert.throws(rij(', sourceId', ', 1'), /CHECK/, 'zonder tijd'); + assert.throws(rij(', blockTime', `, '2026-01-01T00:00:00Z'`), /CHECK/, 'zonder bron'); + assert.throws(rij(', sourceId, blockTime, blockHeight', `, 1, '2026-01-01T00:00:00Z', 900000`), /CHECK/, 'met een blokhoogte'); + assert.throws(() => eigen.exec(`INSERT INTO WalletTransaction (accountId, layer, txid, valueSats, direction, createdAt) + VALUES (1,'bliksem','ff01',1,'in','2026-01-01T00:00:00Z')`), /CHECK/, 'onbekende laag'); + }); + + it('PriceHistory: (asset, provider, moment, resolutie) is uniek', () => { + assert.throws(() => eigen.exec(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) + VALUES (1,1,1.0,'day','2026-08-12T10:00:00.000Z')`), /UNIQUE/); + }); + + it('Connection: hooguit één actief btc_explorer-profiel', () => { + assert.throws(() => eigen.exec(`INSERT INTO Connection (name, searchName, url, kind, isActive, isSystem, createdAt, updatedAt) + VALUES ('Tweede','tweede','https://x.test','btc_explorer',1,0,'2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`), /UNIQUE/); + }); + + it('DashboardProfile: hooguit één actief profiel', () => { + assert.throws(() => eigen.exec(`INSERT INTO DashboardProfile (name, searchName, sortOrder, isActive, isSystem, createdAt, updatedAt) + VALUES ('Fiscaal','fiscaal',1,1,0,'2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`), /UNIQUE/); + }); + + it('DashboardProfile: een tweede INACTIEF profiel mag wel', () => { + eigen.exec(`INSERT INTO DashboardProfile (name, searchName, sortOrder, isActive, isSystem, createdAt, updatedAt) + VALUES ('Fiscaal','fiscaal',1,0,0,'2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`); + assert.equal(eigen.prepare(`SELECT COUNT(*) AS n FROM DashboardProfile`).get().n, 2); + }); + + it('ProviderCoverage: één rij per (provider, symbool)', () => { + assert.throws(() => eigen.exec(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (1,'BTC')`), /UNIQUE/); + }); + + it('ProviderCoverage: een symbool dat géén asset is, mag gewoon', () => { + eigen.exec(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (1,'DOGE')`); + assert.equal(eigen.prepare(`SELECT COUNT(*) AS n FROM ProviderCoverage WHERE symbol='DOGE'`).get().n, 1); + }); +}); + +describe('baseline: geen enkele CHECK-lijst is een losse literal', () => { + const GEKOPPELD = [ + ['Asset', 'kind', ENUMS.ASSET_KIND], + ['Asset', 'displayUnit', ENUMS.ASSET_DISPLAY_UNIT], + ['Account', 'kind', ENUMS.ACCOUNT_KIND], + ['MetalProduct', 'form', ENUMS.METAL_FORM], + ['Event', 'type', ENUMS.EVENT_TYPE], + ['Event', 'source', ENUMS.EVENT_SOURCE], + ['Valuation', 'source', ENUMS.VALUATION_SOURCE], + ['ScanLog', 'status', ENUMS.SCAN_STATUS], + ['WalletTransaction', 'direction', ENUMS.RECONCILE_DIRECTION], + ['WalletSource', 'kind', ENUMS.WALLET_SOURCE_KIND], + ['WalletSource', 'scriptType', ENUMS.SCRIPT_TYPE], + ['ReconcileProposal', 'direction', ENUMS.RECONCILE_DIRECTION], + ['ReconcileProposal', 'suggestedType', ENUMS.RECONCILE_SUGGESTED], + ['ReconcileProposal', 'status', ENUMS.RECONCILE_STATUS], + ['WalletTransactionEventLink', 'method', ENUMS.LINK_METHOD], + ['InterestSchedule', 'rateKind', ENUMS.INTEREST_RATE_KIND], + ['InterestSchedule', 'creditCadence', ENUMS.INTEREST_CADENCE], + ['InterestCredit', 'status', ENUMS.INTEREST_CREDIT_STATUS], + ['Notification', 'kind', ENUMS.NOTIFICATION_KIND], + ['Connection', 'kind', ENUMS.CONNECTION_KIND], + ['Connection', 'lastStatus', ENUMS.CONNECTION_STATUS], + ['PriceHistory', 'resolution', ENUMS.PRICE_RESOLUTION], + ['PriceSource', 'status', ENUMS.PRICE_SOURCE_STATUS], + ['PriceSource', 'capability', SOURCE_CAPABILITIES], + ['ProviderEndpoint', 'capability', CAPABILITIES], + ['ProviderEndpoint', 'method', HTTP_METHODS], + ['ProviderOutputMap', 'capability', CAPABILITIES], + ['ProviderOutputMap', 'internalField', OUTPUT_FIELDS], + ['ProviderOutputMap', 'transform', TRANSFORMS], + ['ProviderCoverage', 'symbol', null], + ['ApiCallMetric', 'capability', CAPABILITIES], + ['ProviderAuthenticationHmac', 'hashAlgorithm', ENUMS.HMAC_HASH_ALGORITHM], + ['ProviderAuthenticationHmac', 'encoding', ENUMS.HMAC_ENCODING], + ['Provider', 'authenticationKind', Object.keys(AUTH_KINDS)], + ['AiToolCall', 'outcome', ENUMS.AI_CALL_OUTCOME], + ].filter(([, , waarden]) => waarden !== null); + + function checkWaarden(tabel, kolom) { + const sql = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name = ?`).get(tabel).sql; + const m = sql.match(new RegExp(`CHECK ?\\(\\s*(?:${kolom} IS NULL OR )?${kolom} IN \\(([^)]*)\\)`)); + assert.ok(m, `geen CHECK-lijst gevonden voor ${tabel}.${kolom}: wordt die kolom nog bewaakt?`); + return m[1].split(',').map(s => s.trim().replace(/^'|'$/g, '')); + } + + for (const [tabel, kolom, waarden] of GEKOPPELD) { + it(`${tabel}.${kolom} dekt exact de registry`, () => { + assert.deepEqual([...checkWaarden(tabel, kolom)].sort(), [...waarden].sort(), + 'de DDL en de registry lopen uit de pas; onthoud dat een CHECK-wijziging een tabel-rebuild kost'); + }); + } + + it('bewaakt elke gekoppelde kolom precies één keer', () => { + const sleutels = GEKOPPELD.map(([t, k]) => `${t}.${k}`); + assert.equal(new Set(sleutels).size, sleutels.length, 'dubbele regel in de GEKOPPELD-tabel'); + }); + + it('laat DashboardWidget.type bewust zónder CHECK', () => { + const sql = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='DashboardWidget'`).get().sql; + assert.equal(/CHECK \(type IN/.test(sql), false); + }); +}); + +describe('baseline: de bewuste uitzonderingen', () => { + it('houdt precies vijf tabellen zonder surrogaat-id', () => { + const zonderId = TABELLEN.filter(t => !KOLOMMEN.get(t).some(k => k.name === 'id')); + assert.deepEqual(zonderId.sort(), + ['ApiCallMetric', 'Preference', 'ProviderAuthenticationHmac', 'ProviderCallLimit', 'ProviderFixedRate']); + }); + + it('gebruikt nergens AUTOINCREMENT', () => { + assert.equal(/AUTOINCREMENT/i.test(BASELINE_SCHEMA), false); + }); + + it('geeft ProviderCoverage geen FK naar Asset', () => { + const fks = db.prepare(`PRAGMA foreign_key_list(ProviderCoverage)`).all(); + assert.deepEqual(fks.map(f => f.table), ['Provider'], + 'dekking mag een symbool noemen dat hier geen asset is; een FK zou die dekking weggooien'); + }); +}); diff --git a/tests/db/change-log.test.mjs b/tests/db/change-log.test.mjs new file mode 100644 index 0000000..4fe9f1b --- /dev/null +++ b/tests/db/change-log.test.mjs @@ -0,0 +1,78 @@ +// Generated, do not edit. sha256:7e68bde59de6fb368899c1d7c4f191cb1a698e2a3f71f077372987a488b10794 +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; + +import { openBaselineDb } from '../helpers/baseline-db.mjs'; +import { CONFIG, ENUMS } from '../../shared/config.js'; + +let db; +const NU = '2026-09-07T12:00:00Z'; +const schrijf = (over = {}) => { + const r = { tableName: 'Account', rowId: 1, operation: 'update', origin: 'manual', + originId: null, changes: '{"name":["a","b"]}', occurredAt: NU, ...over }; + db.prepare(`INSERT INTO ChangeLog (tableName, rowId, operation, origin, originId, changes, occurredAt) + VALUES (?, ?, ?, ?, ?, ?, ?)`) + .run(r.tableName, r.rowId, r.operation, r.origin, r.originId, r.changes, r.occurredAt); +}; + +before(() => { db = openBaselineDb(); }); +after(() => db.close()); + +describe('ChangeLog: wat de tabel accepteert', () => { + it('neemt een gewone wijziging aan', () => { + schrijf({ rowId: 101 }); + const r = db.prepare(`SELECT * FROM ChangeLog WHERE rowId = 101`).get(); + assert.equal(r.operation, 'update'); + assert.equal(r.originId, null, 'een handmatige wijziging heeft geen rij die hem aanwijst'); + }); + + it('neemt een verwijdering aan, met de hele rij in changes', () => { + schrijf({ rowId: 102, operation: 'delete', changes: '{"id":102,"name":"Kluis","kind":"vault"}' }); + const r = db.prepare(`SELECT operation, changes FROM ChangeLog WHERE rowId = 102`).get(); + assert.equal(r.operation, 'delete'); + assert.deepEqual(JSON.parse(r.changes).name, 'Kluis'); + }); + + it('neemt elke herkomst uit de enum aan', () => { + for (const [i, origin] of ENUMS.CHANGE_ORIGIN.entries()) { + schrijf({ rowId: 200 + i, origin }); + } + const n = db.prepare(`SELECT COUNT(*) AS n FROM ChangeLog WHERE rowId >= 200 AND rowId < 300`).get().n; + assert.equal(n, ENUMS.CHANGE_ORIGIN.length); + }); + + it('weigert een insert-regel, want die bestaat niet', () => { + assert.throws(() => schrijf({ rowId: 301, operation: 'insert' }), /CHECK/i, + 'een nieuw record heeft zijn eigen createdAt; dat levert geen logboekregel op'); + }); + + it('weigert een onbekende herkomst', () => { + assert.throws(() => schrijf({ rowId: 302, origin: 'zomaar' }), /CHECK/i); + }); + + it('weigert een regel zonder tabel, rij, herkomst of moment', () => { + assert.throws(() => schrijf({ rowId: 303, tableName: null }), /NOT NULL/i); + assert.throws(() => schrijf({ rowId: null }), /NOT NULL/i); + assert.throws(() => schrijf({ rowId: 304, origin: null }), /NOT NULL/i); + assert.throws(() => schrijf({ rowId: 305, occurredAt: null }), /NOT NULL/i); + assert.throws(() => schrijf({ rowId: 306, changes: null }), /NOT NULL/i); + }); +}); + +describe('ChangeLog: hoe hij in de registers staat', () => { + it('staat als append-only logboek buiten de verwijderbare tabellen', () => { + assert.ok(CONFIG.DB_TABLES.includes('ChangeLog')); + assert.ok(!CONFIG.DB_DELETABLE_TABLES.includes('ChangeLog')); + }); + + it('doet zelf niet mee in het logboek', () => { + assert.equal(CONFIG.DB_HISTORY.UNTRACKED.ChangeLog, 'logboek'); + assert.ok(!CONFIG.DB_HISTORY.TRACKED.includes('ChangeLog'), 'een logboek van een logboek zegt niets'); + }); + + it('deelt `changes` niet met de AI', async () => { + const { FIELD_POLICY } = await import('../../server/ai/policy.js'); + assert.equal(FIELD_POLICY.changes, 'drop'); + assert.equal(FIELD_POLICY.rowId, 'handle', 'een verwijzing, geen getal'); + }); +}); diff --git a/tests/db/migrations.test.mjs b/tests/db/migrations.test.mjs new file mode 100644 index 0000000..4ba10b8 --- /dev/null +++ b/tests/db/migrations.test.mjs @@ -0,0 +1,2532 @@ +// Generated, do not edit. sha256:4ce5c52e16e739212783761b521116e3496e8239a0c021ec82c742e179e252d6 +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +import { openDatabase, closeDatabase, run, all, one } from '../../server/db/conn.mjs'; +import { readVersion, runMigrations, BASELINE_VERSION } from '../../server/db/migrations.js'; +import { openBaselineDb } from '../helpers/baseline-db.mjs'; +import { CONFIG } from '../../shared/config.js'; + +function bronTabellenStomp() { + run(`CREATE TABLE Provider ( + id INTEGER PRIMARY KEY, key TEXT NOT NULL, name TEXT NOT NULL, searchName TEXT NOT NULL, + baseUrl TEXT NOT NULL, authenticationHeaderName TEXT, authenticationParameterName TEXT, + authenticationKind TEXT NOT NULL, authenticationRequired INTEGER NOT NULL, + isActive INTEGER NOT NULL, isSeed INTEGER NOT NULL, isSystem INTEGER NOT NULL, + createdAt TEXT NOT NULL, UNIQUE (key) + ) STRICT;`); + run(`CREATE TABLE ProviderCoverage ( + id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + symbol TEXT NOT NULL, UNIQUE (providerId, symbol) + ) STRICT;`); + run(`CREATE TABLE ProviderEndpoint ( + id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + pathTemplate TEXT NOT NULL, queryTemplate TEXT, capability TEXT NOT NULL, method TEXT NOT NULL, + UNIQUE (providerId, capability) + ) STRICT;`); + run(`CREATE TABLE ProviderOutputMap ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + jsonPath TEXT NOT NULL, + capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')), + internalField TEXT NOT NULL CHECK (internalField IN ('EUR','USD','time','price')), + transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram')) + ) STRICT;`); + run(`CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability)`); + run(`CREATE TABLE ConsumerSource ( + id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT, + consumerKey TEXT NOT NULL, capability TEXT NOT NULL, rank INTEGER, status TEXT NOT NULL, + UNIQUE (consumerKey, assetId, capability, providerId), + UNIQUE (consumerKey, assetId, capability, rank) + ) STRICT;`); +} + +function accountTabelVoor72() { + run(`CREATE TABLE Account ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, + kind TEXT NOT NULL CHECK (kind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER NOT NULL CHECK (isOwnCustody IN (0,1)), + scanEnabled INTEGER NOT NULL CHECK (scanEnabled IN (0,1)), + isActive INTEGER NOT NULL CHECK (isActive IN (0,1)), + closedAt TEXT, + CHECK ((isActive = 1 AND closedAt IS NULL) OR (isActive = 0 AND closedAt IS NOT NULL)) + ) STRICT;`); + run(`CREATE INDEX IxAccountSearchName ON Account(searchName)`); +} + +function dashboardWidgetStomp() { + run(`CREATE TABLE DashboardWidget ( + id INTEGER PRIMARY KEY, profileId INTEGER NOT NULL, type TEXT NOT NULL + ) STRICT;`); +} + +function valuationTabelVoor74() { + run(`CREATE TABLE Valuation ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + accountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, + balance INTEGER NOT NULL, + eurRate REAL NOT NULL, + source TEXT NOT NULL CHECK (source IN ('onchain','manual','statement')), + valuedOn TEXT NOT NULL, + rateAt TEXT NOT NULL, + UNIQUE (assetId, valuedOn, accountId) + ) STRICT;`); + run(`CREATE INDEX IxValuationAccountId ON Valuation(accountId)`); +} + +function interestScheduleTabelVoor75() { + run(`CREATE TABLE InterestSchedule ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + principalAssetId INTEGER REFERENCES Asset(id) ON DELETE NO ACTION, + payoutAssetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + note TEXT, + principalAmount INTEGER CHECK (principalAmount IS NULL OR principalAmount > 0), + annualRate REAL CHECK (annualRate IS NULL OR annualRate >= 0), + rateKind TEXT NOT NULL CHECK (rateKind IN ('fixed','variable')), + creditCadence TEXT CHECK (creditCadence IS NULL OR creditCadence IN ('term_end','annual','monthly','weekly')), + creditWeekday INTEGER CHECK (creditWeekday IS NULL OR creditWeekday BETWEEN 0 AND 6), + creditDayOfMonth INTEGER CHECK (creditDayOfMonth IS NULL OR creditDayOfMonth BETWEEN 1 AND 31), + startsOn TEXT NOT NULL, + endsOn TEXT, + createdAt TEXT NOT NULL, + CHECK (rateKind = 'fixed' OR (principalAmount IS NULL AND annualRate IS NULL)), + CHECK (creditWeekday IS NULL OR creditCadence = 'weekly'), + CHECK (creditDayOfMonth IS NULL OR creditCadence = 'monthly'), + CHECK (endsOn IS NULL OR endsOn >= startsOn) + ) STRICT;`); + run(`CREATE INDEX IxInterestScheduleAccountId ON InterestSchedule(accountId)`); + run(`CREATE INDEX IxInterestSchedulePrincipalAssetId ON InterestSchedule(principalAssetId)`); + run(`CREATE INDEX IxInterestSchedulePayoutAssetId ON InterestSchedule(payoutAssetId)`); +} + +function opzet({ versie = null } = {}) { + openDatabase(':memory:'); + if (versie !== null) { + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (?)`, [versie]); + run(`CREATE TABLE Preference (scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (scope, key)) STRICT;`); + run(`CREATE TABLE Notification ( + id INTEGER PRIMARY KEY, + count INTEGER NOT NULL CHECK (count >= 0), + highWater INTEGER NOT NULL CHECK (highWater >= 0), + kind TEXT NOT NULL CHECK (kind IN ('reconcile','interest','valuation','schedule_ended','source_proposal')), + dismissedAt TEXT, + createdAt TEXT NOT NULL + ) STRICT;`); + run(`CREATE INDEX IxNotificationKind ON Notification(kind)`); + run(`CREATE TABLE Asset ( + id INTEGER PRIMARY KEY, + symbol TEXT NOT NULL, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, + unit TEXT NOT NULL, + subunit TEXT, + displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')), + decimals INTEGER NOT NULL CHECK (decimals >= 0), + kind TEXT NOT NULL CHECK (kind IN ('crypto','metal')), + UNIQUE (symbol), + CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL) + ) STRICT;`); + run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`); + run(`CREATE TABLE Event ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, + toAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, + productId INTEGER REFERENCES MetalProduct(id) ON DELETE NO ACTION, + productCount INTEGER CHECK (productCount IS NULL OR productCount > 0), + importReference TEXT, + externalAddress TEXT, + note TEXT, + amount INTEGER NOT NULL CHECK (amount > 0), + eurPrice REAL, + eurFee REAL, + unitFee INTEGER, + type TEXT NOT NULL CHECK (type IN ('buy','sell','transfer','interest')), + source TEXT NOT NULL CHECK (source IN ('manual','import','reconcile','lightning')), + occurredAt TEXT NOT NULL, + createdAt TEXT NOT NULL, + CHECK ((productId IS NULL) = (productCount IS NULL)), + CHECK (type NOT IN ('buy','sell') OR eurPrice IS NOT NULL) + ) STRICT;`); + run(`CREATE INDEX IxEventAssetId ON Event(assetId)`); + run(`CREATE INDEX IxEventFromAccountId ON Event(fromAccountId)`); + run(`CREATE INDEX IxEventToAccountId ON Event(toAccountId)`); + run(`CREATE INDEX IxEventProductId ON Event(productId)`); + run(`CREATE INDEX IxEventOccurredAt ON Event(occurredAt)`); + run(`CREATE INDEX IxEventType ON Event(type)`); + run(`CREATE INDEX IxEventExternalAddress ON Event(externalAddress)`); + run(`CREATE INDEX IxEventImportReference ON Event(importReference)`); + accountTabelVoor72(); + run(`CREATE TABLE MetalProduct (id INTEGER PRIMARY KEY, name TEXT NOT NULL) STRICT;`); + bronTabellenStomp(); + dashboardWidgetStomp(); + valuationTabelVoor74(); + interestScheduleTabelVoor75(); + } +} + +function zetRijen({ decimals = 8, eurPrice = 30000, eurFee = null, unitFee = null } = {}) { + run(`INSERT INTO Asset (id, symbol, name, searchName, unit, subunit, displayUnit, decimals, kind) + VALUES (1,'BTC','Bitcoin','bitcoin','BTC','sat','subunit',?,'crypto')`, [decimals]); + run(`INSERT INTO Event (id, assetId, amount, eurPrice, eurFee, unitFee, type, source, occurredAt, createdAt) + VALUES (1, 1, 100000, ?, ?, ?, 'buy', 'manual', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, + [eurPrice, eurFee, unitFee]); +} + +const tabellen = () => all(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`).map(r => r.name); + +beforeEach(() => closeDatabase()); +afterEach(() => closeDatabase()); + +describe('migraties: de versie lezen', () => { + it('leest de versie uit Schema', () => { + opzet({ versie: 60 }); + assert.equal(readVersion(), 60); + }); + + it('zegt bij een database zonder Schema-tabel dat hij te oud is, en verwijst naar het script', () => { + opzet(); + assert.throws(() => readVersion(), /from before the new layout/); + assert.throws(() => readVersion(), /baseline-convert/); + }); +}); + +describe('migraties: de drie takken van runMigrations', () => { + it('doet niets als de versie al klopt', () => { + opzet({ versie: CONFIG.SCHEMA_VERSION }); + const voor = tabellen(); + assert.equal(runMigrations(), false, 'niets gemigreerd, dus ook niets te persisteren'); + assert.deepEqual(tabellen(), voor, 'en geen enkele schrijfactie op het schema'); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION, 'de versie staat er onveranderd'); + }); + + it('weigert een database die nieuwer is dan de app', () => { + opzet({ versie: CONFIG.SCHEMA_VERSION + 1 }); + assert.throws(() => runMigrations(), /newer than the app/); + }); + + it('weigert een database van vóór de baseline met een begrijpelijke melding', () => { + opzet({ versie: BASELINE_VERSION - 1 }); + assert.throws(() => runMigrations(), /from before the new layout/); + assert.throws(() => runMigrations(), /baseline-convert/); + try { runMigrations(); } catch (e) { + assert.equal(/Migratie naar versie/.test(e.message), false, + 'juist NIET de oude melding: die stuurt iemand op zoek naar een migratie die niet bestaat'); + } + }); + + it('noemt de gevonden versie in de melding', () => { + opzet({ versie: 42 }); + assert.throws(() => runMigrations(), /version 42/); + }); +}); + +describe('migratie 69: de meldingssoort hernoemen, en wat er met bestaande rijen gebeurt', () => { + function opzet68() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (68)`); + run(`CREATE TABLE Asset ( + id INTEGER PRIMARY KEY, + symbol TEXT NOT NULL, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, + unit TEXT NOT NULL, + subunit TEXT, + displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')), + decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9), + kind TEXT NOT NULL CHECK (kind IN ('crypto','metal')), + UNIQUE (symbol), + CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL) + ) STRICT;`); + run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`); + run(`CREATE TABLE Notification ( + id INTEGER PRIMARY KEY, + count INTEGER NOT NULL CHECK (count >= 0), + highWater INTEGER NOT NULL CHECK (highWater >= 0), + kind TEXT NOT NULL CHECK (kind IN ('reconcile','interest','valuation','schedule_ended','source_proposal','ai_command')), + dismissedAt TEXT, + createdAt TEXT NOT NULL + ) STRICT;`); + run(`CREATE INDEX IxNotificationKind ON Notification(kind)`); + bronTabellenStomp(); + dashboardWidgetStomp(); + valuationTabelVoor74(); + interestScheduleTabelVoor75(); + accountTabelVoor72(); + } + const meldingen = () => all(`SELECT id, kind, count, highWater FROM Notification ORDER BY id`); + + it('gooit een bestaande ai_command-melding weg en laat de andere staan', () => { + opzet68(); + run(`INSERT INTO Notification (kind, count, highWater, createdAt) + VALUES ('ai_command', 7, 99, '2026-08-18T12:00:00.000Z')`); + run(`INSERT INTO Notification (kind, count, highWater, createdAt) + VALUES ('reconcile', 3, 12, '2026-08-18T12:00:00.000Z')`); + + assert.equal(runMigrations(), true); + assert.deepEqual(meldingen(), [{ id: 2, kind: 'reconcile', count: 3, highWater: 12 }], + 'alleen de reconcile-melding blijft, met zijn eigen high-water'); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('kent de nieuwe soort en weigert de oude', () => { + opzet68(); + runMigrations(); + assert.throws(() => run(`INSERT INTO Notification (kind, count, highWater, createdAt) + VALUES ('ai_command', 1, 1, '2026-08-19T12:00:00.000Z')`), /CHECK/i); + assert.doesNotThrow(() => run(`INSERT INTO Notification (kind, count, highWater, createdAt) + VALUES ('ai_proposal', 1, 1, '2026-08-19T12:00:00.000Z')`)); + }); + + it('zet de index terug, want die verdwijnt met de oude tabel', () => { + opzet68(); + runMigrations(); + assert.deepEqual(all(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'Notification'`), + [{ name: 'IxNotificationKind' }]); + }); +}); + +describe('migratie 70: Asset.kind krijgt fiat erbij', () => { + function opzet69() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (69)`); + run(`CREATE TABLE Asset ( + id INTEGER PRIMARY KEY, + symbol TEXT NOT NULL, + name TEXT NOT NULL, + searchName TEXT NOT NULL, + description TEXT, + unit TEXT NOT NULL, + subunit TEXT, + displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')), + decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9), + kind TEXT NOT NULL CHECK (kind IN ('crypto','metal')), + UNIQUE (symbol), + CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL) + ) STRICT;`); + run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`); + bronTabellenStomp(); + dashboardWidgetStomp(); + valuationTabelVoor74(); + interestScheduleTabelVoor75(); + accountTabelVoor72(); + run(`INSERT INTO Asset (id, symbol, name, searchName, unit, subunit, displayUnit, decimals, kind) + VALUES (1,'BTC','Bitcoin','bitcoin','BTC','sat','subunit',8,'crypto')`); + } + const insertKind = (kind) => run( + `INSERT INTO Asset (symbol, name, searchName, unit, displayUnit, decimals, kind) + VALUES ('X${kind}','X','x','X','unit',2,'${kind}')`); + + it('laat fiat toe en weigert een soort die niet bestaat', () => { + opzet69(); + assert.throws(() => insertKind('fiat'), /CHECK/i, 'op 69 bestaat de soort nog niet'); + runMigrations(); + assert.doesNotThrow(() => insertKind('fiat')); + assert.throws(() => insertKind('onzin'), /CHECK/i); + }); + + it('neemt de bestaande assets mee en zet de index terug', () => { + opzet69(); + runMigrations(); + assert.deepEqual(all(`SELECT id, symbol, kind, decimals FROM Asset`), + [{ id: 1, symbol: 'BTC', kind: 'crypto', decimals: 8 }, + { id: 2, symbol: 'EUR', kind: 'fiat', decimals: 2 }, + { id: 3, symbol: 'USD', kind: 'fiat', decimals: 2 }]); + assert.deepEqual( + all(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'Asset' AND name NOT LIKE 'sqlite_%'`), + [{ name: 'IxAssetSearchName' }]); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('houdt de andere CHECKs van die tabel overeind', () => { + opzet69(); + runMigrations(); + assert.throws(() => run(`UPDATE Asset SET decimals = 18 WHERE id = 1`), /CHECK/i); + assert.throws(() => run(`UPDATE Asset SET displayUnit = 'subunit', subunit = NULL WHERE id = 1`), /CHECK/i); + }); +}); + +describe('migratie 71: de vaste-koers-provider en het EUR-asset', () => { + function opzet70() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (70)`); + run(`CREATE TABLE Asset ( + id INTEGER PRIMARY KEY, symbol TEXT NOT NULL, name TEXT NOT NULL, searchName TEXT NOT NULL, + description TEXT, unit TEXT NOT NULL, subunit TEXT, + displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')), + decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9), + kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')), + UNIQUE (symbol), CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL) + ) STRICT;`); + bronTabellenStomp(); + dashboardWidgetStomp(); + valuationTabelVoor74(); + interestScheduleTabelVoor75(); + accountTabelVoor72(); + run(`INSERT INTO Asset (id, symbol, name, searchName, unit, subunit, displayUnit, decimals, kind) + VALUES (1,'BTC','Bitcoin','bitcoin','BTC','sat','subunit',8,'crypto')`); + } + const provider = () => one(`SELECT id, key, name, baseUrl, isActive, isSystem, isSeed FROM Provider WHERE key = 'fixed'`); + const bronkeuze = () => all(`SELECT capability, Provider.key AS bron, rank, status FROM PriceSource + JOIN Provider ON Provider.id = PriceSource.providerId + WHERE assetId = (SELECT id FROM Asset WHERE symbol = 'EUR') + ORDER BY capability, rank`); + + it('zet de provider neer met een vaste koers van 1,00, zijn dekking en zijn twee capabilities', () => { + opzet70(); + runMigrations(); + const p = provider(); + assert.equal(p.isSystem, 1, 'systeemrij, net als manual: de app leunt erop'); + assert.equal(p.baseUrl, '', 'geen URL, want er gaat geen verzoek uit'); + assert.equal(one(`SELECT fiatPrice FROM ProviderFixedRate WHERE providerId = ?`, [p.id]).fiatPrice, 1); + assert.deepEqual(all(`SELECT symbol FROM ProviderCoverage WHERE providerId = ?`, [p.id]), [{ symbol: 'EUR' }]); + assert.deepEqual( + all(`SELECT capability, pathTemplate FROM ProviderEndpoint WHERE providerId = ? ORDER BY capability`, [p.id]), + [{ capability: 'history_point', pathTemplate: '' }, { capability: 'spot', pathTemplate: '' }]); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('zet het EUR-asset neer in centen, en laat de bestaande assets staan', () => { + opzet70(); + runMigrations(); + assert.deepEqual(all(`SELECT symbol, kind, unit, subunit, decimals, displayUnit FROM Asset ORDER BY symbol`), + [{ symbol: 'BTC', kind: 'crypto', unit: 'BTC', subunit: 'sat', decimals: 8, displayUnit: 'subunit' }, + { symbol: 'EUR', kind: 'fiat', unit: 'EUR', subunit: 'cent', decimals: 2, displayUnit: 'unit' }, + { symbol: 'USD', kind: 'fiat', unit: 'USD', subunit: 'cent', decimals: 2, displayUnit: 'unit' }]); + }); + + it('vult de bronkeuze voor de spot en de historie', () => { + opzet70(); + runMigrations(); + assert.deepEqual(bronkeuze(), [ + { capability: 'history', bron: 'fixed', rank: 1, status: 'confirmed' }, + { capability: 'spot', bron: 'fixed', rank: 1, status: 'confirmed' }, + ]); + }); + + it('laat een bestaand EUR-asset staan in plaats van er een tweede naast te zetten', () => { + opzet70(); + run(`INSERT INTO Asset (id, symbol, name, searchName, unit, displayUnit, decimals, kind) + VALUES (5,'EUR','Euro (eigen)','euro (eigen)','EUR','unit',4,'fiat')`); + assert.doesNotThrow(() => runMigrations()); + assert.deepEqual(all(`SELECT id, name, decimals FROM Asset WHERE symbol = 'EUR'`), + [{ id: 5, name: 'Euro (eigen)', decimals: 4 }]); + assert.equal(bronkeuze().length, 2, 'de keten hangt aan het bestaande asset'); + }); + + it('laat een bronkeuze die de gebruiker zelf heeft gemaakt ongemoeid', () => { + opzet70(); + run(`INSERT INTO Asset (id, symbol, name, searchName, unit, displayUnit, decimals, kind) + VALUES (5,'EUR','Euro','euro','EUR','unit',2,'fiat')`); + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES ('eigen','Eigen bron','eigen bron','https://x','none',0,1,0,0,'2026-08-20T10:00:00.000Z')`); + const eigen = one(`SELECT id FROM Provider WHERE key = 'eigen'`).id; + run(`INSERT INTO ConsumerSource (consumerKey, assetId, capability, providerId, rank, status) + VALUES ('tax', 5, 'spot', ?, 1, 'manual')`, [eigen]); + + runMigrations(); + assert.deepEqual(bronkeuze(), [ + { capability: 'history', bron: 'fixed', rank: 1, status: 'confirmed' }, + { capability: 'spot', bron: 'eigen', rank: 1, status: 'manual' }, + { capability: 'spot', bron: 'fixed', rank: 2, status: 'confirmed' }, + ]); + }); + + it('weigert een vaste koers die geen koers is', () => { + opzet70(); + runMigrations(); + const p = provider(); + assert.throws(() => run(`UPDATE ProviderFixedRate SET fiatPrice = 0 WHERE providerId = ?`, [p.id]), /CHECK/i); + assert.throws(() => run(`UPDATE ProviderFixedRate SET fiatPrice = -1 WHERE providerId = ?`, [p.id]), /CHECK/i); + }); + + it('laat een provider die al fixed heet ongemoeid in plaats van er een tweede naast te zetten', () => { + opzet70(); + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES ('fixed','Iets anders','iets anders','https://x','none',0,1,0,0,'2026-08-20T10:00:00.000Z')`); + assert.doesNotThrow(() => runMigrations()); + assert.deepEqual(all(`SELECT name FROM Provider WHERE key = 'fixed'`), [{ name: 'Iets anders' }]); + assert.deepEqual(all(`SELECT fiatPrice FROM ProviderFixedRate`), []); + }); +}); + +describe('meting: wat een RENAME doet met de foreign keys van ándere tabellen', () => { + const opzetTwee = () => { + openDatabase(':memory:'); + run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY) STRICT;`); + run(`CREATE TABLE Event (id INTEGER PRIMARY KEY, assetId INTEGER REFERENCES Asset(id)) STRICT;`); + }; + const eventSql = () => all(`SELECT sql FROM sqlite_master WHERE name = 'Event'`)[0].sql; + + it('node:sqlite honoreert PRAGMA foreign_keys = OFF, ondanks de optie bij het openen', () => { + opzetTwee(); + assert.equal(all(`PRAGMA foreign_keys`)[0].foreign_keys, 1, 'aan bij het openen'); + run(`PRAGMA foreign_keys = OFF`); + assert.equal(all(`PRAGMA foreign_keys`)[0].foreign_keys, 0, 'en hij gaat echt uit'); + }); + + it('herschrijft de REFERENCES-clausule TOCH, met foreign_keys uit', () => { + opzetTwee(); + run(`PRAGMA foreign_keys = OFF`); + run(`ALTER TABLE Asset RENAME TO AssetOud`); + assert.match(eventSql(), /REFERENCES "AssetOud"/, + 'de FK van Event is meeverhuisd naar de tijdelijke naam; na een DROP verwijst hij naar niets'); + }); + + it('laat de clausule staan met legacy_alter_table aan, en dát is de knop die werkt', () => { + opzetTwee(); + run(`PRAGMA foreign_keys = OFF`); + run(`PRAGMA legacy_alter_table = ON`); + run(`ALTER TABLE Asset RENAME TO AssetOud`); + assert.match(eventSql(), /REFERENCES Asset\(id\)/, 'Event blijft naar Asset wijzen'); + assert.equal(/AssetOud/.test(eventSql()), false); + }); + + it('is per verbinding en niet blijvend: legacy_alter_table gaat weer uit', () => { + opzetTwee(); + run(`PRAGMA legacy_alter_table = ON`); + run(`PRAGMA legacy_alter_table = OFF`); + run(`ALTER TABLE Asset RENAME TO AssetOud`); + assert.match(eventSql(), /AssetOud/, 'weer het moderne gedrag'); + }); +}); + +describe('migraties: de keten levert dezelfde tabellen op als de DDL', () => { + const ddl = (q, namen) => q(`SELECT type, name, tbl_name, sql FROM sqlite_master ORDER BY name`) + .filter(r => r.sql && namen.includes(r.tbl_name)) + .map(r => `${r.type} ${r.name}: ${String(r.sql) + .replace(/--[^\n]*/g, '').replace(/\s+/g, ' ') + .replace(/\s+([,)])/g, '$1').replace(/\(\s+/g, '(').trim()}`); + + const gemigreerdeTabellen = () => + all(`SELECT name FROM sqlite_master WHERE type = 'table' + AND name NOT IN ('Schema', 'Preference', 'MetalProduct', 'DashboardWidget', + 'Provider', 'ProviderCoverage') + ORDER BY name`) + .map(r => r.name); + + it('migreert een database van 60 naar de huidige versie en maakt de nieuwe tabellen aan', () => { + opzet({ versie: 60 }); + assert.equal(all(`SELECT name FROM sqlite_master WHERE name = 'AiToolCall'`).length, 0, 'nog niet'); + assert.equal(runMigrations(), true, 'er is gemigreerd, dus er valt iets te persisteren'); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + assert.deepEqual(gemigreerdeTabellen(), + ['Account', 'AiProposal', 'AiToolCall', 'AiToolCallMessage', 'Asset', 'AssetNote', 'BusSignal', 'ChangeLog', + 'Command', 'Event', 'HygieneAcceptance', 'IgnoredPriceGap', 'IntegrationCall', 'InterestPeriod', 'InterestSchedule', 'Notification', + 'Portfolio', 'PriceAbsence', 'PriceSource', 'ProviderCallLimit', 'ProviderEndpoint', 'ProviderFixedRate', 'ProviderOutputMap', 'Valuation']); + }); + + it('geeft élke gemigreerde tabel exact dezelfde DDL als een verse database', () => { + opzet({ versie: 60 }); + runMigrations(); + const namen = gemigreerdeTabellen(); + const gemigreerd = ddl(all, namen); + const vers = openBaselineDb(); + try { + assert.deepEqual(gemigreerd, ddl((sql) => vers.prepare(sql).all(), namen), + 'elke tabel plus zijn indexen, inclusief types, NOT NULL en élke CHECK'); + assert.equal(gemigreerd.length, 62, 'vierentwintig tabellen, zesendertig indexen en twee triggers'); + } finally { vers.close(); } + }); + + it('houdt de foreign keys van andere tabellen op de échte tabelnaam (migratie 68)', () => { + opzet({ versie: 60 }); + runMigrations(); + const sql = all(`SELECT sql FROM sqlite_master WHERE type='table' AND name='Event'`)[0].sql; + assert.match(sql, /REFERENCES Asset\(id\)/, 'de FK hoort naar Asset te wijzen'); + assert.equal(/Oud/.test(sql), false, 'en nergens naar een tijdelijke naam'); + assert.deepEqual(all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AssetOud%' OR sql LIKE '%EventOud%'`), []); + assert.deepEqual(all(`PRAGMA foreign_key_check`), [], 'en de bestaande rijen hangen nog aan hun ouder'); + }); + + it('zet de pragma na de herbouw terug op aan (migratie 68)', () => { + opzet({ versie: 60 }); + runMigrations(); + assert.equal(all(`PRAGMA foreign_keys`)[0].foreign_keys, 1); + }); + + it('neemt de bestaande rijen mee bij de herbouw (migratie 68)', () => { + opzet({ versie: 60 }); + zetRijen({ decimals: 8, eurPrice: 30000, eurFee: 12.5, unitFee: 250 }); + runMigrations(); + assert.deepEqual(all(`SELECT id, symbol, decimals FROM Asset`), + [{ id: 1, symbol: 'BTC', decimals: 8 }, { id: 2, symbol: 'EUR', decimals: 2 }, { id: 3, symbol: 'USD', decimals: 2 }]); + assert.deepEqual(all(`SELECT id, assetId, amount, fiatPrice, fiatFee, unitFee FROM Event`), + [{ id: 1, assetId: 1, amount: 100000, fiatPrice: 30000, fiatFee: 12.5, unitFee: 250 }]); + }); + + it('handhaaft de nieuwe CHECKs ook in de gemigreerde tabellen (migratie 68)', () => { + opzet({ versie: 60 }); + zetRijen(); + runMigrations(); + assert.throws(() => run(`UPDATE Asset SET decimals = 18 WHERE id = 1`), /CHECK/i); + assert.throws(() => run(`UPDATE Event SET unitFee = -1 WHERE id = 1`), /CHECK/i); + assert.throws(() => run(`UPDATE Event SET fiatPrice = -1 WHERE id = 1`), /CHECK/i); + assert.throws(() => run(`UPDATE Event SET fiatFee = -1 WHERE id = 1`), /CHECK/i); + }); + + it('weigert te migreren als een bestaande asset te veel decimalen heeft, en laat de database staan', () => { + opzet({ versie: 60 }); + zetRijen({ decimals: 18 }); + assert.throws(() => runMigrations(), /1 asset/); + assert.throws(() => runMigrations(), /decimals/); + assert.equal(readVersion(), 67, 'de versie is niet opgeschoven'); + assert.equal(all(`SELECT COUNT(*) AS n FROM Asset`)[0].n, 1); + assert.equal(/decimals <= 9/.test(all(`SELECT sql FROM sqlite_master WHERE name='Asset'`)[0].sql), false, + 'de tabel is niet half herbouwd'); + }); + + it('weigert te migreren als een bestaande boeking een negatief geldbedrag heeft', () => { + opzet({ versie: 60 }); + zetRijen({ unitFee: -50000 }); + assert.throws(() => runMigrations(), /1 event/); + assert.throws(() => runMigrations(), /negative/); + assert.equal(readVersion(), 67); + assert.equal(all(`SELECT unitFee FROM Event`)[0].unitFee, -50000, 'de rij staat er nog, onaangeroerd'); + }); + + it('handhaaft de CHECK op outcome ook in de gemigreerde tabel', () => { + opzet({ versie: 60 }); + runMigrations(); + assert.throws( + () => run(`INSERT INTO AiToolCall (sessionId, tool, outcome, occurredAt) VALUES ('s','t','geslaagd','${'2026-08-14T12:00:00.000Z'}')`), + /CHECK/i); + }); +}); + +describe('migratie 72: taxCategory op Asset en Account', () => { + function opzet71() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (71)`); + run(`CREATE TABLE Asset ( + id INTEGER PRIMARY KEY, symbol TEXT NOT NULL, name TEXT NOT NULL, searchName TEXT NOT NULL, + description TEXT, unit TEXT NOT NULL, subunit TEXT, + displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')), + decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9), + kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')), + UNIQUE (symbol), CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL) + ) STRICT;`); + run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`); + accountTabelVoor72(); + dashboardWidgetStomp(); + valuationTabelVoor74(); + interestScheduleTabelVoor75(); + run(`INSERT INTO Asset (id, symbol, name, searchName, unit, subunit, displayUnit, decimals, kind) + VALUES (1,'BTC','Bitcoin','bitcoin','BTC','sat','subunit',8,'crypto')`); + run(`INSERT INTO Account (id, name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) + VALUES (1,'Kluis','kluis','vault',1,0,1,NULL)`); + } + const kolommen = (tabel) => all(`SELECT name FROM pragma_table_info(?)`, [tabel]).map(r => r.name); + + it('zet de kolom op beide tabellen, achter de bestaande kolommen', () => { + opzet71(); + runMigrations(); + assert.ok(kolommen('Asset').includes('taxCategory')); + assert.ok(kolommen('Account').includes('taxCategory')); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('laat de kolom leeg voor bestaande rijen, want NULL betekent hier iets', () => { + opzet71(); + runMigrations(); + assert.equal(one(`SELECT taxCategory AS c FROM Asset WHERE id = 1`).c, null); + assert.equal(one(`SELECT taxCategory AS c FROM Account WHERE id = 1`).c, null); + }); + + it('neemt de bestaande rijen mee en zet beide indexen terug', () => { + opzet71(); + runMigrations(); + assert.deepEqual(all(`SELECT id, symbol, kind FROM Asset`), [{ id: 1, symbol: 'BTC', kind: 'crypto' }]); + assert.deepEqual(all(`SELECT id, name, kind FROM Account`), [{ id: 1, name: 'Kluis', kind: 'vault' }]); + assert.deepEqual( + all(`SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%' ORDER BY name`), + [{ name: 'IxAccountSearchName' }, { name: 'IxAssetSearchName' }, + { name: 'IxChangeLogOccurredAt' }, { name: 'IxChangeLogTableNameRowId' }, + { name: 'IxHygieneAcceptanceKind' }, { name: 'IxIgnoredPriceGapAssetId' }, + { name: 'IxIntegrationCallOccurredAt' }, + { name: 'IxInterestScheduleAccountId' }, { name: 'IxInterestSchedulePayoutAssetId' }, + { name: 'IxInterestSchedulePrincipalAssetId' }, { name: 'IxPriceAbsenceProviderId' }, { name: 'IxValuationAccountId' }, + { name: 'UxAssetNoteAssetId' }, { name: 'UxHygieneAcceptanceFingerprint' }, { name: 'UxPriceAbsenceAssetIdProviderIdDay' }]); + }); + + it('handhaaft de nieuwe CHECK op beide tabellen', () => { + opzet71(); + runMigrations(); + assert.throws(() => run(`UPDATE Asset SET taxCategory = 'onzin' WHERE id = 1`), /CHECK/i); + assert.throws(() => run(`UPDATE Account SET taxCategory = 'onzin' WHERE id = 1`), /CHECK/i); + assert.doesNotThrow(() => run(`UPDATE Account SET taxCategory = 'bank' WHERE id = 1`)); + }); + + it('houdt de andere CHECKs van beide tabellen overeind', () => { + opzet71(); + runMigrations(); + assert.throws(() => run(`UPDATE Asset SET decimals = 18 WHERE id = 1`), /CHECK/i); + assert.throws(() => run(`UPDATE Asset SET kind = 'onzin' WHERE id = 1`), /CHECK/i); + assert.throws(() => run(`UPDATE Account SET isActive = 0 WHERE id = 1`), /CHECK/i, + 'isActive 0 zonder closedAt mag niet'); + assert.throws(() => run(`UPDATE Account SET kind = 'onzin' WHERE id = 1`), /CHECK/i); + }); +}); + +describe('migratie 73', () => { + function opzet72() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (72)`); + dashboardWidgetStomp(); + valuationTabelVoor74(); + interestScheduleTabelVoor75(); + run(`INSERT INTO DashboardWidget (id, profileId, type) VALUES (1, 1, 'dbstatus')`); + run(`INSERT INTO DashboardWidget (id, profileId, type) VALUES (2, 1, 'standing')`); + run(`INSERT INTO DashboardWidget (id, profileId, type) VALUES (3, 2, 'dbstatus')`); + } + + it('verwijdert dat widget uit élk profiel en laat de rest staan', () => { + opzet72(); + runMigrations(); + assert.deepEqual(all(`SELECT id, profileId, type FROM DashboardWidget ORDER BY id`), + [{ id: 2, profileId: 1, type: 'standing' }]); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('doet niets bij een dashboard dat het widget niet had', () => { + opzet72(); + run(`DELETE FROM DashboardWidget WHERE type = 'dbstatus'`); + runMigrations(); + assert.deepEqual(all(`SELECT id FROM DashboardWidget`), [{ id: 2 }]); + }); +}); + +describe('migratie 77: ai.enabled wordt ai.allow_read', () => { + afterEach(() => closeDatabase()); + + function opzet76({ enabled, write = null }) { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (76)`); + run(`CREATE TABLE Preference ( + scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (scope, key) + ) STRICT;`); + if (enabled !== null) { + run(`INSERT INTO Preference (scope, key, value) VALUES ('ai', 'enabled', ?)`, [JSON.stringify(enabled)]); + } + if (write !== null) { + run(`INSERT INTO Preference (scope, key, value) VALUES ('ai', 'allow_write', ?)`, [JSON.stringify(write)]); + } + } + + const voorkeur = (key) => one(`SELECT value AS v FROM Preference WHERE scope = 'ai' AND key = ?`, [key]); + + it('hernoemt de sleutel en laat de waarde staan', () => { + opzet76({ enabled: 1 }); + runMigrations(); + + assert.equal(voorkeur('enabled'), null, 'de oude sleutel hoort weg te zijn'); + assert.equal(voorkeur('allow_read').v, '1'); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('zet bij enabled = 0 ALLE DRIE de schuiven uit, en niet alleen het lezen', () => { + opzet76({ enabled: 0, write: 1 }); + runMigrations(); + + assert.equal(voorkeur('allow_read').v, '0'); + assert.equal(voorkeur('allow_write').v, '0', 'anders is het schrijfrecht stilletjes terug'); + assert.equal(voorkeur('allow_control').v, '0'); + }); + + it('laat bij enabled = 1 de andere schuiven met rust', () => { + opzet76({ enabled: 1, write: 1 }); + runMigrations(); + + assert.equal(voorkeur('allow_read').v, '1'); + assert.equal(voorkeur('allow_write').v, '1'); + assert.equal(voorkeur('allow_control'), null, 'geen rij nodig: de registry-default is uit'); + }); + + it('doet niets bij een database zonder die voorkeur, en valt daar niet over', () => { + opzet76({ enabled: null }); + runMigrations(); + + assert.equal(voorkeur('allow_read'), null); + assert.equal(voorkeur('allow_raw'), null); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); +}); + +describe('migratie 86: PriceHistory herbouwen zonder een rij of een waarde kwijt te raken', () => { + function opzet85() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (85)`); + run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY, symbol TEXT NOT NULL) STRICT;`); + run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY, name TEXT NOT NULL) STRICT;`); + run(`INSERT INTO Asset (id, symbol) VALUES (1, 'BTC')`); + run(`INSERT INTO Provider (id, name) VALUES (1, 'CoinGecko')`); + run(`CREATE TABLE PriceHistory ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT, + eur REAL, + usd REAL, + resolution TEXT NOT NULL CHECK (resolution IN ('day','timestamp')), + observedAt TEXT NOT NULL, + updatedAt TEXT, + UNIQUE (assetId, providerId, observedAt, resolution), + CHECK (eur IS NOT NULL OR usd IS NOT NULL) + ) STRICT;`); + run(`CREATE INDEX IxPriceHistoryProviderId ON PriceHistory(providerId)`); + run(`CREATE INDEX IxPriceHistoryAssetIdObservedAt ON PriceHistory(assetId, observedAt)`); + run(`INSERT INTO PriceHistory (id, assetId, providerId, eur, usd, resolution, observedAt, updatedAt) + VALUES (1, 1, 1, 67000.5, 72000.25, 'day', '2026-09-01T00:00:00Z', '2026-09-02T10:00:00Z')`); + run(`INSERT INTO PriceHistory (id, assetId, providerId, eur, usd, resolution, observedAt, updatedAt) + VALUES (2, 1, 1, NULL, 71000.0, 'timestamp', '2026-09-02T12:34:56Z', NULL)`); + run(`INSERT INTO PriceHistory (id, assetId, providerId, eur, usd, resolution, observedAt, updatedAt) + VALUES (3, 1, 1, 66000.0, NULL, 'day', '2026-09-03T00:00:00Z', NULL)`); + } + + it('houdt elke rij en elke waarde vast', () => { + opzet85(); + const voor = all(`SELECT id, assetId, providerId, eur, resolution, observedAt, updatedAt + FROM PriceHistory WHERE eur IS NOT NULL ORDER BY id`); + runMigrations(); + + const na = all(`SELECT id, assetId, providerId, fiatPrice AS eur, resolution, observedAt, updatedAt + FROM PriceHistory ORDER BY id`); + assert.deepEqual(na, voor, 'de herbouw hoort elke kolom over te zetten, ook de NULL-bare'); + }); + + it('zet createdAt erbij en laat hem leeg', () => { + opzet85(); + runMigrations(); + + const rijen = all(`SELECT createdAt FROM PriceHistory`); + assert.equal(rijen.length, 2, 'drie rijen, min die met alleen een dollarkoers (migratie 107)'); + assert.ok(rijen.every(r => r.createdAt === null), + 'een migratiedatum invullen zou een datum opleveren die bruikbaar oogt en onwaar is'); + }); + + it('zet de twee indexen terug', () => { + opzet85(); + runMigrations(); + + const namen = all(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'PriceHistory' + AND name NOT LIKE 'sqlite_%' ORDER BY name`).map(r => r.name); + assert.deepEqual(namen, ['IxPriceHistoryAssetIdObservedAt', 'IxPriceHistoryProviderId']); + }); + + it('draait niet nog een keer als de kolom er al staat', () => { + opzet85(); + runMigrations(); + assert.equal(runMigrations(), false, 'de keten staat op de huidige versie en heeft niets te doen'); + assert.equal(all(`SELECT id FROM PriceHistory`).length, 2); + }); +}); + +describe('migratie 90: leveringsvaluta en een transform voor datum-sleutels', () => { + function opzet89() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (89)`); + run(`CREATE TABLE Provider ( + id INTEGER PRIMARY KEY, key TEXT NOT NULL, name TEXT NOT NULL, searchName TEXT NOT NULL, + baseUrl TEXT NOT NULL, authenticationHeaderName TEXT, authenticationParameterName TEXT, + authenticationKind TEXT NOT NULL, authenticationRequired INTEGER NOT NULL, + isActive INTEGER NOT NULL, isSeed INTEGER NOT NULL, isSystem INTEGER NOT NULL, + createdAt TEXT NOT NULL, UNIQUE (key) + ) STRICT;`); + run(`INSERT INTO Provider (id, key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES (1, 'goldapi', 'GoldAPI', 'goldapi', 'https://www.goldapi.io', 'header_key', 1, 0, 1, 0, '2026-01-01T00:00:00Z')`); + run(`CREATE TABLE ProviderCoverage (id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL, symbol TEXT NOT NULL) STRICT;`); + run(`CREATE TABLE Asset ( + id INTEGER PRIMARY KEY, symbol TEXT NOT NULL, name TEXT NOT NULL, searchName TEXT NOT NULL, + description TEXT, unit TEXT NOT NULL, subunit TEXT, displayUnit TEXT NOT NULL, + decimals INTEGER NOT NULL, kind TEXT NOT NULL + ) STRICT;`); + run(`CREATE TABLE ConsumerSource ( + id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL, providerId INTEGER NOT NULL, + consumerKey TEXT NOT NULL, capability TEXT NOT NULL, rank INTEGER, status TEXT NOT NULL + ) STRICT;`); + run(`CREATE TABLE ProviderEndpoint ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + pathTemplate TEXT NOT NULL, + queryTemplate TEXT, + capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')), + method TEXT NOT NULL CHECK (method IN ('GET','POST')), + createdAt TEXT, + UNIQUE (providerId, capability) + ) STRICT;`); + run(`CREATE TABLE ProviderOutputMap ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + jsonPath TEXT NOT NULL, + capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')), + internalField TEXT NOT NULL CHECK (internalField IN ('EUR','USD','time','price')), + transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram')), + createdAt TEXT + ) STRICT;`); + run(`CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability)`); + run(`INSERT INTO ProviderEndpoint (id, providerId, pathTemplate, queryTemplate, capability, method, createdAt) + VALUES (4, 1, '/api/{symbol}/EUR', 'a=1', 'spot', 'GET', '2026-09-01T10:00:00Z')`); + run(`INSERT INTO ProviderOutputMap (id, providerId, jsonPath, capability, internalField, transform, createdAt) + VALUES (9, 1, 'price', 'spot', 'EUR', 'troy_ounce_to_gram', '2026-09-01T10:00:00Z')`); + } + + it('neemt elke kolom van beide tabellen mee en laat quoteCurrency leeg', () => { + opzet89(); + runMigrations(); + assert.deepEqual({ ...one(`SELECT * FROM ProviderEndpoint WHERE id = 4`) }, { + id: 4, providerId: 1, pathTemplate: '/api/{symbol}/EUR', queryTemplate: 'a=1', capability: 'spot', + method: 'GET', quoteCurrency: null, minIntervalSeconds: null, createdAt: '2026-09-01T10:00:00Z', + }); + assert.deepEqual({ ...one(`SELECT * FROM ProviderOutputMap WHERE id = 9`) }, { + id: 9, providerId: 1, jsonPath: 'price', capability: 'spot', internalField: 'price', + transform: 'troy_ounce_to_gram', createdAt: '2026-09-01T10:00:00Z', + }); + }); + + it('laat de nieuwe transform toe, weigert een verzonnen, en zet de index terug', () => { + opzet89(); + runMigrations(); + run(`INSERT INTO ProviderOutputMap (providerId, jsonPath, capability, internalField, transform) + VALUES (1, 'rates{}.@key', 'daily_history', 'time', 'iso_date_to_s')`); + assert.throws(() => run(`INSERT INTO ProviderOutputMap (providerId, jsonPath, capability, internalField, transform) + VALUES (1, 'x', 'spot', 'price', 'verzonnen')`), /CHECK/); + assert.ok(one(`SELECT 1 AS x FROM sqlite_master WHERE name = 'IxProviderOutputMapProviderIdCapability'`)); + }); +}); + +describe('migratie 96: een minimum-interval per endpoint', () => { + function opzet95() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (95)`); + run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY, key TEXT NOT NULL) STRICT;`); + run(`INSERT INTO Provider (id, key) VALUES (1, 'eulerpool')`); + run(`CREATE TABLE ProviderEndpoint ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + pathTemplate TEXT NOT NULL, + queryTemplate TEXT, + capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')), + method TEXT NOT NULL CHECK (method IN ('GET','POST')), + quoteCurrency TEXT, + createdAt TEXT, + UNIQUE (providerId, capability) + ) STRICT;`); + run(`INSERT INTO ProviderEndpoint (id, providerId, pathTemplate, queryTemplate, capability, method, quoteCurrency, createdAt) + VALUES (7, 1, '/api/1/x', NULL, 'daily_history', 'GET', 'USD', '2026-09-24T10:00:00Z')`); + } + + it('neemt elke kolom mee, ook de leveringsvaluta, en laat het interval leeg', () => { + opzet95(); + runMigrations(); + assert.deepEqual({ ...one(`SELECT * FROM ProviderEndpoint WHERE id = 7`) }, { + id: 7, providerId: 1, pathTemplate: '/api/1/x', queryTemplate: null, capability: 'daily_history', + method: 'GET', quoteCurrency: 'USD', minIntervalSeconds: null, createdAt: '2026-09-24T10:00:00Z', + }); + assert.ok(!one(`SELECT name FROM sqlite_master WHERE name = 'ProviderEndpointOud'`), 'de tijdelijke tabel is weg'); + }); + + it('weigert een interval van nul in de database zelf', () => { + opzet95(); + runMigrations(); + assert.throws(() => run(`UPDATE ProviderEndpoint SET minIntervalSeconds = 0 WHERE id = 7`), /CHECK/); + run(`UPDATE ProviderEndpoint SET minIntervalSeconds = 600 WHERE id = 7`); + assert.equal(one(`SELECT minIntervalSeconds AS s FROM ProviderEndpoint`).s, 600); + }); +}); + +describe('migratie 97: één bronkeuze per asset, en historie voor reeks en punt samen', () => { + function opzet96() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (96)`); + run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY, symbol TEXT NOT NULL) STRICT;`); + run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY, key TEXT NOT NULL) STRICT;`); + run(`INSERT INTO Asset (id, symbol) VALUES (1, 'XAU'), (2, 'ETH')`); + run(`INSERT INTO Provider (id, key) VALUES (1, 'reeks'), (2, 'perdatum'), (3, 'spot'), (4, 'tweede')`); + consumerSourceVoor97(); + } + const cel = (consumerKey, assetId, capability, providerId, rank, status, createdAt = null) => + run(`INSERT INTO ConsumerSource (consumerKey, assetId, capability, providerId, rank, status, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?)`, [consumerKey, assetId, capability, providerId, rank, status, createdAt]); + const keuzes = (assetId = 1) => all(`SELECT capability, providerId, rank, status FROM PriceSource + WHERE assetId = ? ORDER BY capability, rank IS NULL, rank, providerId`, [assetId]); + + it('neemt per bron de beste rang over alle consumenten en nummert opnieuw', () => { + opzet96(); + cel('fiat', 1, 'spot', 3, 1, 'confirmed'); + cel('fiat', 1, 'spot', 4, 2, 'confirmed'); + cel('tax', 1, 'spot', 4, 1, 'confirmed'); + cel('converter', 1, 'spot', 3, 2, 'confirmed'); + runMigrations(); + assert.deepEqual(keuzes(), [ + { capability: 'spot', providerId: 3, rank: 1, status: 'confirmed' }, + { capability: 'spot', providerId: 4, rank: 2, status: 'confirmed' }, + ], 'gelijke beste rang: de laagste id gaat voor, zodat de uitkomst niet van de volgorde van de rijen afhangt'); + }); + + it('zet onder historie de reeks voor de bron per datum, ook als die een betere rang had', () => { + opzet96(); + cel('tax', 1, 'history_point', 2, 1, 'confirmed'); + cel('tax', 1, 'daily_history', 1, 2, 'confirmed'); + runMigrations(); + assert.deepEqual(keuzes(), [ + { capability: 'history', providerId: 1, rank: 1, status: 'confirmed' }, + { capability: 'history', providerId: 2, rank: 2, status: 'confirmed' }, + ]); + }); + + it('een handmatige keuze wint bij gelijke rang, en de sterkste status blijft', () => { + opzet96(); + cel('fiat', 1, 'spot', 3, 1, 'proposed'); + cel('tax', 1, 'spot', 3, 1, 'confirmed'); + cel('tax', 1, 'history_point', 2, 1, 'confirmed'); + cel('fiat', 1, 'history_point', 4, 1, 'manual'); + runMigrations(); + assert.deepEqual(keuzes(), [ + { capability: 'history', providerId: 4, rank: 1, status: 'manual' }, + { capability: 'history', providerId: 2, rank: 2, status: 'confirmed' }, + { capability: 'spot', providerId: 3, rank: 1, status: 'confirmed' }, + ]); + }); + + it('een bron die alleen uitgesloten staat, blijft uitgesloten en telt niet mee in de rangen', () => { + opzet96(); + cel('tax', 1, 'spot', 4, null, 'manual'); + cel('fiat', 1, 'spot', 3, 2, 'confirmed'); + runMigrations(); + assert.deepEqual(keuzes(), [ + { capability: 'spot', providerId: 3, rank: 1, status: 'confirmed' }, + { capability: 'spot', providerId: 4, rank: null, status: 'manual' }, + ]); + }); + + it('houdt assets apart, bewaart de oudste createdAt, en ruimt ConsumerSource op', () => { + opzet96(); + cel('fiat', 1, 'spot', 3, 1, 'confirmed', '2026-05-01T00:00:00Z'); + cel('tax', 1, 'spot', 3, 1, 'confirmed', '2026-03-01T00:00:00Z'); + cel('tax', 2, 'spot', 4, 1, 'confirmed'); + runMigrations(); + assert.equal(one(`SELECT createdAt FROM PriceSource WHERE assetId = 1`).createdAt, '2026-03-01T00:00:00Z'); + assert.deepEqual(keuzes(2), [{ capability: 'spot', providerId: 4, rank: 1, status: 'confirmed' }]); + assert.equal(one(`SELECT name FROM sqlite_master WHERE name = 'ConsumerSource'`), null); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('start ook als een andere tabel ConsumerSource in een commentaar noemt', () => { + opzet96(); + run(`CREATE TABLE DashboardWidget ( + id INTEGER PRIMARY KEY, + type TEXT NOT NULL -- no CHECK, the precedent is ConsumerSource.consumerKey + ) STRICT;`); + cel('tax', 1, 'spot', 3, 1, 'confirmed'); + assert.doesNotThrow(() => runMigrations()); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('weigert wel als een tabel echt naar ConsumerSource verwijst, en laat dan alles staan', () => { + opzet96(); + run(`CREATE TABLE Verwijzer (id INTEGER PRIMARY KEY, bron INTEGER REFERENCES ConsumerSource(id)) STRICT;`); + cel('tax', 1, 'spot', 3, 1, 'confirmed'); + assert.throws(() => runMigrations(), /Migration 97 aborted: Verwijzer/); + assert.ok(one(`SELECT name FROM sqlite_master WHERE name = 'ConsumerSource'`), 'teruggedraaid'); + assert.equal(one(`SELECT name FROM sqlite_master WHERE name = 'PriceSource'`), null); + }); + + it('weigert in de nieuwe tabel een endpoint-capability als keuze', () => { + opzet96(); + runMigrations(); + assert.throws(() => run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) + VALUES (1, 'daily_history', 1, 1, 'manual')`), /CHECK/); + }); +}); + +describe('migratie 98: de voorstelsoort source_choice', () => { + function opzet97() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (97)`); + for (const t of ['Asset', 'Account', 'Event', 'Valuation']) run(`CREATE TABLE ${t} (id INTEGER PRIMARY KEY) STRICT;`); + run(`INSERT INTO Asset (id) VALUES (1)`); + run(`INSERT INTO Event (id) VALUES (7)`); + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, + accountId INTEGER, fromAccountId INTEGER, toAccountId INTEGER, + createdEventId INTEGER, createdAccountId INTEGER, createdValuationId INTEGER, + kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio','preference')), + name TEXT, accountKind TEXT, isOwnCustody INTEGER, eventType TEXT, amount INTEGER, eur REAL, eurFee REAL, + balance INTEGER, observedAt TEXT, scope TEXT, key TEXT, value TEXT, note TEXT, + status TEXT NOT NULL, detail TEXT, resolvedAt TEXT, createdAt TEXT NOT NULL, + CHECK (kind = 'preference' OR (scope IS NULL AND key IS NULL AND value IS NULL)) + ) STRICT;`); + run(`INSERT INTO AiProposal (id, kind, scope, key, value, status, createdAt) + VALUES (3, 'preference', 'app', 'currency', '"usd"', 'pending', '2026-09-05T00:00:00Z')`); + run(`INSERT INTO AiProposal (id, assetId, createdEventId, kind, eventType, amount, observedAt, toAccountId, status, resolvedAt, createdAt) + VALUES (4, 1, 7, 'event', 'interest', 5, '2026-01-01T00:00:00Z', 1, 'accepted', '2026-09-06T00:00:00Z', '2026-09-05T00:00:00Z')`); + } + + it('neemt elke kolom mee, ook de herkomst en de voorkeurvelden', () => { + opzet97(); + runMigrations(); + assert.deepEqual({ ...one(`SELECT scope, key, value FROM AiProposal WHERE id = 3`) }, + { scope: 'app', key: 'currency', value: '"usd"' }); + assert.deepEqual({ ...one(`SELECT createdEventId, resolvedAt FROM AiProposal WHERE id = 4`) }, + { createdEventId: 7, resolvedAt: '2026-09-06T00:00:00Z' }); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('laat een bronkeuze toe, en weigert er een met een onbekende actie of zonder bron', () => { + opzet97(); + runMigrations(); + run(`INSERT INTO AiProposal (assetId, kind, scope, key, value, status, createdAt) + VALUES (1, 'source_choice', 'history', 'eulerpool', 'first', 'pending', '2026-09-25T00:00:00Z')`); + assert.throws(() => run(`INSERT INTO AiProposal (assetId, kind, scope, key, value, status, createdAt) + VALUES (1, 'source_choice', 'history', 'eulerpool', 'bovenaan', 'pending', '2026-09-25T00:00:00Z')`), /CHECK/); + assert.throws(() => run(`INSERT INTO AiProposal (assetId, kind, scope, key, value, status, createdAt) + VALUES (1, 'source_choice', 'daily_history', 'eulerpool', 'first', 'pending', '2026-09-25T00:00:00Z')`), /CHECK/); + assert.throws(() => run(`INSERT INTO AiProposal (kind, name, scope, status, createdAt) + VALUES ('portfolio', 'Demo', 'history', 'pending', '2026-09-25T00:00:00Z')`), /CHECK/, + 'een andere soort draagt die velden nog steeds niet'); + }); +}); + +describe('migratie 99: Eulerpool als één bron', () => { + function opzet98({ beide = true } = {}) { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (98)`); + run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY, key TEXT NOT NULL UNIQUE) STRICT;`); + run(`CREATE TABLE ProviderCoverage (id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL, symbol TEXT NOT NULL, UNIQUE (providerId, symbol)) STRICT;`); + run(`CREATE TABLE ProviderEndpoint (id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL, capability TEXT NOT NULL, pathTemplate TEXT NOT NULL) STRICT;`); + run(`CREATE TABLE ProviderInputMap ( + id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + symbol TEXT NOT NULL, externalId TEXT NOT NULL, createdAt TEXT, UNIQUE (providerId, symbol)) STRICT;`); + for (const t of ['ProviderOutputMap', 'ProviderCallLimit', 'ProviderAuthenticationHmac', 'ProviderFixedRate']) { + run(`CREATE TABLE ${t} (id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL) STRICT;`); + } + run(`CREATE TABLE PriceSource (id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL, providerId INTEGER NOT NULL, + capability TEXT NOT NULL, rank INTEGER, status TEXT NOT NULL, + UNIQUE (assetId, capability, providerId), UNIQUE (assetId, capability, rank)) STRICT;`); + run(`CREATE TABLE PriceHistory (id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL, providerId INTEGER NOT NULL, + observedAt TEXT NOT NULL, resolution TEXT NOT NULL, eur REAL, UNIQUE (assetId, providerId, observedAt, resolution)) STRICT;`); + run(`CREATE TABLE ApiCallMetric (providerId INTEGER NOT NULL, capability TEXT NOT NULL, bucketMinute TEXT NOT NULL, + okCount INTEGER NOT NULL, failCount INTEGER NOT NULL, sumLatencyMs INTEGER NOT NULL, maxLatencyMs INTEGER NOT NULL, + PRIMARY KEY (providerId, capability, bucketMinute)) STRICT;`); + if (beide) { + run(`INSERT INTO Provider (id, key) VALUES (1, 'eulerpool')`); + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (1, 'BTC')`); + run(`INSERT INTO ProviderEndpoint (providerId, capability, pathTemplate) VALUES (1, 'daily_history', '/api/1/crypto/quotes/{symbol}')`); + run(`INSERT INTO ApiCallMetric VALUES (1, 'daily_history', '2026-09-25T10:00', 3, 1, 300, 150)`); + } + run(`INSERT INTO Provider (id, key) VALUES (2, 'eulerpool_metal')`); + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (2, 'XAU')`); + run(`INSERT INTO ProviderEndpoint (providerId, capability, pathTemplate) VALUES (2, 'daily_history', '/api/1/commodity/quotes/{symbol}')`); + run(`INSERT INTO ProviderCallLimit (providerId) VALUES (2)`); + run(`INSERT INTO PriceSource (assetId, providerId, capability, rank, status) VALUES (9, 2, 'history', 1, 'manual')`); + run(`INSERT INTO PriceHistory (assetId, providerId, observedAt, resolution, eur) VALUES (9, 2, '2026-01-01T00:00:00Z', 'day', 2.3)`); + run(`INSERT INTO ApiCallMetric VALUES (2, 'daily_history', '2026-09-25T10:00', 2, 0, 100, 90)`); + } + + it('voegt de metaalrij samen in de cryptorij, met alles wat eraan hing', () => { + opzet98(); + runMigrations(); + assert.equal(one(`SELECT id FROM Provider WHERE key = 'eulerpool_metal'`), null, 'de metaalrij is weg'); + assert.equal(one(`SELECT pathTemplate AS p FROM ProviderEndpoint WHERE providerId = 1`).p, '/api/1/{external_id}/quotes/{symbol}'); + assert.deepEqual(all(`SELECT symbol, externalId, quoteUnit FROM ProviderInputMap WHERE providerId = 1 ORDER BY symbol`).map(r => ({ ...r })), + [{ symbol: null, externalId: 'crypto', quoteUnit: null }, { symbol: 'XAU', externalId: 'commodity', quoteUnit: 'ozt' }]); + assert.deepEqual(all(`SELECT symbol FROM ProviderCoverage WHERE providerId = 1 ORDER BY symbol`).map(r => r.symbol), ['BTC', 'XAU']); + assert.deepEqual({ ...one(`SELECT providerId, rank, status FROM PriceSource WHERE assetId = 9`) }, { providerId: 1, rank: 1, status: 'manual' }); + assert.equal(one(`SELECT providerId FROM PriceHistory WHERE assetId = 9`).providerId, 1); + assert.deepEqual({ ...one(`SELECT okCount, failCount, sumLatencyMs, maxLatencyMs FROM ApiCallMetric WHERE providerId = 1`) }, + { okCount: 5, failCount: 1, sumLatencyMs: 400, maxLatencyMs: 150 }); + for (const t of ['ProviderEndpoint', 'ProviderCoverage', 'ProviderCallLimit', 'ApiCallMetric', 'PriceSource', 'PriceHistory']) { + assert.equal(one(`SELECT COUNT(*) AS n FROM ${t} WHERE providerId = 2`).n, 0, `${t} houdt een rij van de metaalbron`); + } + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('laat alles staan als alleen de metaalrij er is, maar zet wel de kolom neer', () => { + opzet98({ beide: false }); + runMigrations(); + assert.ok(one(`SELECT id FROM Provider WHERE key = 'eulerpool_metal'`)); + assert.equal(one(`SELECT pathTemplate AS p FROM ProviderEndpoint WHERE providerId = 2`).p, '/api/1/commodity/quotes/{symbol}'); + run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, isCatchAll) VALUES (2, 'XAU', 'x', 'ozt', 0)`); + assert.throws(() => run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, isCatchAll) VALUES (2, 'XAG', 'x', 'pond', 0)`), /CHECK/); + }); +}); + +describe('migratie 102: de catch-all in de invoermapping', () => { + function opzet101() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (101)`); + run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY, key TEXT NOT NULL UNIQUE) STRICT;`); + run(`CREATE TABLE ProviderInputMap ( + id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + symbol TEXT NOT NULL, externalId TEXT NOT NULL, quoteUnit TEXT CHECK (quoteUnit IS NULL OR quoteUnit IN ('ozt')), + createdAt TEXT, UNIQUE (providerId, symbol)) STRICT;`); + run(`INSERT INTO Provider (id, key) VALUES (1, 'eulerpool'), (2, 'andere')`); + for (const s of ['BTC', 'ETH', 'SOL']) run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId) VALUES (1, ?, 'crypto')`, [s]); + run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit) VALUES (1, 'XAU', 'commodity', 'ozt')`); + run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId) VALUES (1, 'WXT', 'wirex')`); + run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId) VALUES (2, 'BTC', 'crypto')`); + } + + it('maakt van de crypto-regels van Eulerpool één catch-all en laat de rest staan', () => { + opzet101(); + runMigrations(); + const rijen = all(`SELECT providerId, symbol, externalId, quoteUnit, isCatchAll FROM ProviderInputMap + ORDER BY providerId, isCatchAll, symbol`).map(r => ({ ...r })); + assert.deepEqual(rijen, [ + { providerId: 1, symbol: 'WXT', externalId: 'wirex', quoteUnit: null, isCatchAll: 0 }, + { providerId: 1, symbol: 'XAU', externalId: 'commodity', quoteUnit: 'ozt', isCatchAll: 0 }, + { providerId: 1, symbol: null, externalId: 'crypto', quoteUnit: null, isCatchAll: 1 }, + { providerId: 2, symbol: 'BTC', externalId: 'crypto', quoteUnit: null, isCatchAll: 0 }, + ]); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('staat één catch-all per bron toe, zonder symbool, en een gewone regel mét', () => { + opzet101(); + runMigrations(); + assert.throws(() => run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, isCatchAll) VALUES (1, NULL, 'x', 1)`), /UNIQUE/); + assert.throws(() => run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, isCatchAll) VALUES (2, 'ETH', 'x', 1)`), /CHECK/); + assert.throws(() => run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, isCatchAll) VALUES (2, NULL, 'x', 0)`), /CHECK/); + }); +}); + +describe('migratie 103: de tijd van Eulerpool-metaal', () => { + function opzet102() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (102)`); + run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY, key TEXT NOT NULL UNIQUE) STRICT;`); + run(`CREATE TABLE ProviderOutputMap ( + id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + jsonPath TEXT NOT NULL, + capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')), + internalField TEXT NOT NULL CHECK (internalField IN ('EUR','USD','time','price')), + transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram','iso_date_to_s')), + createdAt TEXT) STRICT;`); + run(`CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability)`); + run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY, symbol TEXT NOT NULL, kind TEXT NOT NULL) STRICT;`); + run(`CREATE TABLE PriceHistory (id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL, providerId INTEGER NOT NULL, + observedAt TEXT NOT NULL, resolution TEXT NOT NULL, eur REAL, UNIQUE (assetId, providerId, observedAt, resolution)) STRICT;`); + run(`INSERT INTO Provider (id, key) VALUES (1, 'eulerpool'), (2, 'andere')`); + run(`CREATE TABLE ProviderEndpoint (id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL, capability TEXT NOT NULL, + quoteCurrency TEXT) STRICT;`); + run(`INSERT INTO ProviderEndpoint (providerId, capability, quoteCurrency) VALUES (1, 'daily_history', 'USD')`); + const kaart = (p, veld, pad, t) => run(`INSERT INTO ProviderOutputMap (providerId, jsonPath, capability, internalField, transform) + VALUES (?, ?, 'daily_history', ?, ?)`, [p, pad, veld, t]); + kaart(1, 'time', '[].timestamp', 'ms_to_s'); + kaart(1, 'USD', '[].price', 'last_per_day'); + kaart(2, 'time', 'p[].0', 'ms_to_s'); + run(`INSERT INTO Asset (id, symbol, kind) VALUES (1, 'XAG', 'metal'), (2, 'BTC', 'crypto')`); + const koers = (asset, bron) => run(`INSERT INTO PriceHistory (assetId, providerId, observedAt, resolution, eur) + VALUES (?, ?, '2025-12-01T00:00:00Z', 'day', 1)`, [asset, bron]); + koers(1, 1); + koers(2, 1); + koers(1, 2); + } + + it('zet de tijdregel van Eulerpool om en haalt alleen zijn metaalkoersen weg', () => { + opzet102(); + runMigrations(); + assert.deepEqual(all(`SELECT providerId, internalField, transform FROM ProviderOutputMap ORDER BY providerId, internalField`).map(r => ({ ...r })), [ + { providerId: 1, internalField: 'price', transform: 'last_per_day' }, + { providerId: 1, internalField: 'time', transform: 'ms_to_s_local_close' }, + { providerId: 2, internalField: 'time', transform: 'ms_to_s' }, + ]); + assert.deepEqual(all(`SELECT assetId, providerId FROM PriceHistory ORDER BY assetId, providerId`).map(r => ({ ...r })), [ + { assetId: 1, providerId: 2 }, + { assetId: 2, providerId: 1 }, + ]); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('laat de CHECK de nieuwe transformatie toe en een verzonnen nog steeds niet', () => { + opzet102(); + runMigrations(); + run(`INSERT INTO ProviderOutputMap (providerId, jsonPath, capability, internalField, transform) + VALUES (2, 'x', 'spot', 'price', 'ms_to_s_local_close')`); + assert.throws(() => run(`INSERT INTO ProviderOutputMap (providerId, jsonPath, capability, internalField, transform) + VALUES (2, 'x', 'spot', 'price', 'verzonnen')`), /CHECK/); + }); +}); + +describe('migratie 104: de rekenvaluta als gegeven van de portefeuille', () => { + function opzet103() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (103)`); + } + + it('legt EUR neer, zonder verzonnen aanmaakdatum', () => { + opzet103(); + runMigrations(); + assert.deepEqual(all(`SELECT id, baseCurrency, createdAt FROM Portfolio`), + [{ id: 1, baseCurrency: 'EUR', createdAt: null }]); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('laat de rij daarna niet meer wijzigen of weghalen', () => { + opzet103(); + runMigrations(); + assert.throws(() => run(`UPDATE Portfolio SET baseCurrency = 'USD'`), /fixed after creation/); + assert.throws(() => run(`DELETE FROM Portfolio`), /fixed after creation/); + }); + + it('laat een bestaande Portfolio-tabel staan', () => { + opzet103(); + run(`CREATE TABLE Portfolio (id INTEGER PRIMARY KEY, baseCurrency TEXT NOT NULL, createdAt TEXT) STRICT`); + run(`INSERT INTO Portfolio (id, baseCurrency, createdAt) VALUES (1, 'USD', '2026-09-01T00:00:00Z')`); + runMigrations(); + assert.deepEqual(all(`SELECT baseCurrency FROM Portfolio`), [{ baseCurrency: 'USD' }]); + }); +}); + +describe('migratie 105: de geldkolommen heten fiat', () => { + function opzet104() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (104)`); + run(`CREATE TABLE Event (id INTEGER PRIMARY KEY, eurPrice REAL CHECK (eurPrice IS NULL OR eurPrice >= 0), eurFee REAL) STRICT`); + run(`CREATE TABLE PriceHistory (id INTEGER PRIMARY KEY, eur REAL, usd REAL, CHECK (eur IS NOT NULL OR usd IS NOT NULL)) STRICT`); + run(`CREATE TABLE Preference (scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (scope, key)) STRICT`); + run(`INSERT INTO Event VALUES (1, 30000, 12.5)`); + run(`INSERT INTO PriceHistory VALUES (1, 40000, 44000)`); + const zet = (scope, key, value) => run(`INSERT INTO Preference VALUES (?, ?, ?)`, [scope, key, value]); + zet('table.trades', 'view', JSON.stringify({ sort: { key: 'eurPrice', dir: 'desc' }, group: null })); + zet('table.valuations', 'view', JSON.stringify({ sort: { key: 'valuedOn', dir: 'desc' } })); + zet('table.prijsinfo', 'view', JSON.stringify({ sort: { key: 'eur', dir: 'asc' } })); + zet('table.transfers', 'view', JSON.stringify({ sort: { key: 'eurFee', dir: 'asc' } })); + zet('table.trades', 'sticky', 'geen json'); + } + const view = scope => JSON.parse(one(`SELECT value FROM Preference WHERE scope = ? AND key = 'view'`, [scope]).value); + + it('hernoemt de kolommen en houdt elke waarde vast', () => { + opzet104(); + runMigrations(); + assert.deepEqual(one(`SELECT id, fiatPrice, fiatFee FROM Event`), { id: 1, fiatPrice: 30000, fiatFee: 12.5 }); + assert.deepEqual(one(`SELECT id, fiatPrice, usd FROM PriceHistory`), { id: 1, fiatPrice: 40000, usd: 44000 }); + assert.throws(() => run(`UPDATE Event SET fiatPrice = -1`), /CHECK/, 'de CHECK is mee hernoemd'); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('neemt de bewaarde sortering op een hernoemde kolom mee, en verder niets', () => { + opzet104(); + runMigrations(); + assert.deepEqual(view('table.trades'), { sort: { key: 'fiatPrice', dir: 'desc' }, group: null }); + assert.deepEqual(view('table.prijsinfo').sort, { key: 'fiatPrice', dir: 'asc' }); + assert.deepEqual(view('table.valuations').sort, { key: 'valuedOn', dir: 'desc' }, 'een andere kolom blijft'); + assert.deepEqual(view('table.transfers').sort, { key: 'eurFee', dir: 'asc' }, 'een andere scope blijft'); + assert.equal(one(`SELECT value FROM Preference WHERE key = 'sticky'`).value, 'geen json'); + }); +}); + +describe('migratie 109: de totaalkolom van Boekingen heet fiatTotal', () => { + function opzet108() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (108)`); + run(`CREATE TABLE Preference (scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (scope, key)) STRICT`); + const zet = (scope, key, value) => run(`INSERT INTO Preference VALUES (?, ?, ?)`, [scope, key, value]); + zet('table.trades', 'view', JSON.stringify({ sort: { key: 'eurTotal', dir: 'asc' }, group: 'type' })); + zet('table.transfers', 'view', JSON.stringify({ sort: { key: 'eurTotal', dir: 'asc' } })); + zet('table.trades', 'sticky', 'geen json'); + } + const view = scope => JSON.parse(one(`SELECT value FROM Preference WHERE scope = ? AND key = 'view'`, [scope]).value); + + it('zet de sortering op het totaal om, en laat de rest van de weergave staan', () => { + opzet108(); + runMigrations(); + assert.deepEqual(view('table.trades'), { sort: { key: 'fiatTotal', dir: 'asc' }, group: 'type' }); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('raakt een andere scope en een onleesbare voorkeur niet', () => { + opzet108(); + runMigrations(); + assert.deepEqual(view('table.transfers').sort, { key: 'eurTotal', dir: 'asc' }); + assert.equal(one(`SELECT value FROM Preference WHERE key = 'sticky'`).value, 'geen json'); + }); + + it('laat een sortering op een andere kolom staan', () => { + opzet108(); + run(`UPDATE Preference SET value = ? WHERE scope = 'table.trades' AND key = 'view'`, + [JSON.stringify({ sort: { key: 'fiatPrice', dir: 'desc' } })]); + runMigrations(); + assert.deepEqual(view('table.trades').sort, { key: 'fiatPrice', dir: 'desc' }); + }); +}); + +describe('migratie 107: één prijsveld, en PriceHistory.usd weg', () => { + function opzet106() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (106)`); + run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY) STRICT; INSERT INTO Provider VALUES (1), (2), (3);`); + run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY) STRICT; INSERT INTO Asset VALUES (1);`); + run(`CREATE TABLE ProviderEndpoint (id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL, capability TEXT NOT NULL, + quoteCurrency TEXT) STRICT;`); + run(`CREATE TABLE ProviderOutputMap ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL, + jsonPath TEXT NOT NULL, + capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')), + internalField TEXT NOT NULL CHECK (internalField IN ('EUR','USD','time','price')), + transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram','iso_date_to_s','ms_to_s_local_close')), + createdAt TEXT + ) STRICT;`); + run(`CREATE TABLE PriceHistory ( + id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL, providerId INTEGER NOT NULL, fiatPrice REAL, usd REAL, + resolution TEXT NOT NULL, observedAt TEXT NOT NULL, createdAt TEXT, updatedAt TEXT, + UNIQUE (assetId, providerId, observedAt, resolution), CHECK (fiatPrice IS NOT NULL OR usd IS NOT NULL)) STRICT;`); + run(`INSERT INTO ProviderEndpoint (providerId, capability, quoteCurrency) VALUES (1, 'spot', NULL), (2, 'daily_history', 'USD'), (3, 'spot', 'GBP')`); + const kaart = (id, p, cap, veld, pad) => run(`INSERT INTO ProviderOutputMap (id, providerId, jsonPath, capability, internalField, transform) + VALUES (?, ?, ?, ?, ?, 'none')`, [id, p, pad, cap, veld]); + kaart(1, 1, 'spot', 'EUR', 'EUR'); + kaart(2, 1, 'spot', 'USD', 'USD'); + kaart(3, 2, 'daily_history', 'time', '[].timestamp'); + kaart(4, 2, 'daily_history', 'USD', '[].price'); + kaart(5, 3, 'spot', 'price', 'rate'); + run(`INSERT INTO PriceHistory (id, assetId, providerId, fiatPrice, usd, resolution, observedAt) + VALUES (1, 1, 1, 50000, 55000, 'day', '2026-01-01T00:00:00Z'), (2, 1, 1, NULL, 56000, 'day', '2026-01-02T00:00:00Z')`); + } + const mapping = () => all(`SELECT id, providerId, internalField FROM ProviderOutputMap ORDER BY id`); + + it('maakt van het veld dat de adapter las `price`, en haalt de rest weg', () => { + opzet106(); + runMigrations(); + assert.deepEqual(mapping(), [ + { id: 1, providerId: 1, internalField: 'price' }, + { id: 3, providerId: 2, internalField: 'time' }, + { id: 4, providerId: 2, internalField: 'price' }, + { id: 5, providerId: 3, internalField: 'price' }, + ]); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('weigert daarna een veld EUR of USD', () => { + opzet106(); + runMigrations(); + for (const veld of ['EUR', 'USD']) { + assert.throws(() => run(`INSERT INTO ProviderOutputMap (providerId, jsonPath, capability, internalField, transform) + VALUES (1, 'x', 'spot', ?, 'none')`, [veld]), /CHECK/, veld); + } + }); + + it('haalt de usd-kolom weg, en de rij die alleen een dollarkoers had', () => { + opzet106(); + runMigrations(); + assert.deepEqual(all(`SELECT id, fiatPrice FROM PriceHistory`), [{ id: 1, fiatPrice: 50000 }]); + assert.equal(all(`PRAGMA table_info(PriceHistory)`).some(k => k.name === 'usd'), false); + assert.throws(() => run(`INSERT INTO PriceHistory (assetId, providerId, resolution, observedAt) + VALUES (1, 1, 'day', '2026-01-03T00:00:00Z')`), /NOT NULL/, 'een rij zonder prijs kan niet meer'); + }); +}); + +describe('migratie 108: een portefeuille-voorstel noemt zijn munt', () => { + function opzet107() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (107)`); + run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY) STRICT; CREATE TABLE Account (id INTEGER PRIMARY KEY) STRICT; + CREATE TABLE Event (id INTEGER PRIMARY KEY) STRICT; CREATE TABLE Valuation (id INTEGER PRIMARY KEY) STRICT;`); + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, assetId INTEGER, accountId INTEGER, fromAccountId INTEGER, toAccountId INTEGER, + createdEventId INTEGER, createdAccountId INTEGER, createdValuationId INTEGER, + kind TEXT NOT NULL, name TEXT, accountKind TEXT, isOwnCustody INTEGER, eventType TEXT, amount INTEGER, + fiatPrice REAL, fiatFee REAL, balance INTEGER, observedAt TEXT, scope TEXT, key TEXT, value TEXT, + note TEXT, status TEXT NOT NULL, detail TEXT, resolvedAt TEXT, createdAt TEXT NOT NULL) STRICT`); + run(`INSERT INTO AiProposal (id, kind, name, note, status, createdAt) VALUES + (1, 'portfolio', 'Demo', 'een demo', 'pending', '2026-09-01T00:00:00Z'), + (2, 'portfolio', 'Oud', NULL, 'accepted', '2026-08-01T00:00:00Z')`); + run(`INSERT INTO AiProposal (id, kind, name, accountKind, isOwnCustody, status, createdAt) + VALUES (3, 'account', 'Kraken', 'exchange', 0, 'pending', '2026-09-02T00:00:00Z')`); + } + const rijen = () => all(`SELECT id, kind, name, currency, note, status FROM AiProposal ORDER BY id`).map(r => ({ ...r })); + + it('geeft een portefeuille-voorstel EUR en elk ander geen munt', () => { + opzet107(); + runMigrations(); + assert.deepEqual(rijen(), [ + { id: 1, kind: 'portfolio', name: 'Demo', currency: 'EUR', note: 'een demo', status: 'pending' }, + { id: 2, kind: 'portfolio', name: 'Oud', currency: 'EUR', note: null, status: 'accepted' }, + { id: 3, kind: 'account', name: 'Kraken', currency: null, note: null, status: 'pending' }, + ]); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('eist daarna een munt bij een portefeuille, en alleen daar', () => { + opzet107(); + runMigrations(); + const zet = (kind, extra, currency) => () => run(`INSERT INTO AiProposal (kind, name, ${extra ? 'accountKind, isOwnCustody, ' : ''}currency, status, createdAt) + VALUES (?, 'X', ${extra ? "'exchange', 0, " : ''}?, 'pending', '2026-09-03T00:00:00Z')`, [kind, currency]); + assert.throws(zet('portfolio', false, null), /CHECK/, 'een portefeuille zonder munt'); + assert.throws(zet('portfolio', false, 'usd'), /CHECK/, 'een munt in kleine letters'); + assert.throws(zet('account', true, 'USD'), /CHECK/, 'een munt op een ander voorstel'); + assert.doesNotThrow(zet('portfolio', false, 'USD')); + }); + + it('laat een tabel die de kolom al heeft staan', () => { + opzet107(); + run(`ALTER TABLE AiProposal ADD COLUMN currency TEXT`); + run(`UPDATE AiProposal SET currency = 'GBP' WHERE id = 1`); + run(`UPDATE AiProposal SET currency = 'EUR' WHERE id = 2`); + runMigrations(); + assert.equal(one(`SELECT currency FROM AiProposal WHERE id = 1`).currency, 'GBP'); + }); +}); + +describe('migratie 125: een AI-boekingsvoorstel draagt een fee in eenheden', () => { + function opzet124() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (124)`); + run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY) STRICT; CREATE TABLE Account (id INTEGER PRIMARY KEY) STRICT; + CREATE TABLE Event (id INTEGER PRIMARY KEY) STRICT; CREATE TABLE Valuation (id INTEGER PRIMARY KEY) STRICT;`); + run(`INSERT INTO Asset (id) VALUES (1); INSERT INTO Account (id) VALUES (1), (2);`); + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, assetId INTEGER, accountId INTEGER, fromAccountId INTEGER, toAccountId INTEGER, + createdEventId INTEGER, createdAccountId INTEGER, createdValuationId INTEGER, + kind TEXT NOT NULL, name TEXT, accountKind TEXT, isOwnCustody INTEGER, eventType TEXT, amount INTEGER, + fiatPrice REAL, fiatFee REAL, balance INTEGER, observedAt TEXT, scope TEXT, key TEXT, value TEXT, + currency TEXT, note TEXT, status TEXT NOT NULL, detail TEXT, resolvedAt TEXT, createdAt TEXT NOT NULL) STRICT`); + run(`INSERT INTO AiProposal (id, assetId, fromAccountId, kind, eventType, amount, fiatPrice, fiatFee, + observedAt, note, status, createdAt) + VALUES (1, 1, 1, 'event', 'sell', 500, 20.5, 1.25, '2026-10-01T10:00:00Z', 'een verkoop', 'accepted', + '2026-10-01T10:00:00Z')`); + run(`INSERT INTO AiProposal (id, kind, name, currency, status, createdAt) + VALUES (2, 'portfolio', 'Demo', 'USD', 'pending', '2026-10-02T00:00:00Z')`); + } + const voorstel = (eventType, unitFee, { from = 1, to = null, prijs = 10 } = {}) => () => run( + `INSERT INTO AiProposal (assetId, fromAccountId, toAccountId, kind, eventType, amount, fiatPrice, unitFee, + observedAt, status, createdAt) + VALUES (1, ?, ?, 'event', ?, 100, ?, ?, '2026-10-03T00:00:00Z', 'pending', '2026-10-03T00:00:00Z')`, + [from, to, eventType, prijs, unitFee]); + + it('neemt elke bestaande waarde mee en geeft de kolom NULL', () => { + opzet124(); + runMigrations(); + const rijen = all(`SELECT id, kind, eventType, amount, fiatPrice, fiatFee, unitFee, currency, note, status + FROM AiProposal ORDER BY id`).map(r => ({ ...r })); + assert.deepEqual(rijen, [ + { id: 1, kind: 'event', eventType: 'sell', amount: 500, fiatPrice: 20.5, fiatFee: 1.25, unitFee: null, + currency: null, note: 'een verkoop', status: 'accepted' }, + { id: 2, kind: 'portfolio', eventType: null, amount: null, fiatPrice: null, fiatFee: null, unitFee: null, + currency: 'USD', note: null, status: 'pending' }, + ]); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); + + it('laat een fee in eenheden alleen toe op een verkoop of een verplaatsing, en nooit negatief', () => { + opzet124(); + runMigrations(); + assert.doesNotThrow(voorstel('sell', 3)); + assert.doesNotThrow(voorstel('transfer', 3, { to: 2, prijs: null })); + assert.throws(voorstel('buy', 3, { from: null, to: 1 }), /CHECK/, 'een aankoop: daar telt hij nergens'); + assert.throws(voorstel('interest', 3, { from: null, to: 1, prijs: null }), /CHECK/, 'rente: idem'); + assert.throws(voorstel('sell', -1), /CHECK/, 'een negatieve fee'); + }); + + it('laat een tabel die de kolom al heeft staan', () => { + opzet124(); + run(`ALTER TABLE AiProposal ADD COLUMN unitFee INTEGER`); + run(`UPDATE AiProposal SET unitFee = 7 WHERE id = 1`); + runMigrations(); + assert.equal(one(`SELECT unitFee FROM AiProposal WHERE id = 1`).unitFee, 7); + }); +}); + +describe('migratie 106: de weergavevaluta is weg', () => { + function opzet105() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (105)`); + run(`CREATE TABLE Preference (scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (scope, key)) STRICT`); + run(`CREATE TABLE AiProposal (id INTEGER PRIMARY KEY, kind TEXT NOT NULL, scope TEXT, key TEXT, value TEXT, + status TEXT NOT NULL, detail TEXT, resolvedAt TEXT) STRICT`); + run(`INSERT INTO Preference VALUES ('app', 'currency', '"usd"'), ('app', 'theme', '"dark"')`); + run(`INSERT INTO AiProposal (id, kind, scope, key, value, status) VALUES + (1, 'preference', 'app', 'currency', '"usd"', 'pending'), + (2, 'preference', 'app', 'currency', '"eur"', 'accepted'), + (3, 'preference', 'app', 'theme', '"light"', 'pending')`); + } + + it('haalt de voorkeur weg en laat de rest staan', () => { + opzet105(); + runMigrations(); + assert.deepEqual(all(`SELECT scope, key FROM Preference WHERE key <> 'locale'`), [{ scope: 'app', key: 'theme' }]); + }); + + it('wijst alleen een OPENSTAAND voorstel voor die voorkeur af, met een reden', () => { + opzet105(); + runMigrations(); + const rij = id => one(`SELECT status, detail, resolvedAt FROM AiProposal WHERE id = ?`, [id]); + assert.equal(rij(1).status, 'rejected'); + assert.match(rij(1).detail, /app\.currency no longer exists/); + assert.ok(rij(1).resolvedAt); + assert.equal(rij(2).status, 'accepted', 'afgehandeld blijft afgehandeld'); + assert.equal(rij(3).status, 'pending', 'een voorstel voor een andere voorkeur blijft staan'); + }); +}); + +describe('migratie 100: koersen van nul of lager weg', () => { + function opzet99() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (99)`); + run(`CREATE TABLE PriceHistory (id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL, providerId INTEGER NOT NULL, + eur REAL, usd REAL, resolution TEXT NOT NULL, observedAt TEXT NOT NULL, + UNIQUE (assetId, providerId, observedAt, resolution), CHECK (eur IS NOT NULL OR usd IS NOT NULL)) STRICT;`); + const rij = (dag, eur, usd) => run(`INSERT INTO PriceHistory (assetId, providerId, eur, usd, resolution, observedAt) + VALUES (1, 1, ?, ?, 'day', ?)`, [eur, usd, `2012-01-0${dag}T00:00:00Z`]); + rij(1, 0, null); + rij(2, 0, 5); + rij(3, -1, 0); + rij(4, 40000, 0); + rij(5, 40000, 44000); + } + + it('haalt een rij zonder positieve koers weg en maakt een losse nul leeg', () => { + opzet99(); + runMigrations(); + assert.deepEqual(all(`SELECT substr(observedAt, 9, 2) AS dag, fiatPrice AS eur FROM PriceHistory ORDER BY observedAt`).map(r => ({ ...r })), [ + { dag: '04', eur: 40000 }, + { dag: '05', eur: 40000 }, + ]); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + }); +}); + +function terugVoor97() { + run(`DROP TABLE PriceSource`); + consumerSourceVoor97(); +} +function kolomnamenVoor105() { + run(`ALTER TABLE ProviderOutputMap RENAME TO ProviderOutputMapNa107`); + run(`CREATE TABLE ProviderOutputMap ( + id INTEGER PRIMARY KEY, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE, + jsonPath TEXT NOT NULL, + capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')), + internalField TEXT NOT NULL CHECK (internalField IN ('EUR','USD','time','price')), + transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram','iso_date_to_s','ms_to_s_local_close')), + createdAt TEXT + ) STRICT`); + run(`INSERT INTO ProviderOutputMap SELECT * FROM ProviderOutputMapNa107`); + run(`DROP TABLE ProviderOutputMapNa107`); + run(`CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability)`); + if (all(`PRAGMA table_info(PriceHistory)`).length && !all(`PRAGMA table_info(PriceHistory)`).some(k => k.name === 'usd')) { + run(`ALTER TABLE PriceHistory ADD COLUMN usd REAL`); + } + for (const [tabel, nu, oud] of [ + ['Event', 'fiatPrice', 'eurPrice'], ['Event', 'fiatFee', 'eurFee'], ['Valuation', 'fiatRate', 'eurRate'], + ['PriceHistory', 'fiatPrice', 'eur'], ['ProviderFixedRate', 'fiatPrice', 'eur'], + ['AiProposal', 'fiatPrice', 'eur'], ['AiProposal', 'fiatFee', 'eurFee'], + ]) { + const heeft = all(`PRAGMA table_info(${tabel})`).some(k => k.name === nu); + if (heeft) run(`ALTER TABLE ${tabel} RENAME COLUMN ${nu} TO ${oud}`); + } +} +function consumerSourceVoor97() { + run(`CREATE TABLE ConsumerSource ( + id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT, + consumerKey TEXT NOT NULL, capability TEXT NOT NULL, rank INTEGER, status TEXT NOT NULL, createdAt TEXT, + UNIQUE (consumerKey, assetId, capability, providerId), + UNIQUE (consumerKey, assetId, capability, rank) + ) STRICT;`); +} + +describe('migratie 91: het USD-asset en de Frankfurter-bron', () => { + const bronkeuzeUsd = () => all(`SELECT capability, Provider.key AS bron, rank, status + FROM PriceSource + JOIN Provider ON Provider.id = PriceSource.providerId + JOIN Asset ON Asset.id = PriceSource.assetId + WHERE Asset.symbol = 'USD' + ORDER BY capability, rank`); + + it('zet de bron, zijn mapping, het asset en de bronkeuze neer', () => { + opzet({ versie: 60 }); + runMigrations(); + const p = one(`SELECT id, isActive, authenticationRequired FROM Provider WHERE key = 'frankfurter'`); + assert.equal(p.isActive, 1, 'geen sleutel nodig, dus meteen actief'); + assert.equal(p.authenticationRequired, 0); + assert.deepEqual(all(`SELECT capability, pathTemplate, quoteCurrency FROM ProviderEndpoint WHERE providerId = ? ORDER BY capability`, [p.id]), + [{ capability: 'daily_history', pathTemplate: '/v1/1999-01-04..', quoteCurrency: null }, + { capability: 'spot', pathTemplate: '/v1/latest', quoteCurrency: null }]); + assert.equal(all(`SELECT id FROM ProviderOutputMap WHERE providerId = ?`, [p.id]).length, 3); + assert.deepEqual({ ...one(`SELECT kind, unit, decimals FROM Asset WHERE symbol = 'USD'`) }, + { kind: 'fiat', unit: 'USD', decimals: 2 }); + assert.deepEqual(bronkeuzeUsd(), [ + { capability: 'history', bron: 'frankfurter', rank: 1, status: 'confirmed' }, + { capability: 'spot', bron: 'frankfurter', rank: 1, status: 'confirmed' }, + ]); + }); + + it('laat een bestaand USD-asset en een bezette cel staan', () => { + opzet({ versie: 60 }); + runMigrations(); + terugVoor97(); + run(`DELETE FROM Asset WHERE symbol = 'USD'`); + run(`DELETE FROM Provider WHERE key = 'frankfurter'`); + kolomnamenVoor105(); + run(`UPDATE Schema SET version = 90`); + run(`INSERT INTO Asset (symbol, name, searchName, unit, subunit, displayUnit, decimals, kind) + VALUES ('USD', 'Mijn dollar', 'mijn dollar', 'USD', 'cent', 'unit', 2, 'fiat')`); + run(`INSERT INTO ConsumerSource (consumerKey, assetId, capability, providerId, rank, status) + SELECT 'tax', Asset.id, 'spot', Provider.id, 1, 'manual' FROM Asset, Provider + WHERE Asset.symbol = 'USD' AND Provider.key = 'fixed'`); + runMigrations(); + assert.equal(one(`SELECT name FROM Asset WHERE symbol = 'USD'`).name, 'Mijn dollar'); + assert.equal(all(`SELECT id FROM Asset WHERE symbol = 'USD'`).length, 1); + assert.deepEqual(bronkeuzeUsd(), [ + { capability: 'history', bron: 'frankfurter', rank: 1, status: 'confirmed' }, + { capability: 'spot', bron: 'fixed', rank: 1, status: 'manual' }, + { capability: 'spot', bron: 'frankfurter', rank: 2, status: 'confirmed' }, + ]); + }); +}); + +describe('migratie 92: de Eulerpool-rijen op de endpoints die bestaan', () => { + function opzet91() { + opzet({ versie: 60 }); + runMigrations(); + terugVoor97(); + kolomnamenVoor105(); + run(`UPDATE Schema SET version = 91`); + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationParameterName, authenticationKind, + authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES ('eulerpool_metal', 'Eulerpool (metaal)', 'eulerpool (metaal)', 'https://api.eulerpool.com', 'token', + 'query_key', 1, 1, 1, 0, '2026-01-01T00:00:00Z')`); + const p = one(`SELECT id FROM Provider WHERE key = 'eulerpool_metal'`).id; + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'XAU')`, [p]); + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate) + VALUES (?, 'spot', 'GET', '/v1/commodities/{symbol}/quote', 'vs=eur'), + (?, 'daily_history', 'GET', '/v1/commodities/{symbol}/ohlcv', 'vs=eur&interval=1d')`, [p, p]); + run(`INSERT INTO ProviderOutputMap (providerId, capability, internalField, jsonPath, transform) + VALUES (?, 'spot', 'EUR', 'price', 'troy_ounce_to_gram')`, [p]); + run(`INSERT INTO Asset (id, symbol, name, searchName, unit, subunit, displayUnit, decimals, kind) + VALUES (50, 'XAU', 'Goud', 'goud', 'ozt', 'g', 'unit', 7, 'metal')`); + run(`INSERT INTO ConsumerSource (consumerKey, assetId, capability, providerId, rank, status) + VALUES ('tax', 50, 'spot', ?, 2, 'manual'), ('tax', 50, 'daily_history', ?, 1, 'manual')`, [p, p]); + return p; + } + + it('vervangt de endpoints en de mapping door één reeks in USD', () => { + const p = opzet91(); + runMigrations(); + assert.deepEqual(all(`SELECT capability, pathTemplate, queryTemplate, quoteCurrency FROM ProviderEndpoint WHERE providerId = ?`, [p]), + [{ capability: 'daily_history', pathTemplate: '/api/1/commodity/quotes/{symbol}', queryTemplate: null, quoteCurrency: 'USD' }]); + assert.deepEqual(all(`SELECT internalField, jsonPath, transform FROM ProviderOutputMap WHERE providerId = ? ORDER BY internalField`, [p]), + [{ internalField: 'price', jsonPath: '[].price', transform: 'troy_ounce_to_gram' }, + { internalField: 'time', jsonPath: '[].timestamp', transform: 'ms_to_s' }]); + }); + + it('haalt de spotkeuze weg en laat de reeks-keuze, de sleutelstand en de dekking staan', () => { + const p = opzet91(); + runMigrations(); + assert.deepEqual(all(`SELECT capability, rank, status FROM PriceSource WHERE providerId = ?`, [p]), + [{ capability: 'history', rank: 1, status: 'manual' }]); + assert.equal(one(`SELECT isActive FROM Provider WHERE id = ?`, [p]).isActive, 1); + assert.equal(one(`SELECT symbol FROM ProviderCoverage WHERE providerId = ?`, [p]).symbol, 'XAU'); + }); +}); + +describe('migratie 93: de maandlimiet per bron', () => { + function opzet92() { + opzet({ versie: 60 }); + runMigrations(); + run(`DROP TABLE ProviderCallLimit`); + kolomnamenVoor105(); + run(`UPDATE Schema SET version = 92`); + for (const key of ['goldapi', 'mempool']) { + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES (?, ?, ?, 'https://voorbeeld.test', 'none', 0, 1, 1, 0, '2026-01-01T00:00:00Z')`, [key, key, key]); + } + } + const limiet = key => one(`SELECT callsPerMonth FROM ProviderCallLimit + JOIN Provider ON Provider.id = ProviderCallLimit.providerId WHERE Provider.key = ?`, [key])?.callsPerMonth ?? null; + + it('zet de gratis limiet bij de bronnen die er een hebben, en niet bij de rest', () => { + opzet92(); + runMigrations(); + assert.equal(limiet('goldapi'), 100); + assert.equal(limiet('mempool'), null); + }); + + it('weigert een limiet van nul in de nieuwe tabel', () => { + opzet92(); + runMigrations(); + assert.throws(() => run(`UPDATE ProviderCallLimit SET callsPerMonth = 0`), /CHECK/); + }); +}); + +describe('migratie 95: de Eulerpool-limiet', () => { + it('zet 1000 op 100.000, maar laat een limiet die de gebruiker zelf koos staan', () => { + opzet({ versie: 60 }); + runMigrations(); + kolomnamenVoor105(); + run(`UPDATE Schema SET version = 94`); + for (const [key, limiet] of [['eulerpool', 1000], ['eulerpool_metal', 5000]]) { + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES (?, ?, ?, 'https://api.eulerpool.com', 'query_key', 1, 1, 1, 0, '2026-01-01T00:00:00Z')`, [key, key, key]); + run(`INSERT INTO ProviderCallLimit (providerId, callsPerMonth, createdAt) + VALUES ((SELECT id FROM Provider WHERE key = ?), ?, '2026-09-24T00:00:00Z')`, [key, limiet]); + } + runMigrations(); + const limiet = key => one(`SELECT callsPerMonth FROM ProviderCallLimit + JOIN Provider ON Provider.id = ProviderCallLimit.providerId WHERE Provider.key = ?`, [key]).callsPerMonth; + assert.equal(limiet('eulerpool'), 100000); + assert.equal(limiet('eulerpool_metal'), 5000, 'een eigen keuze blijft staan'); + }); +}); + +describe('migratie 111: balanceCapture op Asset', () => { + function opzet110() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (110)`); + run(`CREATE TABLE Asset ( + id INTEGER PRIMARY KEY, symbol TEXT NOT NULL, name TEXT NOT NULL, searchName TEXT NOT NULL, + description TEXT, unit TEXT NOT NULL, subunit TEXT, + displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')), + decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9), + kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')), + taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig','buiten')), + createdAt TEXT, updatedAt TEXT, + UNIQUE (symbol), CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL) + ) STRICT;`); + run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`); + run(`CREATE TABLE AssetNote (id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, note TEXT) STRICT;`); + run(`INSERT INTO Asset (id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind, + taxCategory, createdAt, updatedAt) + VALUES (1,'EUR','Euro','euro','geld','EUR','cent','unit',2,'fiat','bank','2026-01-01','2026-02-01')`); + run(`INSERT INTO AssetNote (id, assetId, note) VALUES (1, 1, 'notitie')`); + } + + it('zet de kolom vóór de tijdstempels en laat hem leeg, want NULL betekent "volgt soort"', () => { + opzet110(); + runMigrations(); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + const kolommen = all(`SELECT name FROM pragma_table_info('Asset')`).map(r => r.name); + assert.deepEqual(kolommen.slice(-3), ['balanceCapture', 'createdAt', 'updatedAt']); + assert.deepEqual(one(`SELECT taxCategory, balanceCapture, createdAt, updatedAt FROM Asset WHERE id = 1`), + { taxCategory: 'bank', balanceCapture: null, createdAt: '2026-01-01', updatedAt: '2026-02-01' }); + }); + + it('weigert een waarde buiten 0 en 1, en laat de verwijzing van een andere tabel heel', () => { + opzet110(); + runMigrations(); + assert.throws(() => run(`UPDATE Asset SET balanceCapture = 2 WHERE id = 1`), /CHECK/i); + assert.doesNotThrow(() => run(`UPDATE Asset SET balanceCapture = 0 WHERE id = 1`)); + assert.match(one(`SELECT sql FROM sqlite_master WHERE name = 'AssetNote'`).sql, /REFERENCES Asset\(id\)/); + run(`DELETE FROM Asset WHERE id = 1`); + assert.equal(one(`SELECT COUNT(*) AS n FROM AssetNote`).n, 0, 'de cascade wijst nog naar de echte tabel'); + }); +}); + +describe('migratie 112: buiten wordt een reikwijdte en geen categorie', () => { + function opzet111() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (111)`); + run(`CREATE TABLE Asset ( + id INTEGER PRIMARY KEY, symbol TEXT NOT NULL, name TEXT NOT NULL, searchName TEXT NOT NULL, + description TEXT, unit TEXT NOT NULL, subunit TEXT, + displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')), + decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9), + kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')), + taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig','buiten')), + balanceCapture INTEGER CHECK (balanceCapture IS NULL OR balanceCapture IN (0,1)), + createdAt TEXT, updatedAt TEXT, + UNIQUE (symbol), CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL) + ) STRICT;`); + run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`); + run(`CREATE TABLE Account ( + id INTEGER PRIMARY KEY, name TEXT NOT NULL, searchName TEXT NOT NULL, description TEXT, + kind TEXT NOT NULL CHECK (kind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER NOT NULL CHECK (isOwnCustody IN (0,1)), + scanEnabled INTEGER NOT NULL CHECK (scanEnabled IN (0,1)), + isActive INTEGER NOT NULL CHECK (isActive IN (0,1)), + closedAt TEXT, + taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig','buiten')), + createdAt TEXT, updatedAt TEXT, + CHECK ((isActive = 1 AND closedAt IS NULL) OR (isActive = 0 AND closedAt IS NOT NULL)) + ) STRICT;`); + run(`CREATE INDEX IxAccountSearchName ON Account(searchName)`); + run(`CREATE TABLE Valuation (id INTEGER PRIMARY KEY, + accountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE) STRICT;`); + run(`INSERT INTO Asset (id, symbol, name, searchName, unit, subunit, displayUnit, decimals, kind, taxCategory, balanceCapture, createdAt) + VALUES (1,'EUR','Euro','euro','EUR','cent','unit',2,'fiat','buiten',1,'2026-01-01'), + (2,'BTC','Bitcoin','bitcoin','BTC','sat','unit',8,'crypto','overig',NULL,NULL)`); + run(`INSERT INTO Account (id, name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt, taxCategory, updatedAt) + VALUES (1,'Pensioen','pensioen','bank',0,0,1,NULL,'buiten','2026-02-01'), + (2,'Spaar','spaar','bank',0,0,0,'2026-03-01','bank',NULL)`); + run(`INSERT INTO Valuation (id, accountId, assetId) VALUES (1, 1, 1)`); + } + + it('maakt van buiten de reikwijdte none, en laat de andere categorieën staan', () => { + opzet111(); + runMigrations(); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + assert.deepEqual(all(`SELECT id, taxCategory, taxScope, balanceCapture, createdAt FROM Asset ORDER BY id`), [ + { id: 1, taxCategory: null, taxScope: 'none', balanceCapture: 1, createdAt: '2026-01-01' }, + { id: 2, taxCategory: 'overig', taxScope: null, balanceCapture: null, createdAt: null }]); + assert.deepEqual(all(`SELECT id, taxCategory, taxScope, isActive, closedAt, updatedAt FROM Account ORDER BY id`), [ + { id: 1, taxCategory: null, taxScope: 'none', isActive: 1, closedAt: null, updatedAt: '2026-02-01' }, + { id: 2, taxCategory: 'bank', taxScope: null, isActive: 0, closedAt: '2026-03-01', updatedAt: null }]); + }); + + it('weigert buiten als categorie en een onbekende reikwijdte, en houdt de verwijzingen heel', () => { + opzet111(); + runMigrations(); + assert.throws(() => run(`UPDATE Account SET taxCategory = 'buiten' WHERE id = 2`), /CHECK/i); + assert.throws(() => run(`UPDATE Asset SET taxScope = 'onzin' WHERE id = 2`), /CHECK/i); + assert.doesNotThrow(() => run(`UPDATE Account SET taxScope = 'category' WHERE id = 2`)); + assert.match(one(`SELECT sql FROM sqlite_master WHERE name = 'Valuation'`).sql, /REFERENCES Account\(id\)/); + assert.match(one(`SELECT sql FROM sqlite_master WHERE name = 'Valuation'`).sql, /REFERENCES Asset\(id\)/); + }); +}); + +describe('migratie 113: rente op rente per afspraak', () => { + function opzet112() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (112)`); + run(`CREATE TABLE Account (id INTEGER PRIMARY KEY) STRICT;`); + run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY) STRICT;`); + run(`INSERT INTO Account (id) VALUES (1)`); + run(`INSERT INTO Asset (id) VALUES (1), (2)`); + run(`CREATE TABLE InterestSchedule ( + id INTEGER PRIMARY KEY, accountId INTEGER NOT NULL, principalAssetId INTEGER, payoutAssetId INTEGER NOT NULL, + note TEXT, principalAmount INTEGER, annualRate REAL, rateKind TEXT NOT NULL, creditCadence TEXT, + creditWeekday INTEGER, creditDayOfMonth INTEGER, startsOn TEXT NOT NULL, endsOn TEXT, + createdAt TEXT NOT NULL, updatedAt TEXT) STRICT;`); + run(`CREATE TABLE InterestCredit (id INTEGER PRIMARY KEY, + scheduleId INTEGER NOT NULL REFERENCES InterestSchedule(id) ON DELETE CASCADE, periodEnd TEXT) STRICT;`); + run(`INSERT INTO InterestSchedule (id, accountId, principalAssetId, payoutAssetId, note, principalAmount, annualRate, + rateKind, creditCadence, startsOn, endsOn, createdAt, updatedAt) + VALUES (1, 1, 2, 2, 'deposito', 1072628, 0.028, 'fixed', 'annual', '2025-08-18', '2029-08-18', '2025-08-18', '2026-01-02'), + (2, 1, NULL, 2, NULL, NULL, NULL, 'variable', NULL, '2024-01-01', NULL, '2024-01-01', NULL)`); + run(`INSERT INTO InterestCredit (id, scheduleId, periodEnd) VALUES (1, 1, '2026-01-01')`); + } + + it('zet de kolom vóór de tijdstempels, op 0 voor elke bestaande afspraak', () => { + opzet112(); + runMigrations(); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + const kolommen = all(`SELECT name FROM pragma_table_info('InterestSchedule')`).map(r => r.name); + assert.deepEqual(kolommen.slice(-3), ['compounding', 'createdAt', 'updatedAt']); + assert.deepEqual(all(`SELECT id, compounding, note, updatedAt FROM InterestSchedule ORDER BY id`), [ + { id: 1, compounding: 0, note: 'deposito', updatedAt: '2026-01-02' }, + { id: 2, compounding: 0, note: null, updatedAt: null }]); + }); + + it('weigert rente op rente waar hij niet kan, en laat de verwijzing heel', () => { + opzet112(); + runMigrations(); + assert.doesNotThrow(() => run(`UPDATE InterestSchedule SET compounding = 1 WHERE id = 1`)); + assert.throws(() => run(`UPDATE InterestSchedule SET compounding = 1 WHERE id = 2`), /CHECK/i, 'variabel'); + assert.throws(() => run(`UPDATE InterestSchedule SET compounding = 1, principalAmount = NULL WHERE id = 1`), /CHECK/i, + 'een hoofdsom die het saldo volgt'); + assert.throws(() => run(`UPDATE InterestSchedule SET compounding = 2 WHERE id = 1`), /CHECK/i); + assert.match(one(`SELECT sql FROM sqlite_master WHERE name = 'InterestCredit'`).sql, /REFERENCES InterestSchedule\(id\)/); + run(`DELETE FROM InterestSchedule WHERE id = 1`); + assert.equal(one(`SELECT COUNT(*) AS n FROM InterestCredit`).n, 0, 'de cascade wijst nog naar de echte tabel'); + }); +}); + +describe('migratie 114: van wie een account is', () => { + function opzet113() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (113)`); + run(`CREATE TABLE Account ( + id INTEGER PRIMARY KEY, name TEXT NOT NULL, searchName TEXT NOT NULL, description TEXT, + kind TEXT NOT NULL CHECK (kind IN ('exchange','wallet','lightning','card','bank','vault')), + isOwnCustody INTEGER NOT NULL CHECK (isOwnCustody IN (0,1)), + scanEnabled INTEGER NOT NULL CHECK (scanEnabled IN (0,1)), + isActive INTEGER NOT NULL CHECK (isActive IN (0,1)), + closedAt TEXT, + taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig')), + taxScope TEXT CHECK (taxScope IS NULL OR taxScope IN ('all','category','none')), + createdAt TEXT, updatedAt TEXT, + CHECK ((isActive = 1 AND closedAt IS NULL) OR (isActive = 0 AND closedAt IS NOT NULL)) + ) STRICT;`); + run(`CREATE INDEX IxAccountSearchName ON Account(searchName)`); + run(`CREATE TABLE Valuation (id INTEGER PRIMARY KEY, + accountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION) STRICT;`); + run(`INSERT INTO Account (id, name, searchName, description, kind, isOwnCustody, scanEnabled, isActive, closedAt, + taxCategory, taxScope, createdAt, updatedAt) + VALUES (1,'Spaarpot','spaarpot','van het kind','bank',0,0,1,NULL,'bank','category','2026-01-01','2026-02-01'), + (2,'Kluis','kluis',NULL,'vault',1,0,0,'2026-03-01',NULL,'none',NULL,NULL)`); + run(`INSERT INTO Valuation (id, accountId) VALUES (1, 1)`); + } + + it('zet de kolom vóór de tijdstempels, leeg voor elk bestaand account, en laat de rest staan', () => { + opzet113(); + runMigrations(); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + const kolommen = all(`SELECT name FROM pragma_table_info('Account')`).map(r => r.name); + assert.deepEqual(kolommen.slice(-4), ['taxScope', 'wealthScope', 'createdAt', 'updatedAt']); + assert.deepEqual(all(`SELECT id, description, taxCategory, taxScope, wealthScope, isActive, closedAt, createdAt, updatedAt + FROM Account ORDER BY id`), [ + { id: 1, description: 'van het kind', taxCategory: 'bank', taxScope: 'category', wealthScope: null, + isActive: 1, closedAt: null, createdAt: '2026-01-01', updatedAt: '2026-02-01' }, + { id: 2, description: null, taxCategory: null, taxScope: 'none', wealthScope: null, + isActive: 0, closedAt: '2026-03-01', createdAt: null, updatedAt: null }]); + }); + + it('weigert een onbekende waarde, kent geen expliciete eigen, en houdt de verwijzing heel', () => { + opzet113(); + runMigrations(); + assert.doesNotThrow(() => run(`UPDATE Account SET wealthScope = 'managed' WHERE id = 1`)); + assert.doesNotThrow(() => run(`UPDATE Account SET wealthScope = 'none' WHERE id = 2`)); + assert.throws(() => run(`UPDATE Account SET wealthScope = 'own' WHERE id = 1`), /CHECK/i, 'eigen is NULL'); + assert.match(one(`SELECT sql FROM sqlite_master WHERE name = 'Valuation'`).sql, /REFERENCES Account\(id\)/); + assert.ok(one(`SELECT 1 AS x FROM sqlite_master WHERE name = 'IxAccountSearchName'`), 'de index is terug'); + }); +}); + +describe('elke herbouw houdt de bestaande rijen heel', () => { + const OUDE_KOLOMMEN = (t) => all(`PRAGMA table_info(${t})`).map(k => k.name); + + function vulAlleKolommen(tabel, overslaan = []) { + for (const k of all(`PRAGMA table_info(${tabel})`)) { + if (k.pk || overslaan.includes(k.name)) continue; + const waarde = k.type === 'INTEGER' ? 7 : k.type === 'REAL' ? 1.25 : 'gevuld'; + try { run(`UPDATE ${tabel} SET ${k.name} = ? WHERE ${k.name} IS NULL`, [waarde]); } + catch { } + } + } + + function momentopname(tabellen) { + const uit = {}; + for (const t of tabellen) uit[t] = { kolommen: OUDE_KOLOMMEN(t), rijen: all(`SELECT * FROM ${t} ORDER BY rowid`) }; + return uit; + } + + const HERNOEMD = { + 'Event.eurPrice': 'fiatPrice', 'Event.eurFee': 'fiatFee', 'Valuation.eurRate': 'fiatRate', + 'PriceHistory.eur': 'fiatPrice', 'ProviderFixedRate.eur': 'fiatPrice', + 'AiProposal.eur': 'fiatPrice', 'AiProposal.eurFee': 'fiatFee', + }; + const VERVALLEN_KOLOM = new Set(['PriceHistory.usd']); + const VERVALLEN_RIJ = { PriceHistory: r => r.eur == null }; + function vergelijk(voor) { + for (const [tabel, { kolommen, rijen }] of Object.entries(voor)) { + const na = new Map(all(`SELECT * FROM ${tabel}`).map(r => [r.id, r])); + for (const oud of rijen) { + if (VERVALLEN_RIJ[tabel]?.(oud)) { + assert.equal(na.has(oud.id), false, `${tabel}: rij ${oud.id} hoort door de keten weg te zijn`); + continue; + } + const nu = na.get(oud.id); + assert.ok(nu, `${tabel}: rij ${oud.id} is bij een herbouw verdwenen`); + for (const k of kolommen) { + if (VERVALLEN_KOLOM.has(`${tabel}.${k}`)) continue; + const nieuw = HERNOEMD[`${tabel}.${k}`] ?? k; + assert.deepEqual(nu[nieuw], oud[k], `${tabel}.${k} is bij een herbouw niet meegekomen (rij ${oud.id})`); + } + } + } + } + + it('neemt elke kolom van elke rij mee, over de hele keten van 60 naar nu', () => { + opzet({ versie: 60 }); + zetRijen({ eurPrice: 30000, eurFee: 12.5, unitFee: 250 }); + run(`INSERT INTO Provider (id, key, name, searchName, baseUrl, authenticationKind, + authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES (1, 'coingecko', 'CoinGecko', 'coingecko', 'https://api.coingecko.com', 'none', + 0, 1, 1, 0, '2026-01-01T00:00:00Z')`); + run(`CREATE TABLE PriceHistory ( + id INTEGER PRIMARY KEY, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT, + eur REAL, + usd REAL, + resolution TEXT NOT NULL CHECK (resolution IN ('day','timestamp')), + observedAt TEXT NOT NULL, + updatedAt TEXT, + UNIQUE (assetId, providerId, observedAt, resolution), + CHECK (eur IS NOT NULL OR usd IS NOT NULL) + ) STRICT;`); + run(`CREATE INDEX IxPriceHistoryProviderId ON PriceHistory(providerId)`); + run(`CREATE INDEX IxPriceHistoryAssetIdObservedAt ON PriceHistory(assetId, observedAt)`); + run(`INSERT INTO PriceHistory (id, assetId, providerId, eur, usd, resolution, observedAt, updatedAt) + VALUES (1, 1, 1, 30000.5, 32000.25, 'day', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z')`); + run(`INSERT INTO PriceHistory (id, assetId, providerId, eur, usd, resolution, observedAt, updatedAt) + VALUES (2, 1, 1, NULL, 31000.0, 'timestamp', '2026-01-03T09:00:00Z', NULL)`); + + for (const t of ['Asset', 'Event', 'PriceHistory']) vulAlleKolommen(t); + const voor = momentopname(['Asset', 'Event', 'PriceHistory']); + runMigrations(); + vergelijk(voor); + }); + + it('houdt AiProposal heel over migratie 83 en 84 heen', () => { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (82)`); + run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY, symbol TEXT NOT NULL) STRICT;`); + run(`CREATE TABLE Account (id INTEGER PRIMARY KEY, name TEXT NOT NULL) STRICT;`); + run(`INSERT INTO Asset (id, symbol) VALUES (1, 'BTC')`); + run(`INSERT INTO Account (id, name) VALUES (1, 'Bank')`); + run(`CREATE TABLE AiProposal ( + id INTEGER PRIMARY KEY, + assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, + accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio')), + name TEXT, + accountKind TEXT, + isOwnCustody INTEGER, + eventType TEXT, + amount INTEGER, + eur REAL, + eurFee REAL, + balance INTEGER, + observedAt TEXT, + note TEXT, + status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')), + detail TEXT, + resolvedAt TEXT, + createdAt TEXT NOT NULL + ) STRICT;`); + run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`); + run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`); + run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`); + run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`); + run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`); + run(`INSERT INTO AiProposal (id, assetId, kind, eur, observedAt, note, status, createdAt) + VALUES (1, 1, 'price_point', 67000.5, '2026-09-01T00:00:00Z', 'van de AI', 'pending', '2026-09-01T10:00:00Z')`); + run(`INSERT INTO AiProposal (id, assetId, fromAccountId, toAccountId, kind, eventType, amount, eur, eurFee, observedAt, note, status, createdAt) + VALUES (2, 1, 1, 1, 'event', 'buy', 100000, 6700.0, 4.5, '2026-09-02T00:00:00Z', 'met kosten', 'pending', '2026-09-02T10:00:00Z')`); + run(`INSERT INTO AiProposal (id, assetId, accountId, kind, balance, observedAt, status, detail, resolvedAt, createdAt) + VALUES (3, 1, 1, 'valuation', 250000, '2026-09-03T00:00:00Z', 'accepted', 'goedgekeurd', '2026-09-03T11:00:00Z', '2026-09-03T10:00:00Z')`); + run(`INSERT INTO AiProposal (id, kind, name, accountKind, isOwnCustody, status, createdAt) + VALUES (4, 'account', 'Nieuwe beurs', 'exchange', 1, 'pending', '2026-09-04T10:00:00Z')`); + + const voor = momentopname(['AiProposal']); + runMigrations(); + vergelijk(voor); + const na = all(`SELECT createdEventId, createdAccountId, createdValuationId FROM AiProposal ORDER BY id`); + assert.ok(na.every(r => r.createdEventId === null && r.createdAccountId === null && r.createdValuationId === null), + 'een voorstel van vóór 84 heeft geen spoor, en dat achteraf raden zou een herkomst VERZINNEN'); + }); +}); + +describe('migratie 115: de herkomst van een spot uit Atreides Services', () => { + function opzet114(metRij = false) { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (114)`); + bronTabellenStomp(); + if (metRij) { + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES ('atreides_services', 'Atreides Services', 'atreides services', '', 'none', 0, 1, 1, 1, '2026-10-09')`); + } + } + + it('zet één actieve systeemrij neer, zonder endpoints en zonder dekking', () => { + opzet114(); + runMigrations(); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + const rij = one(`SELECT id, name, baseUrl, isActive, isSystem FROM Provider WHERE key = 'atreides_services'`); + assert.deepEqual({ name: rij.name, baseUrl: rij.baseUrl, isActive: rij.isActive, isSystem: rij.isSystem }, + { name: 'Atreides Services', baseUrl: '', isActive: 1, isSystem: 1 }); + assert.equal(one(`SELECT COUNT(*) AS n FROM ProviderEndpoint WHERE providerId = ?`, [rij.id]).n, 0); + assert.equal(one(`SELECT COUNT(*) AS n FROM ProviderCoverage WHERE providerId = ?`, [rij.id]).n, 0); + }); + + it('laat een bestaande rij staan in plaats van er een tweede naast te zetten', () => { + opzet114(true); + runMigrations(); + assert.equal(one(`SELECT COUNT(*) AS n FROM Provider WHERE key = 'atreides_services'`).n, 1); + }); +}); + +describe('migratie 116: de systeemrij van Atreides Services aanvullen waar hij ontbreekt', () => { + function opzet115(metRij = false) { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (115)`); + bronTabellenStomp(); + if (metRij) { + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES ('atreides_services', 'Atreides Services', 'atreides services', '', 'none', 0, 1, 1, 1, '2026-10-09')`); + } + } + + it('zet de rij neer op een database van 115 zonder', () => { + opzet115(); + runMigrations(); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + const rij = one(`SELECT isActive, isSystem FROM Provider WHERE key = 'atreides_services'`); + assert.deepEqual({ isActive: rij?.isActive, isSystem: rij?.isSystem }, { isActive: 1, isSystem: 1 }); + }); + + it('laat een database van 115 met de rij zoals hij is', () => { + opzet115(true); + runMigrations(); + assert.equal(one(`SELECT COUNT(*) AS n FROM Provider WHERE key = 'atreides_services'`).n, 1); + }); +}); + +describe('migratie 117: de systeemrij ook bij kolomnamen tussen aanhalingstekens', () => { + function opzet116Geciteerd() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (116)`); + run(`CREATE TABLE "Provider" ("id" INTEGER PRIMARY KEY, "key" TEXT NOT NULL UNIQUE, "name" TEXT NOT NULL, + "searchName" TEXT NOT NULL, "baseUrl" TEXT NOT NULL, "authenticationHeaderName" TEXT, + "authenticationParameterName" TEXT, "authenticationKind" TEXT NOT NULL, "authenticationRequired" INTEGER NOT NULL, + "isActive" INTEGER NOT NULL, "isSeed" INTEGER NOT NULL, "isSystem" INTEGER NOT NULL, "createdAt" TEXT NOT NULL)`); + } + + it('zet de rij neer als de DDL de kolommen tussen aanhalingstekens heeft', () => { + opzet116Geciteerd(); + runMigrations(); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + assert.equal(one(`SELECT COUNT(*) AS n FROM Provider WHERE key = 'atreides_services'`).n, 1); + }); + + it('slaat een fixture zonder de kolommen nog steeds over', () => { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (116)`); + run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY, key TEXT NOT NULL)`); + runMigrations(); + assert.equal(one(`SELECT COUNT(*) AS n FROM Provider`).n, 0); + }); +}); + +describe('migratie 119: lightning in de walletcache', () => { + function opzet118() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (118)`); + run(`CREATE TABLE Account (id INTEGER PRIMARY KEY) STRICT;`); + run(`CREATE TABLE WalletSource (id INTEGER PRIMARY KEY, accountId INTEGER NOT NULL REFERENCES Account(id)) STRICT;`); + run(`CREATE TABLE Label (id INTEGER PRIMARY KEY) STRICT;`); + run(`CREATE TABLE WalletTransaction ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE, + labelId INTEGER REFERENCES Label(id) ON DELETE SET NULL, + txid TEXT NOT NULL, + address TEXT, + valueSats INTEGER NOT NULL, + feeSats INTEGER CHECK (feeSats IS NULL OR feeSats >= 0), + blockHeight INTEGER CHECK (blockHeight IS NULL OR blockHeight > 0), + blockTime TEXT, + direction TEXT NOT NULL CHECK (direction IN ('in','out')), + createdAt TEXT NOT NULL, + UNIQUE (accountId, txid), + CHECK ((blockHeight IS NULL) = (blockTime IS NULL)) + ) STRICT;`); + run(`CREATE TABLE WalletTransactionEventLink (id INTEGER PRIMARY KEY, + walletTransactionId INTEGER NOT NULL REFERENCES WalletTransaction(id) ON DELETE CASCADE) STRICT;`); + run(`CREATE TABLE ReconcileProposal (id INTEGER PRIMARY KEY, + walletTransactionId INTEGER REFERENCES WalletTransaction(id) ON DELETE CASCADE) STRICT;`); + run(`INSERT INTO Account (id) VALUES (1)`); + run(`INSERT INTO WalletSource (id, accountId) VALUES (5, 1)`); + run(`INSERT INTO Label (id) VALUES (3)`); + run(`INSERT INTO WalletTransaction (id, accountId, sourceId, labelId, txid, address, valueSats, feeSats, blockHeight, blockTime, direction, createdAt) + VALUES (7, 1, 5, 3, 'aa11', 'bc1qvoorbeeld', 12000, 150, 900001, '2026-01-02T03:04:05Z', 'out', '2026-01-02'), + (8, 1, NULL, NULL, 'bb22', NULL, 500, NULL, NULL, NULL, 'in', '2026-01-03')`); + run(`INSERT INTO WalletTransactionEventLink (id, walletTransactionId) VALUES (1, 7)`); + run(`INSERT INTO ReconcileProposal (id, walletTransactionId) VALUES (1, 8)`); + } + + it('neemt elke rij met zijn id en elke kolom mee, als on-chain', () => { + opzet118(); + runMigrations(); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + assert.deepEqual(all(`SELECT * FROM WalletTransaction ORDER BY id`), [ + { id: 7, accountId: 1, sourceId: 5, labelId: 3, layer: 'onchain', kind: null, txid: 'aa11', address: 'bc1qvoorbeeld', memo: null, + valueSats: 12000, feeSats: 150, blockHeight: 900001, blockTime: '2026-01-02T03:04:05Z', direction: 'out', createdAt: '2026-01-02' }, + { id: 8, accountId: 1, sourceId: null, labelId: null, layer: 'onchain', kind: null, txid: 'bb22', address: null, memo: null, + valueSats: 500, feeSats: null, blockHeight: null, blockTime: null, direction: 'in', createdAt: '2026-01-03' }]); + }); + + it('houdt de koppeling en het voorstel op de echte tabelnaam, en zet de indexen terug', () => { + opzet118(); + runMigrations(); + for (const t of ['WalletTransactionEventLink', 'ReconcileProposal']) { + assert.match(one(`SELECT sql FROM sqlite_master WHERE name = ?`, [t]).sql, /REFERENCES WalletTransaction\(id\)/); + } + assert.equal(one(`SELECT walletTransactionId AS id FROM WalletTransactionEventLink`).id, 7); + for (const ix of ['UxWalletTransactionAccountIdTxid', 'UxWalletTransactionSourceIdTxid', + 'IxWalletTransactionAccountId', 'IxWalletTransactionSourceId', 'IxWalletTransactionLabelId']) { + assert.ok(one(`SELECT 1 AS x FROM sqlite_master WHERE name = ?`, [ix]), `${ix} ontbreekt`); + } + }); + + it('weigert daarna een dubbele on-chain rij, en laat een lightning-rij met dezelfde hash toe', () => { + opzet118(); + runMigrations(); + assert.throws(() => run(`INSERT INTO WalletTransaction (accountId, layer, txid, valueSats, direction, createdAt) + VALUES (1, 'onchain', 'aa11', 1, 'in', '2026-01-04')`), /UNIQUE/); + assert.doesNotThrow(() => run(`INSERT INTO WalletTransaction (accountId, sourceId, layer, txid, valueSats, blockTime, direction, createdAt) + VALUES (1, 5, 'lightning', 'aa11', 21, '2026-01-04T00:00:00Z', 'in', '2026-01-04')`)); + }); +}); + +describe('migratie 120: het saldo van een lightning-bron', () => { + function opzet119() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (119)`); + run(`CREATE TABLE Account (id INTEGER PRIMARY KEY) STRICT;`); + run(`CREATE TABLE Connection (id INTEGER PRIMARY KEY) STRICT;`); + run(`CREATE TABLE WalletSource ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + connectionId INTEGER REFERENCES Connection(id) ON DELETE RESTRICT, + value TEXT NOT NULL, + name TEXT, + kind TEXT NOT NULL CHECK (kind IN ('xpub','address','nwc','lnd')), + scriptType TEXT CHECK (scriptType IS NULL OR scriptType IN ('p2tr','p2wpkh','p2sh-p2wpkh','p2pkh')), + createdAt TEXT NOT NULL, + updatedAt TEXT, + CHECK (scriptType IS NULL OR kind = 'xpub'), + CHECK ((kind = 'lnd') = (connectionId IS NOT NULL)) + ) STRICT;`); + run(`CREATE TABLE WalletAddress (id INTEGER PRIMARY KEY, + sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE) STRICT;`); + run(`INSERT INTO Account (id) VALUES (1)`); + run(`INSERT INTO Connection (id) VALUES (2)`); + run(`INSERT INTO WalletSource (id, accountId, connectionId, value, name, kind, scriptType, createdAt, updatedAt) + VALUES (3, 1, NULL, 'zpub-test', 'Ledger', 'xpub', 'p2wpkh', '2026-01-01', '2026-02-01'), + (4, 1, 2, '', NULL, 'lnd', NULL, '2026-01-02', NULL)`); + run(`INSERT INTO WalletAddress (id, sourceId) VALUES (1, 3)`); + } + + it('neemt elke bron met zijn id en elke kolom mee, zonder saldo', () => { + opzet119(); + runMigrations(); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + assert.deepEqual(all(`SELECT * FROM WalletSource ORDER BY id`), [ + { id: 3, accountId: 1, connectionId: null, value: 'zpub-test', name: 'Ledger', kind: 'xpub', scriptType: 'p2wpkh', + balanceSats: null, lastScannedAt: null, createdAt: '2026-01-01', updatedAt: '2026-02-01' }, + { id: 4, accountId: 1, connectionId: 2, value: '', name: null, kind: 'lnd', scriptType: null, + balanceSats: null, lastScannedAt: null, createdAt: '2026-01-02', updatedAt: null }]); + }); + + it('houdt de adressen op de echte tabelnaam, en weigert een saldo op een on-chain bron', () => { + opzet119(); + runMigrations(); + assert.match(one(`SELECT sql FROM sqlite_master WHERE name = 'WalletAddress'`).sql, /REFERENCES WalletSource\(id\)/); + assert.doesNotThrow(() => run(`UPDATE WalletSource SET balanceSats = 1, lastScannedAt = '2026-03-01' WHERE id = 4`)); + assert.throws(() => run(`UPDATE WalletSource SET balanceSats = 1, lastScannedAt = '2026-03-01' WHERE id = 3`), /CHECK/); + assert.throws(() => run(`UPDATE WalletSource SET lastScannedAt = NULL WHERE id = 4`), /CHECK/, 'een saldo zonder moment'); + }); + + it('laat na 124 een node op een on-chain bron toe, eist hem op lnd en weigert hem op nwc', () => { + opzet119(); + runMigrations(); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + assert.doesNotThrow(() => run(`UPDATE WalletSource SET connectionId = 2 WHERE id = 3`)); + assert.throws(() => run(`UPDATE WalletSource SET connectionId = NULL WHERE id = 4`), /CHECK/); + assert.throws(() => run(`INSERT INTO WalletSource (accountId, connectionId, value, kind, createdAt) VALUES (1, 2, 'pk', 'nwc', '2026-01-03')`), /CHECK/); + assert.match(one(`SELECT sql FROM sqlite_master WHERE name = 'WalletAddress'`).sql, /REFERENCES WalletSource\(id\)/); + assert.equal(one(`SELECT COUNT(*) AS n FROM WalletSource`).n, 2); + }); +}); + +describe('migratie 121: een beweging binnen het account', () => { + function opzet120() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (120)`); + run(`CREATE TABLE Account (id INTEGER PRIMARY KEY) STRICT;`); + run(`CREATE TABLE WalletSource (id INTEGER PRIMARY KEY, accountId INTEGER NOT NULL REFERENCES Account(id)) STRICT;`); + run(`CREATE TABLE Label (id INTEGER PRIMARY KEY) STRICT;`); + run(`CREATE TABLE WalletTransaction ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE, + labelId INTEGER REFERENCES Label(id) ON DELETE SET NULL, + layer TEXT NOT NULL CHECK (layer IN ('onchain','lightning')), + txid TEXT NOT NULL, + address TEXT, + valueSats INTEGER NOT NULL, + feeSats INTEGER CHECK (feeSats IS NULL OR feeSats >= 0), + blockHeight INTEGER CHECK (blockHeight IS NULL OR blockHeight > 0), + blockTime TEXT, + direction TEXT NOT NULL CHECK (direction IN ('in','out')), + createdAt TEXT NOT NULL, + CHECK ((layer = 'onchain' AND (blockHeight IS NULL) = (blockTime IS NULL)) + OR (layer = 'lightning' AND blockHeight IS NULL AND blockTime IS NOT NULL AND sourceId IS NOT NULL)) + ) STRICT;`); + run(`CREATE UNIQUE INDEX UxWalletTransactionAccountIdTxid ON WalletTransaction(accountId, txid) WHERE layer = 'onchain'`); + run(`CREATE TABLE WalletTransactionEventLink (id INTEGER PRIMARY KEY, + walletTransactionId INTEGER NOT NULL REFERENCES WalletTransaction(id) ON DELETE CASCADE) STRICT;`); + run(`CREATE TABLE ReconcileProposal (id INTEGER PRIMARY KEY, + walletTransactionId INTEGER REFERENCES WalletTransaction(id) ON DELETE CASCADE) STRICT;`); + run(`INSERT INTO Account (id) VALUES (1)`); + run(`INSERT INTO WalletSource (id, accountId) VALUES (5, 1)`); + run(`INSERT INTO Label (id) VALUES (3)`); + run(`INSERT INTO WalletTransaction (id, accountId, sourceId, labelId, layer, txid, address, valueSats, feeSats, blockHeight, blockTime, direction, createdAt) + VALUES (7, 1, 5, 3, 'onchain', 'aa11', 'bc1qvoorbeeld', 12000, 150, 900001, '2026-01-02T03:04:05Z', 'out', '2026-01-02'), + (8, 1, 5, NULL, 'lightning', 'cc33', NULL, 21, 1, NULL, '2026-01-03T00:00:00Z', 'out', '2026-01-03')`); + run(`INSERT INTO WalletTransactionEventLink (id, walletTransactionId) VALUES (1, 7)`); + run(`INSERT INTO ReconcileProposal (id, walletTransactionId) VALUES (1, 8)`); + } + + it('neemt elke rij met zijn id en elke kolom mee, zonder soort', () => { + opzet120(); + runMigrations(); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + assert.deepEqual(all(`SELECT * FROM WalletTransaction ORDER BY id`), [ + { id: 7, accountId: 1, sourceId: 5, labelId: 3, layer: 'onchain', kind: null, txid: 'aa11', address: 'bc1qvoorbeeld', memo: null, + valueSats: 12000, feeSats: 150, blockHeight: 900001, blockTime: '2026-01-02T03:04:05Z', direction: 'out', createdAt: '2026-01-02' }, + { id: 8, accountId: 1, sourceId: 5, labelId: null, layer: 'lightning', kind: null, txid: 'cc33', address: null, memo: null, + valueSats: 21, feeSats: 1, blockHeight: null, blockTime: '2026-01-03T00:00:00Z', direction: 'out', createdAt: '2026-01-03' }]); + for (const t of ['WalletTransactionEventLink', 'ReconcileProposal']) { + assert.match(one(`SELECT sql FROM sqlite_master WHERE name = ?`, [t]).sql, /REFERENCES WalletTransaction\(id\)/); + } + assert.equal(one(`SELECT walletTransactionId AS id FROM ReconcileProposal`).id, 8); + }); + + it('laat een memo alleen op lightning toe, en niet langer dan 2000 tekens (migratie 123)', () => { + opzet120(); + runMigrations(); + assert.doesNotThrow(() => run(`UPDATE WalletTransaction SET memo = 'Koffie' WHERE id = 8`)); + assert.throws(() => run(`UPDATE WalletTransaction SET memo = 'Koffie' WHERE id = 7`), /CHECK/); + assert.throws(() => run(`UPDATE WalletTransaction SET memo = ? WHERE id = 8`, ['x'.repeat(2001)]), /CHECK/); + assert.equal(one(`SELECT walletTransactionId AS id FROM ReconcileProposal`).id, 8, 'het voorstel hangt nog aan zijn beweging'); + }); + + it('laat een kanaal alleen on-chain toe en een betaling aan jezelf alleen op lightning', () => { + opzet120(); + runMigrations(); + assert.doesNotThrow(() => run(`UPDATE WalletTransaction SET kind = 'channel' WHERE id = 7`)); + assert.doesNotThrow(() => run(`UPDATE WalletTransaction SET kind = 'self' WHERE id = 8`)); + assert.throws(() => run(`UPDATE WalletTransaction SET kind = 'self' WHERE id = 7`), /CHECK/); + assert.throws(() => run(`UPDATE WalletTransaction SET kind = 'channel' WHERE id = 8`), /CHECK/); + assert.throws(() => run(`UPDATE WalletTransaction SET kind = 'iets' WHERE id = 7`), /CHECK/); + }); +}); + +describe('migratie 122: een forward als rente', () => { + function opzet121() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (121)`); + run(`CREATE TABLE Account (id INTEGER PRIMARY KEY) STRICT;`); + run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY) STRICT;`); + run(`CREATE TABLE WalletTransaction (id INTEGER PRIMARY KEY) STRICT;`); + run(`CREATE TABLE Event (id INTEGER PRIMARY KEY) STRICT;`); + run(`CREATE TABLE ReconcileProposal ( + id INTEGER PRIMARY KEY, + accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE, + assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, + walletTransactionId INTEGER REFERENCES WalletTransaction(id) ON DELETE CASCADE, + resolvedEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL, + transactionHash TEXT, + amount INTEGER NOT NULL CHECK (amount > 0), + direction TEXT NOT NULL CHECK (direction IN ('in','out')), + suggestedType TEXT NOT NULL CHECK (suggestedType IN ('trade','transfer')), + status TEXT NOT NULL CHECK (status IN ('pending','accepted_trade','accepted_transfer','ignored','auto_linked')), + detectedAt TEXT NOT NULL + ) STRICT;`); + run(`INSERT INTO Account (id) VALUES (1)`); + run(`INSERT INTO Asset (id) VALUES (2)`); + run(`INSERT INTO WalletTransaction (id) VALUES (3)`); + run(`INSERT INTO Event (id) VALUES (4)`); + run(`INSERT INTO ReconcileProposal VALUES (9, 1, 2, 3, 4, 'fwd:1', 7, 'in', 'trade', 'auto_linked', '2026-10-19')`); + } + + it('neemt elk voorstel mee met zijn id, en laat daarna accepted_interest toe', () => { + opzet121(); + runMigrations(); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + assert.deepEqual(all(`SELECT * FROM ReconcileProposal`), [{ id: 9, accountId: 1, assetId: 2, walletTransactionId: 3, + resolvedEventId: 4, transactionHash: 'fwd:1', amount: 7, direction: 'in', suggestedType: 'trade', + status: 'auto_linked', detectedAt: '2026-10-19' }]); + assert.doesNotThrow(() => run(`UPDATE ReconcileProposal SET status = 'accepted_interest' WHERE id = 9`)); + assert.throws(() => run(`UPDATE ReconcileProposal SET status = 'iets' WHERE id = 9`), /CHECK/); + for (const ix of ['IxReconcileProposalStatus', 'UxReconcileProposalLink', 'IxReconcileProposalResolvedEventId']) { + assert.ok(one(`SELECT 1 AS x FROM sqlite_master WHERE name = ?`, [ix]), `${ix} ontbreekt`); + } + }); +}); + +describe('migratie 127: een bestaande portefeuille houdt Nederlands', () => { + function opzet126(rijen) { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (126)`); + run(`CREATE TABLE Preference (scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, + createdAt TEXT, PRIMARY KEY (scope, key)) STRICT;`); + for (const [scope, key, value] of rijen) run(`INSERT INTO Preference (scope, key, value) VALUES (?, ?, ?)`, [scope, key, value]); + } + const taal = () => one(`SELECT value FROM Preference WHERE scope = 'app' AND key = 'locale'`)?.value; + + it('zet Nederlands vast waar geen taal is opgeslagen', () => { + opzet126([['app', 'theme', '"dark"']]); + runMigrations(); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + assert.equal(taal(), '"nl"'); + assert.equal(one(`SELECT value FROM Preference WHERE key = 'theme'`).value, '"dark"'); + }); + + it('laat een gekozen taal staan', () => { + opzet126([['app', 'locale', '"en"']]); + runMigrations(); + assert.equal(taal(), '"en"'); + }); +}); + +describe('migratie 128: de bron achter een dagkoers van de dienst', () => { + function opzet127() { + openDatabase(':memory:'); + run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`); + run(`INSERT INTO Schema (version) VALUES (127)`); + run(`CREATE TABLE PriceHistory (id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL, providerId INTEGER NOT NULL, + fiatPrice REAL NOT NULL, resolution TEXT NOT NULL CHECK (resolution IN ('day','timestamp')), observedAt TEXT NOT NULL, + createdAt TEXT, updatedAt TEXT, UNIQUE (assetId, providerId, observedAt, resolution)) STRICT;`); + run(`INSERT INTO PriceHistory VALUES (7, 1, 2, 61000, 'day', '2026-10-26T00:00:00Z', '2026-10-27T00:00:00Z', NULL)`); + } + + it('houdt elke rij, zet de kolom vóór de tijdstempels en laat hem leeg', () => { + opzet127(); + runMigrations(); + assert.equal(readVersion(), CONFIG.SCHEMA_VERSION); + assert.deepEqual(one(`SELECT id, fiatPrice, observedAt, createdAt, sourceName FROM PriceHistory`), + { id: 7, fiatPrice: 61000, observedAt: '2026-10-26T00:00:00Z', createdAt: '2026-10-27T00:00:00Z', sourceName: null }); + assert.deepEqual(all(`PRAGMA table_info(PriceHistory)`).map(c => c.name).slice(-3), ['sourceName', 'createdAt', 'updatedAt']); + assert.ok(all(`PRAGMA index_list(PriceHistory)`).some(i => i.unique && i.origin === 'u'), 'de UNIQUE is mee herbouwd'); + assert.deepEqual(all(`PRAGMA index_list(PriceHistory)`).filter(i => i.origin === 'c').map(i => i.name).sort(), + ['IxPriceHistoryAssetIdObservedAt', 'IxPriceHistoryProviderId'], 'de indexen zijn mee herbouwd'); + }); +}); diff --git a/tests/db/schema-snapshot.test.mjs b/tests/db/schema-snapshot.test.mjs new file mode 100644 index 0000000..fa822d7 --- /dev/null +++ b/tests/db/schema-snapshot.test.mjs @@ -0,0 +1,568 @@ +// Generated, do not edit. sha256:95b828575da8a035648fa6c5b1be9927259b17fc10e9fccfc392b7da47d8f94f +import { describe, it, before } from 'node:test'; +import assert from 'node:assert/strict'; + +import { openBaselineDb } from '../helpers/baseline-db.mjs'; +import { schemaVorm } from '../helpers/schema-vorm.mjs'; +import { CONFIG } from '../../shared/config.js'; + +let REGELS; + +before(() => { + const db = openBaselineDb(); + REGELS = schemaVorm(db); + db.close(); +}); + +describe('schema-snapshot', () => { + it('heeft de verwachte omvang', () => { + const nieuw = REGELS.filter(r => !VERWACHT.includes(r)); + const weg = VERWACHT.filter(r => !REGELS.includes(r)); + assert.deepEqual({ nieuw, weg }, { nieuw: [], weg: [] }, + 'de vorm van het schema is gewijzigd; genereer de snapshot opnieuw met scripts/schema-snapshot.mjs'); + }); + + it('staat regel voor regel gelijk aan de vastgelegde vorm', () => { + assert.deepEqual(REGELS, VERWACHT); + }); + + it('hoort bij deze schemaversie', () => { + assert.equal(CONFIG.SCHEMA_VERSION, 128,'nieuwe schemaversie? dan hoort er een migratie bij (§8)'); + }); +}); + +const VERWACHT = [ + "Account.closedAt: TEXT", + "Account.createdAt: TEXT", + "Account.description: TEXT", + "Account.id: INTEGER PK1", + "Account.isActive: INTEGER NOT NULL", + "Account.isOwnCustody: INTEGER NOT NULL", + "Account.kind: TEXT NOT NULL", + "Account.name: TEXT NOT NULL", + "Account.scanEnabled: INTEGER NOT NULL", + "Account.searchName: TEXT NOT NULL", + "Account.taxCategory: TEXT", + "Account.taxScope: TEXT", + "Account.updatedAt: TEXT", + "Account.wealthScope: TEXT", + "AiProposal.accountId -> Account.id ON DELETE CASCADE", + "AiProposal.accountId: INTEGER", + "AiProposal.accountKind: TEXT", + "AiProposal.amount: INTEGER", + "AiProposal.assetId -> Asset.id ON DELETE CASCADE", + "AiProposal.assetId: INTEGER", + "AiProposal.balance: INTEGER", + "AiProposal.createdAccountId -> Account.id ON DELETE SET NULL", + "AiProposal.createdAccountId: INTEGER", + "AiProposal.createdAt: TEXT NOT NULL", + "AiProposal.createdEventId -> Event.id ON DELETE SET NULL", + "AiProposal.createdEventId: INTEGER", + "AiProposal.createdValuationId -> Valuation.id ON DELETE SET NULL", + "AiProposal.createdValuationId: INTEGER", + "AiProposal.currency: TEXT", + "AiProposal.detail: TEXT", + "AiProposal.eventType: TEXT", + "AiProposal.fiatFee: REAL", + "AiProposal.fiatPrice: REAL", + "AiProposal.fromAccountId -> Account.id ON DELETE CASCADE", + "AiProposal.fromAccountId: INTEGER", + "AiProposal.id: INTEGER PK1", + "AiProposal.isOwnCustody: INTEGER", + "AiProposal.key: TEXT", + "AiProposal.kind: TEXT NOT NULL", + "AiProposal.name: TEXT", + "AiProposal.note: TEXT", + "AiProposal.observedAt: TEXT", + "AiProposal.resolvedAt: TEXT", + "AiProposal.scope: TEXT", + "AiProposal.status: TEXT NOT NULL", + "AiProposal.toAccountId -> Account.id ON DELETE CASCADE", + "AiProposal.toAccountId: INTEGER", + "AiProposal.unitFee: INTEGER", + "AiProposal.value: TEXT", + "AiToolCall.arguments: TEXT", + "AiToolCall.charactersOut: INTEGER", + "AiToolCall.client: TEXT", + "AiToolCall.detail: TEXT", + "AiToolCall.fieldsShared: TEXT", + "AiToolCall.id: INTEGER PK1", + "AiToolCall.occurredAt: TEXT NOT NULL", + "AiToolCall.outcome: TEXT NOT NULL", + "AiToolCall.serverVersion: TEXT", + "AiToolCall.sessionId: TEXT NOT NULL", + "AiToolCall.tool: TEXT NOT NULL", + "AiToolCallMessage.callId -> AiToolCall.id ON DELETE CASCADE", + "AiToolCallMessage.callId: INTEGER NOT NULL", + "AiToolCallMessage.id: INTEGER PK1", + "AiToolCallMessage.kind: TEXT NOT NULL", + "AiToolCallMessage.occurredAt: TEXT NOT NULL", + "AiToolCallMessage.text: TEXT NOT NULL", + "ApiCallMetric.bucketMinute: TEXT NOT NULL PK3", + "ApiCallMetric.capability: TEXT NOT NULL PK2", + "ApiCallMetric.failCount: INTEGER NOT NULL", + "ApiCallMetric.maxLatencyMs: INTEGER NOT NULL", + "ApiCallMetric.okCount: INTEGER NOT NULL", + "ApiCallMetric.providerId -> Provider.id ON DELETE CASCADE", + "ApiCallMetric.providerId: INTEGER NOT NULL PK1", + "ApiCallMetric.sumLatencyMs: INTEGER NOT NULL", + "Asset.balanceCapture: INTEGER", + "Asset.createdAt: TEXT", + "Asset.decimals: INTEGER NOT NULL", + "Asset.description: TEXT", + "Asset.displayUnit: TEXT NOT NULL", + "Asset.id: INTEGER PK1", + "Asset.kind: TEXT NOT NULL", + "Asset.name: TEXT NOT NULL", + "Asset.searchName: TEXT NOT NULL", + "Asset.subunit: TEXT", + "Asset.symbol: TEXT NOT NULL", + "Asset.taxCategory: TEXT", + "Asset.taxScope: TEXT", + "Asset.unit: TEXT NOT NULL", + "Asset.updatedAt: TEXT", + "AssetNote.assetId -> Asset.id ON DELETE CASCADE", + "AssetNote.assetId: INTEGER NOT NULL", + "AssetNote.body: TEXT NOT NULL", + "AssetNote.createdAt: TEXT NOT NULL", + "AssetNote.id: INTEGER PK1", + "BusSignal.id: INTEGER PK1", + "BusSignal.name: TEXT NOT NULL", + "BusSignal.occurredAt: TEXT NOT NULL", + "ChangeLog.changes: TEXT NOT NULL", + "ChangeLog.id: INTEGER PK1", + "ChangeLog.occurredAt: TEXT NOT NULL", + "ChangeLog.operation: TEXT NOT NULL", + "ChangeLog.origin: TEXT NOT NULL", + "ChangeLog.originId: INTEGER", + "ChangeLog.rowId: INTEGER NOT NULL", + "ChangeLog.tableName: TEXT NOT NULL", + "Command.claimedAt: TEXT", + "Command.claimedBy: TEXT", + "Command.createdAt: TEXT NOT NULL", + "Command.detail: TEXT", + "Command.expiresAt: TEXT", + "Command.id: INTEGER PK1", + "Command.kind: TEXT NOT NULL", + "Command.payload: TEXT", + "Command.resolvedAt: TEXT", + "Command.state: TEXT NOT NULL", + "Connection.createdAt: TEXT NOT NULL", + "Connection.description: TEXT", + "Connection.id: INTEGER PK1", + "Connection.isActive: INTEGER NOT NULL", + "Connection.isSystem: INTEGER NOT NULL", + "Connection.kind: TEXT NOT NULL", + "Connection.lastCheckedAt: TEXT", + "Connection.lastStatus: TEXT", + "Connection.name: TEXT NOT NULL", + "Connection.searchName: TEXT NOT NULL", + "Connection.updatedAt: TEXT NOT NULL", + "Connection.url: TEXT NOT NULL", + "DashboardProfile.createdAt: TEXT NOT NULL", + "DashboardProfile.description: TEXT", + "DashboardProfile.id: INTEGER PK1", + "DashboardProfile.isActive: INTEGER NOT NULL", + "DashboardProfile.isSystem: INTEGER NOT NULL", + "DashboardProfile.name: TEXT NOT NULL", + "DashboardProfile.searchName: TEXT NOT NULL", + "DashboardProfile.sortOrder: INTEGER NOT NULL", + "DashboardProfile.updatedAt: TEXT NOT NULL", + "DashboardWidget.assetId -> Asset.id ON DELETE CASCADE", + "DashboardWidget.assetId: INTEGER", + "DashboardWidget.createdAt: TEXT", + "DashboardWidget.gridX: INTEGER NOT NULL", + "DashboardWidget.gridY: INTEGER NOT NULL", + "DashboardWidget.id: INTEGER PK1", + "DashboardWidget.instanceKey: TEXT NOT NULL", + "DashboardWidget.profileId -> DashboardProfile.id ON DELETE CASCADE", + "DashboardWidget.profileId: INTEGER NOT NULL", + "DashboardWidget.settings: TEXT", + "DashboardWidget.spanHeight: INTEGER", + "DashboardWidget.spanWidth: INTEGER NOT NULL", + "DashboardWidget.type: TEXT NOT NULL", + "Event.amount: INTEGER NOT NULL", + "Event.assetId -> Asset.id ON DELETE CASCADE", + "Event.assetId: INTEGER NOT NULL", + "Event.createdAt: TEXT NOT NULL", + "Event.externalAddress: TEXT", + "Event.fiatFee: REAL", + "Event.fiatPrice: REAL", + "Event.fromAccountId -> Account.id ON DELETE NO ACTION", + "Event.fromAccountId: INTEGER", + "Event.id: INTEGER PK1", + "Event.importReference: TEXT", + "Event.note: TEXT", + "Event.occurredAt: TEXT NOT NULL", + "Event.priceOrigin: TEXT", + "Event.productCount: INTEGER", + "Event.productId -> MetalProduct.id ON DELETE NO ACTION", + "Event.productId: INTEGER", + "Event.source: TEXT NOT NULL", + "Event.toAccountId -> Account.id ON DELETE NO ACTION", + "Event.toAccountId: INTEGER", + "Event.type: TEXT NOT NULL", + "Event.unitFee: INTEGER", + "Event.updatedAt: TEXT", + "HygieneAcceptance.createdAt: TEXT NOT NULL", + "HygieneAcceptance.fingerprint: TEXT NOT NULL", + "HygieneAcceptance.id: INTEGER PK1", + "HygieneAcceptance.kind: TEXT NOT NULL", + "HygieneAcceptance.note: TEXT", + "HygieneAcceptance.summary: TEXT NOT NULL", + "INDEX IxAccountSearchName ON Account(searchName)", + "INDEX IxAiProposalAccountId ON AiProposal(accountId)", + "INDEX IxAiProposalAssetId ON AiProposal(assetId)", + "INDEX IxAiProposalCreatedAccountId ON AiProposal(createdAccountId)", + "INDEX IxAiProposalCreatedEventId ON AiProposal(createdEventId)", + "INDEX IxAiProposalCreatedValuationId ON AiProposal(createdValuationId)", + "INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)", + "INDEX IxAiProposalStatus ON AiProposal(status)", + "INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)", + "INDEX IxAiToolCallMessageCallId ON AiToolCallMessage(callId)", + "INDEX IxAiToolCallOccurredAt ON AiToolCall(occurredAt)", + "INDEX IxAssetSearchName ON Asset(searchName)", + "INDEX IxChangeLogOccurredAt ON ChangeLog(occurredAt)", + "INDEX IxChangeLogTableNameRowId ON ChangeLog(tableName,rowId)", + "INDEX IxDashboardWidgetAssetId ON DashboardWidget(assetId)", + "INDEX IxEventAssetId ON Event(assetId)", + "INDEX IxEventExternalAddress ON Event(externalAddress)", + "INDEX IxEventFromAccountId ON Event(fromAccountId)", + "INDEX IxEventImportReference ON Event(importReference)", + "INDEX IxEventOccurredAt ON Event(occurredAt)", + "INDEX IxEventProductId ON Event(productId)", + "INDEX IxEventToAccountId ON Event(toAccountId)", + "INDEX IxEventType ON Event(type)", + "INDEX IxHygieneAcceptanceKind ON HygieneAcceptance(kind)", + "INDEX IxIgnoredPriceGapAssetId ON IgnoredPriceGap(assetId)", + "INDEX IxImportBatchAccountId ON ImportBatch(accountId)", + "INDEX IxIntegrationCallOccurredAt ON IntegrationCall(occurredAt)", + "INDEX IxInterestCreditEventId ON InterestCredit(eventId)", + "INDEX IxInterestScheduleAccountId ON InterestSchedule(accountId)", + "INDEX IxInterestSchedulePayoutAssetId ON InterestSchedule(payoutAssetId)", + "INDEX IxInterestSchedulePrincipalAssetId ON InterestSchedule(principalAssetId)", + "INDEX IxNotificationKind ON Notification(kind)", + "INDEX IxPriceAbsenceProviderId ON PriceAbsence(providerId)", + "INDEX IxPriceHistoryAssetIdObservedAt ON PriceHistory(assetId,observedAt)", + "INDEX IxPriceHistoryProviderId ON PriceHistory(providerId)", + "INDEX IxPriceSourceProviderId ON PriceSource(providerId)", + "INDEX IxProviderCoverageSymbol ON ProviderCoverage(symbol)", + "INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId,capability)", + "INDEX IxProviderSearchName ON Provider(searchName)", + "INDEX IxReconcileProposalAccountId ON ReconcileProposal(accountId)", + "INDEX IxReconcileProposalAssetId ON ReconcileProposal(assetId)", + "INDEX IxReconcileProposalResolvedEventId ON ReconcileProposal(resolvedEventId)", + "INDEX IxReconcileProposalStatus ON ReconcileProposal(status)", + "INDEX IxReconcileProposalWalletTransactionId ON ReconcileProposal(walletTransactionId)", + "INDEX IxScanLogAccountIdScannedAt ON ScanLog(accountId,scannedAt)", + "INDEX IxValuationAccountId ON Valuation(accountId)", + "INDEX IxWalletAddressAccountId ON WalletAddress(accountId)", + "INDEX IxWalletAddressSourceId ON WalletAddress(sourceId)", + "INDEX IxWalletSourceAccountId ON WalletSource(accountId)", + "INDEX IxWalletSourceConnectionId ON WalletSource(connectionId)", + "INDEX IxWalletTransactionAccountId ON WalletTransaction(accountId)", + "INDEX IxWalletTransactionEventLinkEventId ON WalletTransactionEventLink(eventId)", + "INDEX IxWalletTransactionLabelId ON WalletTransaction(labelId)", + "INDEX IxWalletTransactionSourceId ON WalletTransaction(sourceId)", + "IgnoredPriceGap.assetId -> Asset.id ON DELETE CASCADE", + "IgnoredPriceGap.assetId: INTEGER NOT NULL", + "IgnoredPriceGap.createdAt: TEXT NOT NULL", + "IgnoredPriceGap.fromDay: TEXT NOT NULL", + "IgnoredPriceGap.id: INTEGER PK1", + "IgnoredPriceGap.untilDay: TEXT NOT NULL", + "ImportBatch.accountId -> Account.id ON DELETE SET NULL", + "ImportBatch.accountId: INTEGER", + "ImportBatch.filename: TEXT NOT NULL", + "ImportBatch.id: INTEGER PK1", + "ImportBatch.importedAt: TEXT NOT NULL", + "ImportBatch.origin: TEXT", + "ImportBatch.rawData: TEXT", + "ImportBatch.rowCount: INTEGER NOT NULL", + "IntegrationCall.detail: TEXT", + "IntegrationCall.errorKey: TEXT", + "IntegrationCall.httpStatus: INTEGER", + "IntegrationCall.id: INTEGER PK1", + "IntegrationCall.latencyMs: INTEGER", + "IntegrationCall.occurredAt: TEXT NOT NULL", + "IntegrationCall.operation: TEXT NOT NULL", + "IntegrationCall.outcome: TEXT NOT NULL", + "IntegrationCall.providerKey: TEXT", + "IntegrationCall.requestId: TEXT", + "IntegrationCall.subject: TEXT", + "IntegrationCall.target: TEXT NOT NULL", + "InterestCredit.createdAt: TEXT NOT NULL", + "InterestCredit.eventId -> Event.id ON DELETE CASCADE", + "InterestCredit.eventId: INTEGER", + "InterestCredit.id: INTEGER PK1", + "InterestCredit.periodEnd: TEXT NOT NULL", + "InterestCredit.periodStart: TEXT NOT NULL", + "InterestCredit.scheduleId -> InterestSchedule.id ON DELETE CASCADE", + "InterestCredit.scheduleId: INTEGER NOT NULL", + "InterestCredit.status: TEXT NOT NULL", + "InterestCredit.updatedAt: TEXT", + "InterestPeriod.annualRate: REAL NOT NULL", + "InterestPeriod.createdAt: TEXT", + "InterestPeriod.id: INTEGER PK1", + "InterestPeriod.scheduleId -> InterestSchedule.id ON DELETE CASCADE", + "InterestPeriod.scheduleId: INTEGER NOT NULL", + "InterestPeriod.startsOn: TEXT NOT NULL", + "InterestPeriod.updatedAt: TEXT", + "InterestSchedule.accountId -> Account.id ON DELETE CASCADE", + "InterestSchedule.accountId: INTEGER NOT NULL", + "InterestSchedule.annualRate: REAL", + "InterestSchedule.compounding: INTEGER NOT NULL", + "InterestSchedule.createdAt: TEXT NOT NULL", + "InterestSchedule.creditCadence: TEXT", + "InterestSchedule.creditDayOfMonth: INTEGER", + "InterestSchedule.creditWeekday: INTEGER", + "InterestSchedule.endsOn: TEXT", + "InterestSchedule.id: INTEGER PK1", + "InterestSchedule.note: TEXT", + "InterestSchedule.payoutAssetId -> Asset.id ON DELETE CASCADE", + "InterestSchedule.payoutAssetId: INTEGER NOT NULL", + "InterestSchedule.principalAmount: INTEGER", + "InterestSchedule.principalAssetId -> Asset.id ON DELETE NO ACTION", + "InterestSchedule.principalAssetId: INTEGER", + "InterestSchedule.rateKind: TEXT NOT NULL", + "InterestSchedule.startsOn: TEXT NOT NULL", + "InterestSchedule.updatedAt: TEXT", + "Label.createdAt: TEXT", + "Label.description: TEXT", + "Label.id: INTEGER PK1", + "Label.name: TEXT NOT NULL", + "Label.searchName: TEXT NOT NULL", + "Label.updatedAt: TEXT", + "MetalProduct.assetId -> Asset.id ON DELETE CASCADE", + "MetalProduct.assetId: INTEGER NOT NULL", + "MetalProduct.createdAt: TEXT", + "MetalProduct.description: TEXT", + "MetalProduct.fineGrams: INTEGER NOT NULL", + "MetalProduct.form: TEXT NOT NULL", + "MetalProduct.id: INTEGER PK1", + "MetalProduct.name: TEXT NOT NULL", + "MetalProduct.searchName: TEXT NOT NULL", + "MetalProduct.updatedAt: TEXT", + "Notification.count: INTEGER NOT NULL", + "Notification.createdAt: TEXT NOT NULL", + "Notification.dismissedAt: TEXT", + "Notification.highWater: INTEGER NOT NULL", + "Notification.id: INTEGER PK1", + "Notification.kind: TEXT NOT NULL", + "Portfolio.baseCurrency: TEXT NOT NULL", + "Portfolio.createdAt: TEXT", + "Portfolio.id: INTEGER PK1", + "Preference.createdAt: TEXT", + "Preference.key: TEXT NOT NULL PK2", + "Preference.scope: TEXT NOT NULL PK1", + "Preference.value: TEXT NOT NULL", + "PriceAbsence.assetId -> Asset.id ON DELETE CASCADE", + "PriceAbsence.assetId: INTEGER NOT NULL", + "PriceAbsence.createdAt: TEXT NOT NULL", + "PriceAbsence.day: TEXT NOT NULL", + "PriceAbsence.id: INTEGER PK1", + "PriceAbsence.providerId -> Provider.id ON DELETE CASCADE", + "PriceAbsence.providerId: INTEGER NOT NULL", + "PriceHistory.assetId -> Asset.id ON DELETE CASCADE", + "PriceHistory.assetId: INTEGER NOT NULL", + "PriceHistory.createdAt: TEXT", + "PriceHistory.fiatPrice: REAL NOT NULL", + "PriceHistory.id: INTEGER PK1", + "PriceHistory.observedAt: TEXT NOT NULL", + "PriceHistory.providerId -> Provider.id ON DELETE RESTRICT", + "PriceHistory.providerId: INTEGER NOT NULL", + "PriceHistory.resolution: TEXT NOT NULL", + "PriceHistory.sourceName: TEXT", + "PriceHistory.updatedAt: TEXT", + "PriceSource.assetId -> Asset.id ON DELETE CASCADE", + "PriceSource.assetId: INTEGER NOT NULL", + "PriceSource.capability: TEXT NOT NULL", + "PriceSource.createdAt: TEXT", + "PriceSource.id: INTEGER PK1", + "PriceSource.providerId -> Provider.id ON DELETE RESTRICT", + "PriceSource.providerId: INTEGER NOT NULL", + "PriceSource.rank: INTEGER", + "PriceSource.status: TEXT NOT NULL", + "Provider.authenticationHeaderName: TEXT", + "Provider.authenticationKind: TEXT NOT NULL", + "Provider.authenticationParameterName: TEXT", + "Provider.authenticationRequired: INTEGER NOT NULL", + "Provider.baseUrl: TEXT NOT NULL", + "Provider.createdAt: TEXT NOT NULL", + "Provider.description: TEXT", + "Provider.id: INTEGER PK1", + "Provider.isActive: INTEGER NOT NULL", + "Provider.isSeed: INTEGER NOT NULL", + "Provider.isSystem: INTEGER NOT NULL", + "Provider.key: TEXT NOT NULL", + "Provider.name: TEXT NOT NULL", + "Provider.searchName: TEXT NOT NULL", + "ProviderAuthenticationHmac.createdAt: TEXT", + "ProviderAuthenticationHmac.encoding: TEXT NOT NULL", + "ProviderAuthenticationHmac.hashAlgorithm: TEXT NOT NULL", + "ProviderAuthenticationHmac.keyHeader: TEXT NOT NULL", + "ProviderAuthenticationHmac.messageParts: TEXT NOT NULL", + "ProviderAuthenticationHmac.providerId -> Provider.id ON DELETE CASCADE", + "ProviderAuthenticationHmac.providerId: INTEGER PK1", + "ProviderAuthenticationHmac.signatureHeader: TEXT NOT NULL", + "ProviderAuthenticationHmac.timestampHeader: TEXT NOT NULL", + "ProviderAuthenticationHmac.windowHeader: TEXT", + "ProviderAuthenticationHmac.windowMs: INTEGER", + "ProviderCallLimit.callsPerMonth: INTEGER NOT NULL", + "ProviderCallLimit.createdAt: TEXT", + "ProviderCallLimit.providerId -> Provider.id ON DELETE CASCADE", + "ProviderCallLimit.providerId: INTEGER PK1", + "ProviderCoverage.createdAt: TEXT", + "ProviderCoverage.id: INTEGER PK1", + "ProviderCoverage.providerId -> Provider.id ON DELETE CASCADE", + "ProviderCoverage.providerId: INTEGER NOT NULL", + "ProviderCoverage.symbol: TEXT NOT NULL", + "ProviderEndpoint.capability: TEXT NOT NULL", + "ProviderEndpoint.createdAt: TEXT", + "ProviderEndpoint.id: INTEGER PK1", + "ProviderEndpoint.method: TEXT NOT NULL", + "ProviderEndpoint.minIntervalSeconds: INTEGER", + "ProviderEndpoint.pathTemplate: TEXT NOT NULL", + "ProviderEndpoint.providerId -> Provider.id ON DELETE CASCADE", + "ProviderEndpoint.providerId: INTEGER NOT NULL", + "ProviderEndpoint.queryTemplate: TEXT", + "ProviderEndpoint.quoteCurrency: TEXT", + "ProviderFixedRate.createdAt: TEXT", + "ProviderFixedRate.fiatPrice: REAL NOT NULL", + "ProviderFixedRate.providerId -> Provider.id ON DELETE CASCADE", + "ProviderFixedRate.providerId: INTEGER PK1", + "ProviderInputMap.createdAt: TEXT", + "ProviderInputMap.externalId: TEXT NOT NULL", + "ProviderInputMap.id: INTEGER PK1", + "ProviderInputMap.isCatchAll: INTEGER NOT NULL", + "ProviderInputMap.providerId -> Provider.id ON DELETE CASCADE", + "ProviderInputMap.providerId: INTEGER NOT NULL", + "ProviderInputMap.quoteUnit: TEXT", + "ProviderInputMap.symbol: TEXT", + "ProviderOutputMap.capability: TEXT NOT NULL", + "ProviderOutputMap.createdAt: TEXT", + "ProviderOutputMap.id: INTEGER PK1", + "ProviderOutputMap.internalField: TEXT NOT NULL", + "ProviderOutputMap.jsonPath: TEXT NOT NULL", + "ProviderOutputMap.providerId -> Provider.id ON DELETE CASCADE", + "ProviderOutputMap.providerId: INTEGER NOT NULL", + "ProviderOutputMap.transform: TEXT NOT NULL", + "ReconcileProposal.accountId -> Account.id ON DELETE CASCADE", + "ReconcileProposal.accountId: INTEGER NOT NULL", + "ReconcileProposal.amount: INTEGER NOT NULL", + "ReconcileProposal.assetId -> Asset.id ON DELETE CASCADE", + "ReconcileProposal.assetId: INTEGER NOT NULL", + "ReconcileProposal.detectedAt: TEXT NOT NULL", + "ReconcileProposal.direction: TEXT NOT NULL", + "ReconcileProposal.id: INTEGER PK1", + "ReconcileProposal.resolvedEventId -> Event.id ON DELETE SET NULL", + "ReconcileProposal.resolvedEventId: INTEGER", + "ReconcileProposal.status: TEXT NOT NULL", + "ReconcileProposal.suggestedType: TEXT NOT NULL", + "ReconcileProposal.transactionHash: TEXT", + "ReconcileProposal.walletTransactionId -> WalletTransaction.id ON DELETE CASCADE", + "ReconcileProposal.walletTransactionId: INTEGER", + "ScanLog.accountId -> Account.id ON DELETE CASCADE", + "ScanLog.accountId: INTEGER NOT NULL", + "ScanLog.addressesFound: INTEGER", + "ScanLog.balanceFound: INTEGER", + "ScanLog.id: INTEGER PK1", + "ScanLog.proposalsCreated: INTEGER", + "ScanLog.rawData: TEXT", + "ScanLog.scannedAt: TEXT NOT NULL", + "ScanLog.status: TEXT NOT NULL", + "Schema.id: INTEGER PK1", + "Schema.version: INTEGER NOT NULL", + "UNIQUE INDEX UxAssetNoteAssetId ON AssetNote(assetId)", + "UNIQUE INDEX UxConnectionActiveBtcExplorer ON Connection(kind) PARTIAL", + "UNIQUE INDEX UxDashboardProfileActive ON DashboardProfile(isActive) PARTIAL", + "UNIQUE INDEX UxHygieneAcceptanceFingerprint ON HygieneAcceptance(fingerprint)", + "UNIQUE INDEX UxPriceAbsenceAssetIdProviderIdDay ON PriceAbsence(assetId,providerId,day)", + "UNIQUE INDEX UxProviderInputMapCatchAll ON ProviderInputMap(providerId) PARTIAL", + "UNIQUE INDEX UxReconcileProposalLink ON ReconcileProposal(walletTransactionId,resolvedEventId) PARTIAL", + "UNIQUE INDEX UxWalletTransactionAccountIdTxid ON WalletTransaction(accountId,txid) PARTIAL", + "UNIQUE INDEX UxWalletTransactionSourceIdTxid ON WalletTransaction(sourceId,txid) PARTIAL", + "UNIQUE INDEX sqlite_autoindex_ApiCallMetric_1 ON ApiCallMetric(providerId,capability,bucketMinute)", + "UNIQUE INDEX sqlite_autoindex_Asset_1 ON Asset(symbol) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_Connection_1 ON Connection(kind,searchName) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_DashboardProfile_1 ON DashboardProfile(searchName) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_DashboardWidget_1 ON DashboardWidget(profileId,instanceKey) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_InterestCredit_1 ON InterestCredit(scheduleId,periodEnd) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_InterestPeriod_1 ON InterestPeriod(scheduleId,startsOn) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_Label_1 ON Label(searchName) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_MetalProduct_1 ON MetalProduct(assetId,searchName) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_Preference_1 ON Preference(scope,key)", + "UNIQUE INDEX sqlite_autoindex_PriceHistory_1 ON PriceHistory(assetId,providerId,observedAt,resolution) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_PriceSource_1 ON PriceSource(assetId,capability,providerId) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_PriceSource_2 ON PriceSource(assetId,capability,rank) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_ProviderCoverage_1 ON ProviderCoverage(providerId,symbol) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_ProviderEndpoint_1 ON ProviderEndpoint(providerId,capability) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_ProviderInputMap_1 ON ProviderInputMap(providerId,symbol) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_Provider_1 ON Provider(key) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_Valuation_1 ON Valuation(assetId,valuedOn,accountId) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_WalletAddress_1 ON WalletAddress(address) [uit UNIQUE]", + "UNIQUE INDEX sqlite_autoindex_WalletTransactionEventLink_1 ON WalletTransactionEventLink(walletTransactionId,eventId) [uit UNIQUE]", + "Valuation.accountId -> Account.id ON DELETE NO ACTION", + "Valuation.accountId: INTEGER", + "Valuation.assetId -> Asset.id ON DELETE CASCADE", + "Valuation.assetId: INTEGER NOT NULL", + "Valuation.balance: INTEGER NOT NULL", + "Valuation.createdAt: TEXT", + "Valuation.fiatRate: REAL NOT NULL", + "Valuation.gapAcceptedAt: TEXT", + "Valuation.id: INTEGER PK1", + "Valuation.rateAt: TEXT NOT NULL", + "Valuation.source: TEXT NOT NULL", + "Valuation.updatedAt: TEXT", + "Valuation.valuedOn: TEXT NOT NULL", + "WalletAddress.accountId -> Account.id ON DELETE CASCADE", + "WalletAddress.accountId: INTEGER NOT NULL", + "WalletAddress.address: TEXT NOT NULL", + "WalletAddress.automaticLabel: TEXT", + "WalletAddress.balanceSats: INTEGER", + "WalletAddress.chainIndex: INTEGER", + "WalletAddress.createdAt: TEXT", + "WalletAddress.derivationIndex: INTEGER", + "WalletAddress.id: INTEGER PK1", + "WalletAddress.lastScannedAt: TEXT", + "WalletAddress.sourceId -> WalletSource.id ON DELETE CASCADE", + "WalletAddress.sourceId: INTEGER", + "WalletAddress.transactionCount: INTEGER", + "WalletSource.accountId -> Account.id ON DELETE CASCADE", + "WalletSource.accountId: INTEGER NOT NULL", + "WalletSource.balanceSats: INTEGER", + "WalletSource.connectionId -> Connection.id ON DELETE RESTRICT", + "WalletSource.connectionId: INTEGER", + "WalletSource.createdAt: TEXT NOT NULL", + "WalletSource.id: INTEGER PK1", + "WalletSource.kind: TEXT NOT NULL", + "WalletSource.lastScannedAt: TEXT", + "WalletSource.name: TEXT", + "WalletSource.scriptType: TEXT", + "WalletSource.updatedAt: TEXT", + "WalletSource.value: TEXT NOT NULL", + "WalletTransaction.accountId -> Account.id ON DELETE CASCADE", + "WalletTransaction.accountId: INTEGER NOT NULL", + "WalletTransaction.address: TEXT", + "WalletTransaction.blockHeight: INTEGER", + "WalletTransaction.blockTime: TEXT", + "WalletTransaction.createdAt: TEXT NOT NULL", + "WalletTransaction.direction: TEXT NOT NULL", + "WalletTransaction.feeSats: INTEGER", + "WalletTransaction.id: INTEGER PK1", + "WalletTransaction.kind: TEXT", + "WalletTransaction.labelId -> Label.id ON DELETE SET NULL", + "WalletTransaction.labelId: INTEGER", + "WalletTransaction.layer: TEXT NOT NULL", + "WalletTransaction.memo: TEXT", + "WalletTransaction.sourceId -> WalletSource.id ON DELETE CASCADE", + "WalletTransaction.sourceId: INTEGER", + "WalletTransaction.txid: TEXT NOT NULL", + "WalletTransaction.valueSats: INTEGER NOT NULL", + "WalletTransactionEventLink.createdAt: TEXT NOT NULL", + "WalletTransactionEventLink.eventId -> Event.id ON DELETE CASCADE", + "WalletTransactionEventLink.eventId: INTEGER NOT NULL", + "WalletTransactionEventLink.id: INTEGER PK1", + "WalletTransactionEventLink.method: TEXT NOT NULL", + "WalletTransactionEventLink.walletTransactionId -> WalletTransaction.id ON DELETE CASCADE", + "WalletTransactionEventLink.walletTransactionId: INTEGER NOT NULL", +]; diff --git a/tests/db/search-name.test.mjs b/tests/db/search-name.test.mjs new file mode 100644 index 0000000..9f17b35 --- /dev/null +++ b/tests/db/search-name.test.mjs @@ -0,0 +1,48 @@ +// Generated, do not edit. sha256:af48787cb89b51ea0deb7319053f63d99c76dc61ed23f69fff32ffdd48d87f38 +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { toSearchName } from '../../shared/search-name.js'; + +describe('toSearchName', () => { + it('maakt er kleine letters van', () => { + assert.equal(toSearchName('Bitcoin'), 'bitcoin'); + assert.equal(toSearchName('WERK'), 'werk'); + }); + + it('trimt spaties aan de randen', () => { + assert.equal(toSearchName(' Kluis '), 'kluis'); + }); + + it('haalt diakritische tekens weg', () => { + assert.equal(toSearchName('Café'), 'cafe'); + assert.equal(toSearchName('Bäcker'), 'backer'); + }); + + it('laat spaties BINNENIN staan', () => { + assert.equal(toSearchName('Baar 1 kg'), 'baar 1 kg'); + }); + + it('laat leestekens en cijfers ongemoeid', () => { + assert.equal(toSearchName('mempool.space (publiek)'), 'mempool.space (publiek)'); + assert.equal(toSearchName('Baar 1 kg'), 'baar 1 kg'); + }); + + it('geeft lege tekst voor null en undefined in plaats van te gooien', () => { + assert.equal(toSearchName(null), ''); + assert.equal(toSearchName(undefined), ''); + assert.equal(toSearchName(''), ''); + assert.equal(toSearchName(' '), ''); + }); + + it('is stabiel: nog een keer normaliseren verandert niets', () => { + for (const naam of ['Café', ' Werk ', 'Baar 1 kg', 'ÉÉN']) { + assert.equal(toSearchName(toSearchName(naam)), toSearchName(naam)); + } + }); + + it('maakt van twee schrijfwijzen dezelfde sleutel', () => { + assert.equal(toSearchName('Werk'), toSearchName('WERK')); + assert.equal(toSearchName('Werk'), toSearchName(' werk ')); + }); +}); diff --git a/tests/db/seeds.test.mjs b/tests/db/seeds.test.mjs new file mode 100644 index 0000000..01a9c39 --- /dev/null +++ b/tests/db/seeds.test.mjs @@ -0,0 +1,268 @@ +// Generated, do not edit. sha256:3914f1ddc9004d83b8401e7c0b8dbbc55835029676e9b46f33978bfb59e5b5ee +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +import { openDatabase, closeDatabase, database, run } from '../../server/db/conn.mjs'; +import { SCHEMA } from '../../server/db/schema.js'; +import { seedAssets, seedPriceSource, seedDashboardProfile, seedProviders, seedPreferences } from '../../server/db/seeds.js'; +import { DASHBOARD, PREFERENCES } from '../../shared/config.js'; + +const verseDatabase = (munt) => { + closeDatabase(); openDatabase(':memory:'); run(SCHEMA); + run(`INSERT INTO Portfolio (id, baseCurrency, createdAt) VALUES (1, ?, '2026-01-01T00:00:00Z')`, [munt]); +}; +beforeEach(() => verseDatabase('EUR')); +afterEach(() => closeDatabase()); + +const profielen = () => database().prepare(`SELECT * FROM DashboardProfile ORDER BY id`).all().map(r => ({ ...r })); +const widgets = () => database().prepare(`SELECT * FROM DashboardWidget ORDER BY instanceKey`).all().map(r => ({ ...r })); + +describe('seedDashboardProfile', () => { + it('maakt één actief systeemprofiel', () => { + seedDashboardProfile(); + const p = profielen(); + assert.equal(p.length, 1); + assert.equal(p[0].name, 'Standaard'); + assert.equal(p[0].isSystem, 1); + assert.equal(p[0].isActive, 1); + assert.equal(p[0].sortOrder, 0); + assert.equal(p[0].searchName, 'standaard', 'afgeleid via toSearchName, niet met de hand'); + }); + + it('zet de standaardindeling als rijen neer', () => { + seedDashboardProfile(); + assert.deepEqual(widgets().map(w => w.type).sort(), DASHBOARD.DEFAULT_LAYOUT.map(w => w.type).sort()); + assert.deepEqual(widgets().map(w => w.instanceKey).sort(), DASHBOARD.DEFAULT_LAYOUT.map(w => w.instId).sort()); + }); + + it('laat hoogte en rij open, want die komen uit de meting', () => { + seedDashboardProfile(); + for (const w of widgets()) { + assert.equal(w.spanHeight, null, `${w.instanceKey} heeft al een hoogte`); + assert.equal(w.gridY, 0); + assert.ok(w.spanWidth > 0, 'de breedte komt wél uit de registry: die wordt gekozen'); + } + }); + + it('neemt de voorkeurskolom mee, zodat Meldingen rechts begint', () => { + seedDashboardProfile(); + const kolom = Object.fromEntries(widgets().map(w => [w.instanceKey, w.gridX])); + assert.equal(kolom.clocks, undefined, 'geen wereldklokken bij een verse installatie'); + assert.equal(kolom.notifications, DASHBOARD.GRID_COLUMNS - DASHBOARD.WIDGETS.notifications.defaultSpan); + }); + + it('koppelt de widgets aan het profiel en laat assetId leeg', () => { + seedDashboardProfile(); + const id = profielen()[0].id; + for (const w of widgets()) { + assert.equal(w.profileId, id); + assert.equal(w.assetId, null, 'geen enkele standaardwidget hangt aan één asset'); + assert.equal(w.settings, null, 'en geen van hen heeft parameters'); + } + }); + + it('is idempotent', () => { + seedDashboardProfile(); + seedDashboardProfile(); + assert.equal(profielen().length, 1); + assert.equal(widgets().length, DASHBOARD.DEFAULT_LAYOUT.length, 'geen dubbele widget-rijen'); + }); + + it('vertelt of hij iets heeft aangemaakt', () => { + assert.equal(seedDashboardProfile(), true, 'eerste keer: aangemaakt'); + assert.equal(seedDashboardProfile(), false, 'tweede keer: niets te doen'); + }); + + it('loopt niet op de unieke index als er al een ander actief profiel is', () => { + run(`INSERT INTO DashboardProfile (name, searchName, sortOrder, isActive, isSystem, createdAt, updatedAt) + VALUES ('Eigen','eigen',0,1,0,'2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`); + seedDashboardProfile(); + const p = profielen(); + assert.equal(p.length, 2); + assert.equal(p.filter(x => x.isActive).length, 1, 'nog steeds precies één actief'); + assert.equal(p.find(x => x.isActive).name, 'Eigen', 'de bestaande keuze blijft staan'); + assert.equal(p.find(x => x.isSystem).isActive, 0); + }); +}); + +describe('seeds: de vaste-koers-provider en het EUR-asset', () => { + const provider = () => database().prepare(`SELECT * FROM Provider WHERE key = 'fixed'`).get(); + const bronkeuze = () => database().prepare( + `SELECT capability, rank, status FROM PriceSource + WHERE assetId = (SELECT id FROM Asset WHERE symbol = 'EUR') + ORDER BY capability`).all().map(r => ({ ...r })); + + it('zet de provider neer met een vaste koers van 1,00, zijn dekking en zijn twee capabilities', () => { + seedProviders(); + const p = provider(); + assert.equal(p.isSystem, 1); + assert.equal(p.baseUrl, '', 'geen URL, want er gaat geen verzoek uit'); + assert.equal(database().prepare(`SELECT fiatPrice FROM ProviderFixedRate WHERE providerId = ?`).get(p.id).fiatPrice, 1); + assert.deepEqual( + database().prepare(`SELECT symbol FROM ProviderCoverage WHERE providerId = ?`).all(p.id).map(r => r.symbol), + ['EUR']); + assert.deepEqual( + database().prepare(`SELECT capability FROM ProviderEndpoint WHERE providerId = ? ORDER BY capability`) + .all(p.id).map(r => r.capability), + ['history_point', 'spot'], 'geen daily_history: een vaste koers heeft geen reeks'); + }); + + it('geeft alleen de vaste-koers-provider een vaste koers', () => { + seedProviders(); + assert.deepEqual( + database().prepare(`SELECT p.key FROM ProviderFixedRate f JOIN Provider p ON p.id = f.providerId`).all() + .map(r => r.key), + ['fixed']); + }); + + it('zet het EUR-asset neer in centen', () => { + seedAssets(); + const eur = database().prepare(`SELECT * FROM Asset WHERE symbol = 'EUR'`).get(); + assert.equal(eur.kind, 'fiat'); + assert.equal(eur.unit, 'EUR'); + assert.equal(eur.subunit, 'cent', 'de cent is de opslag-eenheid van 10^decimals'); + assert.equal(eur.decimals, 2); + assert.equal(eur.displayUnit, 'unit', 'niemand leest een saldo in centen'); + }); + + it('koppelt EUR aan de vaste koers voor de spot en de historie', () => { + seedProviders(); + seedAssets(); + seedPriceSource(null, 'confirmed'); + assert.deepEqual(bronkeuze(), [ + { capability: 'history', rank: 1, status: 'confirmed' }, + { capability: 'spot', rank: 1, status: 'confirmed' }, + ]); + }); + + it('geeft een bron alleen de keuze die hij kan leveren', () => { + seedProviders(); + seedAssets(); + database().exec(`DELETE FROM ProviderEndpoint WHERE capability <> 'spot' + AND providerId = (SELECT id FROM Provider WHERE key = 'fixed')`); + seedPriceSource(null, 'confirmed'); + assert.deepEqual(bronkeuze(), [{ capability: 'spot', rank: 1, status: 'confirmed' }]); + }); + + it('is idempotent: twee keer zaaien levert één provider en één asset', () => { + seedProviders(); seedAssets(); seedPriceSource(null, 'confirmed'); + seedProviders(); seedAssets(); seedPriceSource(null, 'confirmed'); + const n = (q) => database().prepare(q).get().n; + assert.equal(n(`SELECT COUNT(*) AS n FROM Provider WHERE key = 'fixed'`), 1); + assert.equal(n(`SELECT COUNT(*) AS n FROM Asset WHERE symbol = 'EUR'`), 1); + assert.equal(n(`SELECT COUNT(*) AS n FROM ProviderFixedRate`), 1); + assert.equal(bronkeuze().length, 2); + }); +}); + +describe('seeds per rekenvaluta', () => { + const zaai = () => { seedProviders(); seedAssets(); seedPriceSource(null, 'confirmed'); }; + const dekking = (key) => database().prepare( + `SELECT symbol FROM ProviderCoverage WHERE providerId = (SELECT id FROM Provider WHERE key = ?) ORDER BY symbol`) + .all(key).map(r => r.symbol); + const startbron = (symbool) => database().prepare( + `SELECT DISTINCT Provider.key FROM PriceSource + JOIN Provider ON Provider.id = PriceSource.providerId + JOIN Asset ON Asset.id = PriceSource.assetId + WHERE Asset.symbol = ?`).all(symbool).map(r => r.key); + const fiat = () => database().prepare(`SELECT symbol, name, subunit, decimals FROM Asset WHERE kind = 'fiat' ORDER BY symbol`) + .all().map(r => ({ ...r })); + + it('in euro: de vaste koers op de euro, en de dollar voor zijn koers', () => { + zaai(); + assert.deepEqual(dekking('fixed'), ['EUR']); + assert.deepEqual(fiat().map(a => a.symbol), ['EUR', 'USD'], 'geen derde munt als de rekenvaluta al één van de twee is'); + }); + + it('in dollars: de vaste koers op de dollar, en de euro zonder eigen bron', () => { + verseDatabase('USD'); + zaai(); + assert.deepEqual(dekking('fixed'), ['USD']); + assert.deepEqual(startbron('USD'), ['fixed']); + assert.deepEqual(startbron('EUR'), [], 'de koers van de euro komt van de dienst'); + }); + + it('geeft een later toegevoegd BTC de bron die de portefeuille al heeft', () => { + zaai(); + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('BTC', 'Bitcoin', 'bitcoin', 'crypto', 'BTC', 'sat', 8, 'subunit')`); + const id = database().prepare(`SELECT id FROM Asset WHERE symbol = 'BTC'`).get().id; + seedPriceSource(id, 'confirmed'); + assert.deepEqual(startbron('BTC'), [], 'een verse database heeft geen mempool-provider'); + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES ('mempool', 'mempool', 'mempool', 'https://mempool.space', 'none', 0, 1, 1, 0, '2026-01-01T00:00:00Z')`); + const pid = database().prepare(`SELECT id FROM Provider WHERE key = 'mempool'`).get().id; + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate) VALUES (?, 'spot', 'GET', '/api/v1/prices')`, [pid]); + seedPriceSource(id, 'confirmed'); + assert.deepEqual(startbron('BTC'), ['mempool']); + }); + + it('geeft de assets uit de onboarding geen eigen bron: hun koers komt van de dienst', async () => { + const { ASSET_DEFINITIONS } = await import('../../shared/config.js'); + zaai(); + for (const d of Object.values(ASSET_DEFINITIONS)) { + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,?,?,?,?,?)`, + [d.symbol, d.name, d.name.toLowerCase(), d.kind, d.unit, d.subunit, d.decimals, d.displayUnit]); + seedPriceSource(database().prepare(`SELECT id FROM Asset WHERE symbol = ?`).get(d.symbol).id, 'confirmed'); + assert.deepEqual(startbron(d.symbol), [], `${d.symbol} kreeg een eigen bron`); + } + }); + + it('zet alleen de systeemproviders neer', () => { + zaai(); + const rijen = database().prepare(`SELECT key, isSystem FROM Provider ORDER BY key`).all().map(r => ({ ...r })); + assert.deepEqual(rijen.map(r => r.key), ['atreides_services', 'fixed', 'manual']); + assert.ok(rijen.every(r => r.isSystem === 1)); + }); + + it('in ponden: drie fiat-assets, en alleen de rekenvaluta met een bron', () => { + verseDatabase('GBP'); + zaai(); + const munten = fiat(); + assert.deepEqual(munten.map(({ name, ...rest }) => rest), [ + { symbol: 'EUR', subunit: 'cent', decimals: 2 }, + { symbol: 'GBP', subunit: null, decimals: 2 }, + { symbol: 'USD', subunit: 'cent', decimals: 2 }, + ]); + const pond = munten.find(a => a.symbol === 'GBP').name; + assert.match(pond, /^[A-Z].*pond$/, `'${pond}' is geen Nederlandse naam met hoofdletter`); + assert.deepEqual(dekking('fixed'), ['GBP']); + assert.deepEqual(startbron('GBP'), ['fixed']); + assert.deepEqual(startbron('USD'), []); + }); + + it('zet in een verse database alleen de fiat-assets neer, geen crypto en geen metaal', () => { + zaai(); + const soorten = database().prepare(`SELECT DISTINCT kind FROM Asset`).all().map(r => r.kind); + assert.deepEqual(soorten, ['fiat']); + }); + + it('geeft een munt zonder centen ook geen centen', () => { + verseDatabase('JPY'); + seedAssets(); + assert.equal(fiat().find(a => a.symbol === 'JPY').decimals, 0); + }); + + it('weigert te zaaien zonder Portfolio-rij, in plaats van de euro aan te nemen', () => { + closeDatabase(); openDatabase(':memory:'); run(SCHEMA); + assert.throws(() => seedAssets(), /Portfolio row/); + assert.throws(() => seedProviders(), /Portfolio row/); + assert.throws(() => seedPriceSource(null, 'confirmed'), /Portfolio row/); + }); +}); + +describe('seedPreferences', () => { + const waarde = (scope, key) => JSON.parse(database().prepare( + `SELECT value FROM Preference WHERE scope = ? AND key = ?`).get(scope, key).value); + + it('zet de dienst vooraan in een verse database', () => { + seedPreferences(); + assert.equal(waarde('app', 'market_spot'), 'first'); + assert.equal(PREFERENCES.app.market_spot.initial, 'off', 'de terugval van een bestaande portefeuille'); + }); + + it('laat de andere voorkeuren op hun initial', () => { + seedPreferences(); + assert.equal(waarde('app', 'result_signs'), PREFERENCES.app.result_signs.initial); + }); +}); diff --git a/tests/db/time.test.mjs b/tests/db/time.test.mjs new file mode 100644 index 0000000..333f39a --- /dev/null +++ b/tests/db/time.test.mjs @@ -0,0 +1,124 @@ +// Generated, do not edit. sha256:e2e0937b7b3c760761ff1e6af43b751d527084ca1706a0de98999069ed45ca25 +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { toUtcIso, toLocalIso, localDate, localDateNl, dayNl, localDateTimeNl, localTime, localDateTime, nowUtcIso, nowLocalIso, setDateLocale } from '../../shared/time.js'; + +describe('tijd: opslaan (naïef lokaal naar UTC)', () => { + it('rekent zomertijd en wintertijd verschillend om', () => { + assert.equal(toUtcIso('2026-06-20T14:30:00'), '2026-06-20T12:30:00Z'); + assert.equal(toUtcIso('2026-01-15T14:30:00'), '2026-01-15T13:30:00Z'); + }); + + it('accepteert een datetime-local-waarde zonder seconden', () => { + assert.equal(toUtcIso('2026-06-20T14:30'), '2026-06-20T12:30:00Z'); + }); + + it('laat een waarde die al UTC is ongemoeid, dus omzetten is idempotent', () => { + const al = '2026-06-20T12:30:00.000Z'; + assert.equal(toUtcIso(al), al); + assert.equal(toUtcIso(toUtcIso(al)), al); + }); + + it('raakt een kalenderdatum en lege waarden niet aan', () => { + assert.equal(toUtcIso('2026-06-20'), '2026-06-20'); + assert.equal(toUtcIso(null), null); + assert.equal(toUtcIso(''), ''); + }); +}); + +describe('tijd: heen-en-terug', () => { + it('komt exact terug, ook op de twee zomertijdgrenzen', () => { + for (const t of ['2026-06-20T14:30:00', '2026-01-15T00:00:00', '2026-03-29T04:00:00', + '2026-10-25T04:00:00', '2025-12-31T23:59:59', '2024-02-29T12:00:00']) { + assert.equal(toLocalIso(toUtcIso(t)), t, `heen-en-terug faalt op ${t}`); + } + }); + + it('houdt het moment gelijk, ook als de kalenderdag verschuift', () => { + const utc = toUtcIso('2026-06-20T00:00:00'); + assert.equal(utc, '2026-06-19T22:00:00Z'); + assert.equal(toLocalIso(utc), '2026-06-20T00:00:00'); + }); +}); + +describe('tijd: tonen', () => { + it('toont de lokale dag en niet de UTC-dag', () => { + assert.equal(localDate('2026-06-19T22:00:00Z'), '2026-06-20'); + assert.equal(localTime('2026-06-19T22:00:00Z'), '00:00'); + assert.equal(localDateTime('2026-06-19T22:00:00Z'), '2026-06-20 00:00'); + }); + + it('toont een zomers tijdstempel twee uur later dan de UTC-tekst', () => { + assert.equal(localTime('2025-11-01T14:52:43.000Z'), '15:52'); + assert.equal(localTime('2026-06-20T12:30:00.000Z'), '14:30'); + }); + + it('geeft lege tekst voor een lege waarde in plaats van te gooien', () => { + for (const leeg of [null, undefined, '']) { + assert.equal(localDate(leeg), ''); + assert.equal(localTime(leeg), ''); + assert.equal(localDateTime(leeg), ''); + } + }); +}); + +describe('tijd: nu', () => { + it('geeft nowUtcIso in UTC en nowLocalIso zonder zone', () => { + assert.match(nowUtcIso(), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + assert.match(nowLocalIso(), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); + }); + + it('geeft voor hetzelfde moment dezelfde tijd in beide vormen', () => { + const utc = nowUtcIso(); + assert.equal(toLocalIso(utc).slice(0, 16), nowLocalIso().slice(0, 16)); + }); +}); + +describe('localDateNl', () => { + it('zet de dag vooraan, met streepjes', () => { + assert.equal(localDateNl('2026-06-20T12:30:00Z'), '20-06-2026'); + }); + + it('vult de dag en de maand aan tot twee cijfers', () => { + assert.equal(localDateNl('2026-01-01T12:00:00Z'), '01-01-2026'); + }); + + it('volgt de LOKALE dag en niet de UTC-dag', () => { + assert.equal(localDateNl('2026-06-20T22:30:00Z'), '21-06-2026'); + assert.equal(localDate('2026-06-20T22:30:00Z'), '2026-06-21', 'dezelfde dag als localDate'); + }); + + it('geeft lege tekst op een LEGE waarde, en gooit op onleesbare invoer', () => { + for (const leeg of [null, undefined, '']) assert.equal(localDateNl(leeg), ''); + assert.throws(() => localDateNl('geen datum'), /Onleesbaar tijdstempel/); + }); +}); + +describe('dayNl en localDateTimeNl', () => { + it('draait een kalenderdatum om zonder tijdzone', () => { + assert.equal(dayNl('2026-01-01'), '01-01-2026'); + assert.equal(dayNl('2026-12-31'), '31-12-2026'); + }); + + it('geeft leeg op leeg en laat iets wat geen datum is staan', () => { + for (const leeg of [null, undefined, '']) assert.equal(dayNl(leeg), ''); + assert.equal(dayNl('onbekend'), 'onbekend'); + }); + + it('zet datum en klok samen neer, in lokale tijd', () => { + assert.equal(localDateTimeNl('2026-06-19T22:00:00Z'), '20-06-2026 00:00'); + assert.equal(localDateTimeNl(null), ''); + }); + + it('volgt de taal van de app, en terug', () => { + setDateLocale('en-US'); + try { + assert.equal(dayNl('2026-06-08'), '06/08/2026'); + assert.equal(localDateTimeNl('2026-06-19T22:00:00Z'), '06/20/2026 00:00'); + } finally { + setDateLocale('nl-NL'); + } + assert.equal(dayNl('2026-06-08'), '08-06-2026', 'terug naar Nederlands, ook met de opmaak in de cache'); + }); +}); diff --git a/tests/db/timestamps.test.mjs b/tests/db/timestamps.test.mjs new file mode 100644 index 0000000..2d5e4b1 --- /dev/null +++ b/tests/db/timestamps.test.mjs @@ -0,0 +1,65 @@ +// Generated, do not edit. sha256:fefc7c108aded894b1e2af3e5fbbedc3eac1b26a65f9c5ddfd7154896dda96a0 +import { describe, it, before } from 'node:test'; +import assert from 'node:assert/strict'; + +import { openBaselineDb } from '../helpers/baseline-db.mjs'; +import { CONFIG } from '../../shared/config.js'; + +const EIGEN_ONTSTAAN = { + ScanLog: 'scannedAt', + ReconcileProposal: 'detectedAt', + ImportBatch: 'importedAt', + AiToolCall: 'occurredAt', + AiToolCallMessage: 'occurredAt', + BusSignal: 'occurredAt', + ChangeLog: 'occurredAt', + IntegrationCall: 'occurredAt', +}; +const GEEN_RIJ_IDENTITEIT = ['ApiCallMetric', 'Schema']; + +let KOLOMMEN; + +before(() => { + const db = openBaselineDb(); + KOLOMMEN = {}; + for (const t of CONFIG.DB_TABLES) { + KOLOMMEN[t] = db.prepare(`PRAGMA table_info(${t})`).all().map(k => k.name); + } + db.close(); +}); + +describe('tijdstempels: createdAt waar het ontstaan nog nergens staat', () => { + it('geeft elke tabel een createdAt, op de vrijgestelde na', () => { + const missend = CONFIG.DB_TABLES.filter(t => + !(t in EIGEN_ONTSTAAN) && !GEEN_RIJ_IDENTITEIT.includes(t) && !KOLOMMEN[t].includes('createdAt')); + assert.deepEqual(missend, [], + 'een nieuwe tabel hoort een createdAt te krijgen, of een gemotiveerde vrijstelling hierboven'); + }); + + it('houdt de vrijgestelde tabellen vrij, want anders staan er twee namen voor hetzelfde moment', () => { + for (const [tabel, kolom] of Object.entries(EIGEN_ONTSTAAN)) { + assert.ok(KOLOMMEN[tabel].includes(kolom), `${tabel} zou zijn ontstaan in ${kolom} bewaren`); + assert.ok(!KOLOMMEN[tabel].includes('createdAt'), + `${tabel} heeft ${kolom} én createdAt: dat is een tweede waarheid over hetzelfde moment`); + } + for (const tabel of GEEN_RIJ_IDENTITEIT) { + assert.ok(!KOLOMMEN[tabel].includes('createdAt'), `${tabel} heeft geen rij om een moment aan te hangen`); + } + }); +}); + +describe('tijdstempels: updatedAt waar een wijziging betekenis heeft', () => { + it('geeft elke getrackte tabel een updatedAt', () => { + const missend = CONFIG.DB_HISTORY.TRACKED.filter(t => !KOLOMMEN[t].includes('updatedAt')); + assert.deepEqual(missend, [], + 'een tabel die meedoet in het wijzigingslogboek hoort te kunnen zeggen wanneer hij wijzigde'); + }); + + it('geeft hem NIET aan een tabel waar niemand die vraag stelt', () => { + const teveel = CONFIG.DB_TABLES.filter(t => + !CONFIG.DB_HISTORY.TRACKED.includes(t) && t !== 'PriceHistory' + && t !== 'DashboardProfile' && KOLOMMEN[t].includes('updatedAt')); + assert.deepEqual(teveel, [], + 'updatedAt op een tabel die niet meedoet: die kolom blijft per definitie leeg'); + }); +}); diff --git a/tests/fixtures/nip44.vectors.json b/tests/fixtures/nip44.vectors.json new file mode 100644 index 0000000..8be2b7b --- /dev/null +++ b/tests/fixtures/nip44.vectors.json @@ -0,0 +1,648 @@ +{ + "v2": { + "valid": { + "get_conversation_key": [ + { + "sec1": "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268", + "pub2": "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133", + "conversation_key": "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1" + }, + { + "sec1": "a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e", + "pub2": "03bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800", + "conversation_key": "4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b" + }, + { + "sec1": "98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311", + "pub2": "aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1", + "conversation_key": "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7" + }, + { + "sec1": "86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f", + "pub2": "59f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585", + "conversation_key": "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba" + }, + { + "sec1": "2528c287fe822421bc0dc4c3615878eb98e8a8c31657616d08b29c00ce209e34", + "pub2": "f66ea16104c01a1c532e03f166c5370a22a5505753005a566366097150c6df60", + "conversation_key": "c833bbb292956c43366145326d53b955ffb5da4e4998a2d853611841903f5442" + }, + { + "sec1": "49808637b2d21129478041813aceb6f2c9d4929cd1303cdaf4fbdbd690905ff2", + "pub2": "74d2aab13e97827ea21baf253ad7e39b974bb2498cc747cdb168582a11847b65", + "conversation_key": "4bf304d3c8c4608864c0fe03890b90279328cd24a018ffa9eb8f8ccec06b505d" + }, + { + "sec1": "af67c382106242c5baabf856efdc0629cc1c5b4061f85b8ceaba52aa7e4b4082", + "pub2": "bdaf0001d63e7ec994fad736eab178ee3c2d7cfc925ae29f37d19224486db57b", + "conversation_key": "a3a575dd66d45e9379904047ebfb9a7873c471687d0535db00ef2daa24b391db" + }, + { + "sec1": "0e44e2d1db3c1717b05ffa0f08d102a09c554a1cbbf678ab158b259a44e682f1", + "pub2": "1ffa76c5cc7a836af6914b840483726207cb750889753d7499fb8b76aa8fe0de", + "conversation_key": "a39970a667b7f861f100e3827f4adbf6f464e2697686fe1a81aeda817d6b8bdf" + }, + { + "sec1": "5fc0070dbd0666dbddc21d788db04050b86ed8b456b080794c2a0c8e33287bb6", + "pub2": "31990752f296dd22e146c9e6f152a269d84b241cc95bb3ff8ec341628a54caf0", + "conversation_key": "72c21075f4b2349ce01a3e604e02a9ab9f07e35dd07eff746de348b4f3c6365e" + }, + { + "sec1": "1b7de0d64d9b12ddbb52ef217a3a7c47c4362ce7ea837d760dad58ab313cba64", + "pub2": "24383541dd8083b93d144b431679d70ef4eec10c98fceef1eff08b1d81d4b065", + "conversation_key": "dd152a76b44e63d1afd4dfff0785fa07b3e494a9e8401aba31ff925caeb8f5b1" + }, + { + "sec1": "df2f560e213ca5fb33b9ecde771c7c0cbd30f1cf43c2c24de54480069d9ab0af", + "pub2": "eeea26e552fc8b5e377acaa03e47daa2d7b0c787fac1e0774c9504d9094c430e", + "conversation_key": "770519e803b80f411c34aef59c3ca018608842ebf53909c48d35250bd9323af6" + }, + { + "sec1": "cffff919fcc07b8003fdc63bc8a00c0f5dc81022c1c927c62c597352190d95b9", + "pub2": "eb5c3cca1a968e26684e5b0eb733aecfc844f95a09ac4e126a9e58a4e4902f92", + "conversation_key": "46a14ee7e80e439ec75c66f04ad824b53a632b8409a29bbb7c192e43c00bb795" + }, + { + "sec1": "64ba5a685e443e881e9094647ddd32db14444bb21aa7986beeba3d1c4673ba0a", + "pub2": "50e6a4339fac1f3bf86f2401dd797af43ad45bbf58e0801a7877a3984c77c3c4", + "conversation_key": "968b9dbbfcede1664a4ca35a5d3379c064736e87aafbf0b5d114dff710b8a946" + }, + { + "sec1": "dd0c31ccce4ec8083f9b75dbf23cc2878e6d1b6baa17713841a2428f69dee91a", + "pub2": "b483e84c1339812bed25be55cff959778dfc6edde97ccd9e3649f442472c091b", + "conversation_key": "09024503c7bde07eb7865505891c1ea672bf2d9e25e18dd7a7cea6c69bf44b5d" + }, + { + "sec1": "af71313b0d95c41e968a172b33ba5ebd19d06cdf8a7a98df80ecf7af4f6f0358", + "pub2": "2a5c25266695b461ee2af927a6c44a3c598b8095b0557e9bd7f787067435bc7c", + "conversation_key": "fe5155b27c1c4b4e92a933edae23726a04802a7cc354a77ac273c85aa3c97a92" + }, + { + "sec1": "6636e8a389f75fe068a03b3edb3ea4a785e2768e3f73f48ffb1fc5e7cb7289dc", + "pub2": "514eb2064224b6a5829ea21b6e8f7d3ea15ff8e70e8555010f649eb6e09aec70", + "conversation_key": "ff7afacd4d1a6856d37ca5b546890e46e922b508639214991cf8048ddbe9745c" + }, + { + "sec1": "94b212f02a3cfb8ad147d52941d3f1dbe1753804458e6645af92c7b2ea791caa", + "pub2": "f0cac333231367a04b652a77ab4f8d658b94e86b5a8a0c472c5c7b0d4c6a40cc", + "conversation_key": "e292eaf873addfed0a457c6bd16c8effde33d6664265697f69f420ab16f6669b" + }, + { + "sec1": "aa61f9734e69ae88e5d4ced5aae881c96f0d7f16cca603d3bed9eec391136da6", + "pub2": "4303e5360a884c360221de8606b72dd316da49a37fe51e17ada4f35f671620a6", + "conversation_key": "8e7d44fd4767456df1fb61f134092a52fcd6836ebab3b00766e16732683ed848" + }, + { + "sec1": "5e914bdac54f3f8e2cba94ee898b33240019297b69e96e70c8a495943a72fc98", + "pub2": "5bd097924f606695c59f18ff8fd53c174adbafaaa71b3c0b4144a3e0a474b198", + "conversation_key": "f5a0aecf2984bf923c8cd5e7bb8be262d1a8353cb93959434b943a07cf5644bc" + }, + { + "sec1": "8b275067add6312ddee064bcdbeb9d17e88aa1df36f430b2cea5cc0413d8278a", + "pub2": "65bbbfca819c90c7579f7a82b750a18c858db1afbec8f35b3c1e0e7b5588e9b8", + "conversation_key": "2c565e7027eb46038c2263563d7af681697107e975e9914b799d425effd248d6" + }, + { + "sec1": "1ac848de312285f85e0f7ec208aac20142a1f453402af9b34ec2ec7a1f9c96fc", + "pub2": "45f7318fe96034d23ee3ddc25b77f275cc1dd329664dd51b89f89c4963868e41", + "conversation_key": "b56e970e5057a8fd929f8aad9248176b9af87819a708d9ddd56e41d1aec74088" + }, + { + "sec1": "295a1cf621de401783d29d0e89036aa1c62d13d9ad307161b4ceb535ba1b40e6", + "pub2": "840115ddc7f1034d3b21d8e2103f6cb5ab0b63cf613f4ea6e61ae3d016715cdd", + "conversation_key": "b4ee9c0b9b9fef88975773394f0a6f981ca016076143a1bb575b9ff46e804753" + }, + { + "sec1": "a28eed0fe977893856ab9667e06ace39f03abbcdb845c329a1981be438ba565d", + "pub2": "b0f38b950a5013eba5ab4237f9ed29204a59f3625c71b7e210fec565edfa288c", + "conversation_key": "9d3a802b45bc5aeeb3b303e8e18a92ddd353375710a31600d7f5fff8f3a7285b" + }, + { + "sec1": "7ab65af72a478c05f5c651bdc4876c74b63d20d04cdbf71741e46978797cd5a4", + "pub2": "f1112159161b568a9cb8c9dd6430b526c4204bcc8ce07464b0845b04c041beda", + "conversation_key": "943884cddaca5a3fef355e9e7f08a3019b0b66aa63ec90278b0f9fdb64821e79" + }, + { + "sec1": "95c79a7b75ba40f2229e85756884c138916f9d103fc8f18acc0877a7cceac9fe", + "pub2": "cad76bcbd31ca7bbda184d20cc42f725ed0bb105b13580c41330e03023f0ffb3", + "conversation_key": "81c0832a669eea13b4247c40be51ccfd15bb63fcd1bba5b4530ce0e2632f301b" + }, + { + "sec1": "baf55cc2febd4d980b4b393972dfc1acf49541e336b56d33d429bce44fa12ec9", + "pub2": "0c31cf87fe565766089b64b39460ebbfdedd4a2bc8379be73ad3c0718c912e18", + "conversation_key": "37e2344da9ecdf60ae2205d81e89d34b280b0a3f111171af7e4391ded93b8ea6" + }, + { + "sec1": "6eeec45acd2ed31693c5256026abf9f072f01c4abb61f51cf64e6956b6dc8907", + "pub2": "e501b34ed11f13d816748c0369b0c728e540df3755bab59ed3327339e16ff828", + "conversation_key": "afaa141b522ddb27bb880d768903a7f618bb8b6357728cae7fb03af639b946e6" + }, + { + "sec1": "261a076a9702af1647fb343c55b3f9a4f1096273002287df0015ba81ce5294df", + "pub2": "b2777c863878893ae100fb740c8fab4bebd2bf7be78c761a75593670380a6112", + "conversation_key": "76f8d2853de0734e51189ced523c09427c3e46338b9522cd6f74ef5e5b475c74" + }, + { + "sec1": "ed3ec71ca406552ea41faec53e19f44b8f90575eda4b7e96380f9cc73c26d6f3", + "pub2": "86425951e61f94b62e20cae24184b42e8e17afcf55bafa58645efd0172624fae", + "conversation_key": "f7ffc520a3a0e9e9b3c0967325c9bf12707f8e7a03f28b6cd69ae92cf33f7036" + }, + { + "sec1": "5a788fc43378d1303ac78639c59a58cb88b08b3859df33193e63a5a3801c722e", + "pub2": "a8cba2f87657d229db69bee07850fd6f7a2ed070171a06d006ec3a8ac562cf70", + "conversation_key": "7d705a27feeedf78b5c07283362f8e361760d3e9f78adab83e3ae5ce7aeb6409" + }, + { + "sec1": "63bffa986e382b0ac8ccc1aa93d18a7aa445116478be6f2453bad1f2d3af2344", + "pub2": "b895c70a83e782c1cf84af558d1038e6b211c6f84ede60408f519a293201031d", + "conversation_key": "3a3b8f00d4987fc6711d9be64d9c59cf9a709c6c6481c2cde404bcc7a28f174e" + }, + { + "sec1": "e4a8bcacbf445fd3721792b939ff58e691cdcba6a8ba67ac3467b45567a03e5c", + "pub2": "b54053189e8c9252c6950059c783edb10675d06d20c7b342f73ec9fa6ed39c9d", + "conversation_key": "7b3933b4ef8189d347169c7955589fc1cfc01da5239591a08a183ff6694c44ad" + }, + { + "sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139", + "pub2": "0000000000000000000000000000000000000000000000000000000000000002", + "conversation_key": "8b6392dbf2ec6a2b2d5b1477fc2be84d63ef254b667cadd31bd3f444c44ae6ba", + "note": "sec1 = n-2, pub2: random, 0x02" + }, + { + "sec1": "0000000000000000000000000000000000000000000000000000000000000002", + "pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdeb", + "conversation_key": "be234f46f60a250bef52a5ee34c758800c4ca8e5030bf4cc1a31d37ba2104d43", + "note": "sec1 = 2, pub2: rand" + }, + { + "sec1": "0000000000000000000000000000000000000000000000000000000000000001", + "pub2": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "conversation_key": "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e", + "note": "sec1 == pub2" + } + ], + "get_message_keys": { + "conversation_key": "a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54", + "keys": [ + { + "nonce": "e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72", + "chacha_key": "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76", + "chacha_nonce": "c4ad129bb01180c0933a160c", + "hmac_key": "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4" + }, + { + "nonce": "e1d6d28c46de60168b43d79dacc519698512ec35e8ccb12640fc8e9f26121101", + "chacha_key": "e35b88f8d4a8f1606c5082f7a64b100e5d85fcdb2e62aeafbec03fb9e860ad92", + "chacha_nonce": "22925e920cee4a50a478be90", + "hmac_key": "46a7c55d4283cb0df1d5e29540be67abfe709e3b2e14b7bf9976e6df994ded30" + }, + { + "nonce": "cfc13bef512ac9c15951ab00030dfaf2626fdca638dedb35f2993a9eeb85d650", + "chacha_key": "020783eb35fdf5b80ef8c75377f4e937efb26bcbad0e61b4190e39939860c4bf", + "chacha_nonce": "d3594987af769a52904656ac", + "hmac_key": "237ec0ccb6ebd53d179fa8fd319e092acff599ef174c1fdafd499ef2b8dee745" + }, + { + "nonce": "ea6eb84cac23c5c1607c334e8bdf66f7977a7e374052327ec28c6906cbe25967", + "chacha_key": "ff68db24b34fa62c78ac5ffeeaf19533afaedf651fb6a08384e46787f6ce94be", + "chacha_nonce": "50bb859aa2dde938cc49ec7a", + "hmac_key": "06ff32e1f7b29753a727d7927b25c2dd175aca47751462d37a2039023ec6b5a6" + }, + { + "nonce": "8c2e1dd3792802f1f9f7842e0323e5d52ad7472daf360f26e15f97290173605d", + "chacha_key": "2f9daeda8683fdeede81adac247c63cc7671fa817a1fd47352e95d9487989d8b", + "chacha_nonce": "400224ba67fc2f1b76736916", + "hmac_key": "465c05302aeeb514e41c13ed6405297e261048cfb75a6f851ffa5b445b746e4b" + }, + { + "nonce": "05c28bf3d834fa4af8143bf5201a856fa5fac1a3aee58f4c93a764fc2f722367", + "chacha_key": "1e3d45777025a035be566d80fd580def73ed6f7c043faec2c8c1c690ad31c110", + "chacha_nonce": "021905b1ea3afc17cb9bf96f", + "hmac_key": "74a6e481a89dcd130aaeb21060d7ec97ad30f0007d2cae7b1b11256cc70dfb81" + }, + { + "nonce": "5e043fb153227866e75a06d60185851bc90273bfb93342f6632a728e18a07a17", + "chacha_key": "1ea72c9293841e7737c71567d8120145a58991aaa1c436ef77bf7adb83f882f1", + "chacha_nonce": "72f69a5a5f795465cee59da8", + "hmac_key": "e9daa1a1e9a266ecaa14e970a84bce3fbbf329079bbccda626582b4e66a0d4c9" + }, + { + "nonce": "7be7338eaf06a87e274244847fe7a97f5c6a91f44adc18fcc3e411ad6f786dbf", + "chacha_key": "881e7968a1f0c2c80742ee03cd49ea587e13f22699730f1075ade01931582bf6", + "chacha_nonce": "6e69be92d61c04a276021565", + "hmac_key": "901afe79e74b19967c8829af23617d7d0ffbf1b57190c096855c6a03523a971b" + }, + { + "nonce": "94571c8d590905bad7becd892832b472f2aa5212894b6ce96e5ba719c178d976", + "chacha_key": "f80873dd48466cb12d46364a97b8705c01b9b4230cb3ec3415a6b9551dc42eef", + "chacha_nonce": "3dda53569cfcb7fac1805c35", + "hmac_key": "e9fc264345e2839a181affebc27d2f528756e66a5f87b04bf6c5f1997047051e" + }, + { + "nonce": "13a6ee974b1fd759135a2c2010e3cdda47081c78e771125e4f0c382f0284a8cb", + "chacha_key": "bc5fb403b0bed0d84cf1db872b6522072aece00363178c98ad52178d805fca85", + "chacha_nonce": "65064239186e50304cc0f156", + "hmac_key": "e872d320dde4ed3487958a8e43b48aabd3ced92bc24bb8ff1ccb57b590d9701a" + }, + { + "nonce": "082fecdb85f358367b049b08be0e82627ae1d8edb0f27327ccb593aa2613b814", + "chacha_key": "1fbdb1cf6f6ea816349baf697932b36107803de98fcd805ebe9849b8ad0e6a45", + "chacha_nonce": "2e605e1d825a3eaeb613db9c", + "hmac_key": "fae910f591cf3c7eb538c598583abad33bc0a03085a96ca4ea3a08baf17c0eec" + }, + { + "nonce": "4c19020c74932c30ec6b2d8cd0d5bb80bd0fc87da3d8b4859d2fb003810afd03", + "chacha_key": "1ab9905a0189e01cda82f843d226a82a03c4f5b6dbea9b22eb9bc953ba1370d4", + "chacha_nonce": "cbb2530ea653766e5a37a83a", + "hmac_key": "267f68acac01ac7b34b675e36c2cef5e7b7a6b697214add62a491bedd6efc178" + }, + { + "nonce": "67723a3381497b149ce24814eddd10c4c41a1e37e75af161930e6b9601afd0ff", + "chacha_key": "9ecbd25e7e2e6c97b8c27d376dcc8c5679da96578557e4e21dba3a7ef4e4ac07", + "chacha_nonce": "ef649fcf335583e8d45e3c2e", + "hmac_key": "04dbbd812fa8226fdb45924c521a62e3d40a9e2b5806c1501efdeba75b006bf1" + }, + { + "nonce": "42063fe80b093e8619b1610972b4c3ab9e76c14fd908e642cd4997cafb30f36c", + "chacha_key": "211c66531bbcc0efcdd0130f9f1ebc12a769105eb39608994bcb188fa6a73a4a", + "chacha_nonce": "67803605a7e5010d0f63f8c8", + "hmac_key": "e840e4e8921b57647369d121c5a19310648105dbdd008200ebf0d3b668704ff8" + }, + { + "nonce": "b5ac382a4be7ac03b554fe5f3043577b47ea2cd7cfc7e9ca010b1ffbb5cf1a58", + "chacha_key": "b3b5f14f10074244ee42a3837a54309f33981c7232a8b16921e815e1f7d1bb77", + "chacha_nonce": "4e62a0073087ed808be62469", + "hmac_key": "c8efa10230b5ea11633816c1230ca05fa602ace80a7598916d83bae3d3d2ccd7" + }, + { + "nonce": "e9d1eba47dd7e6c1532dc782ff63125db83042bb32841db7eeafd528f3ea7af9", + "chacha_key": "54241f68dc2e50e1db79e892c7c7a471856beeb8d51b7f4d16f16ab0645d2f1a", + "chacha_nonce": "a963ed7dc29b7b1046820a1d", + "hmac_key": "aba215c8634530dc21c70ddb3b3ee4291e0fa5fa79be0f85863747bde281c8b2" + }, + { + "nonce": "a94ecf8efeee9d7068de730fad8daf96694acb70901d762de39fa8a5039c3c49", + "chacha_key": "c0565e9e201d2381a2368d7ffe60f555223874610d3d91fbbdf3076f7b1374dd", + "chacha_nonce": "329bb3024461e84b2e1c489b", + "hmac_key": "ac42445491f092481ce4fa33b1f2274700032db64e3a15014fbe8c28550f2fec" + }, + { + "nonce": "533605ea214e70c25e9a22f792f4b78b9f83a18ab2103687c8a0075919eaaa53", + "chacha_key": "ab35a5e1e54d693ff023db8500d8d4e79ad8878c744e0eaec691e96e141d2325", + "chacha_nonce": "653d759042b85194d4d8c0a7", + "hmac_key": "b43628e37ba3c31ce80576f0a1f26d3a7c9361d29bb227433b66f49d44f167ba" + }, + { + "nonce": "7f38df30ceea1577cb60b355b4f5567ff4130c49e84fed34d779b764a9cc184c", + "chacha_key": "a37d7f211b84a551a127ff40908974eb78415395d4f6f40324428e850e8c42a3", + "chacha_nonce": "b822e2c959df32b3cb772a7c", + "hmac_key": "1ba31764f01f69b5c89ded2d7c95828e8052c55f5d36f1cd535510d61ba77420" + }, + { + "nonce": "11b37f9dbc4d0185d1c26d5f4ed98637d7c9701fffa65a65839fa4126573a4e5", + "chacha_key": "964f38d3a31158a5bfd28481247b18dd6e44d69f30ba2a40f6120c6d21d8a6ba", + "chacha_nonce": "5f72c5b87c590bcd0f93b305", + "hmac_key": "2fc4553e7cedc47f29690439890f9f19c1077ef3e9eaeef473d0711e04448918" + }, + { + "nonce": "8be790aa483d4cdd843189f71f135b3ec7e31f381312c8fe9f177aab2a48eafa", + "chacha_key": "95c8c74d633721a131316309cf6daf0804d59eaa90ea998fc35bac3d2fbb7a94", + "chacha_nonce": "409a7654c0e4bf8c2c6489be", + "hmac_key": "21bb0b06eb2b460f8ab075f497efa9a01c9cf9146f1e3986c3bf9da5689b6dc4" + }, + { + "nonce": "19fd2a718ea084827d6bd73f509229ddf856732108b59fc01819f611419fd140", + "chacha_key": "cc6714b9f5616c66143424e1413d520dae03b1a4bd202b82b0a89b0727f5cdc8", + "chacha_nonce": "1b7fd2534f015a8f795d8f32", + "hmac_key": "2bef39c4ce5c3c59b817e86351373d1554c98bc131c7e461ed19d96cfd6399a0" + }, + { + "nonce": "3c2acd893952b2f6d07d8aea76f545ca45961a93fe5757f6a5a80811d5e0255d", + "chacha_key": "c8de6c878cb469278d0af894bc181deb6194053f73da5014c2b5d2c8db6f2056", + "chacha_nonce": "6ffe4f1971b904a1b1a81b99", + "hmac_key": "df1cd69dd3646fca15594284744d4211d70e7d8472e545d276421fbb79559fd4" + }, + { + "nonce": "7dbea4cead9ac91d4137f1c0a6eebb6ba0d1fb2cc46d829fbc75f8d86aca6301", + "chacha_key": "c8e030f6aa680c3d0b597da9c92bb77c21c4285dd620c5889f9beba7446446b0", + "chacha_nonce": "a9b5a67d081d3b42e737d16f", + "hmac_key": "355a85f551bc3cce9a14461aa60994742c9bbb1c81a59ca102dc64e61726ab8e" + }, + { + "nonce": "45422e676cdae5f1071d3647d7a5f1f5adafb832668a578228aa1155a491f2f3", + "chacha_key": "758437245f03a88e2c6a32807edfabff51a91c81ca2f389b0b46f2c97119ea90", + "chacha_nonce": "263830a065af33d9c6c5aa1f", + "hmac_key": "7c581cf3489e2de203a95106bfc0de3d4032e9d5b92b2b61fb444acd99037e17" + }, + { + "nonce": "babc0c03fad24107ad60678751f5db2678041ff0d28671ede8d65bdf7aa407e9", + "chacha_key": "bd68a28bd48d9ffa3602db72c75662ac2848a0047a313d2ae2d6bc1ac153d7e9", + "chacha_nonce": "d0f9d2a1ace6c758f594ffdd", + "hmac_key": "eb435e3a642adfc9d59813051606fc21f81641afd58ea6641e2f5a9f123bb50a" + }, + { + "nonce": "7a1b8aac37d0d20b160291fad124ab697cfca53f82e326d78fef89b4b0ea8f83", + "chacha_key": "9e97875b651a1d30d17d086d1e846778b7faad6fcbc12e08b3365d700f62e4fe", + "chacha_nonce": "ccdaad5b3b7645be430992eb", + "hmac_key": "6f2f55cf35174d75752f63c06cc7cbc8441759b142999ed2d5a6d09d263e1fc4" + }, + { + "nonce": "8370e4e32d7e680a83862cab0da6136ef607014d043e64cdf5ecc0c4e20b3d9a", + "chacha_key": "1472bed5d19db9c546106de946e0649cd83cc9d4a66b087a65906e348dcf92e2", + "chacha_nonce": "ed02dece5fc3a186f123420b", + "hmac_key": "7b3f7739f49d30c6205a46b174f984bb6a9fc38e5ccfacef2dac04fcbd3b184e" + }, + { + "nonce": "9f1c5e8a29cd5677513c2e3a816551d6833ee54991eb3f00d5b68096fc8f0183", + "chacha_key": "5e1a7544e4d4dafe55941fcbdf326f19b0ca37fc49c4d47e9eec7fb68cde4975", + "chacha_nonce": "7d9acb0fdc174e3c220f40de", + "hmac_key": "e265ab116fbbb86b2aefc089a0986a0f5b77eda50c7410404ad3b4f3f385c7a7" + }, + { + "nonce": "c385aa1c37c2bfd5cc35fcdbdf601034d39195e1cabff664ceb2b787c15d0225", + "chacha_key": "06bf4e60677a13e54c4a38ab824d2ef79da22b690da2b82d0aa3e39a14ca7bdd", + "chacha_nonce": "26b450612ca5e905b937e147", + "hmac_key": "22208152be2b1f5f75e6bfcc1f87763d48bb7a74da1be3d102096f257207f8b3" + }, + { + "nonce": "3ff73528f88a50f9d35c0ddba4560bacee5b0462d0f4cb6e91caf41847040ce4", + "chacha_key": "850c8a17a23aa761d279d9901015b2bbdfdff00adbf6bc5cf22bd44d24ecabc9", + "chacha_nonce": "4a296a1fb0048e5020d3b129", + "hmac_key": "b1bf49a533c4da9b1d629b7ff30882e12d37d49c19abd7b01b7807d75ee13806" + }, + { + "nonce": "2dcf39b9d4c52f1cb9db2d516c43a7c6c3b8c401f6a4ac8f131a9e1059957036", + "chacha_key": "17f8057e6156ba7cc5310d01eda8c40f9aa388f9fd1712deb9511f13ecc37d27", + "chacha_nonce": "a8188daff807a1182200b39d", + "hmac_key": "47b89da97f68d389867b5d8a2d7ba55715a30e3d88a3cc11f3646bc2af5580ef" + } + ] + }, + "calc_padded_len": [ + [16, 32], + [32, 32], + [33, 64], + [37, 64], + [45, 64], + [49, 64], + [64, 64], + [65, 96], + [100, 128], + [111, 128], + [200, 224], + [250, 256], + [320, 320], + [383, 384], + [384, 384], + [400, 448], + [500, 512], + [512, 512], + [515, 640], + [700, 768], + [800, 896], + [900, 1024], + [1020, 1024], + [65536, 65536] + ], + "encrypt_decrypt": [ + { + "sec1": "0000000000000000000000000000000000000000000000000000000000000001", + "sec2": "0000000000000000000000000000000000000000000000000000000000000002", + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "plaintext": "a", + "payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb" + }, + { + "sec1": "0000000000000000000000000000000000000000000000000000000000000002", + "sec2": "0000000000000000000000000000000000000000000000000000000000000001", + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "f00000000000000000000000000000f00000000000000000000000000000000f", + "plaintext": "🍕🫃", + "payload": "AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj" + }, + { + "sec1": "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a", + "sec2": "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d", + "conversation_key": "3e2b52a63be47d34fe0a80e34e73d436d6963bc8f39827f327057a9986c20a45", + "nonce": "b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b", + "plaintext": "表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀", + "payload": "ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs=" + }, + { + "sec1": "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c", + "sec2": "b9b0a1e9cc20100c5faa3bbe2777303d25950616c4c6a3fa2e3e046f936ec2ba", + "conversation_key": "d5a2f879123145a4b291d767428870f5a8d9e5007193321795b40183d4ab8c2b", + "nonce": "b20989adc3ddc41cd2c435952c0d59a91315d8c5218d5040573fc3749543acaf", + "plaintext": "ability🤝的 ȺȾ", + "payload": "ArIJia3D3cQc0sQ1lSwNWakTFdjFIY1QQFc/w3SVQ6yvbG2S0x4Yu86QGwPTy7mP3961I1XqB6SFFTzqDZZavhxoWMj7mEVGMQIsh2RLWI5EYQaQDIePSnXPlzf7CIt+voTD" + }, + { + "sec1": "875adb475056aec0b4809bd2db9aa00cff53a649e7b59d8edcbf4e6330b0995c", + "sec2": "9c05781112d5b0a2a7148a222e50e0bd891d6b60c5483f03456e982185944aae", + "conversation_key": "3b15c977e20bfe4b8482991274635edd94f366595b1a3d2993515705ca3cedb8", + "nonce": "8d4442713eb9d4791175cb040d98d6fc5be8864d6ec2f89cf0895a2b2b72d1b1", + "plaintext": "pepper👀їжак", + "payload": "Ao1EQnE+udR5EXXLBA2Y1vxb6IZNbsL4nPCJWisrctGxY3AduCS+jTUgAAnfvKafkmpy15+i9YMwCdccisRa8SvzW671T2JO4LFSPX31K4kYUKelSAdSPwe9NwO6LhOsnoJ+" + }, + { + "sec1": "eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f", + "sec2": "dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3", + "conversation_key": "4f1538411098cf11c8af216836444787c462d47f97287f46cf7edb2c4915b8a5", + "nonce": "2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407", + "plaintext": "( ͡° ͜ʖ ͡°)", + "payload": "AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHv4qhKQwnFQ54OjVMgqCea/Vj0YqBSdhqNR777TJ4zIUk7R0fnizp6l1zwgzWv7+ee6u+0/89KIjY5q1wu6inyuiv" + }, + { + "sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e", + "sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214", + "conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd", + "nonce": "e4cd5f7ce4eea024bc71b17ad456a986a74ac426c2c62b0a15eb5c5c8f888b68", + "plaintext": "مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ،", + "payload": "AuTNX3zk7qAkvHGxetRWqYanSsQmwsYrChXrXFyPiItoIBsWu1CB+sStla2M4VeANASHxM78i1CfHQQH1YbBy24Tng7emYW44ol6QkFD6D8Zq7QPl+8L1c47lx8RoODEQMvNCbOk5ffUV3/AhONHBXnffrI+0025c+uRGzfqpYki4lBqm9iYU+k3Tvjczq9wU0mkVDEaM34WiQi30MfkJdRbeeYaq6kNvGPunLb3xdjjs5DL720d61Flc5ZfoZm+CBhADy9D9XiVZYLKAlkijALJur9dATYKci6OBOoc2SJS2Clai5hOVzR0yVeyHRgRfH9aLSlWW5dXcUxTo7qqRjNf8W5+J4jF4gNQp5f5d0YA4vPAzjBwSP/5bGzNDslKfcAH" + }, + { + "sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e", + "sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214", + "conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd", + "nonce": "38d1ca0abef9e5f564e89761a86cee04574b6825d3ef2063b10ad75899e4b023", + "plaintext": "الكل في المجمو عة (5)", + "payload": "AjjRygq++eX1ZOiXYahs7gRXS2gl0+8gY7EK11iZ5LAjbOTrlfrxak5Lki42v2jMPpLSicy8eHjsWkkMtF0i925vOaKG/ZkMHh9ccQBdfTvgEGKzztedqDCAWb5TP1YwU1PsWaiiqG3+WgVvJiO4lUdMHXL7+zKKx8bgDtowzz4QAwI=" + }, + { + "sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e", + "sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214", + "conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd", + "nonce": "4f1a31909f3483a9e69c8549a55bbc9af25fa5bbecf7bd32d9896f83ef2e12e0", + "plaintext": "𝖑𝖆𝖟𝖞 社會科學院語學研究所", + "payload": "Ak8aMZCfNIOp5pyFSaVbvJryX6W77Pe9MtmJb4PvLhLgh/TsxPLFSANcT67EC1t/qxjru5ZoADjKVEt2ejdx+xGvH49mcdfbc+l+L7gJtkH7GLKpE9pQNQWNHMAmj043PAXJZ++fiJObMRR2mye5VHEANzZWkZXMrXF7YjuG10S1pOU=" + }, + { + "sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e", + "sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214", + "conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd", + "nonce": "a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54", + "plaintext": "🙈 🙉 🙊 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗", + "payload": "AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtUf05aMF89q1FLwJvaFJYICZoMYgRJHFLwPiOHce7fuAc40kX0wXJvipyBJ9HzCOj7CgtnC1/cmPCHR3s5AIORmroBWglm1LiFMohv1FSPEbaBD51VXxJa4JyWpYhreSOEjn1wd0lMKC9b+osV2N2tpbs+rbpQem2tRen3sWflmCqjkG5VOVwRErCuXuPb5+hYwd8BoZbfCrsiAVLd7YT44dRtKNBx6rkabWfddKSLtreHLDysOhQUVOp/XkE7OzSkWl6sky0Hva6qJJ/V726hMlomvcLHjE41iKmW2CpcZfOedg==" + } + ], + "encrypt_decrypt_long_msg": [ + { + "conversation_key": "8fc262099ce0d0bb9b89bac05bb9e04f9bc0090acc181fef6840ccee470371ed", + "nonce": "326bcb2c943cd6bb717588c9e5a7e738edf6ed14ec5f5344caa6ef56f0b9cff7", + "pattern": "x", + "repeat": 65535, + "plaintext_sha256": "09ab7495d3e61a76f0deb12cb0306f0696cbb17ffc12131368c7a939f12f56d3", + "payload_sha256": "90714492225faba06310bff2f249ebdc2a5e609d65a629f1c87f2d4ffc55330a" + }, + { + "conversation_key": "56adbe3720339363ab9c3b8526ffce9fd77600927488bfc4b59f7a68ffe5eae0", + "nonce": "ad68da81833c2a8ff609c3d2c0335fd44fe5954f85bb580c6a8d467aa9fc5dd0", + "pattern": "!", + "repeat": 65535, + "plaintext_sha256": "6af297793b72ae092c422e552c3bb3cbc310da274bd1cf9e31023a7fe4a2d75e", + "payload_sha256": "8013e45a109fad3362133132b460a2d5bce235fe71c8b8f4014793fb52a49844" + }, + { + "conversation_key": "7fc540779979e472bb8d12480b443d1e5eb1098eae546ef2390bee499bbf46be", + "nonce": "34905e82105c20de9a2f6cd385a0d541e6bcc10601d12481ff3a7575dc622033", + "pattern": "🦄", + "repeat": 16383, + "plaintext_sha256": "a249558d161b77297bc0cb311dde7d77190f6571b25c7e4429cd19044634a61f", + "payload_sha256": "b3348422471da1f3c59d79acfe2fe103f3cd24488109e5b18734cdb5953afd15" + } + ] + }, + "invalid": { + "encrypt_msg_lengths": [0, 65536, 100000, 10000000], + "get_conversation_key": [ + { + "sec1": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "note": "sec1 higher than curve.n" + }, + { + "sec1": "0000000000000000000000000000000000000000000000000000000000000000", + "pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "note": "sec1 is 0" + }, + { + "sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139", + "pub2": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "note": "pub2 is invalid, no sqrt, all-ff" + }, + { + "sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", + "pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "note": "sec1 == curve.n" + }, + { + "sec1": "0000000000000000000000000000000000000000000000000000000000000002", + "pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "note": "pub2 is invalid, no sqrt" + }, + { + "sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "pub2": "0000000000000000000000000000000000000000000000000000000000000000", + "note": "pub2 is point of order 3 on twist" + }, + { + "sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "pub2": "eb1f7200aecaa86682376fb1c13cd12b732221e774f553b0a0857f88fa20f86d", + "note": "pub2 is point of order 13 on twist" + }, + { + "sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "pub2": "709858a4c121e4a84eb59c0ded0261093c71e8ca29efeef21a6161c447bcaf9f", + "note": "pub2 is point of order 3319 on twist" + } + ], + "decrypt": [ + { + "conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642", + "nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db", + "plaintext": "n o b l e", + "payload": "#Atqupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJdU0MIDf06CUvEvdnr1cp1fiMtlM/GrE92xAc1K5odTpCzUB+mjXgbaqtntBUbTToSUoT0ovrlPwzGjyp", + "note": "unknown encryption version" + }, + { + "conversation_key": "36f04e558af246352dcf73b692fbd3646a2207bd8abd4b1cd26b234db84d9481", + "nonce": "ad408d4be8616dc84bb0bf046454a2a102edac937c35209c43cd7964c5feb781", + "plaintext": "⚠️", + "payload": "AK1AjUvoYW3IS7C/BGRUoqEC7ayTfDUgnEPNeWTF/reBZFaha6EAIRueE9D1B1RuoiuFScC0Q94yjIuxZD3JStQtE8JMNacWFs9rlYP+ZydtHhRucp+lxfdvFlaGV/sQlqZz", + "note": "unknown encryption version 0" + }, + { + "conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642", + "nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db", + "plaintext": "n o s t r", + "payload": "Atфupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJZE0UICD06CUvEvdnr1cp1fiMtlM/GrE92xAc1EwsVCQEgWEu2gsHUVf4JAa3TpgkmFc3TWsax0v6n/Wq", + "note": "invalid base64" + }, + { + "conversation_key": "cff7bd6a3e29a450fd27f6c125d5edeb0987c475fd1e8d97591e0d4d8a89763c", + "nonce": "09ff97750b084012e15ecb84614ce88180d7b8ec0d468508a86b6d70c0361a25", + "plaintext": "¯\\_(ツ)_/¯", + "payload": "Agn/l3ULCEAS4V7LhGFM6IGA17jsDUaFCKhrbXDANholyySBfeh+EN8wNB9gaLlg4j6wdBYh+3oK+mnxWu3NKRbSvQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "note": "invalid MAC" + }, + { + "conversation_key": "cfcc9cf682dfb00b11357f65bdc45e29156b69db424d20b3596919074f5bf957", + "nonce": "65b14b0b949aaa7d52c417eb753b390e8ad6d84b23af4bec6d9bfa3e03a08af4", + "plaintext": "🥎", + "payload": "AmWxSwuUmqp9UsQX63U7OQ6K1thLI69L7G2b+j4DoIr0oRWQ8avl4OLqWZiTJ10vIgKrNqjoaX+fNhE9RqmR5g0f6BtUg1ijFMz71MO1D4lQLQfW7+UHva8PGYgQ1QpHlKgR", + "note": "invalid MAC" + }, + { + "conversation_key": "5254827d29177622d40a7b67cad014fe7137700c3c523903ebbe3e1b74d40214", + "nonce": "7ab65dbb8bbc2b8e35cafb5745314e1f050325a864d11d0475ef75b3660d91c1", + "plaintext": "elliptic-curve cryptography", + "payload": "Anq2XbuLvCuONcr7V0UxTh8FAyWoZNEdBHXvdbNmDZHB573MI7R7rrTYftpqmvUpahmBC2sngmI14/L0HjOZ7lWGJlzdh6luiOnGPc46cGxf08MRC4CIuxx3i2Lm0KqgJ7vA", + "note": "invalid padding" + }, + { + "conversation_key": "fea39aca9aa8340c3a78ae1f0902aa7e726946e4efcd7783379df8096029c496", + "nonce": "7d4283e3b54c885d6afee881f48e62f0a3f5d7a9e1cb71ccab594a7882c39330", + "plaintext": "noble", + "payload": "An1Cg+O1TIhdav7ogfSOYvCj9dep4ctxzKtZSniCw5MwRrrPJFyAQYZh5VpjC2QYzny5LIQ9v9lhqmZR4WBYRNJ0ognHVNMwiFV1SHpvUFT8HHZN/m/QarflbvDHAtO6pY16", + "note": "invalid padding" + }, + { + "conversation_key": "0c4cffb7a6f7e706ec94b2e879f1fc54ff8de38d8db87e11787694d5392d5b3f", + "nonce": "6f9fd72667c273acd23ca6653711a708434474dd9eb15c3edb01ce9a95743e9b", + "plaintext": "censorship-resistant and global social network", + "payload": "Am+f1yZnwnOs0jymZTcRpwhDRHTdnrFcPtsBzpqVdD6b2NZDaNm/TPkZGr75kbB6tCSoq7YRcbPiNfJXNch3Tf+o9+zZTMxwjgX/nm3yDKR2kHQMBhVleCB9uPuljl40AJ8kXRD0gjw+aYRJFUMK9gCETZAjjmrsCM+nGRZ1FfNsHr6Z", + "note": "invalid padding" + }, + { + "conversation_key": "5cd2d13b9e355aeb2452afbd3786870dbeecb9d355b12cb0a3b6e9da5744cd35", + "nonce": "b60036976a1ada277b948fd4caa065304b96964742b89d26f26a25263a5060bd", + "plaintext": "0", + "payload": "", + "note": "invalid payload length: 0" + }, + { + "conversation_key": "d61d3f09c7dfe1c0be91af7109b60a7d9d498920c90cbba1e137320fdd938853", + "nonce": "1a29d02c8b4527745a2ccb38bfa45655deb37bc338ab9289d756354cea1fd07c", + "plaintext": "1", + "payload": "Ag==", + "note": "invalid payload length: 4" + }, + { + "conversation_key": "873bb0fc665eb950a8e7d5971965539f6ebd645c83c08cd6a85aafbad0f0bc47", + "nonce": "c826d3c38e765ab8cc42060116cd1464b2a6ce01d33deba5dedfb48615306d4a", + "plaintext": "2", + "payload": "AqxgToSh3H7iLYRJjoWAM+vSv/Y1mgNlm6OWWjOYUClrFF8=", + "note": "invalid payload length: 48" + }, + { + "conversation_key": "9f2fef8f5401ac33f74641b568a7a30bb19409c76ffdc5eae2db6b39d2617fbe", + "nonce": "9ff6484642545221624eaac7b9ea27133a4cc2356682a6033aceeef043549861", + "plaintext": "3", + "payload": "Ap/2SEZCVFIhYk6qx7nqJxM6TMI1ZoKmAzrO7vBDVJhhuZXWiM20i/tIsbjT0KxkJs2MZjh1oXNYMO9ggfk7i47WQA==", + "note": "invalid payload length: 92" + } + ] + } + } +} diff --git a/tests/helpers/baseline-db.mjs b/tests/helpers/baseline-db.mjs new file mode 100644 index 0000000..97bd39e --- /dev/null +++ b/tests/helpers/baseline-db.mjs @@ -0,0 +1,114 @@ +// Generated, do not edit. sha256:958c567a3f8a5513bb292a105e1d9e3838bf252c71b7a83188de3f33183e7f66 +import { DatabaseSync } from 'node:sqlite'; + +import { SCHEMA as BASELINE_SCHEMA } from '../../server/db/schema.js'; +import { CONFIG } from '../../shared/config.js'; + +const NU = '2026-08-12T10:00:00.000Z'; + +export function openBaselineDb() { + const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true }); + db.exec(`PRAGMA foreign_keys = ON`); + db.exec(BASELINE_SCHEMA); + return db; +} + +export function foreignKeysAan(db) { + return db.prepare(`PRAGMA foreign_keys`).get().foreign_keys === 1; +} + +const FIXTURE = [ + [`Asset`, `(symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind) + VALUES ('BTC','Bitcoin','bitcoin',NULL,'BTC','sat','subunit',8,'crypto')`], + [`Account`, `(name, searchName, description, kind, isOwnCustody, scanEnabled, isActive, closedAt) + VALUES ('Kluis','kluis',NULL,'wallet',1,0,1,NULL)`], + [`Label`, `(name, searchName, description) VALUES ('Werk','werk',NULL)`], + [`MetalProduct`, `(assetId, name, searchName, description, fineGrams, form) + VALUES (1,'Baar 1 kg','baar 1 kg',NULL,10000000000,'bar')`], + [`Provider`, `(key, name, searchName, description, baseUrl, authenticationHeaderName, authenticationParameterName, + authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES ('mempool','mempool','mempool',NULL,'https://voorbeeld.test',NULL,NULL,'none',0,1,1,0,'${NU}')`], + [`ProviderAuthenticationHmac`, `(providerId, messageParts, keyHeader, signatureHeader, timestampHeader, + windowHeader, windowMs, hashAlgorithm, encoding) + VALUES (1,'timestamp,method','K','S','T',NULL,NULL,'SHA-256','hex')`], + [`ProviderEndpoint`, `(providerId, pathTemplate, queryTemplate, capability, method) + VALUES (1,'/api/prijs',NULL,'spot','GET')`], + [`ProviderFixedRate`, `(providerId, fiatPrice) VALUES (1,1.0)`], + [`ProviderCallLimit`, `(providerId, callsPerMonth, createdAt) VALUES (1,100,'${NU}')`], + [`ProviderInputMap`, `(providerId, symbol, externalId, isCatchAll) VALUES (1,'XAU','gold',0)`], + [`ProviderOutputMap`, `(providerId, jsonPath, capability, internalField, transform) + VALUES (1,'EUR','spot','price','none')`], + [`ProviderCoverage`, `(providerId, symbol) VALUES (1,'BTC')`], + [`ApiCallMetric`, `(providerId, capability, bucketMinute, okCount, failCount, sumLatencyMs, maxLatencyMs) + VALUES (1,'spot','2026-08-12T10:00',1,0,120,120)`], + [`Connection`, `(name, searchName, description, url, kind, isActive, isSystem, lastStatus, + lastCheckedAt, createdAt, updatedAt) + VALUES ('Self-hosted','self-hosted',NULL,'https://knoop.test','btc_explorer',1,0,NULL,NULL,'${NU}','${NU}')`], + [`WalletSource`, `(accountId, connectionId, value, name, kind, scriptType, createdAt) + VALUES (1,NULL,'xpub6voorbeeld','Hoofdbron','xpub','p2tr','${NU}')`], + [`WalletAddress`, `(accountId, sourceId, address, automaticLabel, chainIndex, derivationIndex, + balanceSats, transactionCount, lastScannedAt) + VALUES (1,1,'bc1qvoorbeeld',NULL,0,0,1000,2,'${NU}')`], + [`WalletTransaction`, `(accountId, sourceId, labelId, layer, txid, address, valueSats, feeSats, blockHeight, + blockTime, direction, createdAt) + VALUES (1,1,1,'onchain','aabbcc','bc1qvoorbeeld',1000,10,900000,'${NU}','in','${NU}')`], + [`Event`, `(assetId, fromAccountId, toAccountId, productId, productCount, importReference, externalAddress, + note, amount, fiatPrice, fiatFee, unitFee, type, source, occurredAt, createdAt) + VALUES (1,1,1,1,1,NULL,NULL,NULL,10000000000,50000.0,NULL,NULL,'buy','manual','${NU}','${NU}')`], + [`Valuation`, `(assetId, accountId, balance, fiatRate, source, valuedOn, rateAt) + VALUES (1,1,1000,50000.0,'manual','2026-08-12','${NU}')`], + [`ScanLog`, `(accountId, addressesFound, balanceFound, proposalsCreated, rawData, status, scannedAt) + VALUES (1,2,1000,0,NULL,'success','${NU}')`], + [`ReconcileProposal`, `(accountId, assetId, walletTransactionId, resolvedEventId, transactionHash, amount, + direction, suggestedType, status, detectedAt) + VALUES (1,1,1,1,'aabbcc',1000,'in','transfer','pending','${NU}')`], + [`WalletTransactionEventLink`, `(walletTransactionId, eventId, method, createdAt) VALUES (1,1,'exact','${NU}')`], + [`ImportBatch`, `(accountId, filename, rowCount, rawData, origin, importedAt) + VALUES (1,'export.csv',3,NULL,'Bitvavo','${NU}')`], + [`PriceHistory`, `(assetId, providerId, fiatPrice, resolution, observedAt, updatedAt) + VALUES (1,1,50000.0,'day','${NU}','${NU}')`], + [`InterestSchedule`, `(accountId, principalAssetId, payoutAssetId, note, principalAmount, annualRate, + rateKind, creditCadence, creditWeekday, creditDayOfMonth, startsOn, endsOn, compounding, createdAt) + VALUES (1,1,1,NULL,100000,0.05,'fixed','weekly',1,NULL,'2026-01-01',NULL,0,'${NU}')`], + [`InterestPeriod`, `(scheduleId, startsOn, annualRate) + VALUES (1,'2026-04-01',0.037)`], + [`InterestCredit`, `(scheduleId, eventId, status, periodStart, periodEnd, createdAt) + VALUES (1,1,'booked','2026-08-01','2026-08-08','${NU}')`], + [`Notification`, `(count, highWater, kind, dismissedAt, createdAt) + VALUES (1,3,'reconcile',NULL,'${NU}')`], + [`PriceSource`, `(assetId, providerId, capability, rank, status) + VALUES (1,1,'spot',1,'confirmed')`], + [`DashboardProfile`, `(name, searchName, description, sortOrder, isActive, isSystem, createdAt, updatedAt) + VALUES ('Standaard','standaard',NULL,0,1,1,'${NU}','${NU}')`], + [`DashboardWidget`, `(profileId, assetId, instanceKey, gridX, gridY, spanWidth, spanHeight, type, settings) + VALUES (1,1,'spot:1',0,0,2,2,'spot',NULL)`], + [`Preference`, `(scope, key, value) VALUES ('ui','theme','"dark"')`], + [`AiToolCall`, `(sessionId, client, tool, arguments, fieldsShared, charactersOut, outcome, detail, occurredAt) + VALUES ('11111111-2222-3333-4444-555555555555','voorbeeld-client','connection_test','{}','[]',42,'ok',NULL,'${NU}')`], + [`AiToolCallMessage`, `(callId, kind, text, occurredAt) + VALUES (1,'request','{"note":"hallo"}','${NU}')`], + [`BusSignal`, `(name, occurredAt) VALUES ('providers-changed','${NU}')`], + [`Command`, `(kind, payload, state, expiresAt, createdAt) + VALUES ('navigate','{"route":"system","tab":"ai"}','open','${NU}','${NU}')`], + [`ChangeLog`, `(tableName, rowId, operation, origin, originId, changes, occurredAt) + VALUES ('Account',1,'update','manual',NULL,'{"name":["Oude naam","Nieuwe naam"]}','${NU}')`], + [`AiProposal`, `(kind, assetId, fiatPrice, observedAt, note, status, createdAt) + VALUES ('price_point',1,50000.0,'${NU}','premie volgens de muntwinkel','pending','${NU}')`], + [`HygieneAcceptance`, `(kind, fingerprint, summary, note, createdAt) + VALUES ('transfer_without_counterpart','abc123','Verplaatsing zonder tegenkant (1)',NULL,'${NU}')`], + [`IgnoredPriceGap`, `(assetId, fromDay, untilDay, createdAt) VALUES (1,'1994-01-01','1994-01-31','${NU}')`], + [`PriceAbsence`, `(assetId, providerId, day, createdAt) VALUES (1,1,'2025-12-26','${NU}')`], + [`IntegrationCall`, `(target, providerKey, operation, subject, outcome, httpStatus, errorKey, detail, latencyMs, requestId, occurredAt) + VALUES ('service',NULL,'/v1/series','crypto:BTC','fail',404,'marketService.rejected api.noSeries','No series',42,'a1b2c3d4-0000-4000-8000-000000000000','${NU}')`], + [`AssetNote`, `(assetId, body, createdAt) VALUES (1,'2026-09-26 14:32 geërfd van oma','${NU}')`], + [`Schema`, `(id, version) VALUES (1,${CONFIG.SCHEMA_VERSION})`], + [`Portfolio`, `(id, baseCurrency, createdAt) VALUES (1,'EUR','${NU}')`], +]; + +export function vulFixture(db) { + for (const [tabel, rest] of FIXTURE) db.exec(`INSERT INTO ${tabel} ${rest}`); + return FIXTURE.map(([tabel]) => tabel); +} + +export const FIXTURE_TABELLEN = FIXTURE.map(([tabel]) => tabel); +export const FIXTURE_TIJD = NU; diff --git a/tests/helpers/db-node.mjs b/tests/helpers/db-node.mjs new file mode 100644 index 0000000..228ba47 --- /dev/null +++ b/tests/helpers/db-node.mjs @@ -0,0 +1,80 @@ +// Generated, do not edit. sha256:82c1a99dd9bcb14921519651f00e41b83ebb6753c9cc496d2de5d3792c7a8814 +import { DatabaseSync } from 'node:sqlite'; +import { SCHEMA, createDatabase as echteCreateDatabase } from '../../server/db.js'; +import { CONFIG, PREFERENCES } from '../../shared/config.js'; + +const bind = params => params.map(v => { + if (v === undefined) return null; + if (typeof v === 'boolean') return v ? 1n : 0n; + if (typeof v === 'number' && Number.isInteger(v)) return BigInt(v); + return v; +}); + +export function createDbHarness({ seedPreferences = false } = {}) { + let sqlite; + let calls; + + function fresh() { + sqlite = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true }); + sqlite.exec(SCHEMA); + sqlite.prepare(`INSERT INTO Schema (version) VALUES (?)`).run(CONFIG.SCHEMA_VERSION); + sqlite.prepare(`INSERT INTO Portfolio (id, baseCurrency, createdAt) VALUES (1, ?, ?)`) + .run(CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY, '2026-01-01T00:00:00.000Z'); + calls = { run: 0, persist: 0, seedPriceSource: [] }; + if (seedPreferences) seedPrefs(); + } + + function seedPrefs() { + for (const [scope, keys] of Object.entries(PREFERENCES)) { + for (const [key, spec] of Object.entries(keys)) { + if (spec.secret) continue; + api.run(`INSERT INTO Preference (scope, key, value) VALUES (?,?,?)`, + [scope, key, JSON.stringify(spec.initial)]); + } + } + } + + const api = { + run(sql, params = []) { + calls.run++; + if (params.length === 0) sqlite.exec(sql); + else sqlite.prepare(sql).run(...bind(params)); + }, + all(sql, params = []) { + return sqlite.prepare(sql).all(...bind(params)).map(row => ({ ...row })); + }, + one(sql, params = []) { + const rows = api.all(sql, params); + return rows.length ? rows[0] : null; + }, + lastId() { + const r = api.one(`SELECT last_insert_rowid() AS id`); + return r ? r.id : null; + }, + schemaVersion() { + const r = api.one(`SELECT version FROM Schema`); + return r ? r.version : null; + }, + async persist() { calls.persist++; }, + seedPriceSource(assetId, status) { calls.seedPriceSource.push([assetId, status]); }, + + async init() { return sqlite; }, + exportBytes() { throw new Error('db-node-harness: exportBytes() niet ondersteund in tests.'); }, + async reset() { throw new Error('db-node-harness: reset() niet ondersteund; gebruik harness.recreate().'); }, + async importBytes() { throw new Error('db-node-harness: importBytes() niet ondersteund in tests.'); }, + createDatabase: echteCreateDatabase, + async idbGet(key) { return idb.has(key) ? idb.get(key) : null; }, + async idbPut(key, value) { idb.set(key, value); }, + }; + + const idb = new Map(); + + fresh(); + + return { + exports: api, + recreate() { sqlite.close(); idb.clear(); fresh(); }, + get sqlite() { return sqlite; }, + get calls() { return calls; }, + }; +} diff --git a/tests/helpers/mini-dom.mjs b/tests/helpers/mini-dom.mjs new file mode 100644 index 0000000..461b04b --- /dev/null +++ b/tests/helpers/mini-dom.mjs @@ -0,0 +1,459 @@ +// Generated, do not edit. sha256:db0c8db151ef8689662b07ab525d02d912551c1706e34e778f8bcf4190dc7dc0 +const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']); +const RAW_TEXT = new Set(['textarea', 'script', 'style', 'title']); +const SELF_CLOSING_OK = new Set(['svg', 'path', 'line', 'circle', 'rect', 'polyline', 'polygon', 'g', 'use']); + +const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }; +export function decodeEntities(s) { + return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, name) => { + if (name[0] === '#') { + const code = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10); + return Number.isFinite(code) ? String.fromCodePoint(code) : all; + } + return Object.prototype.hasOwnProperty.call(ENTITIES, name.toLowerCase()) ? ENTITIES[name.toLowerCase()] : all; + }); +} +const escText = s => String(s).replace(/&/g, '&').replace(//g, '>'); +const escAttr = s => String(s).replace(/&/g, '&').replace(/"/g, '"'); + +const hidden = (obj, key, value) => Object.defineProperty(obj, key, { value, writable: true, enumerable: false, configurable: true }); + +class Node { + constructor(doc) { hidden(this, 'ownerDocument', doc); hidden(this, 'parentNode', null); this.childNodes = []; } + get parentElement() { return this.parentNode instanceof Element ? this.parentNode : null; } + get isConnected() { + let n = this; + while (n) { if (n === this.ownerDocument) return true; n = n.parentNode; } + return false; + } + appendChild(child) { + if (child.parentNode) child.parentNode._removeChild(child); + child.parentNode = this; + this.childNodes.push(child); + return child; + } + append(...children) { for (const c of children) this.appendChild(c); } + _removeChild(child) { + const i = this.childNodes.indexOf(child); + if (i >= 0) this.childNodes.splice(i, 1); + child.parentNode = null; + } + remove() { if (this.parentNode) this.parentNode._removeChild(this); } + contains(other) { + for (let n = other; n; n = n.parentNode) if (n === this) return true; + return false; + } +} + +class Text extends Node { + constructor(doc, data) { super(doc); this.nodeType = 3; this.data = data; } + get textContent() { return this.data; } + set textContent(v) { this.data = String(v); } +} + +class ClassList { + constructor(el) { this.el = el; } + _get() { return (this.el.getAttribute('class') || '').split(/\s+/).filter(Boolean); } + _set(list) { this.el.setAttribute('class', list.join(' ')); } + contains(c) { return this._get().includes(c); } + add(...cs) { const l = this._get(); for (const c of cs) if (c && !l.includes(c)) l.push(c); this._set(l); } + remove(...cs) { this._set(this._get().filter(c => !cs.includes(c))); } + toggle(c, force) { + const on = force === undefined ? !this.contains(c) : !!force; + if (on) this.add(c); else this.remove(c); + return on; + } +} + +const kebab = s => s.replace(/[A-Z]/g, m => '-' + m.toLowerCase()); +const camel = s => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + +function makeStyle() { + const props = new Map(); + return new Proxy({ + setProperty(k, v) { props.set(k, String(v)); }, + getPropertyValue(k) { return props.get(k) ?? ''; }, + removeProperty(k) { props.delete(k); }, + }, { + get(target, key) { return key in target ? target[key] : (props.get(kebab(String(key))) ?? ''); }, + set(_t, key, v) { props.set(kebab(String(key)), String(v)); return true; }, + }); +} + +export class Element extends Node { + constructor(doc, tag) { + super(doc); + this.nodeType = 1; + this.localName = tag.toLowerCase(); + this.attrs = new Map(); + hidden(this, 'style', makeStyle()); + hidden(this, 'classList', new ClassList(this)); + hidden(this, 'listeners', []); + this.scrollTop = 0; + this.scrollLeft = 0; + } + get tagName() { return this.localName.toUpperCase(); } + [Symbol.for('nodejs.util.inspect.custom')]() { + const attrs = [...this.attrs].map(([k, v]) => (v === '' ? ` ${k}` : ` ${k}="${v}"`)).join(''); + return `<${this.localName}${attrs}>`; + } + getAttribute(n) { return this.attrs.has(n.toLowerCase()) ? this.attrs.get(n.toLowerCase()) : null; } + setAttribute(n, v) { this.attrs.set(n.toLowerCase(), String(v)); } + removeAttribute(n) { this.attrs.delete(n.toLowerCase()); } + hasAttribute(n) { return this.attrs.has(n.toLowerCase()); } + get id() { return this.getAttribute('id') || ''; } + set id(v) { this.setAttribute('id', v); } + get className() { return this.getAttribute('class') || ''; } + set className(v) { this.setAttribute('class', v); } + get title() { return this.getAttribute('title') || ''; } + set title(v) { this.setAttribute('title', v); } + get src() { return this.getAttribute('src') || ''; } + set src(v) { this.setAttribute('src', v); } + get type() { return this.localName === 'input' ? (this.getAttribute('type') || 'text').toLowerCase() : (this.getAttribute('type') || ''); } + set type(v) { this.setAttribute('type', v); } + get disabled() { return this.hasAttribute('disabled'); } + set disabled(v) { if (v) this.setAttribute('disabled', ''); else this.removeAttribute('disabled'); } + get dataset() { + const el = this; + return new Proxy({}, { + get(_t, key) { return typeof key === 'string' ? (el.getAttribute('data-' + kebab(key)) ?? undefined) : undefined; }, + set(_t, key, v) { el.setAttribute('data-' + kebab(String(key)), v); return true; }, + has(_t, key) { return el.hasAttribute('data-' + kebab(String(key))); }, + ownKeys() { return [...el.attrs.keys()].filter(k => k.startsWith('data-')).map(k => camel(k.slice(5))); }, + getOwnPropertyDescriptor(_t, key) { + const v = el.getAttribute('data-' + kebab(String(key))); + return v === null ? undefined : { value: v, enumerable: true, configurable: true }; + }, + }); + } + get children() { return this.childNodes.filter(n => n instanceof Element); } + + get value() { + if (this.localName === 'select') { + if (this._value !== undefined) return this._value; + const opts = this.querySelectorAll('option'); + const sel = opts.find(o => o.hasAttribute('selected')) || opts[0]; + return sel ? sel.value : ''; + } + if (this.localName === 'option') return this.getAttribute('value') ?? this.textContent; + if (this.localName === 'textarea') return this._value !== undefined ? this._value : this.textContent; + return this._value !== undefined ? this._value : (this.getAttribute('value') ?? ''); + } + set value(v) { this._value = String(v); } + get checked() { return this._checked !== undefined ? this._checked : this.hasAttribute('checked'); } + set checked(v) { this._checked = !!v; } + get selectionStart() { return this._selStart ?? this.value.length; } + get selectionEnd() { return this._selEnd ?? this.value.length; } + setSelectionRange(a, b) { this._selStart = a; this._selEnd = b; } + + get offsetHeight() { return 0; } + get offsetWidth() { return 0; } + get scrollHeight() { return 0; } + get clientHeight() { return 0; } + get clientWidth() { return 0; } + getBoundingClientRect() { return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }; } + scrollIntoView(opts) { this.ownerDocument.scrolledIntoView.push({ el: this, opts }); } + focus() { this.ownerDocument.activeElement = this; } + blur() { if (this.ownerDocument.activeElement === this) this.ownerDocument.activeElement = this.ownerDocument.body; } + + get innerHTML() { return this.childNodes.map(serialize).join(''); } + set innerHTML(html) { + for (const c of [...this.childNodes]) this._removeChild(c); + for (const c of parseHTML(this.ownerDocument, String(html), this.localName)) this.appendChild(c); + this._value = undefined; + } + get outerHTML() { return serialize(this); } + get textContent() { return this.childNodes.map(n => n.textContent).join(''); } + set textContent(v) { + for (const c of [...this.childNodes]) this._removeChild(c); + if (v !== '' && v != null) this.appendChild(new Text(this.ownerDocument, String(v))); + } + + querySelectorAll(sel) { + const list = parseSelector(sel); + const out = []; + walk(this, el => { if (list.some(s => matchComplex(el, s, this))) out.push(el); }); + return out; + } + querySelector(sel) { return this.querySelectorAll(sel)[0] || null; } + matches(sel) { return parseSelector(sel).some(s => matchComplex(this, s, null)); } + closest(sel) { + const list = parseSelector(sel); + for (let n = this; n instanceof Element; n = n.parentNode) if (list.some(s => matchComplex(n, s, null))) return n; + return null; + } + + addEventListener(type, fn, opts) { this.listeners.push({ type, fn, capture: opts === true || !!(opts && opts.capture) }); } + removeEventListener(type, fn, opts) { + const capture = opts === true || !!(opts && opts.capture); + this.listeners = this.listeners.filter(l => !(l.type === type && l.fn === fn && l.capture === capture)); + } + dispatchEvent(ev) { return dispatch(this, ev.type, { bubbles: ev.bubbles, ...pickInit(ev) }); } +} + +function pickInit(ev) { + const init = {}; + for (const k of ['key', 'ctrlKey', 'metaKey', 'shiftKey', 'altKey', 'relatedTarget', 'detail']) if (ev[k] !== undefined) init[k] = ev[k]; + return init; +} + +function walk(root, fn) { + for (const c of root.childNodes) { + if (c instanceof Element) { fn(c); walk(c, fn); } + } +} + +function serialize(n) { + if (n instanceof Text) return escText(n.data); + if (!(n instanceof Element)) return ''; + const attrs = [...n.attrs].map(([k, v]) => (v === '' ? ` ${k}` : ` ${k}="${escAttr(v)}"`)).join(''); + if (VOID.has(n.localName)) return `<${n.localName}${attrs}>`; + return `<${n.localName}${attrs}>${n.childNodes.map(serialize).join('')}`; +} + +export function parseHTML(doc, html) { + const root = new Element(doc, '#fragment'); + const stack = [root]; + const top = () => stack[stack.length - 1]; + let i = 0; + while (i < html.length) { + if (html.startsWith('', i + 4); + i = end < 0 ? html.length : end + 3; + continue; + } + if (html[i] === '<' && html[i + 1] === '/') { + const end = html.indexOf('>', i); + const name = html.slice(i + 2, end < 0 ? html.length : end).trim().toLowerCase(); + i = end < 0 ? html.length : end + 1; + const at = stack.map(n => n.localName).lastIndexOf(name); + if (at > 0) stack.length = at; + continue; + } + if (html[i] === '<' && /[a-zA-Z]/.test(html[i + 1] || '')) { + let j = i + 1, quote = null; + for (; j < html.length; j++) { + const ch = html[j]; + if (quote) { if (ch === quote) quote = null; continue; } + if (ch === '"' || ch === "'") { quote = ch; continue; } + if (ch === '>') break; + } + let body = html.slice(i + 1, j); + i = j + 1; + const selfClose = body.endsWith('/'); + if (selfClose) body = body.slice(0, -1); + const name = body.match(/^[^\s/>]+/)[0].toLowerCase(); + const el = new Element(doc, name); + const attrRe = /([^\s"'>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; + const rest = body.slice(name.length); + let m; + while ((m = attrRe.exec(rest))) { + const v = m[2] ?? m[3] ?? m[4] ?? ''; + el.setAttribute(m[1], decodeEntities(v)); + } + top().appendChild(el); + if (VOID.has(name) || (selfClose && SELF_CLOSING_OK.has(name))) continue; + if (RAW_TEXT.has(name)) { + const close = html.toLowerCase().indexOf(`', close); + i = end < 0 ? html.length : end + 1; + continue; + } + stack.push(el); + continue; + } + const next = html.indexOf('<', i + 1); + const text = html.slice(i, next < 0 ? html.length : next); + i = next < 0 ? html.length : next; + if (text) top().appendChild(new Text(doc, decodeEntities(text))); + } + const nodes = [...root.childNodes]; + for (const n of nodes) root._removeChild(n); + return nodes; +} + +const cache = new Map(); +export function parseSelector(sel) { + if (cache.has(sel)) return cache.get(sel); + const list = splitTop(sel, ',').map(s => s.trim()).filter(Boolean).map(parseComplex); + if (!list.length) throw new Error(`mini-dom: empty selector "${sel}"`); + cache.set(sel, list); + return list; +} + +function splitTop(s, sep) { + const out = []; + let depth = 0, quote = null, start = 0; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (quote) { if (ch === quote) quote = null; continue; } + if (ch === '"' || ch === "'") quote = ch; + else if (ch === '(' || ch === '[') depth++; + else if (ch === ')' || ch === ']') depth--; + else if (ch === sep && depth === 0) { out.push(s.slice(start, i)); start = i + 1; } + } + out.push(s.slice(start)); + return out; +} + +function parseComplex(s) { + const parts = []; + let i = 0; + let combinator = null; + while (i < s.length) { + if (/\s/.test(s[i]) || s[i] === '>') { + let comb = ' '; + while (i < s.length && (/\s/.test(s[i]) || s[i] === '>')) { if (s[i] === '>') comb = '>'; i++; } + if (parts.length) combinator = comb; + continue; + } + const [compound, next] = parseCompound(s, i); + if (parts.length) parts[parts.length - 1].combinator = combinator || ' '; + parts.push({ compound, combinator: null }); + combinator = null; + i = next; + } + return parts; +} + +function parseCompound(s, i) { + const c = { tag: null, id: null, classes: [], attrs: [], nots: [] }; + const ident = (k) => { const m = s.slice(k).match(/^[A-Za-z0-9_-]+/); if (!m) throw new Error(`mini-dom: unsupported selector "${s}"`); return m[0]; }; + if (s[i] === '*') i++; + else if (/[A-Za-z]/.test(s[i])) { c.tag = ident(i).toLowerCase(); i += c.tag.length; } + while (i < s.length && !/[\s>]/.test(s[i])) { + const ch = s[i]; + if (ch === '#') { const n = ident(i + 1); c.id = n; i += 1 + n.length; } + else if (ch === '.') { const n = ident(i + 1); c.classes.push(n); i += 1 + n.length; } + else if (ch === '[') { + const end = findClose(s, i, '[', ']'); + const inner = s.slice(i + 1, end).trim(); + const m = inner.match(/^([A-Za-z0-9_:-]+)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s\]]+)))?$/); + if (!m) throw new Error(`mini-dom: unsupported attribute selector "[${inner}]"`); + c.attrs.push({ name: m[1].toLowerCase(), value: m[2] ?? m[3] ?? m[4] ?? null }); + i = end + 1; + } else if (s.startsWith(':not(', i)) { + const end = findClose(s, i + 4, '(', ')'); + c.nots.push(parseSelector(s.slice(i + 5, end))); + i = end + 1; + } else { + throw new Error(`mini-dom: unsupported selector "${s}" at "${s.slice(i)}"`); + } + } + return [c, i]; +} + +function findClose(s, open, a, b) { + let depth = 0, quote = null; + for (let k = open; k < s.length; k++) { + const ch = s[k]; + if (quote) { if (ch === quote) quote = null; continue; } + if (ch === '"' || ch === "'") quote = ch; + else if (ch === a) depth++; + else if (ch === b && --depth === 0) return k; + } + throw new Error(`mini-dom: unclosed "${a}" in selector "${s}"`); +} + +function matchCompound(el, c) { + if (!(el instanceof Element)) return false; + if (c.tag && el.localName !== c.tag) return false; + if (c.id && el.getAttribute('id') !== c.id) return false; + for (const k of c.classes) if (!el.classList.contains(k)) return false; + for (const a of c.attrs) { + if (!el.hasAttribute(a.name)) return false; + if (a.value !== null && el.getAttribute(a.name) !== a.value) return false; + } + for (const list of c.nots) if (list.some(s => matchComplex(el, s, null))) return false; + return true; +} + +function matchComplex(el, parts, _scope) { + const at = (k, node) => { + if (!matchCompound(node, parts[k].compound)) return false; + if (k === 0) return true; + const comb = parts[k - 1].combinator; + if (comb === '>') return node.parentNode instanceof Element && at(k - 1, node.parentNode); + for (let p = node.parentNode; p instanceof Element; p = p.parentNode) if (at(k - 1, p)) return true; + return false; + }; + return at(parts.length - 1, el); +} + +export function dispatch(target, type, init = {}) { + const doc = target.ownerDocument || target; + let stopped = false; + const ev = { + type, target, bubbles: init.bubbles !== false, defaultPrevented: false, currentTarget: null, + key: init.key, ctrlKey: !!init.ctrlKey, metaKey: !!init.metaKey, shiftKey: !!init.shiftKey, altKey: !!init.altKey, + relatedTarget: init.relatedTarget ?? null, detail: init.detail ?? null, + stopPropagation() { stopped = true; }, + stopImmediatePropagation() { stopped = true; }, + preventDefault() { this.defaultPrevented = true; }, + }; + const path = []; + for (let n = target; n; n = n.parentNode) path.push(n); + if (path[path.length - 1] !== doc) path.push(doc); + const run = (node, capture) => { + ev.currentTarget = node; + if (!capture && typeof node['on' + type] === 'function') node['on' + type](ev); + for (const l of [...(node.listeners || [])]) if (l.type === type && l.capture === capture) l.fn(ev); + }; + for (let k = path.length - 1; k > 0 && !stopped; k--) run(path[k], true); + if (!stopped) { run(target, true); if (!stopped) run(target, false); } + if (ev.bubbles) for (let k = 1; k < path.length && !stopped; k++) run(path[k], false); + return !ev.defaultPrevented; +} + +class Document extends Node { + constructor() { + super(null); + this.ownerDocument = this; + this.listeners = []; + this.documentElement = new Element(this, 'html'); + this.head = new Element(this, 'head'); + this.body = new Element(this, 'body'); + this.appendChild(this.documentElement); + this.documentElement.appendChild(this.head); + this.documentElement.appendChild(this.body); + this.activeElement = this.body; + this.scrolledIntoView = []; + } + [Symbol.for('nodejs.util.inspect.custom')]() { return '#document'; } + createElement(tag) { return new Element(this, tag); } + createTextNode(t) { return new Text(this, t); } + querySelectorAll(sel) { return this.documentElement.querySelectorAll(sel); } + querySelector(sel) { return this.documentElement.querySelector(sel); } + getElementById(id) { return this.querySelector(`#${id}`); } + addEventListener(type, fn, opts) { Element.prototype.addEventListener.call(this, type, fn, opts); } + removeEventListener(type, fn, opts) { Element.prototype.removeEventListener.call(this, type, fn, opts); } + dispatchEvent(ev) { return dispatch(this, ev.type, { bubbles: ev.bubbles, ...pickInit(ev) }); } +} + +export function installDom() { + const document = new Document(); + const frames = []; + const win = { + innerWidth: 1280, innerHeight: 800, listeners: [], + addEventListener(type, fn, opts) { Element.prototype.addEventListener.call(this, type, fn, opts); }, + removeEventListener(type, fn, opts) { Element.prototype.removeEventListener.call(this, type, fn, opts); }, + }; + globalThis.document = document; + globalThis.window = win; + globalThis.requestAnimationFrame = fn => { frames.push(fn); return frames.length; }; + globalThis.getComputedStyle = el => ({ getPropertyValue: k => el.style.getPropertyValue(k) }); + const fire = (el, type, init) => dispatch(el, type, init); + return { + document, + window: win, + fire, + click: el => fire(el, 'click'), + type(el, value) { el.value = value; fire(el, 'input'); fire(el, 'change'); }, + choose(el, value) { el.value = value; fire(el, 'change'); }, + key: (el, key, mods = {}) => fire(el, 'keydown', { key, ...mods }), + flushFrames() { while (frames.length) frames.shift()(); }, + host() { const el = document.createElement('div'); document.body.appendChild(el); return el; }, + }; +} diff --git a/tests/helpers/nwc-api.mjs b/tests/helpers/nwc-api.mjs new file mode 100644 index 0000000..dd1515c --- /dev/null +++ b/tests/helpers/nwc-api.mjs @@ -0,0 +1,61 @@ +// Generated, do not edit. sha256:3babd66c28c9f09e458e639e8c0ca81825c815be279b32e296b0e13ec48e11ca +import { services } from '../../server/registers.mjs'; + +function pubkeyUit(uri) { + const m = /^nostr\+?walletconnect:\/\/([^?/]+)/i.exec(String(uri || '').trim()); + if (!m) throw new Error('Ongeldige NWC-URI (verwacht nostr+walletconnect://…?relay=…&secret=…).'); + return m[1].toLowerCase(); +} + +export function createNwcApiFake() { + const opslag = new Map(); + const calls = { setUri: [], purgeUri: [], validateUri: [], call: [] }; + const fout = { setUri: null, purgeUri: null }; + + const exports = { + async validateNwcUri(uri) { + calls.validateUri.push(uri); + return { walletPubkey: pubkeyUit(uri), relays: ['wss://relay.test'] }; + }, + async setNwcUri(sourceId, uri) { + calls.setUri.push([Number(sourceId), uri]); + if (fout.setUri) throw new Error(fout.setUri); + const walletPubkey = pubkeyUit(uri); + opslag.set(Number(sourceId), String(uri)); + return { source: Number(sourceId), uri_set: true, walletPubkey }; + }, + async deleteNwcUri(sourceId) { + calls.purgeUri.push(Number(sourceId)); + if (fout.purgeUri) throw new Error(fout.purgeUri); + opslag.delete(Number(sourceId)); + return { source: Number(sourceId), deleted: true }; + }, + async nwcStatus(sourceId) { + const uri = opslag.get(Number(sourceId)); + return { source: Number(sourceId), uri_set: !!uri, walletPubkey: uri ? pubkeyUit(uri) : '' }; + }, + async nwcCall() { throw new Error('nwc-api-fake: een relay-call hoort niet in een datalaag-test.'); }, + async getInfo() { return exports.nwcCall(); }, + async getBalance() { return exports.nwcCall(); }, + async listTransactions() { return exports.nwcCall(); }, + async testNwc() { return exports.nwcCall(); }, + }; + + const api = { + exports, + calls, + registreer() { + services.register({ id: 'nwc', feature: 'lightning', setNwcUri: exports.setNwcUri, deleteNwcUri: exports.deleteNwcUri }); + return api; + }, + fout, + geheim: sourceId => opslag.get(Number(sourceId)) ?? null, + get ids() { return [...opslag.keys()]; }, + leeg() { + opslag.clear(); + calls.setUri.length = 0; calls.purgeUri.length = 0; calls.validateUri.length = 0; + fout.setUri = null; fout.purgeUri = null; + }, + }; + return api; +} diff --git a/tests/helpers/rekenvaluta.mjs b/tests/helpers/rekenvaluta.mjs new file mode 100644 index 0000000..2ac8d01 --- /dev/null +++ b/tests/helpers/rekenvaluta.mjs @@ -0,0 +1,23 @@ +// Generated, do not edit. sha256:17b1281e54996148b279858b457fd106d4d2e1b529ec53afb0ad51df4cdcbc22 +const PREFS = new URL('../../client/data/preferences.js', import.meta.url).href; +const APP_SETTINGS = new URL('../../client/app-settings.js', import.meta.url).href; + +async function laad() { + const { loadAppSettings } = await import(APP_SETTINGS); + await loadAppSettings(); +} + +export async function vasteRekenvaluta(mock, baseCurrency = 'EUR') { + mock.module(PREFS, { namedExports: { + getSettings: async () => ({ baseCurrency }), + getPref: async () => null, + setPref: async () => {}, + } }); + await laad(); +} + +export async function rekenvalutaUitDatabase(mock) { + const server = await import(new URL('../../server/data/preferences.js', import.meta.url).href); + mock.module(PREFS, { namedExports: server }); + await laad(); +} diff --git a/tests/helpers/schema-vorm.mjs b/tests/helpers/schema-vorm.mjs new file mode 100644 index 0000000..65e6fa9 --- /dev/null +++ b/tests/helpers/schema-vorm.mjs @@ -0,0 +1,23 @@ +// Generated, do not edit. sha256:d225f580a4545f4be7759feb5bf7808d1ded43a0baf1cbde8115e6901c6a4521 +export function schemaVorm(db) { + const tabellen = db.prepare( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name` + ).all().map(r => r.name); + + const uit = []; + for (const t of tabellen) { + for (const k of db.prepare(`PRAGMA table_info(${t})`).all()) { + uit.push(`${t}.${k.name}: ${k.type}${k.notnull ? ' NOT NULL' : ''}${k.pk ? ` PK${k.pk}` : ''}` + + `${k.dflt_value == null ? '' : ` DEFAULT ${k.dflt_value}`}`); + } + for (const fk of db.prepare(`PRAGMA foreign_key_list(${t})`).all()) { + uit.push(`${t}.${fk.from} -> ${fk.table}.${fk.to} ON DELETE ${fk.on_delete}`); + } + for (const ix of db.prepare(`PRAGMA index_list(${t})`).all()) { + const kolommen = db.prepare(`PRAGMA index_info(${ix.name})`).all().map(k => k.name).join(','); + uit.push(`${ix.unique ? 'UNIQUE ' : ''}INDEX ${ix.name} ON ${t}(${kolommen})` + + `${ix.partial ? ' PARTIAL' : ''}${ix.origin === 'u' ? ' [uit UNIQUE]' : ''}`); + } + } + return uit.sort(); +} diff --git a/tests/migrations/versies.test.mjs b/tests/migrations/versies.test.mjs new file mode 100644 index 0000000..ad99764 --- /dev/null +++ b/tests/migrations/versies.test.mjs @@ -0,0 +1,44 @@ +// Generated, do not edit. sha256:8977ec53367552532dbe1dc9feea008e924ada2d008cd083942fb7af8c711d6b +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +import { CONFIG } from '../../shared/config.js'; +import { BASELINE_VERSION } from '../../server/db/migrations.js'; + +const bron = await readFile(new URL('../../server/db/migrations.js', import.meta.url), 'utf-8'); + +const versies = [...bron.matchAll(/^ {2}(\d+): \(\) =>/gm)].map(m => Number(m[1])); + +describe('migratie-versies', () => { + it('bewaakt zichzelf: het MIGRATIONS-object staat er nog', () => { + assert.match(bron, /const MIGRATIONS = \{/, 'de migratieketen is niet meer te vinden in de bron'); + }); + + it('sluit aan op de baseline: CONFIG.SCHEMA_VERSION is de hoogste bekende versie', () => { + const hoogste = versies.length ? Math.max(...versies) : BASELINE_VERSION; + assert.equal(hoogste, CONFIG.SCHEMA_VERSION, + `hoogste versie is ${hoogste}, CONFIG.SCHEMA_VERSION is ${CONFIG.SCHEMA_VERSION}. ` + + `Te laag: de nieuwste migratie draait nooit. Te hoog: elke bestaande DB crasht.`); + }); + + it('begint na de baseline, want 1 t/m 59 komen niet terug', () => { + const teLaag = versies.filter(v => v <= BASELINE_VERSION); + assert.deepEqual(teLaag, [], `een migratie naar versie ${BASELINE_VERSION} of lager kan niet bestaan`); + }); + + it('heeft geen gaten in de reeks', () => { + if (!versies.length) return; + const gesorteerd = [...versies].sort((a, b) => a - b); + const ontbreekt = []; + for (let v = BASELINE_VERSION + 1; v <= gesorteerd.at(-1); v++) { + if (!versies.includes(v)) ontbreekt.push(v); + } + assert.deepEqual(ontbreekt, [], + 'een ontbrekende versie maakt de app onopenbaar voor wie op de vorige versie zat'); + }); + + it('bevat elke versie precies één keer', () => { + assert.equal(new Set(versies).size, versies.length, 'dubbele migratie-sleutel'); + }); +}); diff --git a/tests/price/bron-diagnose.test.mjs b/tests/price/bron-diagnose.test.mjs new file mode 100644 index 0000000..a1ad06e --- /dev/null +++ b/tests/price/bron-diagnose.test.mjs @@ -0,0 +1,121 @@ +// Generated, do not edit. sha256:082ca8023bd277754ba77e02d92a8c8f7a15d0e14efa7674bf7905310f9ce692 +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { diagnoseSources } from '../../shared/source-diagnosis.js'; + +const GOUD = { id: 7, symbol: 'XAU', kind: 'metal' }; +let id = 0; +const bron = (key, extra = {}) => ({ + id: ++id, key, name: key.toUpperCase(), isActive: 1, isSystem: 0, fixedRate: null, callsPerMonth: null, + coverage: ['XAU'], endpoints: [{ capability: 'daily_history' }], ...extra, +}); +const perDatum = { endpoints: [{ capability: 'history_point' }] }; + +describe('diagnoseSources: de keten van voor naar achter', () => { + it('geen integratie die het symbool dekt', () => { + const uit = diagnoseSources(GOUD, { providers: [bron('mempool', { coverage: ['BTC'] })] }); + assert.deepEqual(uit, [{ severity: 'problem', code: 'noCoverage' }]); + }); + + it('een systeembron of een vaste koers telt niet als dekking', () => { + const uit = diagnoseSources(GOUD, { providers: [bron('manual', { isSystem: 1 }), bron('fixed', { fixedRate: { fiatPrice: 1 } })] }); + assert.equal(uit[0].code, 'noCoverage'); + }); + + it('een integratie zonder historie-endpoint telt niet als dekking', () => { + const uit = diagnoseSources(GOUD, { providers: [bron('alleenspot', { endpoints: [{ capability: 'spot' }] })] }); + assert.equal(uit[0].code, 'noCoverage'); + }); + + it('dekking die uit staat noemt de namen', () => { + const uit = diagnoseSources(GOUD, { providers: [bron('goldapi', { isActive: 0, ...perDatum })] }); + assert.deepEqual(uit, [{ severity: 'problem', code: 'allInactive', names: ['GOLDAPI'] }]); + }); + + it('geen bronkeuze geeft een voorstel voor Historie, en een reeks gaat voor een bron per datum', () => { + const goldapi = bron('goldapi', perDatum); + const eulerpool = bron('eulerpool'); + const [f] = diagnoseSources(GOUD, { providers: [goldapi, eulerpool], historyChain: [] }); + assert.equal(f.code, 'noChoice'); + assert.equal(f.severity, 'problem'); + assert.deepEqual(f.proposal, { providerKey: 'eulerpool', providerId: eulerpool.id, providerName: 'EULERPOOL', capability: 'history' }); + }); + + it('een gekozen bron die uit staat is geen keuze, en het voorstel is de actieve', () => { + const uit = bron('goldapi', { isActive: 0, ...perDatum }); + const aan = bron('eulerpool'); + const [f] = diagnoseSources(GOUD, { providers: [uit, aan], historyChain: ['goldapi'] }); + assert.equal(f.code, 'choiceUnusable'); + assert.deepEqual(f.names, ['GOLDAPI']); + assert.equal(f.proposal.providerKey, 'eulerpool'); + }); + + it('een gekozen bron die het symbool niet dekt, is ook geen keuze', () => { + const mempool = bron('mempool', { coverage: ['BTC'] }); + const [f] = diagnoseSources(GOUD, { providers: [mempool, bron('eulerpool')], historyChain: ['mempool'] }); + assert.equal(f.code, 'choiceUnusable'); + }); + + it('een gekozen bron zonder historie-endpoint is ook geen keuze', () => { + const spot = bron('alleenspot', { endpoints: [{ capability: 'spot' }] }); + const [f] = diagnoseSources(GOUD, { providers: [spot, bron('eulerpool')], historyChain: ['alleenspot'] }); + assert.equal(f.code, 'choiceUnusable'); + }); + + it('een keten die op orde is geeft niets, met een reeks of met een bron per datum', () => { + assert.deepEqual(diagnoseSources(GOUD, { providers: [bron('eulerpool')], historyChain: ['eulerpool'] }), []); + assert.deepEqual(diagnoseSources(GOUD, { providers: [bron('goldapi', perDatum)], historyChain: ['goldapi'] }), []); + }); +}); + +describe('diagnoseSources: de maandlimiet en een uitgezette keuze', () => { + it('alle gekozen bronnen op hun limiet is een probleem', () => { + const g = bron('goldapi', { callsPerMonth: 100, ...perDatum }); + const [f] = diagnoseSources(GOUD, { providers: [g], historyChain: ['goldapi'], usage: { [g.id]: 100 } }); + assert.deepEqual(f, { severity: 'problem', code: 'limitReached', names: ['GOLDAPI'] }); + }); + + it('één van twee op de limiet is aandacht, want de keten valt door', () => { + const g = bron('goldapi', { callsPerMonth: 100, ...perDatum }); + const e = bron('eulerpool'); + const [f] = diagnoseSources(GOUD, { providers: [g, e], historyChain: ['eulerpool', 'goldapi'], usage: { [g.id]: 120 } }); + assert.equal(f.severity, 'attention'); + assert.deepEqual(f.names, ['GOLDAPI']); + }); + + it('onder de limiet is niets, en een bron zonder limiet telt nooit', () => { + const g = bron('goldapi', { callsPerMonth: 100, ...perDatum }); + const e = bron('eulerpool'); + const uit = diagnoseSources(GOUD, { providers: [g, e], historyChain: ['eulerpool', 'goldapi'], usage: { [g.id]: 99, [e.id]: 5000 } }); + assert.deepEqual(uit, []); + }); + + it('een gekozen bron die uit staat naast een werkende is aandacht', () => { + const g = bron('goldapi', { isActive: 0, ...perDatum }); + const e = bron('eulerpool'); + const uit = diagnoseSources(GOUD, { providers: [g, e], historyChain: ['eulerpool', 'goldapi'] }); + assert.deepEqual(uit, [{ severity: 'attention', code: 'choiceInactive', names: ['GOLDAPI'] }]); + }); +}); + +describe('diagnoseSources: de dienst levert de historie', () => { + const dienst = { serviceCovers: true }; + + it('dekking die uit staat, geen dekking of geen keuze is dan geen melding', () => { + assert.deepEqual(diagnoseSources(GOUD, { providers: [bron('goldapi', { isActive: 0, ...perDatum })], ...dienst }), []); + assert.deepEqual(diagnoseSources(GOUD, { providers: [bron('mempool', { coverage: ['BTC'] })], ...dienst }), []); + assert.deepEqual(diagnoseSources(GOUD, { providers: [bron('eulerpool')], historyChain: [], ...dienst }), []); + }); + + it('alle gekozen bronnen op hun limiet is dan aandacht en geen probleem', () => { + const g = bron('goldapi', { callsPerMonth: 100, ...perDatum }); + const [f] = diagnoseSources(GOUD, { providers: [g], historyChain: ['goldapi'], usage: { [g.id]: 100 }, ...dienst }); + assert.equal(f.severity, 'attention'); + }); + + it('zonder de dienst blijft het een probleem', () => { + const uit = diagnoseSources(GOUD, { providers: [bron('goldapi', { isActive: 0, ...perDatum })], serviceCovers: false }); + assert.equal(uit[0].code, 'allInactive'); + }); +}); diff --git a/tests/price/custom-adapter.test.mjs b/tests/price/custom-adapter.test.mjs new file mode 100644 index 0000000..203d8f5 --- /dev/null +++ b/tests/price/custom-adapter.test.mjs @@ -0,0 +1,570 @@ +// Generated, do not edit. sha256:5217955238b304b9e0b4f32509659a4a0f009e7d18d50b262129f84c861f0e94 +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; + +import { + resolveScalar, resolveColumn, mapSpot, mapHistoryPoint, mapDailyHistory, + hmacHeaders, buildRequest, priceCall, failureEntry, +} from '../../server/price/custom.js'; +import { CONFIG } from '../../shared/config.js'; +import { toUtcIso } from '../../shared/time.js'; + +const provider = (extra = {}) => ({ + key: 'test', baseUrl: 'https://api.example.test', + authenticationKind: 'none', authenticationRequired: 0, + authenticationHeaderName: null, authenticationParameterName: null, + hmac: null, endpoints: [], symbols: [], outputs: [], ...extra, +}); + +const endpoint = (capability, pathTemplate, queryTemplate = null) => + ({ capability, method: 'GET', pathTemplate, queryTemplate }); +const output = (capability, internalField, jsonPath, transform = 'none') => + ({ capability, internalField, jsonPath, transform }); + +describe('resolveScalar: over geparst JSON, nooit over tekst', () => { + const doc = { a: { b: { c: 42 } }, lijst: [{ prijs: 1 }, { prijs: 2 }], BTC: { eur: 7 } }; + + it('loopt een pad van objectsleutels af', () => { + assert.equal(resolveScalar(doc, 'a.b.c'), 42); + }); + + it('leest een numeriek segment als index en niet als sleutel', () => { + assert.equal(resolveScalar(doc, 'lijst.1.prijs'), 2); + }); + + it('vult {external_id} in als sleutelnaam', () => { + assert.equal(resolveScalar(doc, '{external_id}.eur', 'BTC'), 7); + }); + + it('gooit als het pad {external_id} gebruikt maar er geen is', () => { + assert.throws(() => resolveScalar(doc, '{external_id}.eur', null), (e) => e.key === 'integrations.price.externalIdMissing'); + }); + + it('geeft undefined bij een pad dat halverwege doodloopt, en gooit niet', () => { + assert.equal(resolveScalar(doc, 'a.bestaat.niet.dieper'), undefined); + assert.equal(resolveScalar(doc, 'weg.helemaal'), undefined); + }); +}); + +describe('resolveColumn: een kolom uit een lijst', () => { + it('leest een kolom uit een array van objecten', () => { + const doc = { prices: [{ t: 1, e: 10 }, { t: 2, e: 20 }] }; + assert.deepEqual(resolveColumn(doc, 'prices[].t'), [1, 2]); + }); + + it('leest een kolom uit een array van arrays met een numerieke index', () => { + const doc = { prices: [[1000, 10], [2000, 20]] }; + assert.deepEqual(resolveColumn(doc, 'prices[].1'), [10, 20]); + }); + + it('behandelt de wortel als array bij een pad dat met [] begint', () => { + assert.deepEqual(resolveColumn([{ x: 1 }, { x: 2 }], '[].x'), [1, 2]); + }); + + it('geeft de elementen zelf terug als er niets achter [] staat', () => { + assert.deepEqual(resolveColumn({ v: [1, 2, 3] }, 'v[]'), [1, 2, 3]); + }); + + it('gooit bij een lijst-pad zonder [], en bij een pad dat geen array aanwijst', () => { + assert.throws(() => resolveColumn({ a: [] }, 'a.b'), (e) => e.key === 'integrations.price.listPathWithoutBrackets'); + assert.throws(() => resolveColumn({ a: 5 }, 'a[].x'), (e) => e.key === 'integrations.price.expectedArray'); + }); +}); + +describe('resolveColumn: een object met de rijen als sleutel', () => { + const doc = { rates: { '2026-09-01': { EUR: 0.86 }, '2026-09-02': { EUR: 0.87 } } }; + + it('geeft met @key de sleutels en met een veld de waarden, in dezelfde volgorde', () => { + assert.deepEqual(resolveColumn(doc, 'rates{}.@key'), ['2026-09-01', '2026-09-02']); + assert.deepEqual(resolveColumn(doc, 'rates{}.EUR'), [0.86, 0.87]); + }); + + it('geeft undefined voor een rij zonder het gevraagde veld, en gooit niet', () => { + assert.deepEqual(resolveColumn({ r: { a: { EUR: 1 }, b: { USD: 2 } } }, 'r{}.EUR'), [1, undefined]); + }); + + it('gooit als het pad geen object aanwijst, ook niet bij een array', () => { + assert.throws(() => resolveColumn({ r: [1, 2] }, 'r{}.EUR'), (e) => e.key === 'integrations.price.expectedObject'); + assert.throws(() => resolveColumn({ r: 5 }, 'r{}.@key'), (e) => e.key === 'integrations.price.expectedObject'); + }); +}); + +describe('mapSpot', () => { + it('mapt het ene prijsveld op het interne contract', () => { + const p = provider({ outputs: [output('spot', 'price', 'data.eur')] }); + assert.deepEqual(mapSpot({ data: { eur: 12.5 } }, p), { price: 12.5 }); + }); + + it('past to_number toe op een prijs die als string binnenkomt', () => { + const p = provider({ outputs: [output('spot', 'price', 'data.eur', 'to_number')] }); + assert.equal(mapSpot({ data: { eur: '12.5' } }, p).price, 12.5); + }); + + it('rekent troy ounce naar gram met de factor uit CONFIG', () => { + const p = provider({ outputs: [output('spot', 'price', 'price', 'troy_ounce_to_gram')] }); + const uit = mapSpot({ price: CONFIG.METALS.GRAMS_PER_TROY_OUNCE }, p); + assert.equal(uit.price, 1, 'een ounce ter waarde van de gramfactor is precies 1 per gram'); + }); + + it('weigert een respons zonder bruikbare prijs', () => { + const p = provider({ outputs: [output('spot', 'price', 'data.eur')] }); + assert.throws(() => mapSpot({ data: {} }, p), (e) => e.key === 'integrations.price.noValidPrice'); + assert.throws(() => mapSpot({ data: { eur: 'kapot' } }, p), (e) => e.key === 'integrations.price.noValidPrice'); + }); + + it('weigert een provider zonder mapping voor deze capability', () => { + assert.throws(() => mapSpot({}, provider()), (e) => e.key === 'integrations.price.noOutputMapping'); + }); + + it('gooit bij een transform die de adapter niet kent', () => { + const p = provider({ outputs: [output('spot', 'price', 'e', 'verzonnen')] }); + assert.throws(() => mapSpot({ e: 1 }, p), (e) => e.key === 'integrations.price.unknownTransform'); + }); +}); + +describe('een endpoint dat in een andere munt antwoordt', () => { + const inDollars = (outputs, capability = 'spot') => provider({ + endpoints: [{ ...endpoint(capability, '/p'), quoteCurrency: 'USD' }], outputs, + }); + + it('geeft price ongewijzigd door, in de munt van het endpoint', () => { + const p = inDollars([output('spot', 'price', 'price')]); + assert.deepEqual(mapSpot({ price: 2000 }, p), { price: 2000 }); + }); + + it('weigert een antwoord zonder prijs, met de provider in de parameters', () => { + const p = inDollars([output('spot', 'price', 'price')]); + assert.throws(() => mapSpot({}, p), (e) => e.key === 'integrations.price.noValidPrice' && e.params.provider === 'test'); + }); + + it('houdt in een reeks de rijen met een prijs en laat de rest vallen', () => { + const p = inDollars([output('daily_history', 'time', 'r[].0'), output('daily_history', 'price', 'r[].1')], 'daily_history'); + assert.deepEqual(mapDailyHistory({ r: [[100, 5], [200, null]] }, p), [{ time: 100, price: 5 }]); + }); + + it('leest alleen price: een mapping op een ander veld levert geen koers op', () => { + const p = provider({ endpoints: [endpoint('spot', '/p')], outputs: [output('spot', 'EUR', 'price')] }); + assert.throws(() => mapSpot({ price: 2000 }, p), (e) => e.key === 'integrations.price.noValidPrice'); + }); +}); + +describe('mapHistoryPoint', () => { + const basis = provider({ outputs: [output('history_point', 'price', 'eur')] }); + + it('leidt het tijdstip af uit de gevraagde datum als de respons er geen geeft', () => { + const uit = mapHistoryPoint({ eur: 3 }, basis, null, '2026-03-01'); + assert.equal(uit.time, Math.floor(Date.parse('2026-03-01T00:00:00Z') / 1000)); + assert.equal(uit.price, 3); + }); + + it('gebruikt het tijdstip uit de respons als dat er wél is', () => { + const p = provider({ outputs: [output('history_point', 'price', 'eur'), output('history_point', 'time', 't')] }); + assert.equal(mapHistoryPoint({ eur: 3, t: 99 }, p, null, '2026-03-01').time, 99); + }); + + it('weigert een onleesbare datum als er ook geen tijdstip in de respons staat', () => { + assert.throws(() => mapHistoryPoint({ eur: 3 }, basis, null, 'geen-datum'), (e) => e.key === 'integrations.price.invalidDate'); + }); +}); + +describe('mapDailyHistory', () => { + const kolommen = (extra = []) => provider({ + outputs: [output('daily_history', 'time', 'p[].0'), output('daily_history', 'price', 'p[].1'), ...extra], + }); + + it('koppelt de kolommen per index en sorteert oplopend op tijd', () => { + const uit = mapDailyHistory({ p: [[300, 3], [100, 1], [200, 2]] }, kolommen()); + assert.deepEqual(uit.map(r => r.time), [100, 200, 300]); + assert.deepEqual(uit.map(r => r.price), [1, 2, 3]); + }); + + it('laat rijen vallen waarvan tijd of prijs onbruikbaar is', () => { + const uit = mapDailyHistory({ p: [[100, 1], [null, 2], [300, 'kapot']] }, kolommen()); + assert.deepEqual(uit, [{ time: 100, price: 1 }]); + }); + + it('weigert kolommen van ongelijke lengte', () => { + const p = provider({ + outputs: [output('daily_history', 'time', 't[]'), output('daily_history', 'price', 'e[]')], + }); + assert.throws(() => mapDailyHistory({ t: [1, 2, 3], e: [10, 20] }, p), (e) => e.key === 'integrations.price.columnsUneven'); + }); + + it('houdt bij last_per_day alleen het laatste punt van elke kalenderdag', () => { + const dag = (iso) => Math.floor(Date.parse(iso) / 1000); + const p = provider({ + outputs: [output('daily_history', 'time', 'p[].0'), + output('daily_history', 'price', 'p[].1', 'last_per_day')], + }); + const uit = mapDailyHistory({ p: [ + [dag('2026-03-01T09:00:00Z'), 1], [dag('2026-03-01T21:00:00Z'), 2], [dag('2026-03-02T09:00:00Z'), 3], + ] }, p); + assert.deepEqual(uit.map(r => r.price), [2, 3], 'per dag blijft de laatste over'); + }); + + it('leest een reeks met datums als sleutel, en laat een sleutel die geen datum is vallen', () => { + const p = provider({ + outputs: [output('daily_history', 'time', 'rates{}.@key', 'iso_date_to_s'), + output('daily_history', 'price', 'rates{}.EUR')], + }); + const uit = mapDailyHistory({ rates: { + '2026-09-02': { EUR: 0.87 }, '2026-09-01': { EUR: 0.86 }, 'laatste': { EUR: 0.9 }, '2026-09-03': {}, + } }, p); + assert.deepEqual(uit, [ + { time: Math.floor(Date.parse('2026-09-01T00:00:00Z') / 1000), price: 0.86 }, + { time: Math.floor(Date.parse('2026-09-02T00:00:00Z') / 1000), price: 0.87 }, + ], 'oplopend gesorteerd; geen datum of geen koers is geen rij'); + }); + + it('dateert een slot op middernacht Amsterdam op zijn eigen dag en laat een maandcijfer vallen', () => { + const p = provider({ + outputs: [output('daily_history', 'time', 'p[].0', 'ms_to_s_local_close'), output('daily_history', 'price', 'p[].1')], + endpoints: [{ ...endpoint('daily_history', '/p'), quoteCurrency: 'USD' }], + }); + const dag = iso => Date.parse(`${iso}T00:00:00Z`) / 1000; + const uit = mapDailyHistory({ p: [ + [1740697200000, 91.79], + [1740787200000, 100.33], + [1743631200000, 1.0208], + ] }, p); + assert.deepEqual(uit.map(r => [r.time, r.price]), [[dag('2025-02-28'), 91.79], [dag('2025-04-03'), 1.0208]]); + }); + + it('geeft rond een maandgrens en een tijdwissel elke dag precies één slot', () => { + const p = provider({ + outputs: [output('daily_history', 'time', 'p[].0', 'ms_to_s_local_close'), output('daily_history', 'price', 'p[].1')], + }); + const slotVan = dag => Date.parse(toUtcIso(`${dag}T00:00:00`)); + const dagen = (van, n) => Array.from({ length: n }, (_, i) => new Date(Date.parse(`${van}T00:00:00Z`) + i * 86400000).toISOString().slice(0, 10)); + for (const week of [dagen('2026-08-29', 7), dagen('2026-10-22', 7), dagen('2026-03-26', 6)]) { + const reeks = week.map((d, i) => [slotVan(d), 100 + i]); + reeks.push([Date.parse('2026-09-01T00:00:00Z'), 999]); + const uit = mapDailyHistory({ p: reeks }, p); + assert.deepEqual(uit.map(r => new Date(r.time * 1000).toISOString().slice(0, 10)), week, `de dagen van ${week[0]}`); + assert.ok(!uit.some(r => r.price === 999), 'het maandcijfer valt weg'); + } + }); + + it('laat een ander tijdstip ongemoeid, zoals de avondpunten van crypto', () => { + const p = provider({ + outputs: [output('daily_history', 'time', 'p[].0', 'ms_to_s_local_close'), output('daily_history', 'price', 'p[].1')], + }); + const uit = mapDailyHistory({ p: [[1758859200000, 1], [1759353813000, 2]] }, p); + assert.deepEqual(uit.map(r => r.time), [1758859200, 1759353813]); + }); +}); + +describe('buildRequest: de poort die weigert zonder sleutel', () => { + const metAuth = (kind, extra = {}) => provider({ + authenticationKind: kind, authenticationRequired: 1, + endpoints: [endpoint('spot', '/v1/prijs')], ...extra, + }); + + it('weigert als de sleutel verplicht is maar ontbreekt', async () => { + await assert.rejects(() => buildRequest(metAuth('header_key', { authenticationHeaderName: 'X-Key' }), + 'spot', 'BTC', null, null), (e) => e.key === 'integrations.price.keyMissing'); + }); + + it('weigert een HALVE sleutel, en dat is het geval dat telt', async () => { + await assert.rejects(() => buildRequest(metAuth('basic'), 'spot', 'BTC', null, { api_key: 'k' }), + (e) => e.key === 'integrations.price.keyMissing'); + }); + + it('laat een provider zonder verplichte sleutel gewoon door', async () => { + const p = provider({ endpoints: [endpoint('spot', '/v1/prijs')] }); + const r = await buildRequest(p, 'spot', 'BTC', null, null); + assert.equal(r.url, 'https://api.example.test/v1/prijs'); + assert.deepEqual(r.headers, {}); + }); + + it('gooit bij een capability die de provider niet kent, en bij een onbekende capability', async () => { + await assert.rejects(() => buildRequest(provider(), 'spot', 'BTC', null, null), (e) => e.key === 'integrations.price.capabilityNotConfigured'); + await assert.rejects(() => buildRequest(provider(), 'verzonnen', 'BTC', null, null), (e) => e.key === 'integrations.price.unknownCapability'); + }); + + it('ondersteunt alleen GET', async () => { + const p = provider({ endpoints: [{ capability: 'spot', method: 'POST', pathTemplate: '/x', queryTemplate: null }] }); + await assert.rejects(() => buildRequest(p, 'spot', 'BTC', null, null), (e) => e.key === 'integrations.price.onlyGet'); + }); +}); + +describe('buildRequest: de placeholders in het pad', () => { + it('vertaalt het symbool via de symbol-map en valt anders terug op het symbool zelf', async () => { + const p = provider({ + endpoints: [endpoint('spot', '/v1/{external_id}')], + symbols: [{ symbol: 'WXT', externalId: 'wirex' }], + }); + assert.equal((await buildRequest(p, 'spot', 'WXT', null, null)).url, 'https://api.example.test/v1/wirex'); + assert.equal((await buildRequest(p, 'spot', 'XAU', null, null)).url, 'https://api.example.test/v1/XAU'); + }); + + it('codeert de placeholder, zodat een raar extern id het pad niet kan openbreken', async () => { + const p = provider({ + endpoints: [endpoint('spot', '/v1/{external_id}')], + symbols: [{ symbol: 'X', externalId: 'a/b?c' }], + }); + assert.equal((await buildRequest(p, 'spot', 'X', null, null)).url, 'https://api.example.test/v1/a%2Fb%3Fc'); + }); + + it('zet {date} om naar YYYYMMDD en weigert een onleesbare datum', async () => { + const p = provider({ endpoints: [endpoint('history_point', '/v1/{date}')] }); + assert.equal((await buildRequest(p, 'history_point', 'X', '2026-03-01', null)).url, + 'https://api.example.test/v1/20260301'); + await assert.rejects(() => buildRequest(p, 'history_point', 'X', 'rommel', null), (e) => e.key === 'integrations.price.templateDateInvalid'); + }); +}); + +describe('buildRequest: de vier auth-soorten', () => { + const p = (kind, extra = {}) => provider({ + authenticationKind: kind, endpoints: [endpoint('spot', '/v1/p', 'a=1')], ...extra, + }); + + it('zet een header bij header_key', async () => { + const r = await buildRequest(p('header_key', { authenticationHeaderName: 'X-Key' }), 'spot', 'X', null, { api_key: 'geheim' }); + assert.equal(r.headers['X-Key'], 'geheim'); + }); + + it('zet een Bearer-header bij bearer', async () => { + const r = await buildRequest(p('bearer'), 'spot', 'X', null, { api_key: 'geheim' }); + assert.equal(r.headers.Authorization, 'Bearer geheim'); + }); + + it('zet een base64-paar bij basic', async () => { + const r = await buildRequest(p('basic'), 'spot', 'X', null, { api_key: 'u', api_secret: 'w' }); + assert.equal(r.headers.Authorization, 'Basic ' + Buffer.from('u:w').toString('base64')); + }); + + it('plakt de sleutel achter de query bij query_key, gecodeerd en zonder de bestaande query te slopen', async () => { + const r = await buildRequest(p('query_key', { authenticationParameterName: 'api key' }), + 'spot', 'X', null, { api_key: 'a&b' }); + assert.equal(r.url, 'https://api.example.test/v1/p?a=1&api%20key=a%26b'); + }); + + it('gooit als de naam van de header of de queryparameter ontbreekt', async () => { + await assert.rejects(() => buildRequest(p('header_key'), 'spot', 'X', null, { api_key: 'k' }), + (e) => e.key === 'integrations.price.headerNameMissing'); + await assert.rejects(() => buildRequest(p('query_key'), 'spot', 'X', null, { api_key: 'k' }), + (e) => e.key === 'integrations.price.parameterNameMissing'); + }); + + it('gooit bij een authenticationKind die niet bestaat', async () => { + await assert.rejects(() => buildRequest(p('verzonnen'), 'spot', 'X', null, null), (e) => e.key === 'integrations.price.unknownAuthKind'); + }); +}); + +describe('hmacHeaders: het bericht dat ondertekend wordt', () => { + const hmac = { + messageParts: 'timestamp,method,full_path,body', hashAlgorithm: 'SHA-256', encoding: 'hex', + keyHeader: 'X-Key', signatureHeader: 'X-Sig', timestampHeader: 'X-Ts', windowHeader: null, windowMs: null, + }; + const creds = { api_key: 'sleutel', api_secret: 'geheim' }; + + it('ondertekent precies de samengevoegde onderdelen, in de opgegeven volgorde', async () => { + const h = await hmacHeaders(hmac, 'GET', '/v1/p?a=1', '', creds); + const verwacht = createHmac('sha256', creds.api_secret) + .update(`${h['X-Ts']}GET/v1/p?a=1`).digest('hex'); + + assert.equal(h['X-Sig'], verwacht); + assert.equal(h['X-Key'], 'sleutel'); + assert.match(h['X-Ts'], /^\d{13}$/, 'een tijdstempel in milliseconden'); + }); + + it('volgt de volgorde uit messageParts en niet een vaste volgorde', async () => { + const omgekeerd = { ...hmac, messageParts: 'method,timestamp' }; + const h = await hmacHeaders(omgekeerd, 'GET', '/x', '', creds); + assert.equal(h['X-Sig'], createHmac('sha256', creds.api_secret).update(`GET${h['X-Ts']}`).digest('hex')); + }); + + it('kan base64 in plaats van hex', async () => { + const h = await hmacHeaders({ ...hmac, encoding: 'base64' }, 'GET', '/x', '', creds); + assert.equal(h['X-Sig'], createHmac('sha256', creds.api_secret).update(`${h['X-Ts']}GET/x`).digest('base64')); + }); + + it('zet de venster-header alleen als hij geconfigureerd is', async () => { + assert.equal('X-Win' in await hmacHeaders(hmac, 'GET', '/x', '', creds), false); + const h = await hmacHeaders({ ...hmac, windowHeader: 'X-Win', windowMs: 5000 }, 'GET', '/x', '', creds); + assert.equal(h['X-Win'], '5000'); + }); + + it('gooit bij een onbekend onderdeel in messageParts', async () => { + await assert.rejects(() => hmacHeaders({ ...hmac, messageParts: 'timestamp,verzonnen' }, 'GET', '/x', '', creds), + (e) => e.key === 'integrations.price.unknownHmacToken'); + }); + + it('ondertekent het VOLLEDIGE pad inclusief query', async () => { + const p = provider({ + authenticationKind: 'hmac', hmac, endpoints: [endpoint('spot', '/v1/p', 'a=1')], + }); + const r = await buildRequest(p, 'spot', 'X', null, creds); + assert.equal(r.headers['X-Sig'], + createHmac('sha256', creds.api_secret).update(`${r.headers['X-Ts']}GET/v1/p?a=1`).digest('hex')); + }); + + it('gooit als de hmac-config bij de provider ontbreekt', async () => { + const p = provider({ authenticationKind: 'hmac', hmac: null, endpoints: [endpoint('spot', '/v1/p')] }); + await assert.rejects(() => buildRequest(p, 'spot', 'X', null, creds), (e) => e.key === 'integrations.price.hmacConfigMissing'); + }); +}); + +describe('priceCall: de enige functie die het netwerk op gaat', () => { + const p = provider({ + endpoints: [endpoint('spot', '/v1/prijs')], + outputs: [output('spot', 'price', 'data.eur')], + }); + + function metFetch(antwoord, dan) { + const origineel = globalThis.fetch; + const gezien = {}; + globalThis.fetch = async (url, opties) => { gezien.url = url; gezien.opties = opties; return antwoord; }; + return Promise.resolve(dan(gezien)).finally(() => { globalThis.fetch = origineel; }); + } + const respons = (status, body) => new Response(JSON.stringify(body), + { status, headers: { 'Content-Type': 'application/json' } }); + + it('mapt een geslaagde respons op het interne contract', async () => { + await metFetch(respons(200, { data: { eur: 42 } }), async (gezien) => { + assert.deepEqual(await priceCall(p, 'spot', 'BTC', null, null), { price: 42 }); + assert.equal(gezien.url, 'https://api.example.test/v1/prijs'); + }); + }); + + it('geeft de ruwe respons erbij als daarom gevraagd wordt', async () => { + await metFetch(respons(200, { data: { eur: 42 } }), async () => { + const uit = await priceCall(p, 'spot', 'BTC', null, null, { withRaw: true }); + assert.deepEqual(uit.result, { price: 42 }); + assert.deepEqual(uit.raw, { data: { eur: 42 } }, 'raw is de geparste respons, niet de tekst'); + }); + }); + + it('meldt een foutstatus met de code erbij', async () => { + await metFetch(respons(429, { fout: 'te veel' }), async () => { + await assert.rejects(() => priceCall(p, 'spot', 'BTC', null, null), (e) => e.key === 'integrations.price.httpFailed' && e.params.status === 429); + }); + }); + + it('laat een respons die geen JSON is niet stil doorgaan', async () => { + const kapot = new Response('storing', { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); + await metFetch(kapot, async () => { + await assert.rejects(() => priceCall(p, 'spot', 'BTC', null, null), (e) => + e.key === 'integrations.price.notJson' && e.params.detail === 'no JSON (text/html, 20 bytes)' && e.httpStatus === 200); + }); + }); + + it('beschrijft een foutantwoord dat geen JSON is in plaats van het weg te gooien', async () => { + const proxy = new Response('Bad Gateway', { status: 502, headers: { 'Content-Type': 'text/html' } }); + await metFetch(proxy, async () => { + await assert.rejects(() => priceCall(p, 'spot', 'BTC', null, null), (e) => + e.key === 'integrations.price.httpFailed' && e.params.detail === ' no JSON (text/html, 24 bytes)' && e.httpStatus === 502); + }); + }); + + it('meldt een netwerkfout als netwerkfout en niet als koersfout', async () => { + const origineel = globalThis.fetch; + globalThis.fetch = async () => { throw new Error('kabel eruit'); }; + try { + await assert.rejects(() => priceCall(p, 'spot', 'BTC', null, null), (e) => e.key === 'integrations.price.networkError'); + } finally { globalThis.fetch = origineel; } + }); +}); + +describe('failureEntry: wat het log van een mislukte koersaanroep krijgt', () => { + const p = provider({ + authenticationKind: 'query_key', authenticationRequired: 1, authenticationParameterName: 'apikey', + endpoints: [endpoint('spot', '/v1/prijs')], outputs: [output('spot', 'price', 'data.eur')], + }); + const fout = async (antwoord, creds = { api_key: 'GEHEIM123' }) => { + const origineel = globalThis.fetch; + globalThis.fetch = async () => antwoord; + try { await priceCall(p, 'spot', 'BTC', null, creds); } catch (e) { return e; } finally { globalThis.fetch = origineel; } + throw new Error('priceCall gooide niet'); + }; + + it('zonder sleutel: "refused", geen tijd, en er ging niets de deur uit', async () => { + let gevraagd = false; + const origineel = globalThis.fetch; + globalThis.fetch = async () => { gevraagd = true; return new Response('{}'); }; + let e; + try { await priceCall(p, 'spot', 'BTC', null, null); } catch (x) { e = x; } finally { globalThis.fetch = origineel; } + assert.equal(gevraagd, false); + const rij = failureEntry(p, 'spot', 'BTC', e, 12); + assert.deepEqual([rij.outcome, rij.latencyMs, rij.errorKey], ['refused', null, 'integrations.price.keyMissing']); + }); + + it('een foutstatus: "fail" met de status en de sleutel, en de capability als operatie en niet de URL', async () => { + const e = await fout(new Response(JSON.stringify({ error: 'bad key GEHEIM123' }), { status: 403 })); + const rij = failureEntry(p, 'spot', 'BTC', e, 12); + assert.deepEqual({ ...rij, detail: undefined }, { + target: 'provider', providerKey: 'test', operation: 'spot', subject: 'BTC', outcome: 'fail', + httpStatus: 403, errorKey: 'integrations.price.httpFailed', detail: undefined, latencyMs: 12, + }); + assert.ok(!JSON.stringify(rij).includes('api.example.test'), 'de URL staat in de rij'); + }); + + it('een antwoord zonder bruikbare prijs: "empty", met status 200', async () => { + const e = await fout(new Response(JSON.stringify({ data: { eur: null } }), { status: 200 })); + const rij = failureEntry(p, 'spot', 'BTC', e, 5); + assert.deepEqual([rij.outcome, rij.httpStatus, rij.errorKey], ['empty', 200, 'integrations.price.noValidPrice']); + }); +}); + +describe('één bron voor crypto en metaal: het segment en de eenheid per symbool (migratie 99)', () => { + const f = CONFIG.METALS.GRAMS_PER_TROY_OUNCE; + const eulerpool = provider({ + endpoints: [endpoint('daily_history', '/api/1/{external_id}/quotes/{symbol}')], + symbols: [{ symbol: 'BTC', externalId: 'crypto', quoteUnit: null }, { symbol: 'XAU', externalId: 'commodity', quoteUnit: 'ozt' }], + outputs: [output('daily_history', 'time', '[].timestamp', 'ms_to_s'), output('daily_history', 'price', '[].price', 'last_per_day')], + }); + const reeks = [{ timestamp: Date.parse('2026-09-01T00:00:00Z'), price: 3100 }]; + async function vraag(symbool) { + const origineel = globalThis.fetch; + let url = null; + globalThis.fetch = async u => { url = u; return new Response(JSON.stringify(reeks), { status: 200 }); }; + try { return { rijen: await priceCall(eulerpool, 'daily_history', symbool, null, null), url }; } + finally { globalThis.fetch = origineel; } + } + + it('zet het segment van het symbool in het pad', async () => { + assert.match((await vraag('BTC')).url, /\/api\/1\/crypto\/quotes\/BTC$/); + assert.match((await vraag('XAU')).url, /\/api\/1\/commodity\/quotes\/XAU$/); + }); + + it('rekent alleen het symbool met ozt om naar per gram', async () => { + assert.equal((await vraag('BTC')).rijen[0].price, 3100, 'crypto blijft per munt'); + assert.equal((await vraag('XAU')).rijen[0].price, 3100 / f, 'metaal wordt per gram'); + }); +}); + +describe('de catch-all in de invoermapping (migratie 102)', () => { + const f = CONFIG.METALS.GRAMS_PER_TROY_OUNCE; + const eulerpool = provider({ + endpoints: [endpoint('daily_history', '/api/1/{external_id}/quotes/{symbol}')], + symbols: [{ symbol: 'XAU', externalId: 'commodity', quoteUnit: 'ozt', isCatchAll: 0 }, + { symbol: null, externalId: 'crypto', quoteUnit: null, isCatchAll: 1 }], + outputs: [output('daily_history', 'time', '[].timestamp', 'ms_to_s'), output('daily_history', 'price', '[].price', 'last_per_day')], + }); + const reeks = [{ timestamp: Date.parse('2026-09-01T00:00:00Z'), price: 3100 }]; + async function vraag(p, symbool) { + const origineel = globalThis.fetch; + let url = null; + globalThis.fetch = async u => { url = u; return new Response(JSON.stringify(reeks), { status: 200 }); }; + try { return { rijen: await priceCall(p, 'daily_history', symbool, null, null), url }; } + finally { globalThis.fetch = origineel; } + } + + it('geeft een munt zonder eigen regel het segment van de catch-all', async () => { + assert.match((await vraag(eulerpool, 'DOGE')).url, /\/api\/1\/crypto\/quotes\/DOGE$/); + assert.equal((await vraag(eulerpool, 'DOGE')).rijen[0].price, 3100, 'zonder eenheid op de catch-all blijft hij per munt'); + }); + + it('laat een eigen regel voorgaan, ook in de eenheid', async () => { + const r = await vraag(eulerpool, 'XAU'); + assert.match(r.url, /\/api\/1\/commodity\/quotes\/XAU$/); + assert.equal(r.rijen[0].price, 3100 / f); + }); + + it('valt zonder catch-all terug op het symbool zelf, zoals voorheen', async () => { + const zonder = { ...eulerpool, symbols: eulerpool.symbols.filter(s => !s.isCatchAll) }; + assert.match((await vraag(zonder, 'DOGE')).url, /\/api\/1\/DOGE\/quotes\/DOGE$/); + }); +}); diff --git a/tests/price/dienst-spot.test.mjs b/tests/price/dienst-spot.test.mjs new file mode 100644 index 0000000..503d3ad --- /dev/null +++ b/tests/price/dienst-spot.test.mjs @@ -0,0 +1,373 @@ +// Generated, do not edit. sha256:be43673f9f978564f187426b8db28e08c641acba85e6d36a4aabd536adf07ca5 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; +import { CONFIG } from '../../shared/config.js'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); +for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics', 'preferences', 'assets']) { + mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) }); +} + +let STAND = 'off'; +mock.module('../../client/app-settings.js', { + namedExports: { appSetting: (k) => (k === 'market_spot' ? STAND : 1), baseCurrency: () => 'EUR' }, +}); + +let DIENST = null; +let dienstVragen = 0; +let DAGEN = { status: 'notCovered' }; +let dagVragen = []; +let DEKKING = { status: 'off' }; +mock.module('../../client/data/market.js', { + namedExports: { + serviceSpot: async () => { dienstVragen++; return DIENST; }, + intradaySeries: async () => ({ status: 'off' }), + serviceDayHistory: async (assetId, since) => { dagVragen.push({ assetId, since }); return DAGEN; }, + serviceCoverage: async () => DEKKING, + }, +}); + +const facade = await import('../../client/price/providers.js'); + +let bronVragen = 0; +let bronWeg = false; +globalThis.fetch = async () => { + bronVragen++; + if (bronWeg) throw new Error('bron weg'); + return { ok: true, status: 200, json: async () => ({ price: 100 }) }; +}; + +const { run, lastId, one } = harness.exports; +let btc; + +function systeemProvider(key, name) { + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES (?, ?, ?, '', 'none', 0, 1, 1, 1, '2026-10-09')`, [key, name, name.toLowerCase()]); + return lastId(); +} +function eigenBron({ key = 'bron', name = 'Eigen bron', rank = 1, status = 'confirmed' } = {}) { + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES (?, ?, ?, 'https://voorbeeld.test', 'none', 0, 1, 1, 0, '2026-10-09')`, [key, name, name.toLowerCase()]); + const id = lastId(); + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'BTC')`, [id]); + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate) VALUES (?, 'spot', 'GET', '/p', NULL)`, [id]); + run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) VALUES (?, 'spot', ?, ?, ?)`, [btc, id, rank, status]); +} +const asset = () => one(`SELECT * FROM Asset WHERE id = ?`, [btc]); +const ok = (price, currency = 'EUR') => ({ status: 'ok', instrument: 'crypto:BTC', price, currency, observedAt: 1, source: 'bitvavo', sourceLabel: 'Bitvavo' }); +const gecached = () => one(`SELECT fiatPrice FROM PriceHistory JOIN Provider ON Provider.id = PriceHistory.providerId + WHERE Provider.key = 'atreides_services' AND assetId = ?`, [btc]); + +beforeEach(() => { + harness.recreate(); + facade.clearSpotCache(); + STAND = 'off'; + DIENST = ok(61000); + dienstVragen = 0; + DAGEN = { status: 'notCovered' }; + dagVragen = []; + DEKKING = { status: 'off' }; + facade.forgetSeriesFetches(); + bronVragen = 0; + bronWeg = false; + systeemProvider('atreides_services', 'Atreides Services'); + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'unit')`); + btc = lastId(); +}); + +describe('de stand van market_spot', () => { + it('uit: de dienst wordt NIET gevraagd, ook als de eigen keten niets geeft', async () => { + assert.equal(await facade.liveSpotFor(asset()), null); + assert.equal(dienstVragen, 0); + }); + + it('als terugval: een werkende eigen bron gaat voor en de dienst wordt niet gevraagd', async () => { + STAND = 'fallback'; + eigenBron(); + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 100 }); + assert.equal(dienstVragen, 0); + assert.equal(facade.lastSpotSource(asset()), 'Eigen bron'); + }); + + it('als terugval: zonder eigen bron levert de dienst, en die koers komt in de cache', async () => { + STAND = 'fallback'; + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 61000 }); + assert.equal(facade.lastSpotSource(asset()), 'Atreides Services (Bitvavo)'); + assert.equal(gecached().fiatPrice, 61000); + }); + + it('eerst: de dienst gaat vóór een bron die de APP zette', async () => { + STAND = 'first'; + eigenBron(); + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 61000 }); + assert.equal(bronVragen, 0, 'de automatische bron wordt dan niet gevraagd'); + }); + + it('eerst: een bron die de GEBRUIKER koos gaat vóór de dienst', async () => { + STAND = 'first'; + eigenBron({ status: 'manual' }); + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 100 }); + assert.equal(dienstVragen, 0); + }); + + it('eerst: zelf gekozen bron weg, dan de dienst, en pas daarna de bron van de app', async () => { + STAND = 'first'; + eigenBron({ key: 'zelf', name: 'Zelf', rank: 1, status: 'manual' }); + eigenBron({ key: 'app', name: 'App', rank: 2, status: 'confirmed' }); + bronWeg = true; + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 61000 }); + assert.equal(bronVragen, 1, 'alleen de zelf gekozen bron is geprobeerd, de bron van de app niet'); + }); + + it('eerst, dienst weg: na de zelf gekozen bron komt de bron van de app aan de beurt', async () => { + STAND = 'first'; + eigenBron({ key: 'app', name: 'App', status: 'confirmed' }); + DIENST = { status: 'unavailable', retryLater: true, key: 'marketService.unreachable', params: {} }; + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 100 }); + assert.equal(facade.lastSpotSource(asset()), 'App'); + }); +}); + +describe('een dienst die even weg is', () => { + it('eerst, dienst weg: de eigen bron neemt het over', async () => { + STAND = 'first'; + eigenBron(); + DIENST = { status: 'unavailable', retryLater: true, key: 'marketService.unreachable', params: {} }; + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 100 }); + }); + + it('dienst weg en geen eigen bron: de laatst bekende koers uit de cache, en die zegt dat', async () => { + STAND = 'first'; + await facade.liveSpotFor(asset()); + facade.clearSpotCache(); + DIENST = { status: 'unavailable', retryLater: true, key: 'marketService.unreachable', params: {} }; + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 61000 }); + assert.match(facade.lastSpotSource(asset()), /laatst bekend/); + }); + + it('een gat (missing) leest niet uit de cache: dan is er echt geen koers', async () => { + STAND = 'first'; + await facade.liveSpotFor(asset()); + facade.clearSpotCache(); + DIENST = { status: 'missing' }; + assert.equal(await facade.liveSpotFor(asset()), null); + }); + + it('eigen bron weg en dienst weg: eerst de eigen bron geprobeerd, dan de cache', async () => { + STAND = 'fallback'; + eigenBron(); + bronWeg = true; + DIENST = ok(61000); + await facade.liveSpotFor(asset()); + facade.clearSpotCache(); + DIENST = { status: 'unavailable', retryLater: true, key: 'marketService.unreachable', params: {} }; + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 61000 }); + assert.ok(bronVragen >= 2, 'de eigen bron kreeg zijn beurt'); + }); +}); + +describe('het statuslampje bij de dienst', () => { + const metDocument = async (fn) => { + const events = []; + globalThis.document = { dispatchEvent: e => { events.push(e.detail); return true; } }; + try { await fn(); } finally { delete globalThis.document; } + return events.filter(d => d.provider_key === 'atreides_services'); + }; + + it('licht op bij een spot uit de dienst, met het symbool', async () => { + STAND = 'first'; + const events = await metDocument(() => facade.liveSpotFor(asset())); + assert.equal(events.length, 1); + assert.deepEqual([events[0].ok, events[0].symbol, events[0].capability], [true, 'BTC', 'spot']); + }); + + it('kleurt rood als de dienst weg is, en zegt niets in de stand uit', async () => { + STAND = 'first'; + DIENST = { status: 'unavailable', retryLater: true }; + const weg = await metDocument(() => facade.liveSpotFor(asset())); + assert.equal(weg[0]?.ok, false); + facade.clearSpotCache(); + STAND = 'off'; + assert.deepEqual(await metDocument(() => facade.liveSpotFor(asset())), []); + }); + + it('licht ook op bij dagkoersen uit de dienst', async () => { + STAND = 'first'; + DAGEN = { status: 'ok', instrument: 'crypto:BTC', currency: 'EUR', source: 'eulerpool', days: [[Date.now() - 2 * 86_400_000, 60000]] }; + const events = await metDocument(() => facade.ensurePriceHistoryFor(asset())); + assert.equal(events.some(d => d.capability === 'daily_history' && d.ok), true); + }); +}); + +describe('de munt van de bron', () => { + it('rekent om naar de rekenvaluta met de koers die elke spot krijgt', async () => { + STAND = 'first'; + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('USD','Dollar','dollar','fiat','USD','cent',2,'unit')`); + const usd = lastId(); + const vast = systeemProvider('fixed', 'Vaste koers'); + const vandaag = new Date().toISOString().slice(0, 10); + run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 0.8, 'day', ?)`, + [usd, vast, `${vandaag}T00:00:00Z`]); + DIENST = ok(70000, 'USD'); + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 56000 }); + }); + + it('zonder wisselkoers geen koers, in plaats van dollars als euro\'s', async () => { + STAND = 'first'; + DIENST = ok(70000, 'USD'); + assert.equal(await facade.liveSpotFor(asset()), null); + }); +}); + +describe('dagkoersen uit de dienst', () => { + const DAG = 86_400_000; + const vandaag = () => Date.parse(`${new Date().toISOString().slice(0, 10)}T00:00:00Z`); + const dagen = (...prijzen) => ({ status: 'ok', instrument: 'crypto:BTC', currency: 'EUR', source: 'eulerpool', + days: prijzen.map(([terug, p]) => [vandaag() - terug * DAG, p]) }); + const opgeslagen = () => harness.exports.all(`SELECT substr(observedAt, 1, 10) AS dag, fiatPrice FROM PriceHistory + JOIN Provider ON Provider.id = PriceHistory.providerId + WHERE Provider.key = 'atreides_services' AND assetId = ? AND resolution = 'day' ORDER BY observedAt`, [btc]); + const dagVan = (terug) => new Date(vandaag() - terug * DAG).toISOString().slice(0, 10); + + it('uit: de dienst wordt niet gevraagd', async () => { + DAGEN = dagen([1, 60000]); + await facade.ensurePriceHistoryFor(asset()); + assert.equal(dagVragen.length, 0); + }); + + it('eerst: de dagen van de dienst komen in de cache, en één keer per dag', async () => { + STAND = 'first'; + DAGEN = dagen([2, 59000], [1, 60000]); + await facade.ensurePriceHistoryFor(asset()); + assert.deepEqual(opgeslagen(), [{ dag: dagVan(2), fiatPrice: 59000 }, { dag: dagVan(1), fiatPrice: 60000 }]); + await facade.ensurePriceHistoryFor(asset()); + assert.equal(dagVragen.length, 1, 'twee keer gevraagd op één dag'); + }); + + it('vraagt vanaf de dag na de nieuwste afgesloten dag, en niets als gisteren er al is', async () => { + STAND = 'first'; + const vast = systeemProvider('fixed', 'Vaste koers'); + run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 1, 'day', ?)`, + [btc, vast, `${dagVan(5)}T00:00:00Z`]); + run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 1, 'day', ?)`, + [btc, vast, `${dagVan(0)}T00:00:00Z`]); + await facade.ensurePriceHistoryFor(asset()); + assert.equal(dagVragen[0].since, vandaag() - 4 * DAG); + + facade.forgetSeriesFetches(); + run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 1, 'day', ?)`, + [btc, vast, `${dagVan(1)}T00:00:00Z`]); + await facade.ensurePriceHistoryFor(asset()); + assert.equal(dagVragen.length, 1); + }); + + it('rekent elke dag om met de koers van DIE dag, en laat een dag zonder koers weg', async () => { + STAND = 'first'; + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('USD','Dollar','dollar','fiat','USD','cent',2,'unit')`); + const usd = lastId(); + const vast = systeemProvider('fixed', 'Vaste koers'); + run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 0.9, 'day', ?)`, + [usd, vast, `${dagVan(20)}T00:00:00Z`]); + run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 0.8, 'day', ?)`, + [usd, vast, `${dagVan(2)}T00:00:00Z`]); + DAGEN = { ...dagen([30, 1000], [12, 1000], [1, 1000]), currency: 'USD' }; + await facade.ensurePriceHistoryFor(asset()); + assert.deepEqual(opgeslagen(), [{ dag: dagVan(1), fiatPrice: 800 }]); + }); + + it('eerst: een reeksbron die de gebruiker zelf koos gaat voor, en de dienst wordt niet gevraagd', async () => { + STAND = 'first'; + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES ('zelf', 'Zelf', 'zelf', 'https://voorbeeld.test', 'none', 0, 1, 1, 0, '2026-10-09')`); + const id = lastId(); + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'BTC')`, [id]); + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate) VALUES (?, 'daily_history', 'GET', '/d', NULL)`, [id]); + run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) VALUES (?, 'history', ?, 1, 'manual')`, [btc, id]); + DAGEN = dagen([1, 60000]); + try { await facade.ensurePriceHistoryFor(asset()); } catch { } + assert.equal(dagVragen.length, 0); + }); + + it('Gaten opvullen: de dienst vult het gat, alleen binnen het gat, en niets in stand uit', async () => { + DAGEN = dagen([12, 100], [10, 101], [9, 102], [3, 103]); + const uit = await facade.fillClosesFor(asset(), dagVan(11), dagVan(9)); + assert.equal(dagVragen.length, 0, 'stand uit vroeg de dienst'); + assert.equal(uit.written, 0); + + STAND = 'fallback'; + const met = await facade.fillClosesFor(asset(), dagVan(11), dagVan(9)); + assert.equal(met.written, 2); + assert.deepEqual(opgeslagen(), [{ dag: dagVan(10), fiatPrice: 101 }, { dag: dagVan(9), fiatPrice: 102 }]); + }); + + it('een weigering wordt onthouden: een tweede omrekening vraagt de dienst niet opnieuw', async () => { + STAND = 'first'; + DAGEN = { status: 'unavailable', retryLater: false, key: 'marketService.rejected', params: { status: 404, key: 'api.noSeries' } }; + await facade.ensurePriceHistoryFor(asset()); + await facade.ensurePriceHistoryFor(asset()); + assert.equal(dagVragen.length, 1, 'de weigering is opnieuw gevraagd'); + + facade.forgetSeriesFetches(); + await facade.ensurePriceHistoryFor(asset()); + assert.equal(dagVragen.length, 2); + }); + + it('later opnieuw: pas na een korte pauze, niet bij elke weergave', async () => { + STAND = 'first'; + DAGEN = { status: 'unavailable', retryLater: true, key: 'marketService.unreachable', params: {} }; + await facade.ensurePriceHistoryFor(asset()); + await facade.ensurePriceHistoryFor(asset()); + assert.equal(dagVragen.length, 1, 'binnen de pauze opnieuw gevraagd'); + + const echt = Date.now; + Date.now = () => echt() + CONFIG.MARKET_SERVICE.RETRY_PAUSE_MS + 1; + try { await facade.ensurePriceHistoryFor(asset()); } finally { Date.now = echt; } + assert.equal(dagVragen.length, 2, 'na de pauze niet opnieuw gevraagd'); + }); + + it('als terugval: een werkende eigen bron gaat voor; zonder eigen bron de dienst', async () => { + STAND = 'fallback'; + eigenBron(); + DAGEN = dagen([1, 60000]); + await facade.ensurePriceHistoryFor(asset()); + assert.equal(dagVragen.length, 0); + + harness.exports.run(`DELETE FROM PriceSource`); + harness.exports.run(`DELETE FROM PriceHistory`); + facade.forgetSeriesFetches(); + await facade.ensurePriceHistoryFor(asset()); + assert.equal(dagVragen.length, 1); + assert.equal(opgeslagen().length, 1); + }); +}); + +describe('rateSourceFor: de dienst als wisselkoersbron', () => { + const dollar = () => { run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('USD','Dollar','dollar','fiat','USD','cent',2,'unit')`); return lastId(); }; + + it('telt mee als de stand niet uit staat en de dienst de munt dekt', async () => { + const usd = dollar(); + DEKKING = { status: 'ok', assetIds: [usd] }; + for (const stand of ['first', 'fallback']) { + STAND = stand; + assert.equal((await facade.rateSourceFor('USD'))?.key, 'atreides_services', stand); + } + }); + + it('telt niet mee in de stand uit, voor een munt die hij niet dekt, of als hij weg is', async () => { + const usd = dollar(); + DEKKING = { status: 'ok', assetIds: [usd] }; + assert.equal(await facade.rateSourceFor('USD'), null, 'stand uit'); + STAND = 'first'; + DEKKING = { status: 'ok', assetIds: [btc] }; + assert.equal(await facade.rateSourceFor('USD'), null, 'niet gedekt'); + DEKKING = { status: 'unavailable' }; + assert.equal(await facade.rateSourceFor('USD'), null, 'onbereikbaar'); + }); +}); diff --git a/tests/price/fiat-herkomst.test.mjs b/tests/price/fiat-herkomst.test.mjs new file mode 100644 index 0000000..b8679fb --- /dev/null +++ b/tests/price/fiat-herkomst.test.mjs @@ -0,0 +1,67 @@ +// Generated, do not edit. sha256:332bac6d5d6fd8fc6489b8ba93b57adc4f35577ee5444684f9169fb9444c227e +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; +import { rekenvalutaUitDatabase } from '../helpers/rekenvaluta.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); +for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics', 'assets', 'tx-links']) { + mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) }); +} +await rekenvalutaUitDatabase(mock); + +const facade = await import('../../client/price/providers.js'); +const fiat = await import('../../client/ui/fiat.js'); + +let fetchCalls = 0; +globalThis.fetch = async () => { fetchCalls++; return { ok: true, status: 200, json: async () => ({ price: 95000 }) }; }; + +const { run, lastId, one } = harness.exports; +let btc, bron; + +beforeEach(() => { + harness.recreate(); + facade.clearSpotCache(); + fetchCalls = 0; + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'unit')`); + btc = lastId(); + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES ('bitvavo','Bitvavo','bitvavo','https://voorbeeld.test','none',0,1,1,0,'2026-01-01T00:00:00Z')`); + bron = lastId(); + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'BTC')`, [bron]); + for (const cap of ['spot', 'daily_history']) { + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate) VALUES (?, ?, 'GET', '/x')`, [bron, cap]); + run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) + VALUES (?, ?, ?, 1, 'confirmed')`, [btc, cap === 'spot' ? 'spot' : 'history', bron]); + } +}); +const asset = () => one(`SELECT * FROM Asset WHERE id = ?`, [btc]); + +describe('fiatHistoricalOrigin: de dagkoers onder een verplaatsing', () => { + it('beschrijft de rij waarmee de waarde gerekend is, met zijn bron', async () => { + run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt, createdAt) + VALUES (?, ?, 80000, 'day', '2026-03-03T00:00:00Z', '2026-03-04T00:00:00Z')`, [btc, bron]); + await fiat.loadHistorical([{ assetId: btc, dateKey: '2026-03-03' }]); + assert.notEqual(fiat.fiatHistorical(1e8, asset(), '2026-03-03'), '', 'er is een waarde'); + const o = fiat.fiatHistoricalOrigin(asset(), '2026-03-03'); + assert.deepEqual({ kind: o.kind, fiatPrice: o.fiatPrice, source: o.source, day: o.day }, { kind: 'close', fiatPrice: 80000, source: 'Bitvavo', day: '2026-03-03' }); + assert.equal(fetchCalls, 0, 'de koers stond er al, dus er ging niets naar buiten'); + }); + + it('heeft geen herkomst zonder koers, en ook niet voor de euro zelf', () => { + assert.equal(fiat.fiatHistoricalOrigin(asset(), '2020-01-01'), null); + assert.equal(fiat.fiatHistoricalOrigin({ id: 99, symbol: 'EUR' }, '2026-03-03'), null); + }); +}); + +describe('fiatValueOrigin: de spot onder een bedrag op Tracking', () => { + it('is live en noemt de bron die de spot gaf', async () => { + await fiat.loadFiat([asset()]); + const o = fiat.fiatValueOrigin(asset()); + assert.deepEqual({ kind: o.kind, fiatPrice: o.fiatPrice, source: o.source }, { kind: 'live', fiatPrice: 95000, source: 'Bitvavo' }); + }); +}); diff --git a/tests/price/interval.test.mjs b/tests/price/interval.test.mjs new file mode 100644 index 0000000..2424bc3 --- /dev/null +++ b/tests/price/interval.test.mjs @@ -0,0 +1,135 @@ +// Generated, do not edit. sha256:80f49e74154e65b0c30e365129d8a9f192b6481371b78383fd6a55befe29b11a +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics', 'preferences']) { + mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) }); +} + +const facade = await import('../../client/price/providers.js'); + +let fetchCalls = []; +let antwoord = { price: 100 }; +globalThis.fetch = async (url, opties) => { + fetchCalls.push({ url, body: opties?.body }); + return { ok: true, status: 200, json: async () => antwoord }; +}; + +const { run, lastId, one } = harness.exports; +let goud; + +function provider(key, { capability = 'spot', interval = null } = {}) { + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES (?,?,?, 'https://voorbeeld.test','none',0,1,1,0,'2026-09-21T10:00:00.000Z')`, [key, key, key]); + const id = lastId(); + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'XAU')`, [id]); + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate, minIntervalSeconds) + VALUES (?, ?, 'GET', '/prijs', NULL, ?)`, [id, capability, interval]); + return id; +} +function bronkeuze(providerId, capability = 'spot') { + run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) + VALUES (?, ?, ?, 1, 'confirmed')`, [goud, capability === 'spot' ? 'spot' : 'history', providerId]); +} +function spotRij(providerId, minutenGeleden, eur) { + const moment = new Date(Date.now() - minutenGeleden * 60000).toISOString(); + run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt, createdAt, updatedAt) + VALUES (?, ?, ?, 'timestamp', ?, ?, ?)`, [goud, providerId, eur, moment, moment, moment]); +} +const asset = () => one(`SELECT * FROM Asset WHERE id = ?`, [goud]); +const dag = n => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10); + +beforeEach(() => { + harness.recreate(); + facade.clearSpotCache(); + facade.forgetSpotAnswers(); + facade.forgetCallTimes(); + fetchCalls = []; + antwoord = { price: 100 }; + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('XAU','Goud','goud','metal','g','mg',3,'unit')`); + goud = lastId(); +}); + +describe('het interval van een spot', () => { + it('vraagt binnen het interval niet opnieuw, ook niet na de tik van de app', async () => { + bronkeuze(provider('goldapi', { interval: 600 })); + await facade.liveSpotFor(asset()); + facade.clearSpotCache(); + antwoord = { price: 999 }; + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 100 }, 'het bewaarde antwoord'); + assert.equal(fetchCalls.length, 1); + }); + + it('vraagt zonder interval na de tik wél opnieuw, zoals voorheen', async () => { + bronkeuze(provider('bron')); + await facade.liveSpotFor(asset()); + facade.clearSpotCache(); + await facade.liveSpotFor(asset()); + assert.equal(fetchCalls.length, 2); + }); + + it('overleeft een herstart via de laatste spotrij van die bron', async () => { + const p = provider('goldapi', { interval: 600 }); + bronkeuze(p); + spotRij(p, 5, 123); + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 123 }); + assert.equal(fetchCalls.length, 0, 'geen verzoek'); + }); + + it('vraagt wél opnieuw als die rij ouder is dan het interval', async () => { + const p = provider('goldapi', { interval: 600 }); + bronkeuze(p); + spotRij(p, 20, 123); + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 100 }); + assert.equal(fetchCalls.length, 1); + }); + + it('telt de spotrij van een ándere bron niet mee', async () => { + const p = provider('goldapi', { interval: 600 }); + const ander = provider('metalsdev'); + bronkeuze(p); + spotRij(ander, 1, 123); + await facade.liveSpotFor(asset()); + assert.equal(fetchCalls.length, 1, 'die rij zegt niets over wanneer goldapi gevraagd is'); + }); +}); + +describe('het interval van een koers per datum', () => { + it('wacht het interval af tussen twee verzoeken', async () => { + provider('perdatum', { capability: 'history_point', interval: 1 }); + antwoord = { time: 1, price: 100 }; + const t0 = Date.now(); + await facade.fetchPriceVia('perdatum', asset(), 'history_point', dag(-3)); + await facade.fetchPriceVia('perdatum', asset(), 'history_point', dag(-2)); + assert.equal(fetchCalls.length, 2); + assert.ok(Date.now() - t0 >= 900, `het tweede verzoek wachtte (${Date.now() - t0} ms)`); + }); + + it('wacht niet zonder interval', async () => { + provider('perdatum', { capability: 'history_point' }); + antwoord = { time: 1, price: 100 }; + const t0 = Date.now(); + await facade.fetchPriceVia('perdatum', asset(), 'history_point', dag(-3)); + await facade.fetchPriceVia('perdatum', asset(), 'history_point', dag(-2)); + assert.ok(Date.now() - t0 < 500); + }); + + it('breekt het wachten af met de stopknop, zonder verzoek', async () => { + bronkeuze(provider('perdatum', { capability: 'history_point', interval: 3600 }), 'history_point'); + antwoord = { time: 1, price: 100 }; + const stop = new AbortController(); + const t0 = Date.now(); + setTimeout(() => stop.abort(), 100); + const uit = await facade.fillClosesFor(asset(), dag(-7), dag(-1), { signal: stop.signal }); + assert.equal(uit.stopped, true); + assert.equal(fetchCalls.length, 1, 'alleen het eerste verzoek ging weg'); + assert.ok(Date.now() - t0 < 2000); + }); +}); diff --git a/tests/price/intraday.test.mjs b/tests/price/intraday.test.mjs new file mode 100644 index 0000000..0de6857 --- /dev/null +++ b/tests/price/intraday.test.mjs @@ -0,0 +1,48 @@ +// Generated, do not edit. sha256:51c49028629aebea324a581fb9c02f64b8a220708011bb4270da71cc94da3e61 +import { describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +let ANTWOORD = null; +mock.module('../../client/data/market.js', { namedExports: { intradaySeries: async () => ANTWOORD } }); +mock.module('../../client/price/providers.js', { namedExports: { currentRate: async () => 2 } }); + +const { loadIntraday } = await import('../../client/price/intraday.js'); +const { t } = await import('../../client/i18n/index.js'); + +describe('loadIntraday: de bron per punt', () => { + it('maakt van een eigen bron per punt een label, en rekent de prijs om', async () => { + ANTWOORD = { status: 'ok', currency: 'USD', source: 'mempool', + points: [[1000, 10, 'bitvavo'], [2000, 11, 'mempool'], [3000, 12]] }; + const r = await loadIntraday(1, 0); + assert.deepEqual(r.points, [[1000, 20], [2000, 22], [3000, 24]]); + assert.equal(r.source, t('marketService.sourceLive', { source: 'mempool' })); + assert.equal(r.sources.get(1000), t('marketService.sourceLive', { source: 'bitvavo' })); + assert.equal(r.sources.get(2000), t('marketService.sourceLive', { source: 'mempool' })); + assert.equal(r.sources.has(3000), false, 'zonder eigen bron valt het punt terug op de bron van de reeks'); + }); + + it('een reeks zonder bron per rij geeft een lege kaart', async () => { + ANTWOORD = { status: 'ok', currency: 'USD', source: 'bitvavo', points: [[1000, 10]] }; + const r = await loadIntraday(2, 0); + assert.equal(r.sources.size, 0); + }); +}); + +describe('loadIntraday: zonder reeks geen melding', () => { + const gevallen = [ + ['de dienst is even weg', { status: 'unavailable', retryLater: true }], + ['de dienst weigert', { status: 'unavailable', key: 'marketService.unauthorized' }], + ['de app gebruikt de dienst niet', { status: 'off' }], + ]; + for (const [i, [naam, antwoord]] of gevallen.entries()) { + it(naam, async () => { + ANTWOORD = antwoord; + assert.deepEqual({ ...(await loadIntraday(10 + i, 0)) }, { points: null }); + }); + } + + it('de aanroep gooit', async () => { + ANTWOORD = null; + assert.deepEqual({ ...(await loadIntraday(20, 0)) }, { points: null }); + }); +}); diff --git a/tests/price/lightning-memo.test.mjs b/tests/price/lightning-memo.test.mjs new file mode 100644 index 0000000..e601c0f --- /dev/null +++ b/tests/price/lightning-memo.test.mjs @@ -0,0 +1,39 @@ +// Generated, do not edit. sha256:70b6740879727acfaef00d88148ac04c728d9809f7bdb7b319265eea0e2e9de7 +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +const { nwcMemo } = await import('../../client/lightning/lightning-tracker.js'); +const { memoLine } = await import('../../client/lightning/memo-line.js'); + +const ALICE = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'; +const zap = { kind: 9734, pubkey: ALICE, content: 'Top', tags: [['p', 'a'.repeat(64)]] }; + +describe('nwcMemo', () => { + it('neemt de omschrijving, en anders het zap-verzoek uit metadata.nostr', () => { + assert.equal(nwcMemo({ description: 'Koffie' }), 'Koffie'); + assert.deepEqual(nwcMemo({ description: 'b'.repeat(64), metadata: { nostr: { pubkey: ALICE, content: 'Top', tags: [] } } }), + { kind: 9734, pubkey: ALICE, content: 'Top', tags: [] }); + assert.equal(nwcMemo({ description: JSON.stringify(zap), metadata: { nostr: { pubkey: 'x' } } }), JSON.stringify(zap)); + assert.equal(nwcMemo({ metadata: { comment: 'Bedankt' } }), 'Bedankt'); + assert.equal(nwcMemo({}), null); + }); +}); + +describe('memoLine', () => { + it('noemt bij binnenkomend de afzender met het bericht', () => { + assert.deepEqual(memoLine(JSON.stringify(zap), 'in'), { text: 'Zap van npub180cv…h6w6: "Top"', title: 'Zap van npub180cv…h6w6: "Top"' }); + }); + + it('noemt bij uitgaand de ontvanger, en kort een lange regel in met de hele tekst als titel', () => { + const lang = { ...zap, content: 'x'.repeat(200) }; + const r = memoLine(JSON.stringify(lang), 'out', 40); + assert.match(r.text, /^Zap aan npub1/); + assert.equal(r.text.length, 40); + assert.ok(r.title.length > 200); + }); + + it('een gewone memo komt zoals hij is, en geen memo geeft niets', () => { + assert.equal(memoLine('Koffie', 'out').text, 'Koffie'); + assert.equal(memoLine(null, 'in'), null); + }); +}); diff --git a/tests/price/lightning-scan.test.mjs b/tests/price/lightning-scan.test.mjs new file mode 100644 index 0000000..2153d1b --- /dev/null +++ b/tests/price/lightning-scan.test.mjs @@ -0,0 +1,147 @@ +// Generated, do not edit. sha256:1e50493a927118d3db6e1a4a85d1b998ed506b6b294e4b4ef8436e2e3deb698d +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +const geschreven = { rijen: [], saldo: [], labels: [] }; +mock.module('../../client/data/wallet-txs.js', { namedExports: { + upsertWalletTxs: async (rows) => { geschreven.rijen.push(...rows); }, + labelChannelTxs: async (accountId, txids) => { geschreven.labels.push([accountId, txids]); return txids.length; }, +} }); +mock.module('../../client/data/wallet-sources.js', { namedExports: { recordSourceBalance: async (id, sats) => { geschreven.saldo.push([id, sats]); } } }); +let KANALEN = { open: [], closed: [] }; +mock.module('../../client/lightning/lnd.js', { namedExports: { + listChannels: async () => ({ channels: KANALEN.open }), + listClosedChannels: async () => ({ channels: KANALEN.closed }), +} }); + +const { walletTxRows, scanLightningSource, lightningAccountSats, isLightningSource, countMovements } = + await import('../../client/lightning/lightning-scan.js'); +const { channelTxids } = await import('../../client/lightning/lightning-tracker.js'); + +beforeEach(() => { geschreven.rijen = []; geschreven.saldo = []; geschreven.labels = []; }); + +const BRON = { id: 4, accountId: 2, kind: 'lnd', connectionId: 9 }; +const AT = '2026-10-19T10:00:00.000Z'; + +describe('walletTxRows', () => { + it('maakt van een betaling, een factuur en een forward elk een lightning-rij met een sleutel', () => { + const { rows, skipped } = walletTxRows([ + { hash: 'aa', direction: 'out', sats: 21, fee_sats: 1, at: AT }, + { hash: 'bb', direction: 'in', sats: 10_000, fee_sats: 0, at: AT }, + { hash: null, ref: 'fwd:1:2:3', direction: 'route', sats: 1, fee_sats: 0, at: AT }, + ], BRON); + assert.equal(skipped, 0); + assert.deepEqual(rows.map(r => [r.txid, r.direction, r.valueSats, r.feeSats]), + [['aa', 'out', 21, 1], ['bb', 'in', 10_000, null], ['fwd:1:2:3', 'in', 1, null]]); + for (const r of rows) { + assert.deepEqual([r.layer, r.accountId, r.sourceId, r.blockTime, r.blockHeight], ['lightning', 2, 4, AT, null]); + } + }); + + it('een betaling aan uzelf krijgt de soort self, een gewone geen', () => { + const { rows } = walletTxRows([ + { hash: 'aa', direction: 'out', sats: 5_000, fee_sats: 3, at: AT, self: true }, + { hash: 'bb', direction: 'out', sats: 21, fee_sats: 1, at: AT, self: false }, + ], BRON); + assert.deepEqual(rows.map(r => [r.txid, r.kind]), [['aa', 'self'], ['bb', null]]); + }); + + it('laat een beweging zonder sleutel of zonder moment weg, en telt hem', () => { + const { rows, skipped } = walletTxRows([ + { hash: null, direction: 'in', sats: 1, at: AT }, + { hash: 'cc', direction: 'in', sats: 1, at: null }, + ], BRON); + assert.deepEqual([rows.length, skipped], [0, 2]); + }); +}); + +describe('scanLightningSource', () => { + const tracker = { + normalizeMovements: async () => [{ hash: 'aa', direction: 'out', sats: 21, fee_sats: 1, at: AT }], + normalizeNwcMovements: async () => [{ hash: 'aa', direction: 'out', sats: 21, fee_sats: 1, at: AT }], + nodeBalance: async () => ({ channelLocal: 250_000, anchor: 900_000 }), + channelTxids: async () => new Set(['f'.repeat(64)]), + }; + + it('LND: de bewegingen in de cache, de kanaal-outbound als saldo, en het kanaallabel op het account', async () => { + const r = await scanLightningSource(BRON, { tracker }); + assert.deepEqual(r, { count: 1, skipped: 0, balanceSats: 250_000, channelLabels: 1, channelTxids: ['f'.repeat(64)] }); + assert.equal(geschreven.rijen[0].txid, 'aa'); + assert.deepEqual(geschreven.saldo, [[4, 250_000]]); + assert.deepEqual(geschreven.labels, [[2, ['f'.repeat(64)]]]); + }); + + it('NWC: het eigen saldo van de wallet, van msat naar hele sats', async () => { + const r = await scanLightningSource({ id: 7, accountId: 2, kind: 'nwc' }, + { tracker, nwcClient: { getBalance: async () => ({ balance: 21_999 }) } }); + assert.equal(r.balanceSats, 21); + assert.deepEqual(geschreven.saldo, [[7, 21]]); + }); + + it('een node die niet antwoordt: een fout, en er wordt niets geschreven', async () => { + const kapot = { ...tracker, normalizeMovements: async () => { throw new Error('node weg'); } }; + await assert.rejects(scanLightningSource(BRON, { tracker: kapot }), /node weg/); + assert.deepEqual([geschreven.rijen.length, geschreven.saldo.length], [0, 0]); + }); +}); + +describe('channelTxids', () => { + const A = 'a'.repeat(64), B = 'b'.repeat(64), C = 'c'.repeat(64); + + it('neemt de funding-tx van open en gesloten kanalen, en de closing-tx van een gesloten', async () => { + KANALEN = { open: [{ channel_point: `${A}:0` }], closed: [{ channel_point: `${B.toUpperCase()}:1`, closing_tx_hash: C }] }; + assert.deepEqual([...await channelTxids(9)].sort(), [A, B, C]); + }); + + it('een kanaal dat nog sluit (een closing-hash van nullen) heeft geen closing-tx', async () => { + KANALEN = { open: [], closed: [{ channel_point: `${B}:0`, closing_tx_hash: '0'.repeat(64) }] }; + assert.deepEqual([...await channelTxids(9)], [B]); + }); +}); + +describe('lightningAccountSats', () => { + it('telt LND, en een NWC-wallet niet ernaast want die is een deel van de outbound', () => { + assert.equal(lightningAccountSats([ + { kind: 'lnd', balanceSats: 250_000 }, { kind: 'nwc', balanceSats: 21 }, { kind: 'xpub', balanceSats: null }]), 250_000); + }); + + it('telt een NWC-wallet wel op een account zonder LND, en een nooit bijgewerkte bron als nul', () => { + assert.equal(lightningAccountSats([{ kind: 'nwc', balanceSats: 21 }, { kind: 'nwc', balanceSats: null }]), 21); + }); + + it('herkent alleen LND en NWC als lightning', () => { + assert.deepEqual(['lnd', 'nwc', 'xpub', 'address'].map(kind => isLightningSource({ kind })), [true, true, false, false]); + }); +}); + +describe('countMovements', () => { + const gekoppeld = new Set([1, 4]); + const isLinked = tx => gekoppeld.has(tx.id); + + it('een betaling op twee bronnen van één account is één beweging, gekoppeld als één rij dat is', () => { + const r = countMovements([ + { id: 1, layer: 'lightning', accountId: 2, txid: 'zap' }, + { id: 2, layer: 'lightning', accountId: 2, txid: 'zap' }, + { id: 3, layer: 'lightning', accountId: 2, txid: 'factuur' }, + ], isLinked); + assert.deepEqual(r, { total: 2, linked: 1 }); + }); + + it('dezelfde hash op een ander account, of als on-chain txid, telt apart', () => { + const r = countMovements([ + { id: 1, layer: 'lightning', accountId: 2, txid: 'zap' }, + { id: 5, layer: 'lightning', accountId: 3, txid: 'zap' }, + { id: 6, layer: 'onchain', accountId: 2, txid: 'zap' }, + ], isLinked); + assert.deepEqual(r, { total: 3, linked: 1 }); + }); + + it('een on-chain rij in twee walletlijsten telt één keer; twee on-chain rijen blijven twee', () => { + const r = countMovements([ + { id: 4, layer: 'onchain', accountId: 2, txid: 'a' }, + { id: 4, layer: 'onchain', accountId: 2, txid: 'a' }, + { id: 7, layer: 'onchain', accountId: 3, txid: 'a' }, + ], isLinked); + assert.deepEqual(r, { total: 2, linked: 1 }); + }); +}); diff --git a/tests/price/limiet.test.mjs b/tests/price/limiet.test.mjs new file mode 100644 index 0000000..859a6f5 --- /dev/null +++ b/tests/price/limiet.test.mjs @@ -0,0 +1,106 @@ +// Generated, do not edit. sha256:9871fb04293a9538a99372a76f6c50a51c7cfcd7eb2971b99eaf20efe539553d +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics']) { + mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) }); +} + +const facade = await import('../../client/price/providers.js'); + +let fetchCalls = []; +globalThis.fetch = async (url, opties) => { + const body = JSON.parse(opties.body); + fetchCalls.push(body.provider.key); + const antwoord = body.date ? { time: Math.floor(Date.parse(`${body.date}T00:00:00Z`) / 1000), price: 60 } : { price: 60 }; + return { ok: true, status: 200, json: async () => antwoord }; +}; + +const { run, lastId, one } = harness.exports; +let goud; +const maand = new Date().toISOString().slice(0, 7); + +function provider(key, { limiet = null, gebruikt = 0, rang = 1, caps = ['spot', 'history_point'] } = {}) { + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES (?,?,?, 'https://voorbeeld.test','none',0,1,1,0,'2026-09-01T10:00:00.000Z')`, [key, key, key]); + const id = lastId(); + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'XAU')`, [id]); + for (const c of caps) { + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate) VALUES (?, ?, 'GET', '/p')`, [id, c]); + run(`INSERT OR IGNORE INTO PriceSource (assetId, capability, providerId, rank, status) + VALUES (?, ?, ?, ?, 'confirmed')`, [goud, c === 'spot' ? 'spot' : 'history', id, rang]); + } + if (limiet) run(`INSERT INTO ProviderCallLimit (providerId, callsPerMonth, createdAt) VALUES (?, ?, '2026-09-01T00:00:00Z')`, [id, limiet]); + if (gebruikt) { + run(`INSERT INTO ApiCallMetric (providerId, capability, bucketMinute, okCount, failCount, sumLatencyMs, maxLatencyMs) + VALUES (?, 'spot', ?, ?, 0, 0, 0)`, [id, `${maand}-01T00:01`, gebruikt]); + } + return id; +} +const asset = () => one(`SELECT * FROM Asset WHERE id = ?`, [goud]); + +beforeEach(() => { + harness.recreate(); + facade.clearSpotCache(); + facade.forgetSeriesFetches(); + facade.forgetUsage(); + fetchCalls = []; + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('XAU','Goud','goud','metal','g','mg',3,'unit')`); + goud = lastId(); +}); + +describe('de maandlimiet', () => { + it('stuurt geen verzoek meer naar een bron op zijn limiet', async () => { + provider('goldapi', { limiet: 100, gebruikt: 100 }); + assert.equal(await facade.liveSpotFor(asset()), null); + assert.deepEqual(fetchCalls, [], 'niets naar buiten'); + }); + + it('telt de verzoeken van deze sessie mee, en weigert het eerste boven de limiet', async () => { + provider('goldapi', { limiet: 3, gebruikt: 1 }); + for (let i = 0; i < 3; i++) { facade.clearSpotCache(); await facade.liveSpotFor(asset()); } + assert.equal(fetchCalls.length, 2, 'één stond er al, dus nog twee mogen'); + }); + + it('schakelt voor de spot over op de volgende rang', async () => { + provider('goldapi', { limiet: 100, gebruikt: 100, rang: 1 }); + provider('reserve', { rang: 2 }); + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 60 }); + assert.deepEqual(fetchCalls, ['reserve']); + }); +}); + +describe('fillClosesFor met een limiet', () => { + it('gaat na de limiet van rang 1 verder bij rang 2', async () => { + provider('goldapi', { limiet: 1, rang: 1 }); + provider('reserve', { rang: 2 }); + const uit = await facade.fillClosesFor(asset(), '2026-09-01', '2026-09-03', { today: '2026-09-24' }); + assert.deepEqual(fetchCalls, ['goldapi', 'reserve', 'reserve']); + assert.equal(uit.written, 3); + assert.equal(uit.limitReached, false); + }); + + it('stopt de ronde als geen enkele bron nog tegoed heeft, in plaats van alle dagen af te lopen', async () => { + provider('goldapi', { limiet: 1 }); + const uit = await facade.fillClosesFor(asset(), '2026-01-01', '2026-09-01', { today: '2026-09-24' }); + assert.deepEqual(fetchCalls, ['goldapi']); + assert.equal(uit.limitReached, true); + assert.ok(uit.requested <= 2, `hij liep ${uit.requested} dagen af`); + }); + + it('stopt tussen twee dagen als het signaal is afgebroken', async () => { + provider('goldapi'); + const stop = new AbortController(); + stop.abort(); + const uit = await facade.fillClosesFor(asset(), '2026-09-01', '2026-09-10', { today: '2026-09-24', signal: stop.signal }); + assert.deepEqual(fetchCalls, []); + assert.equal(uit.stopped, true); + }); +}); diff --git a/tests/price/lnd-client.test.mjs b/tests/price/lnd-client.test.mjs new file mode 100644 index 0000000..0493b9e --- /dev/null +++ b/tests/price/lnd-client.test.mjs @@ -0,0 +1,25 @@ +// Generated, do not edit. sha256:e4e79ee7e120dce5fb4b9cab93e8098467cf35f61e34bbbef06650b85960c70d +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +const { testLnd } = await import('../../client/lightning/lnd.js'); + +function metAntwoord(json, fn) { + const origineel = globalThis.fetch; + globalThis.fetch = async () => new Response(JSON.stringify(json), { status: 200, headers: { 'Content-Type': 'application/json' } }); + return Promise.resolve(fn()).finally(() => { globalThis.fetch = origineel; }); +} + +describe('testLnd', () => { + it('leest alias, synchronisatie en blokhoogte uit het antwoord van LND', async () => { + await metAntwoord({ alias: 'Truman', synced_to_chain: true, block_height: 921334 }, async () => { + assert.deepEqual(await testLnd(3), { alias: 'Truman', synced: true, blockHeight: 921334 }); + }); + }); + + it('zonder blokhoogte in het antwoord: 0 en geen fout', async () => { + await metAntwoord({ alias: '', synced_to_chain: false }, async () => { + assert.deepEqual(await testLnd(3), { alias: null, synced: false, blockHeight: 0 }); + }); + }); +}); diff --git a/tests/price/momentum.test.mjs b/tests/price/momentum.test.mjs new file mode 100644 index 0000000..491b4fe --- /dev/null +++ b/tests/price/momentum.test.mjs @@ -0,0 +1,168 @@ +// Generated, do not edit. sha256:039e04e27156f848885588b6e6606b98400d17636d393358e29be3da504be7d4 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +const OPGESLAGEN = new Map(); +let SCHRIJVEN = 0; +mock.module('../../client/data/preferences.js', { + namedExports: { + getPref: async (scope, key, d) => (OPGESLAGEN.has(`${scope}|${key}`) ? structuredClone(OPGESLAGEN.get(`${scope}|${key}`)) : d), + setPref: async (scope, key, v) => { SCHRIJVEN++; OPGESLAGEN.set(`${scope}|${key}`, structuredClone(v)); }, + }, +}); + +const { MOMENTUM, addPrice, restore, tally, loadMomentum, recordSpot, momentumOf, clearMomentum } = + await import('../../client/price/momentum.js'); + +const MIN = 60000; + +function reeks(prijzen, start = 0) { + return prijzen.reduce((b, p, i) => addPrice(b, p, start + i * MIN), null); +} + +describe('addPrice en tally', () => { + it('telt een stijging en een daling als twee bewegingen', () => { + const t = tally(reeks([100, 101, 100]), 3 * MIN); + assert.deepEqual([t.up, t.down], [1, 1]); + }); + + it('een ongewijzigde prijs is geen beweging', () => { + const t = tally(reeks([100, 100, 100, 101, 101]), 5 * MIN); + assert.deepEqual([t.up, t.down], [1, 0]); + }); + + it('houdt alleen de laatste SIZE bewegingen', () => { + const prijzen = []; + for (let i = 0; i <= MOMENTUM.SIZE; i++) prijzen.push(1000 - i); + for (let i = 1; i <= 10; i++) prijzen.push(1000 - MOMENTUM.SIZE + i); + const t = tally(reeks(prijzen), prijzen.length * MIN); + assert.equal(t.up + t.down, MOMENTUM.SIZE); + assert.deepEqual([t.up, t.down], [10, MOMENTUM.SIZE - 10]); + }); + + it('een sprong na een oude referentie is geen beweging', () => { + let b = addPrice(null, 100, 0); + b = addPrice(b, 200, MOMENTUM.MAX_AGE_MS + MIN); + assert.deepEqual(b.moves, [], 'de nacht ertussen telt niet'); + assert.equal(b.last, 200, 'maar de nieuwe prijs is wel de referentie'); + }); + + it('oude bewegingen vallen uit de telling', () => { + const b = reeks([100, 101, 102]); + assert.equal(tally(b, 2 * MIN).up, 2); + assert.equal(tally(b, 2 * MIN + MOMENTUM.MAX_AGE_MS + 1).up, 0); + }); + + it('een ongeldige prijs laat de buffer staan', () => { + const b = reeks([100, 101]); + assert.equal(addPrice(b, null, 3 * MIN), b); + assert.equal(addPrice(b, 0, 3 * MIN), b); + assert.equal(addPrice(b, NaN, 3 * MIN), b); + }); +}); + +describe('de richting', () => { + it('zonder beweging geen richting', () => { + assert.equal(tally(reeks([100]), 0).tone, null); + assert.equal(tally(reeks([100, 100]), 0).tone, null); + }); + + it('één beweging is al een richting', () => { + assert.equal(tally(reeks([100, 101]), 0).tone, 'up'); + assert.equal(tally(reeks([100, 99]), 0).tone, 'down'); + }); + + it('de meerderheid wint, ook als de laatste beweging de andere kant op gaat', () => { + assert.equal(tally(reeks([100, 101, 102, 50]), 0).tone, 'up'); + }); + + it('gelijke stand houdt wat er stond: eerst omhoog dan omlaag blijft groen', () => { + const t = tally(reeks([100, 101, 100]), 0); + assert.equal(t.up, t.down); + assert.equal(t.tone, 'up'); + }); + + it('en andersom blijft rood', () => { + assert.equal(tally(reeks([100, 99, 100]), 0).tone, 'down'); + }); + + it('gelijke stand na een omslag houdt de laatste richting, niet de eerste', () => { + assert.equal(tally(reeks([100, 101, 102, 101, 100, 99, 100]), 0).tone, 'down'); + }); +}); + +describe('restore: een opgeslagen buffer terug in het geheugen', () => { + it('laat verlopen bewegingen weg en houdt de rest', () => { + const nu = MOMENTUM.MAX_AGE_MS + 10 * MIN; + const b = restore({ last: 100, lastAt: nu - MIN, moves: [{ at: 0, dir: 1 }, { at: nu - 2 * MIN, dir: -1 }] }, nu); + assert.deepEqual(b.moves, [{ at: nu - 2 * MIN, dir: -1 }]); + assert.equal(b.lastAt, nu - MIN, 'de leeftijd van de referentie gaat mee'); + }); + + it('weigert wat geen buffer is', () => { + for (const fout of [null, 'x', {}, { last: 100, lastAt: 0 }, { last: 'a', lastAt: 0, moves: [] }]) { + assert.equal(restore(fout, 0), null, JSON.stringify(fout)); + } + }); + + it('laat een kapotte beweging weg in plaats van de hele buffer', () => { + const b = restore({ last: 100, lastAt: 0, moves: [{ at: 0, dir: 1 }, { at: 0, dir: 7 }, null] }, 0); + assert.deepEqual(b.moves, [{ at: 0, dir: 1 }]); + }); +}); + +describe('de opslag per asset', () => { + beforeEach(() => { clearMomentum(); OPGESLAGEN.clear(); SCHRIJVEN = 0; }); + + it('houdt twee assets apart', async () => { + await recordSpot(1, 100, 0); + await recordSpot(1, 101, MIN); + await recordSpot(2, 100, 0); + await recordSpot(2, 99, MIN); + assert.deepEqual([momentumOf(1, MIN).up, momentumOf(1, MIN).down], [1, 0]); + assert.deepEqual([momentumOf(2, MIN).up, momentumOf(2, MIN).down], [0, 1]); + }); + + it('een asset zonder bewegingen heeft geen richting', () => { + assert.deepEqual(momentumOf(9), { up: 0, down: 0, tone: null }); + }); + + it('schrijft de buffer weg, en niet bij een ongewijzigde prijs', async () => { + await recordSpot(1, 100, 0); + await recordSpot(1, 101, MIN); + assert.equal(SCHRIJVEN, 2); + await recordSpot(1, 101, 2 * MIN); + assert.equal(SCHRIJVEN, 2, 'dezelfde prijs is geen schrijfactie'); + assert.equal(OPGESLAGEN.get('momentum|1').moves.length, 1); + }); + + it('een nieuwe sessie gaat verder met de opgeslagen buffer', async () => { + await recordSpot(1, 100, 0); + await recordSpot(1, 101, MIN); + clearMomentum(); + assert.equal(momentumOf(1, MIN).up, 0, 'nog niet geladen'); + await loadMomentum(1, 2 * MIN); + assert.equal(momentumOf(1, 2 * MIN).up, 1, 'geladen'); + await recordSpot(1, 102, 2 * MIN); + assert.equal(momentumOf(1, 2 * MIN).up, 2, 'en een nieuwe prijs telt erbij in plaats van eroverheen'); + }); + + it('een buffer van gisteren telt niet mee, en zijn referentie ook niet', async () => { + const gisteren = 0; + const vandaag = MOMENTUM.MAX_AGE_MS + 60 * MIN; + const prijzen = Array.from({ length: 9 }, (_, i) => 100 + i); + for (let i = 0; i < prijzen.length; i++) await recordSpot(1, prijzen[i], gisteren + i * MIN); + clearMomentum(); + await loadMomentum(1, vandaag); + assert.deepEqual(momentumOf(1, vandaag), { up: 0, down: 0, tone: null }); + await recordSpot(1, 50, vandaag); + assert.equal(momentumOf(1, vandaag).down, 0, 'de val sinds gisteravond is geen beweging'); + }); + + it('een onleesbare opgeslagen waarde begint gewoon leeg', async () => { + OPGESLAGEN.set('momentum|3', 'geen buffer'); + await recordSpot(3, 100, 0); + await recordSpot(3, 101, MIN); + assert.equal(momentumOf(3, MIN).up, 1); + }); +}); diff --git a/tests/price/omrekenen.test.mjs b/tests/price/omrekenen.test.mjs new file mode 100644 index 0000000..3d3dd1a --- /dev/null +++ b/tests/price/omrekenen.test.mjs @@ -0,0 +1,147 @@ +// Generated, do not edit. sha256:c62ff88846496db91f21b929a9f3c90f1478ad07262b244968b0d786ea63f4b8 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; +import { rekenvalutaUitDatabase } from '../helpers/rekenvaluta.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics', 'assets']) { + mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) }); +} +await rekenvalutaUitDatabase(mock); + +const facade = await import('../../client/price/providers.js'); + +const tijd = dag => Math.floor(Date.parse(`${dag}T00:00:00Z`) / 1000); +let antwoord; +globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => antwoord }); + +const { run, lastId, one } = harness.exports; +let goud, usd; + +function asset(symbol, kind) { + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES (?,?,?,?,?,'cent',2,'unit')`, [symbol, symbol, symbol.toLowerCase(), kind, symbol]); + return lastId(); +} +function dollarbron() { + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES ('dollar','dollar','dollar','https://voorbeeld.test','none',0,1,1,0,'2026-09-01T10:00:00.000Z')`); + const p = lastId(); + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'XAU')`, [p]); + for (const c of ['spot', 'history_point', 'daily_history']) { + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, quoteCurrency) + VALUES (?, ?, 'GET', '/p', 'USD')`, [p, c]); + } + for (const keuze of ['spot', 'history']) { + run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) + VALUES (?, ?, ?, 1, 'confirmed')`, [goud, keuze, p]); + } +} +function koers(dag, eur) { + run(`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, createdAt) + VALUES (?, ?, 'day', ?, (SELECT id FROM Provider WHERE key = 'manual'), '2026-09-24T00:00:00Z')`, + [usd, `${dag}T00:00:00Z`, eur]); +} + +beforeEach(() => { + harness.recreate(); + facade.clearSpotCache(); + facade.forgetSeriesFetches(); + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES ('manual','manual','manual','','none',0,1,1,1,'2026-09-01T10:00:00.000Z')`); + goud = asset('XAU', 'metal'); + usd = asset('USD', 'fiat'); + dollarbron(); +}); +const rij = id => one(`SELECT * FROM Asset WHERE id = ?`, [id]); + +describe('omrekenen naar euro', () => { + it('rekent een punt om met de koers van die dag, en laat het ruwe bedrag niet achter', async () => { + koers('2026-09-18', 0.85); + antwoord = { time: tijd('2026-09-18'), price: 2000 }; + const p = await facade.historicalPointForVia(rij(goud), '2026-09-18'); + assert.deepEqual(p, { time: tijd('2026-09-18'), price: 1700 }); + }); + + it('neemt in het weekend de koers van vrijdag', async () => { + koers('2026-09-18', 0.85); + antwoord = { time: tijd('2026-09-20'), price: 2000 }; + assert.equal((await facade.historicalPointForVia(rij(goud), '2026-09-20')).price, 1700); + }); + + it('geeft GEEN prijs als de koers ouder is dan de grens, en zeker geen dollars als euro', async () => { + koers('2026-09-01', 0.85); + antwoord = { time: tijd('2026-09-18'), price: 2000 }; + assert.equal(await facade.historicalPointForVia(rij(goud), '2026-09-18'), null); + }); + + it('geeft geen prijs zonder asset voor die munt', async () => { + run(`DELETE FROM Asset WHERE id = ?`, [usd]); + antwoord = { time: tijd('2026-09-18'), price: 2000 }; + assert.equal(await facade.historicalPointForVia(rij(goud), '2026-09-18'), null); + }); + + it('laat in een reeks de dagen zonder koers vallen en rekent de rest om', async () => { + koers('2026-09-17', 0.8); + antwoord = [ + { time: tijd('2026-08-01'), price: 1900 }, + { time: tijd('2026-09-17'), price: 2000 }, + { time: tijd('2026-09-18'), price: 2100 }, + ]; + const reeks = await facade.fetchPriceVia('dollar', rij(goud), 'daily_history'); + assert.deepEqual(reeks, [ + { time: tijd('2026-09-17'), price: 1600 }, + { time: tijd('2026-09-18'), price: 1680 }, + ]); + }); + + it('rekent een spot om met de nieuwste koers', async () => { + const gisteren = new Date(Date.now() - 86400000).toISOString().slice(0, 10); + koers(gisteren, 0.9); + antwoord = { price: 2000 }; + assert.deepEqual(await facade.liveSpotFor(rij(goud)), { price: 1800 }); + }); + + it('rateSourceFor: geen bron zonder keten, wel met een actieve bron, niet met een uitgezette', async () => { + assert.equal(await facade.rateSourceFor('USD'), null, 'geen keten'); + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES ('ecb','ECB','ecb','https://voorbeeld.test','none',0,1,1,0,'2026-09-01T10:00:00.000Z')`); + const ecb = lastId(); + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'USD')`, [ecb]); + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate) VALUES (?, 'daily_history', 'GET', '/r')`, [ecb]); + run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) + VALUES (?, 'history', ?, 1, 'confirmed')`, [usd, ecb]); + assert.equal((await facade.rateSourceFor('USD')).key, 'ecb'); + run(`UPDATE Provider SET isActive = 0 WHERE id = ?`, [ecb]); + assert.equal(await facade.rateSourceFor('USD'), null, 'uitgezet'); + assert.equal(await facade.rateSourceFor('EUR'), null, 'de rekenvaluta wordt niet omgerekend'); + }); + + it('currentRate: de koers van vandaag uit de reeks, 1 voor de rekenvaluta, en niets zonder koers', async () => { + const dag = n => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10); + assert.equal(await facade.currentRate('EUR'), 1); + assert.equal(await facade.currentRate('USD'), null, 'nog geen koers'); + koers(dag(30), 0.8); + assert.equal(await facade.currentRate('USD'), null, 'een koers van een maand geleden is geen koers van vandaag'); + koers(dag(1), 0.86); + assert.equal(await facade.currentRate('USD'), 0.86); + assert.equal(await facade.currentRate('GBP'), null, 'geen asset voor die munt'); + }); + + it('rekent een andere munt dan de dollar om met de koers van dát fiat-asset', async () => { + run(`UPDATE ProviderEndpoint SET quoteCurrency = 'GBP'`); + const gbp = asset('GBP', 'fiat'); + run(`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, createdAt) + VALUES (?, '2026-09-18T00:00:00Z', 'day', 1.2, (SELECT id FROM Provider WHERE key = 'manual'), '2026-09-24T00:00:00Z')`, [gbp]); + antwoord = { time: tijd('2026-09-18'), price: 1000 }; + assert.deepEqual(await facade.historicalPointForVia(rij(goud), '2026-09-18'), + { time: tijd('2026-09-18'), price: 1200 }); + }); +}); diff --git a/tests/price/reeks-per-dag.test.mjs b/tests/price/reeks-per-dag.test.mjs new file mode 100644 index 0000000..cb3b9b6 --- /dev/null +++ b/tests/price/reeks-per-dag.test.mjs @@ -0,0 +1,71 @@ +// Generated, do not edit. sha256:e3f07d60b1d7716de10f35f932ae6f2f2e3db5a07433459721f39ceee41a1a6d +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics']) { + mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) }); +} + +const facade = await import('../../client/price/providers.js'); + +const gisteren = new Date(Date.now() - 86400000).toISOString().slice(0, 10); +const tijd = dag => Math.floor(Date.parse(`${dag}T00:00:00Z`) / 1000); +let fetchCalls = 0; +let faalt = false; +globalThis.fetch = async () => { + fetchCalls++; + if (faalt) throw new Error('geen netwerk in deze test'); + return { ok: true, status: 200, json: async () => [{ time: tijd(gisteren), price: 0.87 }] }; +}; + +const { run, lastId, one } = harness.exports; +let usd; + +beforeEach(() => { + harness.recreate(); + facade.forgetSeriesFetches(); + fetchCalls = 0; + faalt = false; + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('USD','Dollar','dollar','fiat','USD','cent',2,'unit')`); + usd = lastId(); + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES ('ecb','ECB','ecb','https://voorbeeld.test','none',0,1,1,0,'2026-09-01T10:00:00.000Z')`); + const p = lastId(); + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'USD')`, [p]); + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate) VALUES (?, 'daily_history', 'GET', '/reeks')`, [p]); + run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) + VALUES (?, 'history', ?, 1, 'confirmed')`, [usd, p]); +}); +const asset = () => one(`SELECT * FROM Asset WHERE id = ?`, [usd]); + +describe('een reeks die vandaag niet haalt', () => { + it('gaat op één dag maar één keer naar buiten', async () => { + await facade.ensurePriceHistoryFor(asset()); + await facade.ensurePriceHistoryFor(asset()); + await facade.ensurePriceHistoryFor(asset()); + assert.equal(fetchCalls, 1); + assert.equal(one(`SELECT fiatPrice FROM PriceHistory WHERE assetId = ?`, [usd]).fiatPrice, 0.87, 'en de reeks staat er'); + }); + + it('probeert het na een MISLUKTE poging gewoon opnieuw', async () => { + faalt = true; + await assert.rejects(() => facade.ensurePriceHistoryFor(asset())); + faalt = false; + await facade.ensurePriceHistoryFor(asset()); + assert.equal(fetchCalls, 2); + }); + + it('haalt na een bronwijziging wel opnieuw op', async () => { + await facade.ensurePriceHistoryFor(asset()); + facade.forgetSeriesFetches(); + await facade.ensurePriceHistoryFor(asset()); + assert.equal(fetchCalls, 2); + }); +}); diff --git a/tests/price/scan-movements.test.mjs b/tests/price/scan-movements.test.mjs new file mode 100644 index 0000000..82344a4 --- /dev/null +++ b/tests/price/scan-movements.test.mjs @@ -0,0 +1,54 @@ +// Generated, do not edit. sha256:5fcf7092848d33c8b3e377e39e0ea6329b71d804e01a41ef65f10de8dcfb1874 +import { describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +const TAP = 'bc1p-taproot-input'; +const SEG = 'bc1q-segwit-wisselgeld'; +const KANAAL = 'bc1q-kanaal-multisig'; + +const FUNDING = { + txid: 'f'.repeat(64), fee: 1000, + status: { confirmed: true, block_height: 900_000, block_time: 1_760_000_000 }, + vin: [{ prevout: { scriptpubkey_address: TAP, value: 100_000 } }], + vout: [{ scriptpubkey_address: KANAAL, value: 70_000 }, { scriptpubkey_address: SEG, value: 29_000 }], +}; +mock.module('../../client/price/btc-explorer.js', { namedExports: { + addressTxs: async () => [FUNDING], + fetchJson: async () => ({}), + explorerBase: async () => 'https://explorer.test', +} }); + +const { scanMovements } = await import('../../client/price/wallet-scanner.js'); + +describe('scanMovements over twee bronnen van één account', () => { + it('de SegWit-bron ziet met de Taproot-adressen erbij de kanaalopening als uitgaand', async () => { + const { rows } = await scanMovements([SEG], 1, 2, null, { ownAddresses: [TAP, SEG] }); + assert.deepEqual(rows.map(r => [r.direction, r.valueSats, r.feeSats]), [['out', 70_000, 1000]]); + }); + + it('de Taproot-bron komt op precies dezelfde beweging uit', async () => { + const { rows } = await scanMovements([TAP], 1, 3, null, { ownAddresses: [TAP, SEG] }); + assert.deepEqual(rows.map(r => [r.direction, r.valueSats, r.feeSats]), [['out', 70_000, 1000]]); + }); + + it('zonder de adressen van het account ziet de SegWit-bron alleen het wisselgeld: de oude fout', async () => { + const { rows } = await scanMovements([SEG], 1, 2, null); + assert.deepEqual(rows.map(r => [r.direction, r.valueSats]), [['in', 29_000]]); + }); + + it('ook andersom: een SegWit-input met het wisselgeld naar Taproot', async () => { + FUNDING.vin = [{ prevout: { scriptpubkey_address: SEG, value: 100_000 } }]; + FUNDING.vout = [{ scriptpubkey_address: KANAAL, value: 70_000 }, { scriptpubkey_address: TAP, value: 29_000 }]; + try { + for (const [bron, eigen] of [[[SEG], 2], [[TAP], 3]]) { + const { rows } = await scanMovements(bron, 1, eigen, null, { ownAddresses: [TAP, SEG] }); + assert.deepEqual(rows.map(r => [r.direction, r.valueSats, r.feeSats]), [['out', 70_000, 1000]]); + } + const { rows: oud } = await scanMovements([TAP], 1, 3, null); + assert.deepEqual(oud.map(r => [r.direction, r.valueSats]), [['in', 29_000]], 'per bron genet: alleen het wisselgeld'); + } finally { + FUNDING.vin = [{ prevout: { scriptpubkey_address: TAP, value: 100_000 } }]; + FUNDING.vout = [{ scriptpubkey_address: KANAAL, value: 70_000 }, { scriptpubkey_address: SEG, value: 29_000 }]; + } + }); +}); diff --git a/tests/price/slotkoersen.test.mjs b/tests/price/slotkoersen.test.mjs new file mode 100644 index 0000000..efc6fc7 --- /dev/null +++ b/tests/price/slotkoersen.test.mjs @@ -0,0 +1,195 @@ +// Generated, do not edit. sha256:17aa65162f57d2f6155970591db45673bb76363e851935e6d5a324bd4bab456f +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; +import { CONFIG } from '../../shared/config.js'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics']) { + mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) }); +} + +const facade = await import('../../client/price/providers.js'); + +const VANDAAG = '2026-09-24'; + +let fetchCalls = []; +let faalt = false; +let reeksTot = null; +let fouten = new Map(); +const geenKoers = { key: 'integrations.price.noValidPrice', params: { provider: 'perdatum' } }; +const http = status => ({ key: 'integrations.price.httpFailed', params: { provider: 'perdatum', capability: 'history_point', status, detail: '' } }); +function stub(url, opties) { + const body = JSON.parse(opties.body); + fetchCalls.push(body); + if (faalt) throw new Error('geen netwerk in deze test'); + if (fouten.has(body.date)) { + const f = fouten.get(body.date); + return { ok: false, status: 502, json: async () => ({ error: 'geen koers', ...f }) }; + } + if (body.capability === 'daily_history') { + const rijen = ['2026-09-18', '2026-09-19', '2026-09-20'].filter(d => d <= reeksTot) + .map(d => ({ time: Math.floor(Date.parse(`${d}T00:00:00Z`) / 1000), price: 80 })); + return { ok: true, status: 200, json: async () => rijen }; + } + const time = Math.floor(Date.parse(`${body.date}T00:00:00Z`) / 1000); + return { ok: true, status: 200, json: async () => ({ time, price: 90 }) }; +} +globalThis.fetch = async (url, opties) => stub(url, opties); + +const { run, all, one, lastId } = harness.exports; +let goud, perDatum; + +function provider(key, capabilities, { isSystem = 0 } = {}) { + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES (?,?,?, 'https://voorbeeld.test','none',0,1,1,?,'2026-09-01T10:00:00.000Z')`, [key, key, key, isSystem]); + const id = lastId(); + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'XAU')`, [id]); + for (const c of capabilities) { + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate) + VALUES (?, ?, 'GET', '/prijs', NULL)`, [id, c]); + } + return id; +} +function bronkeuze(providerId, capability) { + const keuze = capability === 'spot' ? 'spot' : 'history'; + run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) + VALUES (?, ?, ?, (SELECT COALESCE(MAX(rank), 0) + 1 FROM PriceSource WHERE assetId = ? AND capability = ?), 'confirmed')`, + [goud, keuze, providerId, goud, keuze]); +} +function dagrij(providerId, dag, eur, updatedAt) { + run(`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, updatedAt) + VALUES (?, ?, 'day', ?, ?, ?)`, [goud, `${dag}T00:00:00Z`, eur, providerId, updatedAt]); +} +const asset = () => one(`SELECT * FROM Asset WHERE id = ?`, [goud]); +const gevraagd = () => fetchCalls.map(c => c.date); +const koersOp = dag => one(`SELECT fiatPrice FROM PriceHistory WHERE assetId = ? AND observedAt = ? AND providerId = ?`, + [goud, `${dag}T00:00:00Z`, perDatum])?.fiatPrice; + +beforeEach(() => { + harness.recreate(); + facade.forgetSeriesFetches(); + fetchCalls = []; + faalt = false; + reeksTot = null; + fouten = new Map(); + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('XAU','Goud','goud','metal','g','mg',3,'unit')`); + goud = lastId(); + perDatum = provider('perdatum', ['spot', 'history_point']); + bronkeuze(perDatum, 'history_point'); +}); + +describe('fillClosesFor', () => { + it('vervangt een rij die OP zijn eigen dag geschreven is, en laat een slotkoers staan', async () => { + dagrij(perDatum, '2026-09-21', 70, '2026-09-21T12:00:00.000Z'); + dagrij(perDatum, '2026-09-22', 80, '2026-09-23T08:00:00.000Z'); + const uit = await facade.fillClosesFor(asset(), '2026-09-21', '2026-09-23', { today: VANDAAG }); + assert.deepEqual(gevraagd(), ['2026-09-21', '2026-09-23'], 'de slotkoers van de 22e wordt niet opnieuw gevraagd'); + assert.deepEqual(uit, { requested: 2, written: 2, limitReached: false, stopped: false }); + assert.equal(koersOp('2026-09-21'), 90, 'de middagkoers is overschreven'); + assert.equal(koersOp('2026-09-22'), 80, 'en de slotkoers staat er nog'); + }); + + it('vraagt vandaag nooit, ook niet als het bereik tot vandaag loopt', async () => { + await facade.fillClosesFor(asset(), '2026-09-23', VANDAAG, { today: VANDAAG }); + assert.deepEqual(gevraagd(), ['2026-09-23']); + }); + + it('laat een koers op manual staan, ook als hij op dezelfde dag is ingevoerd', async () => { + const manual = provider('manual', [], { isSystem: 1 }); + dagrij(manual, '2026-09-22', 315.5, '2026-09-22T09:00:00.000Z'); + await facade.fillClosesFor(asset(), '2026-09-22', '2026-09-22', { today: VANDAAG }); + assert.deepEqual(gevraagd(), [], 'een met de hand ingevoerde koers is een bewuste keuze'); + }); + + it('haalt bij een bron met een reeks eerst de reeks, en vraagt alleen de dagen die hij mist', async () => { + bronkeuze(provider('reeks', ['daily_history']), 'daily_history'); + reeksTot = '2026-09-20'; + const uit = await facade.fillClosesFor(asset(), '2026-09-18', '2026-09-23', { today: VANDAAG }); + assert.deepEqual(fetchCalls.map(c => c.capability), ['daily_history', 'history_point', 'history_point', 'history_point']); + assert.deepEqual(gevraagd().slice(1), ['2026-09-21', '2026-09-22', '2026-09-23']); + assert.deepEqual(uit, { requested: 3, written: 3, limitReached: false, stopped: false }); + }); + + it('vraagt niets per datum als de reeks het hele bereik dekt', async () => { + bronkeuze(provider('reeks', ['daily_history']), 'daily_history'); + reeksTot = '2026-09-20'; + await facade.fillClosesFor(asset(), '2026-09-18', '2026-09-20', { today: VANDAAG }); + assert.deepEqual(fetchCalls.map(c => c.capability), ['daily_history']); + }); + + it('vraagt bij metaal geen zaterdag en zondag', async () => { + const uit = await facade.fillClosesFor(asset(), '2026-09-18', '2026-09-21', { today: VANDAAG }); + assert.deepEqual(gevraagd(), ['2026-09-18', '2026-09-21'], 'vrijdag en maandag, het weekend ertussen niet'); + assert.equal(uit.requested, 2); + assert.equal(facade.daysToAsk(asset(), '2026-09-18', '2026-09-21'), 2); + }); + + it('vraagt bij crypto het weekend wél, want die markt is dan open', async () => { + run(`UPDATE Asset SET kind = 'crypto' WHERE id = ?`, [goud]); + await facade.fillClosesFor(asset(), '2026-09-18', '2026-09-21', { today: VANDAAG }); + assert.deepEqual(gevraagd(), ['2026-09-18', '2026-09-19', '2026-09-20', '2026-09-21']); + assert.equal(facade.daysToAsk(asset(), '2026-09-18', '2026-09-21'), 4); + }); + + it('slikt een mislukte dag en laat de oude rij staan', async () => { + dagrij(perDatum, '2026-09-22', 70, '2026-09-22T12:00:00.000Z'); + faalt = true; + const uit = await facade.fillClosesFor(asset(), '2026-09-22', '2026-09-23', { today: VANDAAG }); + assert.deepEqual(uit, { requested: 2, written: 0, limitReached: false, stopped: false }); + assert.equal(koersOp('2026-09-22'), 70, 'een netwerkfout wist niets'); + assert.deepEqual(onthouden(), [], 'en een netwerkfout is geen dag zonder koers'); + }); +}); + +const onthouden = () => all(`SELECT day FROM PriceAbsence ORDER BY day`).map(r => r.day); + +describe('fillClosesFor: een dag zonder koers', () => { + it('onthoudt de dag en vraagt hem de volgende ronde niet meer', async () => { + fouten.set('2026-09-22', geenKoers); + const eerste = await facade.fillClosesFor(asset(), '2026-09-21', '2026-09-23', { today: VANDAAG }); + assert.equal(eerste.written, 2); + assert.deepEqual(onthouden(), ['2026-09-22']); + fetchCalls = []; + const tweede = await facade.fillClosesFor(asset(), '2026-09-21', '2026-09-23', { today: VANDAAG }); + assert.deepEqual(gevraagd(), [], 'de 21e en 23e zijn slot, de 22e is onthouden'); + assert.equal(tweede.requested, 0); + }); + + it('telt een 404 van de bron mee, en een 429 of 500 niet', async () => { + fouten.set('2026-09-21', http(404)); + fouten.set('2026-09-22', http(429)); + fouten.set('2026-09-23', http(500)); + await facade.fillClosesFor(asset(), '2026-09-18', '2026-09-23', { today: VANDAAG }); + assert.deepEqual(onthouden(), ['2026-09-21']); + }); + + it('onthoudt niets van een bron die in die ronde geen enkele koers gaf', async () => { + for (const d of ['2026-09-21', '2026-09-22', '2026-09-23']) fouten.set(d, geenKoers); + await facade.fillClosesFor(asset(), '2026-09-21', '2026-09-23', { today: VANDAAG }); + assert.deepEqual(onthouden(), []); + fetchCalls = []; + await facade.fillClosesFor(asset(), '2026-09-21', '2026-09-23', { today: VANDAAG }); + assert.equal(fetchCalls.length, 3, 'de volgende ronde vraagt ze gewoon opnieuw'); + }); +}); + +describe('refreshRecentCloses', () => { + it(`vraagt de werkdagen van de laatste ${CONFIG.PRICE_HISTORY.RECENT_CLOSE_DAYS} dagen tot en met gisteren, en de tweede ronde niets`, async () => { + await facade.refreshRecentCloses([asset()], { today: VANDAAG }); + const dagen = gevraagd(); + assert.equal(dagen.length, CONFIG.PRICE_HISTORY.RECENT_CLOSE_DAYS - 4); + assert.equal(dagen[0], '2026-09-10'); + assert.equal(dagen.at(-1), '2026-09-23', 'gisteren is de laatste dag'); + + fetchCalls = []; + await facade.refreshRecentCloses([asset()], { today: VANDAAG }); + assert.equal(fetchCalls.length, 0); + assert.equal(all(`SELECT id FROM PriceHistory WHERE assetId = ?`, [goud]).length, CONFIG.PRICE_HISTORY.RECENT_CLOSE_DAYS - 4); + }); +}); diff --git a/tests/price/spot-cache.test.mjs b/tests/price/spot-cache.test.mjs new file mode 100644 index 0000000..983d930 --- /dev/null +++ b/tests/price/spot-cache.test.mjs @@ -0,0 +1,129 @@ +// Generated, do not edit. sha256:f2615dafd4b52aacda7e6453bb0c703f6c9c0f0f88c1579820810800ed320281 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics', 'preferences']) { + mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) }); +} + +const facade = await import('../../client/price/providers.js'); +const { momentumOf, clearMomentum } = await import('../../client/price/momentum.js'); + +let fetchCalls = []; +let antwoord = { EUR: 100, USD: null }; +globalThis.fetch = async (url, opties) => { + fetchCalls.push({ url, body: opties?.body }); + return { ok: true, status: 200, json: async () => antwoord }; +}; + +const { run, lastId, one } = harness.exports; +let btc, goud; + +function provider(key, symbolen) { + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES (?,?,?, 'https://voorbeeld.test','none',0,1,1,0,'2026-09-21T10:00:00.000Z')`, [key, key, key]); + const id = lastId(); + for (const s of symbolen) run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?,?)`, [id, s]); + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate) + VALUES (?, 'spot', 'GET', '/prijs', NULL)`, [id]); + return id; +} +function bronkeuze(providerId, assetId) { + run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) + VALUES (?, 'spot', ?, 1, 'confirmed')`, [assetId, providerId]); +} +const assetRij = id => one(`SELECT * FROM Asset WHERE id = ?`, [id]); + +beforeEach(() => { + harness.recreate(); + facade.clearSpotCache(); + fetchCalls = []; + antwoord = { EUR: 100, USD: null }; + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'unit')`); + btc = lastId(); + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('XAU','Goud','goud','metal','g','mg',3,'unit')`); + goud = lastId(); +}); + +describe('de spot wordt gecached', () => { + it('doet ÉÉN verzoek voor twee vragen om dezelfde spot', async () => { + const p = provider('bron', ['BTC']); + bronkeuze(p, btc); + const eerste = await facade.liveSpotFor(assetRij(btc)); + const tweede = await facade.liveSpotFor(assetRij(btc)); + assert.deepEqual(eerste, { EUR: 100, USD: null }); + assert.deepEqual(tweede, eerste, 'hetzelfde antwoord'); + assert.equal(fetchCalls.length, 1, 'en maar één keer naar buiten'); + }); + + it('haalt na leegmaken WEL opnieuw op', async () => { + const p = provider('bron', ['BTC']); + bronkeuze(p, btc); + await facade.liveSpotFor(assetRij(btc)); + facade.clearSpotCache(); + antwoord = { EUR: 111, USD: null }; + assert.deepEqual(await facade.liveSpotFor(assetRij(btc)), { EUR: 111, USD: null }, + 'de nieuwe koers, dus er is echt opnieuw gevraagd'); + assert.equal(fetchCalls.length, 2); + }); + + it('houdt twee assets apart, en vraagt één asset maar één keer', async () => { + bronkeuze(provider('bron-btc', ['BTC']), btc); + bronkeuze(provider('bron-goud', ['XAU']), goud); + await facade.liveSpotFor(assetRij(btc)); + await facade.liveSpotFor(assetRij(goud)); + assert.equal(fetchCalls.length, 2, 'twee assets zijn twee vragen'); + await facade.liveSpotFor(assetRij(btc)); + assert.equal(fetchCalls.length, 2, 'en hetzelfde asset nog eens is geen nieuwe vraag'); + }); + + it('onthoudt welke bron de spot gaf, per asset, en vergeet dat met de cache', async () => { + bronkeuze(provider('bitvavo', ['BTC']), btc); + assert.equal(facade.lastSpotSource(assetRij(btc)), null); + await facade.liveSpotFor(assetRij(btc)); + assert.equal(facade.lastSpotSource(assetRij(btc)), 'bitvavo'); + assert.equal(facade.lastSpotSource(assetRij(goud)), null, 'een ander asset vroeg niets'); + facade.clearSpotCache(); + assert.equal(facade.lastSpotSource(assetRij(btc)), null); + }); + + it('voedt de korte trend met elk vers antwoord', async () => { + clearMomentum(); + bronkeuze(provider('bron', ['BTC']), btc); + antwoord = { price: 100 }; + await facade.liveSpotFor(assetRij(btc)); + facade.clearSpotCache(); + antwoord = { price: 110 }; + await facade.liveSpotFor(assetRij(btc)); + await new Promise(r => setImmediate(r)); + assert.deepEqual([momentumOf(btc).up, momentumOf(btc).down], [1, 0]); + const opgeslagen = JSON.parse(one(`SELECT value FROM Preference WHERE scope = 'momentum' AND key = ?`, [String(btc)]).value); + assert.equal(opgeslagen.last, 110); + assert.equal(opgeslagen.moves.length, 1); + assert.ok(Number.isFinite(opgeslagen.moves[0].at), 'elke beweging draagt zijn tijd'); + }); + + it('bewaart een MISLUKTE poging niet', async () => { + const p = provider('bron', ['BTC']); + bronkeuze(p, btc); + globalThis.fetch = async (url, opties) => { + fetchCalls.push({ url, body: opties?.body }); + throw new Error('geen netwerk in deze test'); + }; + assert.equal(await facade.liveSpotFor(assetRij(btc)), null); + assert.equal(await facade.liveSpotFor(assetRij(btc)), null); + assert.equal(fetchCalls.length, 2, 'twee pogingen, want er is niets bewaard'); + globalThis.fetch = async (url, opties) => { + fetchCalls.push({ url, body: opties?.body }); + return { ok: true, status: 200, json: async () => antwoord }; + }; + }); +}); diff --git a/tests/price/vaste-koers.test.mjs b/tests/price/vaste-koers.test.mjs new file mode 100644 index 0000000..4ad66bd --- /dev/null +++ b/tests/price/vaste-koers.test.mjs @@ -0,0 +1,143 @@ +// Generated, do not edit. sha256:762784c61e8bb974ae7fcd7e502db20b12fd6098e6d5292355f3d348d4e202f7 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics', 'preferences']) { + mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) }); +} + +const facade = await import('../../client/price/providers.js'); + +let fetchCalls = []; +globalThis.fetch = async (url, opties) => { + fetchCalls.push({ url, body: opties?.body }); + throw new Error('geen netwerk in deze test'); +}; + +const { run, all, one, lastId } = harness.exports; +let eur; + +function vasteKoersProvider({ capabilities = ['spot', 'history_point'], koers = 1.0 } = {}) { + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES ('fixed','Vaste koers','vaste koers','','none',0,1,1,1,'2026-08-20T10:00:00.000Z')`); + const id = lastId(); + run(`INSERT INTO ProviderFixedRate (providerId, fiatPrice) VALUES (?, ?)`, [id, koers]); + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'EUR')`, [id]); + for (const capability of capabilities) { + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate) + VALUES (?, ?, 'GET', '', NULL)`, [id, capability]); + } + return id; +} +function bronkeuze(providerId, capabilities) { + for (const capability of capabilities) { + run(`INSERT OR IGNORE INTO PriceSource (assetId, capability, providerId, rank, status) + VALUES (?, ?, ?, 1, 'confirmed')`, [eur, capability === 'spot' ? 'spot' : 'history', providerId]); + } +} +const asset = () => one(`SELECT * FROM Asset WHERE id = ?`, [eur]); +const prijsrijen = () => all( + `SELECT Asset.symbol, Provider.key AS bron, PriceHistory.fiatPrice, PriceHistory.resolution + FROM PriceHistory + JOIN Asset ON Asset.id = PriceHistory.assetId + JOIN Provider ON Provider.id = PriceHistory.providerId + ORDER BY PriceHistory.resolution, PriceHistory.observedAt`); + +beforeEach(() => { + harness.recreate(); + facade.clearSpotCache(); + fetchCalls = []; + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('EUR','Euro','euro','fiat','EUR','cent',2,'unit')`); + eur = lastId(); +}); + +describe('vaste koers: de spot komt uit de provider en niet van het netwerk', () => { + it('geeft 1,00 als spot, in het ene prijsveld', () => { + bronkeuze(vasteKoersProvider(), ['spot']); + return facade.liveSpotFor(asset()).then(spot => { + assert.deepEqual(spot, { price: 1 }); + assert.deepEqual(fetchCalls, [], 'er is niets naar buiten gegaan'); + }); + }); + + it('meet geen api-call, want er is geen api-call', async () => { + bronkeuze(vasteKoersProvider(), ['spot']); + await facade.liveSpotFor(asset()); + await facade.liveSpotFor(asset()); + assert.deepEqual(all(`SELECT * FROM ApiCallMetric`), []); + }); + + it('legt de huidige waarde wél vast als tijdstip-punt', async () => { + bronkeuze(vasteKoersProvider(), ['spot']); + await facade.liveSpotFor(asset()); + assert.deepEqual(prijsrijen(), + [{ symbol: 'EUR', bron: 'fixed', fiatPrice: 1, resolution: 'timestamp' }]); + }); + + it('laat een gewone provider wél naar buiten gaan', async () => { + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, + isActive, isSeed, isSystem, createdAt) + VALUES ('mempool','mempool','mempool','https://voorbeeld.test','none',0,1,1,0,'2026-08-20T10:00:00.000Z')`); + const id = lastId(); + run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'EUR')`, [id]); + run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate) + VALUES (?, 'spot', 'GET', '/prijs', NULL)`, [id]); + bronkeuze(id, ['spot']); + + assert.equal(await facade.liveSpotFor(asset()), null, 'de stub gooit, dus geen koers'); + assert.equal(fetchCalls.length, 1, 'en er is wél een verzoek gedaan'); + assert.equal(fetchCalls[0].url, '/api/price/call'); + }); +}); + +describe('vaste koers: de historie', () => { + it('vult price_history met een dagpunt van 1,00 via de spot-keten', async () => { + bronkeuze(vasteKoersProvider(), ['spot', 'history_point']); + await facade.ensurePriceHistoryFor(asset()); + assert.deepEqual(prijsrijen().filter(r => r.resolution === 'day'), + [{ symbol: 'EUR', bron: 'fixed', fiatPrice: 1, resolution: 'day' }]); + assert.deepEqual(fetchCalls, []); + }); + + it('levert een punt voor élke datum uit het verleden, met het tijdstip van die dag erbij', async () => { + bronkeuze(vasteKoersProvider(), ['history_point']); + assert.deepEqual(await facade.historicalPointForVia(asset(), '2019-12-31'), + { price: 1, time: Date.parse('2019-12-31T00:00:00Z') / 1000 }); + assert.deepEqual(await facade.historicalPointForVia(asset(), '2026-01-01'), + { price: 1, time: Date.parse('2026-01-01T00:00:00Z') / 1000 }); + assert.deepEqual(fetchCalls, []); + }); + + it('slaat een punt op voor elke gevraagde peildatum', async () => { + bronkeuze(vasteKoersProvider(), ['history_point']); + await facade.ensurePointsAtFor(asset(), ['2025-01-01', '2026-01-01']); + assert.deepEqual( + all(`SELECT observedAt, fiatPrice, resolution FROM PriceHistory ORDER BY observedAt`), + [{ observedAt: '2025-01-01T00:00:00Z', fiatPrice: 1, resolution: 'day' }, + { observedAt: '2026-01-01T00:00:00Z', fiatPrice: 1, resolution: 'day' }]); + assert.deepEqual(fetchCalls, []); + }); + + it('heeft geen daggrid, en zegt dat ook', async () => { + bronkeuze(vasteKoersProvider(), ['spot', 'history_point']); + assert.equal(await facade.hasDailyGridFor(asset()), false); + }); + + it('weigert een reeks te verzinnen als een vaste koers tóch daily_history claimt', async () => { + bronkeuze(vasteKoersProvider({ capabilities: ['daily_history'] }), ['daily_history']); + await assert.rejects(() => facade.ensurePriceHistoryFor(asset()), /vaste koers/); + assert.deepEqual(prijsrijen(), [], 'en er staat niets verzonnen in de historie'); + }); + + it('gebruikt de koers uit de tabel en niet een 1 uit de code', async () => { + bronkeuze(vasteKoersProvider({ koers: 0.5 }), ['spot']); + assert.deepEqual(await facade.liveSpotFor(asset()), { price: 0.5 }); + }); +}); diff --git a/tests/price/wallet-scanner.test.mjs b/tests/price/wallet-scanner.test.mjs new file mode 100644 index 0000000..7a0395e --- /dev/null +++ b/tests/price/wallet-scanner.test.mjs @@ -0,0 +1,44 @@ +// Generated, do not edit. sha256:5b9dd8675758ba64b85bca5e16a855027af470810bba49c97e909a501ef3fbbd +import { describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { firstReceiveAddress } = await import('../../client/price/wallet-scanner.js'); + +const VECTOREN = [ + ['p2pkh', + 'xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj', + '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA'], + ['p2sh-p2wpkh', + 'ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP', + '37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf'], + ['p2wpkh', + 'zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs', + 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu'], + ['p2tr', + 'xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ', + 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr'], +]; + +describe('firstReceiveAddress: BIP-testvectoren via de gevendorde noble/scure-bundel', () => { + for (const [scriptType, extKey, verwacht] of VECTOREN) { + it(`leidt het eerste ontvangstadres af voor ${scriptType}`, () => { + assert.equal(firstReceiveAddress(extKey, scriptType), verwacht); + }); + } + + it('gooit bij een sleutel met een kapotte checksum (geen stil verkeerd adres)', () => { + const [, zpub] = VECTOREN[2]; + const kapot = zpub.slice(0, -1) + (zpub.endsWith('s') ? 't' : 's'); + assert.throws(() => firstReceiveAddress(kapot, 'p2wpkh')); + }); + + it('valt bij een onbekend scripttype terug op p2pkh in plaats van te gooien', () => { + const [, xpub, p2pkhAdres] = VECTOREN[0]; + assert.equal(firstReceiveAddress(xpub, 'p2wsh-bestaat-niet'), p2pkhAdres); + }); +}); diff --git a/tests/price/zap.test.mjs b/tests/price/zap.test.mjs new file mode 100644 index 0000000..89d66d2 --- /dev/null +++ b/tests/price/zap.test.mjs @@ -0,0 +1,64 @@ +// Generated, do not edit. sha256:f9a7d616a0b0b96337b4d9aa3810013fc2a8851cf64050bbc67d182adee97bf4 +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { zapContext, compactMemo, npub, npubShort, memoNote, MEMO_MAX } from '../../shared/zap.js'; + +const ALICE = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'; +const BOB = 'a'.repeat(64); +const verzoek = (extra = {}) => JSON.stringify({ + kind: 9734, pubkey: ALICE, content: 'Mooi stuk!', created_at: 1, + tags: [['p', BOB], ['e', 'b'.repeat(64)], ['relays', 'wss://a', 'wss://b'], ['amount', '21000']], sig: 'x', ...extra, +}); + +describe('npub', () => { + it('geeft de npub van NIP-19 voor een bekende sleutel', () => { + assert.equal(npub(ALICE), 'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6'); + assert.equal(npubShort(ALICE), 'npub180cv…h6w6'); + }); + + it('weigert wat geen sleutel van 32 bytes is', () => { + assert.equal(npub('abc'), null); + assert.equal(npub(null), null); + }); +}); + +describe('zapContext', () => { + it('leest afzender, ontvanger, bericht en de gezapte notitie', () => { + assert.deepEqual(zapContext(verzoek()), { sender: ALICE, recipient: BOB, message: 'Mooi stuk!', note: 'b'.repeat(64) }); + }); + + it('een gewone memo, ander JSON of een ander soort event is geen zap', () => { + assert.equal(zapContext('Koffie'), null); + assert.equal(zapContext('{"kind":1,"content":"x"}'), null); + assert.equal(zapContext('{kapot'), null); + assert.equal(zapContext(null), null); + }); +}); + +describe('compactMemo', () => { + it('houdt van een zap-verzoek alleen wat gelezen wordt, en de context blijft gelijk', () => { + const klein = compactMemo(verzoek()); + assert.ok(!klein.includes('relays') && !klein.includes('sig')); + assert.deepEqual(zapContext(klein), zapContext(verzoek())); + }); + + it('kort een lange memo in en laat een lege weg', () => { + assert.equal(compactMemo('x'.repeat(MEMO_MAX + 50)).length, MEMO_MAX); + assert.equal(compactMemo(' '), null); + assert.equal(compactMemo(undefined), null); + }); +}); + +describe('memoNote', () => { + it('noemt bij binnenkomend de afzender en bij uitgaand de ontvanger', () => { + assert.equal(memoNote(verzoek(), 'in'), 'zap from npub180cv…h6w6: "Mooi stuk!"'); + assert.match(memoNote(verzoek(), 'out'), /^zap to npub1/); + assert.equal(memoNote(verzoek({ content: '' }), 'in'), 'zap from npub180cv…h6w6'); + }); + + it('een gewone memo komt zoals hij is, en niets geeft niets', () => { + assert.equal(memoNote('Koffie', 'out'), 'Koffie'); + assert.equal(memoNote(null, 'in'), null); + }); +}); diff --git a/tests/repo/accounts-active.test.mjs b/tests/repo/accounts-active.test.mjs new file mode 100644 index 0000000..1c78f22 --- /dev/null +++ b/tests/repo/accounts-active.test.mjs @@ -0,0 +1,215 @@ +// Generated, do not edit. sha256:a2c33f19cc145348f36ba31f41736c0be16d371654b28747c636f79229822fe6 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { + listAccounts, listSelectableAccounts, deactivateCheck, deactivateAccount, reactivateAccount, +} = await import('../../server/data/accounts.js'); +const { eventsNetByAccount } = await import('../../server/data/events.js'); + +let btc, kluis, tijdelijk; +beforeEach(() => { + harness.recreate(); + harness.exports.run( + `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`); + btc = harness.exports.lastId(); + kluis = account('Kluis'); + tijdelijk = account('Tijdelijk'); +}); + +function account(naam, { scanEnabled = 0 } = {}) { + harness.exports.run( + `INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) + VALUES (?,?, 'wallet', 1, ?, 1, NULL)`, [naam, naam.toLowerCase(), scanEnabled]); + return harness.exports.lastId(); +} +function koop(accountId, sats, at = '2024-05-01T10:00:00Z') { + harness.exports.run( + `INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt) + VALUES (?, 'buy', ?, ?, 50000, ?, 'manual', ?)`, [btc, at, sats, accountId, at]); +} +const rij = id => harness.exports.one(`SELECT * FROM Account WHERE id = ?`, [id]); + +describe('non-actief: uit de keuzelijst, maar niet uit de stand', () => { + it('blijft volledig meetellen in het grootboek', async () => { + koop(tijdelijk, 100_000); + const voor = eventsNetByAccount(tijdelijk, btc, '2030-01-01T00:00:00Z'); + await deactivateAccount(tijdelijk, '2024-06-01T00:00:00Z'); + assert.equal(eventsNetByAccount(tijdelijk, btc, '2030-01-01T00:00:00Z'), voor, + 'deactiveren is géén verwijderen: het saldo verandert niet'); + assert.equal(voor, 100_000); + }); + + it('blijft in listAccounts staan, want die voedt de totalen', () => { + harness.exports.run(`UPDATE Account SET isActive = 0, closedAt = '2024-06-01T00:00:00Z' WHERE id = ?`, [tijdelijk]); + assert.deepEqual(listAccounts().map(a => a.name).sort(), ['Kluis', 'Tijdelijk']); + }); + + it('verdwijnt wél uit de keuzelijst', () => { + harness.exports.run(`UPDATE Account SET isActive = 0, closedAt = '2024-06-01T00:00:00Z' WHERE id = ?`, [tijdelijk]); + assert.deepEqual(listSelectableAccounts().map(a => a.name), ['Kluis']); + }); + + it('komt met de expliciete opt-in tóch mee, actieve eerst', () => { + harness.exports.run(`UPDATE Account SET isActive = 0, closedAt = '2024-06-01T00:00:00Z' WHERE id = ?`, [tijdelijk]); + assert.deepEqual(listSelectableAccounts({ metGesloten: true }).map(a => a.name), ['Kluis', 'Tijdelijk']); + }); + + it('houdt de huidige waarde van een bewerkformulier in de lijst', () => { + harness.exports.run(`UPDATE Account SET isActive = 0, closedAt = '2024-06-01T00:00:00Z' WHERE id = ?`, [tijdelijk]); + assert.deepEqual(listSelectableAccounts({ ookId: tijdelijk }).map(a => a.name), ['Kluis', 'Tijdelijk']); + }); + + it('laat de andere non-actieve accounts wél weg bij ookId', () => { + const derde = account('Derde'); + harness.exports.run(`UPDATE Account SET isActive = 0, closedAt = '2024-06-01T00:00:00Z' WHERE id IN (?,?)`, [tijdelijk, derde]); + assert.deepEqual(listSelectableAccounts({ ookId: tijdelijk }).map(a => a.name), ['Kluis', 'Tijdelijk']); + }); + + it('verandert niets als ookId leeg of onbekend is', () => { + harness.exports.run(`UPDATE Account SET isActive = 0, closedAt = '2024-06-01T00:00:00Z' WHERE id = ?`, [tijdelijk]); + for (const opt of [{}, { ookId: null }, { ookId: undefined }, { ookId: 0 }, { ookId: 9999 }]) { + assert.deepEqual(listSelectableAccounts(opt).map(a => a.name), ['Kluis'], JSON.stringify(opt)); + } + }); +}); + +describe('deactivateCheck', () => { + const soorten = chk => chk.findings.map(f => f.kind).sort(); + + it('meldt niets bij een leeg, ongebruikt account', () => { + const chk = deactivateCheck(kluis); + assert.equal(chk.found, true); + assert.deepEqual(chk.findings, []); + }); + + it('geeft found:false voor een onbekend account', () => { + assert.deepEqual(deactivateCheck(9999), { found: false, findings: [] }); + }); + + it('meldt dat het scannen aanstaat', () => { + const a = account('Scanner', { scanEnabled: 1 }); + assert.deepEqual(soorten(deactivateCheck(a)), ['scanEnabled']); + }); + + it('meldt wallet-bronnen', () => { + harness.exports.run( + `INSERT INTO WalletSource (accountId, kind, value, name, createdAt) + VALUES (?, 'xpub', 'zpub', 'Ledger', '2024-01-01T00:00:00Z')`, [kluis]); + assert.deepEqual(soorten(deactivateCheck(kluis)), ['walletSources']); + }); + + it('meldt een rente-afspraak zonder einddatum', () => { + harness.exports.run( + `INSERT INTO InterestSchedule (accountId, payoutAssetId, rateKind, startsOn, compounding, createdAt) + VALUES (?,?, 'variable', '2024-01-01', 0, '2024-01-01T00:00:00Z')`, [kluis, btc]); + assert.deepEqual(soorten(deactivateCheck(kluis)), ['openSchedules']); + }); + + it('meldt een afspraak met een einddatum in de toekomst, maar niet één die al afliep', () => { + harness.exports.run( + `INSERT INTO InterestSchedule (accountId, payoutAssetId, rateKind, startsOn, endsOn, compounding, createdAt) + VALUES (?,?, 'variable', '2020-01-01', '2020-12-31', 0, '2020-01-01T00:00:00Z')`, [kluis, btc]); + assert.deepEqual(soorten(deactivateCheck(kluis)), [], 'een afgelopen afspraak is geen gevolg'); + + harness.exports.run( + `INSERT INTO InterestSchedule (accountId, payoutAssetId, rateKind, startsOn, endsOn, compounding, createdAt) + VALUES (?,?, 'variable', '2024-01-01', '2099-12-31', 0, '2024-01-01T00:00:00Z')`, [kluis, btc]); + assert.deepEqual(soorten(deactivateCheck(kluis)), ['openSchedules']); + }); + + it('meldt openstaande reconcile-voorstellen, maar niet afgehandelde', () => { + const voorstel = status => harness.exports.run( + `INSERT INTO ReconcileProposal (accountId, assetId, amount, direction, suggestedType, status, detectedAt) + VALUES (?,?, 1000, 'in', 'transfer', ?, '2024-05-01T00:00:00Z')`, [kluis, btc, status]); + voorstel('ignored'); + assert.deepEqual(soorten(deactivateCheck(kluis)), [], 'afgehandeld is geen gevolg'); + voorstel('pending'); + assert.deepEqual(soorten(deactivateCheck(kluis)), ['pendingProposals']); + }); + + it('meldt een saldo dat niet nul is, met het bedrag per asset', () => { + koop(kluis, 250_000); + const chk = deactivateCheck(kluis); + const saldo = chk.findings.find(f => f.kind === 'balance'); + assert.ok(saldo, 'een saldo hoort een bevinding te zijn'); + assert.equal(saldo.balances[0].saldo, 250_000); + }); + + it('meldt een saldo van nul juist NIET', () => { + koop(kluis, 100_000); + harness.exports.run( + `INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fromAccountId, source, createdAt) + VALUES (?, 'sell', '2024-06-01T10:00:00Z', 100000, 50000, ?, 'manual', '2024-06-01T10:00:00Z')`, + [btc, kluis]); + const chk = deactivateCheck(kluis); + assert.equal(chk.findings.some(f => f.kind === 'balance'), false, 'leeg account, geen saldo-bevinding'); + assert.equal(chk.findings.some(f => f.kind === 'events'), true, 'de historie wordt wél gemeld'); + }); + + it('meldt een saldo dat alléén uit een balans komt', () => { + harness.exports.run( + `INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source) + VALUES (?, '2026-06-30', ?, 123456, 1.0, '2026-06-30T00:00:00.000Z', 'manual')`, [btc, kluis]); + const chk = deactivateCheck(kluis); + const saldo = chk.findings.find(f => f.kind === 'balance'); + assert.ok(saldo, 'een gemeten balans is net zo goed een saldo'); + assert.deepEqual(saldo.balances, [{ assetId: btc, saldo: 123456 }]); + assert.equal(chk.findings.some(f => f.kind === 'events'), false, 'en er is geen historie'); + }); + + it('meldt de fee van een verplaatsing naar dezelfde rekening', () => { + harness.exports.run( + `INSERT INTO Event (assetId, type, occurredAt, amount, unitFee, fromAccountId, toAccountId, source, createdAt) + VALUES (?, 'transfer', '2024-06-01T10:00:00Z', 100000, 250, ?, ?, 'manual', '2024-06-01T10:00:00Z')`, + [btc, kluis, kluis]); + const saldo = deactivateCheck(kluis).findings.find(f => f.kind === 'balance'); + assert.ok(saldo, 'de fee is een saldo'); + assert.deepEqual(saldo.balances, [{ assetId: btc, saldo: -250 }]); + }); + + it('merkt de events-bevinding aan als informatief', () => { + koop(kluis, 100_000); + const ev = deactivateCheck(kluis).findings.find(f => f.kind === 'events'); + assert.equal(ev.informational, true, 'de historie blijft en blijft meetellen: dat is geen waarschuwing'); + }); + + it('verandert niets aan de database', () => { + koop(kluis, 100_000); + const voor = rij(kluis); + deactivateCheck(kluis); + assert.deepEqual({ ...rij(kluis) }, { ...voor }); + }); +}); + +describe('deactiveren en heractiveren', () => { + it('zet de vlag, de datum én het scannen uit', async () => { + const a = account('Scanner', { scanEnabled: 1 }); + await deactivateAccount(a, '2024-06-01T00:00:00Z'); + const r = rij(a); + assert.equal(r.isActive, 0); + assert.equal(r.closedAt, '2024-06-01T00:00:00Z'); + assert.equal(r.scanEnabled, 0, 'dat mag stil, want deactivateCheck heeft het gemeld'); + }); + + it('zet bij heractiveren het scannen NIET terug aan', async () => { + const a = account('Scanner', { scanEnabled: 1 }); + await deactivateAccount(a, '2024-06-01T00:00:00Z'); + await reactivateAccount(a); + const r = rij(a); + assert.equal(r.isActive, 1); + assert.equal(r.closedAt, null, 'de CHECK eist dat een actief account geen afsluitdatum heeft'); + assert.equal(r.scanEnabled, 0, 'scannen aanzetten is een eigen, expliciete handeling'); + }); + + it('weigert een onbekend account in beide richtingen', async () => { + await assert.rejects(() => deactivateAccount(9999), (e) => e.key === 'accounts.unknownAccount'); + await assert.rejects(() => reactivateAccount(9999), (e) => e.key === 'accounts.unknownAccount'); + }); +}); diff --git a/tests/repo/ai-audit.test.mjs b/tests/repo/ai-audit.test.mjs new file mode 100644 index 0000000..b89d0f8 --- /dev/null +++ b/tests/repo/ai-audit.test.mjs @@ -0,0 +1,280 @@ +// Generated, do not edit. sha256:8974817703a099c61e2ed0be93a4edca07beef7a6be37fd3761e3616029a859f +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; +import { CONFIG, ENUMS } from '../../shared/config.js'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { logAiToolCall, listAiToolCalls, countAiToolCalls, lastAiToolCallAt, aiSessions, + aiToolCallMessages, countAiToolCallsSince } = await import('../../server/data/ai-audit.js'); +const { takeChanges } = await import('../../server/data/_events.js'); + +const regel = (extra = {}) => ({ + sessionId: 'sessie-1', client: 'voorbeeld-client', tool: 'connection_test', + outcome: 'ok', ...extra, +}); + +beforeEach(() => { harness.recreate(); takeChanges(); }); + +describe('ai-audit: vastleggen', () => { + it('legt een gelukte aanroep vast met alles wat er nodig is om hem te verantwoorden', async () => { + const id = await logAiToolCall(regel({ + arguments: { note: 'hallo' }, fieldsShared: [], charactersOut: 42, + })); + assert.ok(id > 0, 'geeft het id van de nieuwe regel terug'); + const [r] = listAiToolCalls(); + assert.equal(r.id, id); + assert.equal(r.sessionId, 'sessie-1'); + assert.equal(r.client, 'voorbeeld-client'); + assert.equal(r.tool, 'connection_test'); + assert.equal(r.outcome, 'ok'); + assert.equal(r.charactersOut, 42); + assert.deepEqual(r.fieldsShared, [], 'een lege lijst betekent: er ging geen enkel veld mee'); + assert.equal(JSON.parse(r.arguments).note, 'hallo'); + assert.ok(r.occurredAt, 'en er staat een moment bij'); + }); + + it('persisteert', async () => { + const voor = harness.calls.persist; + await logAiToolCall(regel()); + assert.equal(harness.calls.persist, voor + 1, 'een auditregel die niet op schijf komt, bestaat niet'); + }); + + it('houdt het verschil tussen "niets gedeeld" en "onbekend wat er gedeeld is"', async () => { + await logAiToolCall(regel({ fieldsShared: [] })); + await logAiToolCall(regel({ sessionId: 'sessie-2' })); + const rijen = listAiToolCalls(); + const leeg = rijen.find(r => r.sessionId === 'sessie-1'); + const onbekend = rijen.find(r => r.sessionId === 'sessie-2'); + assert.deepEqual(leeg.fieldsShared, []); + assert.equal(onbekend.fieldsShared, null); + }); + + it('bewaart de veldnamen en niet hun waarden', async () => { + await logAiToolCall(regel({ fieldsShared: ['name', 'symbol'] })); + const [r] = listAiToolCalls(); + assert.deepEqual(r.fieldsShared, ['name', 'symbol']); + }); +}); + +describe('ai-audit: welke serverversie een aanroep afhandelde', () => { + it('legt de versie vast als de aanroeper hem meegeeft', async () => { + await logAiToolCall(regel({ serverVersion: '2' })); + assert.equal(listAiToolCalls()[0].serverVersion, '2'); + }); + + it('laat hem LEEG als de aanroeper hem niet kent, en vult niets in', async () => { + await logAiToolCall(regel()); + assert.equal(listAiToolCalls()[0].serverVersion, null); + }); +}); + +describe('ai-audit: de actieve koppelingen', () => { + it('groepeert per sessie, met aantal en laatste moment', async () => { + await logAiToolCall(regel({ sessionId: 's1', serverVersion: '2', occurredAt: '2026-08-15T10:00:00.000Z' })); + await logAiToolCall(regel({ sessionId: 's1', serverVersion: '2', occurredAt: '2026-08-15T10:05:00.000Z' })); + await logAiToolCall(regel({ sessionId: 's2', serverVersion: '2', occurredAt: '2026-08-15T09:00:00.000Z' })); + const s = aiSessions(24 * 365 * 100); + assert.equal(s.length, 2); + assert.equal(s[0].sessionId, 's1', 'nieuwste eerst'); + assert.equal(s[0].calls, 2); + assert.equal(s[0].lastAt, '2026-08-15T10:05:00.000Z'); + }); + + it('markeert een sessie zonder serverversie, want die kent de schakelaar niet', async () => { + await logAiToolCall(regel({ sessionId: 'oud', occurredAt: '2026-08-15T10:00:00.000Z' })); + await logAiToolCall(regel({ sessionId: 'nieuw', serverVersion: '2', occurredAt: '2026-08-15T10:01:00.000Z' })); + const s = aiSessions(24 * 365 * 100); + assert.equal(s.find(x => x.sessionId === 'oud').zonderVersie, true); + assert.equal(s.find(x => x.sessionId === 'nieuw').zonderVersie, false); + }); + + it('laat een sessie buiten het venster weg', async () => { + await logAiToolCall(regel({ sessionId: 'oud', occurredAt: '2020-01-01T00:00:00.000Z' })); + assert.deepEqual(aiSessions(24), []); + }); +}); + +describe('ai-audit: de guards', () => { + it('weigert een onbekende uitkomst', async () => { + await assert.rejects(() => logAiToolCall(regel({ outcome: 'geslaagd' })), /Invalid outcome/); + assert.equal(listAiToolCalls().length, 0, 'en er staat niets in de tabel'); + }); + + it('weigert een regel zonder sessie', async () => { + await assert.rejects(() => logAiToolCall(regel({ sessionId: null })), /without a session/); + assert.equal(listAiToolCalls().length, 0); + }); + + it('weigert een regel zonder toolnaam', async () => { + await assert.rejects(() => logAiToolCall(regel({ tool: '' })), /without a tool name/); + assert.equal(listAiToolCalls().length, 0); + }); + + it('weigert een negatief aantal verstuurde tekens', async () => { + await assert.rejects(() => logAiToolCall(regel({ charactersOut: -1 }))); + assert.equal(listAiToolCalls().length, 0); + }); + + it('legt élke uitkomst uit de registry vast, ook de geweigerde', async () => { + for (const uitkomst of ENUMS.AI_CALL_OUTCOME) { + await logAiToolCall(regel({ outcome: uitkomst, detail: 'reden' })); + } + assert.equal(listAiToolCalls().length, ENUMS.AI_CALL_OUTCOME.length); + }); +}); + +describe('ai-audit: teruglezen', () => { + it('geeft de nieuwste eerst', async () => { + const t = '2026-08-14T12:00:00.000Z'; + const oudste = await logAiToolCall(regel({ occurredAt: '2026-08-14T11:00:00.000Z' })); + const midden = await logAiToolCall(regel({ occurredAt: t })); + const nieuwste = await logAiToolCall(regel({ occurredAt: '2026-08-14T13:00:00.000Z' })); + assert.deepEqual(listAiToolCalls().map(r => r.id), [nieuwste, midden, oudste]); + }); + + it('klemt de limiet en valt terug op de SSOT-waarde', async () => { + for (let i = 0; i < 5; i++) await logAiToolCall(regel()); + assert.equal(listAiToolCalls(3).length, 3); + assert.equal(listAiToolCalls(0).length, 5, '0 is geen geldige limiet, dus de standaard geldt'); + assert.equal(listAiToolCalls('onzin').length, 5); + assert.ok(CONFIG.AI.AUDIT_PAGE_SIZE >= 5, 'anders meet deze test iets anders dan hij bedoelt'); + }); + + it('overleeft onleesbare inhoud in fieldsShared', async () => { + harness.exports.run( + `INSERT INTO AiToolCall (sessionId, tool, fieldsShared, outcome, occurredAt) + VALUES ('s','t','{geen json','ok','2026-08-14T12:00:00.000Z')`); + const [r] = listAiToolCalls(); + assert.deepEqual(r.fieldsShared, [], 'geen exception, maar een lege lijst'); + }); + + it('telt per uitkomst, en noemt elke soort ook als hij nul is', async () => { + await logAiToolCall(regel({ outcome: 'ok' })); + await logAiToolCall(regel({ outcome: 'refused' })); + const telling = countAiToolCalls(); + assert.equal(telling.total, 2); + assert.equal(telling.byOutcome.ok, 1); + assert.equal(telling.byOutcome.refused, 1); + assert.equal(telling.byOutcome.error, 0, 'een soort die niet voorkomt is 0 en niet afwezig'); + }); + + it('geeft op een leeg logboek nul en geen null', () => { + assert.deepEqual(countAiToolCalls(), { total: 0, byOutcome: { ok: 0, error: 0, refused: 0 } }); + assert.equal(lastAiToolCallAt(), null); + }); + + it('noemt het moment van de laatste aanroep', async () => { + await logAiToolCall(regel({ occurredAt: '2026-08-14T12:00:00.000Z' })); + await logAiToolCall(regel({ occurredAt: '2026-08-14T13:00:00.000Z' })); + assert.equal(lastAiToolCallAt(), '2026-08-14T13:00:00.000Z'); + }); +}); + +const gesprek = (extra = {}) => ({ kind: 'request', text: '{"note":"hallo"}', ...extra }); + +describe('ai-audit: het gesprek vastleggen', () => { + it('legt de momenten vast bij de aanroep waar ze bij horen', async () => { + const id = await logAiToolCall(regel({ + messages: [ + gesprek({ occurredAt: '2026-08-17T10:00:00.000Z' }), + gesprek({ kind: 'response', text: 'Verbinding werkt.', occurredAt: '2026-08-17T10:00:01.000Z' }), + ], + })); + const m = aiToolCallMessages(id); + assert.equal(m.length, 2); + assert.deepEqual(m.map(x => x.kind), ['request', 'response'], 'op volgorde van gebeuren'); + assert.equal(m[1].text, 'Verbinding werkt.'); + assert.equal(m[0].callId, id); + assert.equal(m[0].occurredAt, '2026-08-17T10:00:00.000Z'); + }); + + it('houdt de volgorde ook als beide momenten in dezelfde milliseconde vallen', async () => { + const t = '2026-08-17T10:00:00.000Z'; + const id = await logAiToolCall(regel({ + messages: [gesprek({ occurredAt: t }), gesprek({ kind: 'response', text: 'x', occurredAt: t })], + })); + assert.deepEqual(aiToolCallMessages(id).map(x => x.kind), ['request', 'response']); + }); + + it('laat een aanroep zonder gesprek gewoon bestaan', async () => { + const id = await logAiToolCall(regel()); + assert.deepEqual(aiToolCallMessages(id), []); + }); + + it('weigert een verzonnen moment, en laat dan ook de auditregel niet half achter', async () => { + await assert.rejects( + () => logAiToolCall(regel({ messages: [gesprek({ kind: 'antwoord' })] })), /Invalid kind/); + assert.equal(listAiToolCalls().length, 0, 'geen halve aanroep in het logboek'); + }); + + it('kapt een te lange tekst af en zegt dat er afgekapt is', async () => { + const grens = CONFIG.AI.LIMITS.MAX_LOG_CHARS; + const id = await logAiToolCall(regel({ + messages: [gesprek({ kind: 'response', text: 'x'.repeat(grens + 500) })], + })); + const [m] = aiToolCallMessages(id); + assert.ok(m.text.length <= grens, `bewaard: ${m.text.length}, grens: ${grens}`); + assert.match(m.text, /Afgekapt/, 'en dát staat er zichtbaar bij'); + }); + + it('bewaart een leeg gesprek als leeg en niet als ontbrekend', async () => { + const id = await logAiToolCall(regel({ messages: [gesprek({ text: '' })] })); + assert.equal(aiToolCallMessages(id).length, 1); + assert.equal(aiToolCallMessages(id)[0].text, ''); + }); + + it('geeft een lege lijst bij een aanroep die niet bestaat', async () => { + await logAiToolCall(regel({ messages: [gesprek()] })); + assert.deepEqual(aiToolCallMessages(9999), [], 'geen exception op een onbekend id'); + assert.deepEqual(aiToolCallMessages(null), []); + assert.deepEqual(aiToolCallMessages('onzin'), []); + }); + + it('houdt de gesprekken van twee aanroepen uit elkaar', async () => { + const een = await logAiToolCall(regel({ messages: [gesprek({ text: 'eerste' })] })); + const twee = await logAiToolCall(regel({ messages: [gesprek({ text: 'tweede' })] })); + assert.deepEqual(aiToolCallMessages(een).map(m => m.text), ['eerste']); + assert.deepEqual(aiToolCallMessages(twee).map(m => m.text), ['tweede']); + }); +}); + +describe('ai-audit: laten weten dát er iets gebeurd is', () => { + it('zet een verandersignaal bij elke vastgelegde aanroep', async () => { + await logAiToolCall(regel()); + assert.deepEqual(takeChanges(), ['ai-activity-changed']); + }); + + it('zet dat signaal óók bij een geweigerde aanroep', async () => { + await logAiToolCall(regel({ outcome: 'refused', detail: 'buiten scope' })); + assert.deepEqual(takeChanges(), ['ai-activity-changed']); + }); + + it('zet geen signaal als de regel geweigerd wordt', async () => { + await assert.rejects(() => logAiToolCall(regel({ outcome: 'geslaagd' }))); + assert.deepEqual(takeChanges(), []); + }); + + it('telt hoeveel aanroepen er ná een gezien id zijn, mét het hoogste id van nu', async () => { + const een = await logAiToolCall(regel()); + const twee = await logAiToolCall(regel()); + assert.deepEqual(countAiToolCallsSince(een), { count: 1, newestId: twee }); + assert.deepEqual(countAiToolCallsSince(twee), { count: 0, newestId: twee }, + 'alles gezien, maar het id komt wél mee'); + }); + + it('rekent zonder gezien id alles als nieuw', async () => { + await logAiToolCall(regel()); + await logAiToolCall(regel()); + assert.equal(countAiToolCallsSince(0).count, 2); + assert.equal(countAiToolCallsSince(null).count, 2, 'nooit gekeken leest als 0'); + assert.equal(countAiToolCallsSince('onzin').count, 2); + }); + + it('geeft op een leeg logboek nul en geen null', async () => { + assert.deepEqual(countAiToolCallsSince(0), { count: 0, newestId: 0 }); + }); +}); diff --git a/tests/repo/ai-proposals.test.mjs b/tests/repo/ai-proposals.test.mjs new file mode 100644 index 0000000..f3ac271 --- /dev/null +++ b/tests/repo/ai-proposals.test.mjs @@ -0,0 +1,256 @@ +// Generated, do not edit. sha256:e96cfe6ba81097a2ecb4810acd03529886647af210d2bac0f57c242345f7c633 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness({ seedPreferences: true }); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { createAiProposal, listPendingAiProposals, listAiProposals, getAiProposal, acceptAiProposal, + rejectAiProposal } = await import('../../server/data/ai-proposals.js'); +const { createAccount } = await import('../../server/data/accounts.js'); +const { listEvents } = await import('../../server/data/events.js'); +const { createAsset } = await import('../../server/data/assets.js'); +const { listPriceHistory } = await import('../../server/data/price-history.js'); +const { takeChanges } = await import('../../server/data/_events.js'); + +const id = r => (typeof r === 'object' && r !== null ? (r.id ?? r) : r); + +let btc, acc; + +function manualProvider() { + harness.exports.run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES ('manual','Handmatig','handmatig','','none',0,1,1,1,'2026-01-01T00:00:00Z')`); +} + +const voorstel = (extra = {}) => createAiProposal({ + kind: 'price_point', assetId: btc, fiatPrice: 50000, observedAt: '2026-03-01T00:00:00Z', + note: 'koers volgens de muntwinkel', ...extra, +}); + +beforeEach(async () => { + harness.recreate(); + takeChanges(); + btc = id(await createAsset({ symbol: 'BTC', name: 'Bitcoin', kind: 'crypto', unit: 'BTC', subunit: 'sat', decimals: 8, displayUnit: 'unit' })); + acc = id(await createAccount({ name: 'Kluis', kind: 'vault', isOwnCustody: 1, scanEnabled: 0 })); + takeChanges(); +}); + +describe('ai-proposals: aanmaken en opsommen', () => { + it('maakt een voorstel dat openstaat en de asset erbij noemt', async () => { + const pid = await voorstel(); + const rij = getAiProposal(pid); + assert.equal(rij.status, 'pending'); + assert.equal(rij.asset_symbol, 'BTC'); + assert.equal(rij.asset_name, 'Bitcoin'); + assert.equal(rij.resolvedAt, null); + }); + + it('weigert een onbekende soort met een leesbare fout', async () => { + await assert.rejects(() => createAiProposal({ kind: 'iets_anders' }), /kind/); + }); + + it('somt alleen openstaande voorstellen op', async () => { + const blijft = await voorstel(); + const weg = await voorstel({ fiatPrice: 60000 }); + await rejectAiProposal(weg, 'niet nodig'); + assert.deepEqual(listPendingAiProposals().map(r => r.id), [blijft]); + }); +}); + +describe('ai-proposals: accepteren voert écht uit', () => { + it('schrijft het koerspunt via de datalaag naar PriceHistory', async () => { + manualProvider(); + const pid = await voorstel(); + await acceptAiProposal(pid); + + const punten = listPriceHistory(btc); + assert.equal(punten.length, 1, 'het punt hoort in PriceHistory te staan'); + assert.equal(punten[0].fiatPrice, 50000); + assert.equal(punten[0].observedAt, '2026-03-01T00:00:00Z', 'dagresolutie, dus middernacht'); + assert.equal(punten[0].resolution, 'day'); + assert.equal(getAiProposal(pid).status, 'accepted'); + assert.ok(getAiProposal(pid).resolvedAt, 'resolvedAt hoort gevuld te zijn'); + }); + + it('kan twee keer toegepast worden zonder dubbel punt, want de upsert is idempotent', async () => { + manualProvider(); + const eerste = await voorstel(); + await acceptAiProposal(eerste); + const tweede = await voorstel(); + await acceptAiProposal(tweede); + assert.equal(listPriceHistory(btc).length, 1, 'zelfde asset, zelfde dag, zelfde bron: één rij'); + }); +}); + +describe('ai-proposals: de niet-gelukkige paden', () => { + it('weigert een tweede keer accepteren', async () => { + manualProvider(); + const pid = await voorstel(); + await acceptAiProposal(pid); + await assert.rejects(() => acceptAiProposal(pid), (e) => e.key === 'aiProposals.handled_accepted'); + }); + + it('weigert accepteren van een afgewezen voorstel', async () => { + const pid = await voorstel(); + await rejectAiProposal(pid); + await assert.rejects(() => acceptAiProposal(pid), (e) => e.key === 'aiProposals.handled_rejected'); + assert.equal(listPriceHistory(btc).length, 0, 'er hoort niets geschreven te zijn'); + }); + + it('weigert een voorstel dat niet bestaat', async () => { + await assert.rejects(() => acceptAiProposal(999999), (e) => e.key === 'aiProposals.unknownProposal'); + await assert.rejects(() => rejectAiProposal(999999), (e) => e.key === 'aiProposals.unknownProposal'); + }); + + it('verdwijnt met zijn asset, en accepteren gooit daarna', async () => { + const pid = await voorstel(); + harness.exports.run(`DELETE FROM Asset WHERE id = ?`, [btc]); + assert.equal(getAiProposal(pid), null); + await assert.rejects(() => acceptAiProposal(pid), (e) => e.key === 'aiProposals.unknownProposal'); + }); + + it('laat het voorstel STAAN als de uitvoering afketst, met de reden erbij', async () => { + const pid = await voorstel(); + await assert.rejects(() => acceptAiProposal(pid), /manual/); + const rij = getAiProposal(pid); + assert.equal(rij.status, 'pending', 'een afgeketste uitvoering hoort het voorstel niet af te handelen'); + assert.match(rij.detail, /manual/, 'de reden hoort na te lezen te zijn'); + assert.deepEqual(listPendingAiProposals().map(r => r.id), [pid], 'en hij staat nog op de lijst'); + }); + + it('wist de reden zodra het alsnog lukt', async () => { + const pid = await voorstel(); + await assert.rejects(() => acceptAiProposal(pid)); + manualProvider(); + await acceptAiProposal(pid); + assert.equal(getAiProposal(pid).detail, null); + assert.equal(getAiProposal(pid).status, 'accepted'); + }); + + it('bewaart een afgewezen voorstel in plaats van het te verwijderen', async () => { + const pid = await voorstel(); + await rejectAiProposal(pid, 'die koers klopt niet'); + const rij = getAiProposal(pid); + assert.equal(rij.status, 'rejected'); + assert.equal(rij.detail, 'die koers klopt niet'); + assert.ok(rij.resolvedAt); + }); +}); + +describe('het verandersignaal, zodat de kaart zichzelf bijwerkt (27-08-2026)', () => { + it('zet een signaal bij het aanmaken', async () => { + await voorstel(); + assert.deepEqual(takeChanges(), ['ai-proposals-changed']); + }); + + it('zet een signaal bij het afwijzen, voor een tweede tabblad en voor de bolletjes', async () => { + const pid = await voorstel(); + takeChanges(); + await rejectAiProposal(pid, 'niet nodig'); + assert.deepEqual(takeChanges(), ['ai-proposals-changed']); + }); + + it('zet een signaal bij het goedkeuren', async () => { + manualProvider(); + const pid = await voorstel(); + takeChanges(); + await acceptAiProposal(pid); + assert.ok(takeChanges().includes('ai-proposals-changed')); + }); + + it('zet OOK een signaal als de uitvoering afketst', async () => { + const pid = await voorstel(); + takeChanges(); + await assert.rejects(() => acceptAiProposal(pid)); + assert.deepEqual(takeChanges(), ['ai-proposals-changed']); + }); + + it('zegt niets bij een leesactie', async () => { + await voorstel(); + takeChanges(); + listPendingAiProposals(); + getAiProposal(1); + assert.deepEqual(takeChanges(), []); + }); +}); + +describe('listAiProposals: ook wat al afgehandeld is (27-08-2026)', () => { + it('geeft afgewezen en openstaande voorstellen, nieuwste eerst', async () => { + const eerste = await voorstel(); + const tweede = await voorstel({ fiatPrice: 60000 }); + await rejectAiProposal(eerste, 'niet nodig'); + + const rijen = listAiProposals(); + assert.deepEqual(rijen.map(r => r.id), [tweede, eerste], 'nieuwste eerst'); + assert.equal(rijen.find(r => r.id === eerste).status, 'rejected', 'een afgehandeld voorstel hoort erin te zitten'); + }); + + it('houdt zich aan de grens en negeert een onzinnige', async () => { + for (let i = 0; i < 4; i++) await voorstel({ fiatPrice: 50000 + i }); + assert.equal(listAiProposals(2).length, 2); + assert.equal(listAiProposals(0).length, 4); + assert.equal(listAiProposals('twee').length, 4); + }); + + it('geeft NIETS als de koppeling uit staat, net als de pending-lijst', async () => { + await voorstel(); + harness.exports.run(`UPDATE Preference SET value = '0' WHERE scope = 'ai' AND key = 'allow_read'`); + harness.exports.run(`UPDATE Preference SET value = '0' WHERE scope = 'ai' AND key = 'enabled'`); + assert.deepEqual(listAiProposals(), []); + }); +}); + +describe('ai-proposals: kosten', () => { + const boeking = (extra = {}) => createAiProposal({ + kind: 'event', assetId: btc, eventType: 'buy', amount: 100000, fiatPrice: 50000, + observedAt: '2026-03-01T10:00:00Z', toAccountId: acc, ...extra, + }); + + it('bewaart de kosten op het voorstel', async () => { + const pid = await boeking({ fiatFee: 4.5 }); + assert.equal(getAiProposal(pid).fiatFee, 4.5); + }); + + it('laat ze weg als ze er niet zijn, en zet geen nul', async () => { + assert.equal(getAiProposal(await boeking()).fiatFee, null); + }); + + it('ZET ZE OOK ÉCHT OP DE BOEKING, en dat is de hele reden dat dit veld bestaat', async () => { + const pid = await boeking({ fiatFee: 4.5 }); + await acceptAiProposal(pid); + const events = listEvents(); + assert.equal(events.length, 1); + assert.equal(events[0].fiatFee, 4.5, 'zonder deze regel is de kostprijs precies de fee te laag'); + assert.equal(events[0].fiatPrice, 50000, 'en de koers hoort onaangeroerd te blijven'); + }); + + it('weigert kosten op een soort die ze niet kent', async () => { + await assert.rejects( + () => createAiProposal({ kind: 'price_point', assetId: btc, fiatPrice: 50000, + observedAt: '2026-03-01T00:00:00Z', fiatFee: 1 }), + /Fees belong to an event/); + }); + + it('weigert een negatieve fee, want dat is de DDL', async () => { + await assert.rejects(() => boeking({ fiatFee: -1 })); + }); + + const verkoop = (extra = {}) => boeking({ eventType: 'sell', toAccountId: null, fromAccountId: acc, ...extra }); + + it('zet een fee in eenheden op de verkoop die hij goedkeurt', async () => { + const pid = await verkoop({ unitFee: 150 }); + assert.equal(getAiProposal(pid).unitFee, 150); + await acceptAiProposal(pid); + assert.equal(listEvents()[0].unitFee, 150, 'zonder deze regel staat het grootboek de fee te hoog'); + }); + + it('weigert een fee in eenheden waar het grootboek hem niet leest', async () => { + await assert.rejects(() => boeking({ unitFee: 150 }), /fee in units belongs to a sell or a transfer/); + await assert.rejects( + () => createAiProposal({ kind: 'price_point', assetId: btc, fiatPrice: 50000, + observedAt: '2026-03-01T00:00:00Z', unitFee: 1 }), + /Fees belong to an event/); + }); +}); diff --git a/tests/repo/api-metrics.test.mjs b/tests/repo/api-metrics.test.mjs new file mode 100644 index 0000000..fb82ae6 --- /dev/null +++ b/tests/repo/api-metrics.test.mjs @@ -0,0 +1,261 @@ +// Generated, do not edit. sha256:3e601bf7d829d14e2bd02a09636bf79c397a27d8499910e51c05e4aa817aa93b +import { describe, it, beforeEach, mock } from 'node:test'; +import { toSearchName } from '../../shared/search-name.js'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { + apiCallsByProvider, apiMetricsByCapability, apiMetricsGlobal, apiMetricsSeries, apiMetricsTotals, recordApiCall, +} = await import('../../server/data/api-metrics.js'); + +const VANAF = '2020-01-01T00:00'; + +let ctx; +beforeEach(() => { + harness.recreate(); + ctx = { mempool: provider('mempool'), coingecko: provider('coingecko') }; +}); + +function provider(key) { + harness.exports.run( + `INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) VALUES (?,?,?,?,'none',0,1,0,0,'2024-01-01T00:00:00')`, [key, key, toSearchName(key), `https://${key}.test`]); + return harness.exports.lastId(); +} + +function bucket({ providerId = ctx.mempool, capability = 'spot', minute, ok = 1, fail = 0, sum = 100, max = 100 }) { + harness.exports.run( + `INSERT INTO ApiCallMetric (providerId, capability, bucketMinute, okCount, failCount, sumLatencyMs, maxLatencyMs) + VALUES (?,?,?,?,?,?,?)`, [providerId, capability, minute, ok, fail, sum, max]); +} +const rijen = () => harness.exports.all( + `SELECT * FROM ApiCallMetric ORDER BY providerId, capability, bucketMinute`); + +describe('recordApiCall', () => { + it('registreert een gelukte call in de bucket van deze minuut', async () => { + await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true, latency_ms: 120 }); + const r = rijen(); + assert.equal(r.length, 1); + assert.equal(r[0].okCount, 1); + assert.equal(r[0].failCount, 0); + assert.equal(r[0].sumLatencyMs, 120); + assert.equal(r[0].maxLatencyMs, 120); + assert.match(r[0].bucketMinute, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/, + 'de bucket is een minuut-prefix, want daarop wordt later per uur en dag gegroepeerd'); + }); + + it('registreert een mislukte call apart', async () => { + await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: false, latency_ms: 80 }); + assert.equal(rijen()[0].okCount, 0); + assert.equal(rijen()[0].failCount, 1); + assert.equal(rijen()[0].sumLatencyMs, 80, 'ook een mislukte call heeft een latency'); + }); + + it('telt op binnen dezelfde minuut in plaats van te overschrijven', async () => { + await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true, latency_ms: 100 }); + await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true, latency_ms: 200 }); + await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: false, latency_ms: 50 }); + const r = rijen(); + assert.equal(r.length, 1, 'één rij per (provider, capability, minuut)'); + assert.equal(r[0].okCount, 2); + assert.equal(r[0].failCount, 1); + assert.equal(r[0].sumLatencyMs, 350, 'de som telt op'); + assert.equal(r[0].maxLatencyMs, 200, 'het maximum telt NIET op maar neemt het hoogste'); + }); + + it('houdt capabilities en providers gescheiden', async () => { + await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true, latency_ms: 10 }); + await recordApiCall({ providerId: ctx.mempool, capability: 'daily_history', ok: true, latency_ms: 10 }); + await recordApiCall({ providerId: ctx.coingecko, capability: 'spot', ok: true, latency_ms: 10 }); + assert.equal(rijen().length, 3, 'de sleutel is (provider, capability, minuut)'); + }); + + it('negeert een call zonder geldige provider', async () => { + await recordApiCall({ providerId: null, capability: 'spot', ok: true, latency_ms: 10 }); + await recordApiCall({ providerId: undefined, capability: 'spot', ok: true, latency_ms: 10 }); + await recordApiCall({ providerId: 'mempool', capability: 'spot', ok: true, latency_ms: 10 }); + await recordApiCall({ providerId: 1.5, capability: 'spot', ok: true, latency_ms: 10 }); + assert.deepEqual(rijen(), [], 'geen enkele rij, en ook geen fout'); + }); + + it('maakt van een ontbrekende of onzinnige latency een 0', async () => { + await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true }); + assert.equal(rijen()[0].sumLatencyMs, 0, 'de kolom is NOT NULL, dus dit moet een getal zijn'); + + harness.recreate(); + ctx.mempool = provider('mempool'); + await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true, latency_ms: NaN }); + assert.equal(rijen()[0].sumLatencyMs, 0); + }); + + it('rondt af en klemt een negatieve latency op 0', async () => { + await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true, latency_ms: 12.6 }); + assert.equal(rijen()[0].sumLatencyMs, 13); + + harness.recreate(); + ctx.mempool = provider('mempool'); + await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true, latency_ms: -5 }); + assert.equal(rijen()[0].sumLatencyMs, 0, 'een negatieve duur is nooit een meting'); + }); +}); + +describe('apiMetricsTotals', () => { + it('telt over de buckets en leidt gemiddelde en maximum af', () => { + bucket({ minute: '2024-05-01T10:00', ok: 2, fail: 0, sum: 300, max: 200 }); + bucket({ minute: '2024-05-01T10:01', ok: 1, fail: 1, sum: 100, max: 60 }); + assert.deepEqual(apiMetricsTotals(ctx.mempool, VANAF), { + ok: 3, fail: 1, calls: 4, avg_latency_ms: 100, maxLatencyMs: 200, + }); + }); + + it('geeft nullen als er niets is, zonder deling door nul', () => { + assert.deepEqual(apiMetricsTotals(ctx.mempool, VANAF), { + ok: 0, fail: 0, calls: 0, avg_latency_ms: 0, maxLatencyMs: 0, + }); + }); + + it('negeert buckets vóór de grens', () => { + bucket({ minute: '2024-04-30T23:59', ok: 5, sum: 500, max: 500 }); + bucket({ minute: '2024-05-01T00:00', ok: 1, sum: 100, max: 100 }); + const t = apiMetricsTotals(ctx.mempool, '2024-05-01T00:00'); + assert.equal(t.ok, 1, 'de grens is inclusief: >= sinceBucket'); + assert.equal(t.maxLatencyMs, 100, 'ook het maximum kijkt alleen binnen het venster'); + }); + + it('kijkt alleen naar de gevraagde provider', () => { + bucket({ providerId: ctx.mempool, minute: '2024-05-01T10:00', ok: 1 }); + bucket({ providerId: ctx.coingecko, minute: '2024-05-01T10:00', ok: 7 }); + assert.equal(apiMetricsTotals(ctx.mempool, VANAF).ok, 1); + assert.equal(apiMetricsTotals(ctx.coingecko, VANAF).ok, 7); + }); + + it('rondt het gemiddelde af op een heel getal', () => { + bucket({ minute: '2024-05-01T10:00', ok: 3, fail: 0, sum: 100, max: 50 }); + assert.equal(apiMetricsTotals(ctx.mempool, VANAF).avg_latency_ms, 33); + }); +}); + +describe('apiMetricsGlobal', () => { + it('telt over alle providers heen', () => { + bucket({ providerId: ctx.mempool, minute: '2024-05-01T10:00', ok: 2, fail: 1, sum: 300, max: 150 }); + bucket({ providerId: ctx.coingecko, minute: '2024-05-01T10:00', ok: 1, fail: 0, sum: 100, max: 400 }); + assert.deepEqual(apiMetricsGlobal(VANAF), { + ok: 3, fail: 1, calls: 4, avg_latency_ms: 100, maxLatencyMs: 400, + }); + }); + + it('spiegelt de vorm van apiMetricsTotals', () => { + bucket({ minute: '2024-05-01T10:00', ok: 1, sum: 10, max: 10 }); + assert.deepEqual(Object.keys(apiMetricsGlobal(VANAF)).sort(), + Object.keys(apiMetricsTotals(ctx.mempool, VANAF)).sort()); + }); + + it('geeft nullen op een lege tabel', () => { + assert.deepEqual(apiMetricsGlobal(VANAF), { + ok: 0, fail: 0, calls: 0, avg_latency_ms: 0, maxLatencyMs: 0, + }); + }); +}); + +describe('apiCallsByProvider', () => { + it('telt gelukte en mislukte aanroepen samen, per provider, over alle capabilities', () => { + bucket({ providerId: ctx.mempool, capability: 'spot', minute: '2024-05-01T10:00', ok: 2, fail: 1 }); + bucket({ providerId: ctx.mempool, capability: 'daily_history', minute: '2024-05-02T10:00', ok: 1, fail: 0 }); + bucket({ providerId: ctx.coingecko, minute: '2024-05-01T10:00', ok: 0, fail: 4 }); + const uit = new Map(apiCallsByProvider(VANAF).map(r => [r.providerId, r.calls])); + assert.equal(uit.get(ctx.mempool), 4); + assert.equal(uit.get(ctx.coingecko), 4, 'vier mislukte aanroepen kosten ook vier van het tegoed'); + }); + + it('telt vorige maand niet mee', () => { + bucket({ minute: '2024-04-30T23:59', ok: 50 }); + bucket({ minute: '2024-05-01T00:00', ok: 1 }); + assert.deepEqual(apiCallsByProvider('2024-05-01T00:00').map(r => r.calls), [1]); + }); +}); + +describe('apiMetricsByCapability', () => { + it('groepeert per capability, alfabetisch', () => { + bucket({ capability: 'spot', minute: '2024-05-01T10:00', ok: 1, sum: 100, max: 100 }); + bucket({ capability: 'spot', minute: '2024-05-01T10:01', ok: 2, sum: 200, max: 150 }); + bucket({ capability: 'daily_history', minute: '2024-05-01T10:00', ok: 1, fail: 2, sum: 90, max: 90 }); + + const r = apiMetricsByCapability(ctx.mempool, VANAF); + assert.deepEqual(r.map(x => x.capability), ['daily_history', 'spot'], + 'vaste volgorde, zodat het uitklap-paneel niet danst tussen twee aanroepen'); + assert.equal(r[0].fail, 2); + assert.equal(r[1].ok, 3); + assert.equal(r[1].max_lat, 150); + }); + + it('geeft een lege lijst zonder metingen', () => { + assert.deepEqual(apiMetricsByCapability(ctx.mempool, VANAF), []); + }); +}); + +describe('apiMetricsSeries', () => { + beforeEach(() => { + bucket({ minute: '2024-05-01T10:00', ok: 1, sum: 10, max: 10 }); + bucket({ minute: '2024-05-01T10:30', ok: 2, sum: 20, max: 40 }); + bucket({ minute: '2024-05-01T11:00', ok: 1, fail: 1, sum: 30, max: 90 }); + bucket({ minute: '2024-05-02T09:00', ok: 5, sum: 50, max: 20 }); + }); + + it('groepeert per minuut bij prefixlengte 16', () => { + assert.equal(apiMetricsSeries(ctx.mempool, VANAF, 16).length, 4, 'elke bucket apart'); + }); + + it('groepeert per uur bij prefixlengte 13', () => { + const r = apiMetricsSeries(ctx.mempool, VANAF, 13); + assert.deepEqual(r.map(x => x.b), ['2024-05-01T10', '2024-05-01T11', '2024-05-02T09']); + assert.equal(r[0].ok, 3, 'de twee buckets van 10:xx samen'); + assert.equal(r[0].max_lat, 40, 'het maximum van dat uur, niet de som'); + }); + + it('groepeert per dag bij prefixlengte 10', () => { + const r = apiMetricsSeries(ctx.mempool, VANAF, 10); + assert.deepEqual(r.map(x => x.b), ['2024-05-01', '2024-05-02']); + assert.equal(r[0].ok, 4); + assert.equal(r[0].fail, 1); + assert.equal(r[1].ok, 5); + }); + + it('loopt in chronologische volgorde', () => { + const b = apiMetricsSeries(ctx.mempool, VANAF, 13).map(x => x.b); + assert.deepEqual(b, [...b].sort(), 'een sparkline moet van links naar rechts vooruit lopen'); + }); + + it('is sparse: buckets zonder calls komen er niet in', () => { + const uren = apiMetricsSeries(ctx.mempool, VANAF, 13).map(x => x.b); + assert.equal(uren.includes('2024-05-01T12'), false); + }); + + it('respecteert de ondergrens', () => { + assert.deepEqual(apiMetricsSeries(ctx.mempool, '2024-05-02T00:00', 10).map(x => x.b), ['2024-05-02']); + }); +}); + +describe('randwaardes: apiMetricsSeries op de randen', () => { + beforeEach(() => { + bucket({ minute: '2024-05-01T10:00', ok: 1, sum: 10, max: 10 }); + bucket({ minute: '2024-05-05T10:00', ok: 1, sum: 10, max: 10 }); + }); + + it('geeft een lege lijst als er ná de grens niets meer is', () => { + assert.deepEqual(apiMetricsSeries(ctx.mempool, '2030-01-01T00:00', 10), []); + }); + + it('geeft exact één punt terug als er één bucket binnen de grens valt', () => { + const reeks = apiMetricsSeries(ctx.mempool, '2024-05-05T10:00', 10); + assert.equal(reeks.length, 1, 'één bucket blijft één punt; er wordt niets aangevuld'); + assert.equal(reeks[0].b, '2024-05-05'); + }); + + it('is sparse: de dagen tússen twee metingen komen niet in de reeks', () => { + const reeks = apiMetricsSeries(ctx.mempool, VANAF, 10); + assert.deepEqual(reeks.map(r => r.b), ['2024-05-01', '2024-05-05']); + }); +}); diff --git a/tests/repo/asset-notes.test.mjs b/tests/repo/asset-notes.test.mjs new file mode 100644 index 0000000..afef8a3 --- /dev/null +++ b/tests/repo/asset-notes.test.mjs @@ -0,0 +1,57 @@ +// Generated, do not edit. sha256:e5fc151e66dc65fbc4a3b92e5607e9c11c93c4da41ceb4d39b4aa634c9cb82b3 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { toSearchName } from '../../shared/search-name.js'; +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { getAssetNote, setAssetNote } = await import('../../server/data/asset-notes.js'); + +let btc; +beforeEach(() => { + harness.recreate(); + harness.exports.run( + `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin',?,'crypto','BTC',NULL,8,'unit')`, + [toSearchName('Bitcoin')]); + btc = harness.exports.lastId(); +}); + +describe('setAssetNote / getAssetNote', () => { + it('geeft null zonder notitie en de tekst na opslaan', async () => { + assert.equal(getAssetNote(btc), null); + const uit = await setAssetNote(btc, '2026-09-26 14:32 gekocht na de dip'); + assert.equal(uit.body, '2026-09-26 14:32 gekocht na de dip'); + assert.equal(getAssetNote(btc).body, uit.body); + assert.ok(uit.createdAt); + }); + + it('wijzigt de ene notitie en laat createdAt staan', async () => { + await setAssetNote(btc, 'eerste'); + harness.exports.run(`UPDATE AssetNote SET createdAt = '2026-01-01T00:00:00.000Z'`); + const uit = await setAssetNote(btc, 'tweede'); + assert.equal(uit.body, 'tweede'); + assert.equal(uit.createdAt, '2026-01-01T00:00:00.000Z'); + assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM AssetNote`).n, 1, 'één notitie per asset'); + }); + + it('haalt de rij weg bij een lege of alleen-witruimte notitie', async () => { + await setAssetNote(btc, 'iets'); + assert.equal(await setAssetNote(btc, ' \n '), null); + assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM AssetNote`).n, 0); + await setAssetNote(btc, 'iets'); + assert.equal(await setAssetNote(btc, null), null, 'geen tekst is leeg'); + }); + + it('weigert een onbekend asset', async () => { + await assert.rejects(() => setAssetNote(btc + 99, 'x'), /does not exist/); + }); + + it('verdwijnt met zijn asset', async () => { + await setAssetNote(btc, 'weg ermee'); + harness.exports.run(`DELETE FROM Asset WHERE id = ?`, [btc]); + assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM AssetNote`).n, 0); + }); +}); diff --git a/tests/repo/boekbare-assets.test.mjs b/tests/repo/boekbare-assets.test.mjs new file mode 100644 index 0000000..91e5698 --- /dev/null +++ b/tests/repo/boekbare-assets.test.mjs @@ -0,0 +1,45 @@ +// Generated, do not edit. sha256:841593fe1e857633d46e2fe3ce5552c3a7874ece2d6acdbc7160656fde1ef2c6 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { listAssets, listBookableAssets } = await import('../../server/data/assets.js'); +const { run, lastId } = harness.exports; + +function asset(symbol, kind) { + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES (?,?,?,?,?,'cent',2,'unit')`, [symbol, symbol, symbol.toLowerCase(), kind, symbol]); + return lastId(); +} +const symbolen = rijen => rijen.map(a => a.symbol); + +let usd; +beforeEach(() => { + harness.recreate(); + asset('BTC', 'crypto'); + asset('EUR', 'fiat'); + usd = asset('USD', 'fiat'); +}); + +describe('listBookableAssets', () => { + it('laat een ongebruikte vreemde munt weg, en de euro en bitcoin niet', () => { + assert.deepEqual(symbolen(listBookableAssets()), ['BTC', 'EUR']); + assert.deepEqual(symbolen(listAssets()), ['BTC', 'EUR', 'USD'], 'listAssets blijft alles geven'); + }); + + it('toont de munt zodra er een boeking in staat', () => { + run(`INSERT INTO Event (assetId, amount, type, source, occurredAt, createdAt) + VALUES (?, 100, 'transfer', 'manual', '2026-09-01T10:00:00Z', '2026-09-01T10:00:00Z')`, [usd]); + assert.deepEqual(symbolen(listBookableAssets()), ['BTC', 'EUR', 'USD']); + }); + + it('toont de munt zodra er een balans in staat', () => { + run(`INSERT INTO Valuation (assetId, balance, fiatRate, source, valuedOn, rateAt) + VALUES (?, 100, 0.9, 'manual', '2026-09-01', '2026-09-01T00:00:00Z')`, [usd]); + assert.deepEqual(symbolen(listBookableAssets()), ['BTC', 'EUR', 'USD']); + }); +}); diff --git a/tests/repo/bus.test.mjs b/tests/repo/bus.test.mjs new file mode 100644 index 0000000..9c87d00 --- /dev/null +++ b/tests/repo/bus.test.mjs @@ -0,0 +1,172 @@ +// Generated, do not edit. sha256:a27e8c58c9aae75ab07e5381309139cb5309819db52788dbf42ff055ad51599e +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { flushChanges, changesSince, pruneChanges } = await import('../../server/data/bus.js'); +const { emitChange } = await import('../../server/data/_events.js'); +const { CONFIG } = await import('../../shared/config.js'); + +const rijen = () => harness.exports.all(`SELECT id, name, occurredAt FROM BusSignal ORDER BY id`); + +const oudeRij = (naam, urenGeleden) => harness.exports.run( + `INSERT INTO BusSignal (name, occurredAt) VALUES (?,?)`, + [naam, new Date(Date.now() - urenGeleden * 3600_000).toISOString()]); + +beforeEach(() => { + harness.recreate(); + flushChanges(); +}); + +describe('bus: het mandje wegschrijven', () => { + it('schrijft elke naam uit het mandje naar de tabel', async () => { + emitChange('providers-changed'); + emitChange('connections-changed'); + const namen = flushChanges(); + assert.deepEqual(namen.sort(), ['connections-changed', 'providers-changed']); + assert.deepEqual(rijen().map(r => r.name).sort(), ['connections-changed', 'providers-changed']); + assert.ok(rijen()[0].occurredAt, 'en er staat een moment bij'); + }); + + it('voegt hetzelfde signaal binnen één eenheid werk samen', async () => { + emitChange('providers-changed'); + emitChange('providers-changed'); + emitChange('providers-changed'); + flushChanges(); + assert.equal(rijen().length, 1); + }); + + it('maakt het mandje leeg, zodat een tweede flush niets dubbel schrijft', async () => { + emitChange('providers-changed'); + flushChanges(); + assert.deepEqual(flushChanges(), [], 'niets meer over'); + assert.equal(rijen().length, 1); + }); + + it('doet niets bij een leeg mandje, en schrijft dus ook geen lege rij', async () => { + assert.deepEqual(flushChanges(), []); + assert.equal(rijen().length, 0); + }); + + it('GOOIT NOOIT WEG: twee opeenvolgende flushes bewaren allebei hun signaal', async () => { + emitChange('eerst-dit'); + flushChanges(); + emitChange('daarna-dit'); + flushChanges(); + assert.deepEqual(rijen().map(r => r.name), ['eerst-dit', 'daarna-dit'], 'allebei, op volgorde'); + }); +}); + +describe('bus: teruglezen sinds een cursor', () => { + it('geeft bij een nieuwe client alleen de stand en geen historie', async () => { + emitChange('oud-nieuws'); + flushChanges(); + const r = changesSince(null); + assert.deepEqual(r.names, []); + assert.equal(r.cursor, 1, 'wél de stand, zodat de volgende poll wél iets kan opleveren'); + assert.equal(r.reset, false); + }); + + it('geeft alleen wat er ná de cursor bij kwam', async () => { + emitChange('eerste'); + flushChanges(); + const na1 = changesSince(null).cursor; + emitChange('tweede'); + flushChanges(); + const r = changesSince(na1); + assert.deepEqual(r.names, ['tweede']); + assert.equal(r.cursor, 2); + }); + + it('ontdubbelt de namen, want een prikkel is inhoudsloos', async () => { + emitChange('providers-changed'); + flushChanges(); + emitChange('providers-changed'); + flushChanges(); + assert.deepEqual(changesSince(0).names, ['providers-changed'], 'twee rijen, één naam'); + }); + + it('geeft niets terug als er niets nieuws is, en houdt de cursor gelijk', async () => { + emitChange('iets'); + flushChanges(); + const r = changesSince(1); + assert.deepEqual(r.names, []); + assert.equal(r.cursor, 1); + }); + + it('zegt "ververs alles" als de cursor achterloopt op de oudste bewaarde rij', async () => { + for (const naam of ['een', 'twee', 'drie']) { emitChange(naam); flushChanges(); } + harness.exports.run(`DELETE FROM BusSignal WHERE id <= 2`); + const r = changesSince(0); + assert.equal(r.reset, true); + assert.deepEqual(r.names, [], 'en géén halve lijst erbij, die zou suggereren dat het daarbij blijft'); + assert.equal(r.cursor, 3); + }); + + it('meldt geen reset als de cursor precies aansluit op de oudste rij', async () => { + for (const naam of ['een', 'twee', 'drie']) { emitChange(naam); flushChanges(); } + harness.exports.run(`DELETE FROM BusSignal WHERE id <= 2`); + const r = changesSince(2); + assert.equal(r.reset, false); + assert.deepEqual(r.names, ['drie']); + }); + + it('behandelt onzin als een nieuwe client in plaats van om te vallen', async () => { + emitChange('iets'); + flushChanges(); + for (const raar of ['onzin', -1, 1.5, undefined, {}]) { + const r = changesSince(raar); + assert.deepEqual(r.names, [], `${JSON.stringify(raar)} hoort als "ik begin nu" te lezen`); + assert.equal(r.reset, false); + } + }); + + it('geeft op een lege bus een cursor van nul en geen null', async () => { + const r = changesSince(null); + assert.deepEqual(r, { cursor: 0, names: [], reset: false }); + }); +}); + +describe('bus: opruimen', () => { + it('gooit weg wat ouder is dan de bewaartermijn en laat de rest staan', async () => { + oudeRij('van-gisteren', CONFIG.BUS.RETENTION_HOURS + 1); + oudeRij('van-net', 1); + assert.equal(pruneChanges(), 1, 'geeft terug hoeveel er weg zijn'); + assert.deepEqual(rijen().map(r => r.name), ['van-net']); + }); + + it('laat een rij die binnen de termijn valt staan', async () => { + oudeRij('net-binnen', CONFIG.BUS.RETENTION_HOURS - 1); + assert.equal(pruneChanges(), 0); + assert.equal(rijen().length, 1); + }); + + it('doet niets op een lege bus', async () => { + assert.equal(pruneChanges(), 0); + }); + + it('valt terug op de SSOT-waarde bij onzin', async () => { + oudeRij('oud', CONFIG.BUS.RETENTION_HOURS + 2); + assert.equal(pruneChanges('onzin'), 1); + }); + + it('ruimt op tijdens het wegschrijven, zonder dat de aanroeper er iets voor doet', async () => { + oudeRij('van-gisteren', CONFIG.BUS.RETENTION_HOURS + 1); + emitChange('nieuw'); + flushChanges(); + assert.deepEqual(rijen().map(r => r.name), ['nieuw'], 'de oude rij is meegenomen'); + }); + + it('ruimt bij élke schrijfactie op, ook de tweede', async () => { + emitChange('eerste'); + flushChanges(); + oudeRij('van-gisteren', CONFIG.BUS.RETENTION_HOURS + 1); + emitChange('tweede'); + flushChanges(); + assert.equal(rijen().some(r => r.name === 'van-gisteren'), false, 'ook nu meegenomen'); + }); +}); diff --git a/tests/repo/change-log.test.mjs b/tests/repo/change-log.test.mjs new file mode 100644 index 0000000..f83cafc --- /dev/null +++ b/tests/repo/change-log.test.mjs @@ -0,0 +1,191 @@ +// Generated, do not edit. sha256:df7d96413775b40f968338398229b7ff06e570855f37d25616828524e6f0575d +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness({ seedPreferences: true }); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { trackUpdate, trackDelete, trackUpdateMany, withOrigin, currentOrigin, rowHistory } = + await import('../../server/data/change-log.js'); +const db = harness.exports; + +function account(naam = 'Kluis') { + db.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive) + VALUES (?, ?, 'vault', 1, 0, 1)`, [naam, naam.toLowerCase()]); + return db.lastId(); +} +const logboek = () => db.all(`SELECT * FROM ChangeLog ORDER BY id`); + +beforeEach(() => harness.recreate()); + +describe('trackUpdate: wat er veranderde', () => { + it('schrijft één regel met alleen de gewijzigde velden', () => { + const id = account(); + trackUpdate('Account', id, () => db.run(`UPDATE Account SET name = ? WHERE id = ?`, ['Nieuwe naam', id])); + + const regels = logboek(); + assert.equal(regels.length, 1); + assert.equal(regels[0].tableName, 'Account'); + assert.equal(regels[0].rowId, id); + assert.equal(regels[0].operation, 'update'); + assert.deepEqual(JSON.parse(regels[0].changes), { name: ['Kluis', 'Nieuwe naam'] }, + 'alleen wat er veranderde, met oud én nieuw'); + }); + + it('zet updatedAt, en meldt dat niet in zijn eigen regel', () => { + const id = account(); + assert.equal(db.one(`SELECT updatedAt AS u FROM Account WHERE id = ?`, [id]).u, null); + + trackUpdate('Account', id, () => db.run(`UPDATE Account SET name = 'Anders' WHERE id = ?`, [id])); + + assert.ok(db.one(`SELECT updatedAt AS u FROM Account WHERE id = ?`, [id]).u, 'updatedAt hoort gezet'); + assert.deepEqual(Object.keys(JSON.parse(logboek()[0].changes)), ['name'], + 'updatedAt is boekhouding en geen antwoord op een vraag'); + }); + + it('meldt updatedAt ook niet als de aanroeper hem ZÉLF zet', () => { + const id = account(); + trackUpdate('Account', id, () => + db.run(`UPDATE Account SET name = 'X', updatedAt = '2020-01-01T00:00:00Z' WHERE id = ?`, [id])); + + assert.deepEqual(Object.keys(JSON.parse(logboek()[0].changes)), ['name'], + 'het tijdstempel is boekhouding, ook als de aanroeper hem zelf zette'); + }); + + it('schrijft NIETS als er niets veranderde', () => { + const id = account(); + trackUpdate('Account', id, () => db.run(`UPDATE Account SET name = 'Kluis' WHERE id = ?`, [id])); + + assert.deepEqual(logboek(), []); + assert.equal(db.one(`SELECT updatedAt AS u FROM Account WHERE id = ?`, [id]).u, null, + 'en dan hoort updatedAt ook niet op te schuiven'); + }); + + it('meldt meerdere velden in één regel', () => { + const id = account(); + trackUpdate('Account', id, () => + db.run(`UPDATE Account SET name = 'X', kind = 'bank' WHERE id = ?`, [id])); + + const regels = logboek(); + assert.equal(regels.length, 1, 'één handeling is één regel, ook als er twee velden veranderen'); + assert.deepEqual(JSON.parse(regels[0].changes), { name: ['Kluis', 'X'], kind: ['vault', 'bank'] }); + }); + + it('laat een tabel die niet meedoet met rust', () => { + trackUpdate('Preference', 1, () => db.run(`UPDATE Preference SET value = '"light"' WHERE key = 'theme'`)); + + assert.deepEqual(logboek(), []); + }); +}); + +describe('trackDelete: wat er verdween', () => { + it('legt de hele rij vast en niet een verschil', () => { + const id = account('Weg hiermee'); + trackDelete('Account', id, () => db.run(`DELETE FROM Account WHERE id = ?`, [id])); + + const regels = logboek(); + assert.equal(regels.length, 1); + assert.equal(regels[0].operation, 'delete'); + const rij = JSON.parse(regels[0].changes); + assert.equal(rij.name, 'Weg hiermee', 'bij een delete bestaat er geen nieuw: wat er verdween ÍS de rij'); + assert.equal(rij.kind, 'vault'); + }); + + it('doet niets als de rij niet bestond', () => { + trackDelete('Account', 999, () => db.run(`DELETE FROM Account WHERE id = 999`)); + assert.deepEqual(logboek(), []); + }); +}); + +describe('trackUpdateMany: één regel per geraakte rij', () => { + it('schrijft per rij en niet per ronde', () => { + const a = account('Een'), b = account('Twee'); + trackUpdateMany('Account', [a, b], () => db.run(`UPDATE Account SET kind = 'bank' WHERE id IN (?, ?)`, [a, b])); + + const regels = logboek(); + assert.equal(regels.length, 2, 'anders is "wat is er met DEZE rij gebeurd" onbeantwoordbaar'); + assert.deepEqual(regels.map(r => r.rowId), [a, b]); + for (const r of regels) assert.deepEqual(JSON.parse(r.changes).kind, ['vault', 'bank']); + }); + + it('slaat de rijen over die niet veranderden', () => { + const a = account('Een'), b = account('Twee'); + trackUpdateMany('Account', [a, b], () => db.run(`UPDATE Account SET kind = 'bank' WHERE id = ?`, [a])); + + assert.deepEqual(logboek().map(r => r.rowId), [a]); + }); +}); + +describe('withOrigin: door wie', () => { + it('staat standaard op handmatig', () => { + const id = account(); + trackUpdate('Account', id, () => db.run(`UPDATE Account SET name = 'X' WHERE id = ?`, [id])); + + assert.equal(logboek()[0].origin, 'manual'); + assert.equal(logboek()[0].originId, null, 'een handmatige wijziging heeft geen rij die hem aanwijst'); + }); + + it('legt de herkomst en het voorstel vast', () => { + const id = account(); + withOrigin('ai_proposal', 42, () => + trackUpdate('Account', id, () => db.run(`UPDATE Account SET name = 'X' WHERE id = ?`, [id]))); + + assert.equal(logboek()[0].origin, 'ai_proposal'); + assert.equal(logboek()[0].originId, 42); + }); + + it('herstelt de vorige herkomst, ook als het misgaat', () => { + assert.throws(() => withOrigin('import', 7, () => { throw new Error('mislukt'); }), /mislukt/); + assert.deepEqual(currentOrigin(), { origin: 'manual', originId: null }); + }); + + it('weigert een herkomst die niet in de enum staat', () => { + assert.throws(() => withOrigin('zomaar', null, () => {}), /Unknown origin/); + }); +}); + +describe('de datalaag schrijft echt mee', () => { + it('legt een gewijzigde rekening vast, via het echte domeinpad', async () => { + const { createAccount, updateAccount } = await import('../../server/data/accounts.js'); + const id = await createAccount({ name: 'Beurs', kind: 'exchange', isOwnCustody: 0, scanEnabled: 0 }); + assert.deepEqual(logboek(), [], 'aanmaken levert geen regel op: dat staat in createdAt'); + + await updateAccount(id, { name: 'Andere beurs', kind: 'exchange', isOwnCustody: 0, scanEnabled: 0 }); + + const regels = logboek(); + assert.equal(regels.length, 1); + assert.equal(regels[0].tableName, 'Account'); + assert.equal(regels[0].rowId, id); + assert.deepEqual(JSON.parse(regels[0].changes).name, ['Beurs', 'Andere beurs']); + }); + + it('legt een verwijderde rekening vast met zijn hele rij', async () => { + const { createAccount, deleteAccount } = await import('../../server/data/accounts.js'); + const id = await createAccount({ name: 'Weg', kind: 'vault', isOwnCustody: 1, scanEnabled: 0 }); + await deleteAccount(id); + + const regels = logboek(); + assert.equal(regels.length, 1); + assert.equal(regels[0].operation, 'delete'); + assert.equal(JSON.parse(regels[0].changes).name, 'Weg'); + }); +}); + +describe('rowHistory: de leeskant', () => { + it('geeft de wijzigingen van één rij, nieuwste eerst', () => { + const id = account(); + trackUpdate('Account', id, () => db.run(`UPDATE Account SET name = 'Tweede' WHERE id = ?`, [id])); + trackUpdate('Account', id, () => db.run(`UPDATE Account SET name = 'Derde' WHERE id = ?`, [id])); + + const h = rowHistory('Account', id); + assert.equal(h.length, 2); + assert.deepEqual(h[0].changes.name, ['Tweede', 'Derde'], 'nieuwste eerst'); + assert.deepEqual(h[1].changes.name, ['Kluis', 'Tweede']); + }); + + it('geeft niets terug voor een rij zonder geschiedenis', () => { + assert.deepEqual(rowHistory('Account', account()), []); + }); +}); diff --git a/tests/repo/commands.test.mjs b/tests/repo/commands.test.mjs new file mode 100644 index 0000000..ebb3199 --- /dev/null +++ b/tests/repo/commands.test.mjs @@ -0,0 +1,200 @@ +// Generated, do not edit. sha256:dd655ee8d8d39adcd4b3b77241ca06552db9cdf6cc3583a38396eaddd1252282 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { createCommand, claimNextCommand, resolveCommand, getCommand, pruneCommands } = + await import('../../server/data/commands.js'); +const { takeChanges } = await import('../../server/data/_events.js'); +const { CONFIG } = await import('../../shared/config.js'); + +const straks = (seconden = 60) => new Date(Date.now() + seconden * 1000).toISOString(); +const zoëven = (seconden = 60) => new Date(Date.now() - seconden * 1000).toISOString(); +const doel = { route: 'system', tab: 'ai' }; + +beforeEach(() => { harness.recreate(); takeChanges(); }); + +describe('commands: klaarzetten', () => { + it('legt een opdracht neer als open, met zijn lading', async () => { + const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + const c = getCommand(id); + assert.equal(c.state, 'open'); + assert.deepEqual(c.payload, doel, 'de lading komt als object terug, niet als JSON-tekst'); + assert.equal(c.claimedBy, null); + assert.equal(c.resolvedAt, null); + }); + + it('meldt op de bus dát er iets klaarstaat, zonder te zeggen wát', async () => { + await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + assert.deepEqual(takeChanges(), ['commands-changed']); + }); + + it('weigert een verzonnen soort, en legt dan niets neer', async () => { + await assert.rejects(() => createCommand({ kind: 'zweef', payload: doel }), /Invalid kind/); + assert.equal(await claimNextCommand('tab-1'), null, 'er staat niets klaar'); + assert.deepEqual(takeChanges(), [], 'en er gaat ook geen signaal uit'); + }); +}); + +describe('commands: het claim-model', () => { + it('geeft de opdracht aan het tabblad dat hem als eerste pakt', async () => { + const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + const eerste = await claimNextCommand('tab-1'); + assert.equal(eerste.id, id); + assert.equal(eerste.state, 'claimed'); + assert.equal(eerste.claimedBy, 'tab-1'); + }); + + it('GEEFT HEM NIET AAN HET TWEEDE TABBLAD, en dat is de kern van deze fase', async () => { + await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + const eerste = await claimNextCommand('tab-1'); + const tweede = await claimNextCommand('tab-2'); + assert.ok(eerste); + assert.equal(tweede, null, 'de tweede krijgt niets en weet dus dat een ander hem heeft'); + }); + + it('geeft null als er niets klaarstaat', async () => { + assert.equal(await claimNextCommand('tab-1'), null); + }); + + it('pakt de oudste eerst', async () => { + const een = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + assert.equal((await claimNextCommand('tab-1')).id, een); + }); + + it('weigert een claim zonder tabblad in plaats van hem aan niemand toe te wijzen', async () => { + await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + await assert.rejects(() => claimNextCommand(''), /without a tab/); + }); +}); + +describe('commands: claimen op soort', () => { + it('LAAT LIGGEN WAT DIT TABBLAD NIET KAN, en dat is de reden dat de filter bestaat', async () => { + const id = await createCommand({ kind: 'locate' }); + assert.equal(await claimNextCommand('tab-1', ['navigate']), null, 'niet opgepakt'); + assert.equal(getCommand(id).state, 'open', 'en ook niet afgewezen: hij blijft gewoon staan'); + }); + + it('slaat een soort over en pakt de eerstvolgende die hij wél kent', async () => { + await createCommand({ kind: 'locate' }); + const kan = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + assert.equal((await claimNextCommand('tab-1', ['navigate'])).id, kan); + }); + + it('pakt zonder filter nog steeds alles', async () => { + const id = await createCommand({ kind: 'locate' }); + assert.equal((await claimNextCommand('tab-1')).id, id); + }); + + it('weigert een verzonnen soort en een lege lijst', async () => { + await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + await assert.rejects(() => claimNextCommand('tab-1', ['zweef']), /Invalid kind/); + await assert.rejects(() => claimNextCommand('tab-1', []), /zero kinds/); + }); +}); + +describe('commands: verlopen', () => { + it('pakt een verlopen opdracht niet op, maar sluit hem af', async () => { + const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: zoëven() }); + assert.equal(await claimNextCommand('tab-1'), null); + const c = getCommand(id); + assert.equal(c.state, 'expired'); + assert.ok(c.resolvedAt, 'en hij is afgehandeld, niet blijven hangen'); + assert.match(c.detail, /op tijd/); + }); + + it('laat een opdracht zonder verlooptijd staan', async () => { + const id = await createCommand({ kind: 'navigate', payload: doel }); + const c = await claimNextCommand('tab-1'); + assert.equal(c.id, id, 'hij is gewoon op te pakken'); + }); +}); + +describe('commands: afsluiten', () => { + it('rondt af als uitgevoerd', async () => { + const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + await claimNextCommand('tab-1'); + await resolveCommand(id, 'tab-1', 'done'); + const c = getCommand(id); + assert.equal(c.state, 'done'); + assert.ok(c.resolvedAt); + }); + + it('rondt af als tegengehouden, met de reden erbij', async () => { + const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + await claimNextCommand('tab-1'); + await resolveCommand(id, 'tab-1', 'rejected', 'Gestopt door de gebruiker.'); + const c = getCommand(id); + assert.equal(c.state, 'rejected'); + assert.equal(c.detail, 'Gestopt door de gebruiker.'); + }); + + it('LAAT EEN ANDER TABBLAD HEM NIET AFSLUITEN', async () => { + const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + await claimNextCommand('tab-1'); + await resolveCommand(id, 'tab-2', 'done'); + assert.equal(getCommand(id).state, 'claimed', 'onaangeroerd'); + }); + + it('sluit een al afgesloten opdracht niet nog eens af', async () => { + const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + await claimNextCommand('tab-1'); + await resolveCommand(id, 'tab-1', 'done'); + await resolveCommand(id, 'tab-1', 'rejected', 'te laat'); + assert.equal(getCommand(id).state, 'done', 'de eerste uitkomst blijft staan'); + }); + + it('weigert een uitkomst die geen afronding is', async () => { + const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + await claimNextCommand('tab-1'); + await assert.rejects(() => resolveCommand(id, 'tab-1', 'open'), /not a conclusion/); + await assert.rejects(() => resolveCommand(id, 'tab-1', 'claimed'), /not a conclusion/); + }); + + it('weigert een verzonnen uitkomst', async () => { + const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() }); + await claimNextCommand('tab-1'); + await assert.rejects(() => resolveCommand(id, 'tab-1', 'weggewaaid'), /Invalid state/); + }); +}); + +describe('commands: opruimen', () => { + const verouder = (id) => harness.exports.run(`UPDATE Command SET resolvedAt = ? WHERE id = ?`, + [new Date(Date.now() - (CONFIG.COMMANDS.RETENTION_HOURS + 1) * 3600_000).toISOString(), id]); + + it('gooit alleen afgehandelde opdrachten weg', async () => { + const oud = await createCommand({ kind: 'navigate', payload: doel }); + await claimNextCommand('tab-1'); + await resolveCommand(oud, 'tab-1', 'done'); + const open = await createCommand({ kind: 'navigate', payload: doel }); + verouder(oud); + + assert.equal(pruneCommands(), 1); + assert.equal(getCommand(oud), null); + assert.ok(getCommand(open), 'wat nog openstaat blijft, hoe oud ook'); + }); + + it('ruimt op zodra er een nieuwe opdracht wordt klaargezet', async () => { + const oud = await createCommand({ kind: 'navigate', payload: doel }); + await claimNextCommand('tab-1'); + await resolveCommand(oud, 'tab-1', 'done'); + verouder(oud); + + const nieuw = await createCommand({ kind: 'navigate', payload: doel }); + assert.equal(getCommand(oud), null, 'de oude is weg zonder dat iemand erom vroeg'); + assert.ok(getCommand(nieuw), 'en de nieuwe staat er gewoon'); + }); + + it('laat een recent afgehandelde opdracht staan', async () => { + const id = await createCommand({ kind: 'navigate', payload: doel }); + await claimNextCommand('tab-1'); + await resolveCommand(id, 'tab-1', 'done'); + assert.equal(pruneCommands(), 0); + assert.ok(getCommand(id)); + }); +}); diff --git a/tests/repo/counts-scanlogs.test.mjs b/tests/repo/counts-scanlogs.test.mjs new file mode 100644 index 0000000..84d489f --- /dev/null +++ b/tests/repo/counts-scanlogs.test.mjs @@ -0,0 +1,165 @@ +// Generated, do not edit. sha256:dddfdfd366ef82253787978dcf8368fdcb9f0cf259774eee0d63e666ef6c597c +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; +import { CONFIG, ENUMS } from '../../shared/config.js'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { counts, tableCounts } = await import('../../server/data/counts.js'); +const { createScanLog, latestScanLog, listScanLogs } = await import('../../server/data/scan-logs.js'); + +let ctx; +beforeEach(() => { + harness.recreate(); + harness.exports.run( + `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`); + const btc = harness.exports.lastId(); + harness.exports.run( + `INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Kluis','kluis','wallet',1,1,1,NULL)`); + ctx = { btc, account: harness.exports.lastId() }; +}); + +function gebeurtenis() { + harness.exports.run( + `INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt) + VALUES (?, 'buy', '2024-01-01T00:00:00', 100000, 50000, ?, 'manual', '2024-01-01T00:00:00')`, + [ctx.btc, ctx.account]); +} + +describe('counts', () => { + it('telt de vier hoofd-entiteiten', () => { + gebeurtenis(); + harness.exports.run( + `INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source) + VALUES (?, '2024-12-31', ?, 1, 1, '2024-12-31T23:00:00', 'manual')`, [ctx.btc, ctx.account]); + assert.deepEqual(counts(), { assets: 1, accounts: 1, events: 1, valuations: 1 }); + }); + + it('geeft nullen op een lege database in plaats van niets', () => { + harness.exports.run(`DELETE FROM Asset`); + harness.exports.run(`DELETE FROM Account`); + assert.deepEqual(counts(), { assets: 0, accounts: 0, events: 0, valuations: 0 }); + }); +}); + +describe('tableCounts', () => { + it('geeft een rij per tabel uit de whitelist, in dezelfde volgorde', () => { + const rijen = tableCounts(); + assert.deepEqual(rijen.map(r => r.name), [...CONFIG.DB_TABLES], + 'de widget toont de lijst in configuratie-volgorde, dus die mag niet gesorteerd raken'); + }); + + it('draait elke naam uit de whitelist echt af zonder te werpen', () => { + const rijen = tableCounts(); + assert.ok(rijen.length >= 10, `verwacht een flinke whitelist, kreeg ${rijen.length}`); + for (const r of rijen) { + assert.equal(typeof r.n, 'number', `${r.name} gaf geen getal terug`); + assert.ok(r.n >= 0); + } + }); + + it('telt echt en geeft niet overal nul', () => { + gebeurtenis(); + const perTabel = Object.fromEntries(tableCounts().map(r => [r.name, r.n])); + assert.equal(perTabel.Event, 1); + assert.equal(perTabel.Asset, 1); + assert.equal(perTabel.Valuation, 0); + }); +}); + +describe('createScanLog', () => { + const scan = (extra = {}) => ({ accountId: ctx.account, status: 'success', ...extra }); + const rij = (id) => harness.exports.one(`SELECT * FROM ScanLog WHERE id = ?`, [id]); + + it('bewaart de velden en geeft het id terug', async () => { + const id = await createScanLog(scan({ + scannedAt: '2024-05-01T12:00:00', addressesFound: 12, balanceFound: 750_000, proposalsCreated: 2, + })); + const s = rij(id); + assert.equal(s.accountId, ctx.account); + assert.equal(s.scannedAt, '2024-05-01T12:00:00'); + assert.equal(s.status, 'success'); + assert.equal(s.addressesFound, 12); + assert.equal(s.balanceFound, 750_000); + assert.equal(s.proposalsCreated, 2); + }); + + it('zet het tijdstip zelf als de aanroeper er geen geeft', async () => { + const id = await createScanLog(scan()); + assert.ok(rij(id).scannedAt, 'een scan zonder tijdstip is niet te ordenen'); + }); + + it('bewaart rawData als JSON-tekst', async () => { + const id = await createScanLog(scan({ rawData: { adressen: ['bc1q…'], saldo: 750_000 } })); + const ruw = rij(id).rawData; + assert.equal(typeof ruw, 'string'); + assert.deepEqual(JSON.parse(ruw), { adressen: ['bc1q…'], saldo: 750_000 }); + }); + + it('laat rawData leeg als er niets is', async () => { + const id = await createScanLog(scan()); + assert.equal(rij(id).rawData, null, 'geen "null" als tekst, maar echt leeg'); + }); + + it('accepteert elke status uit de registry en weigert de rest', async () => { + for (const status of ENUMS.SCAN_STATUS) { + const id = await createScanLog(scan({ status })); + assert.equal(rij(id).status, status); + } + await assert.rejects(() => createScanLog(scan({ status: 'bijna-goed' })), /status/); + }); + + it('persisteert', async () => { + const voor = harness.calls.persist; + await createScanLog(scan()); + assert.equal(harness.calls.persist, voor + 1); + }); +}); + +describe('latestScanLog en listScanLogs', () => { + const scan = (extra = {}) => createScanLog({ accountId: ctx.account, status: 'success', ...extra }); + + it('geeft de laatste scan van dat account', async () => { + await scan({ scannedAt: '2024-01-01T00:00:00' }); + const laatste = await scan({ scannedAt: '2024-06-01T00:00:00' }); + assert.equal(latestScanLog(ctx.account).id, laatste); + }); + + it('kiest bij een gelijk tijdstip de hoogste id', async () => { + await scan({ scannedAt: '2024-06-01T00:00:00', addressesFound: 1 }); + const tweede = await scan({ scannedAt: '2024-06-01T00:00:00', addressesFound: 2 }); + assert.equal(latestScanLog(ctx.account).id, tweede); + assert.equal(latestScanLog(ctx.account).addressesFound, 2); + }); + + it('geeft null als er nog nooit gescand is', () => { + assert.equal(latestScanLog(ctx.account), null); + }); + + it('kijkt alleen naar het gevraagde account', async () => { + harness.exports.run( + `INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Ander','ander','wallet',1,1,1,NULL)`); + const ander = harness.exports.lastId(); + await scan({ scannedAt: '2024-06-01T00:00:00' }); + assert.equal(latestScanLog(ander), null); + assert.deepEqual(listScanLogs(ander), []); + }); + + it('geeft de volledige historie, nieuwste eerst', async () => { + await scan({ scannedAt: '2024-01-01T00:00:00' }); + await scan({ scannedAt: '2024-06-01T00:00:00' }); + await scan({ scannedAt: '2024-03-01T00:00:00' }); + assert.deepEqual(listScanLogs(ctx.account).map(s => s.scannedAt), + ['2024-06-01T00:00:00', '2024-03-01T00:00:00', '2024-01-01T00:00:00']); + }); + + it('is append-only: een tweede scan overschrijft de eerste niet', async () => { + await scan({ scannedAt: '2024-01-01T00:00:00', status: 'failed' }); + await scan({ scannedAt: '2024-06-01T00:00:00', status: 'success' }); + assert.equal(listScanLogs(ctx.account).length, 2); + assert.deepEqual(listScanLogs(ctx.account).map(s => s.status), ['success', 'failed']); + }); +}); diff --git a/tests/repo/dashboard.test.mjs b/tests/repo/dashboard.test.mjs new file mode 100644 index 0000000..5eca715 --- /dev/null +++ b/tests/repo/dashboard.test.mjs @@ -0,0 +1,244 @@ +// Generated, do not edit. sha256:b4d746208e0e2104c1a5250636bc0551b849cdc5c3d7fff3b175f2d7cb20fa75 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { + listProfiles, activeProfile, createProfile, renameProfile, activateProfile, deleteProfile, + listWidgets, saveWidgets, +} = await import('../../server/data/dashboard.js'); +const { widgetParams } = await import('../../client/data/dashboard.js'); +const { DASHBOARD } = await import('../../shared/config.js'); + +function systeemprofiel() { + harness.exports.run( + `INSERT INTO DashboardProfile (name, searchName, description, sortOrder, isActive, isSystem, createdAt, updatedAt) + VALUES ('Standaard','standaard',NULL,0,1,1,'2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`); + return harness.exports.lastId(); +} + +let btc; +beforeEach(async () => { + harness.recreate(); + harness.exports.run( + `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`); + btc = harness.exports.lastId(); + const id = systeemprofiel(); + await saveWidgets(id, null); +}); + +const namen = () => listProfiles().map(p => p.name); +const actieveNaam = () => activeProfile().name; +const aantalActief = () => harness.exports.one(`SELECT COUNT(*) AS n FROM DashboardProfile WHERE isActive = 1`).n; + +describe('exact één actief profiel', () => { + it('houdt het bij één als je wisselt', async () => { + const id = await createProfile({ name: 'Fiscaal' }); + assert.equal(aantalActief(), 1, 'een nieuw profiel komt inactief binnen'); + await activateProfile(id); + assert.equal(aantalActief(), 1); + assert.equal(actieveNaam(), 'Fiscaal'); + }); + + it('kan heen en terug wisselen', async () => { + const id = await createProfile({ name: 'Fiscaal' }); + const systeem = listProfiles().find(p => p.isSystem).id; + await activateProfile(id); + await activateProfile(systeem); + assert.equal(aantalActief(), 1); + assert.equal(actieveNaam(), 'Standaard'); + }); + + it('wijst een opvolger aan als je het actieve profiel verwijdert', async () => { + const id = await createProfile({ name: 'Fiscaal' }); + await activateProfile(id); + await deleteProfile(id); + assert.equal(aantalActief(), 1); + assert.equal(actieveNaam(), 'Standaard'); + }); + + it('zet het systeemprofiel terug op de standaardindeling in plaats van het te verwijderen', async () => { + const systeem = listProfiles().find(p => p.isSystem).id; + await saveWidgets(systeem, [{ instanceKey: 'clocks', type: 'clocks', gridX: 3, gridY: 2, spanWidth: 6, spanHeight: 4, params: { cities: ['Tokyo'] } }]); + await deleteProfile(systeem); + assert.deepEqual(namen(), ['Standaard'], 'de rij blijft bestaan'); + const widgets = listWidgets(systeem); + assert.deepEqual(widgets.map(w => w.instanceKey).sort(), DASHBOARD.DEFAULT_LAYOUT.map(w => w.instId).sort()); + assert.ok(widgets.every(w => w.spanHeight == null && w.settings == null), 'vers: niet gemeten, geen instellingen'); + assert.deepEqual(Object.fromEntries(widgets.map(w => [w.instanceKey, w.gridX])), + Object.fromEntries(DASHBOARD.DEFAULT_LAYOUT.map(w => [w.instId, w.x || 0]))); + assert.equal(actieveNaam(), 'Standaard', 'het blijft het actieve profiel'); + }); + + it('laat bij het terugzetten van het systeemprofiel een ander actief profiel en zijn indeling staan', async () => { + const id = await createProfile({ name: 'Fiscaal' }); + await saveWidgets(id, [{ instanceKey: 'signal', type: 'signal', gridX: 0, gridY: 0, spanWidth: 4, spanHeight: 3, params: null }]); + await activateProfile(id); + await deleteProfile(listProfiles().find(p => p.isSystem).id); + assert.equal(actieveNaam(), 'Fiscaal'); + assert.equal(aantalActief(), 1); + assert.deepEqual(listWidgets(id).map(w => w.instanceKey), ['signal'], 'alleen het systeemprofiel begint opnieuw'); + }); + + it('weigert een onbekend profiel in alle richtingen', async () => { + await assert.rejects(() => activateProfile(9999), (e) => e.key === 'dashboard.unknownProfile'); + await assert.rejects(() => deleteProfile(9999), (e) => e.key === 'dashboard.unknownProfile'); + await assert.rejects(() => renameProfile(9999, { name: 'X' }), (e) => e.key === 'dashboard.unknownProfile'); + await assert.rejects(() => saveWidgets(9999, []), (e) => e.key === 'dashboard.unknownProfile'); + }); + + it('klaagt hoorbaar als er tóch geen actief profiel is', () => { + harness.exports.run(`UPDATE DashboardProfile SET isActive = 0`); + assert.throws(() => activeProfile(), /No active dashboard profile/); + }); +}); + +describe('profielen beheren', () => { + it('geeft een nieuw profiel de standaardindeling en een eigen sortOrder', async () => { + const id = await createProfile({ name: 'Fiscaal', description: 'Voor de aangifte' }); + const p = listProfiles().find(x => x.id === id); + assert.equal(p.description, 'Voor de aangifte'); + assert.equal(p.isSystem, 0); + assert.ok(p.sortOrder > 0, 'komt achter het systeemprofiel'); + assert.equal(listWidgets(id).length, DASHBOARD.DEFAULT_LAYOUT.length); + }); + + it('leidt searchName af en weigert een dubbele naam', async () => { + const id = await createProfile({ name: 'Fiscaal' }); + assert.equal(listProfiles().find(x => x.id === id).searchName, 'fiscaal'); + await assert.rejects(() => createProfile({ name: 'fiscaal' })); + }); + + it('weigert een lege naam bij aanmaken en hernoemen', async () => { + await assert.rejects(() => createProfile({ name: ' ' }), (e) => e.key === 'dashboard.nameRequired'); + const systeem = listProfiles().find(p => p.isSystem).id; + await assert.rejects(() => renameProfile(systeem, { name: '' }), (e) => e.key === 'dashboard.nameRequired'); + }); + + it('hernoemt met searchName mee', async () => { + const id = await createProfile({ name: 'Fiscaal' }); + await renameProfile(id, { name: 'Belasting' }); + const p = listProfiles().find(x => x.id === id); + assert.equal(p.name, 'Belasting'); + assert.equal(p.searchName, 'belasting'); + }); + + it('neemt de widgets mee als een profiel verdwijnt', async () => { + const id = await createProfile({ name: 'Fiscaal' }); + assert.ok(listWidgets(id).length > 0); + await deleteProfile(id); + assert.equal(harness.exports.one( + `SELECT COUNT(*) AS n FROM DashboardWidget WHERE profileId = ?`, [id]).n, 0); + assert.deepEqual(namen(), ['Standaard']); + }); +}); + +describe('widgets van een profiel', () => { + const indeling = (extra = {}) => [{ + instanceKey: 'signal', type: 'signal', gridX: 0, gridY: 0, spanWidth: 4, spanHeight: 3, params: null, ...extra, + }]; + + it('vervangt de hele set', async () => { + const id = activeProfile().id; + await saveWidgets(id, indeling()); + assert.deepEqual(listWidgets(id).map(w => w.instanceKey), ['signal'], + 'de standaardindeling is vervangen, niet aangevuld'); + }); + + it('slaat een leeg raster op als leeg en niet als de standaard', async () => { + const id = activeProfile().id; + await saveWidgets(id, []); + assert.deepEqual(listWidgets(id), []); + }); + + it('herstelt de standaardindeling met null', async () => { + const id = activeProfile().id; + await saveWidgets(id, []); + await saveWidgets(id, null); + assert.deepEqual(listWidgets(id).map(w => w.type).sort(), DASHBOARD.DEFAULT_LAYOUT.map(w => w.type).sort()); + }); + + it('bewaart een ongemeten hoogte als NULL', async () => { + const id = activeProfile().id; + await saveWidgets(id, indeling({ spanHeight: null })); + assert.equal(listWidgets(id)[0].spanHeight, null); + }); + + it('weigert een onbekend widgettype zónder iets weg te gooien', async () => { + const id = activeProfile().id; + const voor = listWidgets(id).length; + await assert.rejects(() => saveWidgets(id, indeling({ type: 'kwartaalcijfers' })), /Unknown widget type/); + assert.equal(listWidgets(id).length, voor, 'de bestaande indeling staat er nog'); + }); + + it('splitst assetId naar zijn eigen kolom en de rest naar settings', async () => { + const id = activeProfile().id; + await saveWidgets(id, indeling({ + instanceKey: 'spot-1', type: 'spot', params: { assetId: btc, cities: ['amsterdam'] }, + })); + const rij = listWidgets(id)[0]; + assert.equal(rij.assetId, btc, 'een verwijzing hoort een kolom te zijn, want die moet bewaakt worden'); + assert.equal(rij.settings, JSON.stringify({ cities: ['amsterdam'] }), + 'assetId staat NIET ook in settings: twee plekken die hetzelfde beweren gaan afwijken'); + }); + + it('geeft ze als één object terug', async () => { + const id = activeProfile().id; + await saveWidgets(id, indeling({ + instanceKey: 'clocks', type: 'clocks', params: { cities: ['tokyo'], showExchanges: true }, + })); + assert.deepEqual(widgetParams(listWidgets(id)[0]), { cities: ['tokyo'], showExchanges: true }); + }); + + it('geeft null als er niets te melden is', async () => { + const id = activeProfile().id; + await saveWidgets(id, indeling()); + assert.equal(listWidgets(id)[0].settings, null, 'geen leeg {} in de kolom'); + assert.equal(widgetParams(listWidgets(id)[0]), null); + }); + + it('overleeft onleesbare JSON in settings', async () => { + const id = activeProfile().id; + await saveWidgets(id, indeling({ instanceKey: 'spot-1', type: 'spot', params: { assetId: btc } })); + harness.exports.run(`UPDATE DashboardWidget SET settings = '{kapot' WHERE profileId = ?`, [id]); + assert.deepEqual(widgetParams(listWidgets(id)[0]), { assetId: btc }); + }); + + it('neemt de koers-widget mee als zijn asset verdwijnt', async () => { + const id = activeProfile().id; + await saveWidgets(id, [ + ...indeling(), + { instanceKey: 'spot-1', type: 'spot', gridX: 4, gridY: 0, spanWidth: 2, spanHeight: 2, params: { assetId: btc } }, + ]); + harness.exports.run(`DELETE FROM Asset WHERE id = ?`, [btc]); + assert.deepEqual(listWidgets(id).map(w => w.instanceKey), ['signal']); + }); + + it('houdt de instanties per profiel gescheiden', async () => { + const eerste = activeProfile().id; + const tweede = await createProfile({ name: 'Fiscaal' }); + await saveWidgets(tweede, indeling()); + assert.equal(listWidgets(eerste).length, DASHBOARD.DEFAULT_LAYOUT.length); + assert.deepEqual(listWidgets(tweede).map(w => w.instanceKey), ['signal']); + }); + + it('geeft de instanties in leesvolgorde terug', async () => { + const id = activeProfile().id; + await saveWidgets(id, [ + { instanceKey: 'c', type: 'signal', gridX: 0, gridY: 5, spanWidth: 4, spanHeight: 1, params: null }, + { instanceKey: 'b', type: 'metrics', gridX: 6, gridY: 0, spanWidth: 3, spanHeight: 1, params: null }, + { instanceKey: 'a', type: 'netshare', gridX: 0, gridY: 0, spanWidth: 3, spanHeight: 1, params: null }, + ]); + assert.deepEqual(listWidgets(id).map(w => w.instanceKey), ['a', 'b', 'c']); + }); + + it('weigert twee instanties met dezelfde sleutel in één profiel', async () => { + const id = activeProfile().id; + await assert.rejects(() => saveWidgets(id, [...indeling(), ...indeling()])); + }); +}); diff --git a/tests/repo/eigen-vermogen.test.mjs b/tests/repo/eigen-vermogen.test.mjs new file mode 100644 index 0000000..b559492 --- /dev/null +++ b/tests/repo/eigen-vermogen.test.mjs @@ -0,0 +1,148 @@ +// Generated, do not edit. sha256:605d840e94e42af14d150c5b1095887b6d46b752b6e0dc0407b3f4a209ee7b66 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; +import { toSearchName } from '../../shared/search-name.js'; +import { isOwnWealth } from '../../shared/wealth-scope.js'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { assetPositions } = await import('../../server/data/events.js'); +const { positionsByAsset } = await import('../../server/data/positions.js'); +const { latestAnchors } = await import('../../server/data/valuations.js'); +const { metalHoldings } = await import('../../server/data/metals.js'); + +const { run, lastId } = harness.exports; +const PEIL = '2026-12-31T00:00:00.000Z'; +const rijVan = (rijen, id) => rijen.find(r => r.id === id); +const eigen = (assetId) => rijVan(positionsByAsset(PEIL, { ownOnly: true }), assetId); +const alles = (assetId) => rijVan(positionsByAsset(PEIL), assetId); + +let btc, xau, munt, kluis, spaarpot, pensioen; +beforeEach(() => { + harness.recreate(); + btc = asset('BTC', 'Bitcoin', 'crypto', 8); + xau = asset('XAU', 'Goud', 'metal', 4); + run(`INSERT INTO MetalProduct (assetId, name, searchName, form, fineGrams) VALUES (?, 'Krugerrand', 'krugerrand', 'coin', 311035)`, [xau]); + munt = lastId(); + kluis = account('Kluis', null); + spaarpot = account('Spaarpot kind', 'managed'); + pensioen = account('Pensioen', 'none'); +}); + +function asset(symbol, naam, kind, decimals) { + run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES (?,?,?,?,?,NULL,?,'unit')`, [symbol, naam, toSearchName(naam), kind, symbol, decimals]); + return lastId(); +} +function account(naam, wealthScope) { + run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt, wealthScope) + VALUES (?,?,'bank',1,0,1,NULL,?)`, [naam, toSearchName(naam), wealthScope]); + return lastId(); +} +function event({ assetId = null, type = 'buy', to = null, from = null, amount = 1000, unitFee = null, + priceOrigin = null, productId = null, productCount = null, at = '2026-03-01T12:00:00.000Z' } = {}) { + const prijs = type === 'buy' || type === 'sell' ? 50000 : null; + run(`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fiatFee, unitFee, productId, productCount, + fromAccountId, toAccountId, source, priceOrigin, createdAt) + VALUES (?,?,?,?,?,?,?,?,?,?,?,'manual',?,?)`, + [assetId ?? btc, type, at, amount, prijs, type === 'buy' ? 10 : null, unitFee, productId, productCount, + from, to, priceOrigin, at]); + return lastId(); +} +function balans({ assetId = null, acc, balance, at = '2026-06-30T00:00:00.000Z' }) { + run(`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source) + VALUES (?,?,?,?,1.0,?, 'manual')`, [assetId ?? btc, at.slice(0, 10), acc, balance, at]); + return lastId(); +} + +describe('isOwnWealth', () => { + it('is waar zonder account en zonder keuze, onwaar bij beheerd en bij telt niet mee', () => { + assert.equal(isOwnWealth(null), true, 'wat aan geen account hangt is van niemand anders'); + assert.equal(isOwnWealth({ wealthScope: null }), true); + assert.equal(isOwnWealth({ wealthScope: 'managed' }), false); + assert.equal(isOwnWealth({ wealthScope: 'none' }), false); + }); +}); + +describe('ownOnly zonder afwijking', () => { + it('geeft dezelfde rijen als zonder de optie, ook met een gift, een fee en een zelf-transfer', () => { + event({ to: kluis, amount: 100_000 }); + event({ to: null, amount: 20_000, at: '2026-03-02T00:00:00.000Z' }); + event({ type: 'transfer', from: kluis, to: null, amount: 5_000, unitFee: 300, at: '2026-04-01T00:00:00.000Z' }); + event({ type: 'transfer', from: null, to: kluis, amount: 7_000, priceOrigin: 'none', at: '2026-04-02T00:00:00.000Z' }); + event({ type: 'transfer', from: kluis, to: kluis, amount: 1_000, unitFee: 50, at: '2026-04-03T00:00:00.000Z' }); + event({ type: 'sell', from: kluis, amount: 10_000, at: '2026-05-01T00:00:00.000Z' }); + event({ assetId: xau, to: kluis, amount: 622_070, productId: munt, productCount: 2 }); + balans({ acc: kluis, balance: 90_000 }); + assert.deepEqual(assetPositions(PEIL, { ownOnly: true }), assetPositions(PEIL)); + assert.deepEqual(positionsByAsset(PEIL, { ownOnly: true }), positionsByAsset(PEIL)); + assert.deepEqual(latestAnchors(PEIL, { ownOnly: true }), latestAnchors(PEIL)); + assert.deepEqual(metalHoldings({ ownOnly: true }), metalHoldings()); + }); +}); + +describe('ownOnly over de grens van het eigen vermogen', () => { + it('telt een aankoop op een beheerd account niet, ook niet in de aankoopcijfers', () => { + event({ to: kluis, amount: 100_000 }); + event({ to: spaarpot, amount: 40_000 }); + assert.equal(alles(btc).net, 140_000); + assert.equal(alles(btc).buy_events, 2); + const r = eigen(btc); + assert.equal(r.net, 100_000); + assert.equal(r.buy_events, 1); + assert.equal(r.buy_amount, 100_000); + assert.equal(r.buy_fee_total, 10, 'alleen de fee van de eigen aankoop'); + }); + + it('telt een overboeking naar een spaarpot als onttrekking, met de fee', () => { + event({ to: kluis, amount: 100_000 }); + event({ type: 'transfer', from: kluis, to: spaarpot, amount: 30_000, unitFee: 200, at: '2026-04-01T00:00:00.000Z' }); + assert.equal(alles(btc).net, 99_800, 'in het geheel valt alleen de fee weg'); + assert.equal(eigen(btc).net, 69_800, 'het eigen vermogen ziet het geld vertrekken'); + assert.equal(eigen(btc).gift_amount, 0, 'een overboeking met een prijsherkomst is geen gift'); + }); + + it('telt een overboeking terug als storting', () => { + event({ to: spaarpot, amount: 50_000 }); + event({ type: 'transfer', from: spaarpot, to: kluis, amount: 20_000, at: '2026-04-01T00:00:00.000Z' }); + assert.equal(eigen(btc).net, 20_000); + assert.equal(alles(btc).net, 50_000); + }); + + it('negeert een balans op een beheerd account, en houdt die op een eigen account', () => { + event({ to: kluis, amount: 100_000 }); + event({ to: spaarpot, amount: 40_000 }); + balans({ acc: spaarpot, balance: 45_000 }); + balans({ acc: kluis, balance: 101_000 }); + assert.equal(alles(btc).net, 146_000); + assert.equal(eigen(btc).net, 101_000, 'de spaarpot verschuift het eigen vermogen niet'); + assert.deepEqual(latestAnchors(PEIL, { ownOnly: true }).map(a => a.accountId), [kluis]); + }); + + it('houdt een boeking zonder account bij het eigen vermogen', () => { + event({ to: null, amount: 25_000 }); + assert.equal(eigen(btc).net, 25_000); + }); + + it('behandelt telt niet mee net als beheerd', () => { + event({ to: kluis, amount: 100_000 }); + event({ to: pensioen, amount: 60_000 }); + event({ type: 'transfer', from: kluis, to: pensioen, amount: 10_000, at: '2026-04-01T00:00:00.000Z' }); + assert.equal(eigen(btc).net, 90_000); + assert.equal(alles(btc).net, 160_000); + }); + + it('telt munten die naar een spaarpot gaan niet meer bij het eigen vermogen', () => { + event({ assetId: xau, to: kluis, amount: 933_105, productId: munt, productCount: 3 }); + event({ assetId: xau, to: spaarpot, amount: 311_035, productId: munt, productCount: 1 }); + event({ assetId: xau, type: 'transfer', from: kluis, to: spaarpot, amount: 311_035, productId: munt, + productCount: 1, at: '2026-04-01T00:00:00.000Z' }); + assert.equal(metalHoldings()[0].net_count, 4, 'het geheel: vier gekocht, een verplaatsing telt niet'); + const eigenMunt = metalHoldings({ ownOnly: true })[0]; + assert.equal(eigenMunt.net_count, 2, 'drie gekocht, één naar de spaarpot'); + assert.equal(eigenMunt.buy_events, 1, 'de aankoop op de spaarpot telt niet mee'); + }); +}); diff --git a/tests/repo/events.test.mjs b/tests/repo/events.test.mjs new file mode 100644 index 0000000..0d32337 --- /dev/null +++ b/tests/repo/events.test.mjs @@ -0,0 +1,705 @@ +// Generated, do not edit. sha256:3f3763318dbbed19b2856eb5ad9116f8f3871486bcca510f894e39694929a1e0 +import { describe, it, beforeEach, mock } from 'node:test'; +import { toSearchName } from '../../shared/search-name.js'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { + createEvent, eventsCountByAccount, eventsNetByAccount, + eventsWithoutImportRef, findEventByRef, firstTaxEventDate, listAssetTrades, listEvents, listFlowLegs, + listTradeEvents, listTrades, listTransfers, updateEvent +} = await import('../../server/data/events.js'); +const { listInterest, listInterestForAccount, scheduleLabel } = await import('../../server/data/interest-schedules.js'); +const { + anchorBefore, listOpenValuationDiffs, listValuations, valuationDiff, valuationDiffs, valuationGap, + valuationReconcilable +} = await import('../../server/data/valuations.js'); + +let btc, eth, accA, accB; +beforeEach(() => { + harness.recreate(); + btc = asset('BTC'); + eth = asset('ETH'); + accA = account('A'); + accB = account('B'); +}); + +function asset(symbol) { + harness.exports.run( + `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,'crypto',?,NULL,8,'unit')`, [symbol, symbol, toSearchName(symbol), symbol]); + return harness.exports.lastId(); +} +function account(naam) { + harness.exports.run( + `INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,'wallet',1,0,1,NULL)`, [naam, toSearchName(naam)]); + return harness.exports.lastId(); +} +function event({ type = 'buy', assetId = null, from = null, to = null, amount = 1000, + at = '2024-01-01T00:00:00', fiatPrice = null, fiatFee = null, unitFee = null, + ref = null, source = 'manual' } = {}) { + harness.exports.run( + `INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fiatFee, unitFee, + fromAccountId, toAccountId, source, importReference, createdAt) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + [assetId ?? btc, type, at, amount, + fiatPrice ?? (type === 'buy' || type === 'sell' ? 30000 : null), fiatFee, unitFee, + from, to, source, ref, at]); + return harness.exports.lastId(); +} +function waardering({ acc, onDate, balance, assetId = null, rateAt = null }) { + harness.exports.run( + `INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source) + VALUES (?,?,?,?,?,?, 'manual')`, + [assetId ?? btc, onDate, acc, balance, 50000, rateAt ?? `${onDate}T00:00:00`]); + return harness.exports.lastId(); +} + +describe('eventsNetByAccount', () => { + const net = (acc = accA, assetId = btc, until = '2030-01-01T00:00:00') => + eventsNetByAccount(acc, assetId, until); + + it('is 0 zonder events', () => { + assert.equal(net(), 0); + }); + + it('telt een aankoop op het ontvangende account op', () => { + event({ type: 'buy', to: accA, amount: 1000 }); + assert.equal(net(), 1000); + }); + + it('trekt een verkoop van het afgevende account af', () => { + event({ type: 'sell', from: accA, amount: 400 }); + assert.equal(net(), -400); + }); + + it('trekt bij een verkoop ook de fee af', () => { + event({ type: 'sell', from: accA, amount: 400, unitFee: 3 }); + assert.equal(net(), -403); + }); + + it('telt rente op het ontvangende account op', () => { + event({ type: 'interest', to: accA, amount: 25 }); + assert.equal(net(), 25); + }); + + it('telt een inkomende transfer vól mee, zonder fee-aftrek', () => { + event({ type: 'transfer', from: accB, to: accA, amount: 1000, unitFee: 20 }); + assert.equal(net(), 1000); + }); + + it('trekt bij een uitgaande transfer het bedrag én de fee af', () => { + event({ type: 'transfer', from: accA, to: accB, amount: 1000, unitFee: 20 }); + assert.equal(net(), -1020); + }); + + it('rekent een transfer naar hetzelfde account als alleen de fee', () => { + event({ type: 'transfer', from: accA, to: accA, amount: 1000, unitFee: 20 }); + assert.equal(net(), -20); + }); + + it('behandelt een ontbrekende fee als 0', () => { + event({ type: 'transfer', from: accA, to: accB, amount: 1000, unitFee: null }); + assert.equal(net(), -1000); + }); + + it('negeert events van een ander asset', () => { + event({ type: 'buy', to: accA, amount: 1000, assetId: eth }); + assert.equal(net(accA, btc), 0); + assert.equal(net(accA, eth), 1000); + }); + + it('negeert events van een ander account', () => { + event({ type: 'buy', to: accB, amount: 1000 }); + assert.equal(net(accA), 0); + }); + + it('neemt het peilmoment zelf mee (t/m, niet tot)', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-03-01T12:00:00' }); + assert.equal(net(accA, btc, '2024-03-01T12:00:00'), 1000, 'gelijk aan het peilmoment telt mee'); + assert.equal(net(accA, btc, '2024-03-01T11:59:59'), 0, 'daarvóór nog niet'); + }); + + it('telt zonder peilmoment alles mee', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2030-06-01T00:00:00' }); + assert.equal(eventsNetByAccount(accA, btc, null), 1000); + }); + + it('somt een reeks correct op', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + event({ type: 'interest', to: accA, amount: 50, at: '2024-02-01T00:00:00' }); + event({ type: 'transfer', from: accA, to: accB, amount: 300, unitFee: 10, at: '2024-03-01T00:00:00' }); + event({ type: 'sell', from: accA, amount: 200, at: '2024-04-01T00:00:00' }); + assert.equal(net(), 1000 + 50 - 310 - 200); + }); +}); + +describe('listTradeEvents', () => { + beforeEach(() => { + event({ type: 'buy', to: accA, at: '2023-12-31T23:59:59', amount: 1 }); + event({ type: 'buy', to: accA, at: '2024-01-01T00:00:00', amount: 2 }); + event({ type: 'sell', from: accA, at: '2024-06-01T00:00:00', amount: 3 }); + event({ type: 'buy', to: accA, at: '2024-12-31T23:59:59', amount: 4 }); + event({ type: 'buy', to: accA, at: '2025-01-01T00:00:00', amount: 5 }); + event({ type: 'transfer', from: accA, to: accB, at: '2024-05-01T00:00:00', amount: 99 }); + event({ type: 'interest', to: accA, at: '2024-05-02T00:00:00', amount: 98 }); + }); + const bedragen = (van, tot) => listTradeEvents(van, tot).map(e => e.amount).sort((a, b) => a - b); + + it('neemt alleen buy en sell mee', () => { + assert.deepEqual(bedragen(null, '2030-01-01T00:00:00'), [1, 2, 3, 4, 5]); + }); + + it('sluit de ondergrens uit en de bovengrens in', () => { + assert.deepEqual(bedragen('2024-01-01T00:00:00', '2024-12-31T23:59:59'), [3, 4], + 'de aankoop exact op de ondergrens hoort bij het vorige jaar'); + }); + + it('betekent van=null: vanaf het begin', () => { + assert.deepEqual(bedragen(null, '2024-01-01T00:00:00'), [1, 2]); + }); + + it('geeft niets terug voor een leeg venster', () => { + assert.deepEqual(bedragen('2024-07-01T00:00:00', '2024-07-31T00:00:00'), []); + }); + + it('neemt een verplaatsing tussen twee eigen accounts alleen mee als hij een euro-fee draagt', () => { + event({ type: 'transfer', from: accA, to: accB, at: '2024-08-01T00:00:00', amount: 97, fiatFee: 2 }); + event({ type: 'transfer', from: accA, to: accB, at: '2024-08-02T00:00:00', amount: 96, fiatFee: 0 }); + assert.deepEqual(bedragen('2024-07-31T00:00:00', '2024-08-31T00:00:00'), [97]); + }); + + it('neemt elke eenzijdige verplaatsing mee, naar buiten en van buiten', () => { + event({ type: 'transfer', from: accA, to: null, at: '2024-08-03T00:00:00', amount: 95 }); + event({ type: 'transfer', from: null, to: accB, at: '2024-08-04T00:00:00', amount: 94 }); + assert.deepEqual(bedragen('2024-07-31T00:00:00', '2024-08-31T00:00:00'), [94, 95]); + }); + + it('levert de velden die de aanwasberekening nodig heeft', () => { + const e = listTradeEvents('2024-05-31T00:00:00', '2024-06-02T00:00:00')[0]; + assert.deepEqual(Object.keys(e).sort(), + ['amount', 'assetId', 'decimals', 'fiatFee', 'fiatPrice', 'fromAccountId', 'occurredAt', + 'priceOrigin', 'toAccountId', 'type']); + assert.equal(e.decimals, 8, 'de decimals van het asset komen mee voor de omrekening'); + }); +}); + +describe('listFlowLegs', () => { + const benen = (van, tot) => listFlowLegs(van, tot) + .map(l => `${l.accountId}:${l.type}:${l.direction}:${l.units}`).sort(); + + it('geeft een aankoop als één been op de ontvangende rekening', () => { + event({ type: 'buy', to: accA, amount: 100, fiatPrice: 20000, at: '2024-02-01T00:00:00' }); + assert.deepEqual(benen(null, '2025-01-01T00:00:00'), [`${accA}:buy:in:100`]); + }); + + it('geeft een verkoop als één been op de leverende rekening', () => { + event({ type: 'sell', from: accA, amount: 100, fiatPrice: 20000, at: '2024-02-01T00:00:00' }); + assert.deepEqual(benen(null, '2025-01-01T00:00:00'), [`${accA}:sell:out:100`]); + }); + + it('geeft een verplaatsing als TWEE benen, één per rekening', () => { + event({ type: 'transfer', from: accA, to: accB, amount: 300, at: '2024-03-01T00:00:00' }); + assert.deepEqual(benen(null, '2025-01-01T00:00:00'), + [`${accA}:transfer:out:300`, `${accB}:transfer:in:300`].sort()); + }); + + it('laat de unitFee van een verplaatsing BUITEN de stroom', () => { + event({ type: 'transfer', from: accA, to: accB, amount: 300, unitFee: 10, at: '2024-03-01T00:00:00' }); + const uit = listFlowLegs(null, '2025-01-01T00:00:00').find(l => l.direction === 'out'); + assert.equal(uit.units, 300, 'de fee zit niet in de stroom'); + }); + + it('laat rente helemaal weg', () => { + event({ type: 'interest', to: accA, amount: 50, at: '2024-02-01T00:00:00' }); + assert.deepEqual(benen(null, '2025-01-01T00:00:00'), []); + }); + + it('geeft bij een verplaatsing naar zichzelf twee benen die elkaar opheffen', () => { + event({ type: 'transfer', from: accA, to: accA, amount: 300, at: '2024-03-01T00:00:00' }); + assert.deepEqual(benen(null, '2025-01-01T00:00:00'), + [`${accA}:transfer:in:300`, `${accA}:transfer:out:300`].sort()); + }); + + it('houdt één been over bij een verplaatsing van of naar buiten', () => { + event({ type: 'transfer', from: accA, to: null, amount: 300, at: '2024-03-01T00:00:00' }); + assert.deepEqual(benen(null, '2025-01-01T00:00:00'), [`${accA}:transfer:out:300`]); + }); + + it('scheidt de assets', () => { + event({ type: 'buy', to: accA, assetId: eth, amount: 7, fiatPrice: 3000, at: '2024-02-01T00:00:00' }); + const l = listFlowLegs(null, '2025-01-01T00:00:00')[0]; + assert.equal(l.assetId, eth); + }); + + it('sluit de ondergrens uit en de bovengrens in', () => { + event({ type: 'buy', to: accA, amount: 1, fiatPrice: 1, at: '2024-01-01T00:00:00' }); + event({ type: 'buy', to: accA, amount: 2, fiatPrice: 1, at: '2024-12-31T23:59:59' }); + assert.deepEqual(benen('2024-01-01T00:00:00', '2024-12-31T23:59:59'), [`${accA}:buy:in:2`]); + }); + + it('levert de velden die de waardering nodig heeft', () => { + event({ type: 'buy', to: accA, amount: 100, fiatPrice: 20000, fiatFee: 5, at: '2024-02-01T00:00:00' }); + const l = listFlowLegs(null, '2025-01-01T00:00:00')[0]; + assert.deepEqual(Object.keys(l).sort(), + ['accountId', 'assetId', 'decimals', 'direction', 'fiatFee', 'fiatPrice', 'occurredAt', 'priceOrigin', + 'type', 'units']); + assert.equal(l.decimals, 8, 'de decimals komen mee voor de omrekening'); + assert.equal(l.occurredAt, '2024-02-01T00:00:00', 'de dag komt mee, want een verplaatsing wordt op de koers van die dag gewaardeerd'); + }); +}); + +describe('firstTaxEventDate', () => { + it('geeft null zonder events', () => { + assert.equal(firstTaxEventDate(), null); + }); + + it('geeft de vroegste gebeurtenis, niet de eerst ingevoerde', () => { + event({ at: '2024-06-01T00:00:00' }); + event({ at: '2022-03-15T00:00:00' }); + event({ at: '2023-01-01T00:00:00' }); + assert.equal(firstTaxEventDate(), '2022-03-15T00:00:00'); + }); +}); + +describe('event-lijsten', () => { + it('sorteert listEvents nieuwste eerst', () => { + const oud = event({ at: '2024-01-01T00:00:00' }); + const nieuw = event({ at: '2024-06-01T00:00:00' }); + assert.deepEqual(listEvents().map(e => e.id), [nieuw, oud]); + }); + + it('splitst trades en transfers', () => { + const koop = event({ type: 'buy', to: accA }); + const verkoop = event({ type: 'sell', from: accA }); + const verplaatsing = event({ type: 'transfer', from: accA, to: accB }); + event({ type: 'interest', to: accA }); + + assert.deepEqual(listTrades().map(e => e.id).sort((a, b) => a - b), [koop, verkoop]); + assert.deepEqual(listTransfers().map(e => e.id), [verplaatsing]); + }); + + it('geeft alleen de aan- en verkopen van het gevraagde asset', () => { + const koop = event({ type: 'buy', to: accA, at: '2024-03-01T00:00:00' }); + const verkoop = event({ type: 'sell', from: accA, at: '2024-05-01T00:00:00' }); + event({ type: 'transfer', from: accA, to: accB, at: '2024-04-01T00:00:00' }); + event({ type: 'interest', to: accA, at: '2024-04-15T00:00:00' }); + event({ type: 'buy', assetId: eth, to: accA, at: '2024-02-01T00:00:00' }); + + assert.deepEqual(listAssetTrades(btc).map(e => e.id), [koop, verkoop]); + }); + + it('sorteert oplopend, want hij staat naast een grafiek die van links naar rechts loopt', () => { + const laat = event({ type: 'buy', to: accA, at: '2024-06-01T00:00:00' }); + const vroeg = event({ type: 'buy', to: accA, at: '2024-01-01T00:00:00' }); + assert.deepEqual(listAssetTrades(btc).map(e => e.id), [vroeg, laat]); + }); + + it('geeft niets terug voor een asset zonder handel', () => { + event({ type: 'buy', to: accA }); + assert.deepEqual(listAssetTrades(eth), []); + }); + + it('draagt de rekening als id én als naam, want de dialoog groepeert op het id', () => { + event({ type: 'buy', to: accA }); + event({ type: 'sell', from: accB }); + const [koop, verkoop] = listAssetTrades(btc); + assert.equal(koop.toAccountId, accA); + assert.equal(koop.fromAccountId, null, 'een aankoop komt ergens BINNEN, dus alleen de to-kant'); + assert.equal(koop.to_name, 'A'); + assert.equal(verkoop.fromAccountId, accB); + assert.equal(verkoop.toAccountId, null); + assert.equal(verkoop.from_name, 'B'); + }); + + function label(naam) { + harness.exports.run(`INSERT INTO Label (name, searchName, createdAt) VALUES (?,?,?)`, + [naam, toSearchName(naam), '2024-01-01T00:00:00']); + return harness.exports.lastId(); + } + function beweging({ labelId = null, txid = 'tx-1' } = {}) { + harness.exports.run( + `INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, labelId, blockHeight, blockTime, createdAt) + VALUES (?,'onchain',?,'in',?,?,?,?,?)`, + [accA, txid, 100_000, labelId, 840_000, '2024-03-01T00:00:00', '2024-03-02T00:00:00']); + return harness.exports.lastId(); + } + const koppel = (txId, eventId) => harness.exports.run( + `INSERT INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt) + VALUES (?,?, 'manual', '2024-03-02T00:00:00')`, [txId, eventId]); + + it('draagt het label van de gekoppelde beweging mee', () => { + const ev = event({ type: 'buy', to: accA, at: '2024-03-01T00:00:00' }); + koppel(beweging({ labelId: label('Van Jan') }), ev); + assert.equal(listAssetTrades(btc)[0].label_names, 'Van Jan'); + }); + + it('laat het label leeg als er geen beweging of geen label is', () => { + const zonderKoppeling = event({ type: 'buy', to: accA, at: '2024-03-01T00:00:00' }); + const zonderLabel = event({ type: 'buy', to: accA, at: '2024-04-01T00:00:00' }); + koppel(beweging({ txid: 'tx-2' }), zonderLabel); + const rijen = listAssetTrades(btc); + assert.equal(rijen.length, 2, 'beide aankopen staan er, met en zonder koppeling'); + assert.equal(rijen[0].label_names, null, 'geen koppeling, geen label'); + assert.equal(rijen[1].label_names, null, 'wel een beweging, maar die draagt geen label'); + assert.equal(rijen[0].id, zonderKoppeling); + }); + + it('HOUDT ÉÉN RIJ bij twee gekoppelde bewegingen, met beide labels', () => { + const ev = event({ type: 'buy', to: accA, at: '2024-03-01T00:00:00' }); + koppel(beweging({ txid: 'tx-1', labelId: label('Van Jan') }), ev); + koppel(beweging({ txid: 'tx-2', labelId: label('Van Piet') }), ev); + const rijen = listAssetTrades(btc); + assert.equal(rijen.length, 1, 'één aankoop blijft één rij, wat er ook aan hangt'); + assert.deepEqual(rijen[0].label_names.split(',').sort(), ['Van Jan', 'Van Piet']); + }); + + it('verrijkt met asset- en accountnamen', () => { + event({ type: 'transfer', from: accA, to: accB }); + const t = listTransfers()[0]; + assert.equal(t.asset_symbol, 'BTC'); + assert.equal(t.from_name, 'A'); + assert.equal(t.to_name, 'B'); + assert.equal(t.asset_decimals, 8); + }); + + it('geeft rente per account op basis van de bestemming', () => { + event({ type: 'interest', to: accA, amount: 10 }); + event({ type: 'interest', to: accB, amount: 20 }); + assert.deepEqual(listInterestForAccount(accA).map(e => e.amount), [10]); + }); + + it('vindt een event op import-referentie', () => { + const id = event({ ref: 'bitvavo-42' }); + assert.equal(findEventByRef('bitvavo-42').id, id); + assert.equal(findEventByRef('bestaat-niet'), null); + assert.equal(findEventByRef(null), null, 'een lege referentie matcht nooit'); + }); + + it('levert de pool zonder import-referentie voor de fuzzy match', () => { + const handmatig = event({ ref: null }); + event({ ref: 'uit-import' }); + assert.deepEqual(eventsWithoutImportRef().map(e => e.id), [handmatig]); + }); +}); + +describe('listInterest: attributie aan een afspraak', () => { + function afspraak({ acc = accA, assetId = null, van, tot = null, rate = 0.05, type = 'fixed' }) { + harness.exports.run( + `INSERT INTO InterestSchedule + (accountId, rateKind, principalAssetId, payoutAssetId, principalAmount, annualRate, + startsOn, endsOn, creditCadence, compounding, createdAt) + VALUES (?,?,?,?,?,?,?,?,?,0,?)`, + [acc, type, type === 'fixed' ? (assetId ?? btc) : null, assetId ?? btc, + type === 'fixed' ? 100000 : null, type === 'fixed' ? rate : null, + van, tot, type === 'fixed' ? 'term_end' : null, '2024-01-01T00:00:00']); + return harness.exports.lastId(); + } + + it('koppelt een rente-event aan de afspraak van dat account en asset', () => { + const s = afspraak({ van: '2024-01-01', tot: '2024-12-31' }); + event({ type: 'interest', to: accA, at: '2024-06-01T00:00:00' }); + assert.equal(listInterest()[0].scheduleId, s); + }); + + it('koppelt niet buiten het datumvenster', () => { + afspraak({ van: '2024-01-01', tot: '2024-05-31' }); + event({ type: 'interest', to: accA, at: '2024-06-01T00:00:00' }); + assert.equal(listInterest()[0].scheduleId, null); + }); + + it('koppelt niet aan een afspraak van een ander account of asset', () => { + afspraak({ acc: accB, van: '2024-01-01', tot: '2024-12-31' }); + afspraak({ acc: accA, assetId: eth, van: '2024-01-01', tot: '2024-12-31' }); + event({ type: 'interest', to: accA, at: '2024-06-01T00:00:00' }); + assert.equal(listInterest()[0].scheduleId, null); + }); + + it('laat bij overlap de afspraak met de laatste startdatum winnen', () => { + afspraak({ van: '2024-01-01', tot: '2024-12-31' }); + const nieuwer = afspraak({ van: '2024-03-01', tot: '2024-12-31' }); + event({ type: 'interest', to: accA, at: '2024-06-01T00:00:00' }); + assert.equal(listInterest()[0].scheduleId, nieuwer, + 'de meest recente afspraak overschrijft de oudere'); + }); + + it('koppelt aan een lopende afspraak zonder einddatum', () => { + const s = afspraak({ van: '2024-01-01', tot: null, type: 'variable' }); + event({ type: 'interest', to: accA, at: '2029-06-01T00:00:00' }); + assert.equal(listInterest()[0].scheduleId, s); + }); +}); + +describe('scheduleLabel', () => { + const basis = { account_name: 'Spaarrekening', payout_symbol: 'BTC', rateKind: 'fixed', + annualRate: 0.05, startsOn: '2024-01-01', endsOn: '2024-12-31' }; + + it('zet accountnaam, asset, rente en periode achter elkaar', () => { + assert.equal(scheduleLabel(basis), 'Spaarrekening · BTC · 5% · 2024-01-01–2024-12-31'); + }); + + it('schrijft een variabele afspraak als "variabel"', () => { + assert.equal(scheduleLabel({ ...basis, rateKind: 'variable' }), + 'Spaarrekening · BTC · variabel · 2024-01-01–2024-12-31'); + }); + + it('schrijft een lopende afspraak als "heden"', () => { + assert.ok(scheduleLabel({ ...basis, endsOn: null }).endsWith('2024-01-01–heden')); + }); + + it('haalt de nullen van een rond percentage weg', () => { + assert.equal(scheduleLabel({ ...basis, annualRate: 0.0725 }).split(' · ')[2], '7.25%'); + }); +}); + +describe('Valuation', () => { + it('sorteert listValuations op peildatum, nieuwste eerst', () => { + const oud = waardering({ acc: accA, onDate: '2024-01-01', balance: 1 }); + const nieuw = waardering({ acc: accA, onDate: '2024-06-01', balance: 2 }); + assert.deepEqual(listValuations().map(v => v.id), [nieuw, oud]); + }); + + it('verrijkt met account- en assetnaam', () => { + waardering({ acc: accA, onDate: '2024-01-01', balance: 1 }); + const v = listValuations()[0]; + assert.equal(v.account_name, 'A'); + assert.equal(v.asset_symbol, 'BTC'); + }); + + it('berekent het verschil als saldo min grootboek', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 }); + assert.equal(valuationDiff(listValuations()[0]), 200); + }); + + it('geeft null als de waardering geen account heeft', () => { + waardering({ acc: null, onDate: '2024-02-01', balance: 1200 }); + assert.equal(valuationDiff(listValuations()[0]), null); + }); + + it('kijkt alleen naar events t/m het rateAt-moment', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + event({ type: 'buy', to: accA, amount: 5000, at: '2024-09-01T00:00:00' }); + waardering({ acc: accA, onDate: '2024-02-01', balance: 1000 }); + assert.equal(valuationDiff(listValuations()[0]), 0, + 'de latere aankoop valt buiten het venster van deze waardering'); + }); + + it('listOpenValuationDiffs laat reconciled waarderingen weg', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + waardering({ acc: accA, onDate: '2024-02-01', balance: 1000 }); + waardering({ acc: accA, onDate: '2024-03-01', balance: 1500 }); + const open = listOpenValuationDiffs(); + assert.equal(open.length, 1); + assert.equal(open[0].diff, 500); + }); + + it('listOpenValuationDiffs laat waarderingen zonder account weg', () => { + waardering({ acc: null, onDate: '2024-02-01', balance: 1200 }); + assert.deepEqual(listOpenValuationDiffs(), [], + 'diff is null en null is geen openstaand verschil'); + }); + + it('ziet een negatief verschil ook als openstaand', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + waardering({ acc: accA, onDate: '2024-02-01', balance: 800 }); + assert.equal(listOpenValuationDiffs()[0].diff, -200); + }); + + it('is niet verzoenbaar zonder boekingen vóór het peilmoment', () => { + waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 }); + assert.equal(valuationReconcilable(listValuations()[0]), false); + assert.equal(valuationDiff(listValuations()[0]), 1200, 'het verschil bestaat nog wél als getal'); + assert.deepEqual(listOpenValuationDiffs(), [], 'maar het is geen werk'); + }); + + it('is wél verzoenbaar met een boeking vóór het peilmoment', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 }); + assert.equal(valuationReconcilable(listValuations()[0]), true); + assert.equal(listOpenValuationDiffs().length, 1); + }); + + it('kijkt voor de verzoenbaarheid naar hetzelfde venster als het verschil', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-09-01T00:00:00' }); + waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 }); + assert.equal(valuationReconcilable(listValuations()[0]), false); + }); + + it('is niet verzoenbaar zonder account', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + waardering({ acc: null, onDate: '2024-02-01', balance: 1200 }); + assert.equal(valuationReconcilable(listValuations()[0]), false); + }); + + it('telt een grootboek dat op nul uitkomt tóch als grootboek', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + event({ type: 'sell', from: accA, amount: 1000, at: '2024-01-15T00:00:00' }); + waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 }); + assert.equal(eventsNetByAccount(accA, btc, '2024-02-01T00:00:00'), 0, 'de som is nul'); + assert.equal(valuationReconcilable(listValuations()[0]), true, 'en er is tóch een grootboek'); + assert.equal(listOpenValuationDiffs().length, 1); + }); + + it('geeft per waardering het verschil én de verzoenbaarheid in één lijst', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + const met = waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 }); + const zonder = waardering({ acc: accA, onDate: '2023-06-01', balance: 500 }); + const perId = new Map(valuationDiffs().map(s => [s.id, s])); + assert.deepEqual(perId.get(met), { id: met, diff: 200, gap: -300, reconcilable: true, accepted: false, future: false }); + assert.deepEqual(perId.get(zonder), { id: zonder, diff: 500, gap: 500, reconcilable: false, accepted: false, future: false }); + }); + + it('haalt een AANVAARD verschil uit de werklijst en zegt het in de kolom', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + const id = waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 }); + assert.equal(listOpenValuationDiffs().length, 1, 'vooraf staat hij op de werklijst'); + + harness.exports.run(`UPDATE Valuation SET gapAcceptedAt = '2024-03-01T00:00:00Z' WHERE id = ?`, [id]); + assert.deepEqual(listOpenValuationDiffs(), [], 'erna niet meer'); + assert.equal(valuationDiffs().find(s => s.id === id).accepted, true, 'maar de kolom weet het wel'); + assert.equal(valuationDiffs().find(s => s.id === id).gap, 200, 'en het bedrag blijft berekend'); + }); +}); + +describe('eventsCountByAccount', () => { + it('telt de boekingen van dit account voor dit asset', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + event({ type: 'transfer', from: accA, to: accB, amount: 400, at: '2024-02-01T00:00:00' }); + assert.equal(eventsCountByAccount(accA, btc, null), 2, 'in- én uitgaand tellen mee'); + assert.equal(eventsCountByAccount(accB, btc, null), 1); + }); + + it('houdt zich aan het peilmoment', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + event({ type: 'buy', to: accA, amount: 1000, at: '2024-09-01T00:00:00' }); + assert.equal(eventsCountByAccount(accA, btc, '2024-06-01T00:00:00'), 1); + }); + + it('telt niets voor een ander asset of een boeking zonder account', () => { + harness.exports.run( + `INSERT INTO Asset (symbol, name, searchName, kind, unit, decimals, displayUnit) + VALUES ('XMR','Monero','monero','crypto','XMR',8,'unit')`); + const ander = harness.exports.lastId(); + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + assert.equal(eventsCountByAccount(accA, ander, null), 0, 'ander asset'); + harness.exports.run( + `INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, source, createdAt) + VALUES (?, 'buy', '2024-03-01T00:00:00', 500, 50000, 'manual', '2024-03-01T00:00:00')`, [btc]); + assert.equal(eventsCountByAccount(accA, btc, null), 1, 'een boeking zonder rekening hangt hier niet aan'); + }); +}); + +describe('waarderingen met een peildatum die nog moet komen', () => { + const morgen = new Date(Date.now() + 36 * 3600 * 1000).toISOString(); + + it('meldt géén openstaand verschil, ook al wijkt het van het grootboek af', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + waardering({ acc: accA, onDate: morgen.slice(0, 10), balance: 5000, rateAt: morgen }); + assert.deepEqual(listOpenValuationDiffs(), []); + }); + + it('markeert hem als toekomstig en rekent het verschil wél uit', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + const id = waardering({ acc: accA, onDate: morgen.slice(0, 10), balance: 5000, rateAt: morgen }); + const s = valuationDiffs().find(x => x.id === id); + assert.deepEqual(s, { id, diff: 4000, gap: 4000, reconcilable: true, accepted: false, future: true }); + }); + + it('gaat wél als werk gelden zodra het peilmoment voorbij is', () => { + const gisteren = new Date(Date.now() - 36 * 3600 * 1000).toISOString(); + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + waardering({ acc: accA, onDate: gisteren.slice(0, 10), balance: 5000, rateAt: gisteren }); + assert.equal(listOpenValuationDiffs().length, 1); + }); +}); + +describe('valuationGap', () => { + it('is gelijk aan valuationDiff zolang er geen eerdere balans is', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 }); + const v = listValuations()[0]; + assert.equal(valuationGap(v), valuationDiff(v)); + assert.equal(valuationGap(v), 200); + }); + + it('meet vanaf de vorige balans en niet vanaf het begin van het grootboek', () => { + waardering({ acc: accA, onDate: '2024-01-01', balance: 1000 }); + event({ type: 'interest', to: accA, amount: 50, at: '2024-02-01T00:00:00' }); + const nieuw = waardering({ acc: accA, onDate: '2024-03-01', balance: 800 }); + const v = listValuations().find(x => x.id === nieuw); + assert.equal(valuationGap(v), -250, 'wat er sinds de vorige meting ongeboekt uit is gegaan'); + assert.equal(valuationDiff(v), 750, 'het oude getal, en dat is hier onbruikbaar'); + }); + + it('laat een ongeboekt verschil niet bij élke volgende balans terugkomen', () => { + event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' }); + waardering({ acc: accA, onDate: '2024-02-01', balance: 1100 }); + event({ type: 'buy', to: accA, amount: 500, at: '2024-03-01T00:00:00' }); + const tweede = waardering({ acc: accA, onDate: '2024-04-01', balance: 1600 }); + const v = listValuations().find(x => x.id === tweede); + assert.equal(valuationGap(v), 0, 'de tweede meting klopt met de boeking ertussen'); + assert.equal(valuationDiff(v), 100, 'terwijl het oude getal die 100 blijft herhalen'); + }); + + it('kijkt naar de vorige balans van dezelfde rekening en hetzelfde asset', () => { + waardering({ acc: accA, onDate: '2024-01-01', balance: 1000 }); + waardering({ acc: accB, onDate: '2024-02-01', balance: 7777 }); + waardering({ acc: accA, onDate: '2024-02-01', balance: 1000, assetId: eth }); + const v = listValuations().find(x => x.accountId === accA && x.assetId === btc); + assert.equal(anchorBefore(accA, btc, v.rateAt), null, 'er is er geen eerdere voor dit paar'); + assert.equal(valuationGap(v), 1000); + }); + + it('geeft null zonder account', () => { + waardering({ acc: null, onDate: '2024-02-01', balance: 1200 }); + assert.equal(valuationGap(listValuations()[0]), null); + }); +}); + +describe('Event.priceOrigin', () => { + + const payload = (extra = {}) => ({ + assetId: btc, type: 'buy', occurredAt: '2026-05-01T10:00:00.000Z', amount: 100000, + fiatPrice: 50000, source: 'manual', toAccountId: accA, ...extra, + }); + const rij = id => listEvents().find(e => e.id === id); + + it('bewaart de herkomst en geeft hem terug op de leesweg', async () => { + const id = await createEvent(payload({ priceOrigin: 'market_day' })); + assert.equal(rij(id).priceOrigin, 'market_day'); + assert.equal(listTrades().find(e => e.id === id).priceOrigin, 'market_day', + 'ook via de join, want daar leest de pagina hem'); + }); + + it('laat de herkomst NULL als niemand hem meegeeft, en dat betekent onbekend', async () => { + const id = await createEvent(payload()); + assert.equal(rij(id).priceOrigin, null); + }); + + it('maakt van een leeg veld NULL en niet van een lege string', async () => { + const id = await createEvent(payload({ priceOrigin: '' })); + assert.equal(rij(id).priceOrigin, null); + }); + + it('weigert een herkomst die de enum niet kent', async () => { + await assert.rejects(() => createEvent(payload({ priceOrigin: 'guessed' })), /Invalid priceOrigin/); + assert.deepEqual(listEvents(), [], 'en er staat niets, dus de guard zit voor de INSERT'); + }); + + it('LAAT DE HERKOMST STAAN bij een bewerking die er niet over gaat', async () => { + const id = await createEvent(payload({ priceOrigin: 'market_day' })); + await updateEvent(id, payload({ fiatFee: 12 })); + assert.equal(rij(id).priceOrigin, 'market_day', 'een bewerking elders mag dit niet wissen'); + assert.equal(rij(id).fiatFee, 12, 'en de bewerking zelf is wel doorgevoerd'); + }); + + it('wist hem WEL als de aanroeper dat expliciet zegt', async () => { + const id = await createEvent(payload({ priceOrigin: 'paid' })); + await updateEvent(id, payload({ priceOrigin: null })); + assert.equal(rij(id).priceOrigin, null); + }); +}); diff --git a/tests/repo/grootboek-signaal.test.mjs b/tests/repo/grootboek-signaal.test.mjs new file mode 100644 index 0000000..c11be00 --- /dev/null +++ b/tests/repo/grootboek-signaal.test.mjs @@ -0,0 +1,78 @@ +// Generated, do not edit. sha256:6ad601b19ff4b76c3fb34f868abd93917ef0af58cb521f376bcb0c43c66b6827 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { createEvent, updateEvent, deleteEvent, listEvents } = await import('../../server/data/events.js'); +const { createValuation, updateValuation, deleteValuation, acceptValuationGap } = await import('../../server/data/valuations.js'); +const { createSchedule, updateSchedule, deleteSchedule } = await import('../../server/data/interest-schedules.js'); +const { takeChanges } = await import('../../server/data/_events.js'); + +let btc, acc; +beforeEach(() => { + harness.recreate(); + harness.exports.run( + `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`); + btc = harness.exports.lastId(); + harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('A','a','wallet',1,0,1,NULL)`); + acc = harness.exports.lastId(); + takeChanges(); +}); + +const aankoop = { assetId: btc, type: 'buy', occurredAt: '2024-03-01T00:00:00Z', amount: 100_000, + fiatPrice: 30_000, toAccountId: acc, source: 'manual' }; +const balans = () => ({ assetId: btc, valuedOn: '2024-04-01', accountId: acc, balance: 100_000, + fiatRate: 30_000, rateAt: '2024-03-31T22:00:00Z', source: 'manual' }); +const afspraak = () => ({ accountId: acc, rateKind: 'fixed', principalAssetId: btc, payoutAssetId: btc, + principalAmount: 100_000, annualRate: 0.05, startsOn: '2024-01-01', endsOn: '2025-01-01', creditCadence: 'annual' }); + +const signaal = () => assert.deepEqual(takeChanges(), ['ledger-changed']); + +describe('ledger-changed: boekingen', () => { + it('bij aanmaken, wijzigen en verwijderen', async () => { + const id = await createEvent({ ...aankoop, assetId: btc, toAccountId: acc }); + signaal(); + await updateEvent(id, { ...aankoop, assetId: btc, toAccountId: acc, amount: 200_000 }); + signaal(); + await deleteEvent(id); + signaal(); + }); + + it('niet bij een geweigerde boeking, want er veranderde niets', async () => { + await assert.rejects(() => createEvent({ ...aankoop, assetId: btc, toAccountId: acc, fiatPrice: null })); + assert.deepEqual(takeChanges(), []); + }); + + it('niet bij lezen', () => { + listEvents(); + assert.deepEqual(takeChanges(), []); + }); +}); + +describe('ledger-changed: balansen', () => { + it('bij aanmaken, wijzigen, aanvaarden en verwijderen', async () => { + const id = await createValuation(balans()); + signaal(); + await updateValuation(id, { ...balans(), balance: 150_000 }); + signaal(); + await acceptValuationGap(id); + signaal(); + await deleteValuation(id); + signaal(); + }); +}); + +describe('ledger-changed: rente-afspraken', () => { + it('bij aanmaken, wijzigen en verwijderen, want begin en einde staan op de activiteitslijn', async () => { + const id = await createSchedule(afspraak()); + signaal(); + await updateSchedule(id, { ...afspraak(), annualRate: 0.04 }); + signaal(); + await deleteSchedule(id); + signaal(); + }); +}); diff --git a/tests/repo/hygiene-register.test.mjs b/tests/repo/hygiene-register.test.mjs new file mode 100644 index 0000000..10aac12 --- /dev/null +++ b/tests/repo/hygiene-register.test.mjs @@ -0,0 +1,29 @@ +// Generated, do not edit. sha256:b419fb671fda156e789a3b8925ad11b622c94e6d19d9c264f032e72b6dbf961c +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness({ seedPreferences: true }); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { hygieneReport, hygieneKinds, acceptHygieneFinding } = await import('../../server/data/hygiene.js'); +const { healthChecks } = await import('../../server/registers.mjs'); + +beforeEach(() => harness.recreate()); + +describe('een geregistreerde controle', () => { + it('staat in de soorten, levert een bevinding, en is te aanvaarden', async () => { + assert.equal(hygieneKinds().some(k => k.kind === 'test_register'), false); + healthChecks.register({ id: 'test_register', severity: 'info', countsFrom: [], run: () => [{ row: { id: 1 }, counts: {} }] }); + assert.ok(hygieneKinds().some(k => k.kind === 'test_register' && k.severity === 'info')); + const eigen = () => hygieneReport({ kinds: ['test_register'] }).findings[0]; + assert.equal(eigen().total, 1); + await acceptHygieneFinding({ kind: 'test_register', row: { id: 1 }, counts: {}, summary: 'gezien' }); + assert.equal(eigen().total, 0, 'een aanvaarde bevinding valt weg, ook bij een geregistreerde controle'); + }); + + it('mag niet dezelfde soort hebben als een kerncontrole', () => { + healthChecks.register({ id: 'duplicate_events', severity: 'warn', countsFrom: [], run: () => [] }); + assert.throws(() => hygieneKinds(), /also a core check/); + }); +}); diff --git a/tests/repo/integration-log.test.mjs b/tests/repo/integration-log.test.mjs new file mode 100644 index 0000000..5dcfb38 --- /dev/null +++ b/tests/repo/integration-log.test.mjs @@ -0,0 +1,202 @@ +// Generated, do not edit. sha256:fc183f4c5a327b7b47dcb1a430409a48861cde95adfddadca5231c79a33dc477 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; +import { CONFIG } from '../../shared/config.js'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { recordIntegrationCall, listIntegrationCalls, lastIntegrationFailures, countServiceCall, serviceState } = + await import('../../server/data/integration-log.js'); + +beforeEach(() => harness.recreate()); + +const rijen = () => harness.exports.all(`SELECT * FROM IntegrationCall ORDER BY id`); +const dagenGeleden = (d) => new Date(Date.now() - d * 86_400_000).toISOString(); + +const fout = (extra = {}) => ({ + target: 'provider', providerKey: 'goldapi', operation: 'spot', subject: 'XAU', outcome: 'fail', + httpStatus: 403, errorKey: 'integrations.price.httpFailed', detail: 'Forbidden', latencyMs: 120, ...extra, +}); + +function oudeRij(occurredAt, providerKey = 'goldapi') { + harness.exports.run( + `INSERT INTO IntegrationCall (target, providerKey, operation, outcome, occurredAt) VALUES ('provider', ?, 'spot', 'fail', ?)`, + [providerKey, occurredAt]); +} + +describe('recordIntegrationCall: wat er nooit in mag', () => { + it('vervangt een sleutel die de bron in zijn melding herhaalt, ook URL-gecodeerd', async () => { + const sleutel = 'goldapi-abc+123/xyz'; + await recordIntegrationCall(fout({ detail: `Invalid key ${sleutel} (${encodeURIComponent(sleutel)})` }), + { secrets: [sleutel] }); + const [r] = rijen(); + assert.ok(!r.detail.includes(sleutel), r.detail); + assert.ok(!r.detail.includes(encodeURIComponent(sleutel)), r.detail); + assert.match(r.detail, /\*\*\*/); + }); + + it('vervangt het token van de dienst in een Bearer-waarde, ook zonder dat het token is meegegeven', async () => { + await recordIntegrationCall({ target: 'service', operation: '/v1/spot', outcome: 'fail', + detail: 'echo Authorization: Bearer s3cr3t-token-waarde' }); + assert.equal(rijen()[0].detail, 'echo Authorization: Bearer ***'); + }); + + it('laat geen URL staan, want bij query_key staat de sleutel in de query', async () => { + await recordIntegrationCall(fout({ detail: 'GET https://api.example.test/v1/price?apikey=K123456 failed' })); + const [r] = rijen(); + assert.ok(!/https?:|K123456|example\.test/.test(r.detail), r.detail); + assert.match(r.detail, //); + }); + + it('vervangt een sleutelparameter in losse tekst', async () => { + await recordIntegrationCall(fout({ detail: 'rejected token=abcdef1234 and api_key=zzz999' })); + assert.equal(rijen()[0].detail, 'rejected token=*** and api_key=***'); + }); + + it('weigert een URL als operatie in plaats van hem op te schonen', async () => { + await assert.rejects(recordIntegrationCall(fout({ operation: 'https://api.example.test/spot?key=x' })), + /operation is a path or a capability/); + await assert.rejects(recordIntegrationCall(fout({ operation: '/v1/spot?instrument=x' })), + /operation is a path or a capability/); + assert.equal(rijen().length, 0); + }); + + it('kort een lange melding in tot de grens, zichtbaar', async () => { + await recordIntegrationCall(fout({ detail: 'x'.repeat(1000) })); + const { detail } = rijen()[0]; + assert.equal(detail.length, CONFIG.INTEGRATION_LOG.DETAIL_MAX_CHARS); + assert.ok(detail.endsWith('…')); + }); + + it('bewaart in errorKey alleen sleutels en geen vrije tekst uit een antwoord', async () => { + await recordIntegrationCall({ target: 'service', operation: '/v1/series', outcome: 'fail', + errorKey: 'marketService.rejected api.noSeries ' }); + assert.equal(rijen()[0].errorKey, 'marketService.rejected api.noSeries'); + }); + + it('bewaart een request-id alleen bij de dienst en alleen in de afgesproken vorm', async () => { + await recordIntegrationCall({ target: 'service', operation: '/v1/spot', outcome: 'fail', requestId: 'a1b2c3d4-0000-4000-8000-000000000000' }); + await recordIntegrationCall({ target: 'service', operation: '/v1/spot', outcome: 'fail', requestId: 'kort' }); + await recordIntegrationCall(fout({ requestId: 'a1b2c3d4-0000-4000-8000-000000000000' })); + assert.deepEqual(rijen().map(r => r.requestId), ['a1b2c3d4-0000-4000-8000-000000000000', null, null]); + }); +}); + +describe('recordIntegrationCall: contractguards', () => { + it('weigert een onbekende soort en een onbekende uitkomst, ook "ok"', async () => { + await assert.rejects(recordIntegrationCall(fout({ target: 'lnd' })), /Unknown integration target/); + await assert.rejects(recordIntegrationCall(fout({ outcome: 'ok' })), /Unknown integration outcome/); + assert.equal(rijen().length, 0); + }); + + it('eist een providerKey bij een provider en negeert hem bij de dienst', async () => { + await assert.rejects(recordIntegrationCall(fout({ providerKey: '' })), /needs a providerKey/); + await recordIntegrationCall({ target: 'service', providerKey: 'goldapi', operation: '/v1/spot', outcome: 'refused' }); + assert.equal(rijen()[0].providerKey, null); + }); + + it('legt het tijdstip van versturen vast tot op de milliseconde', async () => { + await recordIntegrationCall(fout({ occurredAt: Date.UTC(2026, 9, 19, 8, 30, 15, 123) })); + assert.equal(rijen()[0].occurredAt, '2026-10-19T08:30:15.123Z'); + }); +}); + +describe('opruimen bij elke schrijfactie', () => { + it('haalt rijen ouder dan de termijn weg en laat rijen binnen de termijn staan', async () => { + const dagen = CONFIG.INTEGRATION_LOG.RETENTION_DAYS; + oudeRij(dagenGeleden(dagen + 1), 'oud'); + oudeRij(dagenGeleden(dagen - 1), 'binnen'); + await recordIntegrationCall(fout()); + assert.deepEqual(rijen().map(r => r.providerKey), ['binnen', 'goldapi']); + }); + + it('houdt het plafond aan door de oudste rijen weg te halen', async () => { + const max = CONFIG.INTEGRATION_LOG.MAX_ROWS; + const nu = new Date().toISOString(); + harness.exports.run(`WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ?) + INSERT INTO IntegrationCall (target, providerKey, operation, outcome, occurredAt) + SELECT 'provider', 'p' || i, 'spot', 'fail', ? FROM n`, [max, nu]); + await recordIntegrationCall(fout()); + const { n } = harness.exports.one(`SELECT COUNT(*) AS n FROM IntegrationCall`); + assert.equal(n, max); + assert.ok(!harness.exports.one(`SELECT 1 AS x FROM IntegrationCall WHERE providerKey = 'p1'`)); + assert.ok(harness.exports.one(`SELECT 1 AS x FROM IntegrationCall WHERE providerKey = 'p2'`)); + assert.ok(harness.exports.one(`SELECT 1 AS x FROM IntegrationCall WHERE providerKey = 'goldapi'`)); + }); +}); + +describe('countServiceCall: de dienst in ApiCallMetric', () => { + const tellingen = () => harness.exports.all(`SELECT capability, okCount, failCount FROM ApiCallMetric`); + + it('telt onder de systeemprovider van de dienst', async () => { + harness.exports.run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES ('atreides_services', 'Atreides Services', 'atreides services', '', 'none', 0, 1, 1, 1, '2026-10-19')`); + await countServiceCall({ capability: 'spot', ok: true, latencyMs: 30 }); + await countServiceCall({ capability: 'spot', ok: false, latencyMs: 10 }); + assert.deepEqual(tellingen(), [{ capability: 'spot', okCount: 1, failCount: 1 }]); + }); + + it('telt niets zonder die rij, in plaats van te gooien', async () => { + await countServiceCall({ capability: 'spot', ok: true, latencyMs: 30 }); + assert.deepEqual(tellingen(), []); + }); +}); + +describe('serviceState: het oordeel van het lampje', () => { + const dienst = () => harness.exports.run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES ('atreides_services', 'Atreides Services', 'atreides services', '', 'none', 0, 1, 1, 1, '2026-10-19')`); + const gelukt = (minuut) => harness.exports.run(`INSERT INTO ApiCallMetric (providerId, capability, bucketMinute, okCount, failCount, sumLatencyMs, maxLatencyMs) + SELECT id, 'spot', ?, 1, 0, 10, 10 FROM Provider WHERE key = 'atreides_services'`, [minuut]); + const mislukt = (at, outcome = 'fail') => recordIntegrationCall({ target: 'service', operation: '/v1/spot', outcome, occurredAt: at }); + + it('zonder log en zonder teller: geen oordeel', () => { + dienst(); + assert.deepEqual({ ...serviceState(), lastFail: null }, { ok: null, at: null, lastFail: null, lastOkAt: null }); + }); + + it('een fout na het laatste succes is rood, ook in dezelfde minuut', async () => { + dienst(); + gelukt('2026-10-19T08:00'); + await mislukt('2026-10-19T08:00:30.000Z'); + assert.deepEqual([serviceState().ok, serviceState().at], [false, '2026-10-19T08:00:30.000Z']); + }); + + it('een succes na de laatste fout is groen', async () => { + dienst(); + await mislukt('2026-10-19T08:00:30.000Z'); + gelukt('2026-10-19T08:01'); + assert.deepEqual([serviceState().ok, serviceState().at], [true, '2026-10-19T08:01:00.000Z']); + }); + + it('een leeg antwoord maakt het lampje niet rood', async () => { + dienst(); + gelukt('2026-10-19T08:00'); + await mislukt('2026-10-19T08:05:00.000Z', 'empty'); + assert.equal(serviceState().ok, true); + }); +}); + +describe('lezen', () => { + it('listIntegrationCalls: nieuwste eerst, binnen het venster, te filteren', async () => { + oudeRij(dagenGeleden(10), 'buiten'); + await recordIntegrationCall(fout({ providerKey: 'a' })); + await recordIntegrationCall({ target: 'service', operation: '/v1/series', outcome: 'empty' }); + await recordIntegrationCall(fout({ providerKey: 'b' })); + assert.deepEqual(listIntegrationCalls().map(r => r.providerKey), ['b', null, 'a']); + assert.deepEqual(listIntegrationCalls({ target: 'service' }).map(r => r.operation), ['/v1/series']); + assert.deepEqual(listIntegrationCalls({ providerKey: 'a' }).length, 1); + assert.deepEqual(listIntegrationCalls({ days: 30 }).map(r => r.providerKey), ['b', null, 'a', 'buiten']); + assert.deepEqual(listIntegrationCalls({ limit: 1 }).map(r => r.providerKey), ['b']); + }); + + it('lastIntegrationFailures: per bron de laatste, de dienst als eigen bron', async () => { + await recordIntegrationCall(fout({ providerKey: 'a', detail: 'eerste' })); + await recordIntegrationCall({ target: 'service', operation: '/v1/spot', outcome: 'fail', detail: 'dienst' }); + await recordIntegrationCall(fout({ providerKey: 'a', detail: 'tweede' })); + assert.deepEqual(lastIntegrationFailures().map(r => [r.providerKey, r.detail]), + [['a', 'tweede'], [null, 'dienst']]); + }); +}); diff --git a/tests/repo/integrations-export.test.mjs b/tests/repo/integrations-export.test.mjs new file mode 100644 index 0000000..f46caa8 --- /dev/null +++ b/tests/repo/integrations-export.test.mjs @@ -0,0 +1,94 @@ +// Generated, do not edit. sha256:6f5b8502746b6ae69cfa949fb746f614f73c7a3a69761281686d9078e5f7285a +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness({ seedPreferences: true }); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { listProviders, getProvider } = await import('../../server/data/providers.js'); +const { listAssets } = await import('../../server/data/assets.js'); +const { integrationsJson, INTEGRATIONS_FORMAT } = await import('../../server/integrations-export.mjs'); + +const { run, lastId } = harness.exports; + +function provider(key, { system = false, auth = 'none', required = 0, header = null, param = null } = {}) { + run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, authenticationHeaderName, + authenticationParameterName, isActive, isSeed, isSystem, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, '2026-10-10')`, + [key, key, key, system ? '' : `https://${key}.example`, auth, required, header, param, system ? 1 : 0]); + return lastId(); +} +const endpoint = (id, capability, path, query = null, quote = null, interval = null) => + run(`INSERT INTO ProviderEndpoint (providerId, pathTemplate, queryTemplate, capability, method, quoteCurrency, minIntervalSeconds) + VALUES (?, ?, ?, ?, 'GET', ?, ?)`, [id, path, query, capability, quote, interval]); +const output = (id, capability, field, path, transform) => + run(`INSERT INTO ProviderOutputMap (providerId, jsonPath, capability, internalField, transform) VALUES (?, ?, ?, ?, ?)`, [id, path, capability, field, transform]); +const symbol = (id, sym, externalId, { catchAll = false, unit = null } = {}) => + run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, isCatchAll) VALUES (?, ?, ?, ?, ?)`, + [id, catchAll ? null : sym, externalId, unit, catchAll ? 1 : 0]); +const covers = (id, ...syms) => { for (const s of syms) run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, ?)`, [id, s]); }; +const asset = (sym, kind) => run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES (?, ?, ?, ?, ?, NULL, 8, 'unit')`, [sym, sym, sym.toLowerCase(), kind, sym]); + +const exported = (base = 'EUR') => integrationsJson(listProviders().map(p => getProvider(p.key)), listAssets(), base); + +beforeEach(() => { + harness.recreate(); + for (const k of ['manual', 'atreides_services', 'fixed']) provider(k, { system: true }); + asset('XAU', 'metal'); + asset('LINK', 'crypto'); + const gold = provider('goldapi', { auth: 'header_key', required: 1, header: 'x-access-token' }); + endpoint(gold, 'spot', '/api/{symbol}/EUR', null, 'EUR', 3600); + endpoint(gold, 'history_point', '/api/{symbol}/EUR/{date}', null, 'EUR'); + output(gold, 'spot', 'price', 'price', 'troy_ounce_to_gram'); + covers(gold, 'XAU'); + run(`INSERT INTO ProviderCallLimit (providerId, callsPerMonth) VALUES (?, 100)`, [gold]); + const euler = provider('eulerpool', { auth: 'query_key', required: 1, param: 'token' }); + endpoint(euler, 'daily_history', '/api/1/{external_id}/quotes/{symbol}', null, 'USD'); + symbol(euler, null, 'crypto', { catchAll: true }); + symbol(euler, 'XAU', 'commodity', { unit: 'ozt' }); + covers(euler, 'LINK', 'XAU', 'NOASSET'); + const bitvavo = provider('bitvavo', { auth: 'hmac' }); + run(`INSERT INTO ProviderAuthenticationHmac (providerId, messageParts, keyHeader, signatureHeader, timestampHeader, windowHeader, + windowMs, hashAlgorithm, encoding) VALUES (?, 'timestamp,method,full_path,body', 'K', 'S', 'T', 'W', 10000, 'SHA-256', 'hex')`, [bitvavo]); + endpoint(bitvavo, 'spot', '/v2/ticker/price', 'market={symbol}-EUR', null); + covers(bitvavo, 'LINK'); +}); + +describe('integrationsJson', () => { + it('carries every source with all it has, and no system provider', () => { + const e = exported(); + assert.equal(e.format, INTEGRATIONS_FORMAT); + assert.deepEqual(e.providers.map(p => p.key), ['bitvavo', 'eulerpool', 'goldapi']); + const gold = e.providers.find(p => p.key === 'goldapi'); + assert.deepEqual(gold.auth, { kind: 'header_key', required: true, headerName: 'x-access-token', parameterName: null, hmac: null }); + assert.equal(gold.callsPerMonth, 100); + assert.deepEqual(gold.endpoints.map(x => [x.capability, x.pathTemplate, x.minIntervalSeconds]), + [['history_point', '/api/{symbol}/EUR/{date}', null], ['spot', '/api/{symbol}/EUR', 3600]]); + assert.deepEqual(gold.outputs, [{ capability: 'spot', internalField: 'price', jsonPath: 'price', transform: 'troy_ounce_to_gram' }]); + }); + + it('keeps the HMAC recipe and the catch-all, which only the reader can judge', () => { + const e = exported(); + assert.equal(e.providers.find(p => p.key === 'bitvavo').auth.hmac.messageParts, 'timestamp,method,full_path,body'); + assert.deepEqual(e.providers.find(p => p.key === 'eulerpool').inputMap, [ + { symbol: 'XAU', externalId: 'commodity', quoteUnit: 'ozt', isCatchAll: false }, + { symbol: null, externalId: 'crypto', quoteUnit: null, isCatchAll: true }, + ]); + }); + + it('fills in the unit of account for a NULL quote, and the kind of a covered symbol', () => { + const bitvavo = exported('USD').providers.find(p => p.key === 'bitvavo'); + assert.equal(bitvavo.endpoints[0].quote, 'USD'); + assert.deepEqual(exported().providers.find(p => p.key === 'eulerpool').coverage, + [{ symbol: 'LINK', kind: 'crypto' }, { symbol: 'NOASSET', kind: null }, { symbol: 'XAU', kind: 'metal' }]); + }); + + it('carries no secret and gives the same file twice', () => { + const text = JSON.stringify(exported()); + assert.ok(!/api_key|api_secret|secret"/i.test(text), 'a secret field is in the export'); + assert.equal(JSON.stringify(exported()), text); + }); +}); diff --git a/tests/repo/interest.test.mjs b/tests/repo/interest.test.mjs new file mode 100644 index 0000000..1d7dcbe --- /dev/null +++ b/tests/repo/interest.test.mjs @@ -0,0 +1,722 @@ +// Generated, do not edit. sha256:e9ccb4b4784d24c1c464248bf37dc50c0e76a1e76874e1d0edd11a9b9edf6572 +import { describe, it, beforeEach, mock } from 'node:test'; +import { toSearchName } from '../../shared/search-name.js'; +import { toUtcIso } from '../../shared/time.js'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { createAsset } = await import('../../server/data/assets.js'); +const { dueCreditsForAccount, realizedInterestSub, scheduleForecast, bookDueCredit, dismissDueCredit } = await import('../../server/data/interest-credits.js'); +const { createSchedule, createPeriod, listPeriods, updatePeriod, deletePeriod, + listSchedulesForAccount, listInterest, realizedForSchedule } = await import('../../server/data/interest-schedules.js'); + +const SATS = 8; +const EEN_BTC = 100_000_000; + +let ctx; +beforeEach(() => { + harness.recreate(); + harness.exports.run( + `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,?,?,?,?,?)`, ['BTC', 'Bitcoin', toSearchName('Bitcoin'), 'crypto', 'BTC', 'sat', SATS, 'subunit']); + const assetId = harness.exports.lastId(); + harness.exports.run( + `INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,?,?,?,1,NULL)`, ['Rentekening', toSearchName('Rentekening'), 'bank', 0, 0]); + const accountId = harness.exports.lastId(); + ctx = { assetId, accountId }; +}); + +const afspraak = (extra) => ({ + accountId: ctx.accountId, + rateKind: 'fixed', + principalAssetId: ctx.assetId, + payoutAssetId: ctx.assetId, + principalAmount: EEN_BTC, + principal_decimals: SATS, + payout_decimals: SATS, + annualRate: 0.05, + creditWeekday: null, + creditDayOfMonth: null, + ...extra, +}); + +function renteEvent(dagIso, bedragSub) { + harness.exports.run( + `INSERT INTO Event (assetId, type, occurredAt, amount, toAccountId, source, createdAt) + VALUES (?,?,?,?,?,?,?)`, + [ctx.assetId, 'interest', `${dagIso}T00:00:00`, bedragSub, ctx.accountId, 'manual', '2026-01-01T00:00:00']); +} + +describe('crediteringsmomenten per cadans', () => { + const aantalPerioden = (extra) => { + const s = afspraak(extra); + return scheduleForecast(s, s.startsOn).remainingCount; + }; + + it('term_end levert één periode over de hele looptijd', () => { + assert.equal(aantalPerioden({ + creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2026-12-31', + }), 1); + }); + + it('annual rekent per jaarultimo (1 januari van het volgende jaar)', () => { + assert.equal(aantalPerioden({ + creditCadence: 'annual', startsOn: '2020-03-01', endsOn: '2023-01-01', + }), 3); + }); + + it('annual sluit af op de einddatum en niet op een ultimo erna', () => { + assert.equal(aantalPerioden({ + creditCadence: 'annual', startsOn: '2020-03-01', endsOn: '2022-12-31', + }), 3); + }); + + it('schrijft het laatste stuk van een deposito bij op de einddatum', async () => { + await createSchedule(afspraak({ + creditCadence: 'annual', annualRate: 0.028, startsOn: '2015-06-15', endsOn: '2019-06-15', + })); + const dues = dueCreditsForAccount(ctx.accountId); + const laatste = dues.at(-1); + assert.deepEqual([laatste.periodStart, laatste.periodEnd], ['2019-01-01', '2019-06-15']); + assert.equal(laatste.projected_sub, Math.round(EEN_BTC * 0.028 * 165 / 365)); + const s = listSchedulesForAccount(ctx.accountId)[0]; + const totaal = scheduleForecast(s, '2015-06-15').termTotalSub; + assert.ok(Math.abs(totaal - EEN_BTC * 0.028 * 1461 / 365) <= dues.length, `termijntotaal ${totaal}`); + }); + + it('quarterly rekent per KALENDERkwartaal en niet per drie maanden vanaf de start', () => { + assert.equal(aantalPerioden({ + creditCadence: 'quarterly', startsOn: '2024-02-15', endsOn: '2025-01-01', + }), 4); + }); + + it('quarterly sluit af op de einddatum en niet op een kwartaal erna', () => { + assert.equal(aantalPerioden({ + creditCadence: 'quarterly', startsOn: '2024-02-15', endsOn: '2024-09-30', + }), 3); + }); + + it('quarterly begint óp een kwartaalgrens zonder een periode van nul dagen', () => { + assert.equal(aantalPerioden({ + creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: '2025-01-01', + }), 4); + }); + + it('monthly slaat de startdag over en neemt de einddatum wél mee', () => { + assert.equal(aantalPerioden({ + creditCadence: 'monthly', creditDayOfMonth: 15, + startsOn: '2024-01-15', endsOn: '2024-04-15', + }), 3); + }); + + it('weekly begint bij het eerste voorkomen strikt ná de start', () => { + assert.equal(aantalPerioden({ + creditCadence: 'weekly', creditWeekday: 1, + startsOn: '2024-01-01', endsOn: '2024-01-29', + }), 4); + }); + + it('loopt zonder einddatum door tot vandaag', () => { + const s = afspraak({ creditCadence: 'monthly', creditDayOfMonth: 1, startsOn: '2024-01-01', endsOn: null }); + const tot = scheduleForecast(s, '2024-06-15'); + assert.equal(tot.termTotalSub, null, 'geen termijntotaal zonder einddatum'); + assert.equal(tot.remainingCount, null); + assert.equal(dueCreditsForAccount(ctx.accountId).length, 0, 'deze afspraak staat nog niet in de database'); + }); + + it('geeft zonder einddatum wél te-boeken bijschrijvingen', async () => { + await createSchedule(afspraak({ + creditCadence: 'monthly', creditDayOfMonth: 1, startsOn: '2024-01-01', endsOn: null, + })); + const due = dueCreditsForAccount(ctx.accountId); + assert.ok(due.length >= 12, `verwacht minstens een jaar aan maandmomenten, kreeg ${due.length}`); + assert.ok(due.every(d => d.periodEnd <= new Date().toISOString().slice(0, 10)), + 'geen crediteringsmoment dat nog moet komen'); + assert.ok(due.every(d => d.periodEnd.endsWith('-01')), 'alleen maandmomenten, geen stuk tot vandaag'); + }); + + it('remainingCount telt alleen perioden ná de peildatum', () => { + const s = afspraak({ + creditCadence: 'monthly', creditDayOfMonth: 15, + startsOn: '2024-01-15', endsOn: '2024-04-15', + }); + assert.equal(scheduleForecast(s, '2024-01-15').remainingCount, 3); + assert.equal(scheduleForecast(s, '2024-02-15').remainingCount, 2); + assert.equal(scheduleForecast(s, '2024-04-15').remainingCount, 0); + }); +}); + +describe('crediteringsdatums (via de due-lijst)', () => { + const eersteDues = async (extra, n) => { + await createSchedule(afspraak({ endsOn: '2099-12-31', ...extra })); + return dueCreditsForAccount(ctx.accountId).slice(0, n); + }; + + it('klemt dag 31 op de laatste dag van de maand, inclusief schrikkeljaar', async () => { + const dues = await eersteDues({ + creditCadence: 'monthly', creditDayOfMonth: 31, startsOn: '2024-01-31', + }, 3); + assert.deepEqual(dues.map(d => d.periodEnd), ['2024-02-29', '2024-03-31', '2024-04-30']); + }); + + it('klemt februari op 28 in een niet-schrikkeljaar', async () => { + const dues = await eersteDues({ + creditCadence: 'monthly', creditDayOfMonth: 31, startsOn: '2023-01-31', + }, 2); + assert.deepEqual(dues.map(d => d.periodEnd), ['2023-02-28', '2023-03-31']); + }); + + it('perioden sluiten op elkaar aan en beginnen bij de startdatum', async () => { + const dues = await eersteDues({ + creditCadence: 'monthly', creditDayOfMonth: 10, startsOn: '2024-05-10', + }, 4); + assert.equal(dues[0].periodStart, '2024-05-10'); + for (let i = 1; i < dues.length; i++) { + assert.equal(dues[i].periodStart, dues[i - 1].periodEnd, + 'een periode moet beginnen waar de vorige eindigde, anders valt er rente tussen'); + } + }); + + it('wekelijkse momenten liggen zeven dagen uit elkaar op de gekozen weekdag', async () => { + const dues = await eersteDues({ + creditCadence: 'weekly', creditWeekday: 3, startsOn: '2024-01-01', + }, 3); + assert.deepEqual(dues.map(d => d.periodEnd), ['2024-01-03', '2024-01-10', '2024-01-17']); + }); + + it("geeft geen due's voor een variabele afspraak", async () => { + await createSchedule({ + accountId: ctx.accountId, rateKind: 'variable', payoutAssetId: ctx.assetId, + principalAssetId: null, principalAmount: null, annualRate: null, + startsOn: '2024-01-01', endsOn: '2099-12-31', + creditCadence: null, creditWeekday: null, creditDayOfMonth: null, + }); + assert.deepEqual(dueCreditsForAccount(ctx.accountId), []); + }); + + it("blijft de bijschrijvingen van een afgelopen afspraak aanbieden", async () => { + await createSchedule(afspraak({ + creditCadence: 'monthly', creditDayOfMonth: 1, + startsOn: '2020-01-01', endsOn: '2020-12-31', + })); + const dues = dueCreditsForAccount(ctx.accountId); + assert.equal(dues.length, 12, 'elf maandmomenten en de afsluiting, allemaal nog te boeken'); + assert.equal(dues.at(-1).periodEnd, '2020-12-31', 'en niets ná de einddatum'); + }); + + it('biedt de rente van een verstreken deposito precies één keer aan', async () => { + const eind = new Date(Date.now() - 5 * 24 * 3600 * 1000).toISOString().slice(0, 10); + const start = new Date(Date.now() - 370 * 24 * 3600 * 1000).toISOString().slice(0, 10); + await createSchedule(afspraak({ creditCadence: 'term_end', startsOn: start, endsOn: eind })); + const dues = dueCreditsForAccount(ctx.accountId); + assert.equal(dues.length, 1); + assert.equal(dues[0].periodEnd, eind); + assert.ok(dues[0].projected_sub > 0, 'met een bedrag erbij, anders is er niets te boeken'); + }); +}); + +describe('due-detectie en dubbeltelling', () => { + async function maandelijkseAfspraak() { + const id = await createSchedule(afspraak({ + creditCadence: 'monthly', creditDayOfMonth: 15, + startsOn: '2024-01-15', endsOn: '2099-12-31', + })); + return id; + } + + it('een geboekte credit haalt precies die periode van de lijst', async () => { + const scheduleId = await maandelijkseAfspraak(); + const voor = dueCreditsForAccount(ctx.accountId); + const eerste = voor[0]; + + harness.exports.run( + `INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, status, createdAt) + VALUES (?,?,?, 'booked', ?)`, + [scheduleId, eerste.periodStart, eerste.periodEnd, '2026-01-01T00:00:00']); + + const na = dueCreditsForAccount(ctx.accountId); + assert.equal(na.length, voor.length - 1); + assert.ok(!na.some(d => d.periodEnd === eerste.periodEnd)); + }); + + it('een genegeerde credit haalt de periode ook van de lijst', async () => { + const scheduleId = await maandelijkseAfspraak(); + const eerste = dueCreditsForAccount(ctx.accountId)[0]; + harness.exports.run( + `INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, status, createdAt) + VALUES (?,?,?, 'dismissed', ?)`, + [scheduleId, eerste.periodStart, eerste.periodEnd, '2026-01-01T00:00:00']); + assert.ok(!dueCreditsForAccount(ctx.accountId).some(d => d.periodEnd === eerste.periodEnd)); + }); + + it('rente die al via import binnenkwam onderdrukt de due (geen dubbeltelling)', async () => { + await maandelijkseAfspraak(); + const eerste = dueCreditsForAccount(ctx.accountId)[0]; + renteEvent(eerste.periodEnd, 12_345); + assert.ok(!dueCreditsForAccount(ctx.accountId).some(d => d.periodEnd === eerste.periodEnd), + 'een uitkering óp de crediteringsdag hoort bij de periode die dan eindigt'); + }); + + it('een uitkering op de startdag van een periode onderdrukt die periode NIET', async () => { + await maandelijkseAfspraak(); + const eerste = dueCreditsForAccount(ctx.accountId)[0]; + renteEvent(eerste.periodStart, 12_345); + assert.ok(dueCreditsForAccount(ctx.accountId).some(d => d.periodEnd === eerste.periodEnd)); + }); +}); + +describe('bedragen: de prognose is de som van de bijschrijvingen', () => { + it('rekent één vol jaar op 5% van 1 BTC als 0,05 BTC', () => { + const s = afspraak({ + creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31', + }); + assert.equal(scheduleForecast(s, '2024-01-01').termTotalSub, 5_000_000); + }); + + it('rekent enkelvoudig over meerdere jaren, net als de bijschrijving', () => { + const s = afspraak({ + creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2025-12-31', + }); + assert.equal(scheduleForecast(s, '2024-01-01').termTotalSub, 10_000_000); + }); + + it('telt op tot precies wat de te-boeken-lijst aanbiedt', async () => { + await createSchedule(afspraak({ + creditCadence: 'annual', annualRate: 0.028, startsOn: '2020-03-01', endsOn: '2023-01-01', + })); + const dues = dueCreditsForAccount(ctx.accountId); + assert.equal(dues.length, 3); + const s = listSchedulesForAccount(ctx.accountId)[0]; + assert.equal(scheduleForecast(s, '2020-03-01').termTotalSub, dues.reduce((n, d) => n + d.projected_sub, 0)); + }); + + it('trekt al ontvangen rente af van het resterende bedrag', () => { + const s = afspraak({ + creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31', + }); + renteEvent('2024-06-01', 2_000_000); + const f = scheduleForecast(s, '2024-12-31'); + assert.equal(f.realizedSub, 2_000_000); + assert.equal(f.remainingSub, 3_000_000); + }); + + it('laat het resterende bedrag niet onder nul zakken', () => { + const s = afspraak({ + creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31', + }); + renteEvent('2024-06-01', 9_999_999); + assert.equal(scheduleForecast(s, '2024-12-31').remainingSub, 0); + }); + + it('merkt op wanneer hoofdsom en uitkering hetzelfde asset zijn', () => { + const s = afspraak({ + creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31', + }); + assert.equal(scheduleForecast(s, '2024-01-01').sameAsset, true); + assert.equal(scheduleForecast({ ...s, principalAssetId: 999 }, '2024-01-01').sameAsset, false); + }); +}); + +describe('komende bijschrijvingen', () => { + it('noemt elke resterende periode met zijn bedrag, de lopende inbegrepen', () => { + const s = afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: '2027-01-01' }); + const { upcoming } = scheduleForecast(s, '2025-06-15'); + assert.deepEqual(upcoming, [ + { periodStart: '2025-01-01', periodEnd: '2026-01-01', projectedSub: 5_000_000 }, + { periodStart: '2026-01-01', periodEnd: '2027-01-01', projectedSub: 5_000_000 }, + ]); + }); + + it('laat een periode die op de peildatum eindigt weg, want die staat bij te boeken', () => { + const s = afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: '2027-01-01' }); + assert.deepEqual(scheduleForecast(s, '2026-01-01').upcoming.map(p => p.periodEnd), ['2027-01-01']); + }); + + it('is leeg na de einddatum', () => { + const s = afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: '2027-01-01' }); + assert.deepEqual(scheduleForecast(s, '2027-01-01').upcoming, []); + }); + + it('geeft zonder einddatum alleen de volgende, en die ligt ná vandaag', () => { + const s = afspraak({ creditCadence: 'monthly', creditDayOfMonth: 1, startsOn: '2024-01-01', endsOn: null }); + const f = scheduleForecast(s, '2024-06-15'); + assert.deepEqual(f.upcoming, [ + { periodStart: '2024-06-01', periodEnd: '2024-07-01', projectedSub: Math.round(EEN_BTC * 0.05 * 30 / 365) }, + ]); + assert.equal(f.termTotalSub, null); + }); + + it('vindt zonder einddatum ook de volgende jaarbijschrijving', () => { + const s = afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: null }); + assert.deepEqual(scheduleForecast(s, '2024-03-02').upcoming.map(p => p.periodEnd), ['2025-01-01']); + assert.deepEqual(scheduleForecast(s, '2025-01-01').upcoming.map(p => p.periodEnd), ['2026-01-01']); + }); + + it('telt een rentewijziging mee in het bedrag van de komende periode', async () => { + const id = await createSchedule(afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: '2027-01-01' })); + await createPeriod(id, { startsOn: '2025-07-01', annualRate: 0.01 }); + const s = listSchedulesForAccount(ctx.accountId)[0]; + assert.equal(scheduleForecast(s, '2025-06-15').upcoming[0].projectedSub, + Math.round(EEN_BTC * (0.05 * 181 / 365 + 0.01 * 184 / 365))); + }); +}); + +describe('rente op rente', () => { + const drieJaar = (extra) => afspraak({ + creditCadence: 'annual', startsOn: '2021-01-01', endsOn: '2024-01-01', ...extra, + }); + + it('rekent elke periode ook over de bijschrijvingen ervóór', () => { + const f = scheduleForecast(drieJaar({ compounding: 1 }), '2021-01-01'); + assert.deepEqual(f.upcoming.map(p => p.projectedSub), [5_000_000, 5_250_000, 5_512_500]); + assert.equal(f.termTotalSub, 15_762_500); + }); + + it('rekent zonder de keuze enkelvoudig, zoals voorheen', () => { + const f = scheduleForecast(drieJaar({ compounding: 0 }), '2021-01-01'); + assert.deepEqual(f.upcoming.map(p => p.projectedSub), [5_000_000, 5_000_000, 5_000_000]); + }); + + it('biedt op te boeken dezelfde bedragen aan als de prognose', async () => { + await createSchedule(drieJaar({ compounding: 1 })); + assert.deepEqual(dueCreditsForAccount(ctx.accountId).map(d => d.projected_sub), [5_000_000, 5_250_000, 5_512_500]); + }); + + it('telt een genegeerde bijschrijving gewoon mee, want de bank schrijft hem toch bij', async () => { + const id = await createSchedule(drieJaar({ compounding: 1 })); + harness.exports.run( + `INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, status, createdAt) + VALUES (?, '2021-01-01', '2022-01-01', 'dismissed', '2026-01-01T00:00:00')`, [id]); + assert.deepEqual(dueCreditsForAccount(ctx.accountId).map(d => d.projected_sub), [5_250_000, 5_512_500]); + }); + + it('weigert rente op rente op een hoofdsom die het saldo volgt', async () => { + await assert.rejects(() => createSchedule(drieJaar({ compounding: 1, principalAmount: null })), + (e) => e.key === 'interest.compoundingNeedsPrincipal'); + }); + + it('weigert rente op rente als de rente in een ander asset uitkeert', async () => { + harness.exports.run( + `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,?,?,?,?,?)`, + ['EUR', 'Euro', toSearchName('Euro'), 'fiat', 'EUR', 'cent', 2, 'unit']); + const eur = harness.exports.lastId(); + await assert.rejects(() => createSchedule(drieJaar({ compounding: 1, payoutAssetId: eur })), + (e) => e.key === 'interest.compoundingNeedsSameAsset'); + }); + + it('bewaart de keuze en geeft hem terug', async () => { + await createSchedule(drieJaar({ compounding: 1 })); + assert.equal(listSchedulesForAccount(ctx.accountId)[0].compounding, 1); + }); +}); + +describe('realizedInterestSub', () => { + it('telt vanaf `from` tot en met de dag vóór `to`', () => { + renteEvent('2024-03-01', 100); + renteEvent('2024-03-31', 200); + const som = realizedInterestSub(ctx.accountId, ctx.assetId, '2024-03-01', '2024-03-31'); + assert.equal(som, 100, 'de bovengrens hoort er niet bij te vallen'); + }); + + it('is 0 zonder rente-events', () => { + assert.equal(realizedInterestSub(ctx.accountId, ctx.assetId, '2024-01-01', '2025-01-01'), 0); + }); + + it('telt alleen rente van het gevraagde account en asset', () => { + renteEvent('2024-03-01', 100); + assert.equal(realizedInterestSub(ctx.accountId + 99, ctx.assetId, '2024-01-01', '2025-01-01'), 0); + assert.equal(realizedInterestSub(ctx.accountId, ctx.assetId + 99, '2024-01-01', '2025-01-01'), 0); + }); +}); + +describe('ontvangen per afspraak', () => { + const tweeAfspraken = async () => { + await createSchedule(afspraak({ creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31', note: 'deposito' })); + await createSchedule(afspraak({ creditCadence: 'annual', principalAmount: null, startsOn: '2024-01-01', endsOn: null, note: 'spaar' })); + const lijst = listSchedulesForAccount(ctx.accountId); + return { deposito: lijst.find(s => s.note === 'deposito'), spaar: lijst.find(s => s.note === 'spaar') }; + }; + + it('telt één rente-boeking onder twee overlappende afspraken maar één keer', async () => { + const { deposito, spaar } = await tweeAfspraken(); + renteEvent('2024-06-01', 2_000_000); + const som = scheduleForecast(deposito, '2024-12-31').realizedSub + scheduleForecast(spaar, '2024-12-31').realizedSub; + assert.equal(som, 2_000_000, 'opgeteld over de afspraken is het ontvangen bedrag er één keer'); + assert.equal(realizedForSchedule(spaar, '2024-12-31'), 2_000_000); + }); + + it('rekent een geboekte bijschrijving toe aan de afspraak waaronder hij geboekt is', async () => { + const { deposito, spaar } = await tweeAfspraken(); + renteEvent('2024-06-01', 2_000_000); + const eventId = harness.exports.lastId(); + harness.exports.run(`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, eventId, status, createdAt) + VALUES (?,?,?,?, 'booked', ?)`, [deposito.id, '2024-01-01', '2024-06-01', eventId, '2024-06-01T00:00:00']); + assert.equal(scheduleForecast(deposito, '2024-12-31').realizedSub, 2_000_000); + assert.equal(scheduleForecast(spaar, '2024-12-31').realizedSub, 0); + assert.equal(listInterest().find(r => r.id === eventId).scheduleId, deposito.id); + }); + + const tweeInHetVerleden = async () => { + await createSchedule(afspraak({ creditCadence: 'monthly', creditDayOfMonth: 1, startsOn: '2020-01-01', endsOn: '2020-12-31', note: 'deposito' })); + await createSchedule(afspraak({ creditCadence: 'annual', startsOn: '2020-03-01', endsOn: '2020-12-31', note: 'spaar' })); + const lijst = listSchedulesForAccount(ctx.accountId); + return { deposito: lijst.find(s => s.note === 'deposito'), spaar: lijst.find(s => s.note === 'spaar') }; + }; + const dueVan = (s) => dueCreditsForAccount(ctx.accountId).filter(d => d.scheduleId === s.id); + + it('laat een geboekte bijschrijving van de ene afspraak de andere niet van Te boeken halen', async () => { + const { deposito, spaar } = await tweeInHetVerleden(); + const julDue = dueVan(deposito).find(d => d.periodEnd === '2020-07-01'); + for (const d of dueVan(deposito).filter(d => d.periodEnd < '2020-07-01')) await dismissDueCredit(d); + await bookDueCredit(julDue, julDue.projected_sub); + assert.deepEqual(dueVan(spaar).map(d => d.periodEnd), ['2020-12-31'], 'de spaarrekening houdt haar bijschrijving'); + assert.ok(!dueVan(deposito).some(d => d.periodEnd === '2020-07-01'), 'de geboekte periode is weg'); + }); + + it('laat een losse rente-boeking alleen de afspraak raken waaronder hij valt', async () => { + const { deposito, spaar } = await tweeInHetVerleden(); + renteEvent('2020-06-15', 1_000); + assert.deepEqual(dueVan(spaar), [], 'de import vult de spaarperiode'); + assert.ok(dueVan(deposito).some(d => d.periodEnd === '2020-07-01'), 'en niet de periode van het deposito'); + }); +}); + +describe('createSchedule: validatie', () => { + it('weigert een einddatum vóór de startdatum', async () => { + await assert.rejects(() => createSchedule(afspraak({ + creditCadence: 'term_end', startsOn: '2024-06-01', endsOn: '2024-01-01', + })), (e) => e.key === 'accounts.schEndBeforeStart'); + }); + + it('eist een weekdag bij een wekelijkse cadans', async () => { + await assert.rejects(() => createSchedule(afspraak({ + creditCadence: 'weekly', startsOn: '2024-01-01', endsOn: '2024-12-31', + })), (e) => e.key === 'interest.weekdayRequired'); + }); + + it('staat geen dag-van-de-maand toe bij een wekelijkse cadans', async () => { + await assert.rejects(() => createSchedule(afspraak({ + creditCadence: 'weekly', creditWeekday: 1, creditDayOfMonth: 15, + startsOn: '2024-01-01', endsOn: '2024-12-31', + })), /has no day of the month/); + }); + + it('laat een vaste afspraak zónder einddatum toe (doorlopend)', async () => { + const id = await createSchedule(afspraak({ + creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: null, + })); + assert.ok(id > 0); + }); + + it('laat een vaste afspraak zónder hoofdsom toe (volgt het saldo)', async () => { + const id = await createSchedule(afspraak({ + creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: null, principalAmount: null, + })); + assert.ok(id > 0); + }); + + it('weigert een hoofdsom van nul, want dat is een vergissing en geen keuze', async () => { + await assert.rejects(() => createSchedule(afspraak({ + creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: null, principalAmount: 0, + })), (e) => e.key === 'interest.principalPositive'); + }); + + it('weigert hoofdsom of cadans op een variabele afspraak', async () => { + await assert.rejects(() => createSchedule({ + ...afspraak({ creditCadence: null, startsOn: '2024-01-01', endsOn: '2024-12-31' }), + rateKind: 'variable', + }), /manual or import/); + }); +}); + +describe('due-bijschrijvingen van nul', () => { + it('biedt een periode zonder bedrag niet aan', async () => { + await createSchedule(afspraak({ + creditCadence: 'annual', annualRate: 0, + startsOn: '2020-01-01', endsOn: '2022-01-01', + })); + assert.deepEqual(dueCreditsForAccount(ctx.accountId), []); + }); + + it('biedt de perioden mét een bedrag nog steeds aan', async () => { + await createSchedule(afspraak({ + creditCadence: 'annual', annualRate: 0.05, + startsOn: '2020-01-01', endsOn: '2022-01-01', + })); + const dues = dueCreditsForAccount(ctx.accountId); + assert.equal(dues.length, 2); + assert.ok(dues.every(d => d.projected_sub > 0)); + }); +}); + +describe('renteperioden: een rente die per datum wijzigt', () => { + async function jaarAfspraak(extra) { + return createSchedule(afspraak({ + creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31', ...extra, + })); + } + const dueBedrag = () => dueCreditsForAccount(ctx.accountId)[0].projected_sub; + + it('rekent zonder perioden precies zoals voorheen', async () => { + await jaarAfspraak(); + assert.equal(dueBedrag(), 5_000_000); + }); + + it('knipt de bijschrijving op de ingangsdatum van een wijziging', async () => { + const id = await jaarAfspraak(); + await createPeriod(id, { startsOn: '2024-07-01', annualRate: 0.01 }); + assert.equal(dueBedrag(), 2_994_521); + }); + + it('telt twee wijzigingen in één periode allebei mee', async () => { + const id = await jaarAfspraak(); + await createPeriod(id, { startsOn: '2024-04-01', annualRate: 0.04 }); + await createPeriod(id, { startsOn: '2024-07-01', annualRate: 0.03 }); + const verwacht = Math.round(100_000_000 * (0.05 * 91 / 365 + 0.04 * 91 / 365 + 0.03 * 183 / 365)); + assert.equal(dueBedrag(), verwacht); + assert.notEqual(dueBedrag(), Math.round(100_000_000 * (0.05 * 182 / 365 + 0.03 * 183 / 365)), + 'de middelste periode mag niet overgeslagen worden'); + }); + + it('gebruikt de laatste wijziging vóór het venster als beginrente', async () => { + const id = await createSchedule(afspraak({ + creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: '2024-12-31', + })); + await createPeriod(id, { startsOn: '2024-02-01', annualRate: 0.01 }); + const derde = dueCreditsForAccount(ctx.accountId).find(d => d.periodEnd === '2024-10-01'); + assert.equal(derde.projected_sub, Math.round(100_000_000 * 0.01 * 92 / 365)); + }); + + it('telt in de prognose elk tarief over zijn eigen dagen', async () => { + const id = await createSchedule(afspraak({ + creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2025-12-31', + })); + await createPeriod(id, { startsOn: '2024-12-31', annualRate: 0.03 }); + const s = listSchedulesForAccount(ctx.accountId)[0]; + assert.equal(scheduleForecast(s, '2024-01-01').termTotalSub, 8_000_000); + }); + + it('weigert een ingangsdatum op of vóór de start van de afspraak', async () => { + const id = await jaarAfspraak(); + await assert.rejects(() => createPeriod(id, { startsOn: '2024-01-01', annualRate: 0.01 }), (e) => e.key === 'interest.periodStartBeforeSchedule'); + await assert.rejects(() => createPeriod(id, { startsOn: '2023-12-31', annualRate: 0.01 }), (e) => e.key === 'interest.periodStartBeforeSchedule'); + }); + + it('weigert een ingangsdatum ná het einde van de afspraak', async () => { + const id = await jaarAfspraak(); + await assert.rejects(() => createPeriod(id, { startsOn: '2025-06-01', annualRate: 0.01 }), (e) => e.key === 'interest.periodStartAfterEnd'); + }); + + it('weigert een periode op een variabele afspraak', async () => { + const id = await createSchedule({ + accountId: ctx.accountId, rateKind: 'variable', payoutAssetId: ctx.assetId, + principalAssetId: null, principalAmount: null, annualRate: null, + startsOn: '2024-01-01', endsOn: null, creditCadence: null, creditWeekday: null, creditDayOfMonth: null, + }); + await assert.rejects(() => createPeriod(id, { startsOn: '2024-06-01', annualRate: 0.01 }), /manual or import/); + }); + + it('laat een periode bijwerken en verwijderen, en dat verandert het bedrag terug', async () => { + const id = await jaarAfspraak(); + const pid = await createPeriod(id, { startsOn: '2024-07-01', annualRate: 0.01 }); + assert.equal(dueBedrag(), 2_994_521); + + await updatePeriod(pid, { startsOn: '2024-07-01', annualRate: 0.05 }); + assert.equal(dueBedrag(), 5_000_000, 'zelfde rente als de afspraak: hetzelfde bedrag als zonder periode'); + + await deletePeriod(pid); + assert.deepEqual(listPeriods(id), []); + assert.equal(dueBedrag(), 5_000_000); + }); +}); + +describe('hoofdsom leeg: de rente volgt het saldo van de rekening', () => { + const dueVoor = (eind) => dueCreditsForAccount(ctx.accountId).find(d => d.periodEnd === eind); + + function balansOp(dagIso, balanceSub) { + harness.exports.run( + `INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source) + VALUES (?,?,?,?,1.0,?, 'manual')`, + [ctx.assetId, dagIso, ctx.accountId, balanceSub, toUtcIso(`${dagIso}T00:00:00`)]); + } + + function storting(dagIso, bedragSub) { + harness.exports.run( + `INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt) + VALUES (?, 'buy', ?, ?, 1, ?, 'manual', ?)`, + [ctx.assetId, `${dagIso}T00:00:00`, bedragSub, ctx.accountId, `${dagIso}T00:00:00`]); + } + + async function saldoAfspraak() { + return createSchedule(afspraak({ + creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: null, principalAmount: null, + })); + } + + it('rekent over de stand en niet over een vast bedrag', async () => { + storting('2024-01-01', EEN_BTC); + await saldoAfspraak(); + assert.equal(dueVoor('2024-04-01').projected_sub, Math.round(EEN_BTC * 0.05 * 91 / 365)); + }); + + it('laat een storting halverwege vanaf díe dag meetellen', async () => { + storting('2024-01-01', EEN_BTC); + storting('2024-03-01', EEN_BTC); + await saldoAfspraak(); + const verwacht = Math.round(EEN_BTC * 0.05 * 60 / 365 + 2 * EEN_BTC * 0.05 * 31 / 365); + assert.equal(dueVoor('2024-04-01').projected_sub, verwacht); + assert.notEqual(dueVoor('2024-04-01').projected_sub, Math.round(EEN_BTC * 0.05 * 91 / 365), + 'de storting van maart hoort mee te tellen'); + }); + + it('knipt óók op een balans halverwege de periode', async () => { + balansOp('2024-01-01', EEN_BTC); + balansOp('2024-03-01', EEN_BTC / 4); + await saldoAfspraak(); + const verwacht = Math.round(EEN_BTC * 0.05 * 60 / 365 + (EEN_BTC / 4) * 0.05 * 31 / 365); + assert.equal(dueVoor('2024-04-01').projected_sub, verwacht); + assert.notEqual(dueVoor('2024-04-01').projected_sub, Math.round(EEN_BTC * 0.05 * 91 / 365), + 'de balans van maart hoort mee te tellen'); + }); + + it('laat een balans van middernacht niet op de dag ervóór meetellen', async () => { + balansOp('2024-09-02', 12_000_000); + balansOp('2024-09-03', 14_150_000); + await createSchedule(afspraak({ + creditCadence: 'quarterly', startsOn: '2024-09-02', endsOn: null, principalAmount: null, + })); + const verwacht = Math.round(12_000_000 * 0.05 * 1 / 365 + 14_150_000 * 0.05 * 28 / 365); + assert.equal(dueVoor('2024-10-01').projected_sub, verwacht); + assert.notEqual(dueVoor('2024-10-01').projected_sub, Math.round(14_150_000 * 0.05 * 29 / 365), + 'de balans van 3 september mag niet op 2 september al gelden'); + }); + + it('volgt ook een balans, want dat is dezelfde stand', async () => { + balansOp('2024-01-01', EEN_BTC); + await saldoAfspraak(); + assert.equal(dueVoor('2024-04-01').projected_sub, Math.round(EEN_BTC * 0.05 * 91 / 365)); + }); + + it('combineert een saldowijziging met een rentewijziging', async () => { + storting('2024-01-01', EEN_BTC); + storting('2024-03-01', EEN_BTC); + const id = await saldoAfspraak(); + await createPeriod(id, { startsOn: '2024-02-01', annualRate: 0.02 }); + const verwacht = Math.round( + EEN_BTC * 0.05 * 31 / 365 + EEN_BTC * 0.02 * 29 / 365 + 2 * EEN_BTC * 0.02 * 31 / 365); + assert.equal(dueVoor('2024-04-01').projected_sub, verwacht); + }); + + it('geeft geen prognose, want er is geen termijn om naar toe te rekenen', async () => { + storting('2024-01-01', EEN_BTC); + await saldoAfspraak(); + const s = listSchedulesForAccount(ctx.accountId)[0]; + const f = scheduleForecast(s, '2024-06-01'); + assert.equal(f.termTotalSub, null, 'zonder hoofdsom én einddatum bestaat een termijntotaal niet'); + assert.equal(f.remainingSub, null); + assert.equal(typeof f.realizedSub, 'number', 'wat er ontvangen is, is er wél'); + }); +}); diff --git a/tests/repo/labels.test.mjs b/tests/repo/labels.test.mjs new file mode 100644 index 0000000..acde492 --- /dev/null +++ b/tests/repo/labels.test.mjs @@ -0,0 +1,152 @@ +// Generated, do not edit. sha256:d777a6778dbae1d1860839c9462d7305d69ddab9f3ae6a43a005933e2e4c226c +import { describe, it, beforeEach, mock } from 'node:test'; +import { toSearchName } from '../../shared/search-name.js'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { createLabel, deleteLabel, listLabels, labelUsage } = await import('../../server/data/labels.js'); + +beforeEach(() => { harness.recreate(); }); + +function account(naam = 'Ledger') { + harness.exports.run( + `INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,'wallet',1,1,1,NULL)`, [naam, toSearchName(naam)]); + return harness.exports.lastId(); +} +function beweging({ labelId = null, txid = 'tx1' } = {}) { + harness.exports.run( + `INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, labelId, createdAt) + VALUES (?, 'onchain', ?, 'in', 100000, ?, '2024-01-01T00:00:00')`, + [account(`acc-${txid}`), txid, labelId]); + return harness.exports.lastId(); +} + +describe('labels: CRUD', () => { + it('createLabel geeft het nieuwe id terug', async () => { + const id = await createLabel({ name: 'Sparen' }); + assert.equal(typeof id, 'number'); + assert.deepEqual(listLabels().map(l => l.name), ['Sparen']); + }); + + it('listLabels sorteert op naam, niet op invoervolgorde', async () => { + await createLabel({ name: 'Zakgeld' }); + await createLabel({ name: 'Aflossing' }); + await createLabel({ name: 'Middelen' }); + assert.deepEqual(listLabels().map(l => l.name), ['Aflossing', 'Middelen', 'Zakgeld']); + }); + + it('deleteLabel verwijdert een ongebruikt label', async () => { + const id = await createLabel({ name: 'Tijdelijk' }); + await deleteLabel(id); + assert.deepEqual(listLabels(), []); + }); +}); + +describe('labels: deleteLabel blokkeert bij gebruik', () => { + it('weigert een label dat een beweging draagt, en noemt het aantal', async () => { + const id = await createLabel({ name: 'In gebruik' }); + beweging({ labelId: id, txid: 'tx-a' }); + beweging({ labelId: id, txid: 'tx-b' }); + + await assert.rejects(() => deleteLabel(id), (e) => e.key === 'labels.inUse' && e.params.n === 2); + }); + + it('laat het label én de koppeling intact na een geweigerde delete', async () => { + const id = await createLabel({ name: 'Blijft' }); + const txId = beweging({ labelId: id, txid: 'tx-c' }); + + await assert.rejects(() => deleteLabel(id)); + + assert.deepEqual(listLabels().map(l => l.name), ['Blijft']); + const tx = harness.exports.one(`SELECT labelId FROM WalletTransaction WHERE id = ?`, [txId]); + assert.equal(tx.labelId, id); + }); + + it('laat een label wél weg als een ánder label in gebruik is', async () => { + const bezet = await createLabel({ name: 'Bezet' }); + const vrij = await createLabel({ name: 'Vrij' }); + beweging({ labelId: bezet, txid: 'tx-d' }); + + await deleteLabel(vrij); + assert.deepEqual(listLabels().map(l => l.name), ['Bezet']); + }); + + it('negeert bewegingen zonder label', async () => { + const id = await createLabel({ name: 'Los' }); + beweging({ labelId: null, txid: 'tx-e' }); + + await deleteLabel(id); + assert.deepEqual(listLabels(), []); + }); +}); + +describe('labelUsage: de telling die een handle betekenis geeft', () => { + + it('geeft een rij per label, ook voor een label dat nergens gebruikt wordt', async () => { + const gebruikt = await createLabel({ name: 'Werk' }); + const ongebruikt = await createLabel({ name: 'Ligt stil' }); + beweging({ labelId: gebruikt, txid: 'tx-a' }); + + const rijen = labelUsage(); + assert.equal(rijen.length, 2, 'een label zonder bewegingen hoort er ook in te staan'); + const perId = new Map(rijen.map(r => [r.labelId, r.walletTransactions])); + assert.equal(perId.get(gebruikt), 1); + assert.equal(perId.get(ongebruikt), 0); + }); + + it('telt alleen de bewegingen van dat label', async () => { + const a = await createLabel({ name: 'Aaa' }); + const b = await createLabel({ name: 'Bbb' }); + beweging({ labelId: a, txid: 'tx-1' }); + beweging({ labelId: a, txid: 'tx-2' }); + beweging({ labelId: b, txid: 'tx-3' }); + beweging({ labelId: null, txid: 'tx-4' }); + + const perId = new Map(labelUsage().map(r => [r.labelId, r.walletTransactions])); + assert.equal(perId.get(a), 2); + assert.equal(perId.get(b), 1); + }); + + it('geeft een lege lijst als er geen labels zijn', () => { + assert.deepEqual(labelUsage(), []); + }); + + it('levert gewone getallen en geen Map, want dit gaat ook over de data-API', () => { + assert.ok(Array.isArray(labelUsage())); + }); +}); + +describe('randwaardes: namen die leeg of vreemd zijn', () => { + it('weigert een lege en een witruimte-naam met een leesbare fout', async () => { + await assert.rejects(() => createLabel({ name: '' }), /Labelnaam may not be empty/); + await assert.rejects(() => createLabel({ name: ' ' }), /Labelnaam may not be empty/); + await assert.rejects(() => createLabel({ name: null }), /Labelnaam may not be empty/); + assert.deepEqual(listLabels(), [], 'en er is niets aangemaakt'); + }); + + it('noemt searchName niet meer, de kolom die de gebruiker niet kent', async () => { + await assert.rejects(() => createLabel({ name: ' ' }), + (e) => !/searchName/.test(e.message) && /Labelnaam/.test(e.message)); + }); + + it('slaat de naam getrimd op, want een spatie erachter is dezelfde valkuil', async () => { + await createLabel({ name: ' Werk ' }); + assert.deepEqual(listLabels().map(l => l.name), ['Werk']); + }); + + it('laat een naam van duizend tekens door', async () => { + const lang = 'y'.repeat(1000); + await createLabel({ name: lang }); + assert.equal(listLabels()[0].name.length, 1000); + }); + + it('laat een emoji en een aanhalingsteken door, en dat hóórt', async () => { + await createLabel({ name: 'Werk 🏦' }); + await createLabel({ name: 'Zogenaamd "spaargeld"' }); + assert.deepEqual(listLabels().map(l => l.name).sort(), ['Werk 🏦', 'Zogenaamd "spaargeld"']); + }); +}); diff --git a/tests/repo/license-limits.test.mjs b/tests/repo/license-limits.test.mjs new file mode 100644 index 0000000..ed5f8b0 --- /dev/null +++ b/tests/repo/license-limits.test.mjs @@ -0,0 +1,215 @@ +// Generated, do not edit. sha256:38c4b07d1566cda74ef6cac4d031f49fd52004b6938fdee8f13e3e7678fc12c7 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createDbHarness } from '../helpers/db-node.mjs'; +import { createNwcApiFake } from '../helpers/nwc-api.mjs'; + +const harness = createDbHarness({ seedPreferences: true }); +mock.module('../../server/db.js', { namedExports: harness.exports }); +createNwcApiFake().registreer(); + +let stand = { level: 'free', enforced: true, why: 'none' }; +const echt = await import('../../server/license.mjs'); +mock.module('../../server/license.mjs', { + namedExports: { + ...echt, + licenseState: () => stand, + capacityReached: (soort, huidig) => echt.capacityReached(soort, huidig, stand), + hasFeature: (naam) => echt.hasFeature(naam, stand), + allowedAssets: () => echt.allowedAssets(stand), + }, +}); + +const { createAsset, updateAsset, deleteAsset, listAssets } = await import('../../server/data/assets.js'); +const { createAccount, listAccounts } = await import('../../server/data/accounts.js'); +const { createValuation, listValuations } = await import('../../server/data/valuations.js'); +const { saveConnection, listConnections } = await import('../../server/data/connections.js'); +const { setPriceSourceRank } = await import('../../server/data/price-source.js'); +const { createWalletSource, updateWalletSource, listWalletSources } = await import('../../server/data/wallet-sources.js'); + +const EEN = { + symbol: 'BTC', name: 'Bitcoin', kind: 'crypto', unit: 'BTC', subunit: 'sat', + decimals: 8, displayUnit: 'unit', taxCategory: null, +}; +const TWEE = { ...EEN, symbol: 'XAU', name: 'Goud', kind: 'metal', unit: 'g', subunit: 'mg', decimals: 3 }; + +beforeEach(() => { + harness.recreate(); + stand = { level: 'free', enforced: true, why: 'none' }; +}); + +describe('de gratis stand op fiatrekeningen', () => { + const bank = (name) => ({ name, kind: 'bank', isOwnCustody: false, scanEnabled: false, taxCategory: null }); + + it('laat de EERSTE bankrekening toe en weigert de tweede', async () => { + await createAccount(bank('ING')); + await assert.rejects(() => createAccount(bank('Rabo')), (fout) => { + assert.equal(fout.key, 'accounts.maxFreeBank'); + assert.equal(fout.params.max, 1); + return true; + }); + assert.equal(listAccounts().length, 1); + }); + + it('TELT ALLEEN kind bank, dus een exchange of wallet valt erbuiten', async () => { + await createAccount(bank('ING')); + await createAccount({ ...bank('Kraken'), kind: 'exchange' }); + await createAccount({ ...bank('Ledger'), kind: 'wallet' }); + assert.equal(listAccounts().length, 3); + }); + + it('laat de tweede bankrekening WEL toe in de betaalde stand', async () => { + await createAccount(bank('ING')); + stand = { level: 'full', enforced: true, why: 'licensed' }; + await createAccount(bank('Rabo')); + assert.equal(listAccounts().length, 2); + }); +}); + +describe('de gratis stand op assets', () => { + it('TELT DE BASISVALUTA NIET MEE, anders is de gratis stand onbruikbaar', async () => { + await createAsset({ ...EEN, symbol: 'EUR', name: 'Euro', kind: 'fiat', unit: 'EUR', subunit: 'cent', decimals: 2 }); + const id = await createAsset(EEN); + assert.ok(id > 0); + assert.equal(listAssets().length, 2); + }); + + it('telt een munt die er alleen voor de KOERS is niet mee, en een munt in gebruik wel', async () => { + const DOLLAR = { ...EEN, symbol: 'USD', name: 'Dollar', kind: 'fiat', unit: 'USD', subunit: 'cent', decimals: 2 }; + await createAsset(DOLLAR); + await createAsset(EEN); + assert.equal(listAssets().length, 2, 'bitcoin mag naast een ongebruikte dollar'); + + harness.recreate(); + const usd = await createAsset(DOLLAR); + stand = { level: 'full', enforced: true, why: 'licensed' }; + await createValuation({ assetId: usd, valuedOn: '2026-01-01', accountId: null, balance: 100, fiatRate: 0.9, + rateAt: '2026-01-01T00:00:00Z', source: 'manual' }); + stand = { level: 'free', enforced: true, why: 'none' }; + await assert.rejects(() => createAsset(EEN), (f) => f.key === 'assets.maxFree', + 'met een balans in dollars is de dollar het ene asset van de gratis stand'); + }); + + it('laat de EERSTE asset gewoon toe', async () => { + const id = await createAsset(EEN); + assert.ok(id > 0); + assert.equal(listAssets().length, 1); + }); + + it('weigert de TWEEDE, met een vertaalbare sleutel en niet met een SQL-fout', async () => { + await createAsset(EEN); + const POND = { ...EEN, symbol: 'GBP', name: 'Pond', kind: 'fiat', unit: 'GBP', subunit: 'pence', decimals: 2 }; + await assert.rejects(() => createAsset(POND), (fout) => { + assert.equal(fout.key, 'assets.maxFree'); + assert.equal(fout.params.max, 1); + assert.match(fout.message, /free version/i); + return true; + }); + assert.equal(listAssets().length, 1, 'er mag niets half zijn aangemaakt'); + }); + + it('ALLEEN BITCOIN: een ander asset wordt geweigerd, ook als het het eerste is', async () => { + await assert.rejects(() => createAsset(TWEE), (fout) => { + assert.equal(fout.key, 'assets.freeOnly'); + assert.equal(fout.params.list, 'BTC'); + return true; + }); + assert.equal(listAssets().length, 0, 'er mag niets half zijn aangemaakt'); + }); + + it('laat een munt toe, want die valt onder de telling en niet onder de lijst', async () => { + const id = await createAsset({ ...EEN, symbol: 'GBP', name: 'Pond', kind: 'fiat', unit: 'GBP', subunit: 'pence', decimals: 2 }); + assert.ok(id > 0); + }); + + it('laat de tweede WEL toe in de betaalde stand', async () => { + await createAsset(EEN); + stand = { level: 'full', enforced: true, why: 'licensed' }; + await createAsset(TWEE); + assert.equal(listAssets().length, 2); + }); + + it('NEEMT NIETS AF: wijzigen en verwijderen blijven werken boven de grens', async () => { + stand = { level: 'full', enforced: true, why: 'licensed' }; + const a = await createAsset(EEN); + const b = await createAsset(TWEE); + + stand = { level: 'free', enforced: true, why: 'none' }; + await updateAsset(a, { ...EEN, name: 'Bitcoin hernoemd' }); + assert.equal(listAssets().find((x) => x.id === a).name, 'Bitcoin hernoemd'); + await deleteAsset(b); + assert.equal(listAssets().length, 1); + }); +}); + +describe('de gratis stand op balansen', () => { + const balans = (assetId, dag) => ({ + assetId, valuedOn: dag, accountId: null, balance: 100, fiatRate: 50000, + rateAt: `${dag}T00:00:00Z`, source: 'manual', + }); + + it('laat ÉÉN balans toe en weigert de tweede', async () => { + const asset = await createAsset(EEN); + await createValuation(balans(asset, '2026-01-01')); + await assert.rejects(() => createValuation(balans(asset, '2026-02-01')), (fout) => { + assert.equal(fout.key, 'valuations.maxFree'); + assert.equal(fout.params.max, 1); + return true; + }); + assert.equal(listValuations().length, 1); + }); + + it('laat de tweede WEL toe in de betaalde stand', async () => { + const asset = await createAsset(EEN); + await createValuation(balans(asset, '2026-01-01')); + stand = { level: 'full', enforced: true, why: 'licensed' }; + await createValuation(balans(asset, '2026-02-01')); + assert.equal(listValuations().length, 2); + }); +}); + +describe('de features van de gratis stand', () => { + it('weigert een lightning-verbinding en een eigen explorer', async () => { + await assert.rejects(() => saveConnection({ kind: 'lnd', name: 'Node', url: 'https://x' }), + (f) => f.key === 'connections.lightningPaid'); + await assert.rejects(() => saveConnection({ kind: 'nwc', name: 'Wallet', url: 'nostr+walletconnect://x' }), + (f) => f.key === 'connections.lightningPaid'); + await assert.rejects(() => saveConnection({ kind: 'btc_explorer', name: 'Eigen node', url: 'https://x' }), + (f) => f.key === 'connections.ownExplorerPaid'); + }); + + it('LEEST verbindingen gewoon, want de grens zit op opslaan en niet op kijken', async () => { + stand = { level: 'full', enforced: true, why: 'licensed' }; + await saveConnection({ kind: 'btc_explorer', name: 'Eigen node', url: 'https://x' }); + stand = { level: 'free', enforced: true, why: 'none' }; + assert.equal(listConnections('btc_explorer').length, 1); + }); + + it('laat die verbindingen WEL toe in de betaalde stand', async () => { + stand = { level: 'full', enforced: true, why: 'licensed' }; + const id = await saveConnection({ kind: 'lnd', name: 'Node', url: 'https://x' }); + assert.ok(id); + }); + + it('weigert een NWC- of LND-bron bij een account, ook via wijzigen, maar laat een bestaande staan', async () => { + const accountId = await createAccount({ name: 'Wallet', kind: 'wallet', isOwnCustody: true, scanEnabled: false, taxCategory: null }); + await assert.rejects(() => createWalletSource({ accountId, kind: 'nwc', value: 'ab'.repeat(32), name: 'W', secret: 'nostr+walletconnect://x' }), + (f) => f.key === 'connections.lightningPaid'); + await assert.rejects(() => createWalletSource({ accountId, kind: 'lnd', value: '', name: 'N', connectionId: 1 }), + (f) => f.key === 'connections.lightningPaid'); + const adres = await createWalletSource({ accountId, kind: 'address', value: 'bc1qtest', name: 'A' }); + await assert.rejects(() => updateWalletSource(adres, { kind: 'nwc', value: 'ab'.repeat(32), name: 'A', secret: 'nostr+walletconnect://x' }), + (f) => f.key === 'connections.lightningPaid'); + stand = { level: 'full', enforced: true, why: 'licensed' }; + const bron = await createWalletSource({ accountId, kind: 'nwc', value: 'cd'.repeat(32), name: 'W', secret: 'nostr+walletconnect://y' }); + stand = { level: 'free', enforced: true, why: 'none' }; + await updateWalletSource(bron, { kind: 'nwc', value: 'cd'.repeat(32), name: 'Nieuwe naam' }); + assert.equal(listWalletSources(accountId).find(s => s.id === bron).name, 'Nieuwe naam'); + }); + + it('weigert het wijzigen van de bronkeuze-matrix', async () => { + const asset = await createAsset(EEN); + await assert.rejects(() => setPriceSourceRank(asset, 'spot', 1), + (f) => f.key === 'integrations.matrixPaid'); + }); +}); diff --git a/tests/repo/market.test.mjs b/tests/repo/market.test.mjs new file mode 100644 index 0000000..3bb1fda --- /dev/null +++ b/tests/repo/market.test.mjs @@ -0,0 +1,410 @@ +// Generated, do not edit. sha256:ac2e17a56171cfd2df7cbf0535002e994a9b104a37a2b866dda8f2d218d16c98 +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; +import { AppError } from '../../shared/app-error.js'; +import { CONFIG } from '../../shared/config.js'; +import { isRetryLater } from '../../server/market-service.mjs'; + +const harness = createDbHarness({ seedPreferences: true }); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const nep = { configured: true, register: [], series: null, spots: null, history: null, calls: [], spotCalls: [], historyCalls: [] }; +mock.module('../../server/market-service.mjs', { + namedExports: { + isRetryLater, + marketConfigured: async () => nep.configured, + marketRegister: async () => (typeof nep.register === 'function' ? nep.register() : nep.register), + marketSeries: async (opts) => { nep.calls.push(opts); return nep.series(opts); }, + marketHistory: async (opts) => { nep.historyCalls.push(opts); return nep.history(opts); }, + marketSpots: async (keys, quote) => { nep.spotCalls.push({ keys, quote }); return nep.spots(keys, quote); }, + }, +}); + +const { intradaySeries, serviceSpot, serviceDayHistory, priceAtMoment } = await import('../../server/data/market.js'); + +const BTC_REG = { key: 'crypto:BTC', kind: 'crypto', symbol: 'BTC', unit: null, kinds: ['spot', 'history'], resolutions: ['minute', 'hour', 'day'] }; +const XAU_REG = { key: 'metal:XAU', kind: 'metal', symbol: 'XAU', unit: 'ozt', kinds: ['spot', 'history'], resolutions: ['minute', 'day'] }; +const ETH_DAG = { key: 'crypto:ETH', kind: 'crypto', symbol: 'ETH', unit: null, kinds: ['spot'], resolutions: ['day'] }; + +function asset(symbol, kind) { + harness.exports.run( + `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) + VALUES (?, ?, ?, ?, ?, NULL, 8, 'unit')`, [symbol, symbol, symbol.toLowerCase(), kind, symbol]); + return harness.exports.one(`SELECT id FROM Asset WHERE symbol = ?`, [symbol]).id; +} + +const sinds = () => Date.now() - 3600_000; + +beforeEach(() => { + harness.recreate(); + nep.configured = true; + nep.register = [BTC_REG, XAU_REG, ETH_DAG]; + nep.calls = []; + nep.spotCalls = []; + nep.historyCalls = []; + nep.history = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'eulerpool', points: [] }); + nep.spots = async () => ({ + spot: [{ instrument: 'crypto:BTC', price: 61000, currency: 'EUR', observedAt: 1, source: 'bitvavo', sourceLabel: 'Bitvavo' }], + missing: ['crypto:ETH'], unreachable: [], + }); + nep.series = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo', resolution: 'minute', points: [[o.from + 60_000, 61000]] }); +}); + +describe('intradaySeries: wat er naar de dienst gaat', () => { + it('vraagt het instrument op soort en symbool, in de rekenvaluta, en niets over het asset', async () => { + const id = asset('BTC', 'crypto'); + const from = sinds(); + const uit = await intradaySeries(id, from); + + assert.equal(uit.status, 'ok'); + assert.equal(nep.calls.length, 1); + const vraag = nep.calls[0]; + assert.deepEqual(Object.keys(vraag).sort(), ['from', 'instrument', 'quote', 'resolution', 'to']); + assert.equal(vraag.instrument, 'crypto:BTC'); + assert.equal(vraag.quote, CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY); + assert.equal(vraag.resolution, 'minute'); + assert.equal(vraag.from, from); + assert.equal(Object.values(vraag).includes(id), false); + }); + + it('geeft de munt van de BRON door en niet de gevraagde', async () => { + const id = asset('BTC', 'crypto'); + nep.series = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'x', resolution: 'minute', points: [[o.from + 1, 66000]] }); + const uit = await intradaySeries(id, sinds()); + assert.equal(uit.currency, 'USD'); + }); + + it('geeft de bron per punt door, ook na het omrekenen naar gram', async () => { + const id = asset('XAU', 'metal'); + nep.series = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'goldapi', resolution: 'minute', + points: [[o.from + 1, 2488.278144, 'goldapi'], [o.from + 2, 2488.278144]] }); + const uit = await intradaySeries(id, sinds()); + assert.equal(uit.points[0][2], 'goldapi'); + assert.equal(uit.points[1].length, 2); + assert.ok(Math.abs(uit.points[0][1] - 80) < 1e-9); + }); + + it('noemt een punt bij de naam die de dienst geeft, en zonder naam bij de sleutel', async () => { + const id = asset('BTC', 'crypto'); + nep.series = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo', sourceLabel: 'Een Nederlandse aanbieder', + resolution: 'minute', sourceLabels: { bitvavo: 'Een Nederlandse aanbieder' }, + points: [[o.from + 1, 61000, 'bitvavo'], [o.from + 2, 61010, 'mempool']] }); + const uit = await intradaySeries(id, sinds()); + assert.deepEqual(uit.points.map(p => p[2]), ['Een Nederlandse aanbieder', 'mempool']); + assert.equal(uit.sourceLabel, 'Een Nederlandse aanbieder'); + }); +}); + +describe('priceAtMoment', () => { + const AT = Date.parse('2026-10-19T10:00:00Z'); + const W = CONFIG.MARKET_SERVICE.MOMENT_WINDOW_MS; + + it('vraagt de minuten rond het moment, zonder asset-id, en kiest het dichtstbijzijnde punt', async () => { + const id = asset('BTC', 'crypto'); + nep.series = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo', resolution: 'minute', + points: [[AT - 240_000, 60_000], [AT + 60_000, 61_000], [AT + 180_000, 62_000]] }); + const uit = await priceAtMoment(id, new Date(AT).toISOString()); + assert.deepEqual(uit, { status: 'ok', price: 61_000, currency: 'EUR', at: AT + 60_000, source: 'bitvavo' }); + const vraag = nep.calls[0]; + assert.deepEqual([vraag.instrument, vraag.resolution, vraag.from, vraag.to], ['crypto:BTC', 'minute', AT - W, AT + W]); + assert.equal(Object.values(vraag).includes(id), false); + }); + + it('een punt buiten het venster is geen koers van dat moment', async () => { + const id = asset('BTC', 'crypto'); + nep.series = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo', resolution: 'minute', + points: [[AT - W - 1, 60_000]] }); + assert.deepEqual(await priceAtMoment(id, new Date(AT).toISOString()), { status: 'missing' }); + }); + + it('een metaal per gram, en de bron van het punt als de dienst die noemt', async () => { + const id = asset('XAU', 'metal'); + nep.series = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'x', resolution: 'minute', + points: [[AT, 2488.278144, 'goldapi']] }); + const uit = await priceAtMoment(id, new Date(AT).toISOString()); + assert.ok(Math.abs(uit.price - 80) < 1e-9); + assert.equal(uit.source, 'goldapi'); + }); + + it('zonder token, buiten het register of zonder minuutreeks: niets gevraagd', async () => { + const btc = asset('BTC', 'crypto'); + const eth = asset('ETH', 'crypto'); + nep.configured = false; + assert.deepEqual(await priceAtMoment(btc, new Date(AT).toISOString()), { status: 'off' }); + nep.configured = true; + assert.deepEqual(await priceAtMoment(eth, new Date(AT).toISOString()), { status: 'notCovered' }); + assert.equal(nep.calls.length, 0); + }); + + it('onbereikbaar is een antwoord, en een moment in de toekomst een contractfout', async () => { + const id = asset('BTC', 'crypto'); + nep.series = async () => { throw new AppError('marketService.unreachable', null, 'down'); }; + const uit = await priceAtMoment(id, new Date(AT).toISOString()); + assert.equal(uit.status, 'unavailable'); + await assert.rejects(priceAtMoment(id, new Date(Date.now() + 60_000).toISOString()), /moment in the past/); + await assert.rejects(priceAtMoment(id, 'gisteren'), /moment in the past/); + }); +}); + +describe('intradaySeries: wanneer er niets gevraagd wordt', () => { + it('zonder token: uit, zonder melding en zonder vraag aan de dienst', async () => { + const id = asset('BTC', 'crypto'); + nep.configured = false; + assert.deepEqual(await intradaySeries(id, sinds()), { status: 'off' }); + assert.equal(nep.calls.length, 0); + }); + + it('een asset dat het register niet kent: niet gedekt', async () => { + const id = asset('DOGE', 'crypto'); + assert.deepEqual(await intradaySeries(id, sinds()), { status: 'notCovered' }); + assert.equal(nep.calls.length, 0); + }); + + it('een metaal in troy ounce: gedekt, en per gram, want zo bewaart de app metaal', async () => { + const id = asset('XAU', 'metal'); + nep.series = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'goldapi', resolution: 'minute', points: [[o.from + 60_000, 2488.278144]] }); + const uit = await intradaySeries(id, sinds()); + assert.equal(uit.status, 'ok'); + assert.equal(nep.calls[0].instrument, 'metal:XAU'); + assert.ok(Math.abs(uit.points[0][1] - 80) < 1e-9, `${uit.points[0][1]} per gram`); + }); + + it('een eenheid die de app niet kent: niet gedekt, want een verkeerde factor is een stil verkeerde koers', async () => { + nep.register = [{ key: 'metal:XPT', kind: 'metal', symbol: 'XPT', unit: 'lb', resolutions: ['minute'] }]; + assert.deepEqual(await intradaySeries(asset('XPT', 'metal'), sinds()), { status: 'notCovered' }); + assert.equal(nep.calls.length, 0); + }); + + it('een instrument zonder minuutreeks: niet gedekt', async () => { + const id = asset('ETH', 'crypto'); + assert.deepEqual(await intradaySeries(id, sinds()), { status: 'notCovered' }); + }); + + it('dezelfde code bij een andere soort is een ander instrument', async () => { + const id = asset('BTC', 'fiat'); + assert.deepEqual(await intradaySeries(id, sinds()), { status: 'notCovered' }); + }); +}); + +describe('intradaySeries: een onbereikbare dienst is een antwoord, geen fout', () => { + const metFout = (e) => { nep.series = async () => { throw e; }; }; + + it('api.monthUnreachable: onbeschikbaar en later opnieuw, met de maand', async () => { + const id = asset('BTC', 'crypto'); + metFout(new AppError('marketService.monthUnreachable', { month: '2026-09' }, 'x')); + assert.deepEqual(await intradaySeries(id, sinds()), + { status: 'unavailable', retryLater: true, key: 'marketService.monthUnreachable', params: { month: '2026-09' } }); + }); + + it('dienst weg: onbeschikbaar en later opnieuw', async () => { + const id = asset('BTC', 'crypto'); + metFout(new AppError('marketService.unreachable', { url: 'u', reason: 'ECONNREFUSED' }, 'x')); + const uit = await intradaySeries(id, sinds()); + assert.equal(uit.status, 'unavailable'); + assert.equal(uit.retryLater, true); + }); + + it('401: onbeschikbaar maar NIET later opnieuw, zodat de melding om een nieuw token vraagt', async () => { + const id = asset('BTC', 'crypto'); + metFout(new AppError('marketService.unauthorized', null, 'x')); + const uit = await intradaySeries(id, sinds()); + assert.equal(uit.status, 'unavailable'); + assert.equal(uit.retryLater, false); + assert.equal(uit.key, 'marketService.unauthorized'); + }); + + it('geeft de request-id van de mislukte aanroep mee, zodat de fout in beide logs te vinden is', async () => { + const id = asset('BTC', 'crypto'); + const e = new AppError('marketService.rejected', { status: 404, key: 'api.noSeries', reason: 'x' }, 'x'); + e.requestId = 'a1b2c3d4-0000-4000-8000-000000000001'; + metFout(e); + assert.equal((await intradaySeries(id, sinds())).requestId, 'a1b2c3d4-0000-4000-8000-000000000001'); + }); + + it('een fout uit het register telt net zo', async () => { + const id = asset('BTC', 'crypto'); + nep.register = () => { throw new AppError('marketService.timeout', { s: 10 }, 'x'); }; + const uit = await intradaySeries(id, sinds()); + assert.equal(uit.status, 'unavailable'); + assert.equal(uit.retryLater, true); + assert.equal(nep.calls.length, 0); + }); + + it('een andere fout gaat gewoon door, want dat is een fout in de app', async () => { + const id = asset('BTC', 'crypto'); + metFout(new TypeError('stuk')); + await assert.rejects(() => intradaySeries(id, sinds()), /stuk/); + }); +}); + +describe('intradaySeries: contractfouten van de app zelf', () => { + it('weigert een onbekend asset', async () => { + await assert.rejects(() => intradaySeries(9999, sinds()), /Unknown asset/); + }); + + it('weigert een begin in de toekomst, geen getal, of verder terug dan de grens', async () => { + const id = asset('BTC', 'crypto'); + const grens = CONFIG.MARKET_SERVICE.INTRADAY_MAX_DAYS * 86_400_000; + for (const slecht of [Date.now() + 60_000, 'gisteren', NaN, Date.now() - grens - 60_000]) { + await assert.rejects(() => intradaySeries(id, slecht), /sinceMs/, `sinceMs ${slecht}`); + } + assert.equal(nep.calls.length, 0); + }); +}); + +describe('serviceSpot: de actuele koers uit de dienst (fase 3)', () => { + it('vraagt het HELE bruikbare register als pakket, in de rekenvaluta, en geeft de koers in de munt van de bron', async () => { + const id = asset('BTC', 'crypto'); + const uit = await serviceSpot(id); + assert.deepEqual(uit, { status: 'ok', instrument: 'crypto:BTC', price: 61000, currency: 'EUR', observedAt: 1, source: 'bitvavo', sourceLabel: 'Bitvavo' }); + assert.equal(nep.spotCalls.length, 1); + assert.deepEqual([...nep.spotCalls[0].keys].sort(), ['crypto:BTC', 'crypto:ETH', 'metal:XAU']); + assert.equal(nep.spotCalls[0].quote, CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY); + }); + + it('zonder token: uit, en geen vraag', async () => { + const id = asset('BTC', 'crypto'); + nep.configured = false; + assert.deepEqual(await serviceSpot(id), { status: 'off' }); + assert.equal(nep.spotCalls.length, 0); + }); + + it('een handmatige koers van vandaag gaat voor: de dienst wordt niet gevraagd', async () => { + const id = asset('BTC', 'crypto'); + harness.exports.run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) + VALUES ('manual','Handmatig','handmatig','','none',0,1,1,1,'2026-01-01')`); + const manual = harness.exports.one(`SELECT id FROM Provider WHERE key = 'manual'`).id; + const gisteren = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + harness.exports.run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 50000, 'day', ?)`, + [id, manual, `${gisteren}T00:00:00Z`]); + assert.equal((await serviceSpot(id)).status, 'ok'); + + const vandaag = new Date().toISOString().slice(0, 10); + harness.exports.run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 50000, 'day', ?)`, + [id, manual, `${vandaag}T00:00:00Z`]); + const voor = nep.spotCalls.length; + assert.deepEqual(await serviceSpot(id), { status: 'local' }); + assert.equal(nep.spotCalls.length, voor); + }); + + it('niet in het register: niet gedekt, zonder vraag om koersen', async () => { + assert.deepEqual(await serviceSpot(asset('DOGE', 'crypto')), { status: 'notCovered' }); + assert.equal(nep.spotCalls.length, 0); + }); + + it('een metaal per troy ounce komt per gram terug, in de munt van de bron', async () => { + nep.spots = async () => ({ spot: [{ instrument: 'metal:XAU', price: 2488.278144, currency: 'USD', observedAt: 1, source: 'goldapi', sourceLabel: 'GoldAPI' }], missing: [], unreachable: [] }); + const uit = await serviceSpot(asset('XAU', 'metal')); + assert.equal(uit.status, 'ok'); + assert.equal(uit.currency, 'USD'); + assert.ok(Math.abs(uit.price - 80) < 1e-9, `${uit.price} per gram`); + }); + + it('`missing` is een gat, `unreachable` is later opnieuw', async () => { + assert.deepEqual(await serviceSpot(asset('ETH', 'crypto')), { status: 'missing' }); + nep.spots = async () => ({ spot: [], missing: [], unreachable: ['crypto:BTC'] }); + assert.deepEqual(await serviceSpot(asset('BTC', 'crypto')), + { status: 'unavailable', retryLater: true, key: 'marketService.spotUnreachable', params: null }); + }); + + it('dienst weg of token geweigerd: een antwoord, met het oordeel over later opnieuw', async () => { + const id = asset('BTC', 'crypto'); + nep.spots = async () => { throw new AppError('marketService.unreachable', { url: 'u', reason: 'ECONNREFUSED' }, 'x'); }; + assert.equal((await serviceSpot(id)).retryLater, true); + nep.spots = async () => { throw new AppError('marketService.unauthorized', null, 'x'); }; + const uit = await serviceSpot(id); + assert.equal(uit.status, 'unavailable'); + assert.equal(uit.retryLater, false); + }); + + it('weigert een onbekend asset', async () => { + await assert.rejects(() => serviceSpot(9999), /Unknown asset/); + }); +}); + +describe('serviceCoverage: welke assets de dienst dekt', () => { + it('geeft de assets waarvan het register het instrument kent, metaal in troy ounce inbegrepen', async () => { + const { serviceCoverage } = await import('../../server/data/market.js'); + const b = asset('BTC', 'crypto'), e = asset('ETH', 'crypto'); + const x = asset('XAU', 'metal'); + asset('DOGE', 'crypto'); + assert.deepEqual(await serviceCoverage(), { status: 'ok', assetIds: [b, e, x] }); + }); + + it('zonder token: uit, en het register wordt niet gevraagd', async () => { + const { serviceCoverage } = await import('../../server/data/market.js'); + nep.configured = false; + let gevraagd = false; + nep.register = () => { gevraagd = true; return []; }; + assert.deepEqual(await serviceCoverage(), { status: 'off' }); + assert.equal(gevraagd, false); + }); + + it('register onbereikbaar: onbeschikbaar, geen fout', async () => { + const { serviceCoverage } = await import('../../server/data/market.js'); + nep.register = () => { throw new AppError('marketService.unreachable', { url: 'u', reason: 'x' }, 'x'); }; + assert.equal((await serviceCoverage()).status, 'unavailable'); + }); +}); + +describe('serviceDayHistory: dagkoersen uit de dienst (10-10-2026)', () => { + const DAG = 86_400_000; + const vandaag = () => Math.floor(Date.now() / DAG) * DAG; + const sinds = () => vandaag() - 10 * DAG; + + it('geeft alleen afgesloten dagen, geen momenten en niet vandaag, in de munt van de bron', async () => { + const id = asset('BTC', 'crypto'); + nep.history = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'eulerpool', points: [ + [vandaag() - 2 * DAG, 60000, 'day'], [vandaag() - 2 * DAG + 3600_000, 60500, 'moment'], + [vandaag() - DAG, 61000, 'day'], [vandaag(), 62000, 'day'], + ] }); + const uit = await serviceDayHistory(id, sinds()); + assert.deepEqual(uit, { status: 'ok', instrument: 'crypto:BTC', currency: 'USD', source: 'eulerpool', + days: [[vandaag() - 2 * DAG, 60000], [vandaag() - DAG, 61000]] }); + assert.deepEqual(Object.keys(nep.historyCalls[0]).sort(), ['from', 'instrument', 'quote', 'to']); + assert.equal(Object.values(nep.historyCalls[0]).includes(id), false, 'een asset-id ging de deur uit'); + }); + + it('houdt per dag de bron die de dienst noemde, bij zijn naam (de alias)', async () => { + nep.history = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo', + sourceLabels: { bitvavo: 'Bitvavo (beurs)' }, points: [ + [vandaag() - 2 * DAG, 60000, 'day', 'bitvavo'], + [vandaag() - DAG, 61000, 'day', 'onbekend'], + [vandaag() - DAG + 1, 61500, 'day'], + ] }); + const id = asset('BTC', 'crypto'); + const uit = await serviceDayHistory(id, sinds()); + assert.equal(uit.days[0][2], 'Bitvavo (beurs)', 'de alias uit sourceLabels'); + assert.equal(uit.days[1][2], 'onbekend', 'zonder naam de sleutel'); + assert.equal(uit.days[2].length, 2, 'een rij zonder bron krijgt er geen'); + }); + + it('metaal per gram', async () => { + nep.history = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'eulerpool', points: [[vandaag() - DAG, 2488.278144, 'day']] }); + const uit = await serviceDayHistory(asset('XAU', 'metal'), sinds()); + assert.ok(Math.abs(uit.days[0][1] - 80) < 1e-9); + }); + + it('een instrument zonder historie in het register wordt niet gevraagd', async () => { + assert.deepEqual(await serviceDayHistory(asset('ETH', 'crypto'), sinds()), { status: 'notCovered' }); + assert.equal(nep.historyCalls.length, 0); + }); + + it('zonder token uit, en een onbereikbare dienst is een antwoord', async () => { + const id = asset('BTC', 'crypto'); + nep.configured = false; + assert.deepEqual(await serviceDayHistory(id, sinds()), { status: 'off' }); + nep.configured = true; + nep.history = async () => { throw new AppError('marketService.unreachable', { url: 'u', reason: 'x' }, 'x'); }; + assert.equal((await serviceDayHistory(id, sinds())).retryLater, true); + }); + + it('weigert een begin in de toekomst of verder terug dan de grens', async () => { + const id = asset('BTC', 'crypto'); + for (const slecht of [Date.now() + DAG, 'gisteren', Date.now() - (CONFIG.MARKET_SERVICE.HISTORY_MAX_DAYS + 2) * DAG]) { + await assert.rejects(() => serviceDayHistory(id, slecht), /sinceMs/); + } + }); +}); diff --git a/tests/repo/merge.test.mjs b/tests/repo/merge.test.mjs new file mode 100644 index 0000000..48fc3c3 --- /dev/null +++ b/tests/repo/merge.test.mjs @@ -0,0 +1,302 @@ +// Generated, do not edit. sha256:ba6482c6f8221f445c935cfd65e76c09e943da662ff5dab04e50d8a76e004316 +import { describe, it, beforeEach, mock } from 'node:test'; +import { toSearchName } from '../../shared/search-name.js'; +import assert from 'node:assert/strict'; + +import { createDbHarness } from '../helpers/db-node.mjs'; +import { ACCOUNT_SOURCE_KINDS } from '../../shared/config.js'; + +const harness = createDbHarness(); +mock.module('../../server/db.js', { namedExports: harness.exports }); + +const { mergeAccounts, mergeCheck } = await import('../../server/data/accounts-merge.js'); +const { eventsNetByAccount } = await import('../../server/data/events.js'); + +let btc; +beforeEach(() => { + harness.recreate(); + harness.exports.run( + `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'unit')`); + btc = harness.exports.lastId(); +}); + +function account(naam, kind = 'wallet') { + const eigen = ACCOUNT_SOURCE_KINDS[kind].length > 0 ? 1 : 0; + harness.exports.run( + `INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,?,?,?,1,NULL)`, [naam, toSearchName(naam), kind, eigen, 0]); + return harness.exports.lastId(); +} +function event({ type = 'buy', from = null, to = null, amount = 1000, at = '2024-01-01T00:00:00', unitFee = null }) { + harness.exports.run( + `INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, unitFee, fromAccountId, toAccountId, source, createdAt) + VALUES (?,?,?,?,?,?,?,?,'manual',?)`, + [btc, type, at, amount, type === 'buy' || type === 'sell' ? 1 : null, unitFee, from, to, at]); + return harness.exports.lastId(); +} +function waardering({ acc, onDate, balance, rateAt = null }) { + harness.exports.run( + `INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source) + VALUES (?,?,?,?,?,?, 'manual')`, + [btc, onDate, acc, balance, 50000, rateAt ?? `${onDate}T00:00:00`]); + return harness.exports.lastId(); +} +const tel = (tabel, kolom, id) => + harness.exports.one(`SELECT COUNT(*) AS n FROM ${tabel} WHERE ${kolom} = ?`, [id]).n; +const bestaatAccount = id => harness.exports.one(`SELECT 1 AS x FROM Account WHERE id=?`, [id]) != null; + +describe('mergeCheck: compatibiliteit', () => { + it('vraagt om twee accounts', () => { + const a = account('A'); + assert.equal(mergeCheck(a, null).reason, 'pickTwo'); + assert.equal(mergeCheck(null, a).reason, 'pickTwo'); + assert.equal(mergeCheck(a, null).counts, null); + }); + + it('weigert een account met zichzelf', () => { + const a = account('A'); + assert.equal(mergeCheck(a, a).reason, 'sameAccount'); + assert.equal(mergeCheck(String(a), a).reason, 'sameAccount', + 'ook als het id als string binnenkomt (uit een