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