Files
2026-10-29 11:57:29 +01:00

364 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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; }
};
}