// 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(); }, }); }