// Generated, do not edit. sha256:7c7ced19083e83a4f75c27b5aa8f303c04b02f88225373c3587d1d7f90e78248 import * as db from '../db.js'; import { CONFIG, ENUMS, PREFERENCES, SOURCE_CAPABILITIES, SOURCE_ENDPOINTS } from '../../shared/config.js'; import { placePriceSourceFirst, clearPriceSourceCell } from './price-source.js'; import { setPref } from './preferences.js'; import { emitChange } from './_events.js'; import { assertEnum, assertName, assertTimestamp, nowIso } from './_shared.js'; import { fail, isAppError } from '../../shared/app-error.js'; import { getPriceAt, upsertPriceHistory } from './price-history.js'; import { createAccount, deleteAccount, listAccounts } from './accounts.js'; import { createEvent, eventsCountByAccount } from './events.js'; import { createValuation, listValuations } from './valuations.js'; import { positionAt } from './positions.js'; import { assertValidName as assertValidPortfolioName } from '../portfolios.mjs'; import { createPortfolio } from '../portfolios-admin.mjs'; import { isAiActive } from '../ai/gate.js'; const signaleerVoorstellen = () => emitChange('ai-proposals-changed'); const PROPOSAL_JOIN = ` SELECT AiProposal.*, Asset.symbol AS asset_symbol, Asset.name AS asset_name, Asset.unit AS asset_unit, Asset.subunit AS asset_subunit, Asset.decimals AS asset_decimals, Asset.displayUnit AS asset_display_unit, Asset.kind AS asset_kind, fromAccount.name AS from_name, toAccount.name AS to_name, targetAccount.name AS account_name, targetAccount.kind AS account_kind, Provider.name AS provider_name FROM AiProposal LEFT JOIN Asset ON Asset.id = AiProposal.assetId LEFT JOIN Provider ON AiProposal.kind = 'source_choice' AND Provider.key = AiProposal.key LEFT JOIN Account AS fromAccount ON fromAccount.id = AiProposal.fromAccountId LEFT JOIN Account AS toAccount ON toAccount.id = AiProposal.toAccountId LEFT JOIN Account AS targetAccount ON targetAccount.id = AiProposal.accountId`; const RICHTING = Object.freeze({ buy: Object.freeze({ van: false, naar: true }), sell: Object.freeze({ van: true, naar: false }), transfer: Object.freeze({ van: true, naar: true }), interest: Object.freeze({ van: false, naar: true }), }); function bronVoorKeuze(assetId, capability, providerKey) { const asset = db.one(`SELECT symbol FROM Asset WHERE id = ?`, [assetId]); if (!asset) throw new Error(`Asset ${assetId} does not exist.`); const bron = db.one(`SELECT id, isSystem FROM Provider WHERE key = ?`, [String(providerKey ?? '')]); if (!bron || bron.isSystem) throw new Error(`Unknown price source '${providerKey}'.`); if (!db.one(`SELECT 1 AS x FROM ProviderCoverage WHERE providerId = ? AND symbol = ?`, [bron.id, asset.symbol])) { throw new Error(`Price source '${providerKey}' does not cover ${asset.symbol}.`); } const eindpunten = SOURCE_ENDPOINTS[capability]; if (!db.one(`SELECT 1 AS x FROM ProviderEndpoint WHERE providerId = ? AND capability IN (${eindpunten.map(() => '?').join(',')})`, [bron.id, ...eindpunten])) { throw new Error(`Price source '${providerKey}' delivers no ${capability}.`); } return bron.id; } const CONTROLE = Object.freeze({ price_point: (v) => { if (v.assetId == null) throw new Error('A price-point proposal needs an asset.'); if (v.fiatPrice == null) throw new Error('A price-point proposal needs a price in euro.'); assertTimestamp('observedAt', v.observedAt); }, account: (v) => { assertName('Accountnaam', v.name); assertEnum('accountKind', ENUMS.ACCOUNT_KIND, v.accountKind); if (v.isOwnCustody == null) throw new Error('An account proposal has to say whether it is own custody.'); }, account_delete: (v) => { if (v.accountId == null) throw new Error('A deletion proposal has to say WHICH account.'); if (!listAccounts().some(a => a.id === v.accountId)) { throw new Error(`Account ${v.accountId} no longer exists.`); } }, portfolio: (v) => { assertName('Portefeuillenaam', v.name); assertValidPortfolioName(v.name); assertEnum('currency', CONFIG.PORTFOLIO.BASE_CURRENCIES, v.currency); }, preference: (v) => { if (!CONFIG.AI.PROPOSABLE_PREF_SCOPES.includes(v.scope)) { throw new Error(`A proposal may not set the preference scope '${v.scope}'.`); } if (!PREFERENCES[v.scope]?.[v.key]) { throw new Error(`Unknown preference: ${v.scope}.${v.key}.`); } try { JSON.parse(v.value); } catch { throw new Error(`The value of ${v.scope}.${v.key} is not valid JSON.`); } }, source_choice: (v) => { if (v.assetId == null) throw new Error('A source-choice proposal needs an asset.'); assertEnum('scope', SOURCE_CAPABILITIES, v.scope); assertEnum('value', ENUMS.SOURCE_CHOICE_ACTION, v.value); bronVoorKeuze(v.assetId, v.scope, v.key); }, valuation: (v) => { if (v.assetId == null) throw new Error('A balance proposal needs an asset.'); if (v.accountId == null) throw new Error('A balance proposal needs an account.'); if (!Number.isInteger(v.balance) || v.balance < 0) { throw new Error(`A balance proposal needs a balance >= 0 in the smallest unit (got ${v.balance}).`); } assertTimestamp('observedAt', v.observedAt); }, event: (v) => { if (v.assetId == null) throw new Error('An event proposal needs an asset.'); assertEnum('eventType', ENUMS.LEDGER_EVENT_TYPE, v.eventType); if (!Number.isInteger(v.amount) || v.amount <= 0) { throw new Error(`An event proposal needs an amount > 0 in the smallest unit (got ${v.amount}).`); } assertTimestamp('observedAt', v.observedAt); const richting = RICHTING[v.eventType]; if (richting.van && v.fromAccountId == null) { throw new Error(`An event of kind '${v.eventType}' needs an origin account.`); } if (richting.naar && v.toAccountId == null) { throw new Error(`An event of kind '${v.eventType}' needs a destination account.`); } if (!richting.van && v.fromAccountId != null) { throw new Error(`An event of kind '${v.eventType}' has no origin account.`); } if (!richting.naar && v.toAccountId != null) { throw new Error(`An event of kind '${v.eventType}' has no destination account.`); } if ((v.eventType === 'buy' || v.eventType === 'sell') && v.fiatPrice == null) { throw new Error(`An event of kind '${v.eventType}' needs a price in euro.`); } }, }); export async function createAiProposal({ kind, assetId = null, accountId = null, fromAccountId = null, toAccountId = null, name = null, accountKind = null, isOwnCustody = null, eventType = null, amount = null, fiatPrice = null, fiatFee = null, unitFee = null, balance = null, observedAt = null, scope = null, key = null, value = null, currency = null, note = null, }) { assertEnum('kind', ENUMS.AI_PROPOSAL_KIND, kind); if (balance != null && kind !== 'valuation') { throw new Error(`A balance belongs to a valuation; a proposal of kind '${kind}' has none.`); } if ((fiatFee != null || unitFee != null) && kind !== 'event') { throw new Error(`Fees belong to an event; a proposal of kind '${kind}' has none.`); } if (unitFee != null && eventType !== 'sell' && eventType !== 'transfer') { throw new Error(`A fee in units belongs to a sell or a transfer; an event of kind '${eventType}' has none.`); } if ((scope != null || key != null || value != null) && kind !== 'preference' && kind !== 'source_choice') { throw new Error(`A preference belongs to a preference proposal; kind '${kind}' has none.`); } if (currency != null && kind !== 'portfolio') { throw new Error(`A currency belongs to a portfolio proposal; kind '${kind}' has none.`); } const eigenBeheer = isOwnCustody == null ? null : (isOwnCustody ? 1 : 0); CONTROLE[kind]({ assetId, accountId, fromAccountId, toAccountId, name, accountKind, isOwnCustody: eigenBeheer, eventType, amount, fiatPrice, balance, observedAt, scope, key, value, currency }); db.run(`INSERT INTO AiProposal (assetId, accountId, fromAccountId, toAccountId, kind, name, accountKind, isOwnCustody, eventType, amount, fiatPrice, fiatFee, unitFee, balance, observedAt, scope, key, value, currency, note, status, createdAt) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'pending',?)`, [assetId, accountId, fromAccountId, toAccountId, kind, name, accountKind, eigenBeheer, eventType, amount, fiatPrice, fiatFee, unitFee, balance, observedAt, scope, key, value, currency, note, nowIso()]); const id = db.lastId(); signaleerVoorstellen(); await db.persist(); return id; } export function listPendingAiProposals() { if (!isAiActive()) return []; return db.all(`${PROPOSAL_JOIN} WHERE AiProposal.status = 'pending' ORDER BY AiProposal.id ASC`) .map(verrijkBalans); } function verrijkBalans(rij) { if (rij.kind !== 'valuation') return rij; return { ...rij, ledger_balance: positionAt(rij.accountId, rij.assetId, rij.observedAt), events_before: eventsCountByAccount(rij.accountId, rij.assetId, rij.observedAt), }; } export function listAiProposals(limit = CONFIG.AI.PROPOSAL_PAGE_SIZE) { if (!isAiActive()) return []; const n = Number.isInteger(limit) && limit > 0 ? limit : CONFIG.AI.PROPOSAL_PAGE_SIZE; return db.all(`${PROPOSAL_JOIN} ORDER BY AiProposal.id DESC LIMIT ?`, [n]); } export function aiProposalsStatus() { return { enabled: isAiActive(), pending: listPendingAiProposals() }; } export function getAiProposal(id) { return db.one(`${PROPOSAL_JOIN} WHERE AiProposal.id = ?`, [id]); } const HERKOMST_VAN = Object.freeze({ event: 'createdEventId', account: 'createdAccountId', valuation: 'createdValuationId', }); export function rowOrigin(soort, rijId) { const kolom = HERKOMST_VAN[soort]; if (!kolom) throw new Error(`Unknown kind for an origin query: '${soort}'.`); if (!Number.isInteger(Number(rijId))) return null; const r = db.one( `SELECT id, kind, note, createdAt, resolvedAt FROM AiProposal WHERE ${kolom} = ? AND status = 'accepted' ORDER BY id DESC LIMIT 1`, [Number(rijId)]); if (!r) return null; return { proposalId: r.id, kind: r.kind, note: r.note, createdAt: r.createdAt, resolvedAt: r.resolvedAt, }; } const UITVOEREN = Object.freeze({ price_point: async (voorstel) => { const seconden = Math.floor(Date.parse(voorstel.observedAt) / 1000); if (!Number.isFinite(seconden)) fail('aiProposals.unreadableMoment', { id: voorstel.id, moment: voorstel.observedAt }, `Unreadable moment in proposal ${voorstel.id}: ${voorstel.observedAt}.`); await upsertPriceHistory(voorstel.assetId, [{ time: seconden, price: voorstel.fiatPrice }], 'manual', 'day'); }, account: async (voorstel) => { const id = await createAccount({ name: voorstel.name, kind: voorstel.accountKind, isOwnCustody: !!voorstel.isOwnCustody, scanEnabled: false, taxCategory: null, }); return { kolom: 'createdAccountId', id }; }, account_delete: async (voorstel) => { await deleteAccount(voorstel.accountId); }, event: async (voorstel) => { const id = await createEvent({ assetId: voorstel.assetId, type: voorstel.eventType, occurredAt: voorstel.observedAt, amount: voorstel.amount, fiatPrice: voorstel.fiatPrice ?? null, fiatFee: voorstel.fiatFee ?? null, unitFee: voorstel.unitFee ?? null, fromAccountId: voorstel.fromAccountId, toAccountId: voorstel.toAccountId, source: 'manual', note: voorstel.note, }); return { kolom: 'createdEventId', id }; }, portfolio: async (voorstel) => { await createPortfolio(voorstel.name, { baseCurrency: voorstel.currency }); }, preference: async (voorstel) => { if (!CONFIG.AI.PROPOSABLE_PREF_SCOPES.includes(voorstel.scope)) { fail('aiProposals.prefScopeNotAllowed', { scope: voorstel.scope }, `This proposal wants to set the preference scope '${voorstel.scope}', which is not allowed.`); } await setPref(voorstel.scope, voorstel.key, JSON.parse(voorstel.value)); }, source_choice: async (voorstel) => { const providerId = bronVoorKeuze(voorstel.assetId, voorstel.scope, voorstel.key); if (voorstel.value === 'first') await placePriceSourceFirst(voorstel.assetId, voorstel.scope, providerId); else await clearPriceSourceCell(voorstel.assetId, voorstel.scope, providerId); }, valuation: async (voorstel) => { const dag = voorstel.observedAt.slice(0, 10); const dubbel = listValuations().some(v => v.assetId === voorstel.assetId && v.accountId === voorstel.accountId && v.valuedOn === dag); if (dubbel) { fail('aiProposals.duplicateValuation', { dag }, `A balance for this account and asset on ${dag} already exists.`); } const seconden = Math.floor(Date.parse(voorstel.observedAt) / 1000); if (!Number.isFinite(seconden)) fail('aiProposals.unreadableMoment', { id: voorstel.id, moment: voorstel.observedAt }, `Unreadable moment in proposal ${voorstel.id}: ${voorstel.observedAt}.`); const punt = getPriceAt(voorstel.assetId, seconden); if (!punt || punt.fiatPrice == null) { fail('aiProposals.noPriceForBalance', { dag }, `No price in the history for ${dag}, and a balance cannot do without one.`); } const id = await createValuation({ assetId: voorstel.assetId, accountId: voorstel.accountId, valuedOn: dag, balance: voorstel.balance, fiatRate: punt.fiatPrice, rateAt: voorstel.observedAt, source: 'manual', }); return { kolom: 'createdValuationId', id }; }, }); const HERKOMST_KOLOMMEN = Object.freeze(['createdEventId', 'createdAccountId', 'createdValuationId']); const detailVan = (e) => (isAppError(e) ? JSON.stringify({ key: e.key, params: e.params || undefined }) : ((e && e.message) || String(e))); export async function acceptAiProposal(id) { const voorstel = getAiProposal(id); if (!voorstel) fail('aiProposals.unknownProposal', { id }, `Proposal ${id} no longer exists.`); if (voorstel.status !== 'pending') { fail(`aiProposals.handled_${voorstel.status}`, { id }, `Proposal ${id} has already been handled (${voorstel.status}).`); } const uitvoeren = UITVOEREN[voorstel.kind]; if (!uitvoeren) throw new Error(`No execution known for proposal kind '${voorstel.kind}'.`); let herkomst = null; try { herkomst = await uitvoeren(voorstel); } catch (e) { db.run(`UPDATE AiProposal SET detail = ? WHERE id = ?`, [detailVan(e), id]); signaleerVoorstellen(); await db.persist(); throw e; } const kolom = herkomst && HERKOMST_KOLOMMEN.includes(herkomst.kolom) ? herkomst.kolom : null; if (kolom && Number.isInteger(Number(herkomst.id))) { db.run(`UPDATE AiProposal SET status = 'accepted', detail = NULL, resolvedAt = ?, ${kolom} = ? WHERE id = ?`, [nowIso(), Number(herkomst.id), id]); } else { db.run(`UPDATE AiProposal SET status = 'accepted', detail = NULL, resolvedAt = ? WHERE id = ?`, [nowIso(), id]); } signaleerVoorstellen(); await db.persist(); return id; } export async function acceptAiProposals(ids) { const uitkomsten = []; for (const id of ids) { try { await acceptAiProposal(id); uitkomsten.push({ id, ok: true, reden: null }); } catch (e) { uitkomsten.push({ id, ok: false, reden: e.message, key: e.key || null, params: e.params || null }); } } return uitkomsten; } export async function rejectAiProposal(id, reden = null) { const voorstel = getAiProposal(id); if (!voorstel) fail('aiProposals.unknownProposal', { id }, `Proposal ${id} no longer exists.`); if (voorstel.status !== 'pending') { fail(`aiProposals.handled_${voorstel.status}`, { id }, `Proposal ${id} has already been handled (${voorstel.status}).`); } db.run(`UPDATE AiProposal SET status = 'rejected', detail = ?, resolvedAt = ? WHERE id = ?`, [reden, nowIso(), id]); signaleerVoorstellen(); await db.persist(); return id; } export async function rejectAiProposals(ids, reden = null) { const uitkomsten = []; for (const id of ids) { try { await rejectAiProposal(id, reden); uitkomsten.push({ id, ok: true, reden: null }); } catch (e) { uitkomsten.push({ id, ok: false, reden: e.message, key: e.key || null, params: e.params || null }); } } return uitkomsten; }