Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
// Generated, do not edit. sha256:25337d7bfffe1c04bcf94e07f2ea63c7184bfb60c25a933f7d8e83cbc634dc8c
const mandje = new Set();
export function emitChange(naam) { mandje.add(naam); }
export function takeChanges() {
if (!mandje.size) return [];
const uit = [...mandje];
mandje.clear();
return uit;
}
+27
View File
@@ -0,0 +1,27 @@
// Generated, do not edit. sha256:37f1d463b03d2b5d702238cb632740786fc53e2072e4691719327d700c59d56c
import { capacityReached } from '../license.mjs';
import * as db from '../db.js';
import { counts } from './counts.js';
import { RATE_ONLY_ASSET } from './_shared.js';
import { getBaseCurrency } from './portfolio.js';
function fiatAccountCount() {
return db.one(`SELECT COUNT(*) AS n FROM Account WHERE kind = 'bank'`).n;
}
function assetCount() {
return db.one(`SELECT COUNT(*) AS n FROM Asset WHERE symbol <> ? AND NOT ${RATE_ONLY_ASSET}`,
[getBaseCurrency()]).n;
}
export function currentCount(soort) {
const c = counts();
if (soort === 'assets') return assetCount();
if (soort === 'valuations') return c.valuations;
if (soort === 'fiatAccounts') return fiatAccountCount();
throw new Error(`No counter for capacity '${soort}' yet.`);
}
export function capacityReachedFor(soort) {
return capacityReached(soort, currentCount(soort));
}
+71
View File
@@ -0,0 +1,71 @@
// Generated, do not edit. sha256:945ee26961af9e68d689f5dc6bc4e4b542606ba5501cbaee8b17b7df9349fba6
import { CONFIG, ENUMS } from '../../shared/config.js';
export function assertEnum(name, list, value) {
if (!list.includes(value)) {
throw new Error(`Invalid ${name}: '${value}'. Allowed: ${list.join(', ')}.`);
}
}
export function assertTaxCategory(value) {
if (value == null || value === '') return null;
assertEnum('taxCategory', ENUMS.TAX_CATEGORY, value);
return value;
}
export function assertTaxScope(value) {
if (value == null || value === '') return null;
assertEnum('taxScope', ENUMS.TAX_SCOPE, value);
return value;
}
export function assertWealthScope(value) {
if (value == null || value === '') return null;
assertEnum('wealthScope', ENUMS.WEALTH_SCOPE, value);
return value;
}
export const RATE_ONLY_ASSET = `(Asset.kind = 'fiat' AND Asset.symbol <> (SELECT baseCurrency FROM Portfolio)
AND NOT EXISTS (SELECT 1 FROM Event WHERE Event.assetId = Asset.id)
AND NOT EXISTS (SELECT 1 FROM Valuation WHERE Valuation.assetId = Asset.id)
AND NOT EXISTS (SELECT 1 FROM InterestSchedule
WHERE InterestSchedule.principalAssetId = Asset.id OR InterestSchedule.payoutAssetId = Asset.id))`;
export function nowIso() { return new Date().toISOString(); }
const TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,6})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
export function assertTimestamp(field, value) {
const m = typeof value === 'string' ? value.match(TIMESTAMP) : null;
if (!m) {
throw new Error(`Invalid ${field}: '${value}'. A date is expected (2026-08-19), optionally with a time.`);
}
const [, jaar, maand, dag, uur = '0', minuut = '0', seconde = '0'] = m;
const d = new Date(Date.UTC(Number(jaar), Number(maand) - 1, Number(dag)));
const bestaat = d.getUTCFullYear() === Number(jaar)
&& d.getUTCMonth() === Number(maand) - 1
&& d.getUTCDate() === Number(dag);
if (!bestaat) throw new Error(`Invalid ${field}: '${value}' is not an existing date.`);
if (Number(uur) > 23 || Number(minuut) > 59 || Number(seconde) > 59) {
throw new Error(`Invalid ${field}: '${value}' is not an existing time.`);
}
return value;
}
export function assertName(field, value) {
const naam = typeof value === 'string' ? value.trim() : '';
if (!naam) throw new Error(`${field} may not be empty.`);
return naam;
}
export function todayIso() {
return new Date().toLocaleDateString('en-CA', { timeZone: CONFIG.PEILDATUM.TIMEZONE });
}
export function daysBetween(aIso, bIso) { return (Date.parse(bIso) - Date.parse(aIso)) / 86400000; }
export function isoToDate(iso) { return new Date(`${iso}T00:00:00Z`); }
export function dateToIso(d) { return d.toISOString().slice(0, 10); }
export function addDaysIso(iso, n) { const d = isoToDate(iso); d.setUTCDate(d.getUTCDate() + n); return dateToIso(d); }
export function weekdayOf(iso) { return isoToDate(iso).getUTCDay(); }
export function clampDom(year, month0, dom) { const last = new Date(Date.UTC(year, month0 + 1, 0)).getUTCDate(); return Math.min(dom, last); }
+86
View File
@@ -0,0 +1,86 @@
// Generated, do not edit. sha256:ebad02dba29ae50bcbaf9c21bb59936cf43f6be868e86ac00d0e6055f404b26f
import * as db from '../db.js';
import { ACCOUNT_SOURCE_KINDS } from '../../shared/config.js';
import { fail } from '../../shared/app-error.js';
import { eventsNetByAccount } from './events.js';
import { trackUpdate, trackUpdateWhere, trackDelete, trackDeleteWhere, withOrigin } from './change-log.js';
function mergeCompatibility(survivor, victim) {
const S = ACCOUNT_SOURCE_KINDS[survivor.kind] || [];
const V = ACCOUNT_SOURCE_KINDS[victim.kind] || [];
if ((S.length === 0) !== (V.length === 0)) {
return { ok: false, reason: 'sourceMismatch' };
}
if (V.some(k => !S.includes(k))) {
return { ok: false, reason: 'kindCannotCarry' };
}
return { ok: true, reason: null };
}
export function mergeCheck(survivorId, victimId) {
if (!survivorId || !victimId) return { ok: false, reason: 'pickTwo', counts: null };
if (Number(survivorId) === Number(victimId)) return { ok: false, reason: 'sameAccount', counts: null };
const survivor = db.one(`SELECT * FROM Account WHERE id=?`, [survivorId]);
const victim = db.one(`SELECT * FROM Account WHERE id=?`, [victimId]);
if (!survivor || !victim) return { ok: false, reason: 'notFound', counts: null };
const compat = mergeCompatibility(survivor, victim);
const counts = {
sources: db.one(`SELECT COUNT(*) AS n FROM WalletSource WHERE accountId=?`, [victimId]).n,
events: db.one(`SELECT COUNT(*) AS n FROM Event WHERE fromAccountId=?1 OR toAccountId=?1`, [victimId]).n,
schedules: db.one(`SELECT COUNT(*) AS n FROM InterestSchedule WHERE accountId=?`, [victimId]).n,
valuations: db.one(`SELECT COUNT(*) AS n FROM Valuation WHERE accountId =?`, [victimId]).n,
};
return { ok: compat.ok, reason: compat.reason, counts, survivorName: survivor.name, victimName: victim.name };
}
export async function mergeAccounts(survivorId, victimId) {
survivorId = Number(survivorId); victimId = Number(victimId);
const chk = mergeCheck(survivorId, victimId);
if (!chk.ok) fail(`accounts.mergeReason_${chk.reason}`, null, `Merge not possible (${chk.reason}).`);
return withOrigin('merge', victimId, () => voerSamenvoegingUit(survivorId, victimId, chk));
}
async function voerSamenvoegingUit(survivorId, victimId, chk) {
const preDiff = new Map();
for (const v of db.all(`SELECT id, accountId, assetId, rateAt, balance FROM Valuation WHERE accountId = ?1 OR accountId = ?2`, [survivorId, victimId])) {
preDiff.set(v.id, v.balance - eventsNetByAccount(v.accountId, v.assetId, v.rateAt));
}
trackUpdateWhere('Event', `fromAccountId = ?`, [victimId], () =>
db.run(`UPDATE Event SET fromAccountId=?1 WHERE fromAccountId=?2`, [survivorId, victimId]));
trackUpdateWhere('Event', `toAccountId = ?`, [victimId], () =>
db.run(`UPDATE Event SET toAccountId=?1 WHERE toAccountId=?2`, [survivorId, victimId]));
trackUpdateWhere('WalletSource', `accountId = ?`, [victimId], () =>
db.run(`UPDATE WalletSource SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]));
db.run(`UPDATE WalletAddress SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]);
db.run(`UPDATE WalletTransaction SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]);
db.run(`UPDATE ScanLog SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]);
db.run(`UPDATE ReconcileProposal SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]);
db.run(`UPDATE ImportBatch SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]);
trackUpdateWhere('InterestSchedule', `accountId = ?`, [victimId], () =>
db.run(`UPDATE InterestSchedule SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]));
trackDeleteWhere('Valuation', `accountId = ?2
AND EXISTS (SELECT 1 FROM Valuation v2 WHERE v2.accountId = ?1
AND v2.assetId=Valuation.assetId AND v2.valuedOn=Valuation.valuedOn)`,
[survivorId, victimId], () =>
db.run(`DELETE FROM Valuation WHERE accountId =?2
AND EXISTS (SELECT 1 FROM Valuation v2 WHERE v2.accountId =?1
AND v2.assetId=Valuation.assetId AND v2.valuedOn=Valuation.valuedOn)`,
[survivorId, victimId]));
trackUpdateWhere('Valuation', `accountId = ?`, [victimId], () =>
db.run(`UPDATE Valuation SET accountId =?1 WHERE accountId = ?2`, [survivorId, victimId]));
for (const [id, diff] of preDiff) {
const v = db.one(`SELECT id, assetId, rateAt FROM Valuation WHERE id=?`, [id]);
if (!v) continue;
const newNet = eventsNetByAccount(survivorId, v.assetId, v.rateAt);
trackUpdate('Valuation', id, () =>
db.run(`UPDATE Valuation SET balance=? WHERE id=?`, [newNet + diff, id]));
}
trackDelete('Account', victimId, () => db.run(`DELETE FROM Account WHERE id=?`, [victimId]));
await db.persist();
return chk.counts;
}
+117
View File
@@ -0,0 +1,117 @@
// Generated, do not edit. sha256:defdf4c970fac7940f2bd495e2911283709af51809bb6a2d4c4f9c43ed032b8f
import * as db from '../db.js';
import { toSearchName } from '../../shared/search-name.js';
import { nowUtcIso } from '../../shared/time.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum, assertName, assertTaxCategory, assertTaxScope, assertWealthScope } from './_shared.js';
import { capacityReachedFor } from './_limits.js';
import { fail } from '../../shared/app-error.js';
import { purgeWalletSourceSecret } from './wallet-sources.js';
import { accountPositions } from './positions.js';
import { trackUpdate, trackDelete } from './change-log.js';
export function listAccounts() {
return db.all(`SELECT * FROM Account ORDER BY name`);
}
export function listSelectableAccounts({ metGesloten = false, ookId = null } = {}) {
return metGesloten
? db.all(`SELECT * FROM Account ORDER BY isActive DESC, name`)
: db.all(`SELECT * FROM Account WHERE isActive = 1 OR id = ?1 ORDER BY isActive DESC, name`,
[ookId ?? null]);
}
export async function createAccount({ name, kind, isOwnCustody, scanEnabled, taxCategory, taxScope, wealthScope }) {
assertEnum('kind', ENUMS.ACCOUNT_KIND, kind);
if (kind === 'bank') {
const max = capacityReachedFor('fiatAccounts');
if (max !== null) {
fail('accounts.maxFreeBank', { max },
`The free version holds ${max} fiat account(s). A licence lifts that; nothing is removed in the meantime.`);
}
}
const naam = assertName('Accountnaam', name);
const categorie = assertTaxCategory(taxCategory);
const scope = assertTaxScope(taxScope);
const wealth = assertWealthScope(wealthScope);
db.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt, taxCategory, taxScope, wealthScope)
VALUES (?,?,?,?,?,1,NULL,?,?,?)`,
[naam, toSearchName(naam), kind, isOwnCustody ? 1 : 0, scanEnabled ? 1 : 0, categorie, scope, wealth]);
const id = db.lastId();
await db.persist();
return id;
}
export async function updateAccount(id, a) {
assertEnum('kind', ENUMS.ACCOUNT_KIND, a.kind);
const naam = assertName('Accountnaam', a.name);
const categorie = assertTaxCategory(a.taxCategory);
const scopeGiven = Object.hasOwn(a, 'taxScope');
const scope = scopeGiven ? assertTaxScope(a.taxScope) : null;
const wealthGiven = Object.hasOwn(a, 'wealthScope');
const wealth = wealthGiven ? assertWealthScope(a.wealthScope) : null;
trackUpdate('Account', id, () =>
db.run(`UPDATE Account SET name=?, searchName=?, kind=?, isOwnCustody=?, scanEnabled=?, taxCategory=?,
taxScope = CASE WHEN ? THEN ? ELSE taxScope END,
wealthScope = CASE WHEN ? THEN ? ELSE wealthScope END WHERE id=?`,
[naam, toSearchName(naam), a.kind, a.isOwnCustody ? 1 : 0, a.scanEnabled ? 1 : 0, categorie,
scopeGiven ? 1 : 0, scope, wealthGiven ? 1 : 0, wealth, id]));
await db.persist();
}
export async function deleteAccount(id) {
const used = db.one(`SELECT (SELECT COUNT(*) FROM Event WHERE fromAccountId=?1 OR toAccountId=?1)
+ (SELECT COUNT(*) FROM Valuation WHERE accountId =?1)
+ (SELECT COUNT(*) FROM ScanLog WHERE accountId=?1)
+ (SELECT COUNT(*) FROM ReconcileProposal WHERE accountId=?1) AS n`, [id]).n;
if (used > 0) fail('accounts.inUse', { n: used }, `Account is used by ${used} record(s); remove or relink those first.`);
for (const s of db.all(`SELECT id FROM WalletSource WHERE accountId=? AND kind='nwc'`, [id])) {
await purgeWalletSourceSecret(s.id);
}
trackDelete('Account', id, () => db.run(`DELETE FROM Account WHERE id=?`, [id]));
await db.persist();
}
export function deactivateCheck(accountId) {
const account = db.one(`SELECT * FROM Account WHERE id = ?`, [accountId]);
if (!account) return { found: false, findings: [] };
const n = (sql, params = [accountId]) => db.one(sql, params).n;
const findings = [];
if (account.scanEnabled) {
findings.push({ kind: 'scanEnabled', count: 1 });
}
const sources = n(`SELECT COUNT(*) AS n FROM WalletSource WHERE accountId = ?`);
if (sources) findings.push({ kind: 'walletSources', count: sources });
const vandaag = new Date().toISOString().slice(0, 10);
const schedules = n(
`SELECT COUNT(*) AS n FROM InterestSchedule
WHERE accountId = ? AND (endsOn IS NULL OR endsOn >= ?)`, [accountId, vandaag]);
if (schedules) findings.push({ kind: 'openSchedules', count: schedules });
const proposals = n(`SELECT COUNT(*) AS n FROM ReconcileProposal WHERE accountId = ? AND status = 'pending'`);
if (proposals) findings.push({ kind: 'pendingProposals', count: proposals });
const balances = accountPositions(accountId);
if (balances.length) findings.push({ kind: 'balance', count: balances.length, balances });
const events = n(`SELECT COUNT(*) AS n FROM Event WHERE fromAccountId = ?1 OR toAccountId = ?1`);
if (events) findings.push({ kind: 'events', count: events, informational: true });
return { found: true, isActive: !!account.isActive, name: account.name, findings };
}
export async function deactivateAccount(id, closedAt = nowUtcIso()) {
const account = db.one(`SELECT isActive FROM Account WHERE id = ?`, [id]);
if (!account) fail('accounts.unknownAccount', { id }, `Account #${id} does not exist.`);
trackUpdate('Account', id, () =>
db.run(`UPDATE Account SET isActive = 0, closedAt = ?, scanEnabled = 0 WHERE id = ?`, [closedAt, id]));
await db.persist();
}
export async function reactivateAccount(id) {
const account = db.one(`SELECT isActive FROM Account WHERE id = ?`, [id]);
if (!account) fail('accounts.unknownAccount', { id }, `Account #${id} does not exist.`);
trackUpdate('Account', id, () =>
db.run(`UPDATE Account SET isActive = 1, closedAt = NULL WHERE id = ?`, [id]));
await db.persist();
}
+89
View File
@@ -0,0 +1,89 @@
// Generated, do not edit. sha256:9505c124878c8b5af0571c1b4757d6fc332a4d6d342508d9c8ff717ce279c7db
import * as db from '../db.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
import { emitChange } from './_events.js';
import { limitText } from '../ai/limits.js';
function uitRij(r) {
if (!r) return r;
let velden = null;
if (r.fieldsShared != null) {
try { velden = JSON.parse(r.fieldsShared); } catch { velden = []; }
if (!Array.isArray(velden)) velden = [];
}
return { ...r, fieldsShared: velden };
}
export async function logAiToolCall(entry) {
assertEnum('outcome', ENUMS.AI_CALL_OUTCOME, entry.outcome);
if (!entry.sessionId) throw new Error('An audit entry without a session cannot be traced.');
if (!entry.tool) throw new Error('An audit entry without a tool name cannot be traced.');
const momenten = entry.messages == null ? [] : [...entry.messages];
for (const m of momenten) assertEnum('kind', ENUMS.AI_MESSAGE_KIND, m?.kind);
const velden = entry.fieldsShared == null ? null : JSON.stringify([...entry.fieldsShared]);
db.run(`INSERT INTO AiToolCall
(sessionId, client, tool, arguments, fieldsShared, charactersOut, outcome, detail, occurredAt, serverVersion)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
[entry.sessionId, entry.client ?? null, entry.tool,
entry.arguments != null ? JSON.stringify(entry.arguments) : null,
velden, entry.charactersOut ?? null, entry.outcome, entry.detail ?? null,
entry.occurredAt ?? nowIso(), entry.serverVersion ?? null]);
const id = db.lastId();
for (const m of momenten) {
const { text } = limitText(String(m.text ?? ''), CONFIG.AI.LIMITS.MAX_LOG_CHARS);
db.run(`INSERT INTO AiToolCallMessage (callId, kind, text, occurredAt) VALUES (?,?,?,?)`,
[id, m.kind, text, m.occurredAt ?? nowIso()]);
}
await db.persist();
emitChange('ai-activity-changed');
return id;
}
export function countAiToolCallsSince(sinds) {
const vanaf = Number.isInteger(sinds) && sinds >= 0 ? sinds : 0;
const r = db.one(`SELECT COUNT(*) AS n, MAX(id) AS laatste FROM AiToolCall WHERE id > ?`, [vanaf]);
const alles = db.one(`SELECT MAX(id) AS laatste FROM AiToolCall`);
return {
count: r ? r.n : 0,
newestId: (alles && alles.laatste != null) ? alles.laatste : 0,
};
}
export function aiToolCallMessages(callId) {
const n = Number(callId);
if (!Number.isInteger(n) || n <= 0) return [];
return db.all(`SELECT id, callId, kind, text, occurredAt FROM AiToolCallMessage
WHERE callId = ? ORDER BY id`, [n]);
}
export function aiSessions(uren = 24) {
const grens = new Date(Date.now() - Math.max(1, Number(uren) || 24) * 3600_000).toISOString();
return db.all(
`SELECT sessionId, client, serverVersion,
COUNT(*) AS calls, MIN(occurredAt) AS firstAt, MAX(occurredAt) AS lastAt
FROM AiToolCall
WHERE occurredAt >= ?
GROUP BY sessionId, client, serverVersion
ORDER BY lastAt DESC`, [grens])
.map(r => ({ ...r, zonderVersie: r.serverVersion == null }));
}
export function listAiToolCalls(limit = CONFIG.AI.AUDIT_PAGE_SIZE) {
const n = Math.min(Math.max(Number(limit) || CONFIG.AI.AUDIT_PAGE_SIZE, 1), 1000);
return db.all(`SELECT * FROM AiToolCall ORDER BY occurredAt DESC, id DESC LIMIT ?`, [n]).map(uitRij);
}
export function countAiToolCalls() {
const totaal = db.one(`SELECT COUNT(*) AS n FROM AiToolCall`);
const perUitkomst = db.all(`SELECT outcome, COUNT(*) AS n FROM AiToolCall GROUP BY outcome`);
const uitkomsten = {};
for (const k of ENUMS.AI_CALL_OUTCOME) uitkomsten[k] = 0;
for (const r of perUitkomst) uitkomsten[r.outcome] = r.n;
return { total: totaal ? totaal.n : 0, byOutcome: uitkomsten };
}
export function lastAiToolCallAt() {
const r = db.one(`SELECT occurredAt FROM AiToolCall ORDER BY occurredAt DESC, id DESC LIMIT 1`);
return r ? r.occurredAt : null;
}
+377
View File
@@ -0,0 +1,377 @@
// 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;
}
+56
View File
@@ -0,0 +1,56 @@
// Generated, do not edit. sha256:227dda965713b9b51597f6f128f59f6569f0a5729dc340b98c56d958390f053d
import * as db from '../db.js';
export async function recordApiCall({ providerId, capability, ok, latency_ms }) {
if (!Number.isInteger(providerId)) return;
const bucket = new Date().toISOString().slice(0, 16);
const lat = Number.isFinite(latency_ms) ? Math.max(0, Math.round(latency_ms)) : 0;
db.run(`INSERT INTO ApiCallMetric (providerId, capability, bucketMinute, okCount, failCount, sumLatencyMs, maxLatencyMs)
VALUES (?,?,?,?,?,?,?)
ON CONFLICT(providerId, capability, bucketMinute) DO UPDATE SET
okCount = okCount + excluded.okCount,
failCount = failCount + excluded.failCount,
sumLatencyMs = sumLatencyMs + excluded.sumLatencyMs,
maxLatencyMs = MAX(maxLatencyMs, excluded.maxLatencyMs)`,
[providerId, capability, bucket, ok ? 1 : 0, ok ? 0 : 1, lat, lat]);
await db.persist();
}
export function apiMetricsTotals(providerId, sinceBucket) {
const r = db.one(`SELECT COALESCE(SUM(okCount),0) AS ok, COALESCE(SUM(failCount),0) AS fail,
COALESCE(SUM(sumLatencyMs),0) AS sum_lat, COALESCE(MAX(maxLatencyMs),0) AS max_lat
FROM ApiCallMetric WHERE providerId = ? AND bucketMinute >= ?`, [providerId, sinceBucket]);
const calls = (r.ok || 0) + (r.fail || 0);
return { ok: r.ok || 0, fail: r.fail || 0, calls,
avg_latency_ms: calls ? Math.round(r.sum_lat / calls) : 0, maxLatencyMs: r.max_lat || 0 };
}
export function apiMetricsGlobal(sinceBucket) {
const r = db.one(`SELECT COALESCE(SUM(okCount),0) AS ok, COALESCE(SUM(failCount),0) AS fail,
COALESCE(SUM(sumLatencyMs),0) AS sum_lat, COALESCE(MAX(maxLatencyMs),0) AS max_lat
FROM ApiCallMetric WHERE bucketMinute >= ?`, [sinceBucket]);
const calls = (r.ok || 0) + (r.fail || 0);
return { ok: r.ok || 0, fail: r.fail || 0, calls,
avg_latency_ms: calls ? Math.round(r.sum_lat / calls) : 0, maxLatencyMs: r.max_lat || 0 };
}
export function apiCallsByProvider(sinceBucket) {
return db.all(`SELECT providerId, SUM(okCount) + SUM(failCount) AS calls
FROM ApiCallMetric WHERE bucketMinute >= ?
GROUP BY providerId`, [sinceBucket]);
}
export function apiMetricsByCapability(providerId, sinceBucket) {
return db.all(`SELECT capability, SUM(okCount) AS ok, SUM(failCount) AS fail,
SUM(sumLatencyMs) AS sum_lat, MAX(maxLatencyMs) AS max_lat
FROM ApiCallMetric WHERE providerId = ? AND bucketMinute >= ?
GROUP BY capability ORDER BY capability`, [providerId, sinceBucket]);
}
export function apiMetricsSeries(providerId, sinceBucket, groupLen) {
return db.all(`SELECT substr(bucketMinute,1,?) AS b,
COALESCE(SUM(okCount),0) AS ok, COALESCE(SUM(failCount),0) AS fail,
COALESCE(MAX(maxLatencyMs),0) AS max_lat
FROM ApiCallMetric WHERE providerId = ? AND bucketMinute >= ?
GROUP BY b ORDER BY b`, [groupLen, providerId, sinceBucket]);
}
+19
View File
@@ -0,0 +1,19 @@
// Generated, do not edit. sha256:2a06cfff8bd22ab273abad66a7bff34ee21d692bd6b4215d67a43a86980cd81f
import * as db from '../db.js';
export function getAssetNote(assetId) {
return db.one(`SELECT body, createdAt FROM AssetNote WHERE assetId = ?`, [assetId]) || null;
}
export async function setAssetNote(assetId, body) {
if (!db.one(`SELECT id FROM Asset WHERE id = ?`, [assetId])) throw new Error(`Asset ${assetId} does not exist.`);
const tekst = typeof body === 'string' ? body : '';
if (!tekst.trim()) {
db.run(`DELETE FROM AssetNote WHERE assetId = ?`, [assetId]);
} else {
db.run(`INSERT INTO AssetNote (assetId, body, createdAt) VALUES (?, ?, ?)
ON CONFLICT(assetId) DO UPDATE SET body = excluded.body`, [assetId, tekst, new Date().toISOString()]);
}
await db.persist();
return getAssetNote(assetId);
}
+84
View File
@@ -0,0 +1,84 @@
// Generated, do not edit. sha256:ae0d48181a52fd75f129b890a9ec73664ed0e72770dbab20484d6647df3ef532
import * as db from '../db.js';
import { toSearchName } from '../../shared/search-name.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
import { assertEnum, assertTaxCategory, assertTaxScope, RATE_ONLY_ASSET } from './_shared.js';
import { capacityReachedFor } from './_limits.js';
import { allowedAssets } from '../license.mjs';
import { fail } from '../../shared/app-error.js';
import { trackDelete, trackUpdate } from './change-log.js';
export function listAssets() { return db.all(`SELECT * FROM Asset ORDER BY symbol`); }
export function listBookableAssets() {
return db.all(`SELECT * FROM Asset WHERE NOT ${RATE_ONLY_ASSET} ORDER BY symbol`);
}
function assertDecimals(decimals) {
const leesbaar = typeof decimals === 'number'
|| (typeof decimals === 'string' && decimals.trim() !== '');
const n = leesbaar ? Number(decimals) : NaN;
if (!Number.isInteger(n) || n < 0 || n > CONFIG.MAX_ASSET_DECIMALS) {
fail('assets.decimalsRange', { max: CONFIG.MAX_ASSET_DECIMALS, waarde: decimals },
`Decimals has to be a whole number from 0 to ${CONFIG.MAX_ASSET_DECIMALS} (got: '${decimals}'); `
+ 'above that an amount can no longer be read back.');
}
}
function assertBalanceCapture(value) {
if (value == null || value === '') return null;
const n = Number(value);
if (n !== 0 && n !== 1) throw new Error(`Invalid balanceCapture: '${value}'. Allowed: empty, 0, 1.`);
return n;
}
export async function createAsset({ symbol, name, kind, unit, subunit, decimals, displayUnit, taxCategory, taxScope, balanceCapture }) {
const allowed = allowedAssets();
if (allowed && kind !== 'fiat' && !allowed.includes(symbol)) {
fail('assets.freeOnly', { list: allowed.join(', ') },
`The free version holds ${allowed.join(', ')} and currencies only. A licence lifts that.`);
}
const max = capacityReachedFor('assets');
if (max !== null) {
fail('assets.maxFree', { max },
`The free version holds ${max} asset(s). A licence lifts that; nothing is removed in the meantime.`);
}
assertEnum('kind', ENUMS.ASSET_KIND, kind);
assertEnum('displayUnit', ENUMS.ASSET_DISPLAY_UNIT, displayUnit);
assertDecimals(decimals);
const categorie = assertTaxCategory(taxCategory);
const capture = assertBalanceCapture(balanceCapture);
const scope = assertTaxScope(taxScope);
db.run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit, taxCategory, taxScope, balanceCapture)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
[symbol, name, toSearchName(name), kind, unit, subunit ?? null, decimals, displayUnit, categorie, scope, capture]);
const id = db.lastId();
db.seedPriceSource(id, 'confirmed');
await db.persist();
return id;
}
export async function updateAsset(id, a) {
assertEnum('kind', ENUMS.ASSET_KIND, a.kind);
assertEnum('displayUnit', ENUMS.ASSET_DISPLAY_UNIT, a.displayUnit);
assertDecimals(a.decimals);
const categorie = assertTaxCategory(a.taxCategory);
const given = Object.hasOwn(a, 'balanceCapture');
const capture = given ? assertBalanceCapture(a.balanceCapture) : null;
const scopeGiven = Object.hasOwn(a, 'taxScope');
const scope = scopeGiven ? assertTaxScope(a.taxScope) : null;
trackUpdate('Asset', id, () =>
db.run(`UPDATE Asset SET symbol=?, name=?, searchName=?, kind=?, unit=?, subunit=?, decimals=?, displayUnit=?, taxCategory=?,
taxScope = CASE WHEN ? THEN ? ELSE taxScope END,
balanceCapture = CASE WHEN ? THEN ? ELSE balanceCapture END WHERE id=?`,
[a.symbol, a.name, toSearchName(a.name), a.kind, a.unit, a.subunit ?? null, a.decimals, a.displayUnit, categorie,
scopeGiven ? 1 : 0, scope, given ? 1 : 0, capture, id]));
await db.persist();
}
export async function deleteAsset(id) {
const used = db.one(`SELECT (SELECT COUNT(*) FROM Event WHERE assetId=?1)
+ (SELECT COUNT(*) FROM Valuation WHERE assetId=?1)
+ (SELECT COUNT(*) FROM ReconcileProposal WHERE assetId=?1) AS n`, [id]).n;
if (used > 0) fail('assets.inUse', { n: used }, `The asset is used by ${used} record(s).`);
trackDelete('Asset', id, () => db.run(`DELETE FROM Asset WHERE id=?`, [id]));
await db.persist();
}
+38
View File
@@ -0,0 +1,38 @@
// Generated, do not edit. sha256:8bd40013f30173c4395fe482f251f8d822b2781e68ecf02d880e6748f3e5817a
import * as db from '../db.js';
import { CONFIG } from '../../shared/config.js';
import { nowIso } from './_shared.js';
import { takeChanges } from './_events.js';
export function flushChanges() {
const namen = takeChanges();
if (!namen.length) return [];
const at = nowIso();
for (const naam of namen) {
db.run(`INSERT INTO BusSignal (name, occurredAt) VALUES (?,?)`, [naam, at]);
}
pruneChanges();
return namen;
}
export function pruneChanges(uren = CONFIG.BUS.RETENTION_HOURS) {
const grens = new Date(Date.now() - Math.max(1, Number(uren) || CONFIG.BUS.RETENTION_HOURS) * 3600_000)
.toISOString();
const r = db.one(`SELECT COUNT(*) AS n FROM BusSignal WHERE occurredAt < ?`, [grens]);
const aantal = r ? r.n : 0;
if (aantal) db.run(`DELETE FROM BusSignal WHERE occurredAt < ?`, [grens]);
return aantal;
}
export function changesSince(cursor) {
const vanaf = Number.isInteger(cursor) && cursor >= 0 ? cursor : null;
const grens = db.one(`SELECT MIN(id) AS eerste, MAX(id) AS laatste FROM BusSignal`);
const laatste = grens && grens.laatste != null ? grens.laatste : (vanaf ?? 0);
if (vanaf === null) return { cursor: laatste, names: [], reset: false };
const eerste = grens ? grens.eerste : null;
if (eerste != null && vanaf < eerste - 1) return { cursor: laatste, names: [], reset: true };
const rijen = db.all(`SELECT DISTINCT name FROM BusSignal WHERE id > ? ORDER BY name`, [vanaf]);
return { cursor: laatste, names: rijen.map(r => r.name), reset: false };
}
+99
View File
@@ -0,0 +1,99 @@
// Generated, do not edit. sha256:0ea473364503a4f8549c8bea4bc98c04b7236550cd8e91a8cd91945e7728d55a
import * as db from '../db.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
import { nowUtcIso } from '../../shared/time.js';
const NIET_MELDEN = new Set(['updatedAt', 'lastStatus', 'lastCheckedAt', 'balanceSats', 'lastScannedAt']);
let herkomst = { origin: 'manual', originId: null };
export function withOrigin(origin, originId, fn) {
if (!ENUMS.CHANGE_ORIGIN.includes(origin)) throw new Error(`Unknown origin: ${origin}.`);
const vorige = herkomst;
herkomst = { origin, originId: originId ?? null };
try { return fn(); } finally { herkomst = vorige; }
}
export function currentOrigin() { return { ...herkomst }; }
const meedoet = (tabel) => CONFIG.DB_HISTORY.TRACKED.includes(tabel);
const leesRij = (tabel, id) => db.one(`SELECT * FROM ${tabel} WHERE id = ?`, [id]) || null;
function verschil(voor, na) {
if (!voor || !na) return null;
const uit = {};
for (const veld of Object.keys(voor)) {
if (NIET_MELDEN.has(veld)) continue;
if (voor[veld] !== na[veld]) uit[veld] = [voor[veld], na[veld]];
}
return Object.keys(uit).length ? uit : null;
}
function schrijf(tabel, rowId, operation, changes) {
db.run(`INSERT INTO ChangeLog (tableName, rowId, operation, origin, originId, changes, occurredAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[tabel, rowId, operation, herkomst.origin, herkomst.originId, JSON.stringify(changes), nowUtcIso()]);
}
export function trackUpdate(tabel, id, doe) {
if (!meedoet(tabel)) return doe();
const voor = leesRij(tabel, id);
const uit = doe();
const na = leesRij(tabel, id);
const changes = verschil(voor, na);
if (changes) {
db.run(`UPDATE ${tabel} SET updatedAt = ? WHERE id = ?`, [nowUtcIso(), id]);
schrijf(tabel, id, 'update', changes);
}
return uit;
}
export function trackUpdateField(tabel, id, veld, doe) {
if (!meedoet(tabel)) return doe();
const voor = leesRij(tabel, id);
const uit = doe();
const na = leesRij(tabel, id);
if (voor && na && voor[veld] !== na[veld]) schrijf(tabel, id, 'update', { [veld]: [voor[veld], na[veld]] });
return uit;
}
export function trackDelete(tabel, id, doe) {
if (!meedoet(tabel)) return doe();
const rij = leesRij(tabel, id);
const uit = doe();
if (rij) schrijf(tabel, id, 'delete', rij);
return uit;
}
export function trackUpdateMany(tabel, ids, doe) {
if (!meedoet(tabel) || !ids.length) return doe();
const voor = new Map(ids.map(id => [id, leesRij(tabel, id)]));
const uit = doe();
for (const id of ids) {
const changes = verschil(voor.get(id), leesRij(tabel, id));
if (!changes) continue;
db.run(`UPDATE ${tabel} SET updatedAt = ? WHERE id = ?`, [nowUtcIso(), id]);
schrijf(tabel, id, 'update', changes);
}
return uit;
}
export function trackUpdateWhere(tabel, waar, params, doe) {
if (!meedoet(tabel)) return doe();
const ids = db.all(`SELECT id FROM ${tabel} WHERE ${waar}`, params).map(r => r.id);
return trackUpdateMany(tabel, ids, doe);
}
export function trackDeleteWhere(tabel, waar, params, doe) {
if (!meedoet(tabel)) return doe();
const rijen = db.all(`SELECT * FROM ${tabel} WHERE ${waar}`, params);
const uit = doe();
for (const rij of rijen) schrijf(tabel, rij.id, 'delete', rij);
return uit;
}
export function rowHistory(tabel, rowId) {
return db.all(`SELECT id, operation, origin, originId, changes, occurredAt
FROM ChangeLog WHERE tableName = ? AND rowId = ? ORDER BY id DESC`, [tabel, rowId])
.map(r => ({ ...r, changes: JSON.parse(r.changes) }));
}
+71
View File
@@ -0,0 +1,71 @@
// Generated, do not edit. sha256:d8bebac302697ff7428ee4125057b7ffc589ad6f6f375379f00af756b5b865fc
import * as db from '../db.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
import { emitChange } from './_events.js';
function uitRij(r) {
if (!r) return r;
let lading = null;
if (r.payload != null) {
try { lading = JSON.parse(r.payload); } catch { lading = null; }
}
return { ...r, payload: lading };
}
export async function createCommand({ kind, payload = null, expiresAt = null }) {
assertEnum('kind', ENUMS.COMMAND_KIND, kind);
db.run(`INSERT INTO Command (kind, payload, state, expiresAt, createdAt) VALUES (?,?,?,?,?)`,
[kind, payload == null ? null : JSON.stringify(payload), 'open', expiresAt, nowIso()]);
const id = db.lastId();
pruneCommands();
await db.persist();
emitChange('commands-changed');
return id;
}
export async function claimNextCommand(tabblad, soorten = null) {
if (!tabblad) throw new Error('A claim without a tab cannot be assigned.');
const lijst = soorten == null ? null : [...soorten];
if (lijst) {
if (!lijst.length) throw new Error('A claim on zero kinds can never yield anything.');
for (const soort of lijst) assertEnum('kind', ENUMS.COMMAND_KIND, soort);
}
const nu = nowIso();
db.run(`UPDATE Command SET state = 'expired', detail = ?, resolvedAt = ?
WHERE state = 'open' AND expiresAt IS NOT NULL AND expiresAt < ?`,
['Niemand heeft deze opdracht op tijd opgepakt.', nu, nu]);
const filter = lijst ? `AND kind IN (${lijst.map(() => '?').join(',')})` : '';
db.run(`UPDATE Command SET state = 'claimed', claimedBy = ?, claimedAt = ?
WHERE id = (SELECT id FROM Command WHERE state = 'open' ${filter} ORDER BY id LIMIT 1)`,
[String(tabblad), nu, ...(lijst || [])]);
const rij = db.one(`SELECT * FROM Command WHERE state = 'claimed' AND claimedBy = ? AND resolvedAt IS NULL
ORDER BY id DESC LIMIT 1`, [String(tabblad)]);
await db.persist();
return uitRij(rij) || null;
}
export async function resolveCommand(id, tabblad, uitkomst, detail = null) {
assertEnum('state', ENUMS.COMMAND_STATE, uitkomst);
if (uitkomst === 'open' || uitkomst === 'claimed') {
throw new Error(`'${uitkomst}' is not a conclusion; a command closes as done, rejected or expired.`);
}
db.run(`UPDATE Command SET state = ?, detail = ?, resolvedAt = ?
WHERE id = ? AND claimedBy = ? AND resolvedAt IS NULL`,
[uitkomst, detail, nowIso(), Number(id), String(tabblad)]);
await db.persist();
emitChange('commands-changed');
}
export function getCommand(id) {
return uitRij(db.one(`SELECT * FROM Command WHERE id = ?`, [Number(id)])) || null;
}
export function pruneCommands(uren = CONFIG.COMMANDS.RETENTION_HOURS) {
const grens = new Date(Date.now() - Math.max(1, Number(uren) || CONFIG.COMMANDS.RETENTION_HOURS) * 3600_000)
.toISOString();
const r = db.one(`SELECT COUNT(*) AS n FROM Command WHERE resolvedAt IS NOT NULL AND resolvedAt < ?`, [grens]);
const aantal = r ? r.n : 0;
if (aantal) db.run(`DELETE FROM Command WHERE resolvedAt IS NOT NULL AND resolvedAt < ?`, [grens]);
return aantal;
}
+127
View File
@@ -0,0 +1,127 @@
// Generated, do not edit. sha256:ddd8b3b7b2f9aadd1a75eeaa934fd9a7d05a36cbd2f06e3a6cc5379098c70228
import * as db from '../db.js';
import { toSearchName } from '../../shared/search-name.js';
import { ENUMS, CONNECTION_PROVIDER } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
import { emitProvidersChanged } from './providers.js';
import { emitChange } from './_events.js';
import { trackUpdate, trackUpdateWhere, trackUpdateField, trackDelete } from './change-log.js';
import { fail } from '../../shared/app-error.js';
import { hasFeature } from '../license.mjs';
function emitConnectionsChanged() {
emitChange('connections-changed');
}
function syncConnectionProvider(conn) {
const providerKey = CONNECTION_PROVIDER[conn.kind];
if (!providerKey) return;
const p = db.one(`SELECT id, baseUrl FROM Provider WHERE key = ?`, [providerKey]);
if (!p || p.baseUrl === conn.url) return;
db.run(`UPDATE Provider SET baseUrl = ? WHERE id = ?`, [conn.url, p.id]);
emitProvidersChanged();
}
export function listConnections(kind = null) {
if (kind != null) assertEnum('kind', ENUMS.CONNECTION_KIND, kind);
return kind
? db.all(`SELECT * FROM Connection WHERE kind = ? ORDER BY isSystem DESC, name`, [kind])
: db.all(`SELECT * FROM Connection ORDER BY kind, isSystem DESC, name`);
}
export function activeConnection(kind) {
assertEnum('kind', ENUMS.CONNECTION_KIND, kind);
if (!CONNECTION_PROVIDER[kind] && kind !== 'btc_explorer') {
throw new Error(`Kind '${kind}' is linked per account; use listConnections('${kind}').`);
}
const row = db.one(`SELECT * FROM Connection WHERE kind = ? AND isActive = 1`, [kind]);
if (!row) fail('connections.noActive', { kind }, `No active '${kind}' connection.`);
return row;
}
export async function saveConnection({ id = null, kind, name, url }) {
assertEnum('kind', ENUMS.CONNECTION_KIND, kind);
if ((kind === 'lnd' || kind === 'nwc') && !hasFeature('lightning')) {
fail('connections.lightningPaid', null,
'Lightning connections belong to the paid version.');
}
if (kind === 'btc_explorer' && !hasFeature('ownMempool')) {
fail('connections.ownExplorerPaid', null,
'Your own explorer belongs to the paid version; the standard one keeps working.');
}
name = (name || '').trim();
url = (url || '').trim().replace(/\/+$/, '');
if (!name) fail('connections.nameRequired', null, 'A name is required.');
if (!/^(https?|wss?):\/\//.test(url)) fail('connections.urlScheme', null, 'A URL starts with http(s):// or ws(s)://.');
const now = nowIso();
if (id == null) {
if (db.one(`SELECT 1 AS x FROM Connection WHERE kind = ? AND name = ?`, [kind, name])) {
fail('connections.nameInUse', { kind, naam: name }, `A '${kind}' connection named '${name}' already exists.`);
}
db.run(`INSERT INTO Connection (name, searchName, url, kind, isActive, isSystem, lastStatus, lastCheckedAt, createdAt, updatedAt)
VALUES (?,?,?,?,0,0,NULL,NULL,?,?)`, [name, toSearchName(name), url, kind, now, now]);
id = db.lastId();
} else {
const cur = db.one(`SELECT * FROM Connection WHERE id = ?`, [id]);
if (!cur) fail('connections.unknownConnection', { id }, `Connection #${id} does not exist.`);
if (cur.isSystem) throw new Error('This profile is a system row (the guaranteed fallback) and cannot be edited.');
if (cur.kind !== kind) throw new Error('The kind of an existing connection cannot be changed.');
const urlChanged = cur.url !== url;
trackUpdate('Connection', id, () =>
db.run(`UPDATE Connection SET name = ?, searchName = ?, url = ?, lastStatus = ${urlChanged ? 'NULL' : 'lastStatus'},
lastCheckedAt = ${urlChanged ? 'NULL' : 'lastCheckedAt'}, updatedAt = ? WHERE id = ?`,
[name, toSearchName(name), url, now, id]));
if (urlChanged && cur.isActive) syncConnectionProvider({ kind: cur.kind, url });
}
await db.persist();
emitConnectionsChanged();
return id;
}
export async function setActiveConnection(id) {
const c = db.one(`SELECT * FROM Connection WHERE id = ?`, [id]);
if (!c) fail('connections.unknownConnection', { id }, `Connection #${id} does not exist.`);
const now = nowIso();
if (c.kind === 'btc_explorer') {
trackUpdateWhere('Connection', `kind = ? AND isActive = 1 AND id != ?`, [c.kind, id], () =>
db.run(`UPDATE Connection SET isActive = 0, updatedAt = ? WHERE kind = ? AND isActive = 1 AND id != ?`, [now, c.kind, id]));
}
trackUpdate('Connection', id, () =>
db.run(`UPDATE Connection SET isActive = 1, updatedAt = ? WHERE id = ?`, [now, id]));
if (c.kind === 'btc_explorer') syncConnectionProvider({ kind: c.kind, url: c.url });
await db.persist();
emitConnectionsChanged();
}
export async function deleteConnection(id) {
const c = db.one(`SELECT * FROM Connection WHERE id = ?`, [id]);
if (!c) fail('connections.unknownConnection', { id }, `Connection #${id} does not exist.`);
if (c.isSystem) throw new Error('This profile is a system row (the guaranteed fallback) and cannot be deleted.');
const linked = db.one(`SELECT COUNT(*) AS n FROM WalletSource WHERE connectionId = ?`, [id]);
if (linked && linked.n > 0) {
fail('connections.linkedToSources', { n: linked.n },
`This connection is still attached to ${linked.n} tracking source(s).`);
}
if (c.kind === 'btc_explorer' && c.isActive) {
const fallback = db.one(`SELECT * FROM Connection WHERE kind = 'btc_explorer' AND isSystem = 1`);
if (!fallback) throw new Error('No system fallback profile found; refusing to delete.');
const now = nowIso();
trackUpdate('Connection', c.id, () =>
db.run(`UPDATE Connection SET isActive = 0, updatedAt = ? WHERE id = ?`, [now, c.id]));
trackUpdate('Connection', fallback.id, () =>
db.run(`UPDATE Connection SET isActive = 1, updatedAt = ? WHERE id = ?`, [now, fallback.id]));
syncConnectionProvider({ kind: 'btc_explorer', url: fallback.url });
}
trackDelete('Connection', id, () => db.run(`DELETE FROM Connection WHERE id = ?`, [id]));
await db.persist();
emitConnectionsChanged();
}
export async function recordConnectionStatus(id, ok) {
const now = nowIso();
trackUpdateField('Connection', id, 'lastStatus', () =>
db.run(`UPDATE Connection SET lastStatus = ?, lastCheckedAt = ?, updatedAt = ? WHERE id = ?`,
[ok ? 'ok' : 'fail', now, now, id]));
await db.persist();
emitConnectionsChanged();
}
+18
View File
@@ -0,0 +1,18 @@
// Generated, do not edit. sha256:416f49e0df1b9e917350543775b5f77f278748750a148f230f7651dff4cede7d
import * as db from '../db.js';
import { CONFIG } from '../../shared/config.js';
export function counts() {
return {
assets: db.one(`SELECT COUNT(*) AS n FROM Asset`).n,
accounts: db.one(`SELECT COUNT(*) AS n FROM Account`).n,
events: db.one(`SELECT COUNT(*) AS n FROM Event`).n,
valuations: db.one(`SELECT COUNT(*) AS n FROM Valuation`).n,
};
}
export function tableCounts() {
return CONFIG.DB_TABLES.map(name => ({
name,
n: db.one(`SELECT COUNT(*) AS n FROM ${name}`).n,
}));
}
+124
View File
@@ -0,0 +1,124 @@
// Generated, do not edit. sha256:ebca807e87ebabedc0e0ca214ca3bec781ace05d2be4dc4ed8d664c767fe1d63
import * as db from '../db.js';
import { DASHBOARD } from '../../shared/config.js';
import { toSearchName } from '../../shared/search-name.js';
import { nowUtcIso } from '../../shared/time.js';
import { fail } from '../../shared/app-error.js';
export function listProfiles() {
return db.all(`SELECT * FROM DashboardProfile ORDER BY sortOrder, name`);
}
export function activeProfile() {
const rij = db.one(`SELECT * FROM DashboardProfile WHERE isActive = 1`);
if (!rij) throw new Error('No active dashboard profile: this data layer is supposed to prevent that.');
return rij;
}
function schrijfStandaardIndeling(profileId) {
for (const w of DASHBOARD.DEFAULT_LAYOUT) {
const def = DASHBOARD.WIDGETS[w.type];
if (!def) continue;
db.run(`INSERT INTO DashboardWidget (profileId, assetId, instanceKey, gridX, gridY, spanWidth, spanHeight, type, settings)
VALUES (?,NULL,?,?,0,?,NULL,?,NULL)`, [profileId, w.instId, w.x || 0, def.defaultSpan, w.type]);
}
}
export async function createProfile({ name, description = null }) {
const naam = String(name || '').trim();
if (!naam) fail('dashboard.nameRequired', null, 'A profile needs a name.');
const nu = nowUtcIso();
const volgend = db.one(`SELECT COALESCE(MAX(sortOrder), -1) + 1 AS n FROM DashboardProfile`).n;
db.run(`INSERT INTO DashboardProfile (name, searchName, description, sortOrder, isActive, isSystem, createdAt, updatedAt)
VALUES (?,?,?,?,0,0,?,?)`,
[naam, toSearchName(naam), description || null, volgend, nu, nu]);
const id = db.lastId();
schrijfStandaardIndeling(id);
await db.persist();
return id;
}
export async function renameProfile(id, { name, description = null }) {
const naam = String(name || '').trim();
if (!naam) fail('dashboard.nameRequired', null, 'A profile needs a name.');
const rij = db.one(`SELECT id FROM DashboardProfile WHERE id = ?`, [id]);
if (!rij) fail('dashboard.unknownProfile', { id }, `Dashboard profile #${id} does not exist.`);
db.run(`UPDATE DashboardProfile SET name = ?, searchName = ?, description = ?, updatedAt = ? WHERE id = ?`,
[naam, toSearchName(naam), description || null, nowUtcIso(), id]);
await db.persist();
}
export async function activateProfile(id) {
const rij = db.one(`SELECT id FROM DashboardProfile WHERE id = ?`, [id]);
if (!rij) fail('dashboard.unknownProfile', { id }, `Dashboard profile #${id} does not exist.`);
db.run(`UPDATE DashboardProfile SET isActive = 0, updatedAt = ? WHERE isActive = 1`, [nowUtcIso()]);
db.run(`UPDATE DashboardProfile SET isActive = 1, updatedAt = ? WHERE id = ?`, [nowUtcIso(), id]);
await db.persist();
}
export async function deleteProfile(id) {
const rij = db.one(`SELECT * FROM DashboardProfile WHERE id = ?`, [id]);
if (!rij) fail('dashboard.unknownProfile', { id }, `Dashboard profile #${id} does not exist.`);
if (rij.isSystem) {
await saveWidgets(id, null);
return;
}
if (rij.isActive) {
const opvolger = db.one(
`SELECT id FROM DashboardProfile WHERE id <> ? ORDER BY isSystem DESC, sortOrder, name LIMIT 1`, [id]);
if (!opvolger) fail('dashboard.lastNotDeletable', null, 'The last dashboard profile cannot be deleted.');
db.run(`UPDATE DashboardProfile SET isActive = 0, updatedAt = ? WHERE id = ?`, [nowUtcIso(), id]);
db.run(`UPDATE DashboardProfile SET isActive = 1, updatedAt = ? WHERE id = ?`, [nowUtcIso(), opvolger.id]);
}
db.run(`DELETE FROM DashboardProfile WHERE id = ?`, [id]);
await db.persist();
}
export function listWidgets(profileId) {
return db.all(`SELECT * FROM DashboardWidget WHERE profileId = ? ORDER BY gridY, gridX, instanceKey`, [profileId]);
}
function assertWidgetType(type) {
if (!DASHBOARD.WIDGETS[type]) {
throw new Error(`Unknown widget type '${type}'. Allowed: ${Object.keys(DASHBOARD.WIDGETS).join(', ')}.`);
}
}
export async function addWidget(profileId, { instanceKey, type, gridX = 0, gridY = 0, assetId = null, settings = null }) {
if (!db.one(`SELECT id FROM DashboardProfile WHERE id = ?`, [profileId])) {
fail('dashboard.unknownProfile', { id: profileId }, `Dashboard profile #${profileId} does not exist.`);
}
assertWidgetType(type);
const key = instanceKey || type;
if (db.one(`SELECT 1 AS x FROM DashboardWidget WHERE profileId = ? AND instanceKey = ?`, [profileId, key])) return false;
const json = settings && typeof settings === 'object' && Object.keys(settings).length ? JSON.stringify(settings) : null;
db.run(`INSERT INTO DashboardWidget (profileId, assetId, instanceKey, gridX, gridY, spanWidth, spanHeight, type, settings)
VALUES (?,?,?,?,?,?,NULL,?,?)`, [profileId, assetId, key, gridX, gridY, DASHBOARD.WIDGETS[type].defaultSpan, type, json]);
await db.persist();
return true;
}
export async function saveWidgets(profileId, widgets) {
const rij = db.one(`SELECT id FROM DashboardProfile WHERE id = ?`, [profileId]);
if (!rij) fail('dashboard.unknownProfile', { id: profileId }, `Dashboard profile #${profileId} does not exist.`);
if (widgets === null) {
db.run(`DELETE FROM DashboardWidget WHERE profileId = ?`, [profileId]);
schrijfStandaardIndeling(profileId);
await db.persist();
return;
}
const lijst = Array.isArray(widgets) ? widgets : [];
for (const w of lijst) assertWidgetType(w.type);
db.run(`DELETE FROM DashboardWidget WHERE profileId = ?`, [profileId]);
for (const w of lijst) {
const params = w.params && typeof w.params === 'object' ? { ...w.params } : {};
const assetId = params.assetId == null ? null : Number(params.assetId);
delete params.assetId;
const settings = Object.keys(params).length ? JSON.stringify(params) : null;
db.run(`INSERT INTO DashboardWidget (profileId, assetId, instanceKey, gridX, gridY, spanWidth, spanHeight, type, settings)
VALUES (?,?,?,?,?,?,?,?,?)`,
[profileId, assetId, w.instanceKey, w.gridX, w.gridY, w.spanWidth, w.spanHeight ?? null, w.type, settings]);
}
await db.persist();
}
+261
View File
@@ -0,0 +1,261 @@
// Generated, do not edit. sha256:68008a6906cce53854e3533a4a9207b146e9943ef96ac00c6c0dd8b1e47f76f3
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum, assertTimestamp, nowIso } from './_shared.js';
import { fail } from '../../shared/app-error.js';
import { trackDelete, trackUpdate } from './change-log.js';
import { emitChange } from './_events.js';
export function listEvents() {
return db.all(`SELECT * FROM Event WHERE type <> 'reservation' ORDER BY occurredAt DESC, id DESC`);
}
export const EVENT_JOIN = `
SELECT e.*, a.symbol AS asset_symbol, a.name AS asset_name, a.decimals AS asset_decimals,
a.unit AS asset_unit, a.subunit AS asset_subunit, a.displayUnit AS asset_display_unit,
a.kind AS asset_kind,
lf.name AS from_name, lt.name AS to_name,
mp.name AS product_name, mp.form AS product_form
FROM Event e
JOIN Asset a ON a.id = e.assetId
LEFT JOIN Account lf ON lf.id = e.fromAccountId
LEFT JOIN Account lt ON lt.id = e.toAccountId
LEFT JOIN MetalProduct mp ON mp.id = e.productId`;
export function listTrades() {
return db.all(`${EVENT_JOIN} WHERE e.type IN ('buy','sell') ORDER BY e.occurredAt DESC, e.id DESC`);
}
export function listTransfers() {
return db.all(`${EVENT_JOIN} WHERE e.type = 'transfer' ORDER BY e.occurredAt DESC, e.id DESC`);
}
export function listAssetTrades(assetId) {
return db.all(`SELECT e.id, e.type, e.amount, e.fiatPrice, e.fiatFee, e.occurredAt,
e.fromAccountId, e.toAccountId,
lf.name AS from_name, lt.name AS to_name,
(SELECT GROUP_CONCAT(DISTINCT l.name)
FROM WalletTransactionEventLink wl
JOIN WalletTransaction wt ON wt.id = wl.walletTransactionId
JOIN Label l ON l.id = wt.labelId
WHERE wl.eventId = e.id) AS label_names
FROM Event e
LEFT JOIN Account lf ON lf.id = e.fromAccountId
LEFT JOIN Account lt ON lt.id = e.toAccountId
WHERE e.assetId = ? AND e.type IN ('buy','sell')
ORDER BY e.occurredAt ASC, e.id ASC`, [assetId]);
}
function assertEventPayload(e) {
assertEnum('type', ENUMS.EVENT_TYPE, e.type);
assertEnum('source', ENUMS.EVENT_SOURCE, e.source);
if ((e.type === 'buy' || e.type === 'sell') && (e.fiatPrice === undefined || e.fiatPrice === null)) {
throw new Error(`fiatPrice is required for type '${e.type}'.`);
}
if (e.type === 'interest' && (Number(e.fiatFee) > 0 || Number(e.unitFee) > 0)) {
fail('trades.feeOnInterest', null, 'A fee on interest counts nowhere; it is refused.');
}
if (e.type === 'buy' && Number(e.unitFee) > 0) {
fail('trades.unitFeeOnBuy', null, 'A fee in units on a purchase counts nowhere; lower the amount instead.');
}
if (e.type === 'reservation') {
if ((e.fromAccountId == null) === (e.toAccountId == null)) throw new Error('A reservation sits on exactly one account.');
if (e.fiatPrice != null || e.fiatFee != null || e.unitFee != null || e.productId != null
|| e.priceOrigin != null || e.importReference != null || e.externalAddress != null) {
throw new Error('A reservation carries no price, fee, product, origin, import reference or address.');
}
}
assertTimestamp('occurredAt', e.occurredAt);
assertProductAmount(e);
}
function priceOriginOf(value) {
if (value == null || value === '') return null;
assertEnum('priceOrigin', ENUMS.EVENT_PRICE_ORIGIN, value);
return value;
}
function assertProductAmount(e) {
if (e.productId == null) return;
const p = db.one(`SELECT fineGrams FROM MetalProduct WHERE id = ?`, [e.productId]);
if (!p) throw new Error(`Metal product ${e.productId} does not exist.`);
const verwacht = e.productCount * p.fineGrams;
if (e.amount !== verwacht) {
throw new Error(`amount (${e.amount}) should be ${e.productCount} × ${p.fineGrams} = ${verwacht} ` +
`for this product; adjust the count or the amount.`);
}
}
export async function createEvent(e) {
assertEventPayload(e);
db.run(`INSERT INTO Event
(assetId, type, occurredAt, amount, fiatPrice, fiatFee, unitFee, productId, productCount, fromAccountId, toAccountId, source, note, importReference, externalAddress, priceOrigin, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[e.assetId, e.type, e.occurredAt, e.amount,
e.fiatPrice ?? null, e.fiatFee ?? null, e.unitFee ?? null, e.productId ?? null, e.productCount ?? null,
e.fromAccountId ?? null, e.toAccountId ?? null, e.source, e.note ?? null, e.importReference ?? null, e.externalAddress ?? null,
priceOriginOf(e.priceOrigin), nowIso()]);
const id = db.lastId();
await db.persist();
emitChange('ledger-changed');
return id;
}
export function findEventByRef(ref) {
if (!ref) return null;
return db.one(`SELECT * FROM Event WHERE importReference = ?`, [ref]) || null;
}
export function eventsWithoutImportRef() {
return db.all(`SELECT * FROM Event WHERE importReference IS NULL AND type <> 'reservation'`);
}
export async function updateEvent(id, e) {
assertEventPayload(e);
const origin = Object.hasOwn(e, 'priceOrigin')
? priceOriginOf(e.priceOrigin)
: (db.one(`SELECT priceOrigin AS o FROM Event WHERE id = ?`, [id]) || {}).o ?? null;
trackUpdate('Event', id, () =>
db.run(`UPDATE Event SET
assetId = ?, type = ?, occurredAt = ?, amount = ?, fiatPrice = ?, fiatFee = ?,
unitFee = ?, productId = ?, productCount = ?, fromAccountId = ?, toAccountId = ?, source = ?, note = ?, externalAddress = ?,
priceOrigin = ?
WHERE id = ?`,
[e.assetId, e.type, e.occurredAt, e.amount,
e.fiatPrice ?? null, e.fiatFee ?? null, e.unitFee ?? null, e.productId ?? null, e.productCount ?? null,
e.fromAccountId ?? null, e.toAccountId ?? null, e.source, e.note ?? null, e.externalAddress ?? null,
origin, id]));
await db.persist();
emitChange('ledger-changed');
}
export async function deleteEvent(id) {
trackDelete('Event', id, () => db.run(`DELETE FROM Event WHERE id = ?`, [id]));
await db.persist();
emitChange('ledger-changed');
}
export function eventsNetByAccount(accountId, assetId, untilIso) {
const r = db.one(`
SELECT COALESCE(SUM(
CASE
WHEN type = 'buy' AND toAccountId = ?1 THEN amount
WHEN type = 'sell' AND fromAccountId = ?1 THEN -(amount + COALESCE(unitFee,0))
WHEN type = 'transfer' AND toAccountId = ?1 THEN amount
WHEN type = 'transfer' AND fromAccountId = ?1 THEN -(amount + COALESCE(unitFee,0))
WHEN type = 'interest' AND toAccountId = ?1 THEN amount
ELSE 0
END
- (CASE WHEN type = 'transfer' AND fromAccountId = ?1 AND toAccountId = ?1 THEN amount + COALESCE(unitFee,0) ELSE 0 END)
), 0) AS net
FROM Event WHERE assetId = ?2${untilIso ? ' AND occurredAt <= ?3' : ''}`,
untilIso ? [accountId, assetId, untilIso] : [accountId, assetId]);
return r.net;
}
export function eventsCountByAccount(accountId, assetId, untilIso) {
const r = db.one(
`SELECT COUNT(*) AS n FROM Event
WHERE assetId = ?2 AND (fromAccountId = ?1 OR toAccountId = ?1) AND type <> 'reservation'
${untilIso ? 'AND occurredAt <= ?3' : ''}`,
untilIso ? [accountId, assetId, untilIso] : [accountId, assetId]);
return r.n;
}
export function eventAssetIdsByAccount(accountId) {
return db.all(
`SELECT DISTINCT assetId FROM Event WHERE (fromAccountId = ?1 OR toAccountId = ?1) AND type <> 'reservation' ORDER BY assetId`,
[accountId]).map(r => r.assetId);
}
export function eventAccountIdsByAsset(assetId) {
return db.all(
`SELECT fromAccountId AS accountId FROM Event WHERE assetId = ?1 AND fromAccountId IS NOT NULL AND type <> 'reservation'
UNION
SELECT toAccountId AS accountId FROM Event WHERE assetId = ?1 AND toAccountId IS NOT NULL AND type <> 'reservation'
ORDER BY accountId`,
[assetId]).map(r => r.accountId);
}
export function eventDaysByAccount(accountId, assetId, vanIso, totIso) {
return db.all(
`SELECT DISTINCT substr(occurredAt, 1, 10) AS dag FROM Event
WHERE assetId = ?2 AND (fromAccountId = ?1 OR toAccountId = ?1) AND type <> 'reservation'
AND substr(occurredAt, 1, 10) > ?3 AND substr(occurredAt, 1, 10) < ?4
ORDER BY dag`, [accountId, assetId, vanIso, totIso]).map(r => r.dag);
}
export function assetPositions(untilIso, { ownOnly = false } = {}) {
return db.all(`
SELECT a.id, a.symbol, a.name, a.kind, a.decimals, a.unit, a.subunit,
a.displayUnit,
COALESCE(SUM(CASE
WHEN e.type = 'buy' AND e.toIn THEN e.amount
WHEN e.type = 'sell' AND e.fromIn THEN -(e.amount + COALESCE(e.unitFee,0))
WHEN e.type = 'interest' AND e.toIn THEN e.amount
WHEN e.type = 'transfer' THEN
(CASE WHEN e.toAccountId IS NOT NULL AND e.toIn THEN e.amount ELSE 0 END)
- (CASE WHEN e.fromAccountId IS NOT NULL AND e.fromIn THEN e.amount + COALESCE(e.unitFee,0) ELSE 0 END)
ELSE 0 END), 0) AS net,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN 1 ELSE 0 END), 0) AS buy_events,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN e.amount ELSE 0 END), 0) AS buy_amount,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN e.amount * e.fiatPrice ELSE 0 END), 0) AS buy_spot_raw,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN COALESCE(e.fiatFee,0) ELSE 0 END), 0) AS buy_fee_total,
COALESCE(SUM(CASE WHEN e.priceOrigin IS 'none' THEN
(CASE
WHEN e.type = 'buy' AND e.toIn THEN e.amount
WHEN e.type = 'sell' AND e.fromIn THEN -e.amount
-- A ONE-SIDED TRANSFER without consideration is a gift too,
-- as listTradeEvents already treats it; without this its units took the average
-- purchase price as cost. Two sides is internal and stays out.
WHEN e.type = 'transfer' AND NOT (e.fromAccountId IS NOT NULL AND e.fromIn)
AND e.toAccountId IS NOT NULL AND e.toIn THEN e.amount
WHEN e.type = 'transfer' AND NOT (e.toAccountId IS NOT NULL AND e.toIn)
AND e.fromAccountId IS NOT NULL AND e.fromIn THEN -e.amount
ELSE 0 END)
ELSE 0 END), 0) AS gift_amount,
COALESCE(SUM(CASE WHEN e.type = 'sell' AND e.fromIn THEN 1 ELSE 0 END), 0) AS sell_events
FROM Asset a
LEFT JOIN (
SELECT Event.*,
(?2 = 0 OR toAccount.wealthScope IS NULL) AS toIn,
(?2 = 0 OR fromAccount.wealthScope IS NULL) AS fromIn
FROM Event
LEFT JOIN Account toAccount ON toAccount.id = Event.toAccountId
LEFT JOIN Account fromAccount ON fromAccount.id = Event.fromAccountId
WHERE ?1 IS NULL OR Event.occurredAt <= ?1
) e ON e.assetId = a.id
GROUP BY a.id
ORDER BY a.symbol`, [untilIso ?? null, ownOnly ? 1 : 0]);
}
export function listTradeEvents(fromIso, untilIso) {
return db.all(`SELECT e.type, e.assetId, e.amount, e.fiatPrice, e.fiatFee, e.priceOrigin, e.occurredAt,
e.fromAccountId, e.toAccountId,
a.decimals
FROM Event e JOIN Asset a ON a.id = e.assetId
WHERE (e.type IN ('buy','sell')
-- EVERY one-sided transfer: value entering or leaving the books. With 'none' a
-- gift, without it a deposit or withdrawal at the day price.
OR (e.type = 'transfer' AND (e.fromAccountId IS NULL) <> (e.toAccountId IS NULL))
-- A EURO FEE ON A TRANSFER is money in that is gone, as with a purchase (plan
-- Bewijzen B2): the caller counts the fee and not the units.
OR (e.type = 'transfer' AND e.fromAccountId IS NOT NULL AND e.fiatFee > 0))
AND (?1 IS NULL OR e.occurredAt > ?1) AND e.occurredAt <= ?2`,
[fromIso, untilIso]);
}
export function listFlowLegs(fromIso, untilIso) {
const venster = `(?1 IS NULL OR Event.occurredAt > ?1) AND Event.occurredAt <= ?2`;
const been = (type, kolom, richting) => `
SELECT Event.${kolom} AS accountId, Event.assetId, Asset.decimals,
Event.type, '${richting}' AS direction, Event.amount AS units,
Event.fiatPrice, Event.fiatFee, Event.priceOrigin, Event.occurredAt
FROM Event JOIN Asset ON Asset.id = Event.assetId
WHERE Event.type = '${type}' AND Event.${kolom} IS NOT NULL AND ${venster}`;
return db.all([
been('buy', 'toAccountId', 'in'),
been('sell', 'fromAccountId', 'out'),
been('transfer', 'toAccountId', 'in'),
been('transfer', 'fromAccountId', 'out'),
].join(' UNION ALL '), [fromIso, untilIso]);
}
export function firstTaxEventDate() {
const r = db.one(`SELECT MIN(occurredAt) AS first FROM Event WHERE type <> 'reservation'`);
return r && r.first ? r.first : null;
}
+383
View File
@@ -0,0 +1,383 @@
// Generated, do not edit. sha256:bbb7036ed8755194a6ae32362bf5faec1d944b2733c93e94b0d21804b434b99e
import { createHash } from 'node:crypto';
import * as db from '../db.js';
import { CONFIG } from '../../shared/config.js';
import { fail } from '../../shared/app-error.js';
import { listOpenValuationDiffs } from './valuations.js';
import { listPriceGaps, getPricePoint } from './price-history.js';
import { trackUpdateMany } from './change-log.js';
import { positionsByAsset } from './positions.js';
import { daysBetween } from './_shared.js';
import { healthChecks } from '../registers.mjs';
function rij(row, counts = {}) {
return { row, counts };
}
function dubbeleEvents() {
return db.all(`SELECT Event.assetId, Asset.symbol, Event.type, Event.occurredAt,
Event.productId, MetalProduct.name AS product_name, COUNT(*) AS n
FROM Event
JOIN Asset ON Asset.id = Event.assetId
LEFT JOIN MetalProduct ON MetalProduct.id = Event.productId
WHERE Event.type <> 'reservation'
GROUP BY Event.assetId, Event.type, Event.occurredAt, Event.amount, Event.productId
HAVING COUNT(*) > 1
ORDER BY n DESC, Event.occurredAt DESC`)
.map(r => rij({ assetId: r.assetId, symbol: r.symbol, type: r.type, occurredAt: r.occurredAt,
productId: r.productId, product_name: r.product_name }, { keer: r.n }));
}
function assetsZonderBalans() {
return db.all(`SELECT Asset.id AS assetId, Asset.symbol, COUNT(Event.id) AS events
FROM Asset
JOIN Event ON Event.assetId = Asset.id AND Event.type <> 'reservation'
WHERE NOT EXISTS (SELECT 1 FROM Valuation WHERE Valuation.assetId = Asset.id)
GROUP BY Asset.id, Asset.symbol
ORDER BY events DESC`)
.map(r => rij({ assetId: r.assetId, symbol: r.symbol }, { boekingen: r.events }));
}
function onverzoendeBalansen() {
const symbolen = new Map(db.all(`SELECT id, symbol FROM Asset`).map(a => [a.id, a.symbol]));
return listOpenValuationDiffs()
.map(v => rij({
assetId: v.assetId, symbol: symbolen.get(v.assetId) ?? null,
accountId: v.accountId, valuedOn: v.valuedOn,
}));
}
const LAATSTE_KOERS = `(SELECT MAX(observedAt) FROM PriceHistory WHERE PriceHistory.assetId = Asset.id)`;
function achterlopendeKoersen() {
return db.all(`SELECT Asset.id AS assetId, Asset.symbol,
MAX(Event.occurredAt) AS occurredAt,
${LAATSTE_KOERS} AS observedAt
FROM Asset
JOIN Event ON Event.assetId = Asset.id AND Event.type <> 'reservation'
GROUP BY Asset.id, Asset.symbol
HAVING ${LAATSTE_KOERS} IS NULL OR ${LAATSTE_KOERS} < MAX(Event.occurredAt)
ORDER BY Asset.symbol`)
.map(r => rij(r));
}
const EERSTE_KOERS = `(SELECT MIN(observedAt) FROM PriceHistory WHERE PriceHistory.assetId = Asset.id)`;
function koersenBeginnenTeLaat() {
const maandDag = `${String(CONFIG.PEILDATUM.MONTH).padStart(2, '0')}-${String(CONFIG.PEILDATUM.DAY).padStart(2, '0')}`;
const EERSTE_PEILDATUM = `(CAST(strftime('%Y', MIN(Event.occurredAt)) AS INTEGER) + 1) || '-${maandDag}'`;
return db.all(`SELECT Asset.id AS assetId, Asset.symbol,
MIN(Event.occurredAt) AS occurredAt,
${EERSTE_KOERS} AS observedAt
FROM Asset
JOIN Event ON Event.assetId = Asset.id AND Event.type <> 'reservation'
GROUP BY Asset.id, Asset.symbol
HAVING ${EERSTE_KOERS} IS NOT NULL
AND substr(${EERSTE_KOERS}, 1, 10) > ${EERSTE_PEILDATUM}
ORDER BY Asset.symbol`)
.map(r => rij(r));
}
function gatenInKoershistorie() {
const perAsset = new Map();
for (const g of listPriceGaps({ head: false, tail: false, limit: null }).rows) {
const a = perAsset.get(g.assetId) || { assetId: g.assetId, symbol: g.symbol, gaten: 0, grootste: 0 };
a.gaten++;
a.grootste = Math.max(a.grootste, g.days);
perAsset.set(g.assetId, a);
}
return [...perAsset.values()].sort((x, y) => y.grootste - x.grootste)
.map(r => rij({ assetId: r.assetId, symbol: r.symbol }, { gaten: r.gaten, grootsteGatInDagen: r.grootste }));
}
function koersGestopt() {
const vandaag = new Date().toISOString().slice(0, 10);
const uit = [];
for (const a of positionsByAsset()) {
const grens = CONFIG.PRICE_HISTORY.CARRY_FORWARD_DAYS[a.kind];
if (!(a.net > 0) || grens == null) continue;
const laatste = db.one(`SELECT MAX(observedAt) AS observedAt FROM PriceHistory WHERE assetId = ? AND fiatPrice > 0`, [a.id]).observedAt;
if (!laatste) continue;
const dagen = daysBetween(laatste.slice(0, 10), vandaag);
if (dagen > grens) uit.push({ dagen, r: rij({ assetId: a.id, symbol: a.symbol, observedAt: laatste }, { dagenZonderKoers: dagen }) });
}
return uit.sort((x, y) => y.dagen - x.dagen).map(x => x.r);
}
function verplaatsingZonderTegenkant() {
return db.all(`SELECT Event.id AS eventId, Event.assetId, Asset.symbol, Event.occurredAt,
Event.fromAccountId, vanaf.name AS from_name,
Event.toAccountId, naar.name AS to_name
FROM Event
JOIN Asset ON Asset.id = Event.assetId
LEFT JOIN Account vanaf ON vanaf.id = Event.fromAccountId
LEFT JOIN Account naar ON naar.id = Event.toAccountId
WHERE Event.type = 'transfer'
AND (Event.fromAccountId IS NULL OR Event.toAccountId IS NULL)
AND (Event.externalAddress IS NULL OR Event.externalAddress = '')
AND Event.priceOrigin IS NOT 'none'
ORDER BY Event.occurredAt DESC`)
.map(r => rij(r));
}
function kostenZonderEffect() {
return db.all(`SELECT Event.id AS eventId, Event.assetId, Asset.symbol, Event.type, Event.occurredAt,
Event.source
FROM Event
JOIN Asset ON Asset.id = Event.assetId
WHERE (Event.type = 'interest' AND Event.fiatFee > 0)
OR (Event.type IN ('buy','interest') AND Event.unitFee > 0)
ORDER BY Event.occurredAt DESC`)
.map(r => rij(r));
}
function ongekoppeldeBewegingen() {
return db.all(`SELECT WalletTransaction.id AS walletTransactionId, WalletTransaction.accountId,
Account.name AS account_name,
WalletTransaction.direction, WalletTransaction.blockTime,
WalletTransaction.createdAt
FROM WalletTransaction
LEFT JOIN Account ON Account.id = WalletTransaction.accountId
-- Lightning only from the node, the same rule as syncTxProposals (proposals.js): an NWC
-- payment without its own event is not unlinked, it hangs on its LND twin; on an account
-- without a LND source the NWC wallet counts on its own.
WHERE (WalletTransaction.layer = 'onchain'
OR EXISTS (SELECT 1 FROM WalletSource
WHERE WalletSource.id = WalletTransaction.sourceId
AND (WalletSource.kind = 'lnd'
OR (WalletSource.kind = 'nwc' AND NOT EXISTS (
SELECT 1 FROM WalletSource node
WHERE node.accountId = WalletTransaction.accountId
AND node.kind = 'lnd')))))
AND NOT EXISTS (SELECT 1 FROM WalletTransactionEventLink
WHERE WalletTransactionEventLink.walletTransactionId = WalletTransaction.id)
AND NOT EXISTS (SELECT 1 FROM ReconcileProposal
WHERE ReconcileProposal.walletTransactionId = WalletTransaction.id
AND ReconcileProposal.status = 'pending')
ORDER BY WalletTransaction.blockTime DESC`)
.map(r => rij(r));
}
function dekkingZonderAsset() {
return db.all(`SELECT ProviderCoverage.providerId, ProviderCoverage.symbol
FROM ProviderCoverage
WHERE NOT EXISTS (SELECT 1 FROM Asset WHERE Asset.symbol = ProviderCoverage.symbol)
ORDER BY ProviderCoverage.symbol`)
.map(r => rij(r));
}
function koersRijen() {
return db.all(`SELECT PriceHistory.id AS priceHistoryId, PriceHistory.assetId, Asset.symbol, Asset.kind,
PriceHistory.providerId, Provider.name AS provider_name, PriceHistory.resolution,
PriceHistory.observedAt, PriceHistory.fiatPrice
FROM PriceHistory
JOIN Asset ON Asset.id = PriceHistory.assetId
JOIN Provider ON Provider.id = PriceHistory.providerId
ORDER BY PriceHistory.assetId, PriceHistory.observedAt, PriceHistory.id`);
}
const koersRij = (r, counts) => rij({ priceHistoryId: r.priceHistoryId, assetId: r.assetId, symbol: r.symbol,
observedAt: r.observedAt, providerId: r.providerId, provider_name: r.provider_name }, counts);
const factor = x => Math.round(x * 10) / 10;
function reeksenPerAsset() {
const m = new Map();
for (const r of koersRijen()) {
if (!(r.fiatPrice > 0)) continue;
if (!m.has(r.assetId)) m.set(r.assetId, []);
m.get(r.assetId).push(r);
}
return m;
}
const isOunceJump = ratio => {
const oz = CONFIG.METALS.UNITS.ozt, tol = CONFIG.HYGIENE.TROY_OUNCE_TOLERANCE;
const bij = (x, doel) => x >= doel * (1 - tol) && x <= doel * (1 + tol);
return bij(ratio, oz) || bij(ratio, 1 / oz);
};
function eenheidsverwisseling() {
const uit = [];
for (const reeks of reeksenPerAsset().values()) {
for (let i = 1; i < reeks.length; i++) {
const ratio = reeks[i].fiatPrice / reeks[i - 1].fiatPrice;
if (isOunceJump(ratio)) uit.push(koersRij(reeks[i], { factor: factor(ratio >= 1 ? ratio : 1 / ratio) }));
}
}
return uit;
}
function uitschieters() {
const f = CONFIG.HYGIENE.OUTLIER_FACTOR;
const uit = [];
for (const reeks of reeksenPerAsset().values()) {
for (let i = 1; i < reeks.length - 1; i++) {
const voor = reeks[i].fiatPrice / reeks[i - 1].fiatPrice, na = reeks[i].fiatPrice / reeks[i + 1].fiatPrice;
if (isOunceJump(voor) || isOunceJump(na)) continue;
const omhoog = voor >= f && na >= f, omlaag = voor <= 1 / f && na <= 1 / f;
if (omhoog || omlaag) uit.push(koersRij(reeks[i], { factor: factor(omhoog ? Math.min(voor, na) : 1 / Math.max(voor, na)) }));
}
}
return uit;
}
function bronnenOneens() {
const grens = CONFIG.HYGIENE.SOURCES_DISAGREE_SHARE;
const perDag = new Map();
for (const r of koersRijen()) {
if (r.resolution !== 'day' || !(r.fiatPrice > 0)) continue;
const sleutel = `${r.assetId}|${r.observedAt.slice(0, 10)}`;
if (!perDag.has(sleutel)) perDag.set(sleutel, []);
perDag.get(sleutel).push(r);
}
const uit = [];
for (const rijen of perDag.values()) {
if (new Set(rijen.map(r => r.providerId)).size < 2) continue;
const laag = rijen.reduce((a, b) => (b.fiatPrice < a.fiatPrice ? b : a));
const hoog = rijen.reduce((a, b) => (b.fiatPrice > a.fiatPrice ? b : a));
const spreiding = hoog.fiatPrice / laag.fiatPrice - 1;
if (spreiding > grens && laag.providerId !== hoog.providerId) {
uit.push(rij({ assetId: laag.assetId, symbol: laag.symbol, observedAt: laag.observedAt,
low_provider_name: laag.provider_name, high_provider_name: hoog.provider_name },
{ verschilProcent: Math.round(spreiding * 1000) / 10 }));
}
}
return uit;
}
function koersNietPositief() {
return koersRijen().filter(r => r.fiatPrice <= 0).map(r => koersRij(r, {}));
}
function koersInToekomst() {
const nu = new Date().toISOString();
return koersRijen().filter(r => r.observedAt > nu).map(r => koersRij(r, {}));
}
function boekingenMetDagkoers({ origin } = {}) {
const rijen = db.all(`SELECT Event.id AS eventId, Event.assetId, Asset.symbol, Event.type, Event.occurredAt,
Event.priceOrigin, Event.fiatPrice
FROM Event
JOIN Asset ON Asset.id = Event.assetId
WHERE Event.type IN ('buy','sell') AND Event.fiatPrice > 0
AND Event.priceOrigin IS NOT 'none'
AND (?1 = 0 OR Event.priceOrigin IS NULL)
ORDER BY Event.occurredAt DESC, Event.id DESC`, [origin === 'unknown' ? 1 : 0]);
const uit = [];
for (const r of rijen) {
const dag = getPricePoint(r.assetId, String(r.occurredAt).slice(0, 10));
if (dag && dag.fiatPrice > 0) uit.push({ ...r, ratio: r.fiatPrice / dag.fiatPrice });
}
return uit;
}
const boekingRij = (r, counts) => rij({ eventId: r.eventId, assetId: r.assetId, symbol: r.symbol, type: r.type,
occurredAt: r.occurredAt, priceOrigin: r.priceOrigin }, counts);
function boekingsprijsWijktAf() {
const grens = CONFIG.HYGIENE.BOOKING_PRICE_DEVIATION;
const uit = [];
for (const r of boekingenMetDagkoers()) {
if (isOunceJump(r.ratio)) uit.push(boekingRij(r, { factor: factor(r.ratio >= 1 ? r.ratio : 1 / r.ratio) }));
else if (Math.abs(r.ratio - 1) > grens) uit.push(boekingRij(r, { afwijkingProcent: Math.round((r.ratio - 1) * 1000) / 10 }));
}
return uit;
}
const SAME_PRICE_SHARE = 0.0005;
const afleidbaar = () => boekingenMetDagkoers({ origin: 'unknown' })
.filter(r => Math.abs(r.ratio - 1) <= SAME_PRICE_SHARE);
function herkomstAfleidbaar() {
return afleidbaar().map(r => boekingRij(r, {}));
}
export async function fillDerivablePriceOrigins() {
const ids = afleidbaar().map(r => r.eventId);
if (!ids.length) return 0;
trackUpdateMany('Event', ids, () => db.run(
`UPDATE Event SET priceOrigin = 'market_day'
WHERE priceOrigin IS NULL AND id IN (${ids.map(() => '?').join(',')})`, ids));
await db.persist();
return ids.length;
}
const CHECKS = Object.freeze([
{ kind: 'duplicate_events', severity: 'warn', run: dubbeleEvents, countsFrom: ['id'] },
{ kind: 'assets_without_balance', severity: 'info', run: assetsZonderBalans, countsFrom: ['id'] },
{ kind: 'unreconciled_valuations', severity: 'warn', run: onverzoendeBalansen, countsFrom: [] },
{ kind: 'stale_prices', severity: 'warn', run: achterlopendeKoersen, countsFrom: [] },
{ kind: 'late_price_history', severity: 'warn', run: koersenBeginnenTeLaat, countsFrom: [] },
{ kind: 'price_gaps', severity: 'info', run: gatenInKoershistorie, countsFrom: ['observedAt'] },
{ kind: 'price_stopped', severity: 'warn', run: koersGestopt, countsFrom: ['observedAt'] },
{ kind: 'transfer_without_counterpart', severity: 'warn', run: verplaatsingZonderTegenkant, countsFrom: [] },
{ kind: 'fee_without_effect', severity: 'info', run: kostenZonderEffect, countsFrom: [] },
{ kind: 'unlinked_transactions', severity: 'warn', run: ongekoppeldeBewegingen, countsFrom: [] },
{ kind: 'coverage_without_asset', severity: 'info', run: dekkingZonderAsset, countsFrom: [] },
{ kind: 'price_unit_swap', severity: 'warn', run: eenheidsverwisseling, countsFrom: ['fiatPrice'] },
{ kind: 'price_outlier', severity: 'warn', run: uitschieters, countsFrom: ['fiatPrice'] },
{ kind: 'price_sources_disagree', severity: 'info', run: bronnenOneens, countsFrom: ['fiatPrice'] },
{ kind: 'price_not_positive', severity: 'warn', run: koersNietPositief, countsFrom: [] },
{ kind: 'price_in_future', severity: 'warn', run: koersInToekomst, countsFrom: [] },
{ kind: 'booking_price_deviates', severity: 'warn', run: boekingsprijsWijktAf, countsFrom: ['fiatPrice'] },
{ kind: 'price_origin_derivable', severity: 'info', run: herkomstAfleidbaar, countsFrom: ['fiatPrice'] },
]);
export function hygieneKinds() {
return checks().map(c => ({ kind: c.kind, severity: c.severity }));
}
function checks() {
const extra = healthChecks.list().map(e => ({ kind: e.id, severity: e.severity, run: e.run, countsFrom: e.countsFrom }));
const dubbel = extra.find(e => CHECKS.some(c => c.kind === e.kind));
if (dubbel) throw new Error(`health check '${dubbel.kind}' is registered and also a core check`);
return [...CHECKS, ...extra];
}
export function hygieneFingerprint(kind, row = {}, counts = {}) {
const vast = obj => JSON.stringify(Object.keys(obj).sort().map(k => [k, obj[k]]));
return createHash('sha256').update(`${kind}|${vast(row)}|${vast(counts)}`).digest('hex').slice(0, 32);
}
export function listHygieneAcceptances() {
return db.all(`SELECT id, kind, fingerprint, summary, note, createdAt
FROM HygieneAcceptance ORDER BY createdAt DESC, id DESC`);
}
export async function acceptHygieneFinding({ kind, row = {}, counts = {}, summary, note = null } = {}) {
if (!checks().some(c => c.kind === kind)) {
fail('health.acceptUnknownKind', {}, `Unknown hygiene finding kind '${kind}'.`);
}
if (!summary || !String(summary).trim()) {
fail('health.acceptNeedsSummary', {}, 'A hygiene acceptance needs a readable summary.');
}
const fingerprint = hygieneFingerprint(kind, row, counts);
db.run(`INSERT INTO HygieneAcceptance (kind, fingerprint, summary, note, createdAt)
VALUES (?,?,?,?,?)
ON CONFLICT(fingerprint) DO UPDATE SET
summary = excluded.summary, note = excluded.note, createdAt = excluded.createdAt`,
[kind, fingerprint, String(summary).trim(), note ? String(note).trim() : null, new Date().toISOString()]);
await db.persist();
return fingerprint;
}
export async function unacceptHygieneFinding(id) {
db.run(`DELETE FROM HygieneAcceptance WHERE id = ?`, [id]);
await db.persist();
}
export function hygieneReport({ kinds = null } = {}) {
const alle = checks();
const gekozen = kinds ? alle.filter(c => kinds.includes(c.kind)) : alle;
const grens = CONFIG.HYGIENE.MAX_ROWS_PER_FINDING;
const aanvaard = new Set(db.all(`SELECT fingerprint FROM HygieneAcceptance`).map(r => r.fingerprint));
const bevindingen = gekozen.map(c => {
const alles = aanvaard.size
? c.run().filter(r => !aanvaard.has(hygieneFingerprint(c.kind, r.row, r.counts)))
: c.run();
return {
kind: c.kind, severity: c.severity, countsFrom: c.countsFrom,
total: alles.length,
rows: alles.length > grens ? alles.slice(0, grens) : alles,
};
});
return {
total: bevindingen.reduce((n, b) => n + b.total, 0),
findings: bevindingen,
};
}
+105
View File
@@ -0,0 +1,105 @@
// Generated, do not edit. sha256:3c9603902fe01cc9593bf69315c4734fdbba824688512bed37af01059c22d688
import * as db from '../db.js';
import { recordApiCall } from './api-metrics.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
const LOG = CONFIG.INTEGRATION_LOG;
const MIN_SECRET_LENGTH = 4;
const AUTH_VALUE = /\b(Bearer|Basic)\s+\S+/gi;
const SECRET_PARAM = /\b((?:api[_-]?key|apikey|access[_-]?token|token|key|secret|signature|password)=)[^&\s"']+/gi;
const URL_TEXT = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi;
function cleanDetail(text, secrets) {
if (text == null) return null;
let s = String(text);
for (const v of secrets) {
if (typeof v !== 'string' || v.length < MIN_SECRET_LENGTH) continue;
for (const form of new Set([v, encodeURIComponent(v)])) s = s.split(form).join('***');
}
s = s.replace(URL_TEXT, '<url>').replace(AUTH_VALUE, '$1 ***').replace(SECRET_PARAM, '$1***');
s = s.replace(/\s+/g, ' ').trim();
if (!s) return null;
return s.length > LOG.DETAIL_MAX_CHARS ? `${s.slice(0, LOG.DETAIL_MAX_CHARS - 1)}…` : s;
}
const KEY_PART = /^[A-Za-z][\w.-]{0,79}$/;
const cleanKey = (key) => {
const parts = String(key ?? '').split(/\s+/).filter(p => KEY_PART.test(p));
return parts.length ? parts.join(' ') : null;
};
const REQUEST_ID = /^[A-Za-z0-9-]{8,64}$/;
function timeOf(at) {
if (at == null) return new Date().toISOString();
const d = new Date(at);
if (Number.isNaN(d.getTime())) throw new Error(`Invalid occurredAt: '${at}'.`);
return d.toISOString();
}
function prune() {
const grens = new Date(Date.now() - LOG.RETENTION_DAYS * 86_400_000).toISOString();
db.run(`DELETE FROM IntegrationCall WHERE occurredAt < ?`, [grens]);
db.run(`DELETE FROM IntegrationCall
WHERE id <= (SELECT id FROM IntegrationCall ORDER BY id DESC LIMIT 1 OFFSET ?)`, [LOG.MAX_ROWS]);
}
export async function recordIntegrationCall(entry, { secrets = [] } = {}) {
const e = entry || {};
if (!ENUMS.INTEGRATION_TARGET.includes(e.target)) throw new Error(`Unknown integration target: '${e.target}'.`);
if (!ENUMS.INTEGRATION_OUTCOME.includes(e.outcome)) throw new Error(`Unknown integration outcome: '${e.outcome}'.`);
const providerKey = e.target === 'provider' ? String(e.providerKey || '') : null;
if (e.target === 'provider' && !providerKey) throw new Error('A provider row needs a providerKey.');
const operation = String(e.operation || '');
if (!operation || /[?#]|:\/\//.test(operation)) throw new Error(`An operation is a path or a capability: '${operation}'.`);
const subject = e.subject == null ? null : String(e.subject).slice(0, 100);
const status = Number.isInteger(e.httpStatus) && e.httpStatus >= 100 && e.httpStatus <= 599 ? e.httpStatus : null;
const latency = Number.isFinite(e.latencyMs) && e.latencyMs >= 0 ? Math.round(e.latencyMs) : null;
const requestId = e.target === 'service' && REQUEST_ID.test(String(e.requestId ?? '')) ? e.requestId : null;
const clean = cleanDetail(e.detail, Array.isArray(secrets) ? secrets : []);
db.run(`INSERT INTO IntegrationCall
(target, providerKey, operation, subject, outcome, httpStatus, errorKey, detail, latencyMs, requestId, occurredAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
[e.target, providerKey, operation, subject, e.outcome, status, cleanKey(e.errorKey), clean, latency, requestId,
timeOf(e.occurredAt)]);
prune();
await db.persist();
}
export async function countServiceCall({ capability, ok, latencyMs }) {
const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [CONFIG.MARKET_SERVICE.PROVIDER_KEY]);
if (!p) return;
await recordApiCall({ providerId: p.id, capability, ok: !!ok, latency_ms: latencyMs });
}
export function listIntegrationCalls({ days = 7, target = null, providerKey = null, limit = 200 } = {}) {
const sinds = new Date(Date.now() - Math.max(0, Number(days) || 0) * 86_400_000).toISOString();
const n = Math.min(Math.max(1, Math.trunc(Number(limit)) || 200), LOG.MAX_ROWS);
const t = target || null;
const p = providerKey || null;
return db.all(`SELECT * FROM IntegrationCall
WHERE occurredAt >= ? AND (? IS NULL OR target = ?) AND (? IS NULL OR providerKey = ?)
ORDER BY id DESC LIMIT ?`, [sinds, t, t, p, p, n]);
}
export function serviceState() {
const lastFail = db.one(`SELECT * FROM IntegrationCall WHERE target = 'service' AND outcome = 'fail'
ORDER BY id DESC LIMIT 1`) || null;
const row = db.one(`SELECT MAX(bucketMinute) AS at FROM ApiCallMetric
JOIN Provider ON Provider.id = ApiCallMetric.providerId
WHERE Provider.key = ? AND okCount > 0`, [CONFIG.MARKET_SERVICE.PROVIDER_KEY]);
const lastOkAt = row?.at ?? null;
if (lastFail && (!lastOkAt || lastFail.occurredAt.slice(0, 16) >= lastOkAt)) {
return { ok: false, at: lastFail.occurredAt, lastFail, lastOkAt };
}
return { ok: lastOkAt ? true : null, at: lastOkAt ? `${lastOkAt}:00.000Z` : null, lastFail, lastOkAt };
}
export function lastIntegrationFailures() {
return db.all(`SELECT * FROM IntegrationCall
WHERE id IN (SELECT MAX(id) FROM IntegrationCall GROUP BY target, providerKey)
ORDER BY id DESC`);
}
+233
View File
@@ -0,0 +1,233 @@
// Generated, do not edit. sha256:c2a988d6416e302a0ad7224bc98a3cd265bc5cc9cadb0dce809af179af082299
import * as db from '../db.js';
import { nowIso, todayIso, daysBetween, isoToDate, dateToIso, addDaysIso, weekdayOf, clampDom } from './_shared.js';
import { createEvent, eventDaysByAccount } from './events.js';
import { listSelectableAccounts } from './accounts.js';
import { positionAt } from './positions.js';
import { listSchedulesForAccount, listPeriods, realizedForSchedule } from './interest-schedules.js';
import { anchorDaysBetween } from './valuations.js';
import { toUtcIso } from '../../shared/time.js';
import { fail } from '../../shared/app-error.js';
export function realizedInterestSub(accountId, assetId, fromIso, toIso) {
const r = db.one(`SELECT COALESCE(SUM(amount),0) AS s FROM Event
WHERE type = 'interest' AND toAccountId = ? AND assetId = ? AND occurredAt >= ? AND occurredAt < ?`,
[accountId, assetId, fromIso, toIso]);
return r ? r.s : 0;
}
function weeklyDates(startIso, endIso, weekday) {
const out = [];
let cur = addDaysIso(startIso, 1), guard = 0;
while (weekdayOf(cur) !== weekday && guard++ < 7) cur = addDaysIso(cur, 1);
for (; cur <= endIso; cur = addDaysIso(cur, 7)) out.push(cur);
return out;
}
function monthlyDates(startIso, endIso, dom) {
const out = [];
const s = isoToDate(startIso);
let y = s.getUTCFullYear(), m = s.getUTCMonth();
for (;;) {
const iso = dateToIso(new Date(Date.UTC(y, m, clampDom(y, m, dom))));
if (iso > endIso) break;
if (iso > startIso) out.push(iso);
if (++m > 11) { m = 0; y++; }
}
return out;
}
function buildPeriods(dates, startIso) {
const periods = [];
let prev = startIso;
for (const d of dates) { periods.push({ periodStart: prev, periodEnd: d }); prev = d; }
return periods;
}
function allCreditingPeriods(s, horizonIso = todayIso()) {
const periods = cadencePeriods(s, horizonIso);
if (s.endsOn && s.creditCadence !== 'term_end') {
const last = periods.length ? periods[periods.length - 1].periodEnd : s.startsOn;
if (last < s.endsOn) periods.push({ periodStart: last, periodEnd: s.endsOn });
}
return periods;
}
function cadencePeriods(s, horizonIso) {
const eind = s.endsOn || horizonIso;
if (s.creditCadence === 'term_end') return s.endsOn ? [{ periodStart: s.startsOn, periodEnd: s.endsOn }] : [];
if (s.creditCadence === 'annual') {
const periods = [];
const startYear = Number(s.startsOn.slice(0, 4));
for (let y = startYear; ; y++) {
const periodEnd = `${y + 1}-01-01`;
if (periodEnd > eind) break;
periods.push({ periodStart: y === startYear ? s.startsOn : `${y}-01-01`, periodEnd: periodEnd });
}
return periods;
}
if (s.creditCadence === 'quarterly') {
const periods = [];
const s0 = isoToDate(s.startsOn);
let y = s0.getUTCFullYear();
let m = Math.floor(s0.getUTCMonth() / 3) * 3;
let prev = s.startsOn;
for (;;) {
m += 3;
if (m > 11) { m -= 12; y++; }
const periodEnd = dateToIso(new Date(Date.UTC(y, m, 1)));
if (periodEnd > eind) break;
periods.push({ periodStart: prev, periodEnd });
prev = periodEnd;
}
return periods;
}
const dates = s.creditCadence === 'weekly'
? weeklyDates(s.startsOn, eind, s.creditWeekday)
: monthlyDates(s.startsOn, eind, s.creditDayOfMonth);
return buildPeriods(dates, s.startsOn);
}
export function scheduleForecast(s, asofIso = todayIso()) {
const realizedSub = realizedForSchedule(s, asofIso);
const sameAsset = s.principalAssetId === s.payoutAssetId;
const periods = allCreditingPeriods(s, addDaysIso(asofIso, 400));
const amount = periodAmounts(s, periods);
const later = periods.filter(p => p.periodEnd > asofIso);
const upcoming = (s.endsOn ? later : later.slice(0, 1)).map(p => ({ ...p, projectedSub: amount(p) }));
if (!s.endsOn || s.principalAmount == null) {
return { termTotalSub: null, realizedSub, remainingSub: null, remainingCount: null, sameAsset, upcoming };
}
const termTotalSub = periods.reduce((n, p) => n + amount(p), 0);
const remainingSub = Math.max(0, termTotalSub - realizedSub);
return { termTotalSub, realizedSub, remainingSub, remainingCount: upcoming.length, sameAsset, upcoming };
}
function periodAmounts(s, periods) {
if (!s.compounding) return p => projectedSubForPeriod(s, p);
const byEnd = new Map();
let accrued = 0;
for (const p of periods) {
const sub = projectedSubForPeriod(s, p, accrued);
byEnd.set(p.periodEnd, sub);
accrued += sub;
}
return p => byEnd.get(p.periodEnd);
}
function creditingPeriods(s, asofIso) {
return allCreditingPeriods(s).filter(p => p.periodEnd <= asofIso);
}
function rateSlices(s, vanIso, totIso) {
const perioden = listPeriods(s.id);
const wissels = perioden.filter(p => p.startsOn > vanIso && p.startsOn < totIso);
const eerder = perioden.filter(p => p.startsOn <= vanIso);
let rate = eerder.length ? eerder[eerder.length - 1].annualRate : s.annualRate;
const uit = [];
let van = vanIso;
for (const w of wissels) {
uit.push({ van, tot: w.startsOn, rate });
van = w.startsOn;
rate = w.annualRate;
}
uit.push({ van, tot: totIso, rate });
return uit;
}
function principalMainVoor(s, opDag) {
if (s.principalAmount != null) return s.principalAmount / Math.pow(10, s.principal_decimals);
const moment = toUtcIso(`${opDag}T23:59:59`);
return positionAt(s.accountId, s.principalAssetId, moment) / Math.pow(10, s.principal_decimals);
}
function saldoDagen(s, vanDag, totDag) {
const dagen = new Set([
...eventDaysByAccount(s.accountId, s.principalAssetId, vanDag, totDag),
...anchorDaysBetween(s.accountId, s.principalAssetId, vanDag, totDag),
]);
return [...dagen].sort();
}
function projectedSubForPeriod(s, p, accruedSub = 0) {
const stukken = [];
for (const deel of rateSlices(s, p.periodStart, p.periodEnd)) {
if (s.principalAmount != null) { stukken.push(deel); continue; }
let van = deel.van;
for (const dag of saldoDagen(s, deel.van, deel.tot)) {
stukken.push({ van, tot: dag, rate: deel.rate });
van = dag;
}
stukken.push({ van, tot: deel.tot, rate: deel.rate });
}
const accrued = accruedSub / Math.pow(10, s.payout_decimals);
const som = stukken.reduce((n, deel) =>
n + (principalMainVoor(s, deel.van) + accrued) * deel.rate * Math.max(0, daysBetween(deel.van, deel.tot) / 365), 0);
return Math.round(som * Math.pow(10, s.payout_decimals));
}
function getCredit(scheduleId, periodEnd) {
return db.one(`SELECT * FROM InterestCredit WHERE scheduleId = ? AND periodEnd = ?`, [scheduleId, periodEnd]) || null;
}
export function dueCreditsForAccount(accountId) {
const today = todayIso();
const out = [];
for (const s of listSchedulesForAccount(accountId)) {
if (s.rateKind !== 'fixed') continue;
const periods = creditingPeriods(s, today);
const amount = periodAmounts(s, periods);
for (const p of periods) {
if (getCredit(s.id, p.periodEnd)) continue;
if (realizedForSchedule(s, p.periodEnd, addDaysIso(p.periodStart, 1)) > 0) continue;
const projected = amount(p);
if (projected === 0) continue;
out.push({
scheduleId: s.id, accountId: accountId, payoutAssetId: s.payoutAssetId,
payout_symbol: s.payout_symbol, payout_decimals: s.payout_decimals,
payout_unit: s.payout_unit, payout_subunit: s.payout_subunit, payout_display_unit: s.payout_display_unit,
periodStart: p.periodStart, periodEnd: p.periodEnd, projected_sub: projected,
});
}
}
return out;
}
export function dueCreditsAll() {
const out = [];
for (const a of listSelectableAccounts()) {
for (const d of dueCreditsForAccount(a.id)) out.push({ ...d, account_name: a.name });
}
return out;
}
function olderOpenDue(due) {
return dueCreditsForAccount(due.accountId)
.filter(d => d.periodEnd < due.periodEnd)
.sort((a, b) => a.periodEnd.localeCompare(b.periodEnd))[0] || null;
}
export async function bookDueCredit(due, amountSub) {
const ouder = olderOpenDue(due);
if (ouder) {
fail('interest.bookOlderFirst', { start: ouder.periodStart, eind: ouder.periodEnd },
`Book the credit of ${ouder.periodStart} to ${ouder.periodEnd} first: each credit raises the `
+ 'balance the next one is computed on.');
}
const eventId = await createEvent({
assetId: due.payoutAssetId, type: 'interest', occurredAt: toUtcIso(`${due.periodEnd}T00:00:00`),
amount: amountSub, fiatPrice: null, fiatFee: null, unitFee: null,
fromAccountId: null, toAccountId: due.accountId, source: 'manual',
importReference: null, externalAddress: null, note: null,
});
db.run(`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, eventId, status, createdAt)
VALUES (?,?,?,?, 'booked', ?)`,
[due.scheduleId, due.periodStart, due.periodEnd, eventId, nowIso()]);
await db.persist();
return eventId;
}
export async function dismissDueCredit(due) {
db.run(`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, eventId, status, createdAt)
VALUES (?,?,?, NULL, 'dismissed', ?)`,
[due.scheduleId, due.periodStart, due.periodEnd, nowIso()]);
await db.persist();
}
+193
View File
@@ -0,0 +1,193 @@
// Generated, do not edit. sha256:1f3b140e78bb63390abe586ae0f82dd70eb59c31c977547efd2e278b927a1a2f
import * as db from '../db.js';
import { localDate } from '../../shared/time.js';
import { ENUMS } from '../../shared/config.js';
import { fail } from '../../shared/app-error.js';
import { assertEnum, nowIso } from './_shared.js';
import { EVENT_JOIN } from './events.js';
import { trackDelete, trackUpdate } from './change-log.js';
import { emitChange } from './_events.js';
export function listInterestForAccount(accountId) {
return db.all(`${EVENT_JOIN} WHERE e.type = 'interest' AND e.toAccountId = ? ORDER BY e.occurredAt DESC, e.id DESC`, [accountId]);
}
export function listInterest() {
const rows = db.all(`${EVENT_JOIN} WHERE e.type = 'interest' ORDER BY e.occurredAt DESC, e.id DESC`);
const schedules = listAllSchedules();
const links = creditLinks();
for (const r of rows) {
const s = attributeSchedule(r, schedules, links);
r.scheduleId = s ? s.id : null;
r.schedule_label = s ? scheduleLabel(s) : null;
}
return rows;
}
export function listAllSchedules() {
return db.all(`
SELECT s.*, ac.name AS account_name,
pa.symbol AS principal_symbol, pa.decimals AS principal_decimals,
pa.unit AS principal_unit, pa.subunit AS principal_subunit, pa.displayUnit AS principal_display_unit,
po.symbol AS payout_symbol, po.decimals AS payout_decimals,
po.unit AS payout_unit, po.subunit AS payout_subunit, po.displayUnit AS payout_display_unit
FROM InterestSchedule s
JOIN Account ac ON ac.id = s.accountId
LEFT JOIN Asset pa ON pa.id = s.principalAssetId
JOIN Asset po ON po.id = s.payoutAssetId
ORDER BY s.startsOn DESC, s.id DESC`);
}
export function scheduleLabel(s) {
const period = `${s.startsOn}–${s.endsOn || 'heden'}`;
const kind = s.rateKind === 'variable' ? 'variabel' : `${+(s.annualRate * 100).toFixed(4)}%`;
return `${s.account_name} · ${s.payout_symbol} · ${kind} · ${period}`;
}
export function realizedForSchedule(s, asofIso, sinceIso = null) {
const stored = listSchedulesForAccount(s.accountId);
const schedules = stored.some(o => o.id === s.id) ? stored.map(o => (o.id === s.id ? s : o)) : [...stored, s];
const links = creditLinks();
return db.all(`SELECT id, occurredAt, toAccountId, assetId, amount FROM Event
WHERE type = 'interest' AND toAccountId = ? AND assetId = ? AND occurredAt >= ? AND occurredAt <= ?`,
[s.accountId, s.payoutAssetId, sinceIso || '', `${asofIso}T23:59:59`])
.filter(ev => attributeSchedule(ev, schedules, links) === s)
.reduce((n, ev) => n + ev.amount, 0);
}
function creditLinks() {
return new Map(db.all(`SELECT eventId, scheduleId FROM InterestCredit WHERE eventId IS NOT NULL`)
.map(c => [c.eventId, c.scheduleId]));
}
function attributeSchedule(ev, schedules, links) {
const linkedId = links.get(ev.id);
const linked = linkedId != null ? schedules.find(s => s.id === linkedId) : null;
if (linked) return linked;
const date = localDate(ev.occurredAt);
let best = null;
for (const s of schedules) {
if (s.accountId !== ev.toAccountId || s.payoutAssetId !== ev.assetId) continue;
if (date < s.startsOn || (s.endsOn && date > s.endsOn)) continue;
if (!best || s.startsOn > best.startsOn) best = s;
}
return best;
}
export function listSchedulesForAccount(accountId) {
return db.all(`
SELECT s.*,
pa.symbol AS principal_symbol, pa.decimals AS principal_decimals,
pa.unit AS principal_unit, pa.subunit AS principal_subunit, pa.displayUnit AS principal_display_unit,
po.symbol AS payout_symbol, po.decimals AS payout_decimals,
po.unit AS payout_unit, po.subunit AS payout_subunit, po.displayUnit AS payout_display_unit
FROM InterestSchedule s
LEFT JOIN Asset pa ON pa.id = s.principalAssetId
JOIN Asset po ON po.id = s.payoutAssetId
WHERE s.accountId = ?
ORDER BY s.startsOn DESC, s.id DESC`, [accountId]);
}
function assertSchedule(s) {
assertEnum('rateKind', ENUMS.INTEREST_RATE_KIND, s.rateKind);
if (!s.payoutAssetId) fail('accounts.schPayoutRequired', null, 'A schedule needs a payout asset.');
if (!s.startsOn) fail('accounts.schStartRequired', null, 'A schedule needs a start date.');
if (s.endsOn && s.endsOn < s.startsOn) fail('accounts.schEndBeforeStart', null, 'The end date precedes the start date.');
if (s.rateKind === 'variable') {
if (s.principalAssetId != null || s.principalAmount != null || s.annualRate != null) throw new Error('A schedule on "manual or import" has no principal or rate: the app computes nothing there.');
if (s.creditCadence != null || s.creditWeekday != null || s.creditDayOfMonth != null) throw new Error('A schedule on "manual or import" has no credit cadence: the interest comes from outside.');
if (s.compounding) throw new Error('A schedule on "manual or import" has no interest on interest: the app computes nothing there.');
return;
}
if (s.compounding) {
if (s.principalAmount == null) fail('interest.compoundingNeedsPrincipal', null, 'Interest on interest needs a fixed principal; a principal that follows the balance already grows by every credit.');
if (s.principalAssetId !== s.payoutAssetId) fail('interest.compoundingNeedsSameAsset', null, 'Interest on interest needs the payout in the principal asset.');
}
if (!s.principalAssetId) fail('accounts.schAssetRequired', null, 'A schedule needs a principal asset.');
if (s.principalAmount != null && !(s.principalAmount > 0)) {
fail('interest.principalPositive', null, 'The principal has to be greater than 0; leave it empty to follow the account balance.');
}
if (!(s.annualRate >= 0)) fail('interest.rateNotNegative', null, 'The annual rate may not be negative.');
assertEnum('creditCadence', ENUMS.INTEREST_CADENCE, s.creditCadence);
if (s.creditCadence === 'weekly') {
if (!Number.isInteger(s.creditWeekday) || s.creditWeekday < 0 || s.creditWeekday > 6) fail('interest.weekdayRequired', null, 'Pick a weekday for the weekly credit.');
if (s.creditDayOfMonth != null) throw new Error('A weekly schedule has no day of the month.');
} else if (s.creditCadence === 'monthly') {
if (!Number.isInteger(s.creditDayOfMonth) || s.creditDayOfMonth < 1 || s.creditDayOfMonth > 31) fail('interest.dayOfMonthRequired', null, 'Pick a day of the month for the monthly credit.');
if (s.creditWeekday != null) throw new Error('A monthly schedule has no weekday.');
} else if (s.creditWeekday != null || s.creditDayOfMonth != null) {
throw new Error('This cadence uses neither a weekday nor a day of the month.');
}
}
export async function createSchedule(s) {
assertSchedule(s);
db.run(`INSERT INTO InterestSchedule
(accountId, rateKind, principalAssetId, payoutAssetId, principalAmount, annualRate, startsOn, endsOn, creditCadence, creditWeekday, creditDayOfMonth, compounding, note, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[s.accountId, s.rateKind, s.principalAssetId ?? null, s.payoutAssetId, s.principalAmount ?? null, s.annualRate ?? null,
s.startsOn, s.endsOn ?? null, s.creditCadence ?? null, s.creditWeekday ?? null, s.creditDayOfMonth ?? null,
s.compounding ? 1 : 0, s.note ?? null, nowIso()]);
const id = db.lastId();
await db.persist();
emitChange('ledger-changed');
return id;
}
export async function updateSchedule(id, s) {
assertSchedule(s);
if (!s.accountId) fail('interest.schAccountRequired', null, 'A schedule needs an account.');
trackUpdate('InterestSchedule', id, () =>
db.run(`UPDATE InterestSchedule SET
accountId = ?, rateKind = ?, principalAssetId = ?, payoutAssetId = ?, principalAmount = ?, annualRate = ?,
startsOn = ?, endsOn = ?, creditCadence = ?, creditWeekday = ?, creditDayOfMonth = ?, compounding = ?, note = ?
WHERE id = ?`,
[s.accountId, s.rateKind, s.principalAssetId ?? null, s.payoutAssetId, s.principalAmount ?? null, s.annualRate ?? null,
s.startsOn, s.endsOn ?? null, s.creditCadence ?? null, s.creditWeekday ?? null, s.creditDayOfMonth ?? null,
s.compounding ? 1 : 0, s.note ?? null, id]));
await db.persist();
emitChange('ledger-changed');
}
export async function deleteSchedule(id) {
trackDelete('InterestSchedule', id, () => db.run(`DELETE FROM InterestSchedule WHERE id = ?`, [id]));
await db.persist();
emitChange('ledger-changed');
}
export function listPeriods(scheduleId) {
return db.all(`SELECT * FROM InterestPeriod WHERE scheduleId = ? ORDER BY startsOn ASC`, [scheduleId]);
}
function assertPeriod(scheduleId, p) {
const s = db.one(`SELECT startsOn, endsOn, rateKind FROM InterestSchedule WHERE id = ?`, [scheduleId]);
if (!s) fail('interest.unknownSchedule', null, 'Unknown interest schedule.');
if (s.rateKind !== 'fixed') throw new Error('A schedule on "manual or import" has no rate, and therefore no interest periods.');
if (!p.startsOn) fail('interest.periodStartRequired', null, 'A period needs a start date.');
if (p.startsOn <= s.startsOn) {
fail('interest.periodStartBeforeSchedule', { start: s.startsOn },
`The start date has to be after the start of the schedule (${s.startsOn}); the rate from that day on is on the schedule itself.`);
}
if (s.endsOn && p.startsOn > s.endsOn) {
fail('interest.periodStartAfterEnd', { eind: s.endsOn },
`The start date is after the end of the schedule (${s.endsOn}).`);
}
if (!(p.annualRate >= 0)) fail('interest.rateNotNegative', null, 'The annual rate may not be negative.');
}
export async function createPeriod(scheduleId, p) {
assertPeriod(scheduleId, p);
db.run(`INSERT INTO InterestPeriod (scheduleId, startsOn, annualRate) VALUES (?,?,?)`,
[scheduleId, p.startsOn, p.annualRate]);
const id = db.lastId();
await db.persist();
return id;
}
export async function updatePeriod(id, p) {
const bestaand = db.one(`SELECT scheduleId FROM InterestPeriod WHERE id = ?`, [id]);
if (!bestaand) fail('interest.unknownPeriod', null, 'Unknown interest period.');
assertPeriod(bestaand.scheduleId, p);
trackUpdate('InterestPeriod', id, () =>
db.run(`UPDATE InterestPeriod SET startsOn = ?, annualRate = ? WHERE id = ?`, [p.startsOn, p.annualRate, id]));
await db.persist();
}
export async function deletePeriod(id) {
trackDelete('InterestPeriod', id, () => db.run(`DELETE FROM InterestPeriod WHERE id = ?`, [id]));
await db.persist();
}
+29
View File
@@ -0,0 +1,29 @@
// Generated, do not edit. sha256:85ed9f94869ff2f4ce59b8e785f0225bd901edca3d1e5c1790edb2c8a7b0b6fe
import * as db from '../db.js';
import { toSearchName } from '../../shared/search-name.js';
import { assertName } from './_shared.js';
import { trackDelete } from './change-log.js';
import { fail } from '../../shared/app-error.js';
export function listLabels() { return db.all(`SELECT * FROM Label ORDER BY name`); }
export function labelUsage() {
return db.all(`SELECT Label.id AS labelId, COUNT(WalletTransaction.id) AS walletTransactions
FROM Label
LEFT JOIN WalletTransaction ON WalletTransaction.labelId = Label.id
GROUP BY Label.id
ORDER BY Label.id`);
}
export async function createLabel({ name }) {
const naam = assertName('Labelnaam', name);
db.run(`INSERT INTO Label (name, searchName) VALUES (?,?)`, [naam, toSearchName(naam)]);
const id = db.lastId(); await db.persist(); return id;
}
export async function deleteLabel(id) {
const used = db.one(`SELECT (SELECT COUNT(*) FROM WalletTransaction WHERE labelId=?1) AS n`, [id]).n;
if (used > 0) fail('labels.inUse', { n: used }, `The label is used by ${used} item(s).`);
trackDelete('Label', id, () => db.run(`DELETE FROM Label WHERE id=?`, [id]));
await db.persist();
}
+84
View File
@@ -0,0 +1,84 @@
// Generated, do not edit. sha256:1228a952eacb4d1d9d3f5acbdbe35af2071a6bcad5057d2b1aecfd3b41975c0e
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { CONFIG } from '../../shared/config.js';
import { parseLicense, verifySignature, coversVersion, appVersion, readLicense } from '../license.mjs';
import { installDir } from '../portfolios.mjs';
import { marketBuyerLicenses, marketServiceUrl, marketSale, marketPlaceOrder, marketOrderStatus } from '../market-service.mjs';
const HEX64 = /^[0-9a-f]{64}$/;
const buyerFile = () => join(installDir(), CONFIG.LICENSE.BUYER_FILE_NAME);
function rememberedBuyer() {
try {
const key = JSON.parse(readFileSync(buyerFile(), 'utf8'))?.buyer;
return HEX64.test(String(key)) ? key : null;
} catch {
return null;
}
}
function signerOf(signature) {
const m = /^Nostr ([A-Za-z0-9+/=]+)$/.exec(String(signature ?? ''));
if (!m) throw new Error('A sale call carries an "Authorization: Nostr <base64 event>" header.');
let pubkey;
try { pubkey = JSON.parse(Buffer.from(m[1], 'base64').toString('utf8'))?.pubkey; } catch { pubkey = null; }
if (!HEX64.test(String(pubkey))) throw new Error('The signed event carries no 64-hex pubkey.');
return pubkey;
}
export async function licenseShop() {
return { serviceUrl: await marketServiceUrl(), buyer: rememberedBuyer() };
}
export async function buyerLicenses(signature) {
const signer = signerOf(signature);
const list = await marketBuyerLicenses(signature);
writeFileSync(buyerFile(), `${JSON.stringify({ buyer: signer }, null, 2)}\n`);
const judge = judgeAgainstInstalled();
return list.map(judge);
}
function judgeAgainstInstalled() {
const version = appVersion();
const current = readLicense();
const now = current.state === 'valid' ? current.license : null;
return (file) => {
const text = JSON.stringify(file);
try {
const parsed = parseLicense(text);
const l = parsed.license;
const installed = !!now && ['buyer', 'level', 'issued', 'majorFrom', 'majorTo'].every(k => String(now[k]) === String(l[k]));
return {
file: text, level: l.level, issued: l.issued, majorFrom: l.majorFrom, majorTo: l.majorTo,
valid: verifySignature(parsed), covers: coversVersion(l, version), installed,
};
} catch {
return { file: text, valid: false, unreadable: true };
}
};
}
const ORDER_ID = /^[0-9a-f]{32}$/;
export async function saleInfo() {
return marketSale();
}
export async function placeOrder(signature, body = '{}') {
signerOf(signature);
if (typeof body !== 'string') throw new Error('The body of an order is the signed JSON text.');
return marketPlaceOrder(signature, body);
}
export async function orderStatus(orderId, readKey) {
if (!ORDER_ID.test(String(orderId))) throw new Error('An order id is 32 hex characters.');
if (!HEX64.test(String(readKey))) throw new Error('A read key is 64 hex characters.');
const { order, license } = await marketOrderStatus(orderId, readKey);
return { order, license: license ? judgeAgainstInstalled()(license) : null };
}
export async function forgetBuyer() {
rmSync(buyerFile(), { force: true });
return { buyer: null };
}
+24
View File
@@ -0,0 +1,24 @@
// Generated, do not edit. sha256:446976735f7157c5e0705294189119a2714fb95130e9b9374ed76421fa83e2f5
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { CONFIG } from '../../shared/config.js';
import { parseLicense, verifySignature, coversVersion, appVersion } from '../license.mjs';
import { installDir } from '../portfolios.mjs';
import { fail } from '../../shared/app-error.js';
export async function installLicense(text) {
let parsed;
try {
parsed = parseLicense(String(text ?? ''));
} catch (err) {
fail('license.installUnreadable', { reason: err.message }, `This is not a valid licence file: ${err.message}`);
}
if (!verifySignature(parsed)) {
fail('license.installBadSignature', null, 'The signature does not belong to this licence, or it was not issued for this app.');
}
writeFileSync(join(installDir(), CONFIG.LICENSE.FILE_NAME), `${JSON.stringify(parsed, null, 2)}\n`);
const version = appVersion();
return { license: parsed.license, version, covers: coversVersion(parsed.license, version) };
}
+24
View File
@@ -0,0 +1,24 @@
// Generated, do not edit. sha256:a807f05aaf3a8f8a3367166282f6e35755004e775d3ad2970e654848cde4b032
import { CONFIG } from '../../shared/config.js';
import { licenseState, appVersion, readLicense, coversVersion } from '../license.mjs';
export function licenseStatus() {
const state = licenseState();
const gratis = state.level !== 'full';
const bestand = readLicense();
const installed = bestand.state === 'valid' ? bestand.license : null;
return {
level: state.level,
why: state.why,
enforced: state.enforced,
version: appVersion(),
installed,
installedCovers: installed ? coversVersion(installed, appVersion()) : false,
buyer: state.license?.buyer ?? installed?.buyer ?? null,
issued: state.license?.issued ?? installed?.issued ?? null,
max: gratis ? { ...CONFIG.LICENSE.FREE.MAX } : null,
features: gratis ? { ...CONFIG.LICENSE.FREE.HAS } : null,
};
}
+142
View File
@@ -0,0 +1,142 @@
// Generated, do not edit. sha256:5f58c2b253a83045479ff5ac37e60e082b447d2cdda1c0769094b6a2ac261676
import * as db from '../db.js';
import { getBaseCurrency } from './portfolio.js';
import { marketConfigured, marketRegister, marketSeries, marketHistory, marketSpots, isRetryLater } from '../market-service.mjs';
import { CONFIG } from '../../shared/config.js';
const DAY_MS = 86_400_000;
const unitFactor = (entry) => (entry.unit == null ? 1 : CONFIG.METALS.UNITS[entry.unit] ?? null);
const perStorageUnit = (entry, price) => price / unitFactor(entry);
const usable = register => (register || []).filter(i => i && typeof i.key === 'string' && unitFactor(i) != null);
function registerEntry(asset, register) {
const key = `${asset.kind}:${String(asset.symbol || '').toUpperCase()}`;
return usable(register).find(i => i.key === key) || null;
}
function minuteEntry(asset, register) {
const hit = registerEntry(asset, register);
return hit && Array.isArray(hit.resolutions) && hit.resolutions.includes('minute') ? hit : null;
}
function unavailable(e) {
if (typeof e?.key === 'string' && e.key.startsWith('marketService.')) {
return { status: 'unavailable', retryLater: isRetryLater(e), key: e.key, params: e.params ?? null,
...(e.requestId ? { requestId: e.requestId } : {}) };
}
throw e;
}
const assetRow = (assetId) => {
const asset = db.one(`SELECT id, kind, symbol FROM Asset WHERE id = ?`, [Number(assetId)]);
if (!asset) throw new Error(`Unknown asset id ${assetId}.`);
return asset;
};
export async function intradaySeries(assetId, sinceMs) {
const asset = assetRow(assetId);
const now = Date.now();
const from = Number(sinceMs);
if (!Number.isFinite(from) || from >= now || now - from > CONFIG.MARKET_SERVICE.INTRADAY_MAX_DAYS * DAY_MS) {
throw new Error('sinceMs has to lie in the past, within the intraday limit.');
}
if (!(await marketConfigured())) return { status: 'off' };
try {
const entry = minuteEntry(asset, await marketRegister());
if (!entry) return { status: 'notCovered' };
const s = await marketSeries({ instrument: entry.key, quote: getBaseCurrency(), resolution: 'minute', from, to: now });
const named = src => s.sourceLabels?.[src] ?? src;
return { status: 'ok', instrument: entry.key, currency: s.currency, source: s.source, sourceLabel: s.sourceLabel ?? null,
points: s.points.map(([t, p, src]) => (src ? [t, perStorageUnit(entry, p), named(src)] : [t, perStorageUnit(entry, p)])) };
} catch (e) {
return unavailable(e);
}
}
export async function priceAtMoment(assetId, atIso) {
const asset = assetRow(assetId);
const at = Date.parse(String(atIso));
const now = Date.now();
if (!Number.isFinite(at) || at > now) throw new Error(`A moment in the past is needed: '${atIso}'.`);
if (!(await marketConfigured())) return { status: 'off' };
const window = CONFIG.MARKET_SERVICE.MOMENT_WINDOW_MS;
try {
const entry = minuteEntry(asset, await marketRegister());
if (!entry) return { status: 'notCovered' };
const s = await marketSeries({ instrument: entry.key, quote: getBaseCurrency(), resolution: 'minute',
from: at - window, to: Math.min(at + window, now) });
let best = null;
for (const [t, p, src] of s.points || []) {
if (!Number.isFinite(t) || !Number.isFinite(p) || Math.abs(t - at) > window) continue;
if (!best || Math.abs(t - at) < Math.abs(best[0] - at)) best = [t, p, src];
}
if (!best) return { status: 'missing' };
return { status: 'ok', price: perStorageUnit(entry, best[1]), currency: s.currency, at: best[0],
source: best[2] || s.source };
} catch (e) {
return unavailable(e);
}
}
export async function serviceDayHistory(assetId, sinceMs) {
const asset = assetRow(assetId);
const now = Date.now();
const from = Number(sinceMs);
if (!Number.isFinite(from) || from >= now || now - from > CONFIG.MARKET_SERVICE.HISTORY_MAX_DAYS * DAY_MS + DAY_MS) {
throw new Error('sinceMs has to lie in the past, within the history limit.');
}
if (!(await marketConfigured())) return { status: 'off' };
try {
const entry = registerEntry(asset, await marketRegister());
if (!entry || !Array.isArray(entry.kinds) || !entry.kinds.includes('history')) return { status: 'notCovered' };
const h = await marketHistory({ instrument: entry.key, quote: getBaseCurrency(), from, to: now });
const today = Math.floor(now / DAY_MS) * DAY_MS;
const named = src => h.sourceLabels?.[src] ?? src;
const days = h.points.filter(([t, , resolution]) => resolution === 'day' && t < today)
.map(([t, p, , src]) => (src ? [Math.floor(t / DAY_MS) * DAY_MS, perStorageUnit(entry, p), named(src)]
: [Math.floor(t / DAY_MS) * DAY_MS, perStorageUnit(entry, p)]));
return { status: 'ok', instrument: entry.key, currency: h.currency, source: h.source, days };
} catch (e) {
return unavailable(e);
}
}
export async function serviceCoverage() {
if (!(await marketConfigured())) return { status: 'off' };
try {
const register = await marketRegister();
const assetIds = db.all(`SELECT id, kind, symbol FROM Asset ORDER BY id`)
.filter(a => registerEntry(a, register))
.map(a => a.id);
return { status: 'ok', assetIds };
} catch (e) {
return unavailable(e);
}
}
export async function serviceSpot(assetId) {
const asset = assetRow(assetId);
if (!(await marketConfigured())) return { status: 'off' };
const today = new Date().toISOString().slice(0, 10);
const manual = db.one(
`SELECT 1 AS x FROM PriceHistory JOIN Provider ON Provider.id = PriceHistory.providerId
WHERE PriceHistory.assetId = ? AND Provider.key = 'manual' AND substr(PriceHistory.observedAt, 1, 10) = ?`,
[asset.id, today]);
if (manual) return { status: 'local' };
try {
const register = await marketRegister();
const entry = registerEntry(asset, register);
if (!entry) return { status: 'notCovered' };
const r = await marketSpots(usable(register).map(i => i.key), getBaseCurrency());
const hit = r.spot.find(s => s.instrument === entry.key);
if (hit) return { status: 'ok', ...hit, price: perStorageUnit(entry, hit.price) };
if (r.unreachable.includes(entry.key)) {
return { status: 'unavailable', retryLater: true, key: 'marketService.spotUnreachable', params: null };
}
return { status: 'missing' };
} catch (e) {
return unavailable(e);
}
}
+88
View File
@@ -0,0 +1,88 @@
// Generated, do not edit. sha256:8eb5f3330fcdfdbe9052fd4560a6ccd48f5f3aa7ef341ffd35df63e368fe3a71
import * as db from '../db.js';
import { toSearchName } from '../../shared/search-name.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum } from './_shared.js';
import { fail } from '../../shared/app-error.js';
import { trackDelete, trackUpdate } from './change-log.js';
export function hasMetalAssets() {
return db.one(`SELECT 1 AS x FROM Asset WHERE kind = 'metal' LIMIT 1`) != null;
}
export function listMetalAssets() {
return db.all(`SELECT * FROM Asset WHERE kind = 'metal' ORDER BY symbol`);
}
export function listMetalProducts() {
return db.all(`SELECT mp.*, a.symbol AS asset_symbol, a.name AS asset_name
FROM MetalProduct mp JOIN Asset a ON a.id = mp.assetId
ORDER BY a.symbol, mp.form, mp.fineGrams`);
}
export function listMetalProductsForAsset(assetId) {
return db.all(`SELECT * FROM MetalProduct WHERE assetId = ? ORDER BY form, fineGrams`, [assetId]);
}
function assertMetalProduct({ assetId, name, form, fineGrams }) {
assertEnum('form', ENUMS.METAL_FORM, form);
if (!Number.isInteger(assetId)) fail('metalProducts.assetRequired', null, 'Pick a valid metal asset.');
if (!String(name || '').trim()) fail('metalProducts.nameRequired', null, 'The name is required.');
if (!Number.isInteger(fineGrams) || fineGrams <= 0) fail('metalProducts.fineGramsPositive', null, 'The fine weight has to be greater than 0.');
const a = db.one(`SELECT kind FROM Asset WHERE id = ?`, [assetId]);
if (!a || a.kind !== 'metal') fail('metalProducts.notMetal', null, 'The chosen asset is not a metal.');
}
export async function createMetalProduct(p) {
assertMetalProduct(p);
db.run(`INSERT INTO MetalProduct (assetId, name, searchName, form, fineGrams) VALUES (?,?,?,?,?)`,
[p.assetId, p.name.trim(), toSearchName(p.name), p.form, p.fineGrams]);
const id = db.lastId();
await db.persist();
return id;
}
export async function updateMetalProduct(id, p) {
assertMetalProduct(p);
trackUpdate('MetalProduct', id, () =>
db.run(`UPDATE MetalProduct SET assetId = ?, name = ?, searchName = ?, form = ?, fineGrams = ? WHERE id = ?`,
[p.assetId, p.name.trim(), toSearchName(p.name), p.form, p.fineGrams, id]));
await db.persist();
}
export async function deleteMetalProduct(id) {
const used = db.one(`SELECT COUNT(*) AS n FROM Event WHERE productId = ?`, [id]).n;
if (used > 0) fail('metalProducts.inUse', { n: used }, `Product is used by ${used} event(s); remove those first.`);
trackDelete('MetalProduct', id, () => db.run(`DELETE FROM MetalProduct WHERE id = ?`, [id]));
await db.persist();
}
export function metalHoldings({ ownOnly = false } = {}) {
return db.all(`
SELECT mp.id, mp.name, mp.form, mp.fineGrams, mp.assetId,
a.symbol AS asset_symbol, a.name AS asset_name,
COALESCE(SUM(CASE
WHEN e.type = 'buy' AND e.toIn THEN e.productCount
WHEN e.type = 'sell' AND e.fromIn THEN -e.productCount
-- Only a transfer between two ACCOUNTS of which one is own: a transfer out of the
-- administration counts no pieces in either mode, so the two modes agree without a scope.
WHEN e.type = 'transfer' AND ?1 = 1 AND e.toAccountId IS NOT NULL AND e.fromAccountId IS NOT NULL THEN
(CASE WHEN e.toIn AND NOT e.fromIn THEN e.productCount ELSE 0 END)
- (CASE WHEN e.fromIn AND NOT e.toIn THEN e.productCount ELSE 0 END)
ELSE 0 END), 0) AS net_count,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn THEN e.productCount ELSE 0 END), 0) AS buy_count,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN 1 ELSE 0 END), 0) AS buy_events,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN COALESCE(e.fiatFee,0) ELSE 0 END), 0) AS buy_premium,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN e.amount ELSE 0 END), 0) AS buy_amount,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN e.amount * COALESCE(e.fiatPrice,0) ELSE 0 END), 0) AS buy_spot_raw,
COALESCE(SUM(CASE WHEN e.priceOrigin IS 'none' THEN
(CASE WHEN e.type = 'buy' AND e.toIn THEN e.amount WHEN e.type = 'sell' AND e.fromIn THEN -e.amount ELSE 0 END)
ELSE 0 END), 0) AS gift_amount
FROM MetalProduct mp
JOIN Asset a ON a.id = mp.assetId
LEFT JOIN (
SELECT Event.*,
(?1 = 0 OR toAccount.wealthScope IS NULL) AS toIn,
(?1 = 0 OR fromAccount.wealthScope IS NULL) AS fromIn
FROM Event
LEFT JOIN Account toAccount ON toAccount.id = Event.toAccountId
LEFT JOIN Account fromAccount ON fromAccount.id = Event.fromAccountId
WHERE Event.productCount IS NOT NULL
) e ON e.productId = mp.id
GROUP BY mp.id
HAVING net_count > 0
ORDER BY a.symbol, mp.form, mp.fineGrams`, [ownOnly ? 1 : 0]);
}
+98
View File
@@ -0,0 +1,98 @@
// Generated, do not edit. sha256:2e1c42ef1941376d221fab1a61b5e60c9be06530764fa9486b752263252805ed
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { nowIso, todayIso } from './_shared.js';
import { listPendingAiProposals } from './ai-proposals.js';
import { listPendingProposals } from './proposals.js';
import { dueCreditsAll } from './interest-credits.js';
import { listOpenValuationDiffs } from './valuations.js';
import { lightningBalanceDiffs } from './positions.js';
import { listDueReservations } from './reservations.js';
const dateToInt = d => Number(String(d).slice(0, 10).replace(/-/g, ''));
const NOTIFICATION_SOURCES = {
reconcile: { action: true, state() {
const p = listPendingProposals();
return { count: p.length, highWater: p.reduce((m, x) => Math.max(m, x.id), 0) };
} },
interest: { action: true, state() {
const due = dueCreditsAll();
return { count: due.length, highWater: due.reduce((m, d) => Math.max(m, dateToInt(d.periodEnd)), 0) };
} },
valuation: { action: true, state() {
const diffs = listOpenValuationDiffs();
return { count: diffs.length + lightningBalanceDiffs().length, highWater: diffs.reduce((m, v) => Math.max(m, v.id), 0) };
} },
schedule_ended: { action: false, state() {
const rows = db.all(`SELECT id, endsOn FROM InterestSchedule WHERE endsOn < ?`, [todayIso()]);
return { count: rows.length, highWater: rows.reduce((m, r) => Math.max(m, dateToInt(r.endsOn)), 0) };
} },
source_proposal: { action: true, state() {
const rows = db.all(`SELECT assetId, MAX(id) AS mid FROM PriceSource WHERE status = 'proposed' GROUP BY assetId`);
return { count: rows.length, highWater: rows.reduce((m, r) => Math.max(m, r.mid), 0) };
} },
ai_proposal: { action: true, state() {
const rows = listPendingAiProposals();
return { count: rows.length, highWater: rows.reduce((m, r) => Math.max(m, r.id), 0) };
} },
reservation: { action: true, state() {
const rows = listDueReservations();
return { count: rows.length, highWater: rows.reduce((m, r) => Math.max(m, dateToInt(r.occurredAt)), 0) };
} },
};
export async function refreshNotifications() {
let changed = false;
for (const kind of ENUMS.NOTIFICATION_KIND) {
const src = NOTIFICATION_SOURCES[kind];
if (!src) continue;
const { count, highWater } = src.state();
const active = db.one(`SELECT id, count, highWater FROM Notification WHERE kind = ? AND dismissedAt IS NULL ORDER BY id DESC LIMIT 1`, [kind]);
if (count === 0) {
if (src.action && active) { db.run(`DELETE FROM Notification WHERE id = ?`, [active.id]); changed = true; }
continue;
}
if (active) {
if (active.count !== count || active.highWater !== highWater) {
db.run(`UPDATE Notification SET count = ?, highWater = ? WHERE id = ?`, [count, highWater, active.id]);
changed = true;
}
continue;
}
const last = db.one(`SELECT highWater FROM Notification WHERE kind = ? ORDER BY id DESC LIMIT 1`, [kind]);
if (!last || highWater > last.highWater) {
db.run(`INSERT INTO Notification (kind, count, highWater, createdAt) VALUES (?,?,?,?)`, [kind, count, highWater, nowIso()]);
changed = true;
}
}
if (changed) await db.persist();
}
export function listActiveNotifications() {
return db.all(`SELECT id, kind, count, highWater, createdAt FROM Notification WHERE dismissedAt IS NULL ORDER BY id DESC`);
}
export async function currentNotifications() {
await refreshNotifications();
return listActiveNotifications();
}
export function notificationDetail(n) {
if (n.kind !== 'schedule_ended') return null;
const rows = db.all(`
SELECT s.accountId, ac.name AS account, po.symbol AS asset
FROM InterestSchedule s
JOIN Account ac ON ac.id = s.accountId
JOIN Asset po ON po.id = s.payoutAssetId
WHERE s.endsOn < ?
ORDER BY s.endsOn DESC, s.id DESC`, [todayIso()]);
if (!rows.length) return null;
return { accountId: rows[0].accountId, account: rows[0].account, asset: rows[0].asset, extra: rows.length - 1 };
}
export async function dismissNotification(id) {
db.run(`UPDATE Notification SET dismissedAt = ? WHERE id = ? AND dismissedAt IS NULL`, [nowIso(), id]);
await db.persist();
}
+83
View File
@@ -0,0 +1,83 @@
// Generated, do not edit. sha256:329990ff910102e84cb67048624a913db4948fa6ef673de3eb40c65c633864ba
import * as db from '../db.js';
import { ASSET_DEFINITIONS, DASHBOARD } from '../../shared/config.js';
import { toSearchName } from '../../shared/search-name.js';
import { getPref, setPref } from './preferences.js';
import { createAsset } from './assets.js';
import { createAccount } from './accounts.js';
import { activeProfile, addWidget, listWidgets } from './dashboard.js';
import { currentCount, capacityReachedFor } from './_limits.js';
export function setupStatus() {
const done = Number(getPref('app', 'setup_done')) === 1;
const empty = currentCount('assets') === 0;
const own = db.one(`SELECT (SELECT COUNT(*) FROM Account) + (SELECT COUNT(*) FROM Event)
+ (SELECT COUNT(*) FROM Valuation) AS n`).n;
return { needed: !done && empty, done, empty, persona: getPref('app', 'persona'),
currencyOpen: !done && empty && own === 0 };
}
function assertKnownAsset(symbol) {
if (!ASSET_DEFINITIONS[symbol]) {
throw new Error(`Unknown asset '${symbol}'. Allowed: ${Object.keys(ASSET_DEFINITIONS).join(', ')}.`);
}
}
async function ensureAsset(symbol) {
const def = ASSET_DEFINITIONS[symbol];
const existing = db.one(`SELECT id FROM Asset WHERE symbol = ?`, [def.symbol]);
if (existing) return existing.id;
return capacityReachedFor('assets') === null ? createAsset(def) : null;
}
async function ensureAccount({ name, kind }) {
const existing = db.one(`SELECT id FROM Account WHERE searchName = ?`, [toSearchName(name)]);
if (existing) return existing.id;
if (kind === 'bank' && capacityReachedFor('fiatAccounts') !== null) return null;
const own = kind === 'wallet' || kind === 'vault';
return createAccount({ name, kind, isOwnCustody: own, scanEnabled: false, taxCategory: null });
}
function placeInFreeSpace(rows, items) {
const cols = DASHBOARD.GRID_COLUMNS;
const heightOf = (type, h) => (h > 0 ? h : DASHBOARD.WIDGETS[type]?.defaultH || 1);
const bottom = new Array(cols).fill(0);
for (const r of rows) {
const end = r.gridY + heightOf(r.type, r.spanHeight);
for (let c = r.gridX; c < Math.min(cols, r.gridX + r.spanWidth); c++) bottom[c] = Math.max(bottom[c], end);
}
return items.map(({ prefer, ...w }) => {
const span = DASHBOARD.WIDGETS[w.type].defaultSpan;
let bestX = 0, bestY = Infinity;
for (let x = 0; x + span <= cols; x++) {
const y = Math.max(...bottom.slice(x, x + span));
if (y < bestY || (y === bestY && x === prefer)) { bestY = y; bestX = x; }
}
for (let c = bestX; c < bestX + span; c++) bottom[c] = bestY + heightOf(w.type, null);
return { ...w, gridX: bestX, gridY: bestY };
});
}
export async function completeSetup({ assets = [], persona = 'broad', accounts = [], widgets = [] } = {}) {
assets.forEach(assertKnownAsset);
await setPref('app', 'persona', persona);
const assetIds = [];
for (const s of assets) {
const id = await ensureAsset(s);
if (id != null) assetIds.push(id);
}
let accountCount = 0;
for (const a of accounts) if ((await ensureAccount(a)) != null) accountCount++;
const profileId = activeProfile().id;
const add = [];
if (widgets.includes('standing')) add.push({ type: 'standing', prefer: 4 });
if (widgets.includes('signal') && persona === 'bitcoin') add.push({ type: 'signal', prefer: 4 });
if (widgets.includes('spot')) assetIds.forEach(id => add.push({ type: 'spot', instanceKey: `spot-${id}`, assetId: id, prefer: 0, settings: { momentum: false } }));
let widgetCount = 0;
for (const w of placeInFreeSpace(listWidgets(profileId), add)) if (await addWidget(profileId, w)) widgetCount++;
await setPref('app', 'setup_done', 1);
return { persona, assetIds, accounts: accountCount, widgets: widgetCount };
}
+8
View File
@@ -0,0 +1,8 @@
// Generated, do not edit. sha256:57354eb3f01496daabc5a7208eceb7ac61ff77a12ab41b4a5effdfd3d90f3cc1
import * as db from '../db.js';
export function getBaseCurrency() {
const row = db.one(`SELECT baseCurrency FROM Portfolio WHERE id = 1`);
if (!row) throw new Error('Portfolio row missing: this database has no unit of account.');
return row.baseCurrency;
}
+143
View File
@@ -0,0 +1,143 @@
// Generated, do not edit. sha256:c2c77c87420dd87960f52c5550a95a9491166b15f248300e49e13aae650240fc
import { nowUtcIso } from '../../shared/time.js';
import { fail } from '../../shared/app-error.js';
import { assetPositions, eventAccountIdsByAsset, eventAssetIdsByAccount, eventsCountByAccount,
eventsNetByAccount, firstTaxEventDate } from './events.js';
import { anchorAt, anchorsBetween, createValuation, firstAnchorDate, latestAnchors, listValuations,
updateValuation, valuationDiff, valuationGap, valuationReconcilable } from './valuations.js';
import { accountOnchainBalance } from './wallet-addresses.js';
import { lightningBalances } from './wallet-sources.js';
import { onchainAsset } from './tx-links.js';
export function positionAt(accountId, assetId, atIso) {
const anker = anchorAt(accountId, assetId, atIso);
if (!anker) return eventsNetByAccount(accountId, assetId, atIso);
const naAnker = eventsNetByAccount(accountId, assetId, atIso) - eventsNetByAccount(accountId, assetId, anker.rateAt);
return anker.balance + naAnker;
}
export function positionsByAsset(atIso, { ownOnly = false } = {}) {
const peil = atIso ?? nowUtcIso();
const correctie = new Map();
for (const anker of latestAnchors(peil, { ownOnly })) {
const diff = valuationDiff(anker);
if (diff) correctie.set(anker.assetId, (correctie.get(anker.assetId) || 0) + diff);
}
const rijen = assetPositions(peil, { ownOnly });
if (correctie.size === 0) return rijen;
return rijen.map(r => (correctie.has(r.id) ? { ...r, net: r.net + correctie.get(r.id) } : r));
}
export function unexplainedChange(accountId, assetId, fromIso, untilIso) {
const stand = positionAt(accountId, assetId, untilIso) - positionAt(accountId, assetId, fromIso);
const geboekt = eventsNetByAccount(accountId, assetId, untilIso) - eventsNetByAccount(accountId, assetId, fromIso);
return stand - geboekt;
}
export function firstPositionDate() {
const gebeurtenis = firstTaxEventDate();
const anker = firstAnchorDate();
if (!gebeurtenis) return anker;
if (!anker) return gebeurtenis;
return gebeurtenis < anker ? gebeurtenis : anker;
}
export function unexplainedByPair(fromIso, untilIso) {
const uit = [];
for (const anker of latestAnchors(untilIso)) {
const units = unexplainedChange(anker.accountId, anker.assetId, fromIso, untilIso);
if (!units) continue;
let openingUnits = 0;
for (const v of anchorsBetween(anker.accountId, anker.assetId, fromIso, untilIso)) {
if (valuationReconcilable(v) && !v.gapAcceptedAt) continue;
openingUnits += valuationGap(v) || 0;
}
uit.push({
accountId: anker.accountId, assetId: anker.assetId, units, openingUnits,
hasLedger: eventsCountByAccount(anker.accountId, anker.assetId, untilIso) > 0,
});
}
return uit;
}
export function assetPositionsByAccount(assetId, atIso) {
const peil = atIso ?? nowUtcIso();
const accounts = new Set(eventAccountIdsByAsset(assetId));
for (const anker of latestAnchors(peil)) if (anker.assetId === assetId) accounts.add(anker.accountId);
const uit = [];
for (const accountId of accounts) {
const saldo = positionAt(accountId, assetId, peil);
if (saldo !== 0) uit.push({ accountId, saldo });
}
uit.sort((a, b) => b.saldo - a.saldo || a.accountId - b.accountId);
const totaal = positionsByAsset(peil).find(r => r.id === assetId);
const rest = (totaal ? totaal.net : 0) - uit.reduce((s, r) => s + r.saldo, 0);
if (rest !== 0) uit.push({ accountId: null, saldo: rest });
return uit;
}
export function accountPositions(accountId, atIso) {
const peil = atIso ?? nowUtcIso();
const assets = new Set(eventAssetIdsByAccount(accountId));
for (const anker of latestAnchors(peil)) if (anker.accountId === accountId) assets.add(anker.assetId);
const uit = [];
for (const assetId of [...assets].sort((a, b) => a - b)) {
const saldo = positionAt(accountId, assetId, peil);
if (saldo !== 0) uit.push({ assetId, saldo });
}
return uit;
}
export function accountHoldings(accountIds = [], atIso) {
const peil = atIso ?? nowUtcIso();
const uit = [];
for (const accountId of accountIds) {
for (const { assetId, saldo } of accountPositions(accountId, peil)) {
const anker = anchorAt(accountId, assetId, peil);
uit.push({ accountId, assetId, saldo,
anchor: anker ? { valuedOn: anker.valuedOn, rateAt: anker.rateAt, balance: anker.balance } : null });
}
}
return uit;
}
function measuredNodeBalance(row) {
return accountOnchainBalance(row.accountId) + (Number(row.sats) || 0);
}
export function lightningBalanceDiffs() {
const asset = onchainAsset();
if (!asset) return [];
const uit = [];
for (const row of lightningBalances()) {
const measured = measuredNodeBalance(row);
const ledger = positionAt(row.accountId, asset.id, row.scannedAt);
if (measured !== ledger) {
uit.push({ accountId: row.accountId, account_name: row.account_name, assetId: asset.id,
measured, ledger, gap: measured - ledger, scannedAt: row.scannedAt });
}
}
return uit;
}
export async function recordLightningBalance(accountId, { valuedOn, fiatRate } = {}) {
const row = lightningBalances().find(r => r.accountId === Number(accountId));
const asset = onchainAsset();
if (!row || !asset) throw new Error(`Account ${accountId} has no updated lightning source.`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(valuedOn))) throw new Error(`A balance needs its day: '${valuedOn}'.`);
if (!(Number(fiatRate) > 0)) throw new Error(`A balance needs a rate above zero: '${fiatRate}'.`);
const v = { assetId: asset.id, accountId: row.accountId, valuedOn, balance: measuredNodeBalance(row),
fiatRate: Number(fiatRate), rateAt: row.scannedAt, source: 'onchain' };
const zelfdeDag = listValuations().find(x => x.accountId === v.accountId && x.assetId === v.assetId && x.valuedOn === valuedOn);
if (zelfdeDag && zelfdeDag.source !== 'onchain') {
fail('valuations.lightningSameDay', { date: valuedOn },
`There is already a balance on ${valuedOn} for this account; it is not overwritten.`);
}
if (zelfdeDag) { await updateValuation(zelfdeDag.id, v); return zelfdeDag.id; }
return createValuation(v);
}
+74
View File
@@ -0,0 +1,74 @@
// Generated, do not edit. sha256:816fe4fc80fac9d9927e815f090b57c44b134b1e4cc673a3ba8e1be8e308842d
import * as db from '../db.js';
import { PREFERENCES } from '../../shared/config.js';
import { assertEnum } from './_shared.js';
import { getBaseCurrency } from './portfolio.js';
import { fail } from '../../shared/app-error.js';
import { installLocale, setInstallLocale } from '../installation.mjs';
function prefSpec(scope, key) {
const s = PREFERENCES[scope];
if (!s) return undefined;
return s[key] || s['*'];
}
export function getPref(scope, key, fallback = undefined) {
const spec = prefSpec(scope, key);
const row = db.one(`SELECT value FROM Preference WHERE scope = ? AND key = ?`, [scope, key]);
if (row) {
const v = JSON.parse(row.value);
if (spec && spec.type === 'enum' && Array.isArray(spec.allowed) && !spec.allowed.includes(v)) return spec.initial;
return v;
}
return spec ? spec.initial : fallback;
}
export async function setPref(scope, key, value) {
const spec = prefSpec(scope, key);
if (spec) {
if (spec.type === 'enum') assertEnum(`${scope}.${key}`, spec.allowed, value);
else if (spec.type === 'bool') {
value = Number(value);
if (value !== 0 && value !== 1) fail('settings.prefBadValue', { scope, key, waarde: value }, `Invalid value for ${scope}.${key}: ${value}.`);
}
else if (spec.type === 'int') {
value = Number(value);
if (!Number.isInteger(value)) fail('settings.prefWantsInteger', { scope, key, waarde: value }, `Invalid value for ${scope}.${key}: ${value} (an integer was expected).`);
if (spec.min != null && value < spec.min) fail('settings.prefBelowMin', { scope, key, min: spec.min }, `${scope}.${key} may not be smaller than ${spec.min}.`);
if (spec.max != null && value > spec.max) fail('settings.prefAboveMax', { scope, key, max: spec.max }, `${scope}.${key} may not be greater than ${spec.max}.`);
}
}
db.run(`INSERT INTO Preference (scope, key, value) VALUES (?,?,?)
ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value`,
[scope, key, JSON.stringify(value)]);
await db.persist();
}
export function getSettings() {
const out = {};
for (const [key] of Object.entries(PREFERENCES.app)) {
out[key] = getPref('app', key);
}
out.locale = installLocale();
out.version = db.schemaVersion();
out.baseCurrency = getBaseCurrency();
return out;
}
export async function updateSettings(patch) {
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) continue;
if (key === 'locale') { setInstallLocale(value); continue; }
if (!PREFERENCES.app[key]) fail('settings.unknownSetting', { key }, `Unknown setting: ${key}.`);
await setPref('app', key, value);
}
}
export function forgetPrefRow(scope, key) {
db.run(`DELETE FROM Preference WHERE scope = ? AND key = ?`, [scope, key]);
}
export async function deletePref(scope, key) {
forgetPrefRow(scope, key);
await db.persist();
}
+296
View File
@@ -0,0 +1,296 @@
// Generated, do not edit. sha256:7aabe3b0d2c7bf04e76cf1fb822d83923d1b0287d69de575f4f58ac8cbd62381
import * as db from '../db.js';
import { ENUMS, CONFIG } from '../../shared/config.js';
import { assertEnum, addDaysIso, daysBetween } from './_shared.js';
import { fail } from '../../shared/app-error.js';
import { isMetal, metalFactor } from '../../shared/units.js';
function providerIdVoor(key) {
const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [key]);
if (!p) throw new Error(`Unknown price source '${key}': there is no provider with that key.`);
return p.id;
}
const positief = v => (v != null && Number(v) > 0 ? v : null);
export async function upsertPriceHistory(assetId, points, source, resolution) {
assertEnum('resolution', ENUMS.PRICE_RESOLUTION, resolution);
const providerId = providerIdVoor(source);
const now = new Date().toISOString();
for (const punt of points) {
const prijs = positief(punt.price);
if (prijs == null) continue;
const iso = new Date(punt.time * 1000).toISOString();
const observedAt = resolution === 'day' ? `${iso.slice(0, 10)}T00:00:00Z` : iso;
const sourceName = typeof punt.sourceName === 'string' && punt.sourceName ? punt.sourceName : null;
db.run(`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, createdAt, updatedAt, sourceName)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(assetId, providerId, observedAt, resolution) DO UPDATE SET
fiatPrice = excluded.fiatPrice, updatedAt = excluded.updatedAt, sourceName = excluded.sourceName`,
[assetId, observedAt, resolution, prijs, providerId, now, now, sourceName]);
}
await db.persist();
}
export async function addManualPricePoint(payload = {}) {
const { assetId, observedAt, fiatPrice, resolution = 'day', unit = null } = payload;
assertEnum('resolution', ENUMS.PRICE_RESOLUTION, resolution);
const asset = db.one(`SELECT id, kind, unit, subunit, displayUnit FROM Asset WHERE id = ?`, [assetId]);
if (!asset) {
fail('priceHistory.manualUnknownAsset', {}, `Asset ${assetId} does not exist.`);
}
const ms = Date.parse(observedAt);
if (!Number.isFinite(ms)) {
fail('priceHistory.manualBadMoment', {}, `'${observedAt}' is not a valid moment.`);
}
if (ms > Date.now()) {
fail('priceHistory.manualFutureMoment', {}, `'${observedAt}' lies in the future.`);
}
const koers = Number(fiatPrice);
if (!Number.isFinite(koers) || koers <= 0) {
fail('priceHistory.manualBadPrice', {}, `A price must be a number above zero, got '${fiatPrice}'.`);
}
const eenheid = unit || asset.unit;
const naarBasis = p => (isMetal(asset) ? p / metalFactor(eenheid) : p);
await upsertPriceHistory(assetId, [{ time: Math.floor(ms / 1000), price: naarBasis(koers) }], 'manual', resolution);
const observed = resolution === 'day'
? `${new Date(ms).toISOString().slice(0, 10)}T00:00:00Z`
: new Date(ms).toISOString();
const rij = db.one(
`SELECT id FROM PriceHistory
WHERE assetId = ? AND providerId = ? AND observedAt = ? AND resolution = ?`,
[assetId, providerIdVoor('manual'), observed, resolution]);
return rij ? rij.id : null;
}
const DEKKING = `AND EXISTS (
SELECT 1 FROM Provider
WHERE Provider.id = PriceHistory.providerId
AND (Provider.isSystem = 1
OR EXISTS (SELECT 1 FROM ProviderCoverage
JOIN Asset ON Asset.id = PriceHistory.assetId
WHERE ProviderCoverage.providerId = Provider.id
AND ProviderCoverage.symbol = Asset.symbol)))`;
export function listPriceHistory(assetId, fromDate) {
return fromDate
? db.all(`SELECT * FROM PriceHistory WHERE assetId = ? AND observedAt >= ? ${DEKKING} ORDER BY observedAt DESC`, [assetId, `${fromDate}T00:00:00Z`])
: db.all(`SELECT * FROM PriceHistory WHERE assetId = ? ${DEKKING} ORDER BY observedAt DESC`, [assetId]);
}
export function latestPricePoint(assetId) {
return db.one(`SELECT * FROM PriceHistory WHERE assetId = ? ${DEKKING} ORDER BY observedAt DESC, id DESC LIMIT 1`,
[assetId]) || null;
}
export function latestSpotPoint(assetId, providerKey) {
const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [providerKey]);
if (!p) return null;
return db.one(`SELECT * FROM PriceHistory
WHERE assetId = ? AND providerId = ? AND resolution = 'timestamp' AND fiatPrice > 0
ORDER BY observedAt DESC, id DESC LIMIT 1`, [assetId, p.id]) || null;
}
export function priceSeriesExport() {
return db.all(
`SELECT Asset.kind AS kind, Asset.symbol AS symbol, Provider.key AS source, PriceHistory.resolution AS resolution,
PriceHistory.observedAt AS observedAt, PriceHistory.createdAt AS createdAt, PriceHistory.fiatPrice AS fiatPrice
FROM PriceHistory
JOIN Asset ON Asset.id = PriceHistory.assetId
JOIN Provider ON Provider.id = PriceHistory.providerId
WHERE Provider.key NOT IN ('manual', 'atreides_services', 'fixed') ${DEKKING}
ORDER BY Asset.kind, Asset.symbol, Provider.key, PriceHistory.resolution, PriceHistory.observedAt`);
}
export function priceHistoryCount(assetId) {
return db.one(`SELECT COUNT(*) AS n FROM PriceHistory WHERE assetId = ?`, [assetId]).n;
}
function laatsteSpotVanDag(assetId, dag, preferSource = null) {
const cands = db.all(`SELECT * FROM PriceHistory
WHERE assetId = ? AND resolution = 'timestamp' AND substr(observedAt, 1, 10) = ?
AND fiatPrice > 0 ${DEKKING}`, [assetId, dag]);
const r = pickPreferredRow(cands, preferSource, (a, b) => (a.observedAt < b.observedAt ? 1 : a.observedAt > b.observedAt ? -1 : 0));
return r ? { ...r, derivedFrom: 'spot' } : null;
}
export function getPricePoint(assetId, onDate, { derived = true } = {}) {
const dagrij = db.one(`SELECT * FROM PriceHistory
WHERE assetId = ? AND resolution = 'day' AND observedAt = ? ${DEKKING}
ORDER BY id DESC LIMIT 1`, [assetId, `${onDate}T00:00:00Z`]) || null;
if (dagrij || !derived) return dagrij;
return laatsteSpotVanDag(assetId, onDate);
}
function pickPreferredRow(cands, preferSource, cmp) {
if (!cands.length) return null;
const sorted = [...cands].sort(cmp);
if (preferSource) {
const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [preferSource]);
const pref = p ? sorted.filter(r => r.providerId === p.id) : [];
if (pref.length) return pref[0];
}
return sorted[0];
}
export function getPriceAt(assetId, tsUnix, preferSource = null) {
const iso = new Date(tsUnix * 1000).toISOString();
const dayKey = iso.slice(0, 10);
const maxSec = CONFIG.PRICE_HISTORY.NEAREST_MAX_SECONDS;
const tsCand = [
db.one(`SELECT * FROM PriceHistory WHERE assetId = ? AND resolution = 'timestamp' AND observedAt <= ? ${DEKKING} ORDER BY observedAt DESC LIMIT 1`, [assetId, iso]),
db.one(`SELECT * FROM PriceHistory WHERE assetId = ? AND resolution = 'timestamp' AND observedAt >= ? ${DEKKING} ORDER BY observedAt ASC LIMIT 1`, [assetId, iso]),
].filter(Boolean)
.map(r => ({ ...r, dist_seconds: Math.abs(Date.parse(r.observedAt) / 1000 - tsUnix) }))
.filter(r => r.dist_seconds <= maxSec);
const ts = pickPreferredRow(tsCand, preferSource, (a, b) => a.dist_seconds - b.dist_seconds);
if (ts) return ts;
const dayCand = db.all(`SELECT * FROM PriceHistory WHERE assetId = ? AND resolution = 'day' AND substr(observedAt, 1, 10) = ? ${DEKKING}`, [assetId, dayKey])
.map(r => ({ ...r, dist_seconds: null }));
return pickPreferredRow(dayCand, preferSource, (a, b) => b.id - a.id) || laatsteSpotVanDag(assetId, dayKey, preferSource);
}
export function listPriceGaps({ today = new Date().toISOString().slice(0, 10), head = true, tail = true,
limit = CONFIG.HYGIENE.MAX_ROWS_PER_FINDING } = {}) {
const grens = CONFIG.HYGIENE.PRICE_GAP_DAYS;
const naGisteren = today;
const assets = db.all(`SELECT Asset.id, Asset.symbol, substr(MIN(Event.occurredAt), 1, 10) AS start
FROM Asset
JOIN Event ON Event.assetId = Asset.id AND Event.type <> 'reservation'
WHERE Asset.kind <> 'fiat'
GROUP BY Asset.id, Asset.symbol
ORDER BY Asset.symbol`);
const rows = [];
for (const a of assets) {
if (a.start >= today) continue;
const dagen = db.all(`SELECT DISTINCT substr(observedAt, 1, 10) AS dag FROM PriceHistory
WHERE assetId = ? AND observedAt >= ? AND fiatPrice > 0 ${DEKKING}
ORDER BY dag`, [a.id, `${a.start}T00:00:00Z`]).map(r => r.dag);
const genegeerd = db.all(`SELECT fromDay, untilDay FROM IgnoredPriceGap WHERE assetId = ? ORDER BY fromDay`, [a.id]);
let vorige = addDaysIso(a.start, -1);
if (!head) {
const ervoor = db.one(`SELECT 1 AS x FROM PriceHistory WHERE assetId = ? AND observedAt < ? AND fiatPrice > 0 ${DEKKING}`,
[a.id, `${a.start}T00:00:00Z`]);
if (!ervoor) { if (!dagen.length) continue; vorige = dagen[0]; }
}
for (const dag of [...dagen.filter(d => d < naGisteren), ...(tail ? [naGisteren] : [])]) {
if (daysBetween(vorige, dag) > grens) {
for (const stuk of zonderGenegeerd(addDaysIso(vorige, 1), addDaysIso(dag, -1), genegeerd)) {
const lengte = daysBetween(stuk.from, stuk.until) + 1;
if (lengte > grens - 1) rows.push({ assetId: a.id, symbol: a.symbol, from: stuk.from, until: stuk.until, days: lengte });
}
}
vorige = dag;
}
}
return { total: rows.length, rows: limit == null ? rows : rows.slice(0, limit) };
}
function zonderGenegeerd(from, until, ranges) {
let stukken = [{ from, until }];
for (const r of ranges) {
stukken = stukken.flatMap(s => {
if (r.untilDay < s.from || r.fromDay > s.until) return [s];
const rest = [];
if (r.fromDay > s.from) rest.push({ from: s.from, until: addDaysIso(r.fromDay, -1) });
if (r.untilDay < s.until) rest.push({ from: addDaysIso(r.untilDay, 1), until: s.until });
return rest;
});
}
return stukken;
}
const DAG = /^\d{4}-\d{2}-\d{2}$/;
export async function ignorePriceGap({ assetId, from, until } = {}) {
if (!db.one(`SELECT id FROM Asset WHERE id = ?`, [assetId])) throw new Error(`Asset ${assetId} does not exist.`);
if (!DAG.test(String(from)) || !DAG.test(String(until)) || from > until) {
throw new Error(`'${from}' to '${until}' is not a range of days.`);
}
db.run(`INSERT INTO IgnoredPriceGap (assetId, fromDay, untilDay, createdAt) VALUES (?, ?, ?, ?)`,
[assetId, from, until, new Date().toISOString()]);
const id = db.lastId();
await db.persist();
return id;
}
export function listIgnoredPriceGaps() {
return db.all(`SELECT IgnoredPriceGap.*, Asset.symbol FROM IgnoredPriceGap
JOIN Asset ON Asset.id = IgnoredPriceGap.assetId
ORDER BY Asset.symbol, IgnoredPriceGap.fromDay`);
}
export async function unignorePriceGap(id) {
db.run(`DELETE FROM IgnoredPriceGap WHERE id = ?`, [id]);
await db.persist();
}
export async function recordNoPriceDays(assetId, source, days) {
if (!db.one(`SELECT id FROM Asset WHERE id = ?`, [assetId])) throw new Error(`Asset ${assetId} does not exist.`);
const providerId = providerIdVoor(source);
if (!Array.isArray(days) || days.some(d => !DAG.test(String(d)))) throw new Error(`Not a list of days: ${JSON.stringify(days)}.`);
const now = new Date().toISOString();
for (const day of days) {
db.run(`INSERT OR IGNORE INTO PriceAbsence (assetId, providerId, day, createdAt) VALUES (?, ?, ?, ?)`,
[assetId, providerId, day, now]);
}
await db.persist();
}
export function listPriceAbsences() {
const rijen = db.all(`SELECT PriceAbsence.assetId, PriceAbsence.providerId, Provider.name AS providerName, PriceAbsence.day
FROM PriceAbsence JOIN Provider ON Provider.id = PriceAbsence.providerId
ORDER BY PriceAbsence.assetId, Provider.name, PriceAbsence.day`);
const uit = [];
for (const r of rijen) {
const vorige = uit[uit.length - 1];
if (vorige && vorige.assetId === r.assetId && vorige.providerId === r.providerId && addDaysIso(vorige.untilDay, 1) === r.day) {
vorige.untilDay = r.day;
vorige.days++;
} else {
uit.push({ assetId: r.assetId, providerId: r.providerId, providerName: r.providerName, fromDay: r.day, untilDay: r.day, days: 1 });
}
}
return uit;
}
export async function deletePriceAbsences({ assetId, providerId, from, until } = {}) {
if (!Number.isInteger(assetId) || !Number.isInteger(providerId)) throw new Error(`An asset id and a provider id are required.`);
if (!DAG.test(String(from)) || !DAG.test(String(until)) || from > until) throw new Error(`'${from}' to '${until}' is not a range of days.`);
db.run(`DELETE FROM PriceAbsence WHERE assetId = ? AND providerId = ? AND day BETWEEN ? AND ?`, [assetId, providerId, from, until]);
await db.persist();
}
export function listNoPriceDays(assetId, fromDay = '0000-01-01') {
return db.all(`SELECT providerId, day FROM PriceAbsence WHERE assetId = ? AND day >= ? ORDER BY day, providerId`, [assetId, fromDay]);
}
export function listAllPriceHistory(filter = {}) {
const where = ['1=1'], args = [];
if (filter.assetId) { where.push('ph.assetId = ?'); args.push(filter.assetId); }
if (filter.fromDate) { where.push('ph.observedAt >= ?'); args.push(`${filter.fromDate}T00:00:00Z`); }
return db.all(`SELECT ph.*, a.symbol AS asset_symbol,
p.key AS provider_key, p.name AS provider_name
FROM PriceHistory ph
JOIN Asset a ON a.id = ph.assetId
JOIN Provider p ON p.id = ph.providerId
WHERE ${where.join(' AND ')}
ORDER BY ph.observedAt DESC, ph.id DESC`, args);
}
export async function recordCurrentSpot(assetId, source, spot) {
if (!spot || !Number.isFinite(spot.price)) return;
const today = new Date().toISOString().slice(0, 10);
const existing = db.one(
`SELECT id FROM PriceHistory
WHERE assetId = ? AND providerId = ? AND resolution = 'timestamp' AND substr(observedAt, 1, 10) = ?
ORDER BY observedAt DESC, id DESC LIMIT 1`,
[assetId, providerIdVoor(source), today]);
if (existing) {
db.run(`UPDATE PriceHistory SET observedAt = ?, fiatPrice = ?, updatedAt = ? WHERE id = ?`,
[new Date().toISOString(), spot.price, new Date().toISOString(), existing.id]);
await db.persist();
return;
}
await upsertPriceHistory(assetId, [{ time: Math.floor(Date.now() / 1000), price: spot.price }], source, 'timestamp');
}
+99
View File
@@ -0,0 +1,99 @@
// Generated, do not edit. sha256:db61aab12929b00d557fdd117e7a5efb89fd1b2bb3eb23c37364a1d19f493bbc
import * as db from '../db.js';
import { SOURCE_CAPABILITIES, SOURCE_ENDPOINTS } from '../../shared/config.js';
import { assertEnum } from './_shared.js';
import { hasFeature } from '../license.mjs';
import { fail } from '../../shared/app-error.js';
import { refreshNotifications } from './notifications.js';
export function priceSourceChain(assetId, capability) {
return db.all(
`SELECT Provider.key AS key
FROM PriceSource
JOIN Provider ON Provider.id = PriceSource.providerId
WHERE PriceSource.assetId = ? AND PriceSource.capability = ? AND PriceSource.rank IS NOT NULL
ORDER BY PriceSource.rank`,
[assetId, capability]
).map(r => r.key);
}
export function ownPriceSourceChain(assetId, capability) {
return db.all(
`SELECT Provider.key AS key
FROM PriceSource
JOIN Provider ON Provider.id = PriceSource.providerId
WHERE PriceSource.assetId = ? AND PriceSource.capability = ? AND PriceSource.rank IS NOT NULL
AND PriceSource.status = 'manual'
ORDER BY PriceSource.rank`,
[assetId, capability]
).map(r => r.key);
}
export function listPriceSource() {
return db.all(`SELECT assetId, capability, providerId, rank, status FROM PriceSource`);
}
function assertMatrixAllowed() {
if (hasFeature('sourceMatrix')) return;
fail('integrations.matrixPaid', null,
'The source matrix belongs to the paid version. The app keeps using the standard sources.');
}
function assertAnswers(providerId, capability) {
const eindpunten = SOURCE_ENDPOINTS[capability];
if (!db.one(`SELECT 1 AS x FROM ProviderEndpoint WHERE providerId = ? AND capability IN (${eindpunten.map(() => '?').join(',')})`,
[providerId, ...eindpunten]))
throw new Error('This provider does not cover this capability.');
}
export async function setPriceSourceRank(assetId, capability, providerId) {
assertMatrixAllowed();
assertEnum('capability', SOURCE_CAPABILITIES, capability);
assertAnswers(providerId, capability);
const used = db.all(`SELECT DISTINCT PriceSource.rank FROM PriceSource
JOIN Asset ON Asset.id = PriceSource.assetId
JOIN ProviderCoverage ON ProviderCoverage.providerId = PriceSource.providerId
AND ProviderCoverage.symbol = Asset.symbol
WHERE PriceSource.assetId = ? AND PriceSource.capability = ?
AND PriceSource.rank IS NOT NULL AND PriceSource.status IN ('confirmed','manual')`,
[assetId, capability]).map(r => r.rank);
let next = 1;
while (used.includes(next)) next += 1;
db.run(`DELETE FROM PriceSource WHERE assetId = ? AND capability = ? AND (providerId = ? OR rank = ?)`,
[assetId, capability, providerId, next]);
db.run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status, createdAt) VALUES (?,?,?,?,'manual',?)`,
[assetId, capability, providerId, next, new Date().toISOString()]);
await db.persist();
return next;
}
export async function placePriceSourceFirst(assetId, capability, providerId) {
assertMatrixAllowed();
assertEnum('capability', SOURCE_CAPABILITIES, capability);
assertAnswers(providerId, capability);
const rest = db.all(`SELECT providerId, status, createdAt FROM PriceSource
WHERE assetId = ? AND capability = ? AND rank IS NOT NULL AND providerId <> ?
ORDER BY rank`, [assetId, capability, providerId]);
db.run(`DELETE FROM PriceSource WHERE assetId = ? AND capability = ? AND (rank IS NOT NULL OR providerId = ?)`,
[assetId, capability, providerId]);
db.run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status, createdAt) VALUES (?,?,?,1,'manual',?)`,
[assetId, capability, providerId, new Date().toISOString()]);
rest.forEach((r, i) => db.run(
`INSERT INTO PriceSource (assetId, capability, providerId, rank, status, createdAt) VALUES (?,?,?,?,?,?)`,
[assetId, capability, r.providerId, i + 2, r.status, r.createdAt]));
await db.persist();
}
export async function clearPriceSourceCell(assetId, capability, providerId) {
assertMatrixAllowed();
db.run(`DELETE FROM PriceSource WHERE assetId = ? AND capability = ? AND providerId = ?`,
[assetId, capability, providerId]);
await db.persist();
}
export async function confirmPriceSourceCell(assetId, capability, providerId) {
assertMatrixAllowed();
db.run(`UPDATE PriceSource SET status = 'confirmed'
WHERE assetId = ? AND capability = ? AND providerId = ? AND status = 'proposed'`,
[assetId, capability, providerId]);
await db.persist();
await refreshNotifications();
}
+224
View File
@@ -0,0 +1,224 @@
// Generated, do not edit. sha256:b635cddd04e7b73f066847eaf0cbf8dfca213ceaa0e4c52ee3c3820f5df5cd3a
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
import { fail } from '../../shared/app-error.js';
import { createEvent, deleteEvent } from './events.js';
import { linkProposalTxSoft } from './tx-links.js';
import { memoNote } from '../../shared/zap.js';
const MEMO_OF = `COALESCE(w.memo, (SELECT twin.memo FROM WalletTransaction twin
WHERE w.layer = 'lightning' AND twin.layer = 'lightning' AND twin.accountId = w.accountId
AND twin.txid = w.txid AND twin.id <> w.id AND twin.memo IS NOT NULL
ORDER BY twin.id LIMIT 1))`;
const NODE_ACCOUNT_OF = `(SELECT node.accountId FROM WalletSource onchain
JOIN WalletSource node ON node.connectionId = onchain.connectionId AND node.kind = 'lnd'
WHERE onchain.accountId = w.accountId AND onchain.kind IN ('xpub','address')
AND node.accountId <> w.accountId
ORDER BY node.id LIMIT 1)`;
function bookingNote(p, prefix = 'reconcile tx', zonderHash = 'reconcile') {
const memo = p.walletTransactionId
? db.one(`SELECT ${MEMO_OF} AS memo FROM WalletTransaction w WHERE w.id = ?`, [p.walletTransactionId])?.memo : null;
const basis = p.transactionHash ? `${prefix} ${p.transactionHash}` : zonderHash;
const context = memoNote(memo, p.direction);
return context ? `${context} · ${basis}` : basis;
}
export function listPendingProposals() {
return db.all(`
SELECT p.*, ac.name AS account_name, a.symbol AS asset_symbol, a.name AS asset_name,
a.decimals AS asset_decimals, a.unit AS asset_unit, a.subunit AS asset_subunit,
w.blockTime AS tx_block_time, w.layer AS tx_layer, w.kind AS tx_kind, w.feeSats AS tx_fee_sats,
${MEMO_OF} AS tx_memo,
CASE WHEN w.kind = 'channel' THEN (SELECT name FROM Account WHERE id = ${NODE_ACCOUNT_OF}) END AS tx_node_account,
-- The NWC wallet the payment went through (user, 19-10-2026): its row with the same hash.
(SELECT COALESCE(s.name, '') FROM WalletTransaction twin
JOIN WalletSource s ON s.id = twin.sourceId
WHERE w.layer = 'lightning' AND twin.layer = 'lightning' AND s.kind = 'nwc'
AND twin.accountId = w.accountId AND twin.txid = w.txid AND twin.id <> w.id
ORDER BY twin.id LIMIT 1) AS via_wallet
FROM ReconcileProposal p
JOIN Account ac ON ac.id = p.accountId
JOIN Asset a ON a.id = p.assetId
LEFT JOIN WalletTransaction w ON w.id = p.walletTransactionId
WHERE p.status = 'pending'
AND NOT EXISTS (SELECT 1 FROM WalletTransactionEventLink l WHERE l.walletTransactionId = p.walletTransactionId)
ORDER BY COALESCE(w.blockTime, p.detectedAt) DESC, p.id DESC`);
}
export async function syncTxProposals(accountId, assetId) {
db.run(`DELETE FROM ReconcileProposal WHERE status='pending' AND accountId=?1 AND assetId=?2`, [accountId, assetId]);
db.run(`INSERT INTO ReconcileProposal
(accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, resolvedEventId, detectedAt)
SELECT w.accountId, ?2, w.id, w.txid, w.valueSats, w.direction,
CASE WHEN ac.isOwnCustody THEN 'transfer' ELSE 'trade' END, 'pending', NULL, ?3
FROM WalletTransaction w
JOIN Account ac ON ac.id = w.accountId
WHERE w.accountId = ?1
-- LIGHTNING ONLY FROM THE NODE: an NWC wallet is a grouping
-- on top of LND, so its payment is booked once, from LND, and the NWC row takes the event
-- over by payment hash. On an account WITHOUT a LND source the NWC wallet counts on its own,
-- as in its total on Tracking. The same rule stands in hygiene.js.
AND (w.layer = 'onchain'
OR EXISTS (SELECT 1 FROM WalletSource s
WHERE s.id = w.sourceId
AND (s.kind = 'lnd'
OR (s.kind = 'nwc' AND NOT EXISTS (
SELECT 1 FROM WalletSource node
WHERE node.accountId = w.accountId AND node.kind = 'lnd')))))
AND NOT EXISTS (SELECT 1 FROM WalletTransactionEventLink l WHERE l.walletTransactionId = w.id)
AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p
WHERE p.status = 'ignored'
AND ( p.walletTransactionId = w.id
-- The amount match only for an ignored proposal WITHOUT a movement
-- (a gap proposal). One that hangs on a movement means "this one",
-- and matching it on amount suppressed every other movement of the
-- same size: one ignored 21-sat zap hid every 21-sat zap.
OR (p.walletTransactionId IS NULL
AND p.accountId = w.accountId AND p.assetId = ?2
AND p.direction = w.direction AND p.amount = w.valueSats) ))`,
[accountId, assetId, nowIso()]);
const n = db.one(`SELECT changes() AS n`).n;
await db.persist();
return n;
}
function getProposal(id) {
const p = db.one(`SELECT * FROM ReconcileProposal WHERE id = ?`, [id]);
if (!p) fail('home.recUnknownProposal', { id }, `Proposal ${id} no longer exists.`);
if (p.status !== 'pending') {
fail(`home.recHandled_${p.status}`, { id }, `Proposal ${id} has already been handled (${p.status}).`);
}
return p;
}
export async function resolveProposalAsTrade(id, { fiatPrice, fiatFee, occurredAt, priceOrigin }) {
const p = getProposal(id);
if (fiatPrice === undefined || fiatPrice === null) throw new Error('fiatPrice is required for a trade.');
const wtx = p.walletTransactionId ? db.one(`SELECT blockTime, feeSats FROM WalletTransaction WHERE id=?`, [p.walletTransactionId]) : null;
const blockTime = wtx && wtx.blockTime ? wtx.blockTime : null;
const type = p.direction === 'in' ? 'buy' : 'sell';
const txFee = type === 'sell' && wtx && wtx.feeSats ? wtx.feeSats : null;
const eventId = await createEvent({
assetId: p.assetId, type, occurredAt: occurredAt ?? blockTime ?? p.detectedAt,
amount: p.amount, fiatPrice, fiatFee: fiatFee ?? null, unitFee: txFee,
priceOrigin: priceOrigin ?? null,
fromAccountId: type === 'sell' ? p.accountId : null,
toAccountId: type === 'buy' ? p.accountId : null,
source: 'reconcile', note: bookingNote(p),
});
if (p.walletTransactionId) linkProposalTxSoft(p.walletTransactionId, eventId);
db.run(`UPDATE ReconcileProposal SET status='accepted_trade', resolvedEventId=? WHERE id=?`, [eventId, id]);
await db.persist();
return eventId;
}
export async function resolveProposalAsTransfer(id, { other_account_id, unitFee, occurredAt }) {
const p = getProposal(id);
if (!other_account_id) {
fail('home.recNoCounter', null, 'A counter-account is required for a transfer.');
}
const wtx = p.walletTransactionId ? db.one(`SELECT blockTime, feeSats FROM WalletTransaction WHERE id=?`, [p.walletTransactionId]) : null;
const blockTime = wtx && wtx.blockTime ? wtx.blockTime : null;
const txFee = p.direction === 'out' && wtx && wtx.feeSats != null ? wtx.feeSats : null;
const fromAccountId = p.direction === 'out' ? p.accountId : other_account_id;
const toAccountId = p.direction === 'out' ? other_account_id : p.accountId;
const eventId = await createEvent({
assetId: p.assetId, type: 'transfer', occurredAt: occurredAt ?? blockTime ?? p.detectedAt,
amount: p.amount, fiatPrice: null, fiatFee: null, unitFee: unitFee ?? txFee,
fromAccountId, toAccountId, source: 'reconcile',
note: bookingNote(p),
});
if (p.walletTransactionId) linkProposalTxSoft(p.walletTransactionId, eventId);
db.run(`UPDATE ReconcileProposal SET status='accepted_transfer', resolvedEventId=? WHERE id=?`, [eventId, id]);
await db.persist();
return eventId;
}
export async function resolveProposalAsInternal(id) {
const p = getProposal(id);
const wtx = p.walletTransactionId
? db.one(`SELECT w.blockTime, w.feeSats, w.kind, ${NODE_ACCOUNT_OF} AS nodeAccountId
FROM WalletTransaction w WHERE w.id = ?`, [p.walletTransactionId]) : null;
if (!wtx || wtx.kind == null) {
fail('home.recNotInternal', { id }, `Proposal ${id} is not a movement inside the account.`);
}
const ander = wtx.kind === 'channel' && wtx.nodeAccountId ? wtx.nodeAccountId : p.accountId;
const eventId = await createEvent({
assetId: p.assetId, type: 'transfer', occurredAt: wtx.blockTime ?? p.detectedAt,
amount: p.amount, fiatPrice: null, fiatFee: null,
unitFee: p.direction === 'out' && wtx.feeSats ? wtx.feeSats : null,
fromAccountId: p.direction === 'out' ? p.accountId : ander,
toAccountId: p.direction === 'out' ? ander : p.accountId, source: 'reconcile',
note: bookingNote(p, `reconcile ${wtx.kind}`, `reconcile ${wtx.kind}`),
});
linkProposalTxSoft(p.walletTransactionId, eventId);
db.run(`UPDATE ReconcileProposal SET status='accepted_transfer', resolvedEventId=? WHERE id=?`, [eventId, id]);
await db.persist();
return eventId;
}
export async function resolveProposalAsInterest(id) {
const p = getProposal(id);
const wtx = p.walletTransactionId
? db.one(`SELECT blockTime, layer, txid FROM WalletTransaction WHERE id = ?`, [p.walletTransactionId]) : null;
if (!wtx || wtx.layer !== 'lightning' || !String(wtx.txid).startsWith('fwd:') || p.direction !== 'in') {
fail('home.recNotForward', { id }, `Proposal ${id} is not a lightning forward.`);
}
const eventId = await createEvent({
assetId: p.assetId, type: 'interest', occurredAt: wtx.blockTime ?? p.detectedAt,
amount: p.amount, fiatPrice: null, fiatFee: null, unitFee: null,
fromAccountId: null, toAccountId: p.accountId, source: 'reconcile', note: bookingNote(p, 'reconcile forward', 'reconcile forward'),
});
linkProposalTxSoft(p.walletTransactionId, eventId);
db.run(`UPDATE ReconcileProposal SET status='accepted_interest', resolvedEventId=? WHERE id=?`, [eventId, id]);
await db.persist();
return eventId;
}
export async function ignoreProposal(id) {
getProposal(id);
db.run(`UPDATE ReconcileProposal SET status='ignored' WHERE id=?`, [id]);
await db.persist();
}
export async function deleteProposal(id) {
db.run(`DELETE FROM ReconcileProposal WHERE id=?`, [id]);
await db.persist();
}
export async function ignoreAllPendingProposals() {
db.run(`UPDATE ReconcileProposal SET status='ignored' WHERE status='pending'`);
const n = db.one(`SELECT changes() AS n`).n;
if (n > 0) await db.persist();
return n;
}
export async function deleteAllPendingProposals() {
db.run(`DELETE FROM ReconcileProposal WHERE status = 'pending'`);
const n = db.one(`SELECT changes() AS n`).n;
if (n > 0) await db.persist();
return n;
}
export async function revertProposal(id) {
const p = db.one(`SELECT * FROM ReconcileProposal WHERE id = ?`, [id]);
if (!p) fail('home.recUnknownProposal', { id }, `Proposal ${id} no longer exists.`);
if (p.status === 'accepted_trade' || p.status === 'accepted_transfer' || p.status === 'accepted_interest') {
if (p.resolvedEventId) await deleteEvent(p.resolvedEventId);
db.run(`UPDATE ReconcileProposal SET status = 'pending', resolvedEventId = NULL WHERE id = ?`, [id]);
} else if (p.status === 'ignored') {
if (p.walletTransactionId) db.run(`DELETE FROM ReconcileProposal WHERE id = ?`, [id]);
else db.run(`UPDATE ReconcileProposal SET status = 'pending' WHERE id = ?`, [id]);
} else if (p.status === 'auto_linked') {
fail('home.recAutoLinked', { id }, `Proposal ${id} is auto-linked; unlink it through the match popover.`);
} else {
fail('home.recStillOpen', { id }, `Proposal ${id} (${p.status}) cannot be reverted.`);
}
await db.persist();
}
export function listHandledProposals(limit = 50) {
return db.all(`
SELECT p.*, ac.name AS account_name, a.symbol AS asset_symbol, a.name AS asset_name,
a.decimals AS asset_decimals, a.unit AS asset_unit, a.subunit AS asset_subunit,
e.type AS event_type, w.blockTime AS tx_block_time
FROM ReconcileProposal p
JOIN Account ac ON ac.id = p.accountId
JOIN Asset a ON a.id = p.assetId
LEFT JOIN Event e ON e.id = p.resolvedEventId
LEFT JOIN WalletTransaction w ON w.id = p.walletTransactionId
WHERE p.status != 'pending'
ORDER BY COALESCE(w.blockTime, p.detectedAt) DESC, p.id DESC
LIMIT ?`, [limit]);
}
+280
View File
@@ -0,0 +1,280 @@
// Generated, do not edit. sha256:1db816dc6af267a04206f23a388019cbe76689adb83e89843b65ed1a797537a8
import * as db from '../db.js';
import { toSearchName } from '../../shared/search-name.js';
import { AUTH_KINDS, CAPABILITIES, ENUMS, HTTP_METHODS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
import { emitChange } from './_events.js';
import { fail } from '../../shared/app-error.js';
export function emitProvidersChanged() {
emitChange('providers-changed');
}
export function listProviders() {
return db.all(`SELECT * FROM Provider ORDER BY isSeed DESC, name`);
}
export function getProvider(key) {
const p = db.one(`SELECT * FROM Provider WHERE key = ?`, [key]);
if (!p) return null;
return {
...p,
coverage: db.all(`SELECT symbol FROM ProviderCoverage WHERE providerId = ? ORDER BY symbol`, [p.id]).map(r => r.symbol),
hmac: db.one(`SELECT * FROM ProviderAuthenticationHmac WHERE providerId = ?`, [p.id]) || null,
fixedRate: db.one(`SELECT fiatPrice FROM ProviderFixedRate WHERE providerId = ?`, [p.id]) || null,
callsPerMonth: db.one(`SELECT callsPerMonth FROM ProviderCallLimit WHERE providerId = ?`, [p.id])?.callsPerMonth ?? null,
endpoints: db.all(`SELECT id, capability, method, pathTemplate, queryTemplate, quoteCurrency, minIntervalSeconds FROM ProviderEndpoint WHERE providerId = ?`, [p.id]),
symbols: db.all(`SELECT id, symbol, externalId, quoteUnit, isCatchAll FROM ProviderInputMap WHERE providerId = ?
ORDER BY isCatchAll, symbol`, [p.id]),
outputs: db.all(`SELECT id, capability, internalField, jsonPath, transform FROM ProviderOutputMap WHERE providerId = ?`, [p.id]),
};
}
export function providerCapabilityMap() {
const m = new Map();
for (const r of db.all(`SELECT providerId, capability FROM ProviderEndpoint`)) {
if (!m.has(r.providerId)) m.set(r.providerId, new Set());
m.get(r.providerId).add(r.capability);
}
return m;
}
function assertAuthKind(kind) {
if (!Object.keys(AUTH_KINDS).includes(kind))
throw new Error(`Invalid authenticationKind: '${kind}'. Allowed: ${Object.keys(AUTH_KINDS).join(', ')}.`);
}
function bit01(v, name) {
const n = Number(v);
if (n !== 0 && n !== 1) throw new Error(`Invalid value for ${name}: ${v} (0 or 1 expected).`);
return n;
}
function toCoverageList(v) {
const arr = Array.isArray(v) ? v : String(v ?? '').split(',');
return [...new Set(arr.map(s => String(s).trim()).filter(Boolean))];
}
function schrijfCoverage(providerId, symbolen) {
db.run(`DELETE FROM ProviderCoverage WHERE providerId = ?`, [providerId]);
for (const symbool of symbolen) {
db.run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?,?)`, [providerId, symbool]);
}
}
function keyFromName(name) {
const basis = String(name).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'integratie';
let key = basis;
for (let n = 2; db.one(`SELECT 1 AS x FROM Provider WHERE key = ?`, [key]); n++) key = `${basis}_${n}`;
return key;
}
export async function createProvider(cfg) {
if (!String(cfg.name || '').trim()) fail('integrations.nameRequired', null, 'A provider name is required.');
assertAuthKind(cfg.authenticationKind);
const key = String(cfg.key || '').trim() || keyFromName(cfg.name);
if (db.one(`SELECT 1 AS x FROM Provider WHERE key = ?`, [key])) {
fail('integrations.keyInUse', { key }, `Provider '${key}' already exists.`);
}
db.run(`INSERT INTO Provider
(key, name, searchName, baseUrl, authenticationHeaderName, authenticationParameterName,
authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,0,0,?)`,
[key, cfg.name.trim(), toSearchName(cfg.name), cfg.baseUrl ?? '',
cfg.authenticationHeaderName ?? null, cfg.authenticationParameterName ?? null,
cfg.authenticationKind, bit01(cfg.authenticationRequired, 'authenticationRequired'),
bit01(cfg.isActive ?? 1, 'isActive'), nowIso()]);
const pid = db.lastId();
schrijfCoverage(pid, toCoverageList(cfg.coverage));
await db.persist();
emitProvidersChanged();
return pid;
}
export async function updateProvider(key, cfg) {
const p = db.one(`SELECT * FROM Provider WHERE key = ?`, [key]);
if (!p) fail('integrations.unknownProvider', { key }, `Provider '${key}' does not exist.`);
assertAuthKind(cfg.authenticationKind);
db.run(`UPDATE Provider SET name=?, searchName=?, baseUrl=?, authenticationHeaderName=?,
authenticationParameterName=?, authenticationKind=?, authenticationRequired=?, isActive=? WHERE id=?`,
[cfg.name, toSearchName(cfg.name), cfg.baseUrl ?? '',
cfg.authenticationHeaderName ?? null, cfg.authenticationParameterName ?? null,
cfg.authenticationKind, bit01(cfg.authenticationRequired, 'authenticationRequired'),
bit01(cfg.isActive ?? p.isActive, 'isActive'), p.id]);
if (cfg.coverage !== undefined) schrijfCoverage(p.id, toCoverageList(cfg.coverage));
await db.persist();
emitProvidersChanged();
}
export async function setProviderActive(key, isActive) {
const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [key]);
if (!p) fail('integrations.unknownProvider', { key }, `Provider '${key}' does not exist.`);
db.run(`UPDATE Provider SET isActive = ? WHERE id = ?`, [bit01(isActive, 'isActive'), p.id]);
await db.persist();
emitProvidersChanged();
}
export async function setProviderCallLimit(key, callsPerMonth) {
const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [key]);
if (!p) fail('integrations.unknownProvider', { key }, `Provider '${key}' does not exist.`);
if (callsPerMonth == null || String(callsPerMonth).trim() === '') {
db.run(`DELETE FROM ProviderCallLimit WHERE providerId = ?`, [p.id]);
} else {
const n = Number(callsPerMonth);
if (!Number.isInteger(n) || n <= 0) {
fail('integrations.callLimitInvalid', { waarde: callsPerMonth }, `A monthly limit must be a whole number above zero, got '${callsPerMonth}'.`);
}
db.run(`INSERT INTO ProviderCallLimit (providerId, callsPerMonth, createdAt) VALUES (?, ?, ?)
ON CONFLICT(providerId) DO UPDATE SET callsPerMonth = excluded.callsPerMonth`,
[p.id, n, new Date().toISOString()]);
}
await db.persist();
}
export async function deleteProvider(key) {
const p = db.one(`SELECT id, isSystem FROM Provider WHERE key = ?`, [key]);
if (!p) fail('integrations.unknownProvider', { key }, `Provider '${key}' does not exist.`);
if (p.isSystem) throw new Error(`Provider '${key}' is a system row and cannot be deleted.`);
const used = db.one(`SELECT COUNT(*) AS n FROM PriceSource WHERE providerId = ?`, [p.id]).n;
if (used > 0) {
fail('integrations.providerInMatrix', { key, n: used },
`Provider '${key}' is still in the source matrix (${used} choice(s)).`);
}
const prijsrijen = db.one(`SELECT COUNT(*) AS n FROM PriceHistory WHERE providerId = ?`, [p.id]).n;
if (prijsrijen > 0) {
fail('integrations.providerHasPrices', { key, n: prijsrijen },
`Provider '${key}' still has ${prijsrijen} price row(s) in the history.`);
}
db.run(`DELETE FROM Provider WHERE id = ?`, [p.id]);
await db.persist();
emitProvidersChanged();
}
function assertProviderId(pid) {
if (!db.one(`SELECT 1 AS x FROM Provider WHERE id = ?`, [pid]))
throw new Error(`Provider ${pid} does not exist.`);
}
function reqStr(value, name) {
const s = String(value ?? '').trim();
if (!s) fail('integrations.fieldRequired', { veld: name }, `${name} is required.`);
return s;
}
function quoteCurrency(value) {
const s = String(value ?? '').trim().toUpperCase();
if (!s) return null;
if (!/^[A-Z]{3}$/.test(s)) fail('integrations.quoteCurrencyInvalid', { waarde: value }, `Quote currency '${value}' is not a three-letter code.`);
return s;
}
function minInterval(value) {
if (value == null || String(value).trim() === '') return null;
const n = Number(value);
if (!Number.isInteger(n) || n <= 0) fail('integrations.minIntervalInvalid', { waarde: value }, `Minimum interval '${value}' is not a whole number of seconds above zero.`);
return n;
}
export async function createProviderEndpoint(providerId, e) {
assertProviderId(providerId);
assertEnum('capability', CAPABILITIES, e.capability);
assertEnum('method', HTTP_METHODS, e.method); reqStr(e.pathTemplate, 'pathTemplate');
db.run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate, quoteCurrency, minIntervalSeconds, createdAt)
VALUES (?,?,?,?,?,?,?,?)`,
[providerId, e.capability, e.method, e.pathTemplate, e.queryTemplate ?? null, quoteCurrency(e.quoteCurrency),
minInterval(e.minIntervalSeconds), new Date().toISOString()]);
const id = db.lastId(); await db.persist(); return id;
}
export async function updateProviderEndpoint(id, e) {
assertEnum('capability', CAPABILITIES, e.capability);
assertEnum('method', HTTP_METHODS, e.method); reqStr(e.pathTemplate, 'pathTemplate');
if (!db.one(`SELECT 1 AS x FROM ProviderEndpoint WHERE id = ?`, [id])) fail('integrations.unknownEndpoint', { id }, `Endpoint ${id} does not exist.`);
db.run(`UPDATE ProviderEndpoint SET capability=?, method=?, pathTemplate=?, queryTemplate=? WHERE id=?`,
[e.capability, e.method, e.pathTemplate, e.queryTemplate ?? null, id]);
if (Object.prototype.hasOwnProperty.call(e, 'quoteCurrency')) {
db.run(`UPDATE ProviderEndpoint SET quoteCurrency = ? WHERE id = ?`, [quoteCurrency(e.quoteCurrency), id]);
}
if (Object.prototype.hasOwnProperty.call(e, 'minIntervalSeconds')) {
db.run(`UPDATE ProviderEndpoint SET minIntervalSeconds = ? WHERE id = ?`, [minInterval(e.minIntervalSeconds), id]);
}
await db.persist();
}
export async function deleteProviderEndpoint(id) {
db.run(`DELETE FROM ProviderEndpoint WHERE id = ?`, [id]);
await db.persist();
}
function quoteUnit(v) {
if (v == null || String(v).trim() === '') return null;
const u = String(v).trim();
if (!ENUMS.QUOTE_UNIT.includes(u)) fail('integrations.quoteUnitInvalid', { waarde: u }, `Unknown quote unit '${u}'.`);
return u;
}
function assertEenCatchAll(providerId, behalveId = null) {
const r = db.one(`SELECT id FROM ProviderInputMap WHERE providerId = ? AND isCatchAll = 1 AND id IS NOT ?`, [providerId, behalveId]);
if (r) fail('integrations.catchAllExists', null, 'This provider already has a catch-all row.');
}
export async function createProviderSymbol(providerId, s) {
assertProviderId(providerId);
const catchAll = !!s.isCatchAll;
if (catchAll) assertEenCatchAll(providerId); else reqStr(s.symbol, 'symbol');
reqStr(s.externalId, 'externalId');
db.run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, isCatchAll, createdAt) VALUES (?,?,?,?,?,?)`,
[providerId, catchAll ? null : s.symbol, s.externalId, quoteUnit(s.quoteUnit), catchAll ? 1 : 0, nowIso()]);
const id = db.lastId(); await db.persist(); return id;
}
export async function updateProviderSymbol(id, s) {
const rij = db.one(`SELECT providerId, isCatchAll FROM ProviderInputMap WHERE id = ?`, [id]);
if (!rij) fail('integrations.unknownSymbolMap', { id }, `Symbol map ${id} does not exist.`);
const catchAll = Object.hasOwn(s, 'isCatchAll') ? !!s.isCatchAll : !!rij.isCatchAll;
if (catchAll) assertEenCatchAll(rij.providerId, id); else reqStr(s.symbol, 'symbol');
reqStr(s.externalId, 'externalId');
db.run(`UPDATE ProviderInputMap SET symbol=?, externalId=?, isCatchAll=? WHERE id=?`,
[catchAll ? null : s.symbol, s.externalId, catchAll ? 1 : 0, id]);
if (Object.hasOwn(s, 'quoteUnit')) db.run(`UPDATE ProviderInputMap SET quoteUnit=? WHERE id=?`, [quoteUnit(s.quoteUnit), id]);
await db.persist();
}
export async function deleteProviderSymbol(id) {
db.run(`DELETE FROM ProviderInputMap WHERE id = ?`, [id]);
await db.persist();
}
export async function createProviderOutput(providerId, o) {
assertProviderId(providerId);
assertEnum('capability', CAPABILITIES, o.capability);
reqStr(o.internalField, 'internalField'); reqStr(o.jsonPath, 'jsonPath'); reqStr(o.transform, 'transform');
db.run(`INSERT INTO ProviderOutputMap (providerId, capability, internalField, jsonPath, transform) VALUES (?,?,?,?,?)`,
[providerId, o.capability, o.internalField, o.jsonPath, o.transform]);
const id = db.lastId(); await db.persist(); return id;
}
export async function updateProviderOutput(id, o) {
assertEnum('capability', CAPABILITIES, o.capability);
reqStr(o.internalField, 'internalField'); reqStr(o.jsonPath, 'jsonPath'); reqStr(o.transform, 'transform');
if (!db.one(`SELECT 1 AS x FROM ProviderOutputMap WHERE id = ?`, [id])) fail('integrations.unknownOutputMap', { id }, `Output map ${id} does not exist.`);
db.run(`UPDATE ProviderOutputMap SET capability=?, internalField=?, jsonPath=?, transform=? WHERE id=?`,
[o.capability, o.internalField, o.jsonPath, o.transform, id]);
await db.persist();
}
export async function deleteProviderOutput(id) {
db.run(`DELETE FROM ProviderOutputMap WHERE id = ?`, [id]);
await db.persist();
}
export async function setProviderHmac(providerId, h) {
assertProviderId(providerId);
reqStr(h.messageParts, 'messageParts'); reqStr(h.hashAlgorithm, 'hashAlgorithm'); reqStr(h.encoding, 'encoding');
reqStr(h.keyHeader, 'keyHeader'); reqStr(h.signatureHeader, 'signatureHeader'); reqStr(h.timestampHeader, 'timestampHeader');
db.run(`INSERT INTO ProviderAuthenticationHmac
(providerId, messageParts, hashAlgorithm, encoding, keyHeader, signatureHeader, timestampHeader, windowHeader, windowMs)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(providerId) DO UPDATE SET
messageParts=excluded.messageParts, hashAlgorithm=excluded.hashAlgorithm, encoding=excluded.encoding,
keyHeader=excluded.keyHeader, signatureHeader=excluded.signatureHeader,
timestampHeader=excluded.timestampHeader, windowHeader=excluded.windowHeader, windowMs=excluded.windowMs`,
[providerId, h.messageParts, h.hashAlgorithm, h.encoding, h.keyHeader, h.signatureHeader, h.timestampHeader, h.windowHeader ?? null, h.windowMs ?? null]);
await db.persist();
}
export async function clearProviderHmac(providerId) {
db.run(`DELETE FROM ProviderAuthenticationHmac WHERE providerId = ?`, [providerId]);
await db.persist();
}
+74
View File
@@ -0,0 +1,74 @@
// Generated, do not edit. sha256:f8255fd8148a61f4a49cc0a84d161f8b1bc531a1a315349c9aef9328871b9d6f
import * as db from '../db.js';
import { fail } from '../../shared/app-error.js';
import { assertTimestamp, nowIso } from './_shared.js';
import { createEvent, deleteEvent, EVENT_JOIN } from './events.js';
import { trackUpdate } from './change-log.js';
import { emitChange } from './_events.js';
export function listReservations() {
return db.all(`${EVENT_JOIN} WHERE e.type = 'reservation' ORDER BY e.occurredAt ASC, e.id ASC`);
}
export function listReservationsForAccount(accountId) {
return db.all(`${EVENT_JOIN} WHERE e.type = 'reservation' AND (e.fromAccountId = ?1 OR e.toAccountId = ?1)
ORDER BY e.occurredAt ASC, e.id ASC`, [accountId]);
}
export function listDueReservations(untilIso = nowIso()) {
return db.all(`${EVENT_JOIN} WHERE e.type = 'reservation' AND e.occurredAt <= ?
ORDER BY e.occurredAt ASC, e.id ASC`, [untilIso]);
}
function rowOf(r) {
if (!r.accountId) throw new Error('A reservation needs an account.');
if (r.direction !== 'in' && r.direction !== 'out') throw new Error(`Unknown direction '${r.direction}'; use 'in' or 'out'.`);
return {
assetId: r.assetId, type: 'reservation', occurredAt: r.occurredAt, amount: r.amount,
fromAccountId: r.direction === 'out' ? r.accountId : null,
toAccountId: r.direction === 'in' ? r.accountId : null,
source: 'manual', note: r.note ?? null,
};
}
function openReservation(id) {
const row = db.one(`SELECT * FROM Event WHERE id = ?`, [id]);
if (!row || row.type !== 'reservation') fail('reservations.notOpen', null, 'This reservation is no longer open.');
return row;
}
export async function createReservation(r) {
return createEvent(rowOf(r));
}
export async function updateReservation(id, r) {
openReservation(id);
const e = rowOf(r);
trackUpdate('Event', id, () => db.run(
`UPDATE Event SET assetId = ?, occurredAt = ?, amount = ?, fromAccountId = ?, toAccountId = ?, note = ?
WHERE id = ? AND type = 'reservation'`,
[e.assetId, e.occurredAt, e.amount, e.fromAccountId, e.toAccountId, e.note, id]));
await db.persist();
emitChange('ledger-changed');
}
export async function deleteReservation(id) {
openReservation(id);
await deleteEvent(id);
}
export async function bookReservation(id, { occurredAt, amount, otherAccountId = null, note }) {
const row = openReservation(id);
assertTimestamp('occurredAt', occurredAt);
if (!Number.isInteger(amount) || amount <= 0) throw new Error(`A booking needs an amount > 0 in the smallest unit (got ${amount}).`);
const out = row.fromAccountId != null;
const own = out ? row.fromAccountId : row.toAccountId;
if (otherAccountId != null && otherAccountId === own) throw new Error('The other side of a booking is another account.');
const [from, to] = out ? [own, otherAccountId] : [otherAccountId, own];
trackUpdate('Event', id, () => db.run(
`UPDATE Event SET type = 'transfer', occurredAt = ?, amount = ?, fromAccountId = ?, toAccountId = ?, note = ?
WHERE id = ? AND type = 'reservation'`,
[occurredAt, amount, from, to, note === undefined ? row.note : (note || null), id]));
await db.persist();
emitChange('ledger-changed');
}
+24
View File
@@ -0,0 +1,24 @@
// Generated, do not edit. sha256:aba9f6d2144409570ffba1d05e453cb0ffd966afb4f0fa1cd5960bcb7a63805f
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
export async function createScanLog(s) {
assertEnum('status', ENUMS.SCAN_STATUS, s.status);
db.run(`INSERT INTO ScanLog (accountId, scannedAt, status, addressesFound, balanceFound, proposalsCreated, rawData)
VALUES (?,?,?,?,?,?,?)`,
[s.accountId, s.scannedAt ?? nowIso(), s.status,
s.addressesFound ?? null, s.balanceFound ?? null, s.proposalsCreated ?? null,
s.rawData != null ? JSON.stringify(s.rawData) : null]);
const id = db.lastId();
await db.persist();
return id;
}
export function latestScanLog(accountId) {
return db.one(`SELECT * FROM ScanLog WHERE accountId = ? ORDER BY scannedAt DESC, id DESC LIMIT 1`, [accountId]);
}
export function listScanLogs(accountId) {
return db.all(`SELECT * FROM ScanLog WHERE accountId = ? ORDER BY scannedAt DESC, id DESC`, [accountId]);
}
+93
View File
@@ -0,0 +1,93 @@
// Generated, do not edit. sha256:8017fede3aae4d6eb73088d3b73b43810357285ec3b8acd0f4f14e52251b7e6a
import * as db from '../db.js';
function tel(sql, params) {
const r = db.one(sql, params);
return r ? Number(r.n) : 0;
}
function idLijst(sql, kolom, params) {
return db.all(sql, params).map(r => r[kolom]);
}
export function accountStructure(accountId) {
const row = db.one(`SELECT * FROM Account WHERE id = ?`, [accountId]);
if (!row) return null;
const beideKanten = [accountId, accountId];
return {
row,
counts: {
events: tel(`SELECT COUNT(*) AS n FROM Event
WHERE fromAccountId = ? OR toAccountId = ?`, beideKanten),
assets: tel(`SELECT COUNT(DISTINCT assetId) AS n FROM Event
WHERE fromAccountId = ? OR toAccountId = ?`, beideKanten),
walletSources: tel(`SELECT COUNT(*) AS n FROM WalletSource WHERE accountId = ?`, [accountId]),
addresses: tel(`SELECT COUNT(*) AS n FROM WalletAddress WHERE accountId = ?`, [accountId]),
walletTransactions: tel(`SELECT COUNT(*) AS n FROM WalletTransaction WHERE accountId = ?`, [accountId]),
valuations: tel(`SELECT COUNT(*) AS n FROM Valuation WHERE accountId = ?`, [accountId]),
},
refs: {
assetIds: idLijst(`SELECT DISTINCT assetId FROM Event
WHERE fromAccountId = ? OR toAccountId = ?
ORDER BY assetId`, 'assetId', beideKanten),
},
};
}
export function assetStructure(assetId) {
const row = db.one(`SELECT * FROM Asset WHERE id = ?`, [assetId]);
if (!row) return null;
const REKENINGEN = `SELECT fromAccountId AS a FROM Event WHERE assetId = ? AND fromAccountId IS NOT NULL
UNION
SELECT toAccountId AS a FROM Event WHERE assetId = ? AND toAccountId IS NOT NULL`;
const beideKolommen = [assetId, assetId];
return {
row,
counts: {
events: tel(`SELECT COUNT(*) AS n FROM Event WHERE assetId = ?`, [assetId]),
accounts: tel(`SELECT COUNT(*) AS n FROM (${REKENINGEN})`, beideKolommen),
pricePoints: tel(`SELECT COUNT(*) AS n FROM PriceHistory WHERE assetId = ?`, [assetId]),
valuations: tel(`SELECT COUNT(*) AS n FROM Valuation WHERE assetId = ?`, [assetId]),
products: tel(`SELECT COUNT(*) AS n FROM MetalProduct WHERE assetId = ?`, [assetId]),
coverage: tel(`SELECT COUNT(*) AS n FROM ProviderCoverage
WHERE symbol = (SELECT symbol FROM Asset WHERE id = ?)`, [assetId]),
},
refs: {
accountIds: idLijst(`SELECT a FROM (${REKENINGEN}) ORDER BY a`, 'a', beideKolommen),
},
};
}
export function eventRefs({ accountId = null, assetId = null } = {}, limiet = 50) {
if (accountId == null && assetId == null) {
throw new Error('eventRefs needs an account or an asset; without a filter this is a bulk read.');
}
const n = Number.isInteger(limiet) && limiet > 0 ? limiet : 50;
const waar = [];
const params = [];
if (accountId != null) {
waar.push('(fromAccountId = ? OR toAccountId = ?)');
params.push(accountId, accountId);
}
if (assetId != null) {
waar.push('assetId = ?');
params.push(assetId);
}
return db.all(
`SELECT id, type, substr(occurredAt, 1, 7) AS month FROM Event
WHERE ${waar.join(' AND ')} ORDER BY occurredAt DESC, id DESC LIMIT ?`,
[...params, n],
).map(r => ({ id: r.id, type: r.type, month: r.month }));
}
export function labelStructure(labelId) {
const row = db.one(`SELECT * FROM Label WHERE id = ?`, [labelId]);
if (!row) return null;
return {
row,
counts: {
walletTransactions: tel(`SELECT COUNT(*) AS n FROM WalletTransaction WHERE labelId = ?`, [labelId]),
},
refs: {},
};
}
+35
View File
@@ -0,0 +1,35 @@
// Generated, do not edit. sha256:e7b97bfa74b04b9522e509df409b4fa237f564c7b1fc8cdbe64c7eb87d1cb4dd
import { CONFIG } from '../../shared/config.js';
import { marketServiceUrl, marketSupportMessages, marketSupportSend, marketSupportAttachment } from '../market-service.mjs';
const S = CONFIG.LICENSE.SUPPORT;
const ATTACHMENT_ID = /^[0-9a-f]{32}$/;
function assertSignature(signature) {
if (!/^Nostr [A-Za-z0-9+/=]+$/.test(String(signature ?? ''))) throw new Error('A support call carries an "Authorization: Nostr <base64 event>" header.');
}
export async function supportInfo() {
return {
serviceUrl: await marketServiceUrl(),
maxText: S.MAX_TEXT, maxAttachmentBytes: S.MAX_ATTACHMENT_BYTES, attachmentTypes: [...S.ATTACHMENT_TYPES],
nostr: { pubkey: S.RECIPIENT, name: S.RECIPIENT_NAME },
};
}
export async function supportMessages(signature) {
assertSignature(signature);
return marketSupportMessages(signature);
}
export async function sendSupport(signature, body) {
assertSignature(signature);
if (typeof body !== 'string') throw new Error('The body of a support message is the signed JSON text.');
return marketSupportSend(signature, body);
}
export async function supportAttachment(signature, id) {
assertSignature(signature);
if (!ATTACHMENT_ID.test(String(id))) throw new Error('An attachment id is 32 hex characters.');
return marketSupportAttachment(signature, id);
}
+43
View File
@@ -0,0 +1,43 @@
// Generated, do not edit. sha256:051b73ea4c90cd1dfd7110d9d56e91cb527361b375f6b09d6509f8db81b2835e
import * as db from '../db.js';
import { CONFIG } from '../../shared/config.js';
import { deleteValuation } from './valuations.js';
import { deleteProposal } from './proposals.js';
import { unlinkTxEvent } from './tx-links.js';
export function tableColumns(name) {
if (!CONFIG.DB_TABLES.includes(name)) throw new Error(`Unknown table: ${name}`);
return db.all(`PRAGMA table_info(${name})`).map(c => c.name);
}
export function listTableRaw(name) {
if (!CONFIG.DB_TABLES.includes(name)) throw new Error(`Unknown table: ${name}`);
return db.all(`SELECT * FROM ${name} ORDER BY rowid DESC`);
}
const TABLE_ROW_DELETERS = {
valuations: (id) => deleteValuation(id),
reconcile_proposals: (id) => deleteProposal(id),
tx_event_link: (id) => {
const r = db.one(`SELECT walletTransactionId, eventId FROM WalletTransactionEventLink WHERE id=?`, [id]);
if (!r) throw new Error(`Link ${id} does not exist.`);
return unlinkTxEvent(r.walletTransactionId, r.eventId);
},
};
export async function deleteTableRow(name, id) {
if (!CONFIG.DB_DELETABLE_TABLES.includes(name)) throw new Error(`Table ${name} is not deletable.`);
const custom = TABLE_ROW_DELETERS[name];
if (custom) { await custom(id); return; }
db.run(`DELETE FROM ${name} WHERE id=?`, [id]);
await db.persist();
}
export async function clearTable(name) {
if (!CONFIG.DB_DELETABLE_TABLES.includes(name)) throw new Error(`Table ${name} cannot be cleared.`);
const custom = TABLE_ROW_DELETERS[name];
if (custom) {
for (const r of db.all(`SELECT id FROM ${name}`)) await custom(r.id);
return;
}
db.run(`DELETE FROM ${name}`);
await db.persist();
}
+157
View File
@@ -0,0 +1,157 @@
// Generated, do not edit. sha256:a4f989c4fcb487fdb8c1ab80d536f8fa5a1a10a2f0357c22596ab060e7b38694
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
export async function syncAddressLinks() {
const method = 'exact';
assertEnum('method', ENUMS.LINK_METHOD, method);
db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt)
SELECT w.id, e.id, ?1, ?2
FROM WalletTransaction w
JOIN Event e ON e.externalAddress = w.address
WHERE w.direction = 'in' AND w.address IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p
WHERE p.walletTransactionId = w.id AND p.resolvedEventId = e.id AND p.status = 'ignored')`,
[method, nowIso()]);
let n = db.one(`SELECT changes() AS n`).n;
db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt)
SELECT w.id, e.id, ?1, ?2
FROM WalletTransaction w
JOIN Event e ON e.source = 'lightning'
AND (e.fromAccountId = w.accountId OR e.toAccountId = w.accountId)
AND (e.importReference = 'ln:' || w.txid
OR (w.txid LIKE 'fwd:%' AND e.importReference = 'lnfwd:' || w.blockTime))
WHERE w.layer = 'lightning'
AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p
WHERE p.walletTransactionId = w.id AND p.resolvedEventId = e.id AND p.status = 'ignored')`,
[method, nowIso()]);
n += db.one(`SELECT changes() AS n`).n;
db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt)
SELECT w.id, l.eventId, ?1, ?2
FROM WalletTransaction w
JOIN WalletTransaction twin ON twin.accountId = w.accountId AND twin.txid = w.txid
AND twin.layer = 'lightning' AND twin.id <> w.id
JOIN WalletTransactionEventLink l ON l.walletTransactionId = twin.id
WHERE w.layer = 'lightning'
AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p
WHERE p.walletTransactionId = w.id AND p.resolvedEventId = l.eventId AND p.status = 'ignored')`,
[method, nowIso()]);
n += db.one(`SELECT changes() AS n`).n;
const asset = onchainAsset();
if (asset) {
db.run(`INSERT OR IGNORE INTO ReconcileProposal
(accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, resolvedEventId, detectedAt)
SELECT w.accountId, ?1, w.id, w.txid, w.valueSats, w.direction,
CASE WHEN e.type = 'transfer' THEN 'transfer' ELSE 'trade' END,
'auto_linked', e.id, ?2
FROM WalletTransactionEventLink l
JOIN WalletTransaction w ON w.id = l.walletTransactionId
JOIN Event e ON e.id = l.eventId
WHERE l.method = 'exact'
AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p
WHERE p.walletTransactionId = w.id AND p.resolvedEventId = e.id)`,
[asset.id, nowIso()]);
}
const changed = n + db.one(`SELECT changes() AS n`).n;
if (changed > 0) await db.persist();
return n;
}
export function eventLinkMap() {
const m = new Map();
for (const r of db.all(`SELECT eventId, method FROM WalletTransactionEventLink`)) m.set(r.eventId, r.method);
return m;
}
export function walletTxEventMap() {
const m = new Map();
const rows = db.all(`SELECT l.walletTransactionId, l.method, e.id AS eventId, e.type, e.amount, e.unitFee,
e.fromAccountId, e.toAccountId, fa.name AS from_name, ta.name AS to_name
FROM WalletTransactionEventLink l
JOIN Event e ON e.id = l.eventId
LEFT JOIN Account fa ON fa.id = e.fromAccountId
LEFT JOIN Account ta ON ta.id = e.toAccountId`);
for (const r of rows) {
if (!m.has(r.walletTransactionId)) m.set(r.walletTransactionId, []);
m.get(r.walletTransactionId).push(r);
}
return m;
}
export async function linkTxEvent(walletTxId, eventId, method) {
assertEnum('method', ENUMS.LINK_METHOD, method);
db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt)
VALUES (?,?,?,?)`, [walletTxId, eventId, method, nowIso()]);
await db.persist();
}
export function linkProposalTxSoft(walletTxId, eventId) {
const method = 'soft';
assertEnum('method', ENUMS.LINK_METHOD, method);
db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt)
VALUES (?,?,?,?)`, [walletTxId, eventId, method, nowIso()]);
}
export async function unlinkTxEvent(walletTxId, eventId) {
db.run(`DELETE FROM WalletTransactionEventLink WHERE walletTransactionId = ? AND eventId = ?`, [walletTxId, eventId]);
db.run(`UPDATE ReconcileProposal SET status = 'ignored'
WHERE walletTransactionId = ? AND resolvedEventId = ? AND status = 'auto_linked'`, [walletTxId, eventId]);
await db.persist();
}
export function onchainAsset() {
return db.one(`SELECT * FROM Asset WHERE symbol = 'BTC'`)
|| db.one(`SELECT * FROM Asset WHERE kind = 'crypto' ORDER BY symbol LIMIT 1`)
|| null;
}
export function linksForTx(walletTxId) {
return db.all(`
SELECT tel.eventId AS id, tel.method,
e.type, e.occurredAt, e.amount,
a.symbol AS asset_symbol, lf.name AS from_name, lt.name AS to_name
FROM WalletTransactionEventLink tel
JOIN Event e ON e.id = tel.eventId
JOIN Asset a ON a.id = e.assetId
LEFT JOIN Account lf ON lf.id = e.fromAccountId
LEFT JOIN Account lt ON lt.id = e.toAccountId
WHERE tel.walletTransactionId = ?
ORDER BY e.occurredAt DESC, e.id DESC`, [walletTxId]);
}
export function linksForEvent(eventId) {
return db.all(`
SELECT tel.walletTransactionId AS id, tel.method,
t.direction, t.valueSats, t.blockTime, t.txid, t.address, ac.name AS account_name
FROM WalletTransactionEventLink tel
JOIN WalletTransaction t ON t.id = tel.walletTransactionId
LEFT JOIN Account ac ON ac.id = t.accountId
WHERE tel.eventId = ?
ORDER BY (t.blockTime IS NULL) DESC, t.blockTime DESC, t.id DESC`, [eventId]);
}
export function eventCandidatesForTx(walletTxId, assetId) {
return db.all(`
SELECT e.id, e.type, e.occurredAt, e.amount,
a.symbol AS asset_symbol, lf.name AS from_name, lt.name AS to_name
FROM Event e
JOIN Asset a ON a.id = e.assetId
LEFT JOIN Account lf ON lf.id = e.fromAccountId
LEFT JOIN Account lt ON lt.id = e.toAccountId
WHERE e.assetId = ?2 AND e.type <> 'reservation'
AND e.id NOT IN (SELECT eventId FROM WalletTransactionEventLink WHERE walletTransactionId = ?1)
ORDER BY ABS(strftime('%s', e.occurredAt) -
strftime('%s', COALESCE((SELECT blockTime FROM WalletTransaction WHERE id = ?1),
(SELECT createdAt FROM WalletTransaction WHERE id = ?1)))) ASC,
e.id DESC
LIMIT 100`, [walletTxId, assetId]);
}
export function txCandidatesForEvent(eventId) {
return db.all(`
SELECT t.id, t.direction, t.valueSats, t.blockTime, t.txid, t.address,
ac.name AS account_name, l.name AS label_name
FROM WalletTransaction t
LEFT JOIN Account ac ON ac.id = t.accountId
LEFT JOIN Label l ON l.id = t.labelId
WHERE t.id NOT IN (SELECT walletTransactionId FROM WalletTransactionEventLink WHERE eventId = ?1)
ORDER BY ABS(strftime('%s', COALESCE(t.blockTime, t.createdAt)) -
strftime('%s', (SELECT occurredAt FROM Event WHERE id = ?1))) ASC,
t.id DESC
LIMIT 100`, [eventId]);
}
+138
View File
@@ -0,0 +1,138 @@
// Generated, do not edit. sha256:297c7a1d9ed86e15053cd37bb7126f5fb5b54d94b67e8da901fb66f370ca9301
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { nowUtcIso } from '../../shared/time.js';
import { assertEnum } from './_shared.js';
import { capacityReachedFor } from './_limits.js';
import { fail } from '../../shared/app-error.js';
import { eventsCountByAccount, eventsNetByAccount } from './events.js';
import { trackDelete, trackUpdate } from './change-log.js';
import { emitChange } from './_events.js';
export function listValuations() {
return db.all(`SELECT v.*, ac.name AS account_name, a.symbol AS asset_symbol, a.name AS asset_name,
a.decimals AS asset_decimals, a.unit AS asset_unit, a.subunit AS asset_subunit,
a.displayUnit AS asset_display_unit, a.kind AS asset_kind
FROM Valuation v
LEFT JOIN Account ac ON ac.id = v.accountId
JOIN Asset a ON a.id = v.assetId
ORDER BY v.valuedOn DESC, v.rateAt DESC, v.id DESC`);
}
export function anchorAt(accountId, assetId, atIso) {
return db.one(
`SELECT * FROM Valuation
WHERE accountId = ?1 AND assetId = ?2 ${atIso ? 'AND rateAt <= ?3' : ''}
ORDER BY rateAt DESC, id DESC LIMIT 1`,
atIso ? [accountId, assetId, atIso] : [accountId, assetId]) || null;
}
export function latestAnchors(atIso, { ownOnly = false } = {}) {
return db.all(
`SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY accountId, assetId ORDER BY rateAt DESC, id DESC) AS rn
FROM Valuation WHERE accountId IS NOT NULL AND (?1 IS NULL OR rateAt <= ?1)
AND (?2 = 0 OR accountId IN (SELECT id FROM Account WHERE wealthScope IS NULL))
) WHERE rn = 1`, [atIso ?? null, ownOnly ? 1 : 0]);
}
export function anchorsBetween(accountId, assetId, fromIso, untilIso) {
return db.all(
`SELECT * FROM Valuation
WHERE accountId = ?1 AND assetId = ?2 AND rateAt > ?3 AND rateAt <= ?4
ORDER BY rateAt ASC, id ASC`, [accountId, assetId, fromIso, untilIso]);
}
export function anchorDaysBetween(accountId, assetId, vanDag, totDag) {
return db.all(
`SELECT DISTINCT valuedOn FROM Valuation
WHERE accountId = ?1 AND assetId = ?2 AND valuedOn > ?3 AND valuedOn < ?4
ORDER BY valuedOn`, [accountId, assetId, vanDag, totDag]).map(r => r.valuedOn);
}
export function firstAnchorDate() {
const r = db.one(`SELECT MIN(rateAt) AS first FROM Valuation WHERE accountId IS NOT NULL`);
return r && r.first ? r.first : null;
}
export function valuationDiff(v) {
if (!v.accountId) return null;
return v.balance - eventsNetByAccount(v.accountId, v.assetId, v.rateAt);
}
export function anchorBefore(accountId, assetId, atIso) {
return db.one(
`SELECT * FROM Valuation
WHERE accountId = ?1 AND assetId = ?2 AND rateAt < ?3
ORDER BY rateAt DESC, id DESC LIMIT 1`, [accountId, assetId, atIso]) || null;
}
export function valuationGap(v) {
if (!v.accountId) return null;
const vorige = anchorBefore(v.accountId, v.assetId, v.rateAt);
if (!vorige) return valuationDiff(v);
const sindsVorige = eventsNetByAccount(v.accountId, v.assetId, v.rateAt)
- eventsNetByAccount(v.accountId, v.assetId, vorige.rateAt);
return v.balance - (vorige.balance + sindsVorige);
}
export function valuationReconcilable(v) {
if (!v.accountId) return false;
return eventsCountByAccount(v.accountId, v.assetId, v.rateAt) > 0;
}
function verrijkt() {
const nu = nowUtcIso();
return listValuations().map(v => ({
...v,
diff: valuationDiff(v),
gap: valuationGap(v),
reconcilable: valuationReconcilable(v),
accepted: !!v.gapAcceptedAt,
future: v.rateAt > nu,
}));
}
export function valuationDiffs() {
return verrijkt().map(({ id, diff, gap, reconcilable, accepted, future }) =>
({ id, diff, gap, reconcilable, accepted, future }));
}
export function listOpenValuationDiffs() {
return verrijkt().filter(v => !v.future && !v.accepted && v.reconcilable && v.gap != null && v.gap !== 0);
}
export async function updateValuation(id, v) {
assertEnum('source', ENUMS.VALUATION_SOURCE, v.source);
trackUpdate('Valuation', id, () =>
db.run(`UPDATE Valuation SET assetId=?, valuedOn=?, accountId =?, balance=?, fiatRate=?, rateAt=?, source=? WHERE id=?`,
[v.assetId, v.valuedOn, v.accountId ?? null, v.balance, v.fiatRate, v.rateAt, v.source, id]));
await db.persist();
emitChange('ledger-changed');
}
export async function acceptValuationGap(id, aan = true) {
trackUpdate('Valuation', id, () =>
db.run(`UPDATE Valuation SET gapAcceptedAt = ? WHERE id = ?`, [aan ? nowUtcIso() : null, id]));
await db.persist();
emitChange('ledger-changed');
}
export async function deleteValuation(id) {
trackDelete('Valuation', id, () => db.run(`DELETE FROM Valuation WHERE id=?`, [id]));
await db.persist();
emitChange('ledger-changed');
}
export async function createValuation(v) {
assertEnum('source', ENUMS.VALUATION_SOURCE, v.source);
const max = capacityReachedFor('valuations');
if (max !== null) {
fail('valuations.maxFree', { max },
`The free version holds ${max} balance(s). A licence lifts that; nothing is removed in the meantime.`);
}
db.run(`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,?,?,?)`,
[v.assetId, v.valuedOn, v.accountId ?? null, v.balance, v.fiatRate, v.rateAt, v.source]);
const id = db.lastId();
await db.persist();
emitChange('ledger-changed');
return id;
}
+31
View File
@@ -0,0 +1,31 @@
// Generated, do not edit. sha256:ef4dc1eb01e833a602dd00a17d235a583edc55c162bc923df89b4ecb9c82da16
import * as db from '../db.js';
import { nowIso } from './_shared.js';
import { assertSourceMatchesAccount } from './wallet-sources.js';
export function listWalletAddresses(accountId) {
return db.all(`SELECT * FROM WalletAddress
WHERE accountId = ? ORDER BY chainIndex, derivationIndex, address`, [accountId]);
}
export async function upsertWalletAddresses(list) {
for (const a of list) {
assertSourceMatchesAccount('WalletAddress', a.accountId, a.sourceId);
db.run(`INSERT INTO WalletAddress
(accountId, sourceId, address, chainIndex, derivationIndex, automaticLabel, balanceSats, transactionCount, lastScannedAt)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(address) DO UPDATE SET
accountId=excluded.accountId, sourceId=excluded.sourceId, chainIndex=excluded.chainIndex,
derivationIndex=excluded.derivationIndex, automaticLabel=excluded.automaticLabel,
balanceSats=excluded.balanceSats, transactionCount=excluded.transactionCount, lastScannedAt=excluded.lastScannedAt`,
[a.accountId, a.sourceId ?? null, a.address, a.chainIndex ?? null, a.derivationIndex ?? null,
a.automaticLabel ?? null, a.balanceSats ?? null, a.transactionCount ?? null, a.lastScannedAt ?? nowIso()]);
}
await db.persist();
}
export function sourceBalance(sourceId) {
return db.one(`SELECT COALESCE(SUM(balanceSats),0) AS sats, COUNT(*) AS n, MAX(lastScannedAt) AS scanned
FROM WalletAddress WHERE sourceId=?`, [sourceId]);
}
export function accountOnchainBalance(accountId) {
return db.one(`SELECT COALESCE(SUM(balanceSats),0) AS sats FROM WalletAddress WHERE accountId=?`, [accountId]).sats;
}
+111
View File
@@ -0,0 +1,111 @@
// Generated, do not edit. sha256:1aab7b8b0f1a8c9ae297214b001ed051798627cb16552a7cd6582bdf219ea871
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
import { fail } from '../../shared/app-error.js';
import { trackDelete, trackUpdate } from './change-log.js';
import { hasFeature } from '../license.mjs';
import { services } from '../registers.mjs';
const nwc = () => services.get('nwc');
export async function purgeWalletSourceSecret(id) {
await nwc()?.deleteNwcUri(id);
}
export function assertSourceMatchesAccount(tabel, accountId, sourceId) {
if (sourceId == null) return;
const bron = db.one(`SELECT accountId FROM WalletSource WHERE id = ?`, [sourceId]);
if (!bron) throw new Error(`${tabel}: source ${sourceId} does not exist.`);
if (Number(bron.accountId) !== Number(accountId)) {
throw new Error(`${tabel}: source ${sourceId} belongs to account ${bron.accountId}, not to ${accountId}.`);
}
}
export function listWalletSources(accountId) {
return db.all(`SELECT s.*, c.name AS connection_name, c.url AS connection_url
FROM WalletSource s
LEFT JOIN Connection c ON c.id = s.connectionId
WHERE s.accountId = ? ORDER BY s.id`, [accountId]);
}
export function listAllWalletSources() {
return db.all(`SELECT s.*, a.name AS account_name FROM WalletSource s
JOIN Account a ON a.id = s.accountId ORDER BY a.name, s.id`);
}
function assertLndConnection(connectionId) {
const c = db.one(`SELECT id, kind FROM Connection WHERE id = ?`, [connectionId]);
if (!c) fail('accounts.lndNoConnection', null, 'Pick an LND connection.');
if (c.kind !== 'lnd') fail('accounts.sourceNotLnd', null, 'The chosen connection is not an LND connection.');
}
function connectionFor(kind, connectionId) {
if (kind === 'lnd') { assertLndConnection(Number(connectionId)); return Number(connectionId); }
if (ENUMS.ONCHAIN_SOURCE_KIND.includes(kind) && connectionId != null && connectionId !== '') {
assertLndConnection(Number(connectionId));
return Number(connectionId);
}
return null;
}
const LIGHTNING_SOURCE = new Set(['nwc', 'lnd']);
function assertLightningAllowed(kind) {
if (LIGHTNING_SOURCE.has(kind) && (!hasFeature('lightning') || (kind === 'nwc' && !nwc()))) {
fail('connections.lightningPaid', null, 'Lightning connections belong to the paid version.');
}
}
export async function createWalletSource({ accountId, kind, value, name, scriptType, connectionId = null, secret = null }) {
assertEnum('kind', ENUMS.WALLET_SOURCE_KIND, kind);
assertLightningAllowed(kind);
if (kind === 'xpub') assertEnum('scriptType', ENUMS.SCRIPT_TYPE, scriptType);
if (kind === 'nwc' && !secret) fail('accounts.sourceUriMissing', null, 'The NWC URI is missing.');
const verbinding = connectionFor(kind, connectionId);
db.run(`INSERT INTO WalletSource (accountId, kind, value, name, scriptType, connectionId, createdAt) VALUES (?,?,?,?,?,?,?)`,
[accountId, kind, kind === 'lnd' ? '' : value, name ?? null, kind === 'xpub' ? scriptType : null,
verbinding, nowIso()]);
const id = db.lastId();
if (kind === 'nwc') await nwc().setNwcUri(id, secret);
await db.persist(); return id;
}
export async function updateWalletSource(id, { kind, value, name, scriptType, connectionId = null, secret = null }) {
assertEnum('kind', ENUMS.WALLET_SOURCE_KIND, kind);
if (kind === 'xpub') assertEnum('scriptType', ENUMS.SCRIPT_TYPE, scriptType);
const oud = db.one(`SELECT kind FROM WalletSource WHERE id = ?`, [id]);
if (oud?.kind !== kind) assertLightningAllowed(kind);
const nieuwValue = kind === 'lnd' ? '' : value;
const nieuwConnectie = connectionFor(kind, connectionId);
trackUpdate('WalletSource', id, () =>
db.run(`UPDATE WalletSource SET kind=?, value=?, name=?, scriptType=?, connectionId=?,
balanceSats = CASE WHEN kind = ? AND value = ? AND connectionId IS ? THEN balanceSats END,
lastScannedAt = CASE WHEN kind = ? AND value = ? AND connectionId IS ? THEN lastScannedAt END
WHERE id=?`,
[kind, nieuwValue, name ?? null, kind === 'xpub' ? scriptType : null, nieuwConnectie,
kind, nieuwValue, nieuwConnectie, kind, nieuwValue, nieuwConnectie, id]));
if (kind === 'nwc' && secret) await nwc().setNwcUri(id, secret);
if (kind !== 'nwc') await nwc()?.deleteNwcUri(id);
await db.persist();
}
export async function recordSourceBalance(id, sats) {
const bron = db.one(`SELECT kind FROM WalletSource WHERE id = ?`, [Number(id)]);
if (!bron) throw new Error(`Wallet source ${id} does not exist.`);
if (bron.kind !== 'lnd' && bron.kind !== 'nwc') throw new Error(`Wallet source ${id} is on-chain; its balance comes from its addresses.`);
if (!Number.isInteger(sats) || sats < 0) throw new Error(`A balance is a whole number of sats from zero: '${sats}'.`);
db.run(`UPDATE WalletSource SET balanceSats = ?, lastScannedAt = ? WHERE id = ?`, [sats, nowIso(), Number(id)]);
await db.persist();
}
export function lightningBalances() {
return db.all(`SELECT WalletSource.accountId, Account.name AS account_name,
SUM(WalletSource.balanceSats) AS sats, MAX(WalletSource.lastScannedAt) AS scannedAt
FROM WalletSource
JOIN Account ON Account.id = WalletSource.accountId
WHERE WalletSource.lastScannedAt IS NOT NULL
AND (WalletSource.kind = 'lnd'
OR (WalletSource.kind = 'nwc' AND NOT EXISTS (
SELECT 1 FROM WalletSource node
WHERE node.accountId = WalletSource.accountId AND node.kind = 'lnd')))
GROUP BY WalletSource.accountId, Account.name
ORDER BY Account.name`);
}
export async function deleteWalletSource(id) {
await purgeWalletSourceSecret(id);
trackDelete('WalletSource', id, () => db.run(`DELETE FROM WalletSource WHERE id=?`, [id]));
await db.persist();
}
+103
View File
@@ -0,0 +1,103 @@
// Generated, do not edit. sha256:28bf467843efd410b1dacbe64352649ff4b982578fe5215b9da5061ebec43621
import * as db from '../db.js';
import { nowIso } from './_shared.js';
import { fail } from '../../shared/app-error.js';
import { assertSourceMatchesAccount } from './wallet-sources.js';
import { createLabel } from './labels.js';
import { toSearchName } from '../../shared/search-name.js';
import { CONFIG } from '../../shared/config.js';
import { compactMemo } from '../../shared/zap.js';
const UPSERT = Object.freeze({
onchain: `INSERT INTO WalletTransaction
(accountId, sourceId, layer, txid, direction, valueSats, feeSats, blockHeight, blockTime, address, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (accountId, txid) WHERE layer = 'onchain' DO UPDATE SET
sourceId=excluded.sourceId, direction=excluded.direction, valueSats=excluded.valueSats,
feeSats=excluded.feeSats, blockHeight=excluded.blockHeight, blockTime=excluded.blockTime,
address=excluded.address`,
lightning: `INSERT INTO WalletTransaction
(accountId, sourceId, layer, txid, direction, valueSats, feeSats, blockHeight, blockTime, address, kind, memo, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (sourceId, txid) WHERE layer = 'lightning' DO UPDATE SET
direction=excluded.direction, valueSats=excluded.valueSats,
feeSats=excluded.feeSats, blockHeight=excluded.blockHeight, blockTime=excluded.blockTime,
address=excluded.address, kind=excluded.kind, memo=excluded.memo`,
});
const TWIN_LABEL = `UPDATE WalletTransaction SET labelId = (
SELECT twin.labelId FROM WalletTransaction twin
WHERE twin.layer = 'lightning' AND twin.accountId = WalletTransaction.accountId
AND twin.txid = WalletTransaction.txid AND twin.labelId IS NOT NULL
ORDER BY twin.id LIMIT 1)
WHERE layer = 'lightning' AND labelId IS NULL
AND EXISTS (SELECT 1 FROM WalletTransaction twin
WHERE twin.layer = 'lightning' AND twin.accountId = WalletTransaction.accountId
AND twin.txid = WalletTransaction.txid AND twin.labelId IS NOT NULL)`;
function assertLayerShape(t) {
if (!Object.hasOwn(UPSERT, t.layer)) throw new Error(`Unknown wallet movement layer: '${t.layer}'.`);
if (t.layer !== 'lightning') return;
if (!Number.isInteger(t.sourceId)) throw new Error('A lightning movement needs its source.');
if (!t.blockTime) throw new Error('A lightning movement needs the moment it settled.');
if (t.blockHeight != null) throw new Error('A lightning movement has no block height.');
if (t.kind != null && t.kind !== 'self') throw new Error(`A lightning movement is ordinary or 'self', not '${t.kind}'.`);
}
export async function upsertWalletTxs(list) {
for (const given of list) {
const t = { ...given, layer: given.layer ?? 'onchain' };
assertLayerShape(t);
assertSourceMatchesAccount('WalletTransaction', t.accountId, t.sourceId);
const velden = [t.accountId, t.sourceId ?? null, t.layer, t.txid, t.direction, t.valueSats, t.feeSats ?? null,
t.blockHeight ?? null, t.blockTime ?? null, t.address ?? null];
db.run(UPSERT[t.layer], t.layer === 'lightning'
? [...velden, t.kind ?? null, compactMemo(t.memo), nowIso()] : [...velden, nowIso()]);
}
if (list.some(t => t.layer === 'lightning')) db.run(TWIN_LABEL);
await db.persist();
}
export function listSourceTxs(sourceId) {
return db.all(`SELECT t.*, l.name AS label_name
FROM WalletTransaction t
LEFT JOIN Label l ON l.id = t.labelId
WHERE t.sourceId = ?
ORDER BY (t.blockTime IS NULL) DESC, t.blockTime DESC, t.id DESC`, [sourceId]);
}
const CHANNEL_ROWS = `accountId IN (SELECT value FROM json_each(?1)) AND layer = 'onchain'
AND txid IN (SELECT value FROM json_each(?2))`;
export async function labelChannelTxs(accountId, txids) {
if (!Number.isInteger(accountId)) throw new Error('labelChannelTxs needs an account id.');
if (!Array.isArray(txids) || txids.some(x => typeof x !== 'string' || !/^[0-9a-f]{64}$/.test(x))) {
throw new Error('labelChannelTxs takes a list of transaction ids in lowercase hex.');
}
if (!txids.length) return 0;
const lijst = JSON.stringify(txids);
const accounts = JSON.stringify([accountId, ...db.all(
`SELECT DISTINCT onchain.accountId FROM WalletSource onchain
JOIN WalletSource node ON node.connectionId = onchain.connectionId AND node.kind = 'lnd'
WHERE node.accountId = ? AND onchain.kind IN ('xpub','address') AND onchain.accountId <> ?`,
[accountId, accountId]).map(r => r.accountId)]);
db.run(`UPDATE WalletTransaction SET kind = 'channel' WHERE ${CHANNEL_ROWS} AND kind IS NULL`, [accounts, lijst]);
const kandidaten = db.one(`SELECT COUNT(*) AS n FROM WalletTransaction WHERE ${CHANNEL_ROWS} AND labelId IS NULL`, [accounts, lijst]).n;
if (!kandidaten) { await db.persist(); return 0; }
const naam = CONFIG.LND.CHANNEL_LABEL;
const label = db.one(`SELECT id FROM Label WHERE searchName = ?`, [toSearchName(naam)])
|| { id: await createLabel({ name: naam }) };
db.run(`UPDATE WalletTransaction SET labelId = ?3 WHERE ${CHANNEL_ROWS} AND labelId IS NULL`, [accounts, lijst, label.id]);
await db.persist();
return kandidaten;
}
export async function setTxLabels(ids, labelId) {
if (!Array.isArray(ids) || ids.length === 0) fail('accounts.labelNoTxs', null, 'No transactions given for the label.');
db.run(`UPDATE WalletTransaction SET labelId = ?1
WHERE id IN (SELECT value FROM json_each(?2))
OR (layer = 'lightning' AND EXISTS (
SELECT 1 FROM WalletTransaction chosen
WHERE chosen.id IN (SELECT value FROM json_each(?2)) AND chosen.layer = 'lightning'
AND chosen.accountId = WalletTransaction.accountId AND chosen.txid = WalletTransaction.txid))`,
[labelId ?? null, JSON.stringify(ids)]);
await db.persist();
}