Versie 0.2.0
This commit is contained in:
commit
e77d8fd373
495 files changed
+81026
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) }));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user