Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+241
View File
@@ -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))} `
+ `<span class="t-muted-cell">${esc(localDateNl(p.observedAt))}</span>`,
account: p =>
`${t('aiProposals.kindAccount')}: <strong>${esc(p.name)}</strong> `
+ `<span class="t-muted-cell">${esc(t('enums.account_kind.' + p.accountKind))} · `
+ `${esc(p.isOwnCustody ? t('aiProposals.ownCustody') : t('aiProposals.thirdParty'))}</span>`,
account_delete: p =>
`${t('aiProposals.kindAccountDelete')}: <strong>${esc(p.account_name)}</strong> `
+ `<span class="t-muted-cell">${esc(t('enums.account_kind.' + p.account_kind))}</span>`,
portfolio: p =>
`${t('aiProposals.kindPortfolio')}: <strong>${esc(p.name)}</strong> `
+ `<span class="t-muted-cell">${esc(t('aiProposals.portfolioIn'))} ${esc(currencyName(p.currency))} (${esc(p.currency)})</span><br>`
+ `<span class="t-muted-cell">${esc(t('aiProposals.portfolioNote'))}</span>`,
preference: p => {
const nu = huidigeVoorkeur(p);
return `${t('aiProposals.kindPreference')}: <strong>${esc(p.scope)}.${esc(p.key)}</strong> `
+ `→ ${val(leesbareWaarde(p, p.value))}`
+ (nu === null ? '' : ` <span class="t-muted-cell">(${esc(t('aiProposals.preferenceCurrent'))} ${esc(nu)})</span>`)
+ `<br><span class="t-muted-cell">${esc(t('aiProposals.preferenceNote'))}</span>`;
},
source_choice: p =>
`${t('aiProposals.kindSourceChoice')}: ${assetLabelHtml(assetVan(p))} · `
+ `${esc(t('integrations.sourceCap.' + p.scope))} · <strong>${esc(p.provider_name || p.key)}</strong> `
+ `${esc(t(p.value === 'first' ? 'aiProposals.sourceFirst' : 'aiProposals.sourceRemove'))}<br>`
+ `<span class="t-muted-cell">${esc(t('aiProposals.sourceChoiceNote'))}</span>`,
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) })} `
+ `· <strong>${esc(p.account_name || '')}</strong>${naast}<br>`
+ `<span class="t-muted-cell">${esc(localDateNl(p.observedAt))}</span>`;
},
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}<br>`
+ `<span class="t-muted-cell">${waar} · ${esc(localDateNl(p.observedAt))}</span>`;
},
};
function regel(p) {
const maak = OMSCHRIJVING[p.kind];
const tekst = maak ? maak(p) : esc(t('aiProposals.unknownKind') + ` (${p.kind})`);
const notitie = p.note ? `<div class="t-body-sm t-muted">${esc(p.note)}</div>` : '';
const mislukt = p.detail ? `<div class="t-body-sm t-neg">${esc(t('aiProposals.failed'))}: ${esc(detailTekst(p.detail))}</div>` : '';
const weg = ONOMKEERBAAR.has(p.kind);
const waarschuwing = weg
? `<div class="t-body-sm t-neg">${esc(t('aiProposals.irreversible'))}</div>` : '';
const opening = p.kind === 'valuation' && p.events_before === 0
? `<div class="t-body-sm t-neg">${esc(t('aiProposals.openingBalance'))}</div>` : '';
return `<div class="rec-item" data-aiprop="${p.id}" data-aidanger="${weg ? 1 : 0}" data-aikind="${esc(p.kind)}">
<div class="rec-item-row">
<div class="rec-item-meta">${tekst}${notitie}${waarschuwing}${opening}${mislukt}</div>
<div class="row-actions rec-hover">
<button class="btn ${weg ? 'btn-danger' : 'btn-primary'} btn-xs" data-aiact="accept">${weg ? t('aiProposals.acceptDelete') : t('aiProposals.accept')}</button>
<button class="btn btn-ghost btn-xs" data-aiact="reject">${t('aiProposals.reject')}</button>
</div>
</div>
</div>`;
}
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
? `<div class="t-body-sm t-neg" style="margin-bottom:0.6rem">${esc(t('aiProposals.bulkSkips').replace('{n}', teVerwijderen))}</div>`
: '';
host.innerHTML = taskCardHtml({
title: t('aiProposals.title'),
actions: `<div class="row-actions">
<button class="btn btn-secondary btn-xs" id="aiprop-accept-all">${t('aiProposals.acceptAll')}</button>
<button class="btn btn-ghost btn-xs" id="aiprop-reject-all">${t('aiProposals.rejectAll')}</button>
</div>`,
body: `<div class="t-body-sm t-muted" style="margin-bottom:0.6rem">${t('aiProposals.intro')}</div>
${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;
}
+11
View File
@@ -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);
}
+20
View File
@@ -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));
}
+110
View File
@@ -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) =>
`<button class="btn btn-primary btn-sm" data-book="${i}"${kan ? '' : ` disabled title="${esc(t('accounts.bookOldestFirst'))}"`}>${t('accounts.book')}</button>`
+ `<button class="btn btn-ghost btn-sm" data-dismiss="${i}">${t('accounts.dismiss')}</button>`;
const dueUitleg = (due, boekbaar) => due.some(d => !boekbaar.has(dueKey(d)))
? `<div class="t-body-sm t-muted mt-sm">${t('accounts.bookOldestFirst')}</div>` : '';
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 = '') => `<div class="ip-fig"><div class="t-label">${label}</div>`
+ `<div class="t-value ip-fig-value">${value}</div>${sub ? `<div class="t-body-sm t-muted">${sub}</div>` : ''}</div>`;
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
? `<details class="ip-upcoming" ${expand.attr('upcoming')}><summary class="t-body-sm">${t('accounts.projAllUpcoming', { n: f.upcoming.length })}`
+ `<span class="chev-btn" aria-hidden="true">${CHEVRON}</span></summary>`
+ `<div class="ip-upcoming-list">${f.upcoming.map(p => `<div class="ip-up"><span class="t-muted">${esc(p.periodEnd)}</span>`
+ `<span class="ip-num">${val(fmtUnit(p.projectedSub, m))}</span></div>`).join('')}</div></details>`
: '';
return `<div class="ip-sched"><div class="ip-sched-head"><span class="ip-sched-name">${naam}</span>`
+ `<span class="t-body-sm t-muted">${termen}</span></div><div class="ip-figs">${cijfers}</div>${komend}</div>`;
}).join('');
const dueHtml = due.length ? `<div class="ip-due"><div class="t-label">${t('accounts.dueTitle')}</div>`
+ due.map((d, i) => `<div class="ip-due-row">`
+ `<span class="t-body-sm">${d.periodStart} → ${d.periodEnd}</span><span class="t-body-sm ip-num">${val(fmtUnit(d.projected_sub, dueMeta(d)))}</span>`
+ `<span class="ip-due-btns">${dueKnoppen(i, boekbaar.has(dueKey(d)))}</span></div>`).join('')
+ dueUitleg(due, boekbaar) + '</div>' : '';
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) => `<div class="flex gap-md" style="align-items:center;flex-wrap:wrap;margin-top:0.4rem">`
+ `<span class="t-body-sm">${esc(d.account_name)} · ${d.periodStart} → ${d.periodEnd}: ${val(fmtUnit(d.projected_sub, dueMeta(d)))}</span>`
+ `${dueKnoppen(i, boekbaar.has(dueKey(d)))}</div>`).join('') + dueUitleg(due, boekbaar);
host.innerHTML = taskCardHtml({ title: t('actions.interestTitle'), body });
wireDue(host, due, refresh);
return due.length;
}
+62
View File
@@ -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 `<div class="notify-row${info}" data-target="${target}" data-kind="${n.kind}" tabindex="0">
<button type="button" class="notify-count" data-dismiss="${n.id}" title="${t('notify.dismiss')}" aria-label="${t('notify.dismiss')}">`
+ `<span class="notify-n">${n.count}</span><span class="notify-check" aria-hidden="true">${CHECK}</span></button>
<div class="notify-kind">${t('notify.' + n.kind)}</div>
${detail ? `<div class="notify-detail" title="${esc(detail)}">${esc(detail)}</div>` : ''}
<div class="notify-side">
<span class="notify-date">${date || ''}</span>
<span class="notify-go" aria-hidden="true">${CHEVRON_RIGHT}</span>
</div>
</div>`;
}
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()
: `<div class="notify-list">${(await Promise.all(sorted.map(rowHtml))).join('')}</div>`;
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(); };
});
}
+21
View File
@@ -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;
}
+363
View File
@@ -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 = `<button class="btn btn-primary btn-sm" id="rec-rematch">${t('home.rematch')}</button>`;
const wizard = pending.length === 0
? nothingPendingHtml()
: `<div class="rec-wiz-nav">
<button class="btn btn-ghost btn-xs" id="rec-prev" ${wizIdx === 0 ? 'disabled' : ''}>‹ ${t('home.recPrev')}</button>
<span class="t-body-sm t-muted">${wizIdx + 1} / ${pending.length}</span>
<button class="btn btn-ghost btn-xs" id="rec-next" ${wizIdx >= pending.length - 1 ? 'disabled' : ''}>${t('home.recNext')} ›</button>
</div>
${recRow(current, true, current.id)}`;
host.innerHTML = taskCardHtml({
title: t('home.reconcile'),
actions,
body: `<div id="rec-progress" class="t-body-sm t-muted" style="margin-bottom:0.6rem"></div>
${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 `<span class="t-muted-cell" title="${esc(uitleg)}">(${esc(localDateTimeNl(wanneer))})</span>`;
}
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 ? `<button class="btn btn-secondary btn-xs" data-act="link">${t('home.recLink')}</button>` : '';
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
? `<button class="btn btn-primary btn-xs" data-act="internal" title="${esc(t('home.recInternalHint'))}">${esc(internLabel)}</button>` : '';
const forward = isForward(p);
const renteBtn = forward
? `<button class="btn btn-primary btn-xs" data-act="interest" title="${esc(t('home.recInterestHint'))}">${t('home.recAsInterest')}</button>` : '';
const eerste = p.tx_kind || forward;
const memo = memoLine(p.tx_memo, p.direction, 120);
const memoHtml = memo ? `<div class="t-body-sm t-muted" title="${esc(memo.title)}">${esc(memo.text)}</div>` : '';
return `<div class="${cls}" data-prop="${p.id}"${wtxAttrs}>
<div class="rec-item-row">
<div class="rec-item-meta"><strong>${val(`${fmtInt(p.amount)} sat`)}</strong> ${assetLabelHtml(p)} ${dir}${esc(laag)}${esc(intern)} · ${esc(p.account_name)}
${momentCel(p)}${p.transactionHash ? ` <span class="t-muted-cell" title="${esc(p.transactionHash)}">· ${esc(shortTx(p.transactionHash))}</span>` : ''}${memoHtml}</div>
<div class="row-actions rec-hover">
${linkBtn}
${internBtn}${renteBtn}
<button class="btn ${eerste ? 'btn-secondary' : 'btn-primary'} btn-xs" data-act="trade">${t('home.recAsTrade')}</button>
<button class="btn btn-secondary btn-xs" data-act="transfer">${t('home.recAsTransfer')}</button>
<button class="btn btn-ghost btn-xs" data-act="ignore">${t('home.recIgnore')}</button>
<button class="btn btn-ghost btn-xs" data-del-prop="${p.id}" title="${t('home.recDelete')}">✕</button>
</div>
</div>
<div class="prop-form mt-sm hidden"></div>
</div>`;
}
function drawerSection(pending, handled, current, expand) {
const total = pending.length + handled.length;
const teBoeken = bulkPlan(pending).filter(s => s.action !== 'skip').length;
const bulk = `<div class="rec-bulk">
${teBoeken ? `<button class="btn btn-primary btn-xs" id="rec-book-all">${t('home.recBookAll', { n: teBoeken })}</button>` : ''}
<button class="btn btn-secondary btn-xs" id="rec-ignore-all" ${pending.length ? '' : 'disabled'}>${t('home.recIgnoreAll')}</button>
<button class="btn btn-danger btn-xs" id="rec-delete-all" ${pending.length ? '' : 'disabled'}>${t('home.recDeleteAll')}</button>
</div>`;
const rows = total === 0
? `<div class="t-body-sm t-muted" style="padding:0.5rem 0">${t('home.recHandledNone')}</div>`
: pending.map(p => recRow(p, false, current ? current.id : 0)).join('') + handled.map(handledRow).join('');
return `<details class="mt-lg" ${expand.attr('drawer')}>
<summary class="t-label" style="cursor:pointer">${t('home.recAll')} (${total})</summary>
${bulk}
<div class="mt-sm">${rows}</div>
</details>`;
}
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 = `<span class="badge ${STATUS_BADGE[p.status] || 'badge-neutral'}">${t('enums.reconcile_status.' + p.status)}</span>`;
const revert = REVERTIBLE.has(p.status)
? `<button class="btn btn-ghost btn-xs" data-revert="${p.id}">${t('home.recRevert')}</button>` : '';
const del = p.status !== 'auto_linked'
? `<button class="btn btn-ghost btn-xs" data-del-prop="${p.id}" title="${t('home.recDelete')}">✕</button>` : '';
return `<div class="rec-item rec-handled">
<div class="rec-item-row">
<div class="rec-item-meta"><strong>${val(`${fmtInt(p.amount)} sat`)}</strong> ${assetLabelHtml(p)} ${dir} · ${esc(p.account_name)}
${momentCel(p)}${p.transactionHash ? ` <span class="t-muted-cell" title="${esc(p.transactionHash)}">· ${esc(shortTx(p.transactionHash))}</span>` : ''}</div>
<div class="row-actions"><span class="row-actions rec-hover">${revert}${del}</span>${badge}</div>
</div>
</div>`;
}
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 = `
<div class="flex gap-md" style="flex-wrap:wrap;align-items:flex-end">
<div class="form-group" style="margin-bottom:0">
<label class="form-label">${t('home.recTotal', { munt: baseCurrency() })}</label>
<input type="number" class="input" data-total min="0" step="any" style="min-width:140px">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label">${t('home.recPrice', { munt: baseCurrency() })}</label>
<input type="number" class="input" data-price min="0" step="any" style="min-width:160px">
<div class="form-hint" data-spot></div>
</div>
<button class="btn btn-primary btn-sm" data-confirm>${t('home.recConfirm')}</button>
</div>
<div class="form-error" data-err></div>`;
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 => `<option value="${a.id}">${esc(a.name)}</option>`).join('');
form.innerHTML = `
<div class="flex gap-md" style="flex-wrap:wrap;align-items:flex-end">
<div class="form-group" style="margin-bottom:0">
<label class="form-label">${t('home.recCounter')}</label>
<select class="input" data-counter style="min-width:180px">${opts}</select>
</div>
<button class="btn btn-primary btn-sm" data-confirm>${t('home.recConfirm')}</button>
</div>
<div class="form-error" data-err></div>`;
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; }
};
}
+58
View File
@@ -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;
}
}
+28
View File
@@ -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) => `<div class="flex gap-md" style="align-items:center;flex-wrap:wrap;margin-top:0.4rem">`
+ `<span class="t-body-sm">${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)}` : ''}</span>`
+ `<button class="btn btn-primary btn-sm" data-res-book="${i}">${t('reservations.book')}</button>`
+ `<button class="btn btn-ghost btn-sm" data-res-settle="${i}">${t('reservations.settle')}</button></div>`).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;
}
+23
View File
@@ -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,
};
}
+144
View File
@@ -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) => `<div class="flex gap-md" style="align-items:center;flex-wrap:wrap;margin-top:0.4rem">
<span class="t-body-sm" title="${esc(t('valuations.lightningHint'))}">${esc(localDateNl(d.scannedAt))} · ${esc(d.account_name)} · ${esc(t('valuations.lightningRow'))}:
<span class="${d.gap < 0 ? 't-neg' : 't-pos'}">${val(fmtUnit(d.gap, btcMeta))}</span></span>
<button class="btn btn-secondary btn-sm" data-vrecord="${i}" title="${esc(t('valuations.recordBalanceHint'))}">${t('valuations.recordBalance')}</button>
</div>`).join('');
const balansRijen = rows.map((v, i) => `<div class="flex gap-md" style="align-items:center;flex-wrap:wrap;margin-top:0.4rem">
<span class="t-body-sm">${esc(v.valuedOn)} · ${esc(v.account_name)} · ${assetLabelHtml(v)}:
<span class="${v.gap < 0 ? 't-neg' : 't-pos'}">${val(fmtUnit(v.gap, meta(v)))}</span></span>
<button class="btn btn-secondary btn-sm" data-vbook="${i}">${t('valuations.book')}</button>
<button class="btn btn-ghost btn-sm" data-vaccept="${i}" title="${esc(t('valuations.acceptHint'))}">${t('valuations.accept')}</button>
</div>`).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;
}