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

376 lines
19 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:d029e4f661a0e4d29fa7970f6f2aff7a15cdf1a9757dd86f22ff6a54e12c7c53
import { listSelectableAccounts } from '../data/accounts.js';
import { localDate, localDateNl, localTime, toUtcIso } from '../../shared/time.js';
import { listAssets, listBookableAssets } from '../data/assets.js';
import { listTrades, createEvent, updateEvent, deleteEvent } from '../data/events.js';
import { listMetalProducts, listMetalProductsForAsset } from '../data/metals.js';
import { tableViewCfg } from '../data/preferences.js';
import { eventLinkMap, onchainAsset } from '../data/tx-links.js';
import { t } from '../i18n/index.js';
import { assetLabelHtml } from '../ui/asset-label.js';
import * as dialog from '../ui/dialog.js';
import { ENUMS, CONFIG, TARGETS, targetHash } from '../../shared/config.js';
import { renderTable } from '../ui/table.js';
import { takeRowFocus } from '../ui/row-focus.js';
import { fmtBase, fmtBasePrice, fmtUnit, val, toSubunit, fromSubunit, priceToBase, priceFromBase, isMetal } from '../ui/format.js';
import { totalFromPrice, priceFromTotal, amountFromTotal } from '../../shared/units.js';
import { priceUnitLabel } from '../../shared/units.js';
import { matchDot, wireMatchTriggers } from '../ui/match-popover.js';
import { spotOnDate } from '../ui/fiat.js';
import { ARROW_DOWN, ARROW_UP } from '../ui/icons.js';
import { esc } from '../ui/escape.js';
import { emptyStateHtml } from '../ui/empty-state.js';
import { setPageHint } from '../ui/page-hint.js';
import { bookingOriginHtml } from '../ui/price-origin.js';
import { listPriceHistory } from '../data/price-history.js';
import { herkomstContext } from '../wealth/data.js';
import { provenanceOn, originFrom } from '../../shared/price-provenance.js';
let tableApi = null;
let onlyAccount = null;
let onlyAsset = null;
let onlyKinds = null;
const assetAllowed = a => (onlyAsset == null || a.id === onlyAsset) && (onlyKinds == null || onlyKinds.includes(a.kind));
export async function render(el, { accountId = null, assetId = null, assetKinds = null } = {}) {
onlyAccount = accountId;
onlyAsset = assetId;
onlyKinds = assetKinds;
if ((await listAssets()).length === 0) {
el.innerHTML = emptyStateHtml(t('trades.none'), {
why: t('trades.needAsset'), href: targetHash(TARGETS.ASSETS), linkText: t('common.manageAssets'),
});
return;
}
el.innerHTML = `<div id="tbl"></div>`;
if (onlyAccount == null) setPageHint(el, { title: t('nav.trades'), rows: [t('trades.fiatNote')] });
buildTable(el);
}
function typeOpts() {
return ENUMS.EVENT_TYPE.filter(x => x === 'buy' || x === 'sell')
.map(x => ({ value: x, label: t('enums.event_type.' + x) }));
}
async function assetOptsOfKind(kind) {
return (await listBookableAssets()).filter(a => (!kind || a.kind === kind) && assetAllowed(a))
.map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` }));
}
async function accountOpts(huidigeId) {
const accounts = (await listSelectableAccounts({ ookId: Number(huidigeId) || null }))
.filter(a => onlyAccount == null || a.id === onlyAccount);
return [...(onlyAccount == null ? [{ value: '', label: '-' }] : []), ...accounts.map(a => ({ value: a.id, label: a.name }))];
}
async function productOpts(assetId) {
const id = Number(assetId);
const opts = [{ value: '', label: t('trades.looseWeight') }];
if (Number.isInteger(id)) for (const p of await listMetalProductsForAsset(id)) opts.push({ value: p.id, label: p.name });
return opts;
}
async function dagHerkomsten(trades) {
const uit = new Map();
const perAsset = new Map();
for (const tr of trades) {
if (tr.priceOrigin !== 'market_day' || tr.fiatPrice == null) continue;
if (!perAsset.has(tr.assetId)) perAsset.set(tr.assetId, []);
perAsset.get(tr.assetId).push(tr);
}
if (!perAsset.size) return uit;
const ctx = await herkomstContext();
for (const [assetId, lijst] of perAsset) {
let rijen = [];
try { rijen = await listPriceHistory(assetId); } catch { continue; }
for (const tr of lijst) {
const d = provenanceOn(rijen, tr.occurredAt.slice(0, 10),
{ assetKind: tr.asset_kind, manualProviderId: ctx.manualId, usedPrice: tr.fiatPrice });
if (d && d.fiatPrice != null && Math.abs(d.fiatPrice - tr.fiatPrice) <= 1e-9 * Math.max(1, Math.abs(tr.fiatPrice))) {
uit.set(tr.id, originFrom(d, ctx.names));
}
}
}
return uit;
}
async function buildTable(el) {
let dagHerkomst = new Map();
const rows = async () => {
const lijst = (await listTrades())
.filter(tr => (onlyAccount == null || (tr.type === 'buy' ? tr.toAccountId : tr.fromAccountId) === onlyAccount)
&& (onlyAsset == null || tr.assetId === onlyAsset))
.map(tr => ({ ...tr, loc: tr.type === 'buy' ? tr.to_name : tr.from_name }));
try { dagHerkomst = await dagHerkomsten(lijst); } catch { dagHerkomst = new Map(); }
return lijst;
};
const meta = r => ({ kind: r.asset_kind, decimals: r.asset_decimals, unit: r.asset_unit, subunit: r.asset_subunit, displayUnit: r.asset_display_unit });
const btc = await onchainAsset();
let linkMap = await eventLinkMap();
const today = new Date().toISOString().slice(0, 10);
const alleAssets = await listAssets();
const alleProducten = await listMetalProducts();
const assetById = id => alleAssets.find(a => a.id === Number(id));
const firstOf = kind => { const a = alleAssets.find(x => x.kind === kind && assetAllowed(x)); return a ? a.id : ''; };
async function resolvePrice(v) {
const asset = alleAssets.find(a => a.id === Number(v.assetId));
if (!asset || !v.occurredAt) return;
if (v.priceOrigin === 'none') { v.fiatPrice = 0; return; }
const spot = await spotOnDate(asset, v.occurredAt);
if (v.fiatPrice === '' || v.fiatPrice == null) {
if (spot == null) throw new Error(t('trades.noSpotForDate'));
v.fiatPrice = spot;
v.priceOrigin = 'market_day';
return;
}
const entered = parseFloat(v.fiatPrice);
if (spot != null && spot > 0 && Number.isFinite(entered)) {
const dev = Math.abs(entered - spot) / spot;
if (dev > CONFIG.METALS.SPOT_DEVIATION_WARN) {
const ok = await dialog.confirm({
message: `De ingevoerde prijs (${fmtBasePrice(entered)}) wijkt ${Math.round(dev * 100)}% af van de spot op ${v.occurredAt} (${fmtBasePrice(spot)}). Toch opslaan?`,
});
if (!ok) throw new Error(t('trades.saveCancelled'));
}
}
}
const convertAsset = vals => (vals.productId !== '' && vals.productId != null) ? null : (assetById(vals.assetId) || null);
function productCell(r) {
if (r.productId && r.product_name) return `${esc(r.product_name)} <span class="t-muted-cell">×${r.productCount}</span>`;
if (r.asset_kind === 'metal') return `<span class="t-muted-cell">${t('trades.looseWeight')}</span>`;
return '';
}
function herkomstCel(r) {
const hints = {
paid: 'trades.originPaidHint', market_day: 'trades.originMarketDay',
market_moment: 'trades.originMarketMoment', none: 'trades.originNoneHint',
};
if (!r.priceOrigin) {
return `<span class="t-muted-cell" title="${esc(t('trades.originUnknownHint'))}">?</span>`;
}
if (r.priceOrigin === 'paid') return '';
return `<span class="t-muted-cell" title="${esc(t(hints[r.priceOrigin]))}">${esc(t('enums.event_price_origin.' + r.priceOrigin))}</span>`;
}
function herkomstOpts(vals) {
const opts = [
{ value: '', label: t('trades.originPick') },
{ value: 'paid', label: t('trades.originPickPaid') },
{ value: 'none', label: t('trades.originPickGift') },
];
const huidig = vals && vals.priceOrigin;
if (huidig && !opts.some(o => o.value === huidig)) {
opts.push({ value: huidig, label: t('enums.event_price_origin.' + huidig) });
}
return opts;
}
const columns = [
{ key: 'occurredAt', label: t('trades.colDate'), sortable: true,
render: r => `${btc && r.assetId === btc.id ? matchDot({ matched: linkMap.has(r.id), method: linkMap.get(r.id), kind: 'event', id: r.id }) : ''}${localDateNl(r.occurredAt)}`,
group: { value: r => localDate(r.occurredAt).slice(0, 4), label: v => v, order: 'desc' },
edit: { type: 'date', sticky: true, attrs: { max: today }, value: r => r ? localDate(r.occurredAt) : today } },
{ key: '_time', label: t('trades.time'),
render: r => localTime(r.occurredAt),
edit: { type: 'time', value: r => r ? localTime(r.occurredAt) : '12:00' } },
{ key: 'loc', label: t('trades.colAccount'), sortable: true,
render: r => r.loc || '-', group: { value: r => r.loc || '-', label: v => v },
edit: { type: 'select', sticky: true, options: vals => accountOpts(vals.loc),
value: r => r ? (r.type === 'sell' ? r.fromAccountId : r.toAccountId) ?? '' : '' } },
{ key: 'assetId', label: t('trades.colAsset'), sortable: true, sortValue: r => r.asset_symbol,
render: r => assetLabelHtml(r), group: { value: r => r.asset_symbol, label: v => v },
edit: { type: 'select', sticky: true, reactive: true, options: vals => assetOptsOfKind(vals.asset_kind) } },
{ key: 'asset_kind', label: t('trades.colKind'), sortable: true,
render: r => t('enums.asset_kind.' + r.asset_kind),
group: { value: r => r.asset_kind, label: v => t('enums.asset_kind.' + v) },
edit: { value: r => r ? r.asset_kind : '', visible: () => false } },
{ key: 'type', label: t('trades.colType'), sortable: true,
render: r => r.type === 'buy'
? `<span class="badge badge-accent badge-icon" title="${t('enums.event_type.buy')}" aria-label="${t('enums.event_type.buy')}">${ARROW_DOWN}</span>`
: `<span class="badge badge-neutral badge-icon" title="${t('enums.event_type.sell')}" aria-label="${t('enums.event_type.sell')}">${ARROW_UP}</span>`,
group: { value: r => r.type, label: v => t('enums.event_type.' + v) },
edit: { type: 'select', reactive: true, options: typeOpts } },
{ key: 'productId', label: t('trades.colProduct'), sortable: true, sortValue: r => r.product_name || '',
render: productCell,
edit: { type: 'select', sticky: true, reactive: true, visible: vals => vals.asset_kind === 'metal',
options: vals => productOpts(vals.assetId),
value: r => r ? (r.productId ?? '') : '' } },
{ key: 'amount', label: t('trades.colAmount'), align: 'num', sortable: true, sortValue: r => r.amount,
render: r => val(fmtUnit(r.amount, meta(r))),
edit: { type: 'number', reactive: true, attrs: { min: 0, step: 'any' },
value: r => r ? (r.productCount != null ? r.productCount : fromSubunit(r.amount, meta(r))) : '',
convert: convertAsset } },
{ key: 'priceOrigin', label: t('trades.colOrigin'), sortable: true,
sortValue: r => r.priceOrigin || '',
render: herkomstCel,
group: { value: r => r.priceOrigin || '', label: v => (v ? t('enums.event_price_origin.' + v) : t('trades.originUnknown')) },
edit: { type: 'select', reactive: true, options: herkomstOpts,
value: r => r ? (r.priceOrigin ?? '') : '' } },
{ key: 'fiatTotal', label: t('trades.colTotal'), align: 'num', sortable: true,
sortValue: r => totalFromPrice(r.amount, meta(r).decimals, r.fiatPrice, r.fiatFee, r.type) ?? -1,
render: r => {
const tot = totalFromPrice(r.amount, meta(r).decimals, r.fiatPrice, r.fiatFee, r.type);
return tot == null ? '-' : val(fmtBase(tot));
},
edit: { type: 'number', reactive: true, attrs: { min: 0, step: 'any' },
visible: vals => vals.priceOrigin !== 'none',
value: r => {
const tot = r ? totalFromPrice(r.amount, meta(r).decimals, r.fiatPrice, r.fiatFee, r.type) : null;
return tot == null ? '' : Math.round(tot * 100) / 100;
} } },
{ key: 'fiatPrice', label: t('trades.colPrice'), align: 'num', sortable: true, sortValue: r => r.fiatPrice,
render: r => `${val(fmtBasePrice(priceFromBase(r.fiatPrice, meta(r))))} <span class="t-muted-cell">/${priceUnitLabel(meta(r))}</span>`
+ bookingOriginHtml(r, { ...meta(r), symbol: r.asset_symbol }, dagHerkomst.get(r.id) || null),
edit: { type: 'number', reactive: true, attrs: { min: 0, step: 'any' },
visible: vals => vals.priceOrigin !== 'none',
value: r => r ? priceFromBase(r.fiatPrice, meta(r)) : '' } },
{ key: 'fiatFee', label: t('trades.colFee'), align: 'num', sortable: true, sortValue: r => r.fiatFee ?? -1,
render: r => r.fiatFee != null ? val(fmtBase(r.fiatFee)) : '-',
edit: { type: 'number', reactive: true, attrs: { min: 0, step: 'any' },
visible: vals => vals.priceOrigin !== 'none',
value: r => r && r.fiatFee != null ? r.fiatFee : '' } },
{ key: 'note', label: t('trades.colNote'), text: true,
render: r => `<span class="t-muted-cell">${esc(r.note || '')}</span>`,
edit: { type: 'textarea', expand: true } },
{ key: 'source', label: t('trades.colSource'), sortable: true,
render: r => `<span class="t-muted-cell">${r.source ? t('enums.event_source.' + r.source) : '-'}</span>`,
group: { value: r => r.source || 'manual', label: v => t('enums.event_source.' + v) } },
].filter(c => onlyAccount == null || c.key !== 'loc');
function hoeveelheidBasis(v, asset) {
if (!asset) return null;
const wantProduct = isMetal(asset) && v.productId !== '' && v.productId != null;
if (wantProduct) {
const product = alleProducten.find(p => p.id === Number(v.productId) && p.assetId === asset.id);
const cnt = parseInt(v.amount, 10);
if (!product || !Number.isInteger(cnt) || cnt <= 0) return null;
return product.fineGrams * cnt;
}
const qty = parseFloat(v.amount);
if (!Number.isFinite(qty) || qty <= 0) return null;
return toSubunit(qty, asset);
}
function afgeleid(v, sleutel, aangeraakt = new Set()) {
if (v.priceOrigin === 'none') return { fiatPrice: '0', fiatTotal: '0', fiatFee: '' };
const asset = assetById(v.assetId);
if (!asset) return null;
const dec = asset.decimals;
const rond = n => (n == null ? '' : String(Math.round(n * 100) / 100));
const gevuld = x => x !== '' && x != null && Number.isFinite(Number(x));
const opProduct = isMetal(asset) && v.productId !== '' && v.productId != null;
if (!opProduct && !aangeraakt.has('amount') && gevuld(v.fiatTotal) && gevuld(v.fiatPrice)) {
const basisUitGeld = amountFromTotal(
v.fiatTotal, priceToBase(parseFloat(v.fiatPrice), asset), dec, v.fiatFee, v.type);
if (basisUitGeld !== null) {
const getoond = fromSubunit(Math.round(basisUitGeld), asset);
return { amount: getoond === null ? '' : String(getoond) };
}
}
const basis = hoeveelheidBasis(v, asset);
if (basis === null) return null;
const naarPrijs = sleutel === 'fiatTotal'
|| (gevuld(v.fiatTotal) && (sleutel === 'fiatFee' || !gevuld(v.fiatPrice)));
if (naarPrijs) {
const prijs = priceFromTotal(v.fiatTotal, basis, dec, v.fiatFee, v.type);
return prijs === null ? null : { fiatPrice: rond(priceFromBase(prijs, asset)) };
}
const totaal = totalFromPrice(basis, dec, priceToBase(parseFloat(v.fiatPrice), asset), v.fiatFee, v.type);
return totaal === null ? null : { fiatTotal: rond(totaal) };
}
function herkomst(v, existing, priceBasis) {
const gekozen = v.priceOrigin ?? '';
const opgeslagen = existing ? (existing.priceOrigin ?? '') : '';
if (gekozen !== opgeslagen) return gekozen === '' ? null : gekozen;
if (existing && existing.fiatPrice != null) {
const zelfde = Math.abs(priceBasis - existing.fiatPrice) <= 1e-9 * Math.max(1, Math.abs(existing.fiatPrice));
if (zelfde) return existing.priceOrigin ?? null;
}
return gekozen === '' ? 'paid' : gekozen;
}
async function toPayload(v, existing) {
const asset = alleAssets.find(a => a.id === Number(v.assetId));
const price = parseFloat(v.fiatPrice);
if (!v.occurredAt || !asset || !Number.isFinite(price)) throw new Error(t('trades.fillDateAssetPrice'));
let productId = null, productCount = null, amount;
const wantProduct = isMetal(asset) && v.productId !== '' && v.productId != null;
if (wantProduct) {
const product = (await listMetalProducts()).find(p => p.id === Number(v.productId) && p.assetId === asset.id);
if (!product) throw new Error(t('trades.pickValidProduct'));
const cnt = parseInt(v.amount, 10);
if (!Number.isInteger(cnt) || cnt <= 0) throw new Error(t('trades.fillCount'));
productId = product.id; productCount = cnt;
amount = product.fineGrams * cnt;
} else {
const qty = parseFloat(v.amount);
if (!Number.isFinite(qty) || qty <= 0) throw new Error(t('trades.fillQuantity'));
amount = toSubunit(qty, asset);
}
const fiatPrice = priceToBase(price, asset);
const locVal = v.loc ?? onlyAccount;
const loc = locVal !== '' && locVal != null ? Number(locVal) : null;
return {
priceOrigin: herkomst(v, existing, fiatPrice),
assetId: asset.id, type: v.type,
occurredAt: toUtcIso(`${v.occurredAt}T${v._time || '00:00'}:00`),
amount, fiatPrice,
fiatFee: v.fiatFee === '' || v.fiatFee == null ? null : parseFloat(v.fiatFee),
unitFee: existing ? existing.unitFee ?? null : null,
productId, productCount,
fromAccountId: v.type === 'sell' ? loc : null,
toAccountId: v.type === 'buy' ? loc : null,
source: existing ? existing.source : 'manual',
note: (v.note || '').trim() || null,
};
}
const reload = async () => tableApi.refresh(await rows());
tableApi = renderTable(el.querySelector('#tbl'), {
columns, rows: await rows(),
onEditChange: (vals, sleutel, aangeraakt) => afgeleid(vals, sleutel, aangeraakt),
...(await tableViewCfg('trades', { key: 'occurredAt', dir: 'desc' }, onlyAccount != null ? 'assetId' : null)),
emptyText: t('trades.none'),
noneLabel: t('table.none'),
collapsibleGroups: true,
groupSummary: items => {
const first = items[0]; if (!first) return '';
if (!items.every(r => r.assetId === first.assetId)) return '';
const net = items.reduce((s, r) => s + (r.type === 'buy' ? r.amount : -r.amount), 0);
return `${net < 0 ? '\u2212' : ''}${val(fmtUnit(Math.abs(net), meta(first)))}`;
},
newLabel: t('table.new'),
convertLabel: t('convert.title'),
newDefaults: { type: 'buy', loc: onlyAccount ?? '', fiatFee: '' },
newOptions: [
{ key: 'crypto', label: t('trades.addCrypto'), defaults: { asset_kind: 'crypto', type: 'buy', assetId: firstOf('crypto') } },
{ key: 'metal', label: t('trades.addMetal'), defaults: { asset_kind: 'metal', type: 'buy', assetId: firstOf('metal') } },
{ key: 'fiat', label: t('trades.addFiat'), defaults: { asset_kind: 'fiat', type: 'buy', assetId: firstOf('fiat') } },
].filter(o => o.defaults.assetId !== ''),
saveLabel: t('trades.save'), cancelLabel: t('trades.cancel'),
onCreate: async v => { await resolvePrice(v); const id = await createEvent(await toPayload(v, null)); tableApi.refresh(await rows(), { locate: id }); },
onSave: async (id, v) => {
await resolvePrice(v);
const ex = (await listTrades()).find(x => x.id === id);
await updateEvent(id, await toPayload(v, ex)); reload();
},
onDelete: async id => {
if (!(await dialog.confirm({ message: t('trades.confirmDelete'), danger: true }))) return;
await deleteEvent(id); reload();
},
});
wireMatchTriggers(el.querySelector('#tbl'), (kind, id) => ({
kind, id, assetId: btc ? btc.id : null,
onChange: async () => { linkMap = await eventLinkMap(); tableApi.refresh(await rows()); },
}));
tableApi.focusRow(takeRowFocus('trades'));
}