// 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 = `
`; 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)} ×${r.productCount}`; if (r.asset_kind === 'metal') return `${t('trades.looseWeight')}`; return ''; } function herkomstCel(r) { const hints = { paid: 'trades.originPaidHint', market_day: 'trades.originMarketDay', market_moment: 'trades.originMarketMoment', none: 'trades.originNoneHint', }; if (!r.priceOrigin) { return `?`; } if (r.priceOrigin === 'paid') return ''; return `${esc(t('enums.event_price_origin.' + r.priceOrigin))}`; } 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' ? `${ARROW_DOWN}` : `${ARROW_UP}`, 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))))} /${priceUnitLabel(meta(r))}` + 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 => `${esc(r.note || '')}`, edit: { type: 'textarea', expand: true } }, { key: 'source', label: t('trades.colSource'), sortable: true, render: r => `${r.source ? t('enums.event_source.' + r.source) : '-'}`, 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')); }