Files
AtreidesAssetsFree/server/data/ai-proposals.js
T

377 lines
16 KiB
JavaScript
Raw Normal View History

2026-10-29 11:57:29 +01:00
// 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;
}