Versie 0.2.0

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

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
// Generated, do not edit. sha256:09721f6e6374d0297156a1b8af0d4578d0bbb9c98d4ea7c96458dafbb2d56dcc
import { createHmac } from 'node:crypto';
const HANDLE_TEKENS = 6;
const BECH32 = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const HEX = '0123456789abcdef';
const SOORTEN = Object.freeze({
address: Object.freeze({ prefix: 'bc1q', lengte: 42, alfabet: BECH32, onmogelijk: 'o' }),
xpub: Object.freeze({ prefix: 'xpub', lengte: 111, alfabet: BASE58, onmogelijk: '0' }),
txid: Object.freeze({ prefix: '', lengte: 64, alfabet: HEX, onmogelijk: 'z' }),
});
export const ALIAS_SOORTEN = Object.freeze(Object.keys(SOORTEN));
function vul(alfabet, lengte, zaad) {
let s = '';
for (let i = 0; i < lengte; i++) {
const paar = parseInt(zaad.slice((i * 2) % zaad.length, (i * 2) % zaad.length + 2) || '0', 16);
s += alfabet[(paar + i) % alfabet.length];
}
return s;
}
export function schijnwaarde(soort, zaad) {
const s = SOORTEN[soort];
if (!s) throw new Error(`unknown alias kind: ${soort}`);
const romp = vul(s.alfabet, s.lengte - s.prefix.length, String(zaad));
const met = `${romp.slice(0, 2)}${s.onmogelijk}${romp.slice(3)}`;
return `${s.prefix}${met}`;
}
export function isSchijnwaarde(tekst) {
if (typeof tekst !== 'string') return false;
for (const [, s] of Object.entries(SOORTEN)) {
if (s.prefix && !tekst.startsWith(s.prefix)) continue;
if (tekst.length !== s.lengte) continue;
const data = tekst.slice(s.prefix.length);
if (data.includes(s.onmogelijk)) return true;
}
return false;
}
export function createAliasRegistry({ secret } = {}) {
if (typeof secret !== 'string' || secret.length < 32) {
throw new Error('An alias registry without a secret is not allowed.');
}
const heen = new Map();
const terug = new Map();
const afgeleid = (soort, waarde) =>
createHmac('sha256', secret).update(`${soort}\0${waarde}`).digest('hex');
function onthoud(sleutel, uit, soort, waarde) {
const bestaand = terug.get(uit);
if (bestaand && bestaand.waarde !== waarde) {
throw new Error(`Handle collision on '${uit}': two different values yield the same handle.`);
}
heen.set(sleutel, uit);
terug.set(uit, { soort, waarde });
return uit;
}
return {
alias(soort, waarde) {
const sleutel = `${soort} ${waarde}`;
if (heen.has(sleutel)) return heen.get(sleutel);
return onthoud(sleutel, schijnwaarde(soort, afgeleid(soort, waarde)), soort, waarde);
},
handle(soort, waarde) {
const sleutel = `${soort} ${waarde}`;
if (heen.has(sleutel)) return heen.get(sleutel);
return onthoud(sleutel, `${soort}:${afgeleid(soort, waarde).slice(0, HANDLE_TEKENS)}`, soort, waarde);
},
resolve(uit) {
const r = terug.get(uit);
return r ? r.waarde : null;
},
uitgegeven() {
return [...terug.entries()].map(([uit, r]) => ({ uit, soort: r.soort }));
},
};
}
+90
View File
@@ -0,0 +1,90 @@
// Generated, do not edit. sha256:74e4ed13a040cfe4e2dc4c23217c88607a06c14c8cc97aceaf7026b8d073ba91
import { CONFIG } from '../../shared/config.js';
const LADDERS = CONFIG.AI.BUCKET_LADDERS;
function bodemVan(ladderNaam) {
const min = CONFIG.AI.BUCKET_MIN_VALUES[ladderNaam] || 0;
if (!min) return 0;
const ladder = LADDERS[ladderNaam];
let smalst = Infinity;
for (let i = 0; i < ladder.length; i++) {
const onder = ladder[i];
const boven = i + 1 < ladder.length ? ladder[i + 1] : 10;
smalst = Math.min(smalst, (boven - onder) / onder);
}
return min / smalst;
}
export function bucket(waarde, ladderNaam = 'COARSE') {
const ladder = LADDERS[ladderNaam];
if (!ladder) throw new Error(`unknown ladder: ${ladderNaam}`);
if (typeof waarde !== 'number' || !Number.isFinite(waarde)) return null;
if (waarde === 0) return { from: 0, to: 0, exact: true };
const negatief = waarde < 0;
const abs = Math.abs(waarde);
const bodem = Number.isInteger(waarde) ? bodemVan(ladderNaam) : 0;
if (bodem && abs < bodem) {
return negatief
? { from: -bodem, to: 0, exact: false, floor: true }
: { from: 0, to: bodem, exact: false, floor: true };
}
const orde = Math.floor(Math.log10(abs));
const schaal = 10 ** orde;
const genormaliseerd = Math.min(Math.max(abs / schaal, 1), 9.999999999);
let onder = ladder[0];
let boven = 10;
for (let i = 0; i < ladder.length; i++) {
if (genormaliseerd >= ladder[i]) {
onder = ladder[i];
boven = i + 1 < ladder.length ? ladder[i + 1] : 10;
}
}
const van = onder * schaal;
const tot = boven * schaal;
return negatief ? { from: -tot, to: -van, exact: false } : { from: van, to: tot, exact: false };
}
export function bucketLabel(waarde, ladderNaam = 'COARSE') {
const b = bucket(waarde, ladderNaam);
if (!b) return null;
if (b.exact) return String(b.from);
if (b.floor) return b.to === 0 ? `meer dan ${getal(b.from)}` : `minder dan ${getal(b.to)}`;
return `${getal(b.from)} tot ${getal(b.to)}`;
}
function getal(n) {
return Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100);
}
export function shareBucket(pct) {
const grenzen = CONFIG.AI.SHARE_BUCKETS;
if (typeof pct !== 'number' || !Number.isFinite(pct) || pct < 0) return null;
if (pct === 0) return { from: 0, to: 0, below: false };
if (pct < grenzen[0]) return { from: 0, to: grenzen[0], below: true };
let onder = grenzen[0];
let boven = grenzen[grenzen.length - 1];
for (let i = 0; i < grenzen.length; i++) {
if (pct >= grenzen[i]) {
onder = grenzen[i];
boven = i + 1 < grenzen.length ? grenzen[i + 1] : grenzen[i];
}
}
if (onder === boven) return { from: 100, to: 100, below: false };
return { from: onder, to: boven, below: false };
}
export function shareLabel(pct) {
const b = shareBucket(pct);
if (!b) return null;
if (b.from === b.to) return `${b.from}%`;
if (b.below) return `minder dan ${b.to}%`;
return `${b.from} tot ${b.to}%`;
}
+105
View File
@@ -0,0 +1,105 @@
// Generated, do not edit. sha256:ec282b725055d62dd35348c8698b9fb220cb23c5aa15fc53463e42d07d187d42
import { getPref } from '../data/preferences.js';
import { ENUMS } from '../../shared/config.js';
import { hasFeature } from '../license.mjs';
export const AI_DISABLED = 'ai_disabled';
export const AI_WRITE_DENIED = 'ai_write_denied';
export const AI_CONTROL_DENIED = 'ai_control_denied';
export const AI_UNLICENSED = 'ai_unlicensed';
export const AI_UNLICENSED_TEXT =
'De AI-koppeling hoort bij de betaalde versie van AssetApp. De gratis versie laat de administratie '
+ 'niet lezen of wijzigen via een AI-koppeling. Er is niets gelezen en niets gewijzigd; deze poging '
+ 'staat wel in het activiteitenlogboek.';
export const AI_INACTIVE = 'ai_inactive';
export const AI_REFUSALS = Object.freeze([AI_UNLICENSED, AI_INACTIVE, AI_DISABLED, AI_WRITE_DENIED, AI_CONTROL_DENIED]);
export const AI_INACTIVE_TEXT =
'De AI-koppeling van AssetApp staat uit. Er wordt niets gelezen, niets gewijzigd en niets getoond, '
+ 'ook geen verbindingstest. De eigenaar van de app zet hem weer aan met de schuif "Actief" onder '
+ 'Systeem, tabblad AI-activiteit. Deze poging staat wel in het activiteitenlogboek.';
export const AI_DISABLED_TEXT =
'De AI-koppeling van AssetApp mag de administratie niet lezen. De koppeling zelf staat wel aan; '
+ 'lezen is een aparte schuif die de eigenaar van de app zelf aanzet onder Systeem, tabblad '
+ 'AI-activiteit. Er is niets gelezen en niets gewijzigd; deze poging staat wel in het '
+ 'activiteitenlogboek.';
export function isAiEnabled() {
try { return getPref('ai', 'allow_read') !== 0; }
catch { return false; }
}
export function isAiActive() {
try { return getPref('ai', 'enabled') !== 0; }
catch { return false; }
}
export function assertAiEnabled() {
if (isAiEnabled()) return;
const e = new Error(AI_DISABLED_TEXT);
e.code = AI_DISABLED;
throw e;
}
export const AI_WRITE_DENIED_TEXT =
'De AI-koppeling van AssetApp mag niets wijzigen. Lezen kan wel; schrijven is een aparte instelling '
+ 'die de eigenaar van de app zelf aanzet onder Systeem, tabblad AI-activiteit. Er is niets '
+ 'gewijzigd; deze poging staat wel in het activiteitenlogboek.';
export function isAiWriteAllowed() {
try { return getPref('ai', 'allow_write') === 1; }
catch { return false; }
}
export const AI_CONTROL_DENIED_TEXT =
'De AI-koppeling van AssetApp mag de app niet besturen. Lezen kan wel; navigeren is een aparte '
+ 'instelling die de eigenaar van de app zelf aanzet onder Systeem, tabblad AI-activiteit. Er is '
+ 'niets gewijzigd; deze poging staat wel in het activiteitenlogboek.';
export function isAiControlAllowed() {
try { return getPref('ai', 'allow_control') === 1; }
catch { return false; }
}
export function isAiRawAllowed() {
try { return getPref('ai', 'allow_raw') === 1; }
catch { return false; }
}
function weigering(scope) {
if (!hasFeature('ai')) return { code: AI_UNLICENSED, text: AI_UNLICENSED_TEXT };
if (!isAiActive()) return { code: AI_INACTIVE, text: AI_INACTIVE_TEXT };
if (scope === 'none') return null;
if (scope === 'read') {
return isAiEnabled() ? null : { code: AI_DISABLED, text: AI_DISABLED_TEXT };
}
if (scope === 'write') {
return isAiWriteAllowed() ? null : { code: AI_WRITE_DENIED, text: AI_WRITE_DENIED_TEXT };
}
if (scope === 'control') {
return isAiControlAllowed() ? null : { code: AI_CONTROL_DENIED, text: AI_CONTROL_DENIED_TEXT };
}
return {
code: AI_DISABLED,
text: `Onbekende scope '${scope}'; toegestaan is ${ENUMS.AI_SCOPE.join(', ')}.`,
};
}
export function assertScope(scope) {
const w = weigering(scope);
if (!w) return;
const e = new Error(w.text);
e.code = w.code;
throw e;
}
export function mayScope(scope) {
return weigering(scope) === null;
}
+25
View File
@@ -0,0 +1,25 @@
// Generated, do not edit. sha256:ffc38e35f09c2a28784811c7505bad194c65e207ac58b284c202aff9a6c08ec2
import { randomBytes } from 'node:crypto';
import { readSecretsStore, writeSecretsStore, secretsFile } from '../keys.mjs';
const SLEUTEL = 'ai_handle_secret';
const cache = new Map();
export async function handleSecret() {
const pad = secretsFile();
if (cache.has(pad)) return cache.get(pad);
const store = await readSecretsStore();
const bestaand = store[SLEUTEL];
if (typeof bestaand === 'string' && bestaand.length >= 32) {
cache.set(pad, bestaand);
return bestaand;
}
const nieuw = randomBytes(32).toString('hex');
await writeSecretsStore({ ...store, [SLEUTEL]: nieuw });
cache.set(pad, nieuw);
return nieuw;
}
export function _resetHandleSecret() { cache.clear(); }
+31
View File
@@ -0,0 +1,31 @@
// Generated, do not edit. sha256:ec3bd3327478b77670de174a8843f03ad4b292ec52893e4b4f0f49c508b72a5f
import { CONFIG } from '../../shared/config.js';
export function omittedNote(omitted, max) {
return ` ... en nog ${omitted} niet getoond (grens: ${max} per lijst).`;
}
export function limitRows(rows, max = CONFIG.AI.LIMITS.MAX_ROWS_PER_LIST) {
const alle = Array.isArray(rows) ? rows : [];
if (alle.length <= max) return { rows: alle, omitted: 0, capped: false };
return { rows: alle.slice(0, max), omitted: alle.length - max, capped: true };
}
export function limitedList(rows, render, max = CONFIG.AI.LIMITS.MAX_ROWS_PER_LIST) {
const { rows: zichtbaar, omitted, capped } = limitRows(rows, max);
const regels = zichtbaar.map(render);
if (capped) regels.push(omittedNote(omitted, max));
return { lines: regels, omitted, capped };
}
export function limitText(text, max = CONFIG.AI.LIMITS.MAX_TEXT_CHARS) {
const heel = String(text ?? '');
if (heel.length <= max) return { text: heel, omitted: 0, capped: false };
const markering = `\n[Afgekapt: dit antwoord was te lang en is ingekort op ${max} tekens.]`;
const ruimte = Math.max(0, max - markering.length);
return {
text: heel.slice(0, ruimte) + markering.slice(0, max),
omitted: heel.length - ruimte,
capped: true,
};
}
+231
View File
@@ -0,0 +1,231 @@
// Generated, do not edit. sha256:49025914ad8e6294f0b8c9d075be9b80de842094dd53941a1aaa93bc73409a0d
import { CONFIG } from '../../shared/config.js';
const RANG = new Map(CONFIG.AI.FIELD_CLASSES.map((k, i) => [k, i]));
export const FIELD_POLICY = Object.freeze({
rawData: 'drop',
settings: 'drop',
value: 'drop',
arguments: 'drop',
text: 'drop',
payload: 'drop',
messageParts: 'drop',
keyHeader: 'drop',
signatureHeader: 'drop',
timestampHeader: 'drop',
windowHeader: 'drop',
authenticationHeaderName: 'drop',
authenticationParameterName: 'drop',
address: 'chain',
externalAddress: 'chain',
automaticLabel: 'chain',
txid: 'chain',
transactionHash: 'chain',
blockHeight: 'chain',
blockTime: 'chain',
chainIndex: 'chain',
derivationIndex: 'chain',
scriptType: 'chain',
id: 'handle',
assetId: 'handle',
accountId: 'handle',
fromAccountId: 'handle',
toAccountId: 'handle',
productId: 'handle',
providerId: 'handle',
connectionId: 'handle',
sourceId: 'handle',
labelId: 'handle',
eventId: 'handle',
scheduleId: 'handle',
profileId: 'handle',
walletTransactionId: 'handle',
resolvedEventId: 'handle',
createdEventId: 'handle',
createdAccountId: 'handle',
createdValuationId: 'handle',
callId: 'handle',
claimedBy: 'handle',
principalAssetId: 'handle',
payoutAssetId: 'handle',
name: 'handle',
searchName: 'handle',
description: 'handle',
note: 'handle',
memo: 'handle',
body: 'handle',
fingerprint: 'handle',
summary: 'handle',
origin: 'handle',
filename: 'handle',
importReference: 'handle',
externalId: 'handle',
instanceKey: 'handle',
sessionId: 'handle',
client: 'handle',
detail: 'handle',
fieldsShared: 'handle',
url: 'handle',
baseUrl: 'handle',
scope: 'plain',
amount: 'quantity',
balance: 'quantity',
balanceSats: 'quantity',
balanceFound: 'quantity',
valueSats: 'quantity',
feeSats: 'quantity',
unitFee: 'quantity',
principalAmount: 'quantity',
fineGrams: 'quantity',
productCount: 'quantity',
fiatFee: 'quantityFiat',
fiatValue: 'quantityFiat',
fiatPrice: 'rate',
fiatRate: 'rate',
annualRate: 'rate',
key: 'plain',
portfolio: 'plain',
symbol: 'plain',
unit: 'plain',
subunit: 'plain',
displayUnit: 'plain',
decimals: 'plain',
kind: 'plain',
taxCategory: 'plain',
taxScope: 'plain',
wealthScope: 'plain',
balanceCapture: 'plain',
state: 'plain',
form: 'plain',
type: 'plain',
accountKind: 'plain',
eventType: 'plain',
source: 'plain',
priceOrigin: 'plain',
sourceName: 'plain',
status: 'plain',
lastStatus: 'plain',
direction: 'plain',
suggestedType: 'plain',
method: 'plain',
capability: 'plain',
resolution: 'plain',
transform: 'plain',
internalField: 'plain',
jsonPath: 'plain',
pathTemplate: 'plain',
queryTemplate: 'plain',
quoteCurrency: 'plain',
baseCurrency: 'plain',
currency: 'plain',
quoteUnit: 'plain',
minIntervalSeconds: 'plain',
callsPerMonth: 'plain',
hashAlgorithm: 'plain',
encoding: 'plain',
authenticationKind: 'plain',
authenticationRequired: 'plain',
rateKind: 'plain',
creditCadence: 'plain',
creditWeekday: 'plain',
creditDayOfMonth: 'plain',
compounding: 'plain',
outcome: 'plain',
tool: 'plain',
isActive: 'plain',
isSeed: 'plain',
isSystem: 'plain',
isCatchAll: 'plain',
isOwnCustody: 'plain',
scanEnabled: 'plain',
rank: 'plain',
sortOrder: 'plain',
version: 'plain',
serverVersion: 'plain',
count: 'plain',
highWater: 'plain',
rowCount: 'plain',
okCount: 'plain',
failCount: 'plain',
addressesFound: 'plain',
proposalsCreated: 'plain',
transactionCount: 'plain',
charactersOut: 'plain',
sumLatencyMs: 'plain',
maxLatencyMs: 'plain',
layer: 'plain',
target: 'plain',
providerKey: 'plain',
subject: 'plain',
httpStatus: 'plain',
errorKey: 'plain',
latencyMs: 'plain',
requestId: 'plain',
windowMs: 'plain',
gridX: 'plain',
gridY: 'plain',
spanWidth: 'plain',
spanHeight: 'plain',
bucketMinute: 'plain',
tableName: 'plain',
operation: 'plain',
rowId: 'handle',
originId: 'handle',
changes: 'drop',
occurredAt: 'plain',
claimedAt: 'plain',
expiresAt: 'plain',
resolvedAt: 'plain',
createdAt: 'plain',
updatedAt: 'plain',
closedAt: 'plain',
detectedAt: 'plain',
dismissedAt: 'plain',
gapAcceptedAt: 'plain',
importedAt: 'plain',
observedAt: 'plain',
scannedAt: 'plain',
lastScannedAt: 'plain',
lastCheckedAt: 'plain',
rateAt: 'plain',
valuedOn: 'plain',
fromDay: 'plain',
untilDay: 'plain',
day: 'plain',
startsOn: 'plain',
endsOn: 'plain',
periodStart: 'plain',
periodEnd: 'plain',
});
export const TABLE_POLICY = Object.freeze({
Preference: Object.freeze({ value: 'plain' }),
});
export function fieldClass(field, table = null) {
if (table == null && typeof field === 'string') {
const punt = field.indexOf('.');
if (punt > 0) return fieldClass(field.slice(punt + 1), field.slice(0, punt));
}
const basis = Object.hasOwn(FIELD_POLICY, field) ? FIELD_POLICY[field] : null;
if (basis === null || table == null) return basis;
if (!Object.hasOwn(TABLE_POLICY, table)) return basis;
const perTabel = TABLE_POLICY[table];
return Object.hasOwn(perTabel, field) ? perTabel[field] : basis;
}
export function strictestClass(a, b) {
if (a == null) return b;
if (b == null) return a;
return (RANG.get(a) ?? -1) >= (RANG.get(b) ?? -1) ? a : b;
}
+41
View File
@@ -0,0 +1,41 @@
// Generated, do not edit. sha256:5d115259dddad7958f7f0a20af1477db47898da5fb7fe4a5f958f0a3bc9b0df0
import { isAiRawAllowed } from './gate.js';
import { getPref } from '../data/preferences.js';
function bucketing(key) {
try { return getPref('ai', key) !== 0; }
catch { return true; }
}
export function precisionSettings() {
if (isAiRawAllowed()) {
return Object.freeze({
rawValues: true,
bucketShares: false,
bucketTotals: false,
bucketAmounts: false,
bucketQuantities: false,
});
}
return Object.freeze({
rawValues: false,
bucketShares: bucketing('bucket_shares'),
bucketTotals: bucketing('bucket_totals'),
bucketAmounts: bucketing('bucket_amounts'),
bucketQuantities: bucketing('bucket_quantities'),
});
}
export function shareDisclosure(precision) {
const p = precision ?? {};
if (p.rawValues) return 'exact';
if (!p.bucketAmounts) return 'exact';
if (p.bucketShares) return 'bucketed';
if (p.bucketTotals) return 'exact';
return 'omit';
}
export function precisionKey(precision) {
const p = precision ?? {};
return Object.keys(p).sort().map(k => `${k}=${String(p[k])}`).join(',') || 'leeg';
}
+139
View File
@@ -0,0 +1,139 @@
// Generated, do not edit. sha256:45a1e1f166c06347eecc16618109c15256ed61caf29cc462a558b68275a28363
import { fieldClass } from './policy.js';
import { createAliasRegistry, isSchijnwaarde } from './aliases.js';
import { bucketLabel } from './buckets.js';
const SCHIJN_SOORT = Object.freeze({
address: 'address',
externalAddress: 'address',
automaticLabel: 'address',
txid: 'txid',
transactionHash: 'txid',
});
const HANDLE_SOORT = Object.freeze({
assetId: 'asset',
accountId: 'account',
fromAccountId: 'account',
toAccountId: 'account',
productId: 'product',
providerId: 'provider',
connectionId: 'connection',
sourceId: 'walletSource',
labelId: 'label',
eventId: 'event',
scheduleId: 'schedule',
profileId: 'profile',
walletTransactionId: 'walletTransaction',
resolvedEventId: 'event',
createdEventId: 'event',
createdAccountId: 'account',
createdValuationId: 'valuation',
principalAssetId: 'asset',
payoutAssetId: 'asset',
});
export function createTranslator({ secret, precision } = {}) {
const stand = Object.freeze({ rawValues: false, ...(precision ?? {}) });
const raw = stand.rawValues === true;
const registry = createAliasRegistry({ secret });
const gedeeld = new Set();
const uitgaand = new Map();
function onthoudUit(uit, echt) {
uitgaand.set(uit, echt);
return uit;
}
function veld(naam, waarde, entiteit, tabel, rol) {
const klasse = fieldClass(naam, tabel);
const logNaam = (tabel != null && klasse !== fieldClass(naam)) ? `${tabel}.${naam}` : naam;
if (klasse === null || klasse === 'drop') return undefined;
if (waarde === null || waarde === undefined) {
gedeeld.add(logNaam);
return null;
}
switch (klasse) {
case 'plain':
case 'rate':
gedeeld.add(logNaam);
return waarde;
case 'quantity':
case 'quantityFiat': {
if (raw) { gedeeld.add(logNaam); return waarde; }
const emmeren = klasse === 'quantity'
? stand.bucketQuantities !== false
: (rol === 'total' ? stand.bucketTotals !== false : stand.bucketAmounts !== false);
if (!emmeren) { gedeeld.add(logNaam); return waarde; }
const label = bucketLabel(Number(waarde), klasse === 'quantityFiat' ? 'FINE' : 'COARSE');
if (label === null) return undefined;
gedeeld.add(logNaam);
return label;
}
case 'chain': {
const soort = SCHIJN_SOORT[naam];
if (!soort) return undefined;
gedeeld.add(logNaam);
if (raw) return waarde;
return onthoudUit(registry.alias(soort, String(waarde)), String(waarde));
}
case 'handle': {
if (raw) { gedeeld.add(logNaam); return waarde; }
const soort = naam === 'id' ? (entiteit || 'record') : (HANDLE_SOORT[naam] || 'text');
gedeeld.add(logNaam);
return onthoudUit(registry.handle(soort, String(waarde)), String(waarde));
}
default:
throw new Error(`no handling for class '${klasse}' (field '${naam}')`);
}
}
function toAi(waarde, opties = {}) {
const entiteit = opties.entity || null;
const tabel = opties.table || null;
const rol = opties.role || 'row';
if (Array.isArray(waarde)) return waarde.map(v => toAi(v, opties));
if (waarde === null || typeof waarde !== 'object') return waarde;
const uit = {};
for (const [naam, v] of Object.entries(waarde)) {
const vertaald = (v !== null && typeof v === 'object')
? (fieldClass(naam, tabel) === null ? undefined : toAi(v, {}))
: veld(naam, v, entiteit, tabel, rol);
if (vertaald !== undefined) uit[naam] = vertaald;
}
return uit;
}
function fromAi(tekst) {
if (typeof tekst !== 'string') return { text: tekst, unresolved: [] };
let uit = tekst;
const sleutels = [...uitgaand.keys()].sort((a, b) => b.length - a.length);
for (const s of sleutels) uit = uit.split(s).join(uitgaand.get(s));
const unresolved = [];
for (const m of uit.matchAll(/\b(?:bc1q[0-9a-z]{38}|xpub[0-9A-Za-z]{107}|[0-9a-z]{64})\b/g)) {
if (isSchijnwaarde(m[0])) unresolved.push(m[0]);
}
return { text: uit, unresolved };
}
return {
toAi,
fromAi,
beginCall() { gedeeld.clear(); },
fieldsShared: () => [...gedeeld].sort(),
resolve: uit => registry.resolve(uit),
precision: stand,
};
}
+293
View File
@@ -0,0 +1,293 @@
// Generated, do not edit. sha256:6b0db04ca1e019c207aa13f2f0133b94c5f5783d9d7742ec8001cff39b53d404
import * as custom from './price/custom.js';
import { getKeys, setKeys, keyStatus } from './keys.mjs';
import * as market from './market-service.mjs';
import { handleDataApi } from './data-api.mjs';
import { recordIntegrationCall } from './data/integration-log.js';
import { mcpClientConfig } from './mcp/client-config.mjs';
import { dryRunTool, dryRunTools } from './mcp/dry-run.mjs';
import { exportDatabase } from './db-export.mjs';
import { exportPriceHistory } from './price-export.mjs';
import { exportIntegrations } from './integrations-export.mjs';
import { restoreDatabase } from './db-restore.mjs';
import { startFreshDatabase, chooseSetupCurrency } from './db-fresh.mjs';
import { createBackup, restoreBackup, isBackupBestand } from './db-backup.mjs';
import {
overviewPortfolios, createPortfolio, renamePortfolio, archivePortfolio, purgeArchived,
activatePortfolio,
} from './portfolios-admin.mjs';
import { credentialsStatus, setCredentials, clearCredentials } from './auth.mjs';
import { closeDatabase } from './db.js';
import { apiRoutes, services } from './registers.mjs';
import { CONFIG } from '../shared/config.js';
import { AppError, errorPayload } from '../shared/app-error.js';
function stopVoorWissel() {
try { closeDatabase(); } catch { }
process.exit(CONFIG.PORTFOLIO.RESTART_EXIT_CODE);
}
function stopVoorSluiten() {
services.get('kiosk')?.close();
try { closeDatabase(); } catch { }
process.exit(0);
}
const isLoopback = (req) => ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket?.remoteAddress);
const recordedBrowser = () => services.get('kiosk')?.recorded() ?? null;
function sendJson(res, status, obj) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'X-Content-Type-Options': 'nosniff' });
res.end(JSON.stringify(obj));
}
const sendFout = (res, status, e) => sendJson(res, status, errorPayload(e));
function readRawBody(req, max = 100 * 1024 * 1024) {
return new Promise((resolve, reject) => {
let len = 0; const chunks = [];
req.on('data', c => {
len += c.length;
if (len > max) { req.destroy(); reject(new AppError('common.fileTooLarge', null, 'File too large.')); }
else chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
export function readJsonBody(req, max = 256 * 1024) {
return new Promise((resolve, reject) => {
let len = 0; const chunks = [];
req.on('data', c => {
len += c.length;
if (len > max) { req.destroy(); reject(new Error('Body te groot.')); }
else chunks.push(c);
});
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
if (!raw) return resolve({});
try { resolve(JSON.parse(raw)); } catch { reject(new Error('Invalid JSON.')); }
});
req.on('error', reject);
});
}
export async function handleApi(req, res, url) {
const u = new URL(url, 'http://localhost');
const path = u.pathname;
const q = u.searchParams;
if (path === '/api/health') { sendJson(res, 200, { ok: true }); return true; }
if (path === '/api/app/window' && req.method === 'GET') {
sendJson(res, 200, { canClose: isLoopback(req) && recordedBrowser() !== null });
return true;
}
if (path === '/api/app/close' && req.method === 'POST') {
if (!isLoopback(req) || !recordedBrowser()) {
sendFout(res, 409, new AppError('appWindow.closeUnavailable', null,
'Closing is only possible on this machine, in the app window the app started.'));
return true;
}
res.on('finish', stopVoorSluiten);
sendJson(res, 200, { closing: true });
return true;
}
if (path.startsWith('/api/data/')) return handleDataApi(req, res, url);
if (path === '/api/mcp/config' && req.method === 'GET') {
sendJson(res, 200, mcpClientConfig());
return true;
}
if (path === '/api/mcp/tools' && req.method === 'GET') {
sendJson(res, 200, { tools: dryRunTools() });
return true;
}
if (path === '/api/mcp/dry-run' && req.method === 'POST') {
try {
const body = await readJsonBody(req);
sendJson(res, 200, await dryRunTool(body.tool, body.arguments));
} catch (e) {
sendFout(res, 400, e);
}
return true;
}
if (path === '/api/db/export' && req.method === 'GET') {
try { await exportDatabase(res); }
catch (e) { sendFout(res, 500, e); }
return true;
}
if (path === '/api/price-history/export' && req.method === 'GET') {
try { exportPriceHistory(res); }
catch (e) { sendFout(res, 500, e); }
return true;
}
if (path === '/api/integrations/export' && req.method === 'GET') {
try { exportIntegrations(res); }
catch (e) { sendFout(res, 500, e); }
return true;
}
if (path === '/api/db/backup' && req.method === 'POST') {
try {
const { passphrase } = await readJsonBody(req);
const { bytes, db, secrets } = await createBackup(passphrase);
const d = new Date();
const p = n => String(n).padStart(2, '0');
const naam = `assetapp-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}.assetbak`;
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${naam}"`,
'Content-Length': bytes.length,
'X-Backup-Db-Bytes': String(db),
'X-Backup-Secrets-Bytes': String(secrets),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
});
res.end(bytes);
} catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/db/import' && req.method === 'POST') {
try {
const bytes = await readRawBody(req);
const uit = isBackupBestand(bytes)
? await restoreBackup(bytes, req.headers['x-backup-passphrase'] || '')
: await restoreDatabase(bytes);
sendJson(res, 200, { ...uit, versleuteld: isBackupBestand(bytes) });
} catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/db/fresh' && req.method === 'POST') {
try { sendJson(res, 200, await startFreshDatabase()); }
catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/setup/currency' && req.method === 'POST') {
try { sendJson(res, 200, await chooseSetupCurrency((await readJsonBody(req)).baseCurrency)); }
catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/portfolios' && req.method === 'GET') {
try { sendJson(res, 200, await overviewPortfolios()); }
catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/portfolios/activate' && req.method === 'POST') {
try {
const uit = activatePortfolio((await readJsonBody(req)).naam);
if (uit.gewisseld) res.on('finish', stopVoorWissel);
sendJson(res, 200, uit);
} catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/portfolios/create' && req.method === 'POST') {
try {
const { naam, kopieVan, baseCurrency } = await readJsonBody(req);
sendJson(res, 200, await createPortfolio(naam, { kopieVan: kopieVan || null, baseCurrency: baseCurrency || null }));
} catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/portfolios/rename' && req.method === 'POST') {
try {
const { van, naar } = await readJsonBody(req);
sendJson(res, 200, await renamePortfolio(van, naar));
} catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/portfolios/archive' && req.method === 'POST') {
try { sendJson(res, 200, await archivePortfolio((await readJsonBody(req)).naam)); }
catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/portfolios/purge' && req.method === 'POST') {
try { sendJson(res, 200, await purgeArchived((await readJsonBody(req)).naam)); }
catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/auth/status' && req.method === 'GET') {
try { sendJson(res, 200, await credentialsStatus()); }
catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/auth/set' && req.method === 'POST') {
try {
const { password } = await readJsonBody(req);
sendJson(res, 200, await setCredentials(password));
} catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/auth/clear' && req.method === 'POST') {
try { sendJson(res, 200, await clearCredentials()); }
catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/price/call' && req.method === 'POST') {
let provider = null, capability = null, symbol = null, creds = null;
const t0 = performance.now();
try {
const body = await readJsonBody(req);
({ provider, capability, symbol } = body);
const { date, with_raw } = body;
if (!provider || !provider.key) throw new Error('The provider config is missing.');
creds = await getKeys(provider.key);
sendJson(res, 200, await custom.priceCall(provider, capability, symbol, date, creds, { withRaw: !!with_raw }));
} catch (e) {
if (provider?.key) {
try {
await recordIntegrationCall(custom.failureEntry(provider, capability, symbol, e, performance.now() - t0),
{ secrets: Object.values(creds || {}).filter(v => typeof v === 'string') });
} catch { }
}
sendFout(res, 502, e);
}
return true;
}
if (path === '/api/keys/status' && req.method === 'GET') {
try { sendJson(res, 200, await keyStatus(q.get('provider'), q.get('authenticationKind'))); }
catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/keys/set' && req.method === 'POST') {
try {
const { provider, authenticationKind, values } = await readJsonBody(req);
sendJson(res, 200, await setKeys(provider, authenticationKind, values));
} catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/market/status' && req.method === 'GET') {
try { sendJson(res, 200, await market.marketServiceStatus()); }
catch (e) { sendFout(res, 400, e); }
return true;
}
if (path === '/api/market/test' && req.method === 'POST') {
try { sendJson(res, 200, await market.testMarketService()); }
catch (e) { sendFout(res, 502, e); }
return true;
}
const route = apiRoutes.list().find(r => r.path === path && r.method === req.method);
if (route) {
try {
const body = req.method === 'POST' ? await readJsonBody(req) : {};
sendJson(res, 200, await route.run({ q, body }));
} catch (e) { sendFout(res, route.errorStatus ?? 400, e); }
return true;
}
return false;
}
+167
View File
@@ -0,0 +1,167 @@
// Generated, do not edit. sha256:29c149da1bd119d466012f8c80b13c9f6ba6ac924e6326dd2b0629b05ed6ce6e
import { readFile } from 'node:fs/promises';
import { join, resolve, normalize, extname, dirname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { handleApi, readJsonBody } from './api.mjs';
import {
clearedSessionCookie, passwordMatches, sendUnauthorized, sessionCookie, sessionTokenOf,
} from './auth.mjs';
import { createAttemptLimiter, createSessionStore } from './sessions.mjs';
import { CONFIG } from '../shared/config.js';
import { connectSrcOrigins } from '../shared/csp.js';
import { listConnections } from './data/connections.js';
import { errorPayload } from '../shared/app-error.js';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export const CLIENT = join(ROOT, 'client');
const SHARED = join(ROOT, 'shared');
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.wasm': 'application/wasm',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf',
};
function explorerUrls() {
try { return listConnections('btc_explorer').map(c => c.url); } catch { return []; }
}
const directive = (naam, ...bronnen) => [naam, ...bronnen.flat().filter(Boolean)].join(' ');
const csp = () => [
directive('default-src', `'self'`),
directive('script-src', `'self'`),
directive('style-src', `'self'`, `'unsafe-inline'`),
directive('style-src-elem', `'self'`),
directive('style-src-attr', `'unsafe-inline'`),
directive('font-src', `'self'`),
directive('img-src', `'self'`, 'data:'),
directive('connect-src', `'self'`, connectSrcOrigins(explorerUrls())),
directive('object-src', `'none'`),
directive('base-uri', `'self'`),
directive('frame-ancestors', `'none'`),
].join('; ');
const HTML_CACHE_CONTROL = 'no-store';
const STATIC_CACHE_CONTROL = 'no-cache';
function resolveStatic(urlPath) {
let rel;
try { rel = decodeURIComponent(urlPath.split('?')[0]); }
catch { return null; }
if (rel === '/' || rel === '/index.html') return join(CLIENT, 'index.html');
if (rel === CONFIG.AUTH.LOGIN_PATH) return join(CLIENT, 'login.html');
const onder = (basis, pad) => {
const abs = normalize(join(basis, pad));
return abs.startsWith(basis + sep) ? abs : null;
};
if (rel.startsWith('/shared/')) return onder(SHARED, rel.slice('/shared'.length));
return onder(CLIENT, rel);
}
function send(res, status, body, headers = {}) {
res.writeHead(status, { 'X-Content-Type-Options': 'nosniff', ...headers });
res.end(body);
}
function sendJson(res, status, obj, extra = {}) {
send(res, status, JSON.stringify(obj), { 'Content-Type': 'application/json; charset=utf-8', ...extra });
}
const LOGIN_ASSETS = new Set([
'/login.js',
'/css/styles.css',
'/img/favicon.svg',
'/vendor/inter/inter.css',
]);
const LOGIN_ASSET_PREFIX = '/vendor/inter/files/';
const magZonderSessie = (pad) =>
pad === CONFIG.AUTH.LOGIN_PATH || LOGIN_ASSETS.has(pad) || pad.startsWith(LOGIN_ASSET_PREFIX);
export function createRequestListener({ credentials = null, secure = false } = {}) {
const sessies = createSessionStore();
const limiet = createAttemptLimiter();
async function inloggen(req, res) {
const adres = req.socket?.remoteAddress || 'unknown';
const wacht = limiet.retryAfterMs(adres);
if (wacht > 0) {
return sendJson(res, 429, { error: 'Too many attempts.', retryAfterMs: wacht },
{ 'Retry-After': String(Math.ceil(wacht / 1000)) });
}
let body;
try { body = await readJsonBody(req); }
catch (e) { return sendJson(res, 400, errorPayload(e)); }
if (!passwordMatches(body?.password, credentials)) {
return sendJson(res, 401, { error: 'Wrong password.', retryAfterMs: limiet.fail(adres) });
}
limiet.succeed(adres);
return sendJson(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie(sessies.create(), { secure }) });
}
function uitloggen(req, res) {
const token = sessionTokenOf(req);
if (token) sessies.drop(token);
return sendJson(res, 200, { ok: true }, { 'Set-Cookie': clearedSessionCookie({ secure }) });
}
return async (req, res) => {
try {
const url = req.url || '/';
const pad = url.split('?')[0];
if (credentials) {
if (pad === '/api/login') {
if (req.method !== 'POST') return sendJson(res, 405, { error: 'Method not allowed' });
return await inloggen(req, res);
}
if (pad === '/api/logout') {
if (req.method !== 'POST') return sendJson(res, 405, { error: 'Method not allowed' });
return uitloggen(req, res);
}
const ingelogd = sessies.isValid(sessionTokenOf(req));
if (ingelogd && pad === CONFIG.AUTH.LOGIN_PATH) {
return send(res, 302, '', { Location: '/', 'Cache-Control': 'no-store' });
}
if (!ingelogd && !magZonderSessie(pad)) {
return sendUnauthorized(res, { document: !pad.startsWith('/api/') });
}
} else if (pad === CONFIG.AUTH.LOGIN_PATH) {
return send(res, 302, '', { Location: '/', 'Cache-Control': 'no-store' });
}
if (url.startsWith('/api/')) {
const handled = await handleApi(req, res, url);
if (!handled) sendJson(res, 404, { error: 'Unknown endpoint' });
return;
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
return sendJson(res, 405, { error: 'Method not allowed' });
}
const file = resolveStatic(url);
if (!file) return send(res, 404, 'Not found');
const data = await readFile(file);
const ext = extname(file).toLowerCase();
const type = MIME[ext] || 'application/octet-stream';
const cacheControl = ext === '.html' ? HTML_CACHE_CONTROL : STATIC_CACHE_CONTROL;
return send(res, 200, req.method === 'HEAD' ? '' : data, { 'Content-Type': type, 'Content-Security-Policy': csp(), 'Cache-Control': cacheControl });
} catch (err) {
if (err && err.code === 'ENOENT') return send(res, 404, 'Not found');
console.error('[assetapp] requestfout:', err);
return send(res, 500, 'Interne fout');
}
};
}
+126
View File
@@ -0,0 +1,126 @@
// Generated, do not edit. sha256:b90a0342814d40cb5e6f51f946a82feca678a01b63d4cdb3e948bcbb7e125654
import { createHash, timingSafeEqual } from 'node:crypto';
import { CONFIG } from '../shared/config.js';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { readSecretsStore, writeSecretsStore } from './keys.mjs';
import { installDir } from './portfolios.mjs';
import { fail } from '../shared/app-error.js';
const AUTH_SLEUTEL = 'assetapp_auth';
const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
const digest = (waarde) => createHash('sha256').update(String(waarde), 'utf-8').digest();
export function passwordMatches(presented, expected) {
if (!expected || !expected.password) return false;
if (typeof presented !== 'string' || !presented) return false;
return timingSafeEqual(digest(presented), digest(expected.password));
}
export function parseCookies(header) {
const uit = {};
if (typeof header !== 'string') return uit;
for (const deel of header.split(';')) {
const i = deel.indexOf('=');
if (i < 0) continue;
const naam = deel.slice(0, i).trim();
if (!naam || naam in uit) continue;
uit[naam] = deel.slice(i + 1).trim();
}
return uit;
}
const vlaggen = (secure) => `Path=/; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`;
export function sessionCookie(token, { secure = false } = {}) {
const seconden = Math.floor(CONFIG.AUTH.SESSION_TTL_MS / 1000);
return `${CONFIG.AUTH.COOKIE_NAME}=${token}; Max-Age=${seconden}; ${vlaggen(secure)}`;
}
export function clearedSessionCookie({ secure = false } = {}) {
return `${CONFIG.AUTH.COOKIE_NAME}=; Max-Age=0; ${vlaggen(secure)}`;
}
export function sessionTokenOf(req) {
return parseCookies(req?.headers?.cookie)[CONFIG.AUTH.COOKIE_NAME] || null;
}
export function sendUnauthorized(res, { document = false } = {}) {
if (document) {
res.writeHead(302, {
Location: CONFIG.AUTH.LOGIN_PATH,
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
});
return res.end();
}
res.writeHead(401, {
'Content-Type': 'application/json; charset=utf-8',
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'Not logged in.' }));
}
export async function loadCredentials() {
const { ASSETAPP_PASSWORD: password } = process.env;
if (password) return { password };
const opgeslagen = (await readSecretsStore())[AUTH_SLEUTEL];
if (opgeslagen && opgeslagen.password) return { password: String(opgeslagen.password) };
return null;
}
export async function credentialsStatus() {
const { ASSETAPP_PASSWORD: password } = process.env;
if (password) return { ingesteld: true, bron: 'omgeving', wijzigbaar: false };
const opgeslagen = (await readSecretsStore())[AUTH_SLEUTEL];
const ingesteld = Boolean(opgeslagen && opgeslagen.password);
return { ingesteld, bron: ingesteld ? 'portefeuille' : 'geen', wijzigbaar: true };
}
export async function setCredentials(password) {
const w = String(password ?? '');
if (!w) fail('portfolios.passwordRequired', null, 'A password is required.');
const store = await readSecretsStore();
await writeSecretsStore({ ...store, [AUTH_SLEUTEL]: { password: w } });
return { ...await credentialsStatus(), naHerstart: true };
}
export async function clearCredentials() {
const store = await readSecretsStore();
if (!(AUTH_SLEUTEL in store)) return { ...await credentialsStatus(), naHerstart: false };
delete store[AUTH_SLEUTEL];
await writeSecretsStore(store);
return { ...await credentialsStatus(), naHerstart: true };
}
const leesOfNull = async (pad) => {
try { return await readFile(pad); }
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
};
export async function loadTlsOptions() {
const certPad = process.env.ASSETAPP_TLS_CERT || join(installDir(), 'tls', 'cert.pem');
const keyPad = process.env.ASSETAPP_TLS_KEY || join(installDir(), 'tls', 'key.pem');
const [cert, key] = await Promise.all([leesOfNull(certPad), leesOfNull(keyPad)]);
if (!cert && !key) return null;
if (!cert) throw new Error(`TLS half configured: key found (${keyPad}) but no certificate (${certPad}).`);
if (!key) throw new Error(`TLS half configured: certificate found (${certPad}) but no key (${keyPad}).`);
return { cert, key };
}
export function resolveBinding({ tls = false, credentials = null, host = null } = {}) {
const beschermd = Boolean(tls) && Boolean(credentials);
if (host) {
if (LOOPBACK.has(host) || beschermd) return { host, waarschuwing: null };
const mist = [Boolean(tls) ? null : 'TLS', credentials ? null : 'inloggegevens'].filter(Boolean).join(' en ');
return {
host,
waarschuwing: `ASSETAPP_HOST=${host} stelt de app open voor het hele netwerk terwijl ${mist} ontbreekt. `
+ 'Iedereen die deze machine kan bereiken, kan de app openen.',
};
}
return { host: beschermd ? '0.0.0.0' : '127.0.0.1', waarschuwing: null };
}
+200
View File
@@ -0,0 +1,200 @@
// Generated, do not edit. sha256:eca3b25957829a0a34656d92265e558efc50996dc58250a690f2dba7d2ab107a
import { AppError, errorPayload } from '../shared/app-error.js';
import { dataModules } from './registers.mjs';
import * as accountsMerge from './data/accounts-merge.js';
import * as accounts from './data/accounts.js';
import * as aiAudit from './data/ai-audit.js';
import * as aiProposals from './data/ai-proposals.js';
import * as apiMetrics from './data/api-metrics.js';
import * as assetNotes from './data/asset-notes.js';
import * as assets from './data/assets.js';
import * as bus from './data/bus.js';
import * as commands from './data/commands.js';
import * as connections from './data/connections.js';
import * as counts from './data/counts.js';
import * as dashboard from './data/dashboard.js';
import * as events from './data/events.js';
import * as hygiene from './data/hygiene.js';
import * as integrationLog from './data/integration-log.js';
import * as interestCredits from './data/interest-credits.js';
import * as interestSchedules from './data/interest-schedules.js';
import * as labels from './data/labels.js';
import * as licenseBuyer from './data/license-buyer.js';
import * as licenseInstall from './data/license-install.js';
import * as licenseStatus from './data/license-status.js';
import * as support from './data/support.js';
import * as market from './data/market.js';
import * as metals from './data/metals.js';
import * as notifications from './data/notifications.js';
import * as onboarding from './data/onboarding.js';
import * as positions from './data/positions.js';
import * as preferences from './data/preferences.js';
import * as priceHistory from './data/price-history.js';
import * as priceSource from './data/price-source.js';
import * as proposals from './data/proposals.js';
import * as providers from './data/providers.js';
import * as reservations from './data/reservations.js';
import * as scanLogs from './data/scan-logs.js';
import * as structure from './data/structure.js';
import * as tableViewer from './data/table-viewer.js';
import * as txLinks from './data/tx-links.js';
import * as valuations from './data/valuations.js';
import * as walletAddresses from './data/wallet-addresses.js';
import * as walletSources from './data/wallet-sources.js';
import * as walletTxs from './data/wallet-txs.js';
import { flushChanges } from './data/bus.js';
const MODULES = Object.freeze({
'accounts': accounts,
'accounts-merge': accountsMerge,
'ai-audit': aiAudit,
'ai-proposals': aiProposals,
'api-metrics': apiMetrics,
'asset-notes': assetNotes,
'assets': assets,
'bus': bus,
'commands': commands,
'connections': connections,
'counts': counts,
'dashboard': dashboard,
'events': events,
'hygiene': hygiene,
'integration-log': integrationLog,
'interest-credits': interestCredits,
'interest-schedules': interestSchedules,
'labels': labels,
'license-buyer': licenseBuyer,
'license-install': licenseInstall,
'license-status': licenseStatus,
'market': market,
'metals': metals,
'notifications': notifications,
'onboarding': onboarding,
'positions': positions,
'preferences': preferences,
'price-history': priceHistory,
'price-source': priceSource,
'proposals': proposals,
'providers': providers,
'reservations': reservations,
'scan-logs': scanLogs,
'structure': structure,
'support': support,
'table-viewer': tableViewer,
'tx-links': txLinks,
'valuations': valuations,
'wallet-addresses': walletAddresses,
'wallet-sources': walletSources,
'wallet-txs': walletTxs,
});
const INTERN = Object.freeze({
'bus': ['flushChanges', 'pruneChanges'],
'integration-log': ['recordIntegrationCall', 'countServiceCall'],
'interest-schedules': ['realizedForSchedule'],
'preferences': ['forgetPrefRow', 'tableViewCfg'],
'providers': ['emitProvidersChanged'],
'tx-links': ['linkProposalTxSoft'],
'wallet-sources': ['purgeWalletSourceSecret', 'assertSourceMatchesAccount'],
});
function modules() {
const extra = dataModules.list();
const dubbel = extra.find(m => Object.hasOwn(MODULES, m.id));
if (dubbel) throw new Error(`data module '${dubbel.id}' is registered and also a core module`);
return {
all: { ...MODULES, ...Object.fromEntries(extra.map(m => [m.id, m.module])) },
intern: { ...INTERN, ...Object.fromEntries(extra.filter(m => m.intern).map(m => [m.id, m.intern])) },
};
}
function oppervlakVan(module, internals = []) {
const intern = new Set(internals);
return Object.freeze(Object.entries(module)
.filter(([naam, waarde]) => typeof waarde === 'function' && !intern.has(naam))
.map(([naam]) => naam)
.sort());
}
let cache = { count: -1, surface: null, all: null };
function current() {
const count = dataModules.list().length;
if (cache.count !== count) {
const { all, intern } = modules();
const surface = Object.freeze(Object.fromEntries(
Object.keys(all).sort().map(mod => [mod, oppervlakVan(all[mod], intern[mod])])));
cache = { count, surface, all };
}
return cache;
}
export const surface = () => current().surface;
export function isAllowed(mod, fn) {
const s = surface();
return Object.hasOwn(s, mod) && s[mod].includes(fn);
}
export function codeer(waarde) {
if (waarde instanceof Map) return { __t: 'Map', v: [...waarde].map(([k, v]) => [k, codeer(v)]) };
if (waarde instanceof Set) return { __t: 'Set', v: [...waarde].map(codeer) };
if (Array.isArray(waarde)) return waarde.map(codeer);
if (waarde && typeof waarde === 'object' && Object.getPrototypeOf(waarde) === Object.prototype) {
return Object.fromEntries(Object.entries(waarde).map(([k, v]) => [k, codeer(v)]));
}
return waarde;
}
function sendJson(res, status, obj) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'X-Content-Type-Options': 'nosniff' });
res.end(JSON.stringify(obj));
}
const MAX_BODY = 5 * 1024 * 1024;
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let len = 0; const chunks = [];
req.on('data', c => {
len += c.length;
if (len > MAX_BODY) { req.destroy(); reject(new AppError('common.requestTooLarge', null, 'Request too large.')); }
else chunks.push(c);
});
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
if (!raw) return resolve({});
try { resolve(JSON.parse(raw)); } catch { reject(new Error('Invalid JSON.')); }
});
req.on('error', reject);
});
}
export async function handleDataApi(req, res, url) {
const pad = url.split('?')[0];
if (!pad.startsWith('/api/data/')) return false;
if (req.method !== 'POST') { sendJson(res, 405, { error: 'Alleen POST.' }); return true; }
const delen = pad.slice('/api/data/'.length).split('/');
if (delen.length !== 2) { sendJson(res, 404, { error: 'Unknown endpoint.' }); return true; }
const [mod, fn] = delen;
if (!isAllowed(mod, fn)) {
sendJson(res, 404, { error: `Unknown data-layer function '${mod}/${fn}'.` });
return true;
}
let args;
try {
const body = await readJsonBody(req);
args = Array.isArray(body.args) ? body.args : [];
} catch (e) { sendJson(res, 400, errorPayload(e)); return true; }
try {
flushChanges();
const result = await current().all[mod][fn](...args);
sendJson(res, 200, { result: codeer(result === undefined ? null : result), changes: flushChanges() });
} catch (e) {
sendJson(res, 400, errorPayload(e));
}
return true;
}
+11
View File
@@ -0,0 +1,11 @@
// Generated, do not edit. sha256:25337d7bfffe1c04bcf94e07f2ea63c7184bfb60c25a933f7d8e83cbc634dc8c
const mandje = new Set();
export function emitChange(naam) { mandje.add(naam); }
export function takeChanges() {
if (!mandje.size) return [];
const uit = [...mandje];
mandje.clear();
return uit;
}
+27
View File
@@ -0,0 +1,27 @@
// Generated, do not edit. sha256:37f1d463b03d2b5d702238cb632740786fc53e2072e4691719327d700c59d56c
import { capacityReached } from '../license.mjs';
import * as db from '../db.js';
import { counts } from './counts.js';
import { RATE_ONLY_ASSET } from './_shared.js';
import { getBaseCurrency } from './portfolio.js';
function fiatAccountCount() {
return db.one(`SELECT COUNT(*) AS n FROM Account WHERE kind = 'bank'`).n;
}
function assetCount() {
return db.one(`SELECT COUNT(*) AS n FROM Asset WHERE symbol <> ? AND NOT ${RATE_ONLY_ASSET}`,
[getBaseCurrency()]).n;
}
export function currentCount(soort) {
const c = counts();
if (soort === 'assets') return assetCount();
if (soort === 'valuations') return c.valuations;
if (soort === 'fiatAccounts') return fiatAccountCount();
throw new Error(`No counter for capacity '${soort}' yet.`);
}
export function capacityReachedFor(soort) {
return capacityReached(soort, currentCount(soort));
}
+71
View File
@@ -0,0 +1,71 @@
// Generated, do not edit. sha256:945ee26961af9e68d689f5dc6bc4e4b542606ba5501cbaee8b17b7df9349fba6
import { CONFIG, ENUMS } from '../../shared/config.js';
export function assertEnum(name, list, value) {
if (!list.includes(value)) {
throw new Error(`Invalid ${name}: '${value}'. Allowed: ${list.join(', ')}.`);
}
}
export function assertTaxCategory(value) {
if (value == null || value === '') return null;
assertEnum('taxCategory', ENUMS.TAX_CATEGORY, value);
return value;
}
export function assertTaxScope(value) {
if (value == null || value === '') return null;
assertEnum('taxScope', ENUMS.TAX_SCOPE, value);
return value;
}
export function assertWealthScope(value) {
if (value == null || value === '') return null;
assertEnum('wealthScope', ENUMS.WEALTH_SCOPE, value);
return value;
}
export const RATE_ONLY_ASSET = `(Asset.kind = 'fiat' AND Asset.symbol <> (SELECT baseCurrency FROM Portfolio)
AND NOT EXISTS (SELECT 1 FROM Event WHERE Event.assetId = Asset.id)
AND NOT EXISTS (SELECT 1 FROM Valuation WHERE Valuation.assetId = Asset.id)
AND NOT EXISTS (SELECT 1 FROM InterestSchedule
WHERE InterestSchedule.principalAssetId = Asset.id OR InterestSchedule.payoutAssetId = Asset.id))`;
export function nowIso() { return new Date().toISOString(); }
const TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,6})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
export function assertTimestamp(field, value) {
const m = typeof value === 'string' ? value.match(TIMESTAMP) : null;
if (!m) {
throw new Error(`Invalid ${field}: '${value}'. A date is expected (2026-08-19), optionally with a time.`);
}
const [, jaar, maand, dag, uur = '0', minuut = '0', seconde = '0'] = m;
const d = new Date(Date.UTC(Number(jaar), Number(maand) - 1, Number(dag)));
const bestaat = d.getUTCFullYear() === Number(jaar)
&& d.getUTCMonth() === Number(maand) - 1
&& d.getUTCDate() === Number(dag);
if (!bestaat) throw new Error(`Invalid ${field}: '${value}' is not an existing date.`);
if (Number(uur) > 23 || Number(minuut) > 59 || Number(seconde) > 59) {
throw new Error(`Invalid ${field}: '${value}' is not an existing time.`);
}
return value;
}
export function assertName(field, value) {
const naam = typeof value === 'string' ? value.trim() : '';
if (!naam) throw new Error(`${field} may not be empty.`);
return naam;
}
export function todayIso() {
return new Date().toLocaleDateString('en-CA', { timeZone: CONFIG.PEILDATUM.TIMEZONE });
}
export function daysBetween(aIso, bIso) { return (Date.parse(bIso) - Date.parse(aIso)) / 86400000; }
export function isoToDate(iso) { return new Date(`${iso}T00:00:00Z`); }
export function dateToIso(d) { return d.toISOString().slice(0, 10); }
export function addDaysIso(iso, n) { const d = isoToDate(iso); d.setUTCDate(d.getUTCDate() + n); return dateToIso(d); }
export function weekdayOf(iso) { return isoToDate(iso).getUTCDay(); }
export function clampDom(year, month0, dom) { const last = new Date(Date.UTC(year, month0 + 1, 0)).getUTCDate(); return Math.min(dom, last); }
+86
View File
@@ -0,0 +1,86 @@
// Generated, do not edit. sha256:ebad02dba29ae50bcbaf9c21bb59936cf43f6be868e86ac00d0e6055f404b26f
import * as db from '../db.js';
import { ACCOUNT_SOURCE_KINDS } from '../../shared/config.js';
import { fail } from '../../shared/app-error.js';
import { eventsNetByAccount } from './events.js';
import { trackUpdate, trackUpdateWhere, trackDelete, trackDeleteWhere, withOrigin } from './change-log.js';
function mergeCompatibility(survivor, victim) {
const S = ACCOUNT_SOURCE_KINDS[survivor.kind] || [];
const V = ACCOUNT_SOURCE_KINDS[victim.kind] || [];
if ((S.length === 0) !== (V.length === 0)) {
return { ok: false, reason: 'sourceMismatch' };
}
if (V.some(k => !S.includes(k))) {
return { ok: false, reason: 'kindCannotCarry' };
}
return { ok: true, reason: null };
}
export function mergeCheck(survivorId, victimId) {
if (!survivorId || !victimId) return { ok: false, reason: 'pickTwo', counts: null };
if (Number(survivorId) === Number(victimId)) return { ok: false, reason: 'sameAccount', counts: null };
const survivor = db.one(`SELECT * FROM Account WHERE id=?`, [survivorId]);
const victim = db.one(`SELECT * FROM Account WHERE id=?`, [victimId]);
if (!survivor || !victim) return { ok: false, reason: 'notFound', counts: null };
const compat = mergeCompatibility(survivor, victim);
const counts = {
sources: db.one(`SELECT COUNT(*) AS n FROM WalletSource WHERE accountId=?`, [victimId]).n,
events: db.one(`SELECT COUNT(*) AS n FROM Event WHERE fromAccountId=?1 OR toAccountId=?1`, [victimId]).n,
schedules: db.one(`SELECT COUNT(*) AS n FROM InterestSchedule WHERE accountId=?`, [victimId]).n,
valuations: db.one(`SELECT COUNT(*) AS n FROM Valuation WHERE accountId =?`, [victimId]).n,
};
return { ok: compat.ok, reason: compat.reason, counts, survivorName: survivor.name, victimName: victim.name };
}
export async function mergeAccounts(survivorId, victimId) {
survivorId = Number(survivorId); victimId = Number(victimId);
const chk = mergeCheck(survivorId, victimId);
if (!chk.ok) fail(`accounts.mergeReason_${chk.reason}`, null, `Merge not possible (${chk.reason}).`);
return withOrigin('merge', victimId, () => voerSamenvoegingUit(survivorId, victimId, chk));
}
async function voerSamenvoegingUit(survivorId, victimId, chk) {
const preDiff = new Map();
for (const v of db.all(`SELECT id, accountId, assetId, rateAt, balance FROM Valuation WHERE accountId = ?1 OR accountId = ?2`, [survivorId, victimId])) {
preDiff.set(v.id, v.balance - eventsNetByAccount(v.accountId, v.assetId, v.rateAt));
}
trackUpdateWhere('Event', `fromAccountId = ?`, [victimId], () =>
db.run(`UPDATE Event SET fromAccountId=?1 WHERE fromAccountId=?2`, [survivorId, victimId]));
trackUpdateWhere('Event', `toAccountId = ?`, [victimId], () =>
db.run(`UPDATE Event SET toAccountId=?1 WHERE toAccountId=?2`, [survivorId, victimId]));
trackUpdateWhere('WalletSource', `accountId = ?`, [victimId], () =>
db.run(`UPDATE WalletSource SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]));
db.run(`UPDATE WalletAddress SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]);
db.run(`UPDATE WalletTransaction SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]);
db.run(`UPDATE ScanLog SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]);
db.run(`UPDATE ReconcileProposal SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]);
db.run(`UPDATE ImportBatch SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]);
trackUpdateWhere('InterestSchedule', `accountId = ?`, [victimId], () =>
db.run(`UPDATE InterestSchedule SET accountId=?1 WHERE accountId=?2`, [survivorId, victimId]));
trackDeleteWhere('Valuation', `accountId = ?2
AND EXISTS (SELECT 1 FROM Valuation v2 WHERE v2.accountId = ?1
AND v2.assetId=Valuation.assetId AND v2.valuedOn=Valuation.valuedOn)`,
[survivorId, victimId], () =>
db.run(`DELETE FROM Valuation WHERE accountId =?2
AND EXISTS (SELECT 1 FROM Valuation v2 WHERE v2.accountId =?1
AND v2.assetId=Valuation.assetId AND v2.valuedOn=Valuation.valuedOn)`,
[survivorId, victimId]));
trackUpdateWhere('Valuation', `accountId = ?`, [victimId], () =>
db.run(`UPDATE Valuation SET accountId =?1 WHERE accountId = ?2`, [survivorId, victimId]));
for (const [id, diff] of preDiff) {
const v = db.one(`SELECT id, assetId, rateAt FROM Valuation WHERE id=?`, [id]);
if (!v) continue;
const newNet = eventsNetByAccount(survivorId, v.assetId, v.rateAt);
trackUpdate('Valuation', id, () =>
db.run(`UPDATE Valuation SET balance=? WHERE id=?`, [newNet + diff, id]));
}
trackDelete('Account', victimId, () => db.run(`DELETE FROM Account WHERE id=?`, [victimId]));
await db.persist();
return chk.counts;
}
+117
View File
@@ -0,0 +1,117 @@
// Generated, do not edit. sha256:defdf4c970fac7940f2bd495e2911283709af51809bb6a2d4c4f9c43ed032b8f
import * as db from '../db.js';
import { toSearchName } from '../../shared/search-name.js';
import { nowUtcIso } from '../../shared/time.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum, assertName, assertTaxCategory, assertTaxScope, assertWealthScope } from './_shared.js';
import { capacityReachedFor } from './_limits.js';
import { fail } from '../../shared/app-error.js';
import { purgeWalletSourceSecret } from './wallet-sources.js';
import { accountPositions } from './positions.js';
import { trackUpdate, trackDelete } from './change-log.js';
export function listAccounts() {
return db.all(`SELECT * FROM Account ORDER BY name`);
}
export function listSelectableAccounts({ metGesloten = false, ookId = null } = {}) {
return metGesloten
? db.all(`SELECT * FROM Account ORDER BY isActive DESC, name`)
: db.all(`SELECT * FROM Account WHERE isActive = 1 OR id = ?1 ORDER BY isActive DESC, name`,
[ookId ?? null]);
}
export async function createAccount({ name, kind, isOwnCustody, scanEnabled, taxCategory, taxScope, wealthScope }) {
assertEnum('kind', ENUMS.ACCOUNT_KIND, kind);
if (kind === 'bank') {
const max = capacityReachedFor('fiatAccounts');
if (max !== null) {
fail('accounts.maxFreeBank', { max },
`The free version holds ${max} fiat account(s). A licence lifts that; nothing is removed in the meantime.`);
}
}
const naam = assertName('Accountnaam', name);
const categorie = assertTaxCategory(taxCategory);
const scope = assertTaxScope(taxScope);
const wealth = assertWealthScope(wealthScope);
db.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt, taxCategory, taxScope, wealthScope)
VALUES (?,?,?,?,?,1,NULL,?,?,?)`,
[naam, toSearchName(naam), kind, isOwnCustody ? 1 : 0, scanEnabled ? 1 : 0, categorie, scope, wealth]);
const id = db.lastId();
await db.persist();
return id;
}
export async function updateAccount(id, a) {
assertEnum('kind', ENUMS.ACCOUNT_KIND, a.kind);
const naam = assertName('Accountnaam', a.name);
const categorie = assertTaxCategory(a.taxCategory);
const scopeGiven = Object.hasOwn(a, 'taxScope');
const scope = scopeGiven ? assertTaxScope(a.taxScope) : null;
const wealthGiven = Object.hasOwn(a, 'wealthScope');
const wealth = wealthGiven ? assertWealthScope(a.wealthScope) : null;
trackUpdate('Account', id, () =>
db.run(`UPDATE Account SET name=?, searchName=?, kind=?, isOwnCustody=?, scanEnabled=?, taxCategory=?,
taxScope = CASE WHEN ? THEN ? ELSE taxScope END,
wealthScope = CASE WHEN ? THEN ? ELSE wealthScope END WHERE id=?`,
[naam, toSearchName(naam), a.kind, a.isOwnCustody ? 1 : 0, a.scanEnabled ? 1 : 0, categorie,
scopeGiven ? 1 : 0, scope, wealthGiven ? 1 : 0, wealth, id]));
await db.persist();
}
export async function deleteAccount(id) {
const used = db.one(`SELECT (SELECT COUNT(*) FROM Event WHERE fromAccountId=?1 OR toAccountId=?1)
+ (SELECT COUNT(*) FROM Valuation WHERE accountId =?1)
+ (SELECT COUNT(*) FROM ScanLog WHERE accountId=?1)
+ (SELECT COUNT(*) FROM ReconcileProposal WHERE accountId=?1) AS n`, [id]).n;
if (used > 0) fail('accounts.inUse', { n: used }, `Account is used by ${used} record(s); remove or relink those first.`);
for (const s of db.all(`SELECT id FROM WalletSource WHERE accountId=? AND kind='nwc'`, [id])) {
await purgeWalletSourceSecret(s.id);
}
trackDelete('Account', id, () => db.run(`DELETE FROM Account WHERE id=?`, [id]));
await db.persist();
}
export function deactivateCheck(accountId) {
const account = db.one(`SELECT * FROM Account WHERE id = ?`, [accountId]);
if (!account) return { found: false, findings: [] };
const n = (sql, params = [accountId]) => db.one(sql, params).n;
const findings = [];
if (account.scanEnabled) {
findings.push({ kind: 'scanEnabled', count: 1 });
}
const sources = n(`SELECT COUNT(*) AS n FROM WalletSource WHERE accountId = ?`);
if (sources) findings.push({ kind: 'walletSources', count: sources });
const vandaag = new Date().toISOString().slice(0, 10);
const schedules = n(
`SELECT COUNT(*) AS n FROM InterestSchedule
WHERE accountId = ? AND (endsOn IS NULL OR endsOn >= ?)`, [accountId, vandaag]);
if (schedules) findings.push({ kind: 'openSchedules', count: schedules });
const proposals = n(`SELECT COUNT(*) AS n FROM ReconcileProposal WHERE accountId = ? AND status = 'pending'`);
if (proposals) findings.push({ kind: 'pendingProposals', count: proposals });
const balances = accountPositions(accountId);
if (balances.length) findings.push({ kind: 'balance', count: balances.length, balances });
const events = n(`SELECT COUNT(*) AS n FROM Event WHERE fromAccountId = ?1 OR toAccountId = ?1`);
if (events) findings.push({ kind: 'events', count: events, informational: true });
return { found: true, isActive: !!account.isActive, name: account.name, findings };
}
export async function deactivateAccount(id, closedAt = nowUtcIso()) {
const account = db.one(`SELECT isActive FROM Account WHERE id = ?`, [id]);
if (!account) fail('accounts.unknownAccount', { id }, `Account #${id} does not exist.`);
trackUpdate('Account', id, () =>
db.run(`UPDATE Account SET isActive = 0, closedAt = ?, scanEnabled = 0 WHERE id = ?`, [closedAt, id]));
await db.persist();
}
export async function reactivateAccount(id) {
const account = db.one(`SELECT isActive FROM Account WHERE id = ?`, [id]);
if (!account) fail('accounts.unknownAccount', { id }, `Account #${id} does not exist.`);
trackUpdate('Account', id, () =>
db.run(`UPDATE Account SET isActive = 1, closedAt = NULL WHERE id = ?`, [id]));
await db.persist();
}
+89
View File
@@ -0,0 +1,89 @@
// Generated, do not edit. sha256:9505c124878c8b5af0571c1b4757d6fc332a4d6d342508d9c8ff717ce279c7db
import * as db from '../db.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
import { emitChange } from './_events.js';
import { limitText } from '../ai/limits.js';
function uitRij(r) {
if (!r) return r;
let velden = null;
if (r.fieldsShared != null) {
try { velden = JSON.parse(r.fieldsShared); } catch { velden = []; }
if (!Array.isArray(velden)) velden = [];
}
return { ...r, fieldsShared: velden };
}
export async function logAiToolCall(entry) {
assertEnum('outcome', ENUMS.AI_CALL_OUTCOME, entry.outcome);
if (!entry.sessionId) throw new Error('An audit entry without a session cannot be traced.');
if (!entry.tool) throw new Error('An audit entry without a tool name cannot be traced.');
const momenten = entry.messages == null ? [] : [...entry.messages];
for (const m of momenten) assertEnum('kind', ENUMS.AI_MESSAGE_KIND, m?.kind);
const velden = entry.fieldsShared == null ? null : JSON.stringify([...entry.fieldsShared]);
db.run(`INSERT INTO AiToolCall
(sessionId, client, tool, arguments, fieldsShared, charactersOut, outcome, detail, occurredAt, serverVersion)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
[entry.sessionId, entry.client ?? null, entry.tool,
entry.arguments != null ? JSON.stringify(entry.arguments) : null,
velden, entry.charactersOut ?? null, entry.outcome, entry.detail ?? null,
entry.occurredAt ?? nowIso(), entry.serverVersion ?? null]);
const id = db.lastId();
for (const m of momenten) {
const { text } = limitText(String(m.text ?? ''), CONFIG.AI.LIMITS.MAX_LOG_CHARS);
db.run(`INSERT INTO AiToolCallMessage (callId, kind, text, occurredAt) VALUES (?,?,?,?)`,
[id, m.kind, text, m.occurredAt ?? nowIso()]);
}
await db.persist();
emitChange('ai-activity-changed');
return id;
}
export function countAiToolCallsSince(sinds) {
const vanaf = Number.isInteger(sinds) && sinds >= 0 ? sinds : 0;
const r = db.one(`SELECT COUNT(*) AS n, MAX(id) AS laatste FROM AiToolCall WHERE id > ?`, [vanaf]);
const alles = db.one(`SELECT MAX(id) AS laatste FROM AiToolCall`);
return {
count: r ? r.n : 0,
newestId: (alles && alles.laatste != null) ? alles.laatste : 0,
};
}
export function aiToolCallMessages(callId) {
const n = Number(callId);
if (!Number.isInteger(n) || n <= 0) return [];
return db.all(`SELECT id, callId, kind, text, occurredAt FROM AiToolCallMessage
WHERE callId = ? ORDER BY id`, [n]);
}
export function aiSessions(uren = 24) {
const grens = new Date(Date.now() - Math.max(1, Number(uren) || 24) * 3600_000).toISOString();
return db.all(
`SELECT sessionId, client, serverVersion,
COUNT(*) AS calls, MIN(occurredAt) AS firstAt, MAX(occurredAt) AS lastAt
FROM AiToolCall
WHERE occurredAt >= ?
GROUP BY sessionId, client, serverVersion
ORDER BY lastAt DESC`, [grens])
.map(r => ({ ...r, zonderVersie: r.serverVersion == null }));
}
export function listAiToolCalls(limit = CONFIG.AI.AUDIT_PAGE_SIZE) {
const n = Math.min(Math.max(Number(limit) || CONFIG.AI.AUDIT_PAGE_SIZE, 1), 1000);
return db.all(`SELECT * FROM AiToolCall ORDER BY occurredAt DESC, id DESC LIMIT ?`, [n]).map(uitRij);
}
export function countAiToolCalls() {
const totaal = db.one(`SELECT COUNT(*) AS n FROM AiToolCall`);
const perUitkomst = db.all(`SELECT outcome, COUNT(*) AS n FROM AiToolCall GROUP BY outcome`);
const uitkomsten = {};
for (const k of ENUMS.AI_CALL_OUTCOME) uitkomsten[k] = 0;
for (const r of perUitkomst) uitkomsten[r.outcome] = r.n;
return { total: totaal ? totaal.n : 0, byOutcome: uitkomsten };
}
export function lastAiToolCallAt() {
const r = db.one(`SELECT occurredAt FROM AiToolCall ORDER BY occurredAt DESC, id DESC LIMIT 1`);
return r ? r.occurredAt : null;
}
+377
View File
@@ -0,0 +1,377 @@
// Generated, do not edit. sha256:7c7ced19083e83a4f75c27b5aa8f303c04b02f88225373c3587d1d7f90e78248
import * as db from '../db.js';
import { CONFIG, ENUMS, PREFERENCES, SOURCE_CAPABILITIES, SOURCE_ENDPOINTS } from '../../shared/config.js';
import { placePriceSourceFirst, clearPriceSourceCell } from './price-source.js';
import { setPref } from './preferences.js';
import { emitChange } from './_events.js';
import { assertEnum, assertName, assertTimestamp, nowIso } from './_shared.js';
import { fail, isAppError } from '../../shared/app-error.js';
import { getPriceAt, upsertPriceHistory } from './price-history.js';
import { createAccount, deleteAccount, listAccounts } from './accounts.js';
import { createEvent, eventsCountByAccount } from './events.js';
import { createValuation, listValuations } from './valuations.js';
import { positionAt } from './positions.js';
import { assertValidName as assertValidPortfolioName } from '../portfolios.mjs';
import { createPortfolio } from '../portfolios-admin.mjs';
import { isAiActive } from '../ai/gate.js';
const signaleerVoorstellen = () => emitChange('ai-proposals-changed');
const PROPOSAL_JOIN = `
SELECT AiProposal.*, Asset.symbol AS asset_symbol, Asset.name AS asset_name,
Asset.unit AS asset_unit, Asset.subunit AS asset_subunit,
Asset.decimals AS asset_decimals, Asset.displayUnit AS asset_display_unit,
Asset.kind AS asset_kind,
fromAccount.name AS from_name, toAccount.name AS to_name,
targetAccount.name AS account_name, targetAccount.kind AS account_kind,
Provider.name AS provider_name
FROM AiProposal
LEFT JOIN Asset ON Asset.id = AiProposal.assetId
LEFT JOIN Provider ON AiProposal.kind = 'source_choice' AND Provider.key = AiProposal.key
LEFT JOIN Account AS fromAccount ON fromAccount.id = AiProposal.fromAccountId
LEFT JOIN Account AS toAccount ON toAccount.id = AiProposal.toAccountId
LEFT JOIN Account AS targetAccount ON targetAccount.id = AiProposal.accountId`;
const RICHTING = Object.freeze({
buy: Object.freeze({ van: false, naar: true }),
sell: Object.freeze({ van: true, naar: false }),
transfer: Object.freeze({ van: true, naar: true }),
interest: Object.freeze({ van: false, naar: true }),
});
function bronVoorKeuze(assetId, capability, providerKey) {
const asset = db.one(`SELECT symbol FROM Asset WHERE id = ?`, [assetId]);
if (!asset) throw new Error(`Asset ${assetId} does not exist.`);
const bron = db.one(`SELECT id, isSystem FROM Provider WHERE key = ?`, [String(providerKey ?? '')]);
if (!bron || bron.isSystem) throw new Error(`Unknown price source '${providerKey}'.`);
if (!db.one(`SELECT 1 AS x FROM ProviderCoverage WHERE providerId = ? AND symbol = ?`, [bron.id, asset.symbol])) {
throw new Error(`Price source '${providerKey}' does not cover ${asset.symbol}.`);
}
const eindpunten = SOURCE_ENDPOINTS[capability];
if (!db.one(`SELECT 1 AS x FROM ProviderEndpoint WHERE providerId = ? AND capability IN (${eindpunten.map(() => '?').join(',')})`,
[bron.id, ...eindpunten])) {
throw new Error(`Price source '${providerKey}' delivers no ${capability}.`);
}
return bron.id;
}
const CONTROLE = Object.freeze({
price_point: (v) => {
if (v.assetId == null) throw new Error('A price-point proposal needs an asset.');
if (v.fiatPrice == null) throw new Error('A price-point proposal needs a price in euro.');
assertTimestamp('observedAt', v.observedAt);
},
account: (v) => {
assertName('Accountnaam', v.name);
assertEnum('accountKind', ENUMS.ACCOUNT_KIND, v.accountKind);
if (v.isOwnCustody == null) throw new Error('An account proposal has to say whether it is own custody.');
},
account_delete: (v) => {
if (v.accountId == null) throw new Error('A deletion proposal has to say WHICH account.');
if (!listAccounts().some(a => a.id === v.accountId)) {
throw new Error(`Account ${v.accountId} no longer exists.`);
}
},
portfolio: (v) => {
assertName('Portefeuillenaam', v.name);
assertValidPortfolioName(v.name);
assertEnum('currency', CONFIG.PORTFOLIO.BASE_CURRENCIES, v.currency);
},
preference: (v) => {
if (!CONFIG.AI.PROPOSABLE_PREF_SCOPES.includes(v.scope)) {
throw new Error(`A proposal may not set the preference scope '${v.scope}'.`);
}
if (!PREFERENCES[v.scope]?.[v.key]) {
throw new Error(`Unknown preference: ${v.scope}.${v.key}.`);
}
try { JSON.parse(v.value); }
catch { throw new Error(`The value of ${v.scope}.${v.key} is not valid JSON.`); }
},
source_choice: (v) => {
if (v.assetId == null) throw new Error('A source-choice proposal needs an asset.');
assertEnum('scope', SOURCE_CAPABILITIES, v.scope);
assertEnum('value', ENUMS.SOURCE_CHOICE_ACTION, v.value);
bronVoorKeuze(v.assetId, v.scope, v.key);
},
valuation: (v) => {
if (v.assetId == null) throw new Error('A balance proposal needs an asset.');
if (v.accountId == null) throw new Error('A balance proposal needs an account.');
if (!Number.isInteger(v.balance) || v.balance < 0) {
throw new Error(`A balance proposal needs a balance >= 0 in the smallest unit (got ${v.balance}).`);
}
assertTimestamp('observedAt', v.observedAt);
},
event: (v) => {
if (v.assetId == null) throw new Error('An event proposal needs an asset.');
assertEnum('eventType', ENUMS.LEDGER_EVENT_TYPE, v.eventType);
if (!Number.isInteger(v.amount) || v.amount <= 0) {
throw new Error(`An event proposal needs an amount > 0 in the smallest unit (got ${v.amount}).`);
}
assertTimestamp('observedAt', v.observedAt);
const richting = RICHTING[v.eventType];
if (richting.van && v.fromAccountId == null) {
throw new Error(`An event of kind '${v.eventType}' needs an origin account.`);
}
if (richting.naar && v.toAccountId == null) {
throw new Error(`An event of kind '${v.eventType}' needs a destination account.`);
}
if (!richting.van && v.fromAccountId != null) {
throw new Error(`An event of kind '${v.eventType}' has no origin account.`);
}
if (!richting.naar && v.toAccountId != null) {
throw new Error(`An event of kind '${v.eventType}' has no destination account.`);
}
if ((v.eventType === 'buy' || v.eventType === 'sell') && v.fiatPrice == null) {
throw new Error(`An event of kind '${v.eventType}' needs a price in euro.`);
}
},
});
export async function createAiProposal({
kind, assetId = null, accountId = null, fromAccountId = null, toAccountId = null,
name = null, accountKind = null, isOwnCustody = null,
eventType = null, amount = null, fiatPrice = null, fiatFee = null, unitFee = null, balance = null,
observedAt = null, scope = null, key = null, value = null, currency = null, note = null,
}) {
assertEnum('kind', ENUMS.AI_PROPOSAL_KIND, kind);
if (balance != null && kind !== 'valuation') {
throw new Error(`A balance belongs to a valuation; a proposal of kind '${kind}' has none.`);
}
if ((fiatFee != null || unitFee != null) && kind !== 'event') {
throw new Error(`Fees belong to an event; a proposal of kind '${kind}' has none.`);
}
if (unitFee != null && eventType !== 'sell' && eventType !== 'transfer') {
throw new Error(`A fee in units belongs to a sell or a transfer; an event of kind '${eventType}' has none.`);
}
if ((scope != null || key != null || value != null) && kind !== 'preference' && kind !== 'source_choice') {
throw new Error(`A preference belongs to a preference proposal; kind '${kind}' has none.`);
}
if (currency != null && kind !== 'portfolio') {
throw new Error(`A currency belongs to a portfolio proposal; kind '${kind}' has none.`);
}
const eigenBeheer = isOwnCustody == null ? null : (isOwnCustody ? 1 : 0);
CONTROLE[kind]({ assetId, accountId, fromAccountId, toAccountId, name, accountKind, isOwnCustody: eigenBeheer, eventType, amount, fiatPrice, balance, observedAt, scope, key, value, currency });
db.run(`INSERT INTO AiProposal
(assetId, accountId, fromAccountId, toAccountId, kind, name, accountKind, isOwnCustody,
eventType, amount, fiatPrice, fiatFee, unitFee, balance, observedAt, scope, key, value, currency, note, status, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'pending',?)`,
[assetId, accountId, fromAccountId, toAccountId, kind, name, accountKind, eigenBeheer,
eventType, amount, fiatPrice, fiatFee, unitFee, balance, observedAt, scope, key, value, currency, note, nowIso()]);
const id = db.lastId();
signaleerVoorstellen();
await db.persist();
return id;
}
export function listPendingAiProposals() {
if (!isAiActive()) return [];
return db.all(`${PROPOSAL_JOIN} WHERE AiProposal.status = 'pending' ORDER BY AiProposal.id ASC`)
.map(verrijkBalans);
}
function verrijkBalans(rij) {
if (rij.kind !== 'valuation') return rij;
return {
...rij,
ledger_balance: positionAt(rij.accountId, rij.assetId, rij.observedAt),
events_before: eventsCountByAccount(rij.accountId, rij.assetId, rij.observedAt),
};
}
export function listAiProposals(limit = CONFIG.AI.PROPOSAL_PAGE_SIZE) {
if (!isAiActive()) return [];
const n = Number.isInteger(limit) && limit > 0 ? limit : CONFIG.AI.PROPOSAL_PAGE_SIZE;
return db.all(`${PROPOSAL_JOIN} ORDER BY AiProposal.id DESC LIMIT ?`, [n]);
}
export function aiProposalsStatus() {
return { enabled: isAiActive(), pending: listPendingAiProposals() };
}
export function getAiProposal(id) {
return db.one(`${PROPOSAL_JOIN} WHERE AiProposal.id = ?`, [id]);
}
const HERKOMST_VAN = Object.freeze({
event: 'createdEventId',
account: 'createdAccountId',
valuation: 'createdValuationId',
});
export function rowOrigin(soort, rijId) {
const kolom = HERKOMST_VAN[soort];
if (!kolom) throw new Error(`Unknown kind for an origin query: '${soort}'.`);
if (!Number.isInteger(Number(rijId))) return null;
const r = db.one(
`SELECT id, kind, note, createdAt, resolvedAt FROM AiProposal
WHERE ${kolom} = ? AND status = 'accepted' ORDER BY id DESC LIMIT 1`, [Number(rijId)]);
if (!r) return null;
return {
proposalId: r.id, kind: r.kind, note: r.note,
createdAt: r.createdAt, resolvedAt: r.resolvedAt,
};
}
const UITVOEREN = Object.freeze({
price_point: async (voorstel) => {
const seconden = Math.floor(Date.parse(voorstel.observedAt) / 1000);
if (!Number.isFinite(seconden)) fail('aiProposals.unreadableMoment', { id: voorstel.id, moment: voorstel.observedAt }, `Unreadable moment in proposal ${voorstel.id}: ${voorstel.observedAt}.`);
await upsertPriceHistory(voorstel.assetId, [{ time: seconden, price: voorstel.fiatPrice }], 'manual', 'day');
},
account: async (voorstel) => {
const id = await createAccount({
name: voorstel.name,
kind: voorstel.accountKind,
isOwnCustody: !!voorstel.isOwnCustody,
scanEnabled: false,
taxCategory: null,
});
return { kolom: 'createdAccountId', id };
},
account_delete: async (voorstel) => {
await deleteAccount(voorstel.accountId);
},
event: async (voorstel) => {
const id = await createEvent({
assetId: voorstel.assetId,
type: voorstel.eventType,
occurredAt: voorstel.observedAt,
amount: voorstel.amount,
fiatPrice: voorstel.fiatPrice ?? null,
fiatFee: voorstel.fiatFee ?? null,
unitFee: voorstel.unitFee ?? null,
fromAccountId: voorstel.fromAccountId,
toAccountId: voorstel.toAccountId,
source: 'manual',
note: voorstel.note,
});
return { kolom: 'createdEventId', id };
},
portfolio: async (voorstel) => {
await createPortfolio(voorstel.name, { baseCurrency: voorstel.currency });
},
preference: async (voorstel) => {
if (!CONFIG.AI.PROPOSABLE_PREF_SCOPES.includes(voorstel.scope)) {
fail('aiProposals.prefScopeNotAllowed', { scope: voorstel.scope },
`This proposal wants to set the preference scope '${voorstel.scope}', which is not allowed.`);
}
await setPref(voorstel.scope, voorstel.key, JSON.parse(voorstel.value));
},
source_choice: async (voorstel) => {
const providerId = bronVoorKeuze(voorstel.assetId, voorstel.scope, voorstel.key);
if (voorstel.value === 'first') await placePriceSourceFirst(voorstel.assetId, voorstel.scope, providerId);
else await clearPriceSourceCell(voorstel.assetId, voorstel.scope, providerId);
},
valuation: async (voorstel) => {
const dag = voorstel.observedAt.slice(0, 10);
const dubbel = listValuations().some(v =>
v.assetId === voorstel.assetId && v.accountId === voorstel.accountId && v.valuedOn === dag);
if (dubbel) {
fail('aiProposals.duplicateValuation', { dag }, `A balance for this account and asset on ${dag} already exists.`);
}
const seconden = Math.floor(Date.parse(voorstel.observedAt) / 1000);
if (!Number.isFinite(seconden)) fail('aiProposals.unreadableMoment', { id: voorstel.id, moment: voorstel.observedAt }, `Unreadable moment in proposal ${voorstel.id}: ${voorstel.observedAt}.`);
const punt = getPriceAt(voorstel.assetId, seconden);
if (!punt || punt.fiatPrice == null) {
fail('aiProposals.noPriceForBalance', { dag },
`No price in the history for ${dag}, and a balance cannot do without one.`);
}
const id = await createValuation({
assetId: voorstel.assetId,
accountId: voorstel.accountId,
valuedOn: dag,
balance: voorstel.balance,
fiatRate: punt.fiatPrice,
rateAt: voorstel.observedAt,
source: 'manual',
});
return { kolom: 'createdValuationId', id };
},
});
const HERKOMST_KOLOMMEN = Object.freeze(['createdEventId', 'createdAccountId', 'createdValuationId']);
const detailVan = (e) => (isAppError(e)
? JSON.stringify({ key: e.key, params: e.params || undefined })
: ((e && e.message) || String(e)));
export async function acceptAiProposal(id) {
const voorstel = getAiProposal(id);
if (!voorstel) fail('aiProposals.unknownProposal', { id }, `Proposal ${id} no longer exists.`);
if (voorstel.status !== 'pending') {
fail(`aiProposals.handled_${voorstel.status}`, { id },
`Proposal ${id} has already been handled (${voorstel.status}).`);
}
const uitvoeren = UITVOEREN[voorstel.kind];
if (!uitvoeren) throw new Error(`No execution known for proposal kind '${voorstel.kind}'.`);
let herkomst = null;
try {
herkomst = await uitvoeren(voorstel);
} catch (e) {
db.run(`UPDATE AiProposal SET detail = ? WHERE id = ?`, [detailVan(e), id]);
signaleerVoorstellen();
await db.persist();
throw e;
}
const kolom = herkomst && HERKOMST_KOLOMMEN.includes(herkomst.kolom) ? herkomst.kolom : null;
if (kolom && Number.isInteger(Number(herkomst.id))) {
db.run(`UPDATE AiProposal SET status = 'accepted', detail = NULL, resolvedAt = ?, ${kolom} = ?
WHERE id = ?`, [nowIso(), Number(herkomst.id), id]);
} else {
db.run(`UPDATE AiProposal SET status = 'accepted', detail = NULL, resolvedAt = ? WHERE id = ?`,
[nowIso(), id]);
}
signaleerVoorstellen();
await db.persist();
return id;
}
export async function acceptAiProposals(ids) {
const uitkomsten = [];
for (const id of ids) {
try {
await acceptAiProposal(id);
uitkomsten.push({ id, ok: true, reden: null });
} catch (e) {
uitkomsten.push({ id, ok: false, reden: e.message, key: e.key || null, params: e.params || null });
}
}
return uitkomsten;
}
export async function rejectAiProposal(id, reden = null) {
const voorstel = getAiProposal(id);
if (!voorstel) fail('aiProposals.unknownProposal', { id }, `Proposal ${id} no longer exists.`);
if (voorstel.status !== 'pending') {
fail(`aiProposals.handled_${voorstel.status}`, { id },
`Proposal ${id} has already been handled (${voorstel.status}).`);
}
db.run(`UPDATE AiProposal SET status = 'rejected', detail = ?, resolvedAt = ? WHERE id = ?`,
[reden, nowIso(), id]);
signaleerVoorstellen();
await db.persist();
return id;
}
export async function rejectAiProposals(ids, reden = null) {
const uitkomsten = [];
for (const id of ids) {
try {
await rejectAiProposal(id, reden);
uitkomsten.push({ id, ok: true, reden: null });
} catch (e) {
uitkomsten.push({ id, ok: false, reden: e.message, key: e.key || null, params: e.params || null });
}
}
return uitkomsten;
}
+56
View File
@@ -0,0 +1,56 @@
// Generated, do not edit. sha256:227dda965713b9b51597f6f128f59f6569f0a5729dc340b98c56d958390f053d
import * as db from '../db.js';
export async function recordApiCall({ providerId, capability, ok, latency_ms }) {
if (!Number.isInteger(providerId)) return;
const bucket = new Date().toISOString().slice(0, 16);
const lat = Number.isFinite(latency_ms) ? Math.max(0, Math.round(latency_ms)) : 0;
db.run(`INSERT INTO ApiCallMetric (providerId, capability, bucketMinute, okCount, failCount, sumLatencyMs, maxLatencyMs)
VALUES (?,?,?,?,?,?,?)
ON CONFLICT(providerId, capability, bucketMinute) DO UPDATE SET
okCount = okCount + excluded.okCount,
failCount = failCount + excluded.failCount,
sumLatencyMs = sumLatencyMs + excluded.sumLatencyMs,
maxLatencyMs = MAX(maxLatencyMs, excluded.maxLatencyMs)`,
[providerId, capability, bucket, ok ? 1 : 0, ok ? 0 : 1, lat, lat]);
await db.persist();
}
export function apiMetricsTotals(providerId, sinceBucket) {
const r = db.one(`SELECT COALESCE(SUM(okCount),0) AS ok, COALESCE(SUM(failCount),0) AS fail,
COALESCE(SUM(sumLatencyMs),0) AS sum_lat, COALESCE(MAX(maxLatencyMs),0) AS max_lat
FROM ApiCallMetric WHERE providerId = ? AND bucketMinute >= ?`, [providerId, sinceBucket]);
const calls = (r.ok || 0) + (r.fail || 0);
return { ok: r.ok || 0, fail: r.fail || 0, calls,
avg_latency_ms: calls ? Math.round(r.sum_lat / calls) : 0, maxLatencyMs: r.max_lat || 0 };
}
export function apiMetricsGlobal(sinceBucket) {
const r = db.one(`SELECT COALESCE(SUM(okCount),0) AS ok, COALESCE(SUM(failCount),0) AS fail,
COALESCE(SUM(sumLatencyMs),0) AS sum_lat, COALESCE(MAX(maxLatencyMs),0) AS max_lat
FROM ApiCallMetric WHERE bucketMinute >= ?`, [sinceBucket]);
const calls = (r.ok || 0) + (r.fail || 0);
return { ok: r.ok || 0, fail: r.fail || 0, calls,
avg_latency_ms: calls ? Math.round(r.sum_lat / calls) : 0, maxLatencyMs: r.max_lat || 0 };
}
export function apiCallsByProvider(sinceBucket) {
return db.all(`SELECT providerId, SUM(okCount) + SUM(failCount) AS calls
FROM ApiCallMetric WHERE bucketMinute >= ?
GROUP BY providerId`, [sinceBucket]);
}
export function apiMetricsByCapability(providerId, sinceBucket) {
return db.all(`SELECT capability, SUM(okCount) AS ok, SUM(failCount) AS fail,
SUM(sumLatencyMs) AS sum_lat, MAX(maxLatencyMs) AS max_lat
FROM ApiCallMetric WHERE providerId = ? AND bucketMinute >= ?
GROUP BY capability ORDER BY capability`, [providerId, sinceBucket]);
}
export function apiMetricsSeries(providerId, sinceBucket, groupLen) {
return db.all(`SELECT substr(bucketMinute,1,?) AS b,
COALESCE(SUM(okCount),0) AS ok, COALESCE(SUM(failCount),0) AS fail,
COALESCE(MAX(maxLatencyMs),0) AS max_lat
FROM ApiCallMetric WHERE providerId = ? AND bucketMinute >= ?
GROUP BY b ORDER BY b`, [groupLen, providerId, sinceBucket]);
}
+19
View File
@@ -0,0 +1,19 @@
// Generated, do not edit. sha256:2a06cfff8bd22ab273abad66a7bff34ee21d692bd6b4215d67a43a86980cd81f
import * as db from '../db.js';
export function getAssetNote(assetId) {
return db.one(`SELECT body, createdAt FROM AssetNote WHERE assetId = ?`, [assetId]) || null;
}
export async function setAssetNote(assetId, body) {
if (!db.one(`SELECT id FROM Asset WHERE id = ?`, [assetId])) throw new Error(`Asset ${assetId} does not exist.`);
const tekst = typeof body === 'string' ? body : '';
if (!tekst.trim()) {
db.run(`DELETE FROM AssetNote WHERE assetId = ?`, [assetId]);
} else {
db.run(`INSERT INTO AssetNote (assetId, body, createdAt) VALUES (?, ?, ?)
ON CONFLICT(assetId) DO UPDATE SET body = excluded.body`, [assetId, tekst, new Date().toISOString()]);
}
await db.persist();
return getAssetNote(assetId);
}
+84
View File
@@ -0,0 +1,84 @@
// Generated, do not edit. sha256:ae0d48181a52fd75f129b890a9ec73664ed0e72770dbab20484d6647df3ef532
import * as db from '../db.js';
import { toSearchName } from '../../shared/search-name.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
import { assertEnum, assertTaxCategory, assertTaxScope, RATE_ONLY_ASSET } from './_shared.js';
import { capacityReachedFor } from './_limits.js';
import { allowedAssets } from '../license.mjs';
import { fail } from '../../shared/app-error.js';
import { trackDelete, trackUpdate } from './change-log.js';
export function listAssets() { return db.all(`SELECT * FROM Asset ORDER BY symbol`); }
export function listBookableAssets() {
return db.all(`SELECT * FROM Asset WHERE NOT ${RATE_ONLY_ASSET} ORDER BY symbol`);
}
function assertDecimals(decimals) {
const leesbaar = typeof decimals === 'number'
|| (typeof decimals === 'string' && decimals.trim() !== '');
const n = leesbaar ? Number(decimals) : NaN;
if (!Number.isInteger(n) || n < 0 || n > CONFIG.MAX_ASSET_DECIMALS) {
fail('assets.decimalsRange', { max: CONFIG.MAX_ASSET_DECIMALS, waarde: decimals },
`Decimals has to be a whole number from 0 to ${CONFIG.MAX_ASSET_DECIMALS} (got: '${decimals}'); `
+ 'above that an amount can no longer be read back.');
}
}
function assertBalanceCapture(value) {
if (value == null || value === '') return null;
const n = Number(value);
if (n !== 0 && n !== 1) throw new Error(`Invalid balanceCapture: '${value}'. Allowed: empty, 0, 1.`);
return n;
}
export async function createAsset({ symbol, name, kind, unit, subunit, decimals, displayUnit, taxCategory, taxScope, balanceCapture }) {
const allowed = allowedAssets();
if (allowed && kind !== 'fiat' && !allowed.includes(symbol)) {
fail('assets.freeOnly', { list: allowed.join(', ') },
`The free version holds ${allowed.join(', ')} and currencies only. A licence lifts that.`);
}
const max = capacityReachedFor('assets');
if (max !== null) {
fail('assets.maxFree', { max },
`The free version holds ${max} asset(s). A licence lifts that; nothing is removed in the meantime.`);
}
assertEnum('kind', ENUMS.ASSET_KIND, kind);
assertEnum('displayUnit', ENUMS.ASSET_DISPLAY_UNIT, displayUnit);
assertDecimals(decimals);
const categorie = assertTaxCategory(taxCategory);
const capture = assertBalanceCapture(balanceCapture);
const scope = assertTaxScope(taxScope);
db.run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit, taxCategory, taxScope, balanceCapture)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
[symbol, name, toSearchName(name), kind, unit, subunit ?? null, decimals, displayUnit, categorie, scope, capture]);
const id = db.lastId();
db.seedPriceSource(id, 'confirmed');
await db.persist();
return id;
}
export async function updateAsset(id, a) {
assertEnum('kind', ENUMS.ASSET_KIND, a.kind);
assertEnum('displayUnit', ENUMS.ASSET_DISPLAY_UNIT, a.displayUnit);
assertDecimals(a.decimals);
const categorie = assertTaxCategory(a.taxCategory);
const given = Object.hasOwn(a, 'balanceCapture');
const capture = given ? assertBalanceCapture(a.balanceCapture) : null;
const scopeGiven = Object.hasOwn(a, 'taxScope');
const scope = scopeGiven ? assertTaxScope(a.taxScope) : null;
trackUpdate('Asset', id, () =>
db.run(`UPDATE Asset SET symbol=?, name=?, searchName=?, kind=?, unit=?, subunit=?, decimals=?, displayUnit=?, taxCategory=?,
taxScope = CASE WHEN ? THEN ? ELSE taxScope END,
balanceCapture = CASE WHEN ? THEN ? ELSE balanceCapture END WHERE id=?`,
[a.symbol, a.name, toSearchName(a.name), a.kind, a.unit, a.subunit ?? null, a.decimals, a.displayUnit, categorie,
scopeGiven ? 1 : 0, scope, given ? 1 : 0, capture, id]));
await db.persist();
}
export async function deleteAsset(id) {
const used = db.one(`SELECT (SELECT COUNT(*) FROM Event WHERE assetId=?1)
+ (SELECT COUNT(*) FROM Valuation WHERE assetId=?1)
+ (SELECT COUNT(*) FROM ReconcileProposal WHERE assetId=?1) AS n`, [id]).n;
if (used > 0) fail('assets.inUse', { n: used }, `The asset is used by ${used} record(s).`);
trackDelete('Asset', id, () => db.run(`DELETE FROM Asset WHERE id=?`, [id]));
await db.persist();
}
+38
View File
@@ -0,0 +1,38 @@
// Generated, do not edit. sha256:8bd40013f30173c4395fe482f251f8d822b2781e68ecf02d880e6748f3e5817a
import * as db from '../db.js';
import { CONFIG } from '../../shared/config.js';
import { nowIso } from './_shared.js';
import { takeChanges } from './_events.js';
export function flushChanges() {
const namen = takeChanges();
if (!namen.length) return [];
const at = nowIso();
for (const naam of namen) {
db.run(`INSERT INTO BusSignal (name, occurredAt) VALUES (?,?)`, [naam, at]);
}
pruneChanges();
return namen;
}
export function pruneChanges(uren = CONFIG.BUS.RETENTION_HOURS) {
const grens = new Date(Date.now() - Math.max(1, Number(uren) || CONFIG.BUS.RETENTION_HOURS) * 3600_000)
.toISOString();
const r = db.one(`SELECT COUNT(*) AS n FROM BusSignal WHERE occurredAt < ?`, [grens]);
const aantal = r ? r.n : 0;
if (aantal) db.run(`DELETE FROM BusSignal WHERE occurredAt < ?`, [grens]);
return aantal;
}
export function changesSince(cursor) {
const vanaf = Number.isInteger(cursor) && cursor >= 0 ? cursor : null;
const grens = db.one(`SELECT MIN(id) AS eerste, MAX(id) AS laatste FROM BusSignal`);
const laatste = grens && grens.laatste != null ? grens.laatste : (vanaf ?? 0);
if (vanaf === null) return { cursor: laatste, names: [], reset: false };
const eerste = grens ? grens.eerste : null;
if (eerste != null && vanaf < eerste - 1) return { cursor: laatste, names: [], reset: true };
const rijen = db.all(`SELECT DISTINCT name FROM BusSignal WHERE id > ? ORDER BY name`, [vanaf]);
return { cursor: laatste, names: rijen.map(r => r.name), reset: false };
}
+99
View File
@@ -0,0 +1,99 @@
// Generated, do not edit. sha256:0ea473364503a4f8549c8bea4bc98c04b7236550cd8e91a8cd91945e7728d55a
import * as db from '../db.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
import { nowUtcIso } from '../../shared/time.js';
const NIET_MELDEN = new Set(['updatedAt', 'lastStatus', 'lastCheckedAt', 'balanceSats', 'lastScannedAt']);
let herkomst = { origin: 'manual', originId: null };
export function withOrigin(origin, originId, fn) {
if (!ENUMS.CHANGE_ORIGIN.includes(origin)) throw new Error(`Unknown origin: ${origin}.`);
const vorige = herkomst;
herkomst = { origin, originId: originId ?? null };
try { return fn(); } finally { herkomst = vorige; }
}
export function currentOrigin() { return { ...herkomst }; }
const meedoet = (tabel) => CONFIG.DB_HISTORY.TRACKED.includes(tabel);
const leesRij = (tabel, id) => db.one(`SELECT * FROM ${tabel} WHERE id = ?`, [id]) || null;
function verschil(voor, na) {
if (!voor || !na) return null;
const uit = {};
for (const veld of Object.keys(voor)) {
if (NIET_MELDEN.has(veld)) continue;
if (voor[veld] !== na[veld]) uit[veld] = [voor[veld], na[veld]];
}
return Object.keys(uit).length ? uit : null;
}
function schrijf(tabel, rowId, operation, changes) {
db.run(`INSERT INTO ChangeLog (tableName, rowId, operation, origin, originId, changes, occurredAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[tabel, rowId, operation, herkomst.origin, herkomst.originId, JSON.stringify(changes), nowUtcIso()]);
}
export function trackUpdate(tabel, id, doe) {
if (!meedoet(tabel)) return doe();
const voor = leesRij(tabel, id);
const uit = doe();
const na = leesRij(tabel, id);
const changes = verschil(voor, na);
if (changes) {
db.run(`UPDATE ${tabel} SET updatedAt = ? WHERE id = ?`, [nowUtcIso(), id]);
schrijf(tabel, id, 'update', changes);
}
return uit;
}
export function trackUpdateField(tabel, id, veld, doe) {
if (!meedoet(tabel)) return doe();
const voor = leesRij(tabel, id);
const uit = doe();
const na = leesRij(tabel, id);
if (voor && na && voor[veld] !== na[veld]) schrijf(tabel, id, 'update', { [veld]: [voor[veld], na[veld]] });
return uit;
}
export function trackDelete(tabel, id, doe) {
if (!meedoet(tabel)) return doe();
const rij = leesRij(tabel, id);
const uit = doe();
if (rij) schrijf(tabel, id, 'delete', rij);
return uit;
}
export function trackUpdateMany(tabel, ids, doe) {
if (!meedoet(tabel) || !ids.length) return doe();
const voor = new Map(ids.map(id => [id, leesRij(tabel, id)]));
const uit = doe();
for (const id of ids) {
const changes = verschil(voor.get(id), leesRij(tabel, id));
if (!changes) continue;
db.run(`UPDATE ${tabel} SET updatedAt = ? WHERE id = ?`, [nowUtcIso(), id]);
schrijf(tabel, id, 'update', changes);
}
return uit;
}
export function trackUpdateWhere(tabel, waar, params, doe) {
if (!meedoet(tabel)) return doe();
const ids = db.all(`SELECT id FROM ${tabel} WHERE ${waar}`, params).map(r => r.id);
return trackUpdateMany(tabel, ids, doe);
}
export function trackDeleteWhere(tabel, waar, params, doe) {
if (!meedoet(tabel)) return doe();
const rijen = db.all(`SELECT * FROM ${tabel} WHERE ${waar}`, params);
const uit = doe();
for (const rij of rijen) schrijf(tabel, rij.id, 'delete', rij);
return uit;
}
export function rowHistory(tabel, rowId) {
return db.all(`SELECT id, operation, origin, originId, changes, occurredAt
FROM ChangeLog WHERE tableName = ? AND rowId = ? ORDER BY id DESC`, [tabel, rowId])
.map(r => ({ ...r, changes: JSON.parse(r.changes) }));
}
+71
View File
@@ -0,0 +1,71 @@
// Generated, do not edit. sha256:d8bebac302697ff7428ee4125057b7ffc589ad6f6f375379f00af756b5b865fc
import * as db from '../db.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
import { emitChange } from './_events.js';
function uitRij(r) {
if (!r) return r;
let lading = null;
if (r.payload != null) {
try { lading = JSON.parse(r.payload); } catch { lading = null; }
}
return { ...r, payload: lading };
}
export async function createCommand({ kind, payload = null, expiresAt = null }) {
assertEnum('kind', ENUMS.COMMAND_KIND, kind);
db.run(`INSERT INTO Command (kind, payload, state, expiresAt, createdAt) VALUES (?,?,?,?,?)`,
[kind, payload == null ? null : JSON.stringify(payload), 'open', expiresAt, nowIso()]);
const id = db.lastId();
pruneCommands();
await db.persist();
emitChange('commands-changed');
return id;
}
export async function claimNextCommand(tabblad, soorten = null) {
if (!tabblad) throw new Error('A claim without a tab cannot be assigned.');
const lijst = soorten == null ? null : [...soorten];
if (lijst) {
if (!lijst.length) throw new Error('A claim on zero kinds can never yield anything.');
for (const soort of lijst) assertEnum('kind', ENUMS.COMMAND_KIND, soort);
}
const nu = nowIso();
db.run(`UPDATE Command SET state = 'expired', detail = ?, resolvedAt = ?
WHERE state = 'open' AND expiresAt IS NOT NULL AND expiresAt < ?`,
['Niemand heeft deze opdracht op tijd opgepakt.', nu, nu]);
const filter = lijst ? `AND kind IN (${lijst.map(() => '?').join(',')})` : '';
db.run(`UPDATE Command SET state = 'claimed', claimedBy = ?, claimedAt = ?
WHERE id = (SELECT id FROM Command WHERE state = 'open' ${filter} ORDER BY id LIMIT 1)`,
[String(tabblad), nu, ...(lijst || [])]);
const rij = db.one(`SELECT * FROM Command WHERE state = 'claimed' AND claimedBy = ? AND resolvedAt IS NULL
ORDER BY id DESC LIMIT 1`, [String(tabblad)]);
await db.persist();
return uitRij(rij) || null;
}
export async function resolveCommand(id, tabblad, uitkomst, detail = null) {
assertEnum('state', ENUMS.COMMAND_STATE, uitkomst);
if (uitkomst === 'open' || uitkomst === 'claimed') {
throw new Error(`'${uitkomst}' is not a conclusion; a command closes as done, rejected or expired.`);
}
db.run(`UPDATE Command SET state = ?, detail = ?, resolvedAt = ?
WHERE id = ? AND claimedBy = ? AND resolvedAt IS NULL`,
[uitkomst, detail, nowIso(), Number(id), String(tabblad)]);
await db.persist();
emitChange('commands-changed');
}
export function getCommand(id) {
return uitRij(db.one(`SELECT * FROM Command WHERE id = ?`, [Number(id)])) || null;
}
export function pruneCommands(uren = CONFIG.COMMANDS.RETENTION_HOURS) {
const grens = new Date(Date.now() - Math.max(1, Number(uren) || CONFIG.COMMANDS.RETENTION_HOURS) * 3600_000)
.toISOString();
const r = db.one(`SELECT COUNT(*) AS n FROM Command WHERE resolvedAt IS NOT NULL AND resolvedAt < ?`, [grens]);
const aantal = r ? r.n : 0;
if (aantal) db.run(`DELETE FROM Command WHERE resolvedAt IS NOT NULL AND resolvedAt < ?`, [grens]);
return aantal;
}
+127
View File
@@ -0,0 +1,127 @@
// Generated, do not edit. sha256:ddd8b3b7b2f9aadd1a75eeaa934fd9a7d05a36cbd2f06e3a6cc5379098c70228
import * as db from '../db.js';
import { toSearchName } from '../../shared/search-name.js';
import { ENUMS, CONNECTION_PROVIDER } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
import { emitProvidersChanged } from './providers.js';
import { emitChange } from './_events.js';
import { trackUpdate, trackUpdateWhere, trackUpdateField, trackDelete } from './change-log.js';
import { fail } from '../../shared/app-error.js';
import { hasFeature } from '../license.mjs';
function emitConnectionsChanged() {
emitChange('connections-changed');
}
function syncConnectionProvider(conn) {
const providerKey = CONNECTION_PROVIDER[conn.kind];
if (!providerKey) return;
const p = db.one(`SELECT id, baseUrl FROM Provider WHERE key = ?`, [providerKey]);
if (!p || p.baseUrl === conn.url) return;
db.run(`UPDATE Provider SET baseUrl = ? WHERE id = ?`, [conn.url, p.id]);
emitProvidersChanged();
}
export function listConnections(kind = null) {
if (kind != null) assertEnum('kind', ENUMS.CONNECTION_KIND, kind);
return kind
? db.all(`SELECT * FROM Connection WHERE kind = ? ORDER BY isSystem DESC, name`, [kind])
: db.all(`SELECT * FROM Connection ORDER BY kind, isSystem DESC, name`);
}
export function activeConnection(kind) {
assertEnum('kind', ENUMS.CONNECTION_KIND, kind);
if (!CONNECTION_PROVIDER[kind] && kind !== 'btc_explorer') {
throw new Error(`Kind '${kind}' is linked per account; use listConnections('${kind}').`);
}
const row = db.one(`SELECT * FROM Connection WHERE kind = ? AND isActive = 1`, [kind]);
if (!row) fail('connections.noActive', { kind }, `No active '${kind}' connection.`);
return row;
}
export async function saveConnection({ id = null, kind, name, url }) {
assertEnum('kind', ENUMS.CONNECTION_KIND, kind);
if ((kind === 'lnd' || kind === 'nwc') && !hasFeature('lightning')) {
fail('connections.lightningPaid', null,
'Lightning connections belong to the paid version.');
}
if (kind === 'btc_explorer' && !hasFeature('ownMempool')) {
fail('connections.ownExplorerPaid', null,
'Your own explorer belongs to the paid version; the standard one keeps working.');
}
name = (name || '').trim();
url = (url || '').trim().replace(/\/+$/, '');
if (!name) fail('connections.nameRequired', null, 'A name is required.');
if (!/^(https?|wss?):\/\//.test(url)) fail('connections.urlScheme', null, 'A URL starts with http(s):// or ws(s)://.');
const now = nowIso();
if (id == null) {
if (db.one(`SELECT 1 AS x FROM Connection WHERE kind = ? AND name = ?`, [kind, name])) {
fail('connections.nameInUse', { kind, naam: name }, `A '${kind}' connection named '${name}' already exists.`);
}
db.run(`INSERT INTO Connection (name, searchName, url, kind, isActive, isSystem, lastStatus, lastCheckedAt, createdAt, updatedAt)
VALUES (?,?,?,?,0,0,NULL,NULL,?,?)`, [name, toSearchName(name), url, kind, now, now]);
id = db.lastId();
} else {
const cur = db.one(`SELECT * FROM Connection WHERE id = ?`, [id]);
if (!cur) fail('connections.unknownConnection', { id }, `Connection #${id} does not exist.`);
if (cur.isSystem) throw new Error('This profile is a system row (the guaranteed fallback) and cannot be edited.');
if (cur.kind !== kind) throw new Error('The kind of an existing connection cannot be changed.');
const urlChanged = cur.url !== url;
trackUpdate('Connection', id, () =>
db.run(`UPDATE Connection SET name = ?, searchName = ?, url = ?, lastStatus = ${urlChanged ? 'NULL' : 'lastStatus'},
lastCheckedAt = ${urlChanged ? 'NULL' : 'lastCheckedAt'}, updatedAt = ? WHERE id = ?`,
[name, toSearchName(name), url, now, id]));
if (urlChanged && cur.isActive) syncConnectionProvider({ kind: cur.kind, url });
}
await db.persist();
emitConnectionsChanged();
return id;
}
export async function setActiveConnection(id) {
const c = db.one(`SELECT * FROM Connection WHERE id = ?`, [id]);
if (!c) fail('connections.unknownConnection', { id }, `Connection #${id} does not exist.`);
const now = nowIso();
if (c.kind === 'btc_explorer') {
trackUpdateWhere('Connection', `kind = ? AND isActive = 1 AND id != ?`, [c.kind, id], () =>
db.run(`UPDATE Connection SET isActive = 0, updatedAt = ? WHERE kind = ? AND isActive = 1 AND id != ?`, [now, c.kind, id]));
}
trackUpdate('Connection', id, () =>
db.run(`UPDATE Connection SET isActive = 1, updatedAt = ? WHERE id = ?`, [now, id]));
if (c.kind === 'btc_explorer') syncConnectionProvider({ kind: c.kind, url: c.url });
await db.persist();
emitConnectionsChanged();
}
export async function deleteConnection(id) {
const c = db.one(`SELECT * FROM Connection WHERE id = ?`, [id]);
if (!c) fail('connections.unknownConnection', { id }, `Connection #${id} does not exist.`);
if (c.isSystem) throw new Error('This profile is a system row (the guaranteed fallback) and cannot be deleted.');
const linked = db.one(`SELECT COUNT(*) AS n FROM WalletSource WHERE connectionId = ?`, [id]);
if (linked && linked.n > 0) {
fail('connections.linkedToSources', { n: linked.n },
`This connection is still attached to ${linked.n} tracking source(s).`);
}
if (c.kind === 'btc_explorer' && c.isActive) {
const fallback = db.one(`SELECT * FROM Connection WHERE kind = 'btc_explorer' AND isSystem = 1`);
if (!fallback) throw new Error('No system fallback profile found; refusing to delete.');
const now = nowIso();
trackUpdate('Connection', c.id, () =>
db.run(`UPDATE Connection SET isActive = 0, updatedAt = ? WHERE id = ?`, [now, c.id]));
trackUpdate('Connection', fallback.id, () =>
db.run(`UPDATE Connection SET isActive = 1, updatedAt = ? WHERE id = ?`, [now, fallback.id]));
syncConnectionProvider({ kind: 'btc_explorer', url: fallback.url });
}
trackDelete('Connection', id, () => db.run(`DELETE FROM Connection WHERE id = ?`, [id]));
await db.persist();
emitConnectionsChanged();
}
export async function recordConnectionStatus(id, ok) {
const now = nowIso();
trackUpdateField('Connection', id, 'lastStatus', () =>
db.run(`UPDATE Connection SET lastStatus = ?, lastCheckedAt = ?, updatedAt = ? WHERE id = ?`,
[ok ? 'ok' : 'fail', now, now, id]));
await db.persist();
emitConnectionsChanged();
}
+18
View File
@@ -0,0 +1,18 @@
// Generated, do not edit. sha256:416f49e0df1b9e917350543775b5f77f278748750a148f230f7651dff4cede7d
import * as db from '../db.js';
import { CONFIG } from '../../shared/config.js';
export function counts() {
return {
assets: db.one(`SELECT COUNT(*) AS n FROM Asset`).n,
accounts: db.one(`SELECT COUNT(*) AS n FROM Account`).n,
events: db.one(`SELECT COUNT(*) AS n FROM Event`).n,
valuations: db.one(`SELECT COUNT(*) AS n FROM Valuation`).n,
};
}
export function tableCounts() {
return CONFIG.DB_TABLES.map(name => ({
name,
n: db.one(`SELECT COUNT(*) AS n FROM ${name}`).n,
}));
}
+124
View File
@@ -0,0 +1,124 @@
// Generated, do not edit. sha256:ebca807e87ebabedc0e0ca214ca3bec781ace05d2be4dc4ed8d664c767fe1d63
import * as db from '../db.js';
import { DASHBOARD } from '../../shared/config.js';
import { toSearchName } from '../../shared/search-name.js';
import { nowUtcIso } from '../../shared/time.js';
import { fail } from '../../shared/app-error.js';
export function listProfiles() {
return db.all(`SELECT * FROM DashboardProfile ORDER BY sortOrder, name`);
}
export function activeProfile() {
const rij = db.one(`SELECT * FROM DashboardProfile WHERE isActive = 1`);
if (!rij) throw new Error('No active dashboard profile: this data layer is supposed to prevent that.');
return rij;
}
function schrijfStandaardIndeling(profileId) {
for (const w of DASHBOARD.DEFAULT_LAYOUT) {
const def = DASHBOARD.WIDGETS[w.type];
if (!def) continue;
db.run(`INSERT INTO DashboardWidget (profileId, assetId, instanceKey, gridX, gridY, spanWidth, spanHeight, type, settings)
VALUES (?,NULL,?,?,0,?,NULL,?,NULL)`, [profileId, w.instId, w.x || 0, def.defaultSpan, w.type]);
}
}
export async function createProfile({ name, description = null }) {
const naam = String(name || '').trim();
if (!naam) fail('dashboard.nameRequired', null, 'A profile needs a name.');
const nu = nowUtcIso();
const volgend = db.one(`SELECT COALESCE(MAX(sortOrder), -1) + 1 AS n FROM DashboardProfile`).n;
db.run(`INSERT INTO DashboardProfile (name, searchName, description, sortOrder, isActive, isSystem, createdAt, updatedAt)
VALUES (?,?,?,?,0,0,?,?)`,
[naam, toSearchName(naam), description || null, volgend, nu, nu]);
const id = db.lastId();
schrijfStandaardIndeling(id);
await db.persist();
return id;
}
export async function renameProfile(id, { name, description = null }) {
const naam = String(name || '').trim();
if (!naam) fail('dashboard.nameRequired', null, 'A profile needs a name.');
const rij = db.one(`SELECT id FROM DashboardProfile WHERE id = ?`, [id]);
if (!rij) fail('dashboard.unknownProfile', { id }, `Dashboard profile #${id} does not exist.`);
db.run(`UPDATE DashboardProfile SET name = ?, searchName = ?, description = ?, updatedAt = ? WHERE id = ?`,
[naam, toSearchName(naam), description || null, nowUtcIso(), id]);
await db.persist();
}
export async function activateProfile(id) {
const rij = db.one(`SELECT id FROM DashboardProfile WHERE id = ?`, [id]);
if (!rij) fail('dashboard.unknownProfile', { id }, `Dashboard profile #${id} does not exist.`);
db.run(`UPDATE DashboardProfile SET isActive = 0, updatedAt = ? WHERE isActive = 1`, [nowUtcIso()]);
db.run(`UPDATE DashboardProfile SET isActive = 1, updatedAt = ? WHERE id = ?`, [nowUtcIso(), id]);
await db.persist();
}
export async function deleteProfile(id) {
const rij = db.one(`SELECT * FROM DashboardProfile WHERE id = ?`, [id]);
if (!rij) fail('dashboard.unknownProfile', { id }, `Dashboard profile #${id} does not exist.`);
if (rij.isSystem) {
await saveWidgets(id, null);
return;
}
if (rij.isActive) {
const opvolger = db.one(
`SELECT id FROM DashboardProfile WHERE id <> ? ORDER BY isSystem DESC, sortOrder, name LIMIT 1`, [id]);
if (!opvolger) fail('dashboard.lastNotDeletable', null, 'The last dashboard profile cannot be deleted.');
db.run(`UPDATE DashboardProfile SET isActive = 0, updatedAt = ? WHERE id = ?`, [nowUtcIso(), id]);
db.run(`UPDATE DashboardProfile SET isActive = 1, updatedAt = ? WHERE id = ?`, [nowUtcIso(), opvolger.id]);
}
db.run(`DELETE FROM DashboardProfile WHERE id = ?`, [id]);
await db.persist();
}
export function listWidgets(profileId) {
return db.all(`SELECT * FROM DashboardWidget WHERE profileId = ? ORDER BY gridY, gridX, instanceKey`, [profileId]);
}
function assertWidgetType(type) {
if (!DASHBOARD.WIDGETS[type]) {
throw new Error(`Unknown widget type '${type}'. Allowed: ${Object.keys(DASHBOARD.WIDGETS).join(', ')}.`);
}
}
export async function addWidget(profileId, { instanceKey, type, gridX = 0, gridY = 0, assetId = null, settings = null }) {
if (!db.one(`SELECT id FROM DashboardProfile WHERE id = ?`, [profileId])) {
fail('dashboard.unknownProfile', { id: profileId }, `Dashboard profile #${profileId} does not exist.`);
}
assertWidgetType(type);
const key = instanceKey || type;
if (db.one(`SELECT 1 AS x FROM DashboardWidget WHERE profileId = ? AND instanceKey = ?`, [profileId, key])) return false;
const json = settings && typeof settings === 'object' && Object.keys(settings).length ? JSON.stringify(settings) : null;
db.run(`INSERT INTO DashboardWidget (profileId, assetId, instanceKey, gridX, gridY, spanWidth, spanHeight, type, settings)
VALUES (?,?,?,?,?,?,NULL,?,?)`, [profileId, assetId, key, gridX, gridY, DASHBOARD.WIDGETS[type].defaultSpan, type, json]);
await db.persist();
return true;
}
export async function saveWidgets(profileId, widgets) {
const rij = db.one(`SELECT id FROM DashboardProfile WHERE id = ?`, [profileId]);
if (!rij) fail('dashboard.unknownProfile', { id: profileId }, `Dashboard profile #${profileId} does not exist.`);
if (widgets === null) {
db.run(`DELETE FROM DashboardWidget WHERE profileId = ?`, [profileId]);
schrijfStandaardIndeling(profileId);
await db.persist();
return;
}
const lijst = Array.isArray(widgets) ? widgets : [];
for (const w of lijst) assertWidgetType(w.type);
db.run(`DELETE FROM DashboardWidget WHERE profileId = ?`, [profileId]);
for (const w of lijst) {
const params = w.params && typeof w.params === 'object' ? { ...w.params } : {};
const assetId = params.assetId == null ? null : Number(params.assetId);
delete params.assetId;
const settings = Object.keys(params).length ? JSON.stringify(params) : null;
db.run(`INSERT INTO DashboardWidget (profileId, assetId, instanceKey, gridX, gridY, spanWidth, spanHeight, type, settings)
VALUES (?,?,?,?,?,?,?,?,?)`,
[profileId, assetId, w.instanceKey, w.gridX, w.gridY, w.spanWidth, w.spanHeight ?? null, w.type, settings]);
}
await db.persist();
}
+261
View File
@@ -0,0 +1,261 @@
// Generated, do not edit. sha256:68008a6906cce53854e3533a4a9207b146e9943ef96ac00c6c0dd8b1e47f76f3
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum, assertTimestamp, nowIso } from './_shared.js';
import { fail } from '../../shared/app-error.js';
import { trackDelete, trackUpdate } from './change-log.js';
import { emitChange } from './_events.js';
export function listEvents() {
return db.all(`SELECT * FROM Event WHERE type <> 'reservation' ORDER BY occurredAt DESC, id DESC`);
}
export const EVENT_JOIN = `
SELECT e.*, a.symbol AS asset_symbol, a.name AS asset_name, a.decimals AS asset_decimals,
a.unit AS asset_unit, a.subunit AS asset_subunit, a.displayUnit AS asset_display_unit,
a.kind AS asset_kind,
lf.name AS from_name, lt.name AS to_name,
mp.name AS product_name, mp.form AS product_form
FROM Event e
JOIN Asset a ON a.id = e.assetId
LEFT JOIN Account lf ON lf.id = e.fromAccountId
LEFT JOIN Account lt ON lt.id = e.toAccountId
LEFT JOIN MetalProduct mp ON mp.id = e.productId`;
export function listTrades() {
return db.all(`${EVENT_JOIN} WHERE e.type IN ('buy','sell') ORDER BY e.occurredAt DESC, e.id DESC`);
}
export function listTransfers() {
return db.all(`${EVENT_JOIN} WHERE e.type = 'transfer' ORDER BY e.occurredAt DESC, e.id DESC`);
}
export function listAssetTrades(assetId) {
return db.all(`SELECT e.id, e.type, e.amount, e.fiatPrice, e.fiatFee, e.occurredAt,
e.fromAccountId, e.toAccountId,
lf.name AS from_name, lt.name AS to_name,
(SELECT GROUP_CONCAT(DISTINCT l.name)
FROM WalletTransactionEventLink wl
JOIN WalletTransaction wt ON wt.id = wl.walletTransactionId
JOIN Label l ON l.id = wt.labelId
WHERE wl.eventId = e.id) AS label_names
FROM Event e
LEFT JOIN Account lf ON lf.id = e.fromAccountId
LEFT JOIN Account lt ON lt.id = e.toAccountId
WHERE e.assetId = ? AND e.type IN ('buy','sell')
ORDER BY e.occurredAt ASC, e.id ASC`, [assetId]);
}
function assertEventPayload(e) {
assertEnum('type', ENUMS.EVENT_TYPE, e.type);
assertEnum('source', ENUMS.EVENT_SOURCE, e.source);
if ((e.type === 'buy' || e.type === 'sell') && (e.fiatPrice === undefined || e.fiatPrice === null)) {
throw new Error(`fiatPrice is required for type '${e.type}'.`);
}
if (e.type === 'interest' && (Number(e.fiatFee) > 0 || Number(e.unitFee) > 0)) {
fail('trades.feeOnInterest', null, 'A fee on interest counts nowhere; it is refused.');
}
if (e.type === 'buy' && Number(e.unitFee) > 0) {
fail('trades.unitFeeOnBuy', null, 'A fee in units on a purchase counts nowhere; lower the amount instead.');
}
if (e.type === 'reservation') {
if ((e.fromAccountId == null) === (e.toAccountId == null)) throw new Error('A reservation sits on exactly one account.');
if (e.fiatPrice != null || e.fiatFee != null || e.unitFee != null || e.productId != null
|| e.priceOrigin != null || e.importReference != null || e.externalAddress != null) {
throw new Error('A reservation carries no price, fee, product, origin, import reference or address.');
}
}
assertTimestamp('occurredAt', e.occurredAt);
assertProductAmount(e);
}
function priceOriginOf(value) {
if (value == null || value === '') return null;
assertEnum('priceOrigin', ENUMS.EVENT_PRICE_ORIGIN, value);
return value;
}
function assertProductAmount(e) {
if (e.productId == null) return;
const p = db.one(`SELECT fineGrams FROM MetalProduct WHERE id = ?`, [e.productId]);
if (!p) throw new Error(`Metal product ${e.productId} does not exist.`);
const verwacht = e.productCount * p.fineGrams;
if (e.amount !== verwacht) {
throw new Error(`amount (${e.amount}) should be ${e.productCount} × ${p.fineGrams} = ${verwacht} ` +
`for this product; adjust the count or the amount.`);
}
}
export async function createEvent(e) {
assertEventPayload(e);
db.run(`INSERT INTO Event
(assetId, type, occurredAt, amount, fiatPrice, fiatFee, unitFee, productId, productCount, fromAccountId, toAccountId, source, note, importReference, externalAddress, priceOrigin, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[e.assetId, e.type, e.occurredAt, e.amount,
e.fiatPrice ?? null, e.fiatFee ?? null, e.unitFee ?? null, e.productId ?? null, e.productCount ?? null,
e.fromAccountId ?? null, e.toAccountId ?? null, e.source, e.note ?? null, e.importReference ?? null, e.externalAddress ?? null,
priceOriginOf(e.priceOrigin), nowIso()]);
const id = db.lastId();
await db.persist();
emitChange('ledger-changed');
return id;
}
export function findEventByRef(ref) {
if (!ref) return null;
return db.one(`SELECT * FROM Event WHERE importReference = ?`, [ref]) || null;
}
export function eventsWithoutImportRef() {
return db.all(`SELECT * FROM Event WHERE importReference IS NULL AND type <> 'reservation'`);
}
export async function updateEvent(id, e) {
assertEventPayload(e);
const origin = Object.hasOwn(e, 'priceOrigin')
? priceOriginOf(e.priceOrigin)
: (db.one(`SELECT priceOrigin AS o FROM Event WHERE id = ?`, [id]) || {}).o ?? null;
trackUpdate('Event', id, () =>
db.run(`UPDATE Event SET
assetId = ?, type = ?, occurredAt = ?, amount = ?, fiatPrice = ?, fiatFee = ?,
unitFee = ?, productId = ?, productCount = ?, fromAccountId = ?, toAccountId = ?, source = ?, note = ?, externalAddress = ?,
priceOrigin = ?
WHERE id = ?`,
[e.assetId, e.type, e.occurredAt, e.amount,
e.fiatPrice ?? null, e.fiatFee ?? null, e.unitFee ?? null, e.productId ?? null, e.productCount ?? null,
e.fromAccountId ?? null, e.toAccountId ?? null, e.source, e.note ?? null, e.externalAddress ?? null,
origin, id]));
await db.persist();
emitChange('ledger-changed');
}
export async function deleteEvent(id) {
trackDelete('Event', id, () => db.run(`DELETE FROM Event WHERE id = ?`, [id]));
await db.persist();
emitChange('ledger-changed');
}
export function eventsNetByAccount(accountId, assetId, untilIso) {
const r = db.one(`
SELECT COALESCE(SUM(
CASE
WHEN type = 'buy' AND toAccountId = ?1 THEN amount
WHEN type = 'sell' AND fromAccountId = ?1 THEN -(amount + COALESCE(unitFee,0))
WHEN type = 'transfer' AND toAccountId = ?1 THEN amount
WHEN type = 'transfer' AND fromAccountId = ?1 THEN -(amount + COALESCE(unitFee,0))
WHEN type = 'interest' AND toAccountId = ?1 THEN amount
ELSE 0
END
- (CASE WHEN type = 'transfer' AND fromAccountId = ?1 AND toAccountId = ?1 THEN amount + COALESCE(unitFee,0) ELSE 0 END)
), 0) AS net
FROM Event WHERE assetId = ?2${untilIso ? ' AND occurredAt <= ?3' : ''}`,
untilIso ? [accountId, assetId, untilIso] : [accountId, assetId]);
return r.net;
}
export function eventsCountByAccount(accountId, assetId, untilIso) {
const r = db.one(
`SELECT COUNT(*) AS n FROM Event
WHERE assetId = ?2 AND (fromAccountId = ?1 OR toAccountId = ?1) AND type <> 'reservation'
${untilIso ? 'AND occurredAt <= ?3' : ''}`,
untilIso ? [accountId, assetId, untilIso] : [accountId, assetId]);
return r.n;
}
export function eventAssetIdsByAccount(accountId) {
return db.all(
`SELECT DISTINCT assetId FROM Event WHERE (fromAccountId = ?1 OR toAccountId = ?1) AND type <> 'reservation' ORDER BY assetId`,
[accountId]).map(r => r.assetId);
}
export function eventAccountIdsByAsset(assetId) {
return db.all(
`SELECT fromAccountId AS accountId FROM Event WHERE assetId = ?1 AND fromAccountId IS NOT NULL AND type <> 'reservation'
UNION
SELECT toAccountId AS accountId FROM Event WHERE assetId = ?1 AND toAccountId IS NOT NULL AND type <> 'reservation'
ORDER BY accountId`,
[assetId]).map(r => r.accountId);
}
export function eventDaysByAccount(accountId, assetId, vanIso, totIso) {
return db.all(
`SELECT DISTINCT substr(occurredAt, 1, 10) AS dag FROM Event
WHERE assetId = ?2 AND (fromAccountId = ?1 OR toAccountId = ?1) AND type <> 'reservation'
AND substr(occurredAt, 1, 10) > ?3 AND substr(occurredAt, 1, 10) < ?4
ORDER BY dag`, [accountId, assetId, vanIso, totIso]).map(r => r.dag);
}
export function assetPositions(untilIso, { ownOnly = false } = {}) {
return db.all(`
SELECT a.id, a.symbol, a.name, a.kind, a.decimals, a.unit, a.subunit,
a.displayUnit,
COALESCE(SUM(CASE
WHEN e.type = 'buy' AND e.toIn THEN e.amount
WHEN e.type = 'sell' AND e.fromIn THEN -(e.amount + COALESCE(e.unitFee,0))
WHEN e.type = 'interest' AND e.toIn THEN e.amount
WHEN e.type = 'transfer' THEN
(CASE WHEN e.toAccountId IS NOT NULL AND e.toIn THEN e.amount ELSE 0 END)
- (CASE WHEN e.fromAccountId IS NOT NULL AND e.fromIn THEN e.amount + COALESCE(e.unitFee,0) ELSE 0 END)
ELSE 0 END), 0) AS net,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN 1 ELSE 0 END), 0) AS buy_events,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN e.amount ELSE 0 END), 0) AS buy_amount,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN e.amount * e.fiatPrice ELSE 0 END), 0) AS buy_spot_raw,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN COALESCE(e.fiatFee,0) ELSE 0 END), 0) AS buy_fee_total,
COALESCE(SUM(CASE WHEN e.priceOrigin IS 'none' THEN
(CASE
WHEN e.type = 'buy' AND e.toIn THEN e.amount
WHEN e.type = 'sell' AND e.fromIn THEN -e.amount
-- A ONE-SIDED TRANSFER without consideration is a gift too,
-- as listTradeEvents already treats it; without this its units took the average
-- purchase price as cost. Two sides is internal and stays out.
WHEN e.type = 'transfer' AND NOT (e.fromAccountId IS NOT NULL AND e.fromIn)
AND e.toAccountId IS NOT NULL AND e.toIn THEN e.amount
WHEN e.type = 'transfer' AND NOT (e.toAccountId IS NOT NULL AND e.toIn)
AND e.fromAccountId IS NOT NULL AND e.fromIn THEN -e.amount
ELSE 0 END)
ELSE 0 END), 0) AS gift_amount,
COALESCE(SUM(CASE WHEN e.type = 'sell' AND e.fromIn THEN 1 ELSE 0 END), 0) AS sell_events
FROM Asset a
LEFT JOIN (
SELECT Event.*,
(?2 = 0 OR toAccount.wealthScope IS NULL) AS toIn,
(?2 = 0 OR fromAccount.wealthScope IS NULL) AS fromIn
FROM Event
LEFT JOIN Account toAccount ON toAccount.id = Event.toAccountId
LEFT JOIN Account fromAccount ON fromAccount.id = Event.fromAccountId
WHERE ?1 IS NULL OR Event.occurredAt <= ?1
) e ON e.assetId = a.id
GROUP BY a.id
ORDER BY a.symbol`, [untilIso ?? null, ownOnly ? 1 : 0]);
}
export function listTradeEvents(fromIso, untilIso) {
return db.all(`SELECT e.type, e.assetId, e.amount, e.fiatPrice, e.fiatFee, e.priceOrigin, e.occurredAt,
e.fromAccountId, e.toAccountId,
a.decimals
FROM Event e JOIN Asset a ON a.id = e.assetId
WHERE (e.type IN ('buy','sell')
-- EVERY one-sided transfer: value entering or leaving the books. With 'none' a
-- gift, without it a deposit or withdrawal at the day price.
OR (e.type = 'transfer' AND (e.fromAccountId IS NULL) <> (e.toAccountId IS NULL))
-- A EURO FEE ON A TRANSFER is money in that is gone, as with a purchase (plan
-- Bewijzen B2): the caller counts the fee and not the units.
OR (e.type = 'transfer' AND e.fromAccountId IS NOT NULL AND e.fiatFee > 0))
AND (?1 IS NULL OR e.occurredAt > ?1) AND e.occurredAt <= ?2`,
[fromIso, untilIso]);
}
export function listFlowLegs(fromIso, untilIso) {
const venster = `(?1 IS NULL OR Event.occurredAt > ?1) AND Event.occurredAt <= ?2`;
const been = (type, kolom, richting) => `
SELECT Event.${kolom} AS accountId, Event.assetId, Asset.decimals,
Event.type, '${richting}' AS direction, Event.amount AS units,
Event.fiatPrice, Event.fiatFee, Event.priceOrigin, Event.occurredAt
FROM Event JOIN Asset ON Asset.id = Event.assetId
WHERE Event.type = '${type}' AND Event.${kolom} IS NOT NULL AND ${venster}`;
return db.all([
been('buy', 'toAccountId', 'in'),
been('sell', 'fromAccountId', 'out'),
been('transfer', 'toAccountId', 'in'),
been('transfer', 'fromAccountId', 'out'),
].join(' UNION ALL '), [fromIso, untilIso]);
}
export function firstTaxEventDate() {
const r = db.one(`SELECT MIN(occurredAt) AS first FROM Event WHERE type <> 'reservation'`);
return r && r.first ? r.first : null;
}
+383
View File
@@ -0,0 +1,383 @@
// Generated, do not edit. sha256:bbb7036ed8755194a6ae32362bf5faec1d944b2733c93e94b0d21804b434b99e
import { createHash } from 'node:crypto';
import * as db from '../db.js';
import { CONFIG } from '../../shared/config.js';
import { fail } from '../../shared/app-error.js';
import { listOpenValuationDiffs } from './valuations.js';
import { listPriceGaps, getPricePoint } from './price-history.js';
import { trackUpdateMany } from './change-log.js';
import { positionsByAsset } from './positions.js';
import { daysBetween } from './_shared.js';
import { healthChecks } from '../registers.mjs';
function rij(row, counts = {}) {
return { row, counts };
}
function dubbeleEvents() {
return db.all(`SELECT Event.assetId, Asset.symbol, Event.type, Event.occurredAt,
Event.productId, MetalProduct.name AS product_name, COUNT(*) AS n
FROM Event
JOIN Asset ON Asset.id = Event.assetId
LEFT JOIN MetalProduct ON MetalProduct.id = Event.productId
WHERE Event.type <> 'reservation'
GROUP BY Event.assetId, Event.type, Event.occurredAt, Event.amount, Event.productId
HAVING COUNT(*) > 1
ORDER BY n DESC, Event.occurredAt DESC`)
.map(r => rij({ assetId: r.assetId, symbol: r.symbol, type: r.type, occurredAt: r.occurredAt,
productId: r.productId, product_name: r.product_name }, { keer: r.n }));
}
function assetsZonderBalans() {
return db.all(`SELECT Asset.id AS assetId, Asset.symbol, COUNT(Event.id) AS events
FROM Asset
JOIN Event ON Event.assetId = Asset.id AND Event.type <> 'reservation'
WHERE NOT EXISTS (SELECT 1 FROM Valuation WHERE Valuation.assetId = Asset.id)
GROUP BY Asset.id, Asset.symbol
ORDER BY events DESC`)
.map(r => rij({ assetId: r.assetId, symbol: r.symbol }, { boekingen: r.events }));
}
function onverzoendeBalansen() {
const symbolen = new Map(db.all(`SELECT id, symbol FROM Asset`).map(a => [a.id, a.symbol]));
return listOpenValuationDiffs()
.map(v => rij({
assetId: v.assetId, symbol: symbolen.get(v.assetId) ?? null,
accountId: v.accountId, valuedOn: v.valuedOn,
}));
}
const LAATSTE_KOERS = `(SELECT MAX(observedAt) FROM PriceHistory WHERE PriceHistory.assetId = Asset.id)`;
function achterlopendeKoersen() {
return db.all(`SELECT Asset.id AS assetId, Asset.symbol,
MAX(Event.occurredAt) AS occurredAt,
${LAATSTE_KOERS} AS observedAt
FROM Asset
JOIN Event ON Event.assetId = Asset.id AND Event.type <> 'reservation'
GROUP BY Asset.id, Asset.symbol
HAVING ${LAATSTE_KOERS} IS NULL OR ${LAATSTE_KOERS} < MAX(Event.occurredAt)
ORDER BY Asset.symbol`)
.map(r => rij(r));
}
const EERSTE_KOERS = `(SELECT MIN(observedAt) FROM PriceHistory WHERE PriceHistory.assetId = Asset.id)`;
function koersenBeginnenTeLaat() {
const maandDag = `${String(CONFIG.PEILDATUM.MONTH).padStart(2, '0')}-${String(CONFIG.PEILDATUM.DAY).padStart(2, '0')}`;
const EERSTE_PEILDATUM = `(CAST(strftime('%Y', MIN(Event.occurredAt)) AS INTEGER) + 1) || '-${maandDag}'`;
return db.all(`SELECT Asset.id AS assetId, Asset.symbol,
MIN(Event.occurredAt) AS occurredAt,
${EERSTE_KOERS} AS observedAt
FROM Asset
JOIN Event ON Event.assetId = Asset.id AND Event.type <> 'reservation'
GROUP BY Asset.id, Asset.symbol
HAVING ${EERSTE_KOERS} IS NOT NULL
AND substr(${EERSTE_KOERS}, 1, 10) > ${EERSTE_PEILDATUM}
ORDER BY Asset.symbol`)
.map(r => rij(r));
}
function gatenInKoershistorie() {
const perAsset = new Map();
for (const g of listPriceGaps({ head: false, tail: false, limit: null }).rows) {
const a = perAsset.get(g.assetId) || { assetId: g.assetId, symbol: g.symbol, gaten: 0, grootste: 0 };
a.gaten++;
a.grootste = Math.max(a.grootste, g.days);
perAsset.set(g.assetId, a);
}
return [...perAsset.values()].sort((x, y) => y.grootste - x.grootste)
.map(r => rij({ assetId: r.assetId, symbol: r.symbol }, { gaten: r.gaten, grootsteGatInDagen: r.grootste }));
}
function koersGestopt() {
const vandaag = new Date().toISOString().slice(0, 10);
const uit = [];
for (const a of positionsByAsset()) {
const grens = CONFIG.PRICE_HISTORY.CARRY_FORWARD_DAYS[a.kind];
if (!(a.net > 0) || grens == null) continue;
const laatste = db.one(`SELECT MAX(observedAt) AS observedAt FROM PriceHistory WHERE assetId = ? AND fiatPrice > 0`, [a.id]).observedAt;
if (!laatste) continue;
const dagen = daysBetween(laatste.slice(0, 10), vandaag);
if (dagen > grens) uit.push({ dagen, r: rij({ assetId: a.id, symbol: a.symbol, observedAt: laatste }, { dagenZonderKoers: dagen }) });
}
return uit.sort((x, y) => y.dagen - x.dagen).map(x => x.r);
}
function verplaatsingZonderTegenkant() {
return db.all(`SELECT Event.id AS eventId, Event.assetId, Asset.symbol, Event.occurredAt,
Event.fromAccountId, vanaf.name AS from_name,
Event.toAccountId, naar.name AS to_name
FROM Event
JOIN Asset ON Asset.id = Event.assetId
LEFT JOIN Account vanaf ON vanaf.id = Event.fromAccountId
LEFT JOIN Account naar ON naar.id = Event.toAccountId
WHERE Event.type = 'transfer'
AND (Event.fromAccountId IS NULL OR Event.toAccountId IS NULL)
AND (Event.externalAddress IS NULL OR Event.externalAddress = '')
AND Event.priceOrigin IS NOT 'none'
ORDER BY Event.occurredAt DESC`)
.map(r => rij(r));
}
function kostenZonderEffect() {
return db.all(`SELECT Event.id AS eventId, Event.assetId, Asset.symbol, Event.type, Event.occurredAt,
Event.source
FROM Event
JOIN Asset ON Asset.id = Event.assetId
WHERE (Event.type = 'interest' AND Event.fiatFee > 0)
OR (Event.type IN ('buy','interest') AND Event.unitFee > 0)
ORDER BY Event.occurredAt DESC`)
.map(r => rij(r));
}
function ongekoppeldeBewegingen() {
return db.all(`SELECT WalletTransaction.id AS walletTransactionId, WalletTransaction.accountId,
Account.name AS account_name,
WalletTransaction.direction, WalletTransaction.blockTime,
WalletTransaction.createdAt
FROM WalletTransaction
LEFT JOIN Account ON Account.id = WalletTransaction.accountId
-- Lightning only from the node, the same rule as syncTxProposals (proposals.js): an NWC
-- payment without its own event is not unlinked, it hangs on its LND twin; on an account
-- without a LND source the NWC wallet counts on its own.
WHERE (WalletTransaction.layer = 'onchain'
OR EXISTS (SELECT 1 FROM WalletSource
WHERE WalletSource.id = WalletTransaction.sourceId
AND (WalletSource.kind = 'lnd'
OR (WalletSource.kind = 'nwc' AND NOT EXISTS (
SELECT 1 FROM WalletSource node
WHERE node.accountId = WalletTransaction.accountId
AND node.kind = 'lnd')))))
AND NOT EXISTS (SELECT 1 FROM WalletTransactionEventLink
WHERE WalletTransactionEventLink.walletTransactionId = WalletTransaction.id)
AND NOT EXISTS (SELECT 1 FROM ReconcileProposal
WHERE ReconcileProposal.walletTransactionId = WalletTransaction.id
AND ReconcileProposal.status = 'pending')
ORDER BY WalletTransaction.blockTime DESC`)
.map(r => rij(r));
}
function dekkingZonderAsset() {
return db.all(`SELECT ProviderCoverage.providerId, ProviderCoverage.symbol
FROM ProviderCoverage
WHERE NOT EXISTS (SELECT 1 FROM Asset WHERE Asset.symbol = ProviderCoverage.symbol)
ORDER BY ProviderCoverage.symbol`)
.map(r => rij(r));
}
function koersRijen() {
return db.all(`SELECT PriceHistory.id AS priceHistoryId, PriceHistory.assetId, Asset.symbol, Asset.kind,
PriceHistory.providerId, Provider.name AS provider_name, PriceHistory.resolution,
PriceHistory.observedAt, PriceHistory.fiatPrice
FROM PriceHistory
JOIN Asset ON Asset.id = PriceHistory.assetId
JOIN Provider ON Provider.id = PriceHistory.providerId
ORDER BY PriceHistory.assetId, PriceHistory.observedAt, PriceHistory.id`);
}
const koersRij = (r, counts) => rij({ priceHistoryId: r.priceHistoryId, assetId: r.assetId, symbol: r.symbol,
observedAt: r.observedAt, providerId: r.providerId, provider_name: r.provider_name }, counts);
const factor = x => Math.round(x * 10) / 10;
function reeksenPerAsset() {
const m = new Map();
for (const r of koersRijen()) {
if (!(r.fiatPrice > 0)) continue;
if (!m.has(r.assetId)) m.set(r.assetId, []);
m.get(r.assetId).push(r);
}
return m;
}
const isOunceJump = ratio => {
const oz = CONFIG.METALS.UNITS.ozt, tol = CONFIG.HYGIENE.TROY_OUNCE_TOLERANCE;
const bij = (x, doel) => x >= doel * (1 - tol) && x <= doel * (1 + tol);
return bij(ratio, oz) || bij(ratio, 1 / oz);
};
function eenheidsverwisseling() {
const uit = [];
for (const reeks of reeksenPerAsset().values()) {
for (let i = 1; i < reeks.length; i++) {
const ratio = reeks[i].fiatPrice / reeks[i - 1].fiatPrice;
if (isOunceJump(ratio)) uit.push(koersRij(reeks[i], { factor: factor(ratio >= 1 ? ratio : 1 / ratio) }));
}
}
return uit;
}
function uitschieters() {
const f = CONFIG.HYGIENE.OUTLIER_FACTOR;
const uit = [];
for (const reeks of reeksenPerAsset().values()) {
for (let i = 1; i < reeks.length - 1; i++) {
const voor = reeks[i].fiatPrice / reeks[i - 1].fiatPrice, na = reeks[i].fiatPrice / reeks[i + 1].fiatPrice;
if (isOunceJump(voor) || isOunceJump(na)) continue;
const omhoog = voor >= f && na >= f, omlaag = voor <= 1 / f && na <= 1 / f;
if (omhoog || omlaag) uit.push(koersRij(reeks[i], { factor: factor(omhoog ? Math.min(voor, na) : 1 / Math.max(voor, na)) }));
}
}
return uit;
}
function bronnenOneens() {
const grens = CONFIG.HYGIENE.SOURCES_DISAGREE_SHARE;
const perDag = new Map();
for (const r of koersRijen()) {
if (r.resolution !== 'day' || !(r.fiatPrice > 0)) continue;
const sleutel = `${r.assetId}|${r.observedAt.slice(0, 10)}`;
if (!perDag.has(sleutel)) perDag.set(sleutel, []);
perDag.get(sleutel).push(r);
}
const uit = [];
for (const rijen of perDag.values()) {
if (new Set(rijen.map(r => r.providerId)).size < 2) continue;
const laag = rijen.reduce((a, b) => (b.fiatPrice < a.fiatPrice ? b : a));
const hoog = rijen.reduce((a, b) => (b.fiatPrice > a.fiatPrice ? b : a));
const spreiding = hoog.fiatPrice / laag.fiatPrice - 1;
if (spreiding > grens && laag.providerId !== hoog.providerId) {
uit.push(rij({ assetId: laag.assetId, symbol: laag.symbol, observedAt: laag.observedAt,
low_provider_name: laag.provider_name, high_provider_name: hoog.provider_name },
{ verschilProcent: Math.round(spreiding * 1000) / 10 }));
}
}
return uit;
}
function koersNietPositief() {
return koersRijen().filter(r => r.fiatPrice <= 0).map(r => koersRij(r, {}));
}
function koersInToekomst() {
const nu = new Date().toISOString();
return koersRijen().filter(r => r.observedAt > nu).map(r => koersRij(r, {}));
}
function boekingenMetDagkoers({ origin } = {}) {
const rijen = db.all(`SELECT Event.id AS eventId, Event.assetId, Asset.symbol, Event.type, Event.occurredAt,
Event.priceOrigin, Event.fiatPrice
FROM Event
JOIN Asset ON Asset.id = Event.assetId
WHERE Event.type IN ('buy','sell') AND Event.fiatPrice > 0
AND Event.priceOrigin IS NOT 'none'
AND (?1 = 0 OR Event.priceOrigin IS NULL)
ORDER BY Event.occurredAt DESC, Event.id DESC`, [origin === 'unknown' ? 1 : 0]);
const uit = [];
for (const r of rijen) {
const dag = getPricePoint(r.assetId, String(r.occurredAt).slice(0, 10));
if (dag && dag.fiatPrice > 0) uit.push({ ...r, ratio: r.fiatPrice / dag.fiatPrice });
}
return uit;
}
const boekingRij = (r, counts) => rij({ eventId: r.eventId, assetId: r.assetId, symbol: r.symbol, type: r.type,
occurredAt: r.occurredAt, priceOrigin: r.priceOrigin }, counts);
function boekingsprijsWijktAf() {
const grens = CONFIG.HYGIENE.BOOKING_PRICE_DEVIATION;
const uit = [];
for (const r of boekingenMetDagkoers()) {
if (isOunceJump(r.ratio)) uit.push(boekingRij(r, { factor: factor(r.ratio >= 1 ? r.ratio : 1 / r.ratio) }));
else if (Math.abs(r.ratio - 1) > grens) uit.push(boekingRij(r, { afwijkingProcent: Math.round((r.ratio - 1) * 1000) / 10 }));
}
return uit;
}
const SAME_PRICE_SHARE = 0.0005;
const afleidbaar = () => boekingenMetDagkoers({ origin: 'unknown' })
.filter(r => Math.abs(r.ratio - 1) <= SAME_PRICE_SHARE);
function herkomstAfleidbaar() {
return afleidbaar().map(r => boekingRij(r, {}));
}
export async function fillDerivablePriceOrigins() {
const ids = afleidbaar().map(r => r.eventId);
if (!ids.length) return 0;
trackUpdateMany('Event', ids, () => db.run(
`UPDATE Event SET priceOrigin = 'market_day'
WHERE priceOrigin IS NULL AND id IN (${ids.map(() => '?').join(',')})`, ids));
await db.persist();
return ids.length;
}
const CHECKS = Object.freeze([
{ kind: 'duplicate_events', severity: 'warn', run: dubbeleEvents, countsFrom: ['id'] },
{ kind: 'assets_without_balance', severity: 'info', run: assetsZonderBalans, countsFrom: ['id'] },
{ kind: 'unreconciled_valuations', severity: 'warn', run: onverzoendeBalansen, countsFrom: [] },
{ kind: 'stale_prices', severity: 'warn', run: achterlopendeKoersen, countsFrom: [] },
{ kind: 'late_price_history', severity: 'warn', run: koersenBeginnenTeLaat, countsFrom: [] },
{ kind: 'price_gaps', severity: 'info', run: gatenInKoershistorie, countsFrom: ['observedAt'] },
{ kind: 'price_stopped', severity: 'warn', run: koersGestopt, countsFrom: ['observedAt'] },
{ kind: 'transfer_without_counterpart', severity: 'warn', run: verplaatsingZonderTegenkant, countsFrom: [] },
{ kind: 'fee_without_effect', severity: 'info', run: kostenZonderEffect, countsFrom: [] },
{ kind: 'unlinked_transactions', severity: 'warn', run: ongekoppeldeBewegingen, countsFrom: [] },
{ kind: 'coverage_without_asset', severity: 'info', run: dekkingZonderAsset, countsFrom: [] },
{ kind: 'price_unit_swap', severity: 'warn', run: eenheidsverwisseling, countsFrom: ['fiatPrice'] },
{ kind: 'price_outlier', severity: 'warn', run: uitschieters, countsFrom: ['fiatPrice'] },
{ kind: 'price_sources_disagree', severity: 'info', run: bronnenOneens, countsFrom: ['fiatPrice'] },
{ kind: 'price_not_positive', severity: 'warn', run: koersNietPositief, countsFrom: [] },
{ kind: 'price_in_future', severity: 'warn', run: koersInToekomst, countsFrom: [] },
{ kind: 'booking_price_deviates', severity: 'warn', run: boekingsprijsWijktAf, countsFrom: ['fiatPrice'] },
{ kind: 'price_origin_derivable', severity: 'info', run: herkomstAfleidbaar, countsFrom: ['fiatPrice'] },
]);
export function hygieneKinds() {
return checks().map(c => ({ kind: c.kind, severity: c.severity }));
}
function checks() {
const extra = healthChecks.list().map(e => ({ kind: e.id, severity: e.severity, run: e.run, countsFrom: e.countsFrom }));
const dubbel = extra.find(e => CHECKS.some(c => c.kind === e.kind));
if (dubbel) throw new Error(`health check '${dubbel.kind}' is registered and also a core check`);
return [...CHECKS, ...extra];
}
export function hygieneFingerprint(kind, row = {}, counts = {}) {
const vast = obj => JSON.stringify(Object.keys(obj).sort().map(k => [k, obj[k]]));
return createHash('sha256').update(`${kind}|${vast(row)}|${vast(counts)}`).digest('hex').slice(0, 32);
}
export function listHygieneAcceptances() {
return db.all(`SELECT id, kind, fingerprint, summary, note, createdAt
FROM HygieneAcceptance ORDER BY createdAt DESC, id DESC`);
}
export async function acceptHygieneFinding({ kind, row = {}, counts = {}, summary, note = null } = {}) {
if (!checks().some(c => c.kind === kind)) {
fail('health.acceptUnknownKind', {}, `Unknown hygiene finding kind '${kind}'.`);
}
if (!summary || !String(summary).trim()) {
fail('health.acceptNeedsSummary', {}, 'A hygiene acceptance needs a readable summary.');
}
const fingerprint = hygieneFingerprint(kind, row, counts);
db.run(`INSERT INTO HygieneAcceptance (kind, fingerprint, summary, note, createdAt)
VALUES (?,?,?,?,?)
ON CONFLICT(fingerprint) DO UPDATE SET
summary = excluded.summary, note = excluded.note, createdAt = excluded.createdAt`,
[kind, fingerprint, String(summary).trim(), note ? String(note).trim() : null, new Date().toISOString()]);
await db.persist();
return fingerprint;
}
export async function unacceptHygieneFinding(id) {
db.run(`DELETE FROM HygieneAcceptance WHERE id = ?`, [id]);
await db.persist();
}
export function hygieneReport({ kinds = null } = {}) {
const alle = checks();
const gekozen = kinds ? alle.filter(c => kinds.includes(c.kind)) : alle;
const grens = CONFIG.HYGIENE.MAX_ROWS_PER_FINDING;
const aanvaard = new Set(db.all(`SELECT fingerprint FROM HygieneAcceptance`).map(r => r.fingerprint));
const bevindingen = gekozen.map(c => {
const alles = aanvaard.size
? c.run().filter(r => !aanvaard.has(hygieneFingerprint(c.kind, r.row, r.counts)))
: c.run();
return {
kind: c.kind, severity: c.severity, countsFrom: c.countsFrom,
total: alles.length,
rows: alles.length > grens ? alles.slice(0, grens) : alles,
};
});
return {
total: bevindingen.reduce((n, b) => n + b.total, 0),
findings: bevindingen,
};
}
+105
View File
@@ -0,0 +1,105 @@
// Generated, do not edit. sha256:3c9603902fe01cc9593bf69315c4734fdbba824688512bed37af01059c22d688
import * as db from '../db.js';
import { recordApiCall } from './api-metrics.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
const LOG = CONFIG.INTEGRATION_LOG;
const MIN_SECRET_LENGTH = 4;
const AUTH_VALUE = /\b(Bearer|Basic)\s+\S+/gi;
const SECRET_PARAM = /\b((?:api[_-]?key|apikey|access[_-]?token|token|key|secret|signature|password)=)[^&\s"']+/gi;
const URL_TEXT = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi;
function cleanDetail(text, secrets) {
if (text == null) return null;
let s = String(text);
for (const v of secrets) {
if (typeof v !== 'string' || v.length < MIN_SECRET_LENGTH) continue;
for (const form of new Set([v, encodeURIComponent(v)])) s = s.split(form).join('***');
}
s = s.replace(URL_TEXT, '<url>').replace(AUTH_VALUE, '$1 ***').replace(SECRET_PARAM, '$1***');
s = s.replace(/\s+/g, ' ').trim();
if (!s) return null;
return s.length > LOG.DETAIL_MAX_CHARS ? `${s.slice(0, LOG.DETAIL_MAX_CHARS - 1)}…` : s;
}
const KEY_PART = /^[A-Za-z][\w.-]{0,79}$/;
const cleanKey = (key) => {
const parts = String(key ?? '').split(/\s+/).filter(p => KEY_PART.test(p));
return parts.length ? parts.join(' ') : null;
};
const REQUEST_ID = /^[A-Za-z0-9-]{8,64}$/;
function timeOf(at) {
if (at == null) return new Date().toISOString();
const d = new Date(at);
if (Number.isNaN(d.getTime())) throw new Error(`Invalid occurredAt: '${at}'.`);
return d.toISOString();
}
function prune() {
const grens = new Date(Date.now() - LOG.RETENTION_DAYS * 86_400_000).toISOString();
db.run(`DELETE FROM IntegrationCall WHERE occurredAt < ?`, [grens]);
db.run(`DELETE FROM IntegrationCall
WHERE id <= (SELECT id FROM IntegrationCall ORDER BY id DESC LIMIT 1 OFFSET ?)`, [LOG.MAX_ROWS]);
}
export async function recordIntegrationCall(entry, { secrets = [] } = {}) {
const e = entry || {};
if (!ENUMS.INTEGRATION_TARGET.includes(e.target)) throw new Error(`Unknown integration target: '${e.target}'.`);
if (!ENUMS.INTEGRATION_OUTCOME.includes(e.outcome)) throw new Error(`Unknown integration outcome: '${e.outcome}'.`);
const providerKey = e.target === 'provider' ? String(e.providerKey || '') : null;
if (e.target === 'provider' && !providerKey) throw new Error('A provider row needs a providerKey.');
const operation = String(e.operation || '');
if (!operation || /[?#]|:\/\//.test(operation)) throw new Error(`An operation is a path or a capability: '${operation}'.`);
const subject = e.subject == null ? null : String(e.subject).slice(0, 100);
const status = Number.isInteger(e.httpStatus) && e.httpStatus >= 100 && e.httpStatus <= 599 ? e.httpStatus : null;
const latency = Number.isFinite(e.latencyMs) && e.latencyMs >= 0 ? Math.round(e.latencyMs) : null;
const requestId = e.target === 'service' && REQUEST_ID.test(String(e.requestId ?? '')) ? e.requestId : null;
const clean = cleanDetail(e.detail, Array.isArray(secrets) ? secrets : []);
db.run(`INSERT INTO IntegrationCall
(target, providerKey, operation, subject, outcome, httpStatus, errorKey, detail, latencyMs, requestId, occurredAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
[e.target, providerKey, operation, subject, e.outcome, status, cleanKey(e.errorKey), clean, latency, requestId,
timeOf(e.occurredAt)]);
prune();
await db.persist();
}
export async function countServiceCall({ capability, ok, latencyMs }) {
const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [CONFIG.MARKET_SERVICE.PROVIDER_KEY]);
if (!p) return;
await recordApiCall({ providerId: p.id, capability, ok: !!ok, latency_ms: latencyMs });
}
export function listIntegrationCalls({ days = 7, target = null, providerKey = null, limit = 200 } = {}) {
const sinds = new Date(Date.now() - Math.max(0, Number(days) || 0) * 86_400_000).toISOString();
const n = Math.min(Math.max(1, Math.trunc(Number(limit)) || 200), LOG.MAX_ROWS);
const t = target || null;
const p = providerKey || null;
return db.all(`SELECT * FROM IntegrationCall
WHERE occurredAt >= ? AND (? IS NULL OR target = ?) AND (? IS NULL OR providerKey = ?)
ORDER BY id DESC LIMIT ?`, [sinds, t, t, p, p, n]);
}
export function serviceState() {
const lastFail = db.one(`SELECT * FROM IntegrationCall WHERE target = 'service' AND outcome = 'fail'
ORDER BY id DESC LIMIT 1`) || null;
const row = db.one(`SELECT MAX(bucketMinute) AS at FROM ApiCallMetric
JOIN Provider ON Provider.id = ApiCallMetric.providerId
WHERE Provider.key = ? AND okCount > 0`, [CONFIG.MARKET_SERVICE.PROVIDER_KEY]);
const lastOkAt = row?.at ?? null;
if (lastFail && (!lastOkAt || lastFail.occurredAt.slice(0, 16) >= lastOkAt)) {
return { ok: false, at: lastFail.occurredAt, lastFail, lastOkAt };
}
return { ok: lastOkAt ? true : null, at: lastOkAt ? `${lastOkAt}:00.000Z` : null, lastFail, lastOkAt };
}
export function lastIntegrationFailures() {
return db.all(`SELECT * FROM IntegrationCall
WHERE id IN (SELECT MAX(id) FROM IntegrationCall GROUP BY target, providerKey)
ORDER BY id DESC`);
}
+233
View File
@@ -0,0 +1,233 @@
// Generated, do not edit. sha256:c2a988d6416e302a0ad7224bc98a3cd265bc5cc9cadb0dce809af179af082299
import * as db from '../db.js';
import { nowIso, todayIso, daysBetween, isoToDate, dateToIso, addDaysIso, weekdayOf, clampDom } from './_shared.js';
import { createEvent, eventDaysByAccount } from './events.js';
import { listSelectableAccounts } from './accounts.js';
import { positionAt } from './positions.js';
import { listSchedulesForAccount, listPeriods, realizedForSchedule } from './interest-schedules.js';
import { anchorDaysBetween } from './valuations.js';
import { toUtcIso } from '../../shared/time.js';
import { fail } from '../../shared/app-error.js';
export function realizedInterestSub(accountId, assetId, fromIso, toIso) {
const r = db.one(`SELECT COALESCE(SUM(amount),0) AS s FROM Event
WHERE type = 'interest' AND toAccountId = ? AND assetId = ? AND occurredAt >= ? AND occurredAt < ?`,
[accountId, assetId, fromIso, toIso]);
return r ? r.s : 0;
}
function weeklyDates(startIso, endIso, weekday) {
const out = [];
let cur = addDaysIso(startIso, 1), guard = 0;
while (weekdayOf(cur) !== weekday && guard++ < 7) cur = addDaysIso(cur, 1);
for (; cur <= endIso; cur = addDaysIso(cur, 7)) out.push(cur);
return out;
}
function monthlyDates(startIso, endIso, dom) {
const out = [];
const s = isoToDate(startIso);
let y = s.getUTCFullYear(), m = s.getUTCMonth();
for (;;) {
const iso = dateToIso(new Date(Date.UTC(y, m, clampDom(y, m, dom))));
if (iso > endIso) break;
if (iso > startIso) out.push(iso);
if (++m > 11) { m = 0; y++; }
}
return out;
}
function buildPeriods(dates, startIso) {
const periods = [];
let prev = startIso;
for (const d of dates) { periods.push({ periodStart: prev, periodEnd: d }); prev = d; }
return periods;
}
function allCreditingPeriods(s, horizonIso = todayIso()) {
const periods = cadencePeriods(s, horizonIso);
if (s.endsOn && s.creditCadence !== 'term_end') {
const last = periods.length ? periods[periods.length - 1].periodEnd : s.startsOn;
if (last < s.endsOn) periods.push({ periodStart: last, periodEnd: s.endsOn });
}
return periods;
}
function cadencePeriods(s, horizonIso) {
const eind = s.endsOn || horizonIso;
if (s.creditCadence === 'term_end') return s.endsOn ? [{ periodStart: s.startsOn, periodEnd: s.endsOn }] : [];
if (s.creditCadence === 'annual') {
const periods = [];
const startYear = Number(s.startsOn.slice(0, 4));
for (let y = startYear; ; y++) {
const periodEnd = `${y + 1}-01-01`;
if (periodEnd > eind) break;
periods.push({ periodStart: y === startYear ? s.startsOn : `${y}-01-01`, periodEnd: periodEnd });
}
return periods;
}
if (s.creditCadence === 'quarterly') {
const periods = [];
const s0 = isoToDate(s.startsOn);
let y = s0.getUTCFullYear();
let m = Math.floor(s0.getUTCMonth() / 3) * 3;
let prev = s.startsOn;
for (;;) {
m += 3;
if (m > 11) { m -= 12; y++; }
const periodEnd = dateToIso(new Date(Date.UTC(y, m, 1)));
if (periodEnd > eind) break;
periods.push({ periodStart: prev, periodEnd });
prev = periodEnd;
}
return periods;
}
const dates = s.creditCadence === 'weekly'
? weeklyDates(s.startsOn, eind, s.creditWeekday)
: monthlyDates(s.startsOn, eind, s.creditDayOfMonth);
return buildPeriods(dates, s.startsOn);
}
export function scheduleForecast(s, asofIso = todayIso()) {
const realizedSub = realizedForSchedule(s, asofIso);
const sameAsset = s.principalAssetId === s.payoutAssetId;
const periods = allCreditingPeriods(s, addDaysIso(asofIso, 400));
const amount = periodAmounts(s, periods);
const later = periods.filter(p => p.periodEnd > asofIso);
const upcoming = (s.endsOn ? later : later.slice(0, 1)).map(p => ({ ...p, projectedSub: amount(p) }));
if (!s.endsOn || s.principalAmount == null) {
return { termTotalSub: null, realizedSub, remainingSub: null, remainingCount: null, sameAsset, upcoming };
}
const termTotalSub = periods.reduce((n, p) => n + amount(p), 0);
const remainingSub = Math.max(0, termTotalSub - realizedSub);
return { termTotalSub, realizedSub, remainingSub, remainingCount: upcoming.length, sameAsset, upcoming };
}
function periodAmounts(s, periods) {
if (!s.compounding) return p => projectedSubForPeriod(s, p);
const byEnd = new Map();
let accrued = 0;
for (const p of periods) {
const sub = projectedSubForPeriod(s, p, accrued);
byEnd.set(p.periodEnd, sub);
accrued += sub;
}
return p => byEnd.get(p.periodEnd);
}
function creditingPeriods(s, asofIso) {
return allCreditingPeriods(s).filter(p => p.periodEnd <= asofIso);
}
function rateSlices(s, vanIso, totIso) {
const perioden = listPeriods(s.id);
const wissels = perioden.filter(p => p.startsOn > vanIso && p.startsOn < totIso);
const eerder = perioden.filter(p => p.startsOn <= vanIso);
let rate = eerder.length ? eerder[eerder.length - 1].annualRate : s.annualRate;
const uit = [];
let van = vanIso;
for (const w of wissels) {
uit.push({ van, tot: w.startsOn, rate });
van = w.startsOn;
rate = w.annualRate;
}
uit.push({ van, tot: totIso, rate });
return uit;
}
function principalMainVoor(s, opDag) {
if (s.principalAmount != null) return s.principalAmount / Math.pow(10, s.principal_decimals);
const moment = toUtcIso(`${opDag}T23:59:59`);
return positionAt(s.accountId, s.principalAssetId, moment) / Math.pow(10, s.principal_decimals);
}
function saldoDagen(s, vanDag, totDag) {
const dagen = new Set([
...eventDaysByAccount(s.accountId, s.principalAssetId, vanDag, totDag),
...anchorDaysBetween(s.accountId, s.principalAssetId, vanDag, totDag),
]);
return [...dagen].sort();
}
function projectedSubForPeriod(s, p, accruedSub = 0) {
const stukken = [];
for (const deel of rateSlices(s, p.periodStart, p.periodEnd)) {
if (s.principalAmount != null) { stukken.push(deel); continue; }
let van = deel.van;
for (const dag of saldoDagen(s, deel.van, deel.tot)) {
stukken.push({ van, tot: dag, rate: deel.rate });
van = dag;
}
stukken.push({ van, tot: deel.tot, rate: deel.rate });
}
const accrued = accruedSub / Math.pow(10, s.payout_decimals);
const som = stukken.reduce((n, deel) =>
n + (principalMainVoor(s, deel.van) + accrued) * deel.rate * Math.max(0, daysBetween(deel.van, deel.tot) / 365), 0);
return Math.round(som * Math.pow(10, s.payout_decimals));
}
function getCredit(scheduleId, periodEnd) {
return db.one(`SELECT * FROM InterestCredit WHERE scheduleId = ? AND periodEnd = ?`, [scheduleId, periodEnd]) || null;
}
export function dueCreditsForAccount(accountId) {
const today = todayIso();
const out = [];
for (const s of listSchedulesForAccount(accountId)) {
if (s.rateKind !== 'fixed') continue;
const periods = creditingPeriods(s, today);
const amount = periodAmounts(s, periods);
for (const p of periods) {
if (getCredit(s.id, p.periodEnd)) continue;
if (realizedForSchedule(s, p.periodEnd, addDaysIso(p.periodStart, 1)) > 0) continue;
const projected = amount(p);
if (projected === 0) continue;
out.push({
scheduleId: s.id, accountId: accountId, payoutAssetId: s.payoutAssetId,
payout_symbol: s.payout_symbol, payout_decimals: s.payout_decimals,
payout_unit: s.payout_unit, payout_subunit: s.payout_subunit, payout_display_unit: s.payout_display_unit,
periodStart: p.periodStart, periodEnd: p.periodEnd, projected_sub: projected,
});
}
}
return out;
}
export function dueCreditsAll() {
const out = [];
for (const a of listSelectableAccounts()) {
for (const d of dueCreditsForAccount(a.id)) out.push({ ...d, account_name: a.name });
}
return out;
}
function olderOpenDue(due) {
return dueCreditsForAccount(due.accountId)
.filter(d => d.periodEnd < due.periodEnd)
.sort((a, b) => a.periodEnd.localeCompare(b.periodEnd))[0] || null;
}
export async function bookDueCredit(due, amountSub) {
const ouder = olderOpenDue(due);
if (ouder) {
fail('interest.bookOlderFirst', { start: ouder.periodStart, eind: ouder.periodEnd },
`Book the credit of ${ouder.periodStart} to ${ouder.periodEnd} first: each credit raises the `
+ 'balance the next one is computed on.');
}
const eventId = await createEvent({
assetId: due.payoutAssetId, type: 'interest', occurredAt: toUtcIso(`${due.periodEnd}T00:00:00`),
amount: amountSub, fiatPrice: null, fiatFee: null, unitFee: null,
fromAccountId: null, toAccountId: due.accountId, source: 'manual',
importReference: null, externalAddress: null, note: null,
});
db.run(`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, eventId, status, createdAt)
VALUES (?,?,?,?, 'booked', ?)`,
[due.scheduleId, due.periodStart, due.periodEnd, eventId, nowIso()]);
await db.persist();
return eventId;
}
export async function dismissDueCredit(due) {
db.run(`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, eventId, status, createdAt)
VALUES (?,?,?, NULL, 'dismissed', ?)`,
[due.scheduleId, due.periodStart, due.periodEnd, nowIso()]);
await db.persist();
}
+193
View File
@@ -0,0 +1,193 @@
// Generated, do not edit. sha256:1f3b140e78bb63390abe586ae0f82dd70eb59c31c977547efd2e278b927a1a2f
import * as db from '../db.js';
import { localDate } from '../../shared/time.js';
import { ENUMS } from '../../shared/config.js';
import { fail } from '../../shared/app-error.js';
import { assertEnum, nowIso } from './_shared.js';
import { EVENT_JOIN } from './events.js';
import { trackDelete, trackUpdate } from './change-log.js';
import { emitChange } from './_events.js';
export function listInterestForAccount(accountId) {
return db.all(`${EVENT_JOIN} WHERE e.type = 'interest' AND e.toAccountId = ? ORDER BY e.occurredAt DESC, e.id DESC`, [accountId]);
}
export function listInterest() {
const rows = db.all(`${EVENT_JOIN} WHERE e.type = 'interest' ORDER BY e.occurredAt DESC, e.id DESC`);
const schedules = listAllSchedules();
const links = creditLinks();
for (const r of rows) {
const s = attributeSchedule(r, schedules, links);
r.scheduleId = s ? s.id : null;
r.schedule_label = s ? scheduleLabel(s) : null;
}
return rows;
}
export function listAllSchedules() {
return db.all(`
SELECT s.*, ac.name AS account_name,
pa.symbol AS principal_symbol, pa.decimals AS principal_decimals,
pa.unit AS principal_unit, pa.subunit AS principal_subunit, pa.displayUnit AS principal_display_unit,
po.symbol AS payout_symbol, po.decimals AS payout_decimals,
po.unit AS payout_unit, po.subunit AS payout_subunit, po.displayUnit AS payout_display_unit
FROM InterestSchedule s
JOIN Account ac ON ac.id = s.accountId
LEFT JOIN Asset pa ON pa.id = s.principalAssetId
JOIN Asset po ON po.id = s.payoutAssetId
ORDER BY s.startsOn DESC, s.id DESC`);
}
export function scheduleLabel(s) {
const period = `${s.startsOn}–${s.endsOn || 'heden'}`;
const kind = s.rateKind === 'variable' ? 'variabel' : `${+(s.annualRate * 100).toFixed(4)}%`;
return `${s.account_name} · ${s.payout_symbol} · ${kind} · ${period}`;
}
export function realizedForSchedule(s, asofIso, sinceIso = null) {
const stored = listSchedulesForAccount(s.accountId);
const schedules = stored.some(o => o.id === s.id) ? stored.map(o => (o.id === s.id ? s : o)) : [...stored, s];
const links = creditLinks();
return db.all(`SELECT id, occurredAt, toAccountId, assetId, amount FROM Event
WHERE type = 'interest' AND toAccountId = ? AND assetId = ? AND occurredAt >= ? AND occurredAt <= ?`,
[s.accountId, s.payoutAssetId, sinceIso || '', `${asofIso}T23:59:59`])
.filter(ev => attributeSchedule(ev, schedules, links) === s)
.reduce((n, ev) => n + ev.amount, 0);
}
function creditLinks() {
return new Map(db.all(`SELECT eventId, scheduleId FROM InterestCredit WHERE eventId IS NOT NULL`)
.map(c => [c.eventId, c.scheduleId]));
}
function attributeSchedule(ev, schedules, links) {
const linkedId = links.get(ev.id);
const linked = linkedId != null ? schedules.find(s => s.id === linkedId) : null;
if (linked) return linked;
const date = localDate(ev.occurredAt);
let best = null;
for (const s of schedules) {
if (s.accountId !== ev.toAccountId || s.payoutAssetId !== ev.assetId) continue;
if (date < s.startsOn || (s.endsOn && date > s.endsOn)) continue;
if (!best || s.startsOn > best.startsOn) best = s;
}
return best;
}
export function listSchedulesForAccount(accountId) {
return db.all(`
SELECT s.*,
pa.symbol AS principal_symbol, pa.decimals AS principal_decimals,
pa.unit AS principal_unit, pa.subunit AS principal_subunit, pa.displayUnit AS principal_display_unit,
po.symbol AS payout_symbol, po.decimals AS payout_decimals,
po.unit AS payout_unit, po.subunit AS payout_subunit, po.displayUnit AS payout_display_unit
FROM InterestSchedule s
LEFT JOIN Asset pa ON pa.id = s.principalAssetId
JOIN Asset po ON po.id = s.payoutAssetId
WHERE s.accountId = ?
ORDER BY s.startsOn DESC, s.id DESC`, [accountId]);
}
function assertSchedule(s) {
assertEnum('rateKind', ENUMS.INTEREST_RATE_KIND, s.rateKind);
if (!s.payoutAssetId) fail('accounts.schPayoutRequired', null, 'A schedule needs a payout asset.');
if (!s.startsOn) fail('accounts.schStartRequired', null, 'A schedule needs a start date.');
if (s.endsOn && s.endsOn < s.startsOn) fail('accounts.schEndBeforeStart', null, 'The end date precedes the start date.');
if (s.rateKind === 'variable') {
if (s.principalAssetId != null || s.principalAmount != null || s.annualRate != null) throw new Error('A schedule on "manual or import" has no principal or rate: the app computes nothing there.');
if (s.creditCadence != null || s.creditWeekday != null || s.creditDayOfMonth != null) throw new Error('A schedule on "manual or import" has no credit cadence: the interest comes from outside.');
if (s.compounding) throw new Error('A schedule on "manual or import" has no interest on interest: the app computes nothing there.');
return;
}
if (s.compounding) {
if (s.principalAmount == null) fail('interest.compoundingNeedsPrincipal', null, 'Interest on interest needs a fixed principal; a principal that follows the balance already grows by every credit.');
if (s.principalAssetId !== s.payoutAssetId) fail('interest.compoundingNeedsSameAsset', null, 'Interest on interest needs the payout in the principal asset.');
}
if (!s.principalAssetId) fail('accounts.schAssetRequired', null, 'A schedule needs a principal asset.');
if (s.principalAmount != null && !(s.principalAmount > 0)) {
fail('interest.principalPositive', null, 'The principal has to be greater than 0; leave it empty to follow the account balance.');
}
if (!(s.annualRate >= 0)) fail('interest.rateNotNegative', null, 'The annual rate may not be negative.');
assertEnum('creditCadence', ENUMS.INTEREST_CADENCE, s.creditCadence);
if (s.creditCadence === 'weekly') {
if (!Number.isInteger(s.creditWeekday) || s.creditWeekday < 0 || s.creditWeekday > 6) fail('interest.weekdayRequired', null, 'Pick a weekday for the weekly credit.');
if (s.creditDayOfMonth != null) throw new Error('A weekly schedule has no day of the month.');
} else if (s.creditCadence === 'monthly') {
if (!Number.isInteger(s.creditDayOfMonth) || s.creditDayOfMonth < 1 || s.creditDayOfMonth > 31) fail('interest.dayOfMonthRequired', null, 'Pick a day of the month for the monthly credit.');
if (s.creditWeekday != null) throw new Error('A monthly schedule has no weekday.');
} else if (s.creditWeekday != null || s.creditDayOfMonth != null) {
throw new Error('This cadence uses neither a weekday nor a day of the month.');
}
}
export async function createSchedule(s) {
assertSchedule(s);
db.run(`INSERT INTO InterestSchedule
(accountId, rateKind, principalAssetId, payoutAssetId, principalAmount, annualRate, startsOn, endsOn, creditCadence, creditWeekday, creditDayOfMonth, compounding, note, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[s.accountId, s.rateKind, s.principalAssetId ?? null, s.payoutAssetId, s.principalAmount ?? null, s.annualRate ?? null,
s.startsOn, s.endsOn ?? null, s.creditCadence ?? null, s.creditWeekday ?? null, s.creditDayOfMonth ?? null,
s.compounding ? 1 : 0, s.note ?? null, nowIso()]);
const id = db.lastId();
await db.persist();
emitChange('ledger-changed');
return id;
}
export async function updateSchedule(id, s) {
assertSchedule(s);
if (!s.accountId) fail('interest.schAccountRequired', null, 'A schedule needs an account.');
trackUpdate('InterestSchedule', id, () =>
db.run(`UPDATE InterestSchedule SET
accountId = ?, rateKind = ?, principalAssetId = ?, payoutAssetId = ?, principalAmount = ?, annualRate = ?,
startsOn = ?, endsOn = ?, creditCadence = ?, creditWeekday = ?, creditDayOfMonth = ?, compounding = ?, note = ?
WHERE id = ?`,
[s.accountId, s.rateKind, s.principalAssetId ?? null, s.payoutAssetId, s.principalAmount ?? null, s.annualRate ?? null,
s.startsOn, s.endsOn ?? null, s.creditCadence ?? null, s.creditWeekday ?? null, s.creditDayOfMonth ?? null,
s.compounding ? 1 : 0, s.note ?? null, id]));
await db.persist();
emitChange('ledger-changed');
}
export async function deleteSchedule(id) {
trackDelete('InterestSchedule', id, () => db.run(`DELETE FROM InterestSchedule WHERE id = ?`, [id]));
await db.persist();
emitChange('ledger-changed');
}
export function listPeriods(scheduleId) {
return db.all(`SELECT * FROM InterestPeriod WHERE scheduleId = ? ORDER BY startsOn ASC`, [scheduleId]);
}
function assertPeriod(scheduleId, p) {
const s = db.one(`SELECT startsOn, endsOn, rateKind FROM InterestSchedule WHERE id = ?`, [scheduleId]);
if (!s) fail('interest.unknownSchedule', null, 'Unknown interest schedule.');
if (s.rateKind !== 'fixed') throw new Error('A schedule on "manual or import" has no rate, and therefore no interest periods.');
if (!p.startsOn) fail('interest.periodStartRequired', null, 'A period needs a start date.');
if (p.startsOn <= s.startsOn) {
fail('interest.periodStartBeforeSchedule', { start: s.startsOn },
`The start date has to be after the start of the schedule (${s.startsOn}); the rate from that day on is on the schedule itself.`);
}
if (s.endsOn && p.startsOn > s.endsOn) {
fail('interest.periodStartAfterEnd', { eind: s.endsOn },
`The start date is after the end of the schedule (${s.endsOn}).`);
}
if (!(p.annualRate >= 0)) fail('interest.rateNotNegative', null, 'The annual rate may not be negative.');
}
export async function createPeriod(scheduleId, p) {
assertPeriod(scheduleId, p);
db.run(`INSERT INTO InterestPeriod (scheduleId, startsOn, annualRate) VALUES (?,?,?)`,
[scheduleId, p.startsOn, p.annualRate]);
const id = db.lastId();
await db.persist();
return id;
}
export async function updatePeriod(id, p) {
const bestaand = db.one(`SELECT scheduleId FROM InterestPeriod WHERE id = ?`, [id]);
if (!bestaand) fail('interest.unknownPeriod', null, 'Unknown interest period.');
assertPeriod(bestaand.scheduleId, p);
trackUpdate('InterestPeriod', id, () =>
db.run(`UPDATE InterestPeriod SET startsOn = ?, annualRate = ? WHERE id = ?`, [p.startsOn, p.annualRate, id]));
await db.persist();
}
export async function deletePeriod(id) {
trackDelete('InterestPeriod', id, () => db.run(`DELETE FROM InterestPeriod WHERE id = ?`, [id]));
await db.persist();
}
+29
View File
@@ -0,0 +1,29 @@
// Generated, do not edit. sha256:85ed9f94869ff2f4ce59b8e785f0225bd901edca3d1e5c1790edb2c8a7b0b6fe
import * as db from '../db.js';
import { toSearchName } from '../../shared/search-name.js';
import { assertName } from './_shared.js';
import { trackDelete } from './change-log.js';
import { fail } from '../../shared/app-error.js';
export function listLabels() { return db.all(`SELECT * FROM Label ORDER BY name`); }
export function labelUsage() {
return db.all(`SELECT Label.id AS labelId, COUNT(WalletTransaction.id) AS walletTransactions
FROM Label
LEFT JOIN WalletTransaction ON WalletTransaction.labelId = Label.id
GROUP BY Label.id
ORDER BY Label.id`);
}
export async function createLabel({ name }) {
const naam = assertName('Labelnaam', name);
db.run(`INSERT INTO Label (name, searchName) VALUES (?,?)`, [naam, toSearchName(naam)]);
const id = db.lastId(); await db.persist(); return id;
}
export async function deleteLabel(id) {
const used = db.one(`SELECT (SELECT COUNT(*) FROM WalletTransaction WHERE labelId=?1) AS n`, [id]).n;
if (used > 0) fail('labels.inUse', { n: used }, `The label is used by ${used} item(s).`);
trackDelete('Label', id, () => db.run(`DELETE FROM Label WHERE id=?`, [id]));
await db.persist();
}
+84
View File
@@ -0,0 +1,84 @@
// Generated, do not edit. sha256:1228a952eacb4d1d9d3f5acbdbe35af2071a6bcad5057d2b1aecfd3b41975c0e
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { CONFIG } from '../../shared/config.js';
import { parseLicense, verifySignature, coversVersion, appVersion, readLicense } from '../license.mjs';
import { installDir } from '../portfolios.mjs';
import { marketBuyerLicenses, marketServiceUrl, marketSale, marketPlaceOrder, marketOrderStatus } from '../market-service.mjs';
const HEX64 = /^[0-9a-f]{64}$/;
const buyerFile = () => join(installDir(), CONFIG.LICENSE.BUYER_FILE_NAME);
function rememberedBuyer() {
try {
const key = JSON.parse(readFileSync(buyerFile(), 'utf8'))?.buyer;
return HEX64.test(String(key)) ? key : null;
} catch {
return null;
}
}
function signerOf(signature) {
const m = /^Nostr ([A-Za-z0-9+/=]+)$/.exec(String(signature ?? ''));
if (!m) throw new Error('A sale call carries an "Authorization: Nostr <base64 event>" header.');
let pubkey;
try { pubkey = JSON.parse(Buffer.from(m[1], 'base64').toString('utf8'))?.pubkey; } catch { pubkey = null; }
if (!HEX64.test(String(pubkey))) throw new Error('The signed event carries no 64-hex pubkey.');
return pubkey;
}
export async function licenseShop() {
return { serviceUrl: await marketServiceUrl(), buyer: rememberedBuyer() };
}
export async function buyerLicenses(signature) {
const signer = signerOf(signature);
const list = await marketBuyerLicenses(signature);
writeFileSync(buyerFile(), `${JSON.stringify({ buyer: signer }, null, 2)}\n`);
const judge = judgeAgainstInstalled();
return list.map(judge);
}
function judgeAgainstInstalled() {
const version = appVersion();
const current = readLicense();
const now = current.state === 'valid' ? current.license : null;
return (file) => {
const text = JSON.stringify(file);
try {
const parsed = parseLicense(text);
const l = parsed.license;
const installed = !!now && ['buyer', 'level', 'issued', 'majorFrom', 'majorTo'].every(k => String(now[k]) === String(l[k]));
return {
file: text, level: l.level, issued: l.issued, majorFrom: l.majorFrom, majorTo: l.majorTo,
valid: verifySignature(parsed), covers: coversVersion(l, version), installed,
};
} catch {
return { file: text, valid: false, unreadable: true };
}
};
}
const ORDER_ID = /^[0-9a-f]{32}$/;
export async function saleInfo() {
return marketSale();
}
export async function placeOrder(signature, body = '{}') {
signerOf(signature);
if (typeof body !== 'string') throw new Error('The body of an order is the signed JSON text.');
return marketPlaceOrder(signature, body);
}
export async function orderStatus(orderId, readKey) {
if (!ORDER_ID.test(String(orderId))) throw new Error('An order id is 32 hex characters.');
if (!HEX64.test(String(readKey))) throw new Error('A read key is 64 hex characters.');
const { order, license } = await marketOrderStatus(orderId, readKey);
return { order, license: license ? judgeAgainstInstalled()(license) : null };
}
export async function forgetBuyer() {
rmSync(buyerFile(), { force: true });
return { buyer: null };
}
+24
View File
@@ -0,0 +1,24 @@
// Generated, do not edit. sha256:446976735f7157c5e0705294189119a2714fb95130e9b9374ed76421fa83e2f5
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { CONFIG } from '../../shared/config.js';
import { parseLicense, verifySignature, coversVersion, appVersion } from '../license.mjs';
import { installDir } from '../portfolios.mjs';
import { fail } from '../../shared/app-error.js';
export async function installLicense(text) {
let parsed;
try {
parsed = parseLicense(String(text ?? ''));
} catch (err) {
fail('license.installUnreadable', { reason: err.message }, `This is not a valid licence file: ${err.message}`);
}
if (!verifySignature(parsed)) {
fail('license.installBadSignature', null, 'The signature does not belong to this licence, or it was not issued for this app.');
}
writeFileSync(join(installDir(), CONFIG.LICENSE.FILE_NAME), `${JSON.stringify(parsed, null, 2)}\n`);
const version = appVersion();
return { license: parsed.license, version, covers: coversVersion(parsed.license, version) };
}
+24
View File
@@ -0,0 +1,24 @@
// Generated, do not edit. sha256:a807f05aaf3a8f8a3367166282f6e35755004e775d3ad2970e654848cde4b032
import { CONFIG } from '../../shared/config.js';
import { licenseState, appVersion, readLicense, coversVersion } from '../license.mjs';
export function licenseStatus() {
const state = licenseState();
const gratis = state.level !== 'full';
const bestand = readLicense();
const installed = bestand.state === 'valid' ? bestand.license : null;
return {
level: state.level,
why: state.why,
enforced: state.enforced,
version: appVersion(),
installed,
installedCovers: installed ? coversVersion(installed, appVersion()) : false,
buyer: state.license?.buyer ?? installed?.buyer ?? null,
issued: state.license?.issued ?? installed?.issued ?? null,
max: gratis ? { ...CONFIG.LICENSE.FREE.MAX } : null,
features: gratis ? { ...CONFIG.LICENSE.FREE.HAS } : null,
};
}
+142
View File
@@ -0,0 +1,142 @@
// Generated, do not edit. sha256:5f58c2b253a83045479ff5ac37e60e082b447d2cdda1c0769094b6a2ac261676
import * as db from '../db.js';
import { getBaseCurrency } from './portfolio.js';
import { marketConfigured, marketRegister, marketSeries, marketHistory, marketSpots, isRetryLater } from '../market-service.mjs';
import { CONFIG } from '../../shared/config.js';
const DAY_MS = 86_400_000;
const unitFactor = (entry) => (entry.unit == null ? 1 : CONFIG.METALS.UNITS[entry.unit] ?? null);
const perStorageUnit = (entry, price) => price / unitFactor(entry);
const usable = register => (register || []).filter(i => i && typeof i.key === 'string' && unitFactor(i) != null);
function registerEntry(asset, register) {
const key = `${asset.kind}:${String(asset.symbol || '').toUpperCase()}`;
return usable(register).find(i => i.key === key) || null;
}
function minuteEntry(asset, register) {
const hit = registerEntry(asset, register);
return hit && Array.isArray(hit.resolutions) && hit.resolutions.includes('minute') ? hit : null;
}
function unavailable(e) {
if (typeof e?.key === 'string' && e.key.startsWith('marketService.')) {
return { status: 'unavailable', retryLater: isRetryLater(e), key: e.key, params: e.params ?? null,
...(e.requestId ? { requestId: e.requestId } : {}) };
}
throw e;
}
const assetRow = (assetId) => {
const asset = db.one(`SELECT id, kind, symbol FROM Asset WHERE id = ?`, [Number(assetId)]);
if (!asset) throw new Error(`Unknown asset id ${assetId}.`);
return asset;
};
export async function intradaySeries(assetId, sinceMs) {
const asset = assetRow(assetId);
const now = Date.now();
const from = Number(sinceMs);
if (!Number.isFinite(from) || from >= now || now - from > CONFIG.MARKET_SERVICE.INTRADAY_MAX_DAYS * DAY_MS) {
throw new Error('sinceMs has to lie in the past, within the intraday limit.');
}
if (!(await marketConfigured())) return { status: 'off' };
try {
const entry = minuteEntry(asset, await marketRegister());
if (!entry) return { status: 'notCovered' };
const s = await marketSeries({ instrument: entry.key, quote: getBaseCurrency(), resolution: 'minute', from, to: now });
const named = src => s.sourceLabels?.[src] ?? src;
return { status: 'ok', instrument: entry.key, currency: s.currency, source: s.source, sourceLabel: s.sourceLabel ?? null,
points: s.points.map(([t, p, src]) => (src ? [t, perStorageUnit(entry, p), named(src)] : [t, perStorageUnit(entry, p)])) };
} catch (e) {
return unavailable(e);
}
}
export async function priceAtMoment(assetId, atIso) {
const asset = assetRow(assetId);
const at = Date.parse(String(atIso));
const now = Date.now();
if (!Number.isFinite(at) || at > now) throw new Error(`A moment in the past is needed: '${atIso}'.`);
if (!(await marketConfigured())) return { status: 'off' };
const window = CONFIG.MARKET_SERVICE.MOMENT_WINDOW_MS;
try {
const entry = minuteEntry(asset, await marketRegister());
if (!entry) return { status: 'notCovered' };
const s = await marketSeries({ instrument: entry.key, quote: getBaseCurrency(), resolution: 'minute',
from: at - window, to: Math.min(at + window, now) });
let best = null;
for (const [t, p, src] of s.points || []) {
if (!Number.isFinite(t) || !Number.isFinite(p) || Math.abs(t - at) > window) continue;
if (!best || Math.abs(t - at) < Math.abs(best[0] - at)) best = [t, p, src];
}
if (!best) return { status: 'missing' };
return { status: 'ok', price: perStorageUnit(entry, best[1]), currency: s.currency, at: best[0],
source: best[2] || s.source };
} catch (e) {
return unavailable(e);
}
}
export async function serviceDayHistory(assetId, sinceMs) {
const asset = assetRow(assetId);
const now = Date.now();
const from = Number(sinceMs);
if (!Number.isFinite(from) || from >= now || now - from > CONFIG.MARKET_SERVICE.HISTORY_MAX_DAYS * DAY_MS + DAY_MS) {
throw new Error('sinceMs has to lie in the past, within the history limit.');
}
if (!(await marketConfigured())) return { status: 'off' };
try {
const entry = registerEntry(asset, await marketRegister());
if (!entry || !Array.isArray(entry.kinds) || !entry.kinds.includes('history')) return { status: 'notCovered' };
const h = await marketHistory({ instrument: entry.key, quote: getBaseCurrency(), from, to: now });
const today = Math.floor(now / DAY_MS) * DAY_MS;
const named = src => h.sourceLabels?.[src] ?? src;
const days = h.points.filter(([t, , resolution]) => resolution === 'day' && t < today)
.map(([t, p, , src]) => (src ? [Math.floor(t / DAY_MS) * DAY_MS, perStorageUnit(entry, p), named(src)]
: [Math.floor(t / DAY_MS) * DAY_MS, perStorageUnit(entry, p)]));
return { status: 'ok', instrument: entry.key, currency: h.currency, source: h.source, days };
} catch (e) {
return unavailable(e);
}
}
export async function serviceCoverage() {
if (!(await marketConfigured())) return { status: 'off' };
try {
const register = await marketRegister();
const assetIds = db.all(`SELECT id, kind, symbol FROM Asset ORDER BY id`)
.filter(a => registerEntry(a, register))
.map(a => a.id);
return { status: 'ok', assetIds };
} catch (e) {
return unavailable(e);
}
}
export async function serviceSpot(assetId) {
const asset = assetRow(assetId);
if (!(await marketConfigured())) return { status: 'off' };
const today = new Date().toISOString().slice(0, 10);
const manual = db.one(
`SELECT 1 AS x FROM PriceHistory JOIN Provider ON Provider.id = PriceHistory.providerId
WHERE PriceHistory.assetId = ? AND Provider.key = 'manual' AND substr(PriceHistory.observedAt, 1, 10) = ?`,
[asset.id, today]);
if (manual) return { status: 'local' };
try {
const register = await marketRegister();
const entry = registerEntry(asset, register);
if (!entry) return { status: 'notCovered' };
const r = await marketSpots(usable(register).map(i => i.key), getBaseCurrency());
const hit = r.spot.find(s => s.instrument === entry.key);
if (hit) return { status: 'ok', ...hit, price: perStorageUnit(entry, hit.price) };
if (r.unreachable.includes(entry.key)) {
return { status: 'unavailable', retryLater: true, key: 'marketService.spotUnreachable', params: null };
}
return { status: 'missing' };
} catch (e) {
return unavailable(e);
}
}
+88
View File
@@ -0,0 +1,88 @@
// Generated, do not edit. sha256:8eb5f3330fcdfdbe9052fd4560a6ccd48f5f3aa7ef341ffd35df63e368fe3a71
import * as db from '../db.js';
import { toSearchName } from '../../shared/search-name.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum } from './_shared.js';
import { fail } from '../../shared/app-error.js';
import { trackDelete, trackUpdate } from './change-log.js';
export function hasMetalAssets() {
return db.one(`SELECT 1 AS x FROM Asset WHERE kind = 'metal' LIMIT 1`) != null;
}
export function listMetalAssets() {
return db.all(`SELECT * FROM Asset WHERE kind = 'metal' ORDER BY symbol`);
}
export function listMetalProducts() {
return db.all(`SELECT mp.*, a.symbol AS asset_symbol, a.name AS asset_name
FROM MetalProduct mp JOIN Asset a ON a.id = mp.assetId
ORDER BY a.symbol, mp.form, mp.fineGrams`);
}
export function listMetalProductsForAsset(assetId) {
return db.all(`SELECT * FROM MetalProduct WHERE assetId = ? ORDER BY form, fineGrams`, [assetId]);
}
function assertMetalProduct({ assetId, name, form, fineGrams }) {
assertEnum('form', ENUMS.METAL_FORM, form);
if (!Number.isInteger(assetId)) fail('metalProducts.assetRequired', null, 'Pick a valid metal asset.');
if (!String(name || '').trim()) fail('metalProducts.nameRequired', null, 'The name is required.');
if (!Number.isInteger(fineGrams) || fineGrams <= 0) fail('metalProducts.fineGramsPositive', null, 'The fine weight has to be greater than 0.');
const a = db.one(`SELECT kind FROM Asset WHERE id = ?`, [assetId]);
if (!a || a.kind !== 'metal') fail('metalProducts.notMetal', null, 'The chosen asset is not a metal.');
}
export async function createMetalProduct(p) {
assertMetalProduct(p);
db.run(`INSERT INTO MetalProduct (assetId, name, searchName, form, fineGrams) VALUES (?,?,?,?,?)`,
[p.assetId, p.name.trim(), toSearchName(p.name), p.form, p.fineGrams]);
const id = db.lastId();
await db.persist();
return id;
}
export async function updateMetalProduct(id, p) {
assertMetalProduct(p);
trackUpdate('MetalProduct', id, () =>
db.run(`UPDATE MetalProduct SET assetId = ?, name = ?, searchName = ?, form = ?, fineGrams = ? WHERE id = ?`,
[p.assetId, p.name.trim(), toSearchName(p.name), p.form, p.fineGrams, id]));
await db.persist();
}
export async function deleteMetalProduct(id) {
const used = db.one(`SELECT COUNT(*) AS n FROM Event WHERE productId = ?`, [id]).n;
if (used > 0) fail('metalProducts.inUse', { n: used }, `Product is used by ${used} event(s); remove those first.`);
trackDelete('MetalProduct', id, () => db.run(`DELETE FROM MetalProduct WHERE id = ?`, [id]));
await db.persist();
}
export function metalHoldings({ ownOnly = false } = {}) {
return db.all(`
SELECT mp.id, mp.name, mp.form, mp.fineGrams, mp.assetId,
a.symbol AS asset_symbol, a.name AS asset_name,
COALESCE(SUM(CASE
WHEN e.type = 'buy' AND e.toIn THEN e.productCount
WHEN e.type = 'sell' AND e.fromIn THEN -e.productCount
-- Only a transfer between two ACCOUNTS of which one is own: a transfer out of the
-- administration counts no pieces in either mode, so the two modes agree without a scope.
WHEN e.type = 'transfer' AND ?1 = 1 AND e.toAccountId IS NOT NULL AND e.fromAccountId IS NOT NULL THEN
(CASE WHEN e.toIn AND NOT e.fromIn THEN e.productCount ELSE 0 END)
- (CASE WHEN e.fromIn AND NOT e.toIn THEN e.productCount ELSE 0 END)
ELSE 0 END), 0) AS net_count,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn THEN e.productCount ELSE 0 END), 0) AS buy_count,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN 1 ELSE 0 END), 0) AS buy_events,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN COALESCE(e.fiatFee,0) ELSE 0 END), 0) AS buy_premium,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN e.amount ELSE 0 END), 0) AS buy_amount,
COALESCE(SUM(CASE WHEN e.type = 'buy' AND e.toIn AND e.priceOrigin IS NOT 'none' THEN e.amount * COALESCE(e.fiatPrice,0) ELSE 0 END), 0) AS buy_spot_raw,
COALESCE(SUM(CASE WHEN e.priceOrigin IS 'none' THEN
(CASE WHEN e.type = 'buy' AND e.toIn THEN e.amount WHEN e.type = 'sell' AND e.fromIn THEN -e.amount ELSE 0 END)
ELSE 0 END), 0) AS gift_amount
FROM MetalProduct mp
JOIN Asset a ON a.id = mp.assetId
LEFT JOIN (
SELECT Event.*,
(?1 = 0 OR toAccount.wealthScope IS NULL) AS toIn,
(?1 = 0 OR fromAccount.wealthScope IS NULL) AS fromIn
FROM Event
LEFT JOIN Account toAccount ON toAccount.id = Event.toAccountId
LEFT JOIN Account fromAccount ON fromAccount.id = Event.fromAccountId
WHERE Event.productCount IS NOT NULL
) e ON e.productId = mp.id
GROUP BY mp.id
HAVING net_count > 0
ORDER BY a.symbol, mp.form, mp.fineGrams`, [ownOnly ? 1 : 0]);
}
+98
View File
@@ -0,0 +1,98 @@
// Generated, do not edit. sha256:2e1c42ef1941376d221fab1a61b5e60c9be06530764fa9486b752263252805ed
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { nowIso, todayIso } from './_shared.js';
import { listPendingAiProposals } from './ai-proposals.js';
import { listPendingProposals } from './proposals.js';
import { dueCreditsAll } from './interest-credits.js';
import { listOpenValuationDiffs } from './valuations.js';
import { lightningBalanceDiffs } from './positions.js';
import { listDueReservations } from './reservations.js';
const dateToInt = d => Number(String(d).slice(0, 10).replace(/-/g, ''));
const NOTIFICATION_SOURCES = {
reconcile: { action: true, state() {
const p = listPendingProposals();
return { count: p.length, highWater: p.reduce((m, x) => Math.max(m, x.id), 0) };
} },
interest: { action: true, state() {
const due = dueCreditsAll();
return { count: due.length, highWater: due.reduce((m, d) => Math.max(m, dateToInt(d.periodEnd)), 0) };
} },
valuation: { action: true, state() {
const diffs = listOpenValuationDiffs();
return { count: diffs.length + lightningBalanceDiffs().length, highWater: diffs.reduce((m, v) => Math.max(m, v.id), 0) };
} },
schedule_ended: { action: false, state() {
const rows = db.all(`SELECT id, endsOn FROM InterestSchedule WHERE endsOn < ?`, [todayIso()]);
return { count: rows.length, highWater: rows.reduce((m, r) => Math.max(m, dateToInt(r.endsOn)), 0) };
} },
source_proposal: { action: true, state() {
const rows = db.all(`SELECT assetId, MAX(id) AS mid FROM PriceSource WHERE status = 'proposed' GROUP BY assetId`);
return { count: rows.length, highWater: rows.reduce((m, r) => Math.max(m, r.mid), 0) };
} },
ai_proposal: { action: true, state() {
const rows = listPendingAiProposals();
return { count: rows.length, highWater: rows.reduce((m, r) => Math.max(m, r.id), 0) };
} },
reservation: { action: true, state() {
const rows = listDueReservations();
return { count: rows.length, highWater: rows.reduce((m, r) => Math.max(m, dateToInt(r.occurredAt)), 0) };
} },
};
export async function refreshNotifications() {
let changed = false;
for (const kind of ENUMS.NOTIFICATION_KIND) {
const src = NOTIFICATION_SOURCES[kind];
if (!src) continue;
const { count, highWater } = src.state();
const active = db.one(`SELECT id, count, highWater FROM Notification WHERE kind = ? AND dismissedAt IS NULL ORDER BY id DESC LIMIT 1`, [kind]);
if (count === 0) {
if (src.action && active) { db.run(`DELETE FROM Notification WHERE id = ?`, [active.id]); changed = true; }
continue;
}
if (active) {
if (active.count !== count || active.highWater !== highWater) {
db.run(`UPDATE Notification SET count = ?, highWater = ? WHERE id = ?`, [count, highWater, active.id]);
changed = true;
}
continue;
}
const last = db.one(`SELECT highWater FROM Notification WHERE kind = ? ORDER BY id DESC LIMIT 1`, [kind]);
if (!last || highWater > last.highWater) {
db.run(`INSERT INTO Notification (kind, count, highWater, createdAt) VALUES (?,?,?,?)`, [kind, count, highWater, nowIso()]);
changed = true;
}
}
if (changed) await db.persist();
}
export function listActiveNotifications() {
return db.all(`SELECT id, kind, count, highWater, createdAt FROM Notification WHERE dismissedAt IS NULL ORDER BY id DESC`);
}
export async function currentNotifications() {
await refreshNotifications();
return listActiveNotifications();
}
export function notificationDetail(n) {
if (n.kind !== 'schedule_ended') return null;
const rows = db.all(`
SELECT s.accountId, ac.name AS account, po.symbol AS asset
FROM InterestSchedule s
JOIN Account ac ON ac.id = s.accountId
JOIN Asset po ON po.id = s.payoutAssetId
WHERE s.endsOn < ?
ORDER BY s.endsOn DESC, s.id DESC`, [todayIso()]);
if (!rows.length) return null;
return { accountId: rows[0].accountId, account: rows[0].account, asset: rows[0].asset, extra: rows.length - 1 };
}
export async function dismissNotification(id) {
db.run(`UPDATE Notification SET dismissedAt = ? WHERE id = ? AND dismissedAt IS NULL`, [nowIso(), id]);
await db.persist();
}
+83
View File
@@ -0,0 +1,83 @@
// Generated, do not edit. sha256:329990ff910102e84cb67048624a913db4948fa6ef673de3eb40c65c633864ba
import * as db from '../db.js';
import { ASSET_DEFINITIONS, DASHBOARD } from '../../shared/config.js';
import { toSearchName } from '../../shared/search-name.js';
import { getPref, setPref } from './preferences.js';
import { createAsset } from './assets.js';
import { createAccount } from './accounts.js';
import { activeProfile, addWidget, listWidgets } from './dashboard.js';
import { currentCount, capacityReachedFor } from './_limits.js';
export function setupStatus() {
const done = Number(getPref('app', 'setup_done')) === 1;
const empty = currentCount('assets') === 0;
const own = db.one(`SELECT (SELECT COUNT(*) FROM Account) + (SELECT COUNT(*) FROM Event)
+ (SELECT COUNT(*) FROM Valuation) AS n`).n;
return { needed: !done && empty, done, empty, persona: getPref('app', 'persona'),
currencyOpen: !done && empty && own === 0 };
}
function assertKnownAsset(symbol) {
if (!ASSET_DEFINITIONS[symbol]) {
throw new Error(`Unknown asset '${symbol}'. Allowed: ${Object.keys(ASSET_DEFINITIONS).join(', ')}.`);
}
}
async function ensureAsset(symbol) {
const def = ASSET_DEFINITIONS[symbol];
const existing = db.one(`SELECT id FROM Asset WHERE symbol = ?`, [def.symbol]);
if (existing) return existing.id;
return capacityReachedFor('assets') === null ? createAsset(def) : null;
}
async function ensureAccount({ name, kind }) {
const existing = db.one(`SELECT id FROM Account WHERE searchName = ?`, [toSearchName(name)]);
if (existing) return existing.id;
if (kind === 'bank' && capacityReachedFor('fiatAccounts') !== null) return null;
const own = kind === 'wallet' || kind === 'vault';
return createAccount({ name, kind, isOwnCustody: own, scanEnabled: false, taxCategory: null });
}
function placeInFreeSpace(rows, items) {
const cols = DASHBOARD.GRID_COLUMNS;
const heightOf = (type, h) => (h > 0 ? h : DASHBOARD.WIDGETS[type]?.defaultH || 1);
const bottom = new Array(cols).fill(0);
for (const r of rows) {
const end = r.gridY + heightOf(r.type, r.spanHeight);
for (let c = r.gridX; c < Math.min(cols, r.gridX + r.spanWidth); c++) bottom[c] = Math.max(bottom[c], end);
}
return items.map(({ prefer, ...w }) => {
const span = DASHBOARD.WIDGETS[w.type].defaultSpan;
let bestX = 0, bestY = Infinity;
for (let x = 0; x + span <= cols; x++) {
const y = Math.max(...bottom.slice(x, x + span));
if (y < bestY || (y === bestY && x === prefer)) { bestY = y; bestX = x; }
}
for (let c = bestX; c < bestX + span; c++) bottom[c] = bestY + heightOf(w.type, null);
return { ...w, gridX: bestX, gridY: bestY };
});
}
export async function completeSetup({ assets = [], persona = 'broad', accounts = [], widgets = [] } = {}) {
assets.forEach(assertKnownAsset);
await setPref('app', 'persona', persona);
const assetIds = [];
for (const s of assets) {
const id = await ensureAsset(s);
if (id != null) assetIds.push(id);
}
let accountCount = 0;
for (const a of accounts) if ((await ensureAccount(a)) != null) accountCount++;
const profileId = activeProfile().id;
const add = [];
if (widgets.includes('standing')) add.push({ type: 'standing', prefer: 4 });
if (widgets.includes('signal') && persona === 'bitcoin') add.push({ type: 'signal', prefer: 4 });
if (widgets.includes('spot')) assetIds.forEach(id => add.push({ type: 'spot', instanceKey: `spot-${id}`, assetId: id, prefer: 0, settings: { momentum: false } }));
let widgetCount = 0;
for (const w of placeInFreeSpace(listWidgets(profileId), add)) if (await addWidget(profileId, w)) widgetCount++;
await setPref('app', 'setup_done', 1);
return { persona, assetIds, accounts: accountCount, widgets: widgetCount };
}
+8
View File
@@ -0,0 +1,8 @@
// Generated, do not edit. sha256:57354eb3f01496daabc5a7208eceb7ac61ff77a12ab41b4a5effdfd3d90f3cc1
import * as db from '../db.js';
export function getBaseCurrency() {
const row = db.one(`SELECT baseCurrency FROM Portfolio WHERE id = 1`);
if (!row) throw new Error('Portfolio row missing: this database has no unit of account.');
return row.baseCurrency;
}
+143
View File
@@ -0,0 +1,143 @@
// Generated, do not edit. sha256:c2c77c87420dd87960f52c5550a95a9491166b15f248300e49e13aae650240fc
import { nowUtcIso } from '../../shared/time.js';
import { fail } from '../../shared/app-error.js';
import { assetPositions, eventAccountIdsByAsset, eventAssetIdsByAccount, eventsCountByAccount,
eventsNetByAccount, firstTaxEventDate } from './events.js';
import { anchorAt, anchorsBetween, createValuation, firstAnchorDate, latestAnchors, listValuations,
updateValuation, valuationDiff, valuationGap, valuationReconcilable } from './valuations.js';
import { accountOnchainBalance } from './wallet-addresses.js';
import { lightningBalances } from './wallet-sources.js';
import { onchainAsset } from './tx-links.js';
export function positionAt(accountId, assetId, atIso) {
const anker = anchorAt(accountId, assetId, atIso);
if (!anker) return eventsNetByAccount(accountId, assetId, atIso);
const naAnker = eventsNetByAccount(accountId, assetId, atIso) - eventsNetByAccount(accountId, assetId, anker.rateAt);
return anker.balance + naAnker;
}
export function positionsByAsset(atIso, { ownOnly = false } = {}) {
const peil = atIso ?? nowUtcIso();
const correctie = new Map();
for (const anker of latestAnchors(peil, { ownOnly })) {
const diff = valuationDiff(anker);
if (diff) correctie.set(anker.assetId, (correctie.get(anker.assetId) || 0) + diff);
}
const rijen = assetPositions(peil, { ownOnly });
if (correctie.size === 0) return rijen;
return rijen.map(r => (correctie.has(r.id) ? { ...r, net: r.net + correctie.get(r.id) } : r));
}
export function unexplainedChange(accountId, assetId, fromIso, untilIso) {
const stand = positionAt(accountId, assetId, untilIso) - positionAt(accountId, assetId, fromIso);
const geboekt = eventsNetByAccount(accountId, assetId, untilIso) - eventsNetByAccount(accountId, assetId, fromIso);
return stand - geboekt;
}
export function firstPositionDate() {
const gebeurtenis = firstTaxEventDate();
const anker = firstAnchorDate();
if (!gebeurtenis) return anker;
if (!anker) return gebeurtenis;
return gebeurtenis < anker ? gebeurtenis : anker;
}
export function unexplainedByPair(fromIso, untilIso) {
const uit = [];
for (const anker of latestAnchors(untilIso)) {
const units = unexplainedChange(anker.accountId, anker.assetId, fromIso, untilIso);
if (!units) continue;
let openingUnits = 0;
for (const v of anchorsBetween(anker.accountId, anker.assetId, fromIso, untilIso)) {
if (valuationReconcilable(v) && !v.gapAcceptedAt) continue;
openingUnits += valuationGap(v) || 0;
}
uit.push({
accountId: anker.accountId, assetId: anker.assetId, units, openingUnits,
hasLedger: eventsCountByAccount(anker.accountId, anker.assetId, untilIso) > 0,
});
}
return uit;
}
export function assetPositionsByAccount(assetId, atIso) {
const peil = atIso ?? nowUtcIso();
const accounts = new Set(eventAccountIdsByAsset(assetId));
for (const anker of latestAnchors(peil)) if (anker.assetId === assetId) accounts.add(anker.accountId);
const uit = [];
for (const accountId of accounts) {
const saldo = positionAt(accountId, assetId, peil);
if (saldo !== 0) uit.push({ accountId, saldo });
}
uit.sort((a, b) => b.saldo - a.saldo || a.accountId - b.accountId);
const totaal = positionsByAsset(peil).find(r => r.id === assetId);
const rest = (totaal ? totaal.net : 0) - uit.reduce((s, r) => s + r.saldo, 0);
if (rest !== 0) uit.push({ accountId: null, saldo: rest });
return uit;
}
export function accountPositions(accountId, atIso) {
const peil = atIso ?? nowUtcIso();
const assets = new Set(eventAssetIdsByAccount(accountId));
for (const anker of latestAnchors(peil)) if (anker.accountId === accountId) assets.add(anker.assetId);
const uit = [];
for (const assetId of [...assets].sort((a, b) => a - b)) {
const saldo = positionAt(accountId, assetId, peil);
if (saldo !== 0) uit.push({ assetId, saldo });
}
return uit;
}
export function accountHoldings(accountIds = [], atIso) {
const peil = atIso ?? nowUtcIso();
const uit = [];
for (const accountId of accountIds) {
for (const { assetId, saldo } of accountPositions(accountId, peil)) {
const anker = anchorAt(accountId, assetId, peil);
uit.push({ accountId, assetId, saldo,
anchor: anker ? { valuedOn: anker.valuedOn, rateAt: anker.rateAt, balance: anker.balance } : null });
}
}
return uit;
}
function measuredNodeBalance(row) {
return accountOnchainBalance(row.accountId) + (Number(row.sats) || 0);
}
export function lightningBalanceDiffs() {
const asset = onchainAsset();
if (!asset) return [];
const uit = [];
for (const row of lightningBalances()) {
const measured = measuredNodeBalance(row);
const ledger = positionAt(row.accountId, asset.id, row.scannedAt);
if (measured !== ledger) {
uit.push({ accountId: row.accountId, account_name: row.account_name, assetId: asset.id,
measured, ledger, gap: measured - ledger, scannedAt: row.scannedAt });
}
}
return uit;
}
export async function recordLightningBalance(accountId, { valuedOn, fiatRate } = {}) {
const row = lightningBalances().find(r => r.accountId === Number(accountId));
const asset = onchainAsset();
if (!row || !asset) throw new Error(`Account ${accountId} has no updated lightning source.`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(valuedOn))) throw new Error(`A balance needs its day: '${valuedOn}'.`);
if (!(Number(fiatRate) > 0)) throw new Error(`A balance needs a rate above zero: '${fiatRate}'.`);
const v = { assetId: asset.id, accountId: row.accountId, valuedOn, balance: measuredNodeBalance(row),
fiatRate: Number(fiatRate), rateAt: row.scannedAt, source: 'onchain' };
const zelfdeDag = listValuations().find(x => x.accountId === v.accountId && x.assetId === v.assetId && x.valuedOn === valuedOn);
if (zelfdeDag && zelfdeDag.source !== 'onchain') {
fail('valuations.lightningSameDay', { date: valuedOn },
`There is already a balance on ${valuedOn} for this account; it is not overwritten.`);
}
if (zelfdeDag) { await updateValuation(zelfdeDag.id, v); return zelfdeDag.id; }
return createValuation(v);
}
+74
View File
@@ -0,0 +1,74 @@
// Generated, do not edit. sha256:816fe4fc80fac9d9927e815f090b57c44b134b1e4cc673a3ba8e1be8e308842d
import * as db from '../db.js';
import { PREFERENCES } from '../../shared/config.js';
import { assertEnum } from './_shared.js';
import { getBaseCurrency } from './portfolio.js';
import { fail } from '../../shared/app-error.js';
import { installLocale, setInstallLocale } from '../installation.mjs';
function prefSpec(scope, key) {
const s = PREFERENCES[scope];
if (!s) return undefined;
return s[key] || s['*'];
}
export function getPref(scope, key, fallback = undefined) {
const spec = prefSpec(scope, key);
const row = db.one(`SELECT value FROM Preference WHERE scope = ? AND key = ?`, [scope, key]);
if (row) {
const v = JSON.parse(row.value);
if (spec && spec.type === 'enum' && Array.isArray(spec.allowed) && !spec.allowed.includes(v)) return spec.initial;
return v;
}
return spec ? spec.initial : fallback;
}
export async function setPref(scope, key, value) {
const spec = prefSpec(scope, key);
if (spec) {
if (spec.type === 'enum') assertEnum(`${scope}.${key}`, spec.allowed, value);
else if (spec.type === 'bool') {
value = Number(value);
if (value !== 0 && value !== 1) fail('settings.prefBadValue', { scope, key, waarde: value }, `Invalid value for ${scope}.${key}: ${value}.`);
}
else if (spec.type === 'int') {
value = Number(value);
if (!Number.isInteger(value)) fail('settings.prefWantsInteger', { scope, key, waarde: value }, `Invalid value for ${scope}.${key}: ${value} (an integer was expected).`);
if (spec.min != null && value < spec.min) fail('settings.prefBelowMin', { scope, key, min: spec.min }, `${scope}.${key} may not be smaller than ${spec.min}.`);
if (spec.max != null && value > spec.max) fail('settings.prefAboveMax', { scope, key, max: spec.max }, `${scope}.${key} may not be greater than ${spec.max}.`);
}
}
db.run(`INSERT INTO Preference (scope, key, value) VALUES (?,?,?)
ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value`,
[scope, key, JSON.stringify(value)]);
await db.persist();
}
export function getSettings() {
const out = {};
for (const [key] of Object.entries(PREFERENCES.app)) {
out[key] = getPref('app', key);
}
out.locale = installLocale();
out.version = db.schemaVersion();
out.baseCurrency = getBaseCurrency();
return out;
}
export async function updateSettings(patch) {
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) continue;
if (key === 'locale') { setInstallLocale(value); continue; }
if (!PREFERENCES.app[key]) fail('settings.unknownSetting', { key }, `Unknown setting: ${key}.`);
await setPref('app', key, value);
}
}
export function forgetPrefRow(scope, key) {
db.run(`DELETE FROM Preference WHERE scope = ? AND key = ?`, [scope, key]);
}
export async function deletePref(scope, key) {
forgetPrefRow(scope, key);
await db.persist();
}
+296
View File
@@ -0,0 +1,296 @@
// Generated, do not edit. sha256:7aabe3b0d2c7bf04e76cf1fb822d83923d1b0287d69de575f4f58ac8cbd62381
import * as db from '../db.js';
import { ENUMS, CONFIG } from '../../shared/config.js';
import { assertEnum, addDaysIso, daysBetween } from './_shared.js';
import { fail } from '../../shared/app-error.js';
import { isMetal, metalFactor } from '../../shared/units.js';
function providerIdVoor(key) {
const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [key]);
if (!p) throw new Error(`Unknown price source '${key}': there is no provider with that key.`);
return p.id;
}
const positief = v => (v != null && Number(v) > 0 ? v : null);
export async function upsertPriceHistory(assetId, points, source, resolution) {
assertEnum('resolution', ENUMS.PRICE_RESOLUTION, resolution);
const providerId = providerIdVoor(source);
const now = new Date().toISOString();
for (const punt of points) {
const prijs = positief(punt.price);
if (prijs == null) continue;
const iso = new Date(punt.time * 1000).toISOString();
const observedAt = resolution === 'day' ? `${iso.slice(0, 10)}T00:00:00Z` : iso;
const sourceName = typeof punt.sourceName === 'string' && punt.sourceName ? punt.sourceName : null;
db.run(`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, createdAt, updatedAt, sourceName)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(assetId, providerId, observedAt, resolution) DO UPDATE SET
fiatPrice = excluded.fiatPrice, updatedAt = excluded.updatedAt, sourceName = excluded.sourceName`,
[assetId, observedAt, resolution, prijs, providerId, now, now, sourceName]);
}
await db.persist();
}
export async function addManualPricePoint(payload = {}) {
const { assetId, observedAt, fiatPrice, resolution = 'day', unit = null } = payload;
assertEnum('resolution', ENUMS.PRICE_RESOLUTION, resolution);
const asset = db.one(`SELECT id, kind, unit, subunit, displayUnit FROM Asset WHERE id = ?`, [assetId]);
if (!asset) {
fail('priceHistory.manualUnknownAsset', {}, `Asset ${assetId} does not exist.`);
}
const ms = Date.parse(observedAt);
if (!Number.isFinite(ms)) {
fail('priceHistory.manualBadMoment', {}, `'${observedAt}' is not a valid moment.`);
}
if (ms > Date.now()) {
fail('priceHistory.manualFutureMoment', {}, `'${observedAt}' lies in the future.`);
}
const koers = Number(fiatPrice);
if (!Number.isFinite(koers) || koers <= 0) {
fail('priceHistory.manualBadPrice', {}, `A price must be a number above zero, got '${fiatPrice}'.`);
}
const eenheid = unit || asset.unit;
const naarBasis = p => (isMetal(asset) ? p / metalFactor(eenheid) : p);
await upsertPriceHistory(assetId, [{ time: Math.floor(ms / 1000), price: naarBasis(koers) }], 'manual', resolution);
const observed = resolution === 'day'
? `${new Date(ms).toISOString().slice(0, 10)}T00:00:00Z`
: new Date(ms).toISOString();
const rij = db.one(
`SELECT id FROM PriceHistory
WHERE assetId = ? AND providerId = ? AND observedAt = ? AND resolution = ?`,
[assetId, providerIdVoor('manual'), observed, resolution]);
return rij ? rij.id : null;
}
const DEKKING = `AND EXISTS (
SELECT 1 FROM Provider
WHERE Provider.id = PriceHistory.providerId
AND (Provider.isSystem = 1
OR EXISTS (SELECT 1 FROM ProviderCoverage
JOIN Asset ON Asset.id = PriceHistory.assetId
WHERE ProviderCoverage.providerId = Provider.id
AND ProviderCoverage.symbol = Asset.symbol)))`;
export function listPriceHistory(assetId, fromDate) {
return fromDate
? db.all(`SELECT * FROM PriceHistory WHERE assetId = ? AND observedAt >= ? ${DEKKING} ORDER BY observedAt DESC`, [assetId, `${fromDate}T00:00:00Z`])
: db.all(`SELECT * FROM PriceHistory WHERE assetId = ? ${DEKKING} ORDER BY observedAt DESC`, [assetId]);
}
export function latestPricePoint(assetId) {
return db.one(`SELECT * FROM PriceHistory WHERE assetId = ? ${DEKKING} ORDER BY observedAt DESC, id DESC LIMIT 1`,
[assetId]) || null;
}
export function latestSpotPoint(assetId, providerKey) {
const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [providerKey]);
if (!p) return null;
return db.one(`SELECT * FROM PriceHistory
WHERE assetId = ? AND providerId = ? AND resolution = 'timestamp' AND fiatPrice > 0
ORDER BY observedAt DESC, id DESC LIMIT 1`, [assetId, p.id]) || null;
}
export function priceSeriesExport() {
return db.all(
`SELECT Asset.kind AS kind, Asset.symbol AS symbol, Provider.key AS source, PriceHistory.resolution AS resolution,
PriceHistory.observedAt AS observedAt, PriceHistory.createdAt AS createdAt, PriceHistory.fiatPrice AS fiatPrice
FROM PriceHistory
JOIN Asset ON Asset.id = PriceHistory.assetId
JOIN Provider ON Provider.id = PriceHistory.providerId
WHERE Provider.key NOT IN ('manual', 'atreides_services', 'fixed') ${DEKKING}
ORDER BY Asset.kind, Asset.symbol, Provider.key, PriceHistory.resolution, PriceHistory.observedAt`);
}
export function priceHistoryCount(assetId) {
return db.one(`SELECT COUNT(*) AS n FROM PriceHistory WHERE assetId = ?`, [assetId]).n;
}
function laatsteSpotVanDag(assetId, dag, preferSource = null) {
const cands = db.all(`SELECT * FROM PriceHistory
WHERE assetId = ? AND resolution = 'timestamp' AND substr(observedAt, 1, 10) = ?
AND fiatPrice > 0 ${DEKKING}`, [assetId, dag]);
const r = pickPreferredRow(cands, preferSource, (a, b) => (a.observedAt < b.observedAt ? 1 : a.observedAt > b.observedAt ? -1 : 0));
return r ? { ...r, derivedFrom: 'spot' } : null;
}
export function getPricePoint(assetId, onDate, { derived = true } = {}) {
const dagrij = db.one(`SELECT * FROM PriceHistory
WHERE assetId = ? AND resolution = 'day' AND observedAt = ? ${DEKKING}
ORDER BY id DESC LIMIT 1`, [assetId, `${onDate}T00:00:00Z`]) || null;
if (dagrij || !derived) return dagrij;
return laatsteSpotVanDag(assetId, onDate);
}
function pickPreferredRow(cands, preferSource, cmp) {
if (!cands.length) return null;
const sorted = [...cands].sort(cmp);
if (preferSource) {
const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [preferSource]);
const pref = p ? sorted.filter(r => r.providerId === p.id) : [];
if (pref.length) return pref[0];
}
return sorted[0];
}
export function getPriceAt(assetId, tsUnix, preferSource = null) {
const iso = new Date(tsUnix * 1000).toISOString();
const dayKey = iso.slice(0, 10);
const maxSec = CONFIG.PRICE_HISTORY.NEAREST_MAX_SECONDS;
const tsCand = [
db.one(`SELECT * FROM PriceHistory WHERE assetId = ? AND resolution = 'timestamp' AND observedAt <= ? ${DEKKING} ORDER BY observedAt DESC LIMIT 1`, [assetId, iso]),
db.one(`SELECT * FROM PriceHistory WHERE assetId = ? AND resolution = 'timestamp' AND observedAt >= ? ${DEKKING} ORDER BY observedAt ASC LIMIT 1`, [assetId, iso]),
].filter(Boolean)
.map(r => ({ ...r, dist_seconds: Math.abs(Date.parse(r.observedAt) / 1000 - tsUnix) }))
.filter(r => r.dist_seconds <= maxSec);
const ts = pickPreferredRow(tsCand, preferSource, (a, b) => a.dist_seconds - b.dist_seconds);
if (ts) return ts;
const dayCand = db.all(`SELECT * FROM PriceHistory WHERE assetId = ? AND resolution = 'day' AND substr(observedAt, 1, 10) = ? ${DEKKING}`, [assetId, dayKey])
.map(r => ({ ...r, dist_seconds: null }));
return pickPreferredRow(dayCand, preferSource, (a, b) => b.id - a.id) || laatsteSpotVanDag(assetId, dayKey, preferSource);
}
export function listPriceGaps({ today = new Date().toISOString().slice(0, 10), head = true, tail = true,
limit = CONFIG.HYGIENE.MAX_ROWS_PER_FINDING } = {}) {
const grens = CONFIG.HYGIENE.PRICE_GAP_DAYS;
const naGisteren = today;
const assets = db.all(`SELECT Asset.id, Asset.symbol, substr(MIN(Event.occurredAt), 1, 10) AS start
FROM Asset
JOIN Event ON Event.assetId = Asset.id AND Event.type <> 'reservation'
WHERE Asset.kind <> 'fiat'
GROUP BY Asset.id, Asset.symbol
ORDER BY Asset.symbol`);
const rows = [];
for (const a of assets) {
if (a.start >= today) continue;
const dagen = db.all(`SELECT DISTINCT substr(observedAt, 1, 10) AS dag FROM PriceHistory
WHERE assetId = ? AND observedAt >= ? AND fiatPrice > 0 ${DEKKING}
ORDER BY dag`, [a.id, `${a.start}T00:00:00Z`]).map(r => r.dag);
const genegeerd = db.all(`SELECT fromDay, untilDay FROM IgnoredPriceGap WHERE assetId = ? ORDER BY fromDay`, [a.id]);
let vorige = addDaysIso(a.start, -1);
if (!head) {
const ervoor = db.one(`SELECT 1 AS x FROM PriceHistory WHERE assetId = ? AND observedAt < ? AND fiatPrice > 0 ${DEKKING}`,
[a.id, `${a.start}T00:00:00Z`]);
if (!ervoor) { if (!dagen.length) continue; vorige = dagen[0]; }
}
for (const dag of [...dagen.filter(d => d < naGisteren), ...(tail ? [naGisteren] : [])]) {
if (daysBetween(vorige, dag) > grens) {
for (const stuk of zonderGenegeerd(addDaysIso(vorige, 1), addDaysIso(dag, -1), genegeerd)) {
const lengte = daysBetween(stuk.from, stuk.until) + 1;
if (lengte > grens - 1) rows.push({ assetId: a.id, symbol: a.symbol, from: stuk.from, until: stuk.until, days: lengte });
}
}
vorige = dag;
}
}
return { total: rows.length, rows: limit == null ? rows : rows.slice(0, limit) };
}
function zonderGenegeerd(from, until, ranges) {
let stukken = [{ from, until }];
for (const r of ranges) {
stukken = stukken.flatMap(s => {
if (r.untilDay < s.from || r.fromDay > s.until) return [s];
const rest = [];
if (r.fromDay > s.from) rest.push({ from: s.from, until: addDaysIso(r.fromDay, -1) });
if (r.untilDay < s.until) rest.push({ from: addDaysIso(r.untilDay, 1), until: s.until });
return rest;
});
}
return stukken;
}
const DAG = /^\d{4}-\d{2}-\d{2}$/;
export async function ignorePriceGap({ assetId, from, until } = {}) {
if (!db.one(`SELECT id FROM Asset WHERE id = ?`, [assetId])) throw new Error(`Asset ${assetId} does not exist.`);
if (!DAG.test(String(from)) || !DAG.test(String(until)) || from > until) {
throw new Error(`'${from}' to '${until}' is not a range of days.`);
}
db.run(`INSERT INTO IgnoredPriceGap (assetId, fromDay, untilDay, createdAt) VALUES (?, ?, ?, ?)`,
[assetId, from, until, new Date().toISOString()]);
const id = db.lastId();
await db.persist();
return id;
}
export function listIgnoredPriceGaps() {
return db.all(`SELECT IgnoredPriceGap.*, Asset.symbol FROM IgnoredPriceGap
JOIN Asset ON Asset.id = IgnoredPriceGap.assetId
ORDER BY Asset.symbol, IgnoredPriceGap.fromDay`);
}
export async function unignorePriceGap(id) {
db.run(`DELETE FROM IgnoredPriceGap WHERE id = ?`, [id]);
await db.persist();
}
export async function recordNoPriceDays(assetId, source, days) {
if (!db.one(`SELECT id FROM Asset WHERE id = ?`, [assetId])) throw new Error(`Asset ${assetId} does not exist.`);
const providerId = providerIdVoor(source);
if (!Array.isArray(days) || days.some(d => !DAG.test(String(d)))) throw new Error(`Not a list of days: ${JSON.stringify(days)}.`);
const now = new Date().toISOString();
for (const day of days) {
db.run(`INSERT OR IGNORE INTO PriceAbsence (assetId, providerId, day, createdAt) VALUES (?, ?, ?, ?)`,
[assetId, providerId, day, now]);
}
await db.persist();
}
export function listPriceAbsences() {
const rijen = db.all(`SELECT PriceAbsence.assetId, PriceAbsence.providerId, Provider.name AS providerName, PriceAbsence.day
FROM PriceAbsence JOIN Provider ON Provider.id = PriceAbsence.providerId
ORDER BY PriceAbsence.assetId, Provider.name, PriceAbsence.day`);
const uit = [];
for (const r of rijen) {
const vorige = uit[uit.length - 1];
if (vorige && vorige.assetId === r.assetId && vorige.providerId === r.providerId && addDaysIso(vorige.untilDay, 1) === r.day) {
vorige.untilDay = r.day;
vorige.days++;
} else {
uit.push({ assetId: r.assetId, providerId: r.providerId, providerName: r.providerName, fromDay: r.day, untilDay: r.day, days: 1 });
}
}
return uit;
}
export async function deletePriceAbsences({ assetId, providerId, from, until } = {}) {
if (!Number.isInteger(assetId) || !Number.isInteger(providerId)) throw new Error(`An asset id and a provider id are required.`);
if (!DAG.test(String(from)) || !DAG.test(String(until)) || from > until) throw new Error(`'${from}' to '${until}' is not a range of days.`);
db.run(`DELETE FROM PriceAbsence WHERE assetId = ? AND providerId = ? AND day BETWEEN ? AND ?`, [assetId, providerId, from, until]);
await db.persist();
}
export function listNoPriceDays(assetId, fromDay = '0000-01-01') {
return db.all(`SELECT providerId, day FROM PriceAbsence WHERE assetId = ? AND day >= ? ORDER BY day, providerId`, [assetId, fromDay]);
}
export function listAllPriceHistory(filter = {}) {
const where = ['1=1'], args = [];
if (filter.assetId) { where.push('ph.assetId = ?'); args.push(filter.assetId); }
if (filter.fromDate) { where.push('ph.observedAt >= ?'); args.push(`${filter.fromDate}T00:00:00Z`); }
return db.all(`SELECT ph.*, a.symbol AS asset_symbol,
p.key AS provider_key, p.name AS provider_name
FROM PriceHistory ph
JOIN Asset a ON a.id = ph.assetId
JOIN Provider p ON p.id = ph.providerId
WHERE ${where.join(' AND ')}
ORDER BY ph.observedAt DESC, ph.id DESC`, args);
}
export async function recordCurrentSpot(assetId, source, spot) {
if (!spot || !Number.isFinite(spot.price)) return;
const today = new Date().toISOString().slice(0, 10);
const existing = db.one(
`SELECT id FROM PriceHistory
WHERE assetId = ? AND providerId = ? AND resolution = 'timestamp' AND substr(observedAt, 1, 10) = ?
ORDER BY observedAt DESC, id DESC LIMIT 1`,
[assetId, providerIdVoor(source), today]);
if (existing) {
db.run(`UPDATE PriceHistory SET observedAt = ?, fiatPrice = ?, updatedAt = ? WHERE id = ?`,
[new Date().toISOString(), spot.price, new Date().toISOString(), existing.id]);
await db.persist();
return;
}
await upsertPriceHistory(assetId, [{ time: Math.floor(Date.now() / 1000), price: spot.price }], source, 'timestamp');
}
+99
View File
@@ -0,0 +1,99 @@
// Generated, do not edit. sha256:db61aab12929b00d557fdd117e7a5efb89fd1b2bb3eb23c37364a1d19f493bbc
import * as db from '../db.js';
import { SOURCE_CAPABILITIES, SOURCE_ENDPOINTS } from '../../shared/config.js';
import { assertEnum } from './_shared.js';
import { hasFeature } from '../license.mjs';
import { fail } from '../../shared/app-error.js';
import { refreshNotifications } from './notifications.js';
export function priceSourceChain(assetId, capability) {
return db.all(
`SELECT Provider.key AS key
FROM PriceSource
JOIN Provider ON Provider.id = PriceSource.providerId
WHERE PriceSource.assetId = ? AND PriceSource.capability = ? AND PriceSource.rank IS NOT NULL
ORDER BY PriceSource.rank`,
[assetId, capability]
).map(r => r.key);
}
export function ownPriceSourceChain(assetId, capability) {
return db.all(
`SELECT Provider.key AS key
FROM PriceSource
JOIN Provider ON Provider.id = PriceSource.providerId
WHERE PriceSource.assetId = ? AND PriceSource.capability = ? AND PriceSource.rank IS NOT NULL
AND PriceSource.status = 'manual'
ORDER BY PriceSource.rank`,
[assetId, capability]
).map(r => r.key);
}
export function listPriceSource() {
return db.all(`SELECT assetId, capability, providerId, rank, status FROM PriceSource`);
}
function assertMatrixAllowed() {
if (hasFeature('sourceMatrix')) return;
fail('integrations.matrixPaid', null,
'The source matrix belongs to the paid version. The app keeps using the standard sources.');
}
function assertAnswers(providerId, capability) {
const eindpunten = SOURCE_ENDPOINTS[capability];
if (!db.one(`SELECT 1 AS x FROM ProviderEndpoint WHERE providerId = ? AND capability IN (${eindpunten.map(() => '?').join(',')})`,
[providerId, ...eindpunten]))
throw new Error('This provider does not cover this capability.');
}
export async function setPriceSourceRank(assetId, capability, providerId) {
assertMatrixAllowed();
assertEnum('capability', SOURCE_CAPABILITIES, capability);
assertAnswers(providerId, capability);
const used = db.all(`SELECT DISTINCT PriceSource.rank FROM PriceSource
JOIN Asset ON Asset.id = PriceSource.assetId
JOIN ProviderCoverage ON ProviderCoverage.providerId = PriceSource.providerId
AND ProviderCoverage.symbol = Asset.symbol
WHERE PriceSource.assetId = ? AND PriceSource.capability = ?
AND PriceSource.rank IS NOT NULL AND PriceSource.status IN ('confirmed','manual')`,
[assetId, capability]).map(r => r.rank);
let next = 1;
while (used.includes(next)) next += 1;
db.run(`DELETE FROM PriceSource WHERE assetId = ? AND capability = ? AND (providerId = ? OR rank = ?)`,
[assetId, capability, providerId, next]);
db.run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status, createdAt) VALUES (?,?,?,?,'manual',?)`,
[assetId, capability, providerId, next, new Date().toISOString()]);
await db.persist();
return next;
}
export async function placePriceSourceFirst(assetId, capability, providerId) {
assertMatrixAllowed();
assertEnum('capability', SOURCE_CAPABILITIES, capability);
assertAnswers(providerId, capability);
const rest = db.all(`SELECT providerId, status, createdAt FROM PriceSource
WHERE assetId = ? AND capability = ? AND rank IS NOT NULL AND providerId <> ?
ORDER BY rank`, [assetId, capability, providerId]);
db.run(`DELETE FROM PriceSource WHERE assetId = ? AND capability = ? AND (rank IS NOT NULL OR providerId = ?)`,
[assetId, capability, providerId]);
db.run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status, createdAt) VALUES (?,?,?,1,'manual',?)`,
[assetId, capability, providerId, new Date().toISOString()]);
rest.forEach((r, i) => db.run(
`INSERT INTO PriceSource (assetId, capability, providerId, rank, status, createdAt) VALUES (?,?,?,?,?,?)`,
[assetId, capability, r.providerId, i + 2, r.status, r.createdAt]));
await db.persist();
}
export async function clearPriceSourceCell(assetId, capability, providerId) {
assertMatrixAllowed();
db.run(`DELETE FROM PriceSource WHERE assetId = ? AND capability = ? AND providerId = ?`,
[assetId, capability, providerId]);
await db.persist();
}
export async function confirmPriceSourceCell(assetId, capability, providerId) {
assertMatrixAllowed();
db.run(`UPDATE PriceSource SET status = 'confirmed'
WHERE assetId = ? AND capability = ? AND providerId = ? AND status = 'proposed'`,
[assetId, capability, providerId]);
await db.persist();
await refreshNotifications();
}
+224
View File
@@ -0,0 +1,224 @@
// Generated, do not edit. sha256:b635cddd04e7b73f066847eaf0cbf8dfca213ceaa0e4c52ee3c3820f5df5cd3a
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
import { fail } from '../../shared/app-error.js';
import { createEvent, deleteEvent } from './events.js';
import { linkProposalTxSoft } from './tx-links.js';
import { memoNote } from '../../shared/zap.js';
const MEMO_OF = `COALESCE(w.memo, (SELECT twin.memo FROM WalletTransaction twin
WHERE w.layer = 'lightning' AND twin.layer = 'lightning' AND twin.accountId = w.accountId
AND twin.txid = w.txid AND twin.id <> w.id AND twin.memo IS NOT NULL
ORDER BY twin.id LIMIT 1))`;
const NODE_ACCOUNT_OF = `(SELECT node.accountId FROM WalletSource onchain
JOIN WalletSource node ON node.connectionId = onchain.connectionId AND node.kind = 'lnd'
WHERE onchain.accountId = w.accountId AND onchain.kind IN ('xpub','address')
AND node.accountId <> w.accountId
ORDER BY node.id LIMIT 1)`;
function bookingNote(p, prefix = 'reconcile tx', zonderHash = 'reconcile') {
const memo = p.walletTransactionId
? db.one(`SELECT ${MEMO_OF} AS memo FROM WalletTransaction w WHERE w.id = ?`, [p.walletTransactionId])?.memo : null;
const basis = p.transactionHash ? `${prefix} ${p.transactionHash}` : zonderHash;
const context = memoNote(memo, p.direction);
return context ? `${context} · ${basis}` : basis;
}
export function listPendingProposals() {
return db.all(`
SELECT p.*, ac.name AS account_name, a.symbol AS asset_symbol, a.name AS asset_name,
a.decimals AS asset_decimals, a.unit AS asset_unit, a.subunit AS asset_subunit,
w.blockTime AS tx_block_time, w.layer AS tx_layer, w.kind AS tx_kind, w.feeSats AS tx_fee_sats,
${MEMO_OF} AS tx_memo,
CASE WHEN w.kind = 'channel' THEN (SELECT name FROM Account WHERE id = ${NODE_ACCOUNT_OF}) END AS tx_node_account,
-- The NWC wallet the payment went through (user, 19-10-2026): its row with the same hash.
(SELECT COALESCE(s.name, '') FROM WalletTransaction twin
JOIN WalletSource s ON s.id = twin.sourceId
WHERE w.layer = 'lightning' AND twin.layer = 'lightning' AND s.kind = 'nwc'
AND twin.accountId = w.accountId AND twin.txid = w.txid AND twin.id <> w.id
ORDER BY twin.id LIMIT 1) AS via_wallet
FROM ReconcileProposal p
JOIN Account ac ON ac.id = p.accountId
JOIN Asset a ON a.id = p.assetId
LEFT JOIN WalletTransaction w ON w.id = p.walletTransactionId
WHERE p.status = 'pending'
AND NOT EXISTS (SELECT 1 FROM WalletTransactionEventLink l WHERE l.walletTransactionId = p.walletTransactionId)
ORDER BY COALESCE(w.blockTime, p.detectedAt) DESC, p.id DESC`);
}
export async function syncTxProposals(accountId, assetId) {
db.run(`DELETE FROM ReconcileProposal WHERE status='pending' AND accountId=?1 AND assetId=?2`, [accountId, assetId]);
db.run(`INSERT INTO ReconcileProposal
(accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, resolvedEventId, detectedAt)
SELECT w.accountId, ?2, w.id, w.txid, w.valueSats, w.direction,
CASE WHEN ac.isOwnCustody THEN 'transfer' ELSE 'trade' END, 'pending', NULL, ?3
FROM WalletTransaction w
JOIN Account ac ON ac.id = w.accountId
WHERE w.accountId = ?1
-- LIGHTNING ONLY FROM THE NODE: an NWC wallet is a grouping
-- on top of LND, so its payment is booked once, from LND, and the NWC row takes the event
-- over by payment hash. On an account WITHOUT a LND source the NWC wallet counts on its own,
-- as in its total on Tracking. The same rule stands in hygiene.js.
AND (w.layer = 'onchain'
OR EXISTS (SELECT 1 FROM WalletSource s
WHERE s.id = w.sourceId
AND (s.kind = 'lnd'
OR (s.kind = 'nwc' AND NOT EXISTS (
SELECT 1 FROM WalletSource node
WHERE node.accountId = w.accountId AND node.kind = 'lnd')))))
AND NOT EXISTS (SELECT 1 FROM WalletTransactionEventLink l WHERE l.walletTransactionId = w.id)
AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p
WHERE p.status = 'ignored'
AND ( p.walletTransactionId = w.id
-- The amount match only for an ignored proposal WITHOUT a movement
-- (a gap proposal). One that hangs on a movement means "this one",
-- and matching it on amount suppressed every other movement of the
-- same size: one ignored 21-sat zap hid every 21-sat zap.
OR (p.walletTransactionId IS NULL
AND p.accountId = w.accountId AND p.assetId = ?2
AND p.direction = w.direction AND p.amount = w.valueSats) ))`,
[accountId, assetId, nowIso()]);
const n = db.one(`SELECT changes() AS n`).n;
await db.persist();
return n;
}
function getProposal(id) {
const p = db.one(`SELECT * FROM ReconcileProposal WHERE id = ?`, [id]);
if (!p) fail('home.recUnknownProposal', { id }, `Proposal ${id} no longer exists.`);
if (p.status !== 'pending') {
fail(`home.recHandled_${p.status}`, { id }, `Proposal ${id} has already been handled (${p.status}).`);
}
return p;
}
export async function resolveProposalAsTrade(id, { fiatPrice, fiatFee, occurredAt, priceOrigin }) {
const p = getProposal(id);
if (fiatPrice === undefined || fiatPrice === null) throw new Error('fiatPrice is required for a trade.');
const wtx = p.walletTransactionId ? db.one(`SELECT blockTime, feeSats FROM WalletTransaction WHERE id=?`, [p.walletTransactionId]) : null;
const blockTime = wtx && wtx.blockTime ? wtx.blockTime : null;
const type = p.direction === 'in' ? 'buy' : 'sell';
const txFee = type === 'sell' && wtx && wtx.feeSats ? wtx.feeSats : null;
const eventId = await createEvent({
assetId: p.assetId, type, occurredAt: occurredAt ?? blockTime ?? p.detectedAt,
amount: p.amount, fiatPrice, fiatFee: fiatFee ?? null, unitFee: txFee,
priceOrigin: priceOrigin ?? null,
fromAccountId: type === 'sell' ? p.accountId : null,
toAccountId: type === 'buy' ? p.accountId : null,
source: 'reconcile', note: bookingNote(p),
});
if (p.walletTransactionId) linkProposalTxSoft(p.walletTransactionId, eventId);
db.run(`UPDATE ReconcileProposal SET status='accepted_trade', resolvedEventId=? WHERE id=?`, [eventId, id]);
await db.persist();
return eventId;
}
export async function resolveProposalAsTransfer(id, { other_account_id, unitFee, occurredAt }) {
const p = getProposal(id);
if (!other_account_id) {
fail('home.recNoCounter', null, 'A counter-account is required for a transfer.');
}
const wtx = p.walletTransactionId ? db.one(`SELECT blockTime, feeSats FROM WalletTransaction WHERE id=?`, [p.walletTransactionId]) : null;
const blockTime = wtx && wtx.blockTime ? wtx.blockTime : null;
const txFee = p.direction === 'out' && wtx && wtx.feeSats != null ? wtx.feeSats : null;
const fromAccountId = p.direction === 'out' ? p.accountId : other_account_id;
const toAccountId = p.direction === 'out' ? other_account_id : p.accountId;
const eventId = await createEvent({
assetId: p.assetId, type: 'transfer', occurredAt: occurredAt ?? blockTime ?? p.detectedAt,
amount: p.amount, fiatPrice: null, fiatFee: null, unitFee: unitFee ?? txFee,
fromAccountId, toAccountId, source: 'reconcile',
note: bookingNote(p),
});
if (p.walletTransactionId) linkProposalTxSoft(p.walletTransactionId, eventId);
db.run(`UPDATE ReconcileProposal SET status='accepted_transfer', resolvedEventId=? WHERE id=?`, [eventId, id]);
await db.persist();
return eventId;
}
export async function resolveProposalAsInternal(id) {
const p = getProposal(id);
const wtx = p.walletTransactionId
? db.one(`SELECT w.blockTime, w.feeSats, w.kind, ${NODE_ACCOUNT_OF} AS nodeAccountId
FROM WalletTransaction w WHERE w.id = ?`, [p.walletTransactionId]) : null;
if (!wtx || wtx.kind == null) {
fail('home.recNotInternal', { id }, `Proposal ${id} is not a movement inside the account.`);
}
const ander = wtx.kind === 'channel' && wtx.nodeAccountId ? wtx.nodeAccountId : p.accountId;
const eventId = await createEvent({
assetId: p.assetId, type: 'transfer', occurredAt: wtx.blockTime ?? p.detectedAt,
amount: p.amount, fiatPrice: null, fiatFee: null,
unitFee: p.direction === 'out' && wtx.feeSats ? wtx.feeSats : null,
fromAccountId: p.direction === 'out' ? p.accountId : ander,
toAccountId: p.direction === 'out' ? ander : p.accountId, source: 'reconcile',
note: bookingNote(p, `reconcile ${wtx.kind}`, `reconcile ${wtx.kind}`),
});
linkProposalTxSoft(p.walletTransactionId, eventId);
db.run(`UPDATE ReconcileProposal SET status='accepted_transfer', resolvedEventId=? WHERE id=?`, [eventId, id]);
await db.persist();
return eventId;
}
export async function resolveProposalAsInterest(id) {
const p = getProposal(id);
const wtx = p.walletTransactionId
? db.one(`SELECT blockTime, layer, txid FROM WalletTransaction WHERE id = ?`, [p.walletTransactionId]) : null;
if (!wtx || wtx.layer !== 'lightning' || !String(wtx.txid).startsWith('fwd:') || p.direction !== 'in') {
fail('home.recNotForward', { id }, `Proposal ${id} is not a lightning forward.`);
}
const eventId = await createEvent({
assetId: p.assetId, type: 'interest', occurredAt: wtx.blockTime ?? p.detectedAt,
amount: p.amount, fiatPrice: null, fiatFee: null, unitFee: null,
fromAccountId: null, toAccountId: p.accountId, source: 'reconcile', note: bookingNote(p, 'reconcile forward', 'reconcile forward'),
});
linkProposalTxSoft(p.walletTransactionId, eventId);
db.run(`UPDATE ReconcileProposal SET status='accepted_interest', resolvedEventId=? WHERE id=?`, [eventId, id]);
await db.persist();
return eventId;
}
export async function ignoreProposal(id) {
getProposal(id);
db.run(`UPDATE ReconcileProposal SET status='ignored' WHERE id=?`, [id]);
await db.persist();
}
export async function deleteProposal(id) {
db.run(`DELETE FROM ReconcileProposal WHERE id=?`, [id]);
await db.persist();
}
export async function ignoreAllPendingProposals() {
db.run(`UPDATE ReconcileProposal SET status='ignored' WHERE status='pending'`);
const n = db.one(`SELECT changes() AS n`).n;
if (n > 0) await db.persist();
return n;
}
export async function deleteAllPendingProposals() {
db.run(`DELETE FROM ReconcileProposal WHERE status = 'pending'`);
const n = db.one(`SELECT changes() AS n`).n;
if (n > 0) await db.persist();
return n;
}
export async function revertProposal(id) {
const p = db.one(`SELECT * FROM ReconcileProposal WHERE id = ?`, [id]);
if (!p) fail('home.recUnknownProposal', { id }, `Proposal ${id} no longer exists.`);
if (p.status === 'accepted_trade' || p.status === 'accepted_transfer' || p.status === 'accepted_interest') {
if (p.resolvedEventId) await deleteEvent(p.resolvedEventId);
db.run(`UPDATE ReconcileProposal SET status = 'pending', resolvedEventId = NULL WHERE id = ?`, [id]);
} else if (p.status === 'ignored') {
if (p.walletTransactionId) db.run(`DELETE FROM ReconcileProposal WHERE id = ?`, [id]);
else db.run(`UPDATE ReconcileProposal SET status = 'pending' WHERE id = ?`, [id]);
} else if (p.status === 'auto_linked') {
fail('home.recAutoLinked', { id }, `Proposal ${id} is auto-linked; unlink it through the match popover.`);
} else {
fail('home.recStillOpen', { id }, `Proposal ${id} (${p.status}) cannot be reverted.`);
}
await db.persist();
}
export function listHandledProposals(limit = 50) {
return db.all(`
SELECT p.*, ac.name AS account_name, a.symbol AS asset_symbol, a.name AS asset_name,
a.decimals AS asset_decimals, a.unit AS asset_unit, a.subunit AS asset_subunit,
e.type AS event_type, w.blockTime AS tx_block_time
FROM ReconcileProposal p
JOIN Account ac ON ac.id = p.accountId
JOIN Asset a ON a.id = p.assetId
LEFT JOIN Event e ON e.id = p.resolvedEventId
LEFT JOIN WalletTransaction w ON w.id = p.walletTransactionId
WHERE p.status != 'pending'
ORDER BY COALESCE(w.blockTime, p.detectedAt) DESC, p.id DESC
LIMIT ?`, [limit]);
}
+280
View File
@@ -0,0 +1,280 @@
// Generated, do not edit. sha256:1db816dc6af267a04206f23a388019cbe76689adb83e89843b65ed1a797537a8
import * as db from '../db.js';
import { toSearchName } from '../../shared/search-name.js';
import { AUTH_KINDS, CAPABILITIES, ENUMS, HTTP_METHODS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
import { emitChange } from './_events.js';
import { fail } from '../../shared/app-error.js';
export function emitProvidersChanged() {
emitChange('providers-changed');
}
export function listProviders() {
return db.all(`SELECT * FROM Provider ORDER BY isSeed DESC, name`);
}
export function getProvider(key) {
const p = db.one(`SELECT * FROM Provider WHERE key = ?`, [key]);
if (!p) return null;
return {
...p,
coverage: db.all(`SELECT symbol FROM ProviderCoverage WHERE providerId = ? ORDER BY symbol`, [p.id]).map(r => r.symbol),
hmac: db.one(`SELECT * FROM ProviderAuthenticationHmac WHERE providerId = ?`, [p.id]) || null,
fixedRate: db.one(`SELECT fiatPrice FROM ProviderFixedRate WHERE providerId = ?`, [p.id]) || null,
callsPerMonth: db.one(`SELECT callsPerMonth FROM ProviderCallLimit WHERE providerId = ?`, [p.id])?.callsPerMonth ?? null,
endpoints: db.all(`SELECT id, capability, method, pathTemplate, queryTemplate, quoteCurrency, minIntervalSeconds FROM ProviderEndpoint WHERE providerId = ?`, [p.id]),
symbols: db.all(`SELECT id, symbol, externalId, quoteUnit, isCatchAll FROM ProviderInputMap WHERE providerId = ?
ORDER BY isCatchAll, symbol`, [p.id]),
outputs: db.all(`SELECT id, capability, internalField, jsonPath, transform FROM ProviderOutputMap WHERE providerId = ?`, [p.id]),
};
}
export function providerCapabilityMap() {
const m = new Map();
for (const r of db.all(`SELECT providerId, capability FROM ProviderEndpoint`)) {
if (!m.has(r.providerId)) m.set(r.providerId, new Set());
m.get(r.providerId).add(r.capability);
}
return m;
}
function assertAuthKind(kind) {
if (!Object.keys(AUTH_KINDS).includes(kind))
throw new Error(`Invalid authenticationKind: '${kind}'. Allowed: ${Object.keys(AUTH_KINDS).join(', ')}.`);
}
function bit01(v, name) {
const n = Number(v);
if (n !== 0 && n !== 1) throw new Error(`Invalid value for ${name}: ${v} (0 or 1 expected).`);
return n;
}
function toCoverageList(v) {
const arr = Array.isArray(v) ? v : String(v ?? '').split(',');
return [...new Set(arr.map(s => String(s).trim()).filter(Boolean))];
}
function schrijfCoverage(providerId, symbolen) {
db.run(`DELETE FROM ProviderCoverage WHERE providerId = ?`, [providerId]);
for (const symbool of symbolen) {
db.run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?,?)`, [providerId, symbool]);
}
}
function keyFromName(name) {
const basis = String(name).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'integratie';
let key = basis;
for (let n = 2; db.one(`SELECT 1 AS x FROM Provider WHERE key = ?`, [key]); n++) key = `${basis}_${n}`;
return key;
}
export async function createProvider(cfg) {
if (!String(cfg.name || '').trim()) fail('integrations.nameRequired', null, 'A provider name is required.');
assertAuthKind(cfg.authenticationKind);
const key = String(cfg.key || '').trim() || keyFromName(cfg.name);
if (db.one(`SELECT 1 AS x FROM Provider WHERE key = ?`, [key])) {
fail('integrations.keyInUse', { key }, `Provider '${key}' already exists.`);
}
db.run(`INSERT INTO Provider
(key, name, searchName, baseUrl, authenticationHeaderName, authenticationParameterName,
authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,0,0,?)`,
[key, cfg.name.trim(), toSearchName(cfg.name), cfg.baseUrl ?? '',
cfg.authenticationHeaderName ?? null, cfg.authenticationParameterName ?? null,
cfg.authenticationKind, bit01(cfg.authenticationRequired, 'authenticationRequired'),
bit01(cfg.isActive ?? 1, 'isActive'), nowIso()]);
const pid = db.lastId();
schrijfCoverage(pid, toCoverageList(cfg.coverage));
await db.persist();
emitProvidersChanged();
return pid;
}
export async function updateProvider(key, cfg) {
const p = db.one(`SELECT * FROM Provider WHERE key = ?`, [key]);
if (!p) fail('integrations.unknownProvider', { key }, `Provider '${key}' does not exist.`);
assertAuthKind(cfg.authenticationKind);
db.run(`UPDATE Provider SET name=?, searchName=?, baseUrl=?, authenticationHeaderName=?,
authenticationParameterName=?, authenticationKind=?, authenticationRequired=?, isActive=? WHERE id=?`,
[cfg.name, toSearchName(cfg.name), cfg.baseUrl ?? '',
cfg.authenticationHeaderName ?? null, cfg.authenticationParameterName ?? null,
cfg.authenticationKind, bit01(cfg.authenticationRequired, 'authenticationRequired'),
bit01(cfg.isActive ?? p.isActive, 'isActive'), p.id]);
if (cfg.coverage !== undefined) schrijfCoverage(p.id, toCoverageList(cfg.coverage));
await db.persist();
emitProvidersChanged();
}
export async function setProviderActive(key, isActive) {
const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [key]);
if (!p) fail('integrations.unknownProvider', { key }, `Provider '${key}' does not exist.`);
db.run(`UPDATE Provider SET isActive = ? WHERE id = ?`, [bit01(isActive, 'isActive'), p.id]);
await db.persist();
emitProvidersChanged();
}
export async function setProviderCallLimit(key, callsPerMonth) {
const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [key]);
if (!p) fail('integrations.unknownProvider', { key }, `Provider '${key}' does not exist.`);
if (callsPerMonth == null || String(callsPerMonth).trim() === '') {
db.run(`DELETE FROM ProviderCallLimit WHERE providerId = ?`, [p.id]);
} else {
const n = Number(callsPerMonth);
if (!Number.isInteger(n) || n <= 0) {
fail('integrations.callLimitInvalid', { waarde: callsPerMonth }, `A monthly limit must be a whole number above zero, got '${callsPerMonth}'.`);
}
db.run(`INSERT INTO ProviderCallLimit (providerId, callsPerMonth, createdAt) VALUES (?, ?, ?)
ON CONFLICT(providerId) DO UPDATE SET callsPerMonth = excluded.callsPerMonth`,
[p.id, n, new Date().toISOString()]);
}
await db.persist();
}
export async function deleteProvider(key) {
const p = db.one(`SELECT id, isSystem FROM Provider WHERE key = ?`, [key]);
if (!p) fail('integrations.unknownProvider', { key }, `Provider '${key}' does not exist.`);
if (p.isSystem) throw new Error(`Provider '${key}' is a system row and cannot be deleted.`);
const used = db.one(`SELECT COUNT(*) AS n FROM PriceSource WHERE providerId = ?`, [p.id]).n;
if (used > 0) {
fail('integrations.providerInMatrix', { key, n: used },
`Provider '${key}' is still in the source matrix (${used} choice(s)).`);
}
const prijsrijen = db.one(`SELECT COUNT(*) AS n FROM PriceHistory WHERE providerId = ?`, [p.id]).n;
if (prijsrijen > 0) {
fail('integrations.providerHasPrices', { key, n: prijsrijen },
`Provider '${key}' still has ${prijsrijen} price row(s) in the history.`);
}
db.run(`DELETE FROM Provider WHERE id = ?`, [p.id]);
await db.persist();
emitProvidersChanged();
}
function assertProviderId(pid) {
if (!db.one(`SELECT 1 AS x FROM Provider WHERE id = ?`, [pid]))
throw new Error(`Provider ${pid} does not exist.`);
}
function reqStr(value, name) {
const s = String(value ?? '').trim();
if (!s) fail('integrations.fieldRequired', { veld: name }, `${name} is required.`);
return s;
}
function quoteCurrency(value) {
const s = String(value ?? '').trim().toUpperCase();
if (!s) return null;
if (!/^[A-Z]{3}$/.test(s)) fail('integrations.quoteCurrencyInvalid', { waarde: value }, `Quote currency '${value}' is not a three-letter code.`);
return s;
}
function minInterval(value) {
if (value == null || String(value).trim() === '') return null;
const n = Number(value);
if (!Number.isInteger(n) || n <= 0) fail('integrations.minIntervalInvalid', { waarde: value }, `Minimum interval '${value}' is not a whole number of seconds above zero.`);
return n;
}
export async function createProviderEndpoint(providerId, e) {
assertProviderId(providerId);
assertEnum('capability', CAPABILITIES, e.capability);
assertEnum('method', HTTP_METHODS, e.method); reqStr(e.pathTemplate, 'pathTemplate');
db.run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate, quoteCurrency, minIntervalSeconds, createdAt)
VALUES (?,?,?,?,?,?,?,?)`,
[providerId, e.capability, e.method, e.pathTemplate, e.queryTemplate ?? null, quoteCurrency(e.quoteCurrency),
minInterval(e.minIntervalSeconds), new Date().toISOString()]);
const id = db.lastId(); await db.persist(); return id;
}
export async function updateProviderEndpoint(id, e) {
assertEnum('capability', CAPABILITIES, e.capability);
assertEnum('method', HTTP_METHODS, e.method); reqStr(e.pathTemplate, 'pathTemplate');
if (!db.one(`SELECT 1 AS x FROM ProviderEndpoint WHERE id = ?`, [id])) fail('integrations.unknownEndpoint', { id }, `Endpoint ${id} does not exist.`);
db.run(`UPDATE ProviderEndpoint SET capability=?, method=?, pathTemplate=?, queryTemplate=? WHERE id=?`,
[e.capability, e.method, e.pathTemplate, e.queryTemplate ?? null, id]);
if (Object.prototype.hasOwnProperty.call(e, 'quoteCurrency')) {
db.run(`UPDATE ProviderEndpoint SET quoteCurrency = ? WHERE id = ?`, [quoteCurrency(e.quoteCurrency), id]);
}
if (Object.prototype.hasOwnProperty.call(e, 'minIntervalSeconds')) {
db.run(`UPDATE ProviderEndpoint SET minIntervalSeconds = ? WHERE id = ?`, [minInterval(e.minIntervalSeconds), id]);
}
await db.persist();
}
export async function deleteProviderEndpoint(id) {
db.run(`DELETE FROM ProviderEndpoint WHERE id = ?`, [id]);
await db.persist();
}
function quoteUnit(v) {
if (v == null || String(v).trim() === '') return null;
const u = String(v).trim();
if (!ENUMS.QUOTE_UNIT.includes(u)) fail('integrations.quoteUnitInvalid', { waarde: u }, `Unknown quote unit '${u}'.`);
return u;
}
function assertEenCatchAll(providerId, behalveId = null) {
const r = db.one(`SELECT id FROM ProviderInputMap WHERE providerId = ? AND isCatchAll = 1 AND id IS NOT ?`, [providerId, behalveId]);
if (r) fail('integrations.catchAllExists', null, 'This provider already has a catch-all row.');
}
export async function createProviderSymbol(providerId, s) {
assertProviderId(providerId);
const catchAll = !!s.isCatchAll;
if (catchAll) assertEenCatchAll(providerId); else reqStr(s.symbol, 'symbol');
reqStr(s.externalId, 'externalId');
db.run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, isCatchAll, createdAt) VALUES (?,?,?,?,?,?)`,
[providerId, catchAll ? null : s.symbol, s.externalId, quoteUnit(s.quoteUnit), catchAll ? 1 : 0, nowIso()]);
const id = db.lastId(); await db.persist(); return id;
}
export async function updateProviderSymbol(id, s) {
const rij = db.one(`SELECT providerId, isCatchAll FROM ProviderInputMap WHERE id = ?`, [id]);
if (!rij) fail('integrations.unknownSymbolMap', { id }, `Symbol map ${id} does not exist.`);
const catchAll = Object.hasOwn(s, 'isCatchAll') ? !!s.isCatchAll : !!rij.isCatchAll;
if (catchAll) assertEenCatchAll(rij.providerId, id); else reqStr(s.symbol, 'symbol');
reqStr(s.externalId, 'externalId');
db.run(`UPDATE ProviderInputMap SET symbol=?, externalId=?, isCatchAll=? WHERE id=?`,
[catchAll ? null : s.symbol, s.externalId, catchAll ? 1 : 0, id]);
if (Object.hasOwn(s, 'quoteUnit')) db.run(`UPDATE ProviderInputMap SET quoteUnit=? WHERE id=?`, [quoteUnit(s.quoteUnit), id]);
await db.persist();
}
export async function deleteProviderSymbol(id) {
db.run(`DELETE FROM ProviderInputMap WHERE id = ?`, [id]);
await db.persist();
}
export async function createProviderOutput(providerId, o) {
assertProviderId(providerId);
assertEnum('capability', CAPABILITIES, o.capability);
reqStr(o.internalField, 'internalField'); reqStr(o.jsonPath, 'jsonPath'); reqStr(o.transform, 'transform');
db.run(`INSERT INTO ProviderOutputMap (providerId, capability, internalField, jsonPath, transform) VALUES (?,?,?,?,?)`,
[providerId, o.capability, o.internalField, o.jsonPath, o.transform]);
const id = db.lastId(); await db.persist(); return id;
}
export async function updateProviderOutput(id, o) {
assertEnum('capability', CAPABILITIES, o.capability);
reqStr(o.internalField, 'internalField'); reqStr(o.jsonPath, 'jsonPath'); reqStr(o.transform, 'transform');
if (!db.one(`SELECT 1 AS x FROM ProviderOutputMap WHERE id = ?`, [id])) fail('integrations.unknownOutputMap', { id }, `Output map ${id} does not exist.`);
db.run(`UPDATE ProviderOutputMap SET capability=?, internalField=?, jsonPath=?, transform=? WHERE id=?`,
[o.capability, o.internalField, o.jsonPath, o.transform, id]);
await db.persist();
}
export async function deleteProviderOutput(id) {
db.run(`DELETE FROM ProviderOutputMap WHERE id = ?`, [id]);
await db.persist();
}
export async function setProviderHmac(providerId, h) {
assertProviderId(providerId);
reqStr(h.messageParts, 'messageParts'); reqStr(h.hashAlgorithm, 'hashAlgorithm'); reqStr(h.encoding, 'encoding');
reqStr(h.keyHeader, 'keyHeader'); reqStr(h.signatureHeader, 'signatureHeader'); reqStr(h.timestampHeader, 'timestampHeader');
db.run(`INSERT INTO ProviderAuthenticationHmac
(providerId, messageParts, hashAlgorithm, encoding, keyHeader, signatureHeader, timestampHeader, windowHeader, windowMs)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(providerId) DO UPDATE SET
messageParts=excluded.messageParts, hashAlgorithm=excluded.hashAlgorithm, encoding=excluded.encoding,
keyHeader=excluded.keyHeader, signatureHeader=excluded.signatureHeader,
timestampHeader=excluded.timestampHeader, windowHeader=excluded.windowHeader, windowMs=excluded.windowMs`,
[providerId, h.messageParts, h.hashAlgorithm, h.encoding, h.keyHeader, h.signatureHeader, h.timestampHeader, h.windowHeader ?? null, h.windowMs ?? null]);
await db.persist();
}
export async function clearProviderHmac(providerId) {
db.run(`DELETE FROM ProviderAuthenticationHmac WHERE providerId = ?`, [providerId]);
await db.persist();
}
+74
View File
@@ -0,0 +1,74 @@
// Generated, do not edit. sha256:f8255fd8148a61f4a49cc0a84d161f8b1bc531a1a315349c9aef9328871b9d6f
import * as db from '../db.js';
import { fail } from '../../shared/app-error.js';
import { assertTimestamp, nowIso } from './_shared.js';
import { createEvent, deleteEvent, EVENT_JOIN } from './events.js';
import { trackUpdate } from './change-log.js';
import { emitChange } from './_events.js';
export function listReservations() {
return db.all(`${EVENT_JOIN} WHERE e.type = 'reservation' ORDER BY e.occurredAt ASC, e.id ASC`);
}
export function listReservationsForAccount(accountId) {
return db.all(`${EVENT_JOIN} WHERE e.type = 'reservation' AND (e.fromAccountId = ?1 OR e.toAccountId = ?1)
ORDER BY e.occurredAt ASC, e.id ASC`, [accountId]);
}
export function listDueReservations(untilIso = nowIso()) {
return db.all(`${EVENT_JOIN} WHERE e.type = 'reservation' AND e.occurredAt <= ?
ORDER BY e.occurredAt ASC, e.id ASC`, [untilIso]);
}
function rowOf(r) {
if (!r.accountId) throw new Error('A reservation needs an account.');
if (r.direction !== 'in' && r.direction !== 'out') throw new Error(`Unknown direction '${r.direction}'; use 'in' or 'out'.`);
return {
assetId: r.assetId, type: 'reservation', occurredAt: r.occurredAt, amount: r.amount,
fromAccountId: r.direction === 'out' ? r.accountId : null,
toAccountId: r.direction === 'in' ? r.accountId : null,
source: 'manual', note: r.note ?? null,
};
}
function openReservation(id) {
const row = db.one(`SELECT * FROM Event WHERE id = ?`, [id]);
if (!row || row.type !== 'reservation') fail('reservations.notOpen', null, 'This reservation is no longer open.');
return row;
}
export async function createReservation(r) {
return createEvent(rowOf(r));
}
export async function updateReservation(id, r) {
openReservation(id);
const e = rowOf(r);
trackUpdate('Event', id, () => db.run(
`UPDATE Event SET assetId = ?, occurredAt = ?, amount = ?, fromAccountId = ?, toAccountId = ?, note = ?
WHERE id = ? AND type = 'reservation'`,
[e.assetId, e.occurredAt, e.amount, e.fromAccountId, e.toAccountId, e.note, id]));
await db.persist();
emitChange('ledger-changed');
}
export async function deleteReservation(id) {
openReservation(id);
await deleteEvent(id);
}
export async function bookReservation(id, { occurredAt, amount, otherAccountId = null, note }) {
const row = openReservation(id);
assertTimestamp('occurredAt', occurredAt);
if (!Number.isInteger(amount) || amount <= 0) throw new Error(`A booking needs an amount > 0 in the smallest unit (got ${amount}).`);
const out = row.fromAccountId != null;
const own = out ? row.fromAccountId : row.toAccountId;
if (otherAccountId != null && otherAccountId === own) throw new Error('The other side of a booking is another account.');
const [from, to] = out ? [own, otherAccountId] : [otherAccountId, own];
trackUpdate('Event', id, () => db.run(
`UPDATE Event SET type = 'transfer', occurredAt = ?, amount = ?, fromAccountId = ?, toAccountId = ?, note = ?
WHERE id = ? AND type = 'reservation'`,
[occurredAt, amount, from, to, note === undefined ? row.note : (note || null), id]));
await db.persist();
emitChange('ledger-changed');
}
+24
View File
@@ -0,0 +1,24 @@
// Generated, do not edit. sha256:aba9f6d2144409570ffba1d05e453cb0ffd966afb4f0fa1cd5960bcb7a63805f
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
export async function createScanLog(s) {
assertEnum('status', ENUMS.SCAN_STATUS, s.status);
db.run(`INSERT INTO ScanLog (accountId, scannedAt, status, addressesFound, balanceFound, proposalsCreated, rawData)
VALUES (?,?,?,?,?,?,?)`,
[s.accountId, s.scannedAt ?? nowIso(), s.status,
s.addressesFound ?? null, s.balanceFound ?? null, s.proposalsCreated ?? null,
s.rawData != null ? JSON.stringify(s.rawData) : null]);
const id = db.lastId();
await db.persist();
return id;
}
export function latestScanLog(accountId) {
return db.one(`SELECT * FROM ScanLog WHERE accountId = ? ORDER BY scannedAt DESC, id DESC LIMIT 1`, [accountId]);
}
export function listScanLogs(accountId) {
return db.all(`SELECT * FROM ScanLog WHERE accountId = ? ORDER BY scannedAt DESC, id DESC`, [accountId]);
}
+93
View File
@@ -0,0 +1,93 @@
// Generated, do not edit. sha256:8017fede3aae4d6eb73088d3b73b43810357285ec3b8acd0f4f14e52251b7e6a
import * as db from '../db.js';
function tel(sql, params) {
const r = db.one(sql, params);
return r ? Number(r.n) : 0;
}
function idLijst(sql, kolom, params) {
return db.all(sql, params).map(r => r[kolom]);
}
export function accountStructure(accountId) {
const row = db.one(`SELECT * FROM Account WHERE id = ?`, [accountId]);
if (!row) return null;
const beideKanten = [accountId, accountId];
return {
row,
counts: {
events: tel(`SELECT COUNT(*) AS n FROM Event
WHERE fromAccountId = ? OR toAccountId = ?`, beideKanten),
assets: tel(`SELECT COUNT(DISTINCT assetId) AS n FROM Event
WHERE fromAccountId = ? OR toAccountId = ?`, beideKanten),
walletSources: tel(`SELECT COUNT(*) AS n FROM WalletSource WHERE accountId = ?`, [accountId]),
addresses: tel(`SELECT COUNT(*) AS n FROM WalletAddress WHERE accountId = ?`, [accountId]),
walletTransactions: tel(`SELECT COUNT(*) AS n FROM WalletTransaction WHERE accountId = ?`, [accountId]),
valuations: tel(`SELECT COUNT(*) AS n FROM Valuation WHERE accountId = ?`, [accountId]),
},
refs: {
assetIds: idLijst(`SELECT DISTINCT assetId FROM Event
WHERE fromAccountId = ? OR toAccountId = ?
ORDER BY assetId`, 'assetId', beideKanten),
},
};
}
export function assetStructure(assetId) {
const row = db.one(`SELECT * FROM Asset WHERE id = ?`, [assetId]);
if (!row) return null;
const REKENINGEN = `SELECT fromAccountId AS a FROM Event WHERE assetId = ? AND fromAccountId IS NOT NULL
UNION
SELECT toAccountId AS a FROM Event WHERE assetId = ? AND toAccountId IS NOT NULL`;
const beideKolommen = [assetId, assetId];
return {
row,
counts: {
events: tel(`SELECT COUNT(*) AS n FROM Event WHERE assetId = ?`, [assetId]),
accounts: tel(`SELECT COUNT(*) AS n FROM (${REKENINGEN})`, beideKolommen),
pricePoints: tel(`SELECT COUNT(*) AS n FROM PriceHistory WHERE assetId = ?`, [assetId]),
valuations: tel(`SELECT COUNT(*) AS n FROM Valuation WHERE assetId = ?`, [assetId]),
products: tel(`SELECT COUNT(*) AS n FROM MetalProduct WHERE assetId = ?`, [assetId]),
coverage: tel(`SELECT COUNT(*) AS n FROM ProviderCoverage
WHERE symbol = (SELECT symbol FROM Asset WHERE id = ?)`, [assetId]),
},
refs: {
accountIds: idLijst(`SELECT a FROM (${REKENINGEN}) ORDER BY a`, 'a', beideKolommen),
},
};
}
export function eventRefs({ accountId = null, assetId = null } = {}, limiet = 50) {
if (accountId == null && assetId == null) {
throw new Error('eventRefs needs an account or an asset; without a filter this is a bulk read.');
}
const n = Number.isInteger(limiet) && limiet > 0 ? limiet : 50;
const waar = [];
const params = [];
if (accountId != null) {
waar.push('(fromAccountId = ? OR toAccountId = ?)');
params.push(accountId, accountId);
}
if (assetId != null) {
waar.push('assetId = ?');
params.push(assetId);
}
return db.all(
`SELECT id, type, substr(occurredAt, 1, 7) AS month FROM Event
WHERE ${waar.join(' AND ')} ORDER BY occurredAt DESC, id DESC LIMIT ?`,
[...params, n],
).map(r => ({ id: r.id, type: r.type, month: r.month }));
}
export function labelStructure(labelId) {
const row = db.one(`SELECT * FROM Label WHERE id = ?`, [labelId]);
if (!row) return null;
return {
row,
counts: {
walletTransactions: tel(`SELECT COUNT(*) AS n FROM WalletTransaction WHERE labelId = ?`, [labelId]),
},
refs: {},
};
}
+35
View File
@@ -0,0 +1,35 @@
// Generated, do not edit. sha256:e7b97bfa74b04b9522e509df409b4fa237f564c7b1fc8cdbe64c7eb87d1cb4dd
import { CONFIG } from '../../shared/config.js';
import { marketServiceUrl, marketSupportMessages, marketSupportSend, marketSupportAttachment } from '../market-service.mjs';
const S = CONFIG.LICENSE.SUPPORT;
const ATTACHMENT_ID = /^[0-9a-f]{32}$/;
function assertSignature(signature) {
if (!/^Nostr [A-Za-z0-9+/=]+$/.test(String(signature ?? ''))) throw new Error('A support call carries an "Authorization: Nostr <base64 event>" header.');
}
export async function supportInfo() {
return {
serviceUrl: await marketServiceUrl(),
maxText: S.MAX_TEXT, maxAttachmentBytes: S.MAX_ATTACHMENT_BYTES, attachmentTypes: [...S.ATTACHMENT_TYPES],
nostr: { pubkey: S.RECIPIENT, name: S.RECIPIENT_NAME },
};
}
export async function supportMessages(signature) {
assertSignature(signature);
return marketSupportMessages(signature);
}
export async function sendSupport(signature, body) {
assertSignature(signature);
if (typeof body !== 'string') throw new Error('The body of a support message is the signed JSON text.');
return marketSupportSend(signature, body);
}
export async function supportAttachment(signature, id) {
assertSignature(signature);
if (!ATTACHMENT_ID.test(String(id))) throw new Error('An attachment id is 32 hex characters.');
return marketSupportAttachment(signature, id);
}
+43
View File
@@ -0,0 +1,43 @@
// Generated, do not edit. sha256:051b73ea4c90cd1dfd7110d9d56e91cb527361b375f6b09d6509f8db81b2835e
import * as db from '../db.js';
import { CONFIG } from '../../shared/config.js';
import { deleteValuation } from './valuations.js';
import { deleteProposal } from './proposals.js';
import { unlinkTxEvent } from './tx-links.js';
export function tableColumns(name) {
if (!CONFIG.DB_TABLES.includes(name)) throw new Error(`Unknown table: ${name}`);
return db.all(`PRAGMA table_info(${name})`).map(c => c.name);
}
export function listTableRaw(name) {
if (!CONFIG.DB_TABLES.includes(name)) throw new Error(`Unknown table: ${name}`);
return db.all(`SELECT * FROM ${name} ORDER BY rowid DESC`);
}
const TABLE_ROW_DELETERS = {
valuations: (id) => deleteValuation(id),
reconcile_proposals: (id) => deleteProposal(id),
tx_event_link: (id) => {
const r = db.one(`SELECT walletTransactionId, eventId FROM WalletTransactionEventLink WHERE id=?`, [id]);
if (!r) throw new Error(`Link ${id} does not exist.`);
return unlinkTxEvent(r.walletTransactionId, r.eventId);
},
};
export async function deleteTableRow(name, id) {
if (!CONFIG.DB_DELETABLE_TABLES.includes(name)) throw new Error(`Table ${name} is not deletable.`);
const custom = TABLE_ROW_DELETERS[name];
if (custom) { await custom(id); return; }
db.run(`DELETE FROM ${name} WHERE id=?`, [id]);
await db.persist();
}
export async function clearTable(name) {
if (!CONFIG.DB_DELETABLE_TABLES.includes(name)) throw new Error(`Table ${name} cannot be cleared.`);
const custom = TABLE_ROW_DELETERS[name];
if (custom) {
for (const r of db.all(`SELECT id FROM ${name}`)) await custom(r.id);
return;
}
db.run(`DELETE FROM ${name}`);
await db.persist();
}
+157
View File
@@ -0,0 +1,157 @@
// Generated, do not edit. sha256:a4f989c4fcb487fdb8c1ab80d536f8fa5a1a10a2f0357c22596ab060e7b38694
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
export async function syncAddressLinks() {
const method = 'exact';
assertEnum('method', ENUMS.LINK_METHOD, method);
db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt)
SELECT w.id, e.id, ?1, ?2
FROM WalletTransaction w
JOIN Event e ON e.externalAddress = w.address
WHERE w.direction = 'in' AND w.address IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p
WHERE p.walletTransactionId = w.id AND p.resolvedEventId = e.id AND p.status = 'ignored')`,
[method, nowIso()]);
let n = db.one(`SELECT changes() AS n`).n;
db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt)
SELECT w.id, e.id, ?1, ?2
FROM WalletTransaction w
JOIN Event e ON e.source = 'lightning'
AND (e.fromAccountId = w.accountId OR e.toAccountId = w.accountId)
AND (e.importReference = 'ln:' || w.txid
OR (w.txid LIKE 'fwd:%' AND e.importReference = 'lnfwd:' || w.blockTime))
WHERE w.layer = 'lightning'
AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p
WHERE p.walletTransactionId = w.id AND p.resolvedEventId = e.id AND p.status = 'ignored')`,
[method, nowIso()]);
n += db.one(`SELECT changes() AS n`).n;
db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt)
SELECT w.id, l.eventId, ?1, ?2
FROM WalletTransaction w
JOIN WalletTransaction twin ON twin.accountId = w.accountId AND twin.txid = w.txid
AND twin.layer = 'lightning' AND twin.id <> w.id
JOIN WalletTransactionEventLink l ON l.walletTransactionId = twin.id
WHERE w.layer = 'lightning'
AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p
WHERE p.walletTransactionId = w.id AND p.resolvedEventId = l.eventId AND p.status = 'ignored')`,
[method, nowIso()]);
n += db.one(`SELECT changes() AS n`).n;
const asset = onchainAsset();
if (asset) {
db.run(`INSERT OR IGNORE INTO ReconcileProposal
(accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, resolvedEventId, detectedAt)
SELECT w.accountId, ?1, w.id, w.txid, w.valueSats, w.direction,
CASE WHEN e.type = 'transfer' THEN 'transfer' ELSE 'trade' END,
'auto_linked', e.id, ?2
FROM WalletTransactionEventLink l
JOIN WalletTransaction w ON w.id = l.walletTransactionId
JOIN Event e ON e.id = l.eventId
WHERE l.method = 'exact'
AND NOT EXISTS (SELECT 1 FROM ReconcileProposal p
WHERE p.walletTransactionId = w.id AND p.resolvedEventId = e.id)`,
[asset.id, nowIso()]);
}
const changed = n + db.one(`SELECT changes() AS n`).n;
if (changed > 0) await db.persist();
return n;
}
export function eventLinkMap() {
const m = new Map();
for (const r of db.all(`SELECT eventId, method FROM WalletTransactionEventLink`)) m.set(r.eventId, r.method);
return m;
}
export function walletTxEventMap() {
const m = new Map();
const rows = db.all(`SELECT l.walletTransactionId, l.method, e.id AS eventId, e.type, e.amount, e.unitFee,
e.fromAccountId, e.toAccountId, fa.name AS from_name, ta.name AS to_name
FROM WalletTransactionEventLink l
JOIN Event e ON e.id = l.eventId
LEFT JOIN Account fa ON fa.id = e.fromAccountId
LEFT JOIN Account ta ON ta.id = e.toAccountId`);
for (const r of rows) {
if (!m.has(r.walletTransactionId)) m.set(r.walletTransactionId, []);
m.get(r.walletTransactionId).push(r);
}
return m;
}
export async function linkTxEvent(walletTxId, eventId, method) {
assertEnum('method', ENUMS.LINK_METHOD, method);
db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt)
VALUES (?,?,?,?)`, [walletTxId, eventId, method, nowIso()]);
await db.persist();
}
export function linkProposalTxSoft(walletTxId, eventId) {
const method = 'soft';
assertEnum('method', ENUMS.LINK_METHOD, method);
db.run(`INSERT OR IGNORE INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt)
VALUES (?,?,?,?)`, [walletTxId, eventId, method, nowIso()]);
}
export async function unlinkTxEvent(walletTxId, eventId) {
db.run(`DELETE FROM WalletTransactionEventLink WHERE walletTransactionId = ? AND eventId = ?`, [walletTxId, eventId]);
db.run(`UPDATE ReconcileProposal SET status = 'ignored'
WHERE walletTransactionId = ? AND resolvedEventId = ? AND status = 'auto_linked'`, [walletTxId, eventId]);
await db.persist();
}
export function onchainAsset() {
return db.one(`SELECT * FROM Asset WHERE symbol = 'BTC'`)
|| db.one(`SELECT * FROM Asset WHERE kind = 'crypto' ORDER BY symbol LIMIT 1`)
|| null;
}
export function linksForTx(walletTxId) {
return db.all(`
SELECT tel.eventId AS id, tel.method,
e.type, e.occurredAt, e.amount,
a.symbol AS asset_symbol, lf.name AS from_name, lt.name AS to_name
FROM WalletTransactionEventLink tel
JOIN Event e ON e.id = tel.eventId
JOIN Asset a ON a.id = e.assetId
LEFT JOIN Account lf ON lf.id = e.fromAccountId
LEFT JOIN Account lt ON lt.id = e.toAccountId
WHERE tel.walletTransactionId = ?
ORDER BY e.occurredAt DESC, e.id DESC`, [walletTxId]);
}
export function linksForEvent(eventId) {
return db.all(`
SELECT tel.walletTransactionId AS id, tel.method,
t.direction, t.valueSats, t.blockTime, t.txid, t.address, ac.name AS account_name
FROM WalletTransactionEventLink tel
JOIN WalletTransaction t ON t.id = tel.walletTransactionId
LEFT JOIN Account ac ON ac.id = t.accountId
WHERE tel.eventId = ?
ORDER BY (t.blockTime IS NULL) DESC, t.blockTime DESC, t.id DESC`, [eventId]);
}
export function eventCandidatesForTx(walletTxId, assetId) {
return db.all(`
SELECT e.id, e.type, e.occurredAt, e.amount,
a.symbol AS asset_symbol, lf.name AS from_name, lt.name AS to_name
FROM Event e
JOIN Asset a ON a.id = e.assetId
LEFT JOIN Account lf ON lf.id = e.fromAccountId
LEFT JOIN Account lt ON lt.id = e.toAccountId
WHERE e.assetId = ?2 AND e.type <> 'reservation'
AND e.id NOT IN (SELECT eventId FROM WalletTransactionEventLink WHERE walletTransactionId = ?1)
ORDER BY ABS(strftime('%s', e.occurredAt) -
strftime('%s', COALESCE((SELECT blockTime FROM WalletTransaction WHERE id = ?1),
(SELECT createdAt FROM WalletTransaction WHERE id = ?1)))) ASC,
e.id DESC
LIMIT 100`, [walletTxId, assetId]);
}
export function txCandidatesForEvent(eventId) {
return db.all(`
SELECT t.id, t.direction, t.valueSats, t.blockTime, t.txid, t.address,
ac.name AS account_name, l.name AS label_name
FROM WalletTransaction t
LEFT JOIN Account ac ON ac.id = t.accountId
LEFT JOIN Label l ON l.id = t.labelId
WHERE t.id NOT IN (SELECT walletTransactionId FROM WalletTransactionEventLink WHERE eventId = ?1)
ORDER BY ABS(strftime('%s', COALESCE(t.blockTime, t.createdAt)) -
strftime('%s', (SELECT occurredAt FROM Event WHERE id = ?1))) ASC,
t.id DESC
LIMIT 100`, [eventId]);
}
+138
View File
@@ -0,0 +1,138 @@
// Generated, do not edit. sha256:297c7a1d9ed86e15053cd37bb7126f5fb5b54d94b67e8da901fb66f370ca9301
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { nowUtcIso } from '../../shared/time.js';
import { assertEnum } from './_shared.js';
import { capacityReachedFor } from './_limits.js';
import { fail } from '../../shared/app-error.js';
import { eventsCountByAccount, eventsNetByAccount } from './events.js';
import { trackDelete, trackUpdate } from './change-log.js';
import { emitChange } from './_events.js';
export function listValuations() {
return db.all(`SELECT v.*, ac.name AS account_name, a.symbol AS asset_symbol, a.name AS asset_name,
a.decimals AS asset_decimals, a.unit AS asset_unit, a.subunit AS asset_subunit,
a.displayUnit AS asset_display_unit, a.kind AS asset_kind
FROM Valuation v
LEFT JOIN Account ac ON ac.id = v.accountId
JOIN Asset a ON a.id = v.assetId
ORDER BY v.valuedOn DESC, v.rateAt DESC, v.id DESC`);
}
export function anchorAt(accountId, assetId, atIso) {
return db.one(
`SELECT * FROM Valuation
WHERE accountId = ?1 AND assetId = ?2 ${atIso ? 'AND rateAt <= ?3' : ''}
ORDER BY rateAt DESC, id DESC LIMIT 1`,
atIso ? [accountId, assetId, atIso] : [accountId, assetId]) || null;
}
export function latestAnchors(atIso, { ownOnly = false } = {}) {
return db.all(
`SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY accountId, assetId ORDER BY rateAt DESC, id DESC) AS rn
FROM Valuation WHERE accountId IS NOT NULL AND (?1 IS NULL OR rateAt <= ?1)
AND (?2 = 0 OR accountId IN (SELECT id FROM Account WHERE wealthScope IS NULL))
) WHERE rn = 1`, [atIso ?? null, ownOnly ? 1 : 0]);
}
export function anchorsBetween(accountId, assetId, fromIso, untilIso) {
return db.all(
`SELECT * FROM Valuation
WHERE accountId = ?1 AND assetId = ?2 AND rateAt > ?3 AND rateAt <= ?4
ORDER BY rateAt ASC, id ASC`, [accountId, assetId, fromIso, untilIso]);
}
export function anchorDaysBetween(accountId, assetId, vanDag, totDag) {
return db.all(
`SELECT DISTINCT valuedOn FROM Valuation
WHERE accountId = ?1 AND assetId = ?2 AND valuedOn > ?3 AND valuedOn < ?4
ORDER BY valuedOn`, [accountId, assetId, vanDag, totDag]).map(r => r.valuedOn);
}
export function firstAnchorDate() {
const r = db.one(`SELECT MIN(rateAt) AS first FROM Valuation WHERE accountId IS NOT NULL`);
return r && r.first ? r.first : null;
}
export function valuationDiff(v) {
if (!v.accountId) return null;
return v.balance - eventsNetByAccount(v.accountId, v.assetId, v.rateAt);
}
export function anchorBefore(accountId, assetId, atIso) {
return db.one(
`SELECT * FROM Valuation
WHERE accountId = ?1 AND assetId = ?2 AND rateAt < ?3
ORDER BY rateAt DESC, id DESC LIMIT 1`, [accountId, assetId, atIso]) || null;
}
export function valuationGap(v) {
if (!v.accountId) return null;
const vorige = anchorBefore(v.accountId, v.assetId, v.rateAt);
if (!vorige) return valuationDiff(v);
const sindsVorige = eventsNetByAccount(v.accountId, v.assetId, v.rateAt)
- eventsNetByAccount(v.accountId, v.assetId, vorige.rateAt);
return v.balance - (vorige.balance + sindsVorige);
}
export function valuationReconcilable(v) {
if (!v.accountId) return false;
return eventsCountByAccount(v.accountId, v.assetId, v.rateAt) > 0;
}
function verrijkt() {
const nu = nowUtcIso();
return listValuations().map(v => ({
...v,
diff: valuationDiff(v),
gap: valuationGap(v),
reconcilable: valuationReconcilable(v),
accepted: !!v.gapAcceptedAt,
future: v.rateAt > nu,
}));
}
export function valuationDiffs() {
return verrijkt().map(({ id, diff, gap, reconcilable, accepted, future }) =>
({ id, diff, gap, reconcilable, accepted, future }));
}
export function listOpenValuationDiffs() {
return verrijkt().filter(v => !v.future && !v.accepted && v.reconcilable && v.gap != null && v.gap !== 0);
}
export async function updateValuation(id, v) {
assertEnum('source', ENUMS.VALUATION_SOURCE, v.source);
trackUpdate('Valuation', id, () =>
db.run(`UPDATE Valuation SET assetId=?, valuedOn=?, accountId =?, balance=?, fiatRate=?, rateAt=?, source=? WHERE id=?`,
[v.assetId, v.valuedOn, v.accountId ?? null, v.balance, v.fiatRate, v.rateAt, v.source, id]));
await db.persist();
emitChange('ledger-changed');
}
export async function acceptValuationGap(id, aan = true) {
trackUpdate('Valuation', id, () =>
db.run(`UPDATE Valuation SET gapAcceptedAt = ? WHERE id = ?`, [aan ? nowUtcIso() : null, id]));
await db.persist();
emitChange('ledger-changed');
}
export async function deleteValuation(id) {
trackDelete('Valuation', id, () => db.run(`DELETE FROM Valuation WHERE id=?`, [id]));
await db.persist();
emitChange('ledger-changed');
}
export async function createValuation(v) {
assertEnum('source', ENUMS.VALUATION_SOURCE, v.source);
const max = capacityReachedFor('valuations');
if (max !== null) {
fail('valuations.maxFree', { max },
`The free version holds ${max} balance(s). A licence lifts that; nothing is removed in the meantime.`);
}
db.run(`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,?,?,?)`,
[v.assetId, v.valuedOn, v.accountId ?? null, v.balance, v.fiatRate, v.rateAt, v.source]);
const id = db.lastId();
await db.persist();
emitChange('ledger-changed');
return id;
}
+31
View File
@@ -0,0 +1,31 @@
// Generated, do not edit. sha256:ef4dc1eb01e833a602dd00a17d235a583edc55c162bc923df89b4ecb9c82da16
import * as db from '../db.js';
import { nowIso } from './_shared.js';
import { assertSourceMatchesAccount } from './wallet-sources.js';
export function listWalletAddresses(accountId) {
return db.all(`SELECT * FROM WalletAddress
WHERE accountId = ? ORDER BY chainIndex, derivationIndex, address`, [accountId]);
}
export async function upsertWalletAddresses(list) {
for (const a of list) {
assertSourceMatchesAccount('WalletAddress', a.accountId, a.sourceId);
db.run(`INSERT INTO WalletAddress
(accountId, sourceId, address, chainIndex, derivationIndex, automaticLabel, balanceSats, transactionCount, lastScannedAt)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(address) DO UPDATE SET
accountId=excluded.accountId, sourceId=excluded.sourceId, chainIndex=excluded.chainIndex,
derivationIndex=excluded.derivationIndex, automaticLabel=excluded.automaticLabel,
balanceSats=excluded.balanceSats, transactionCount=excluded.transactionCount, lastScannedAt=excluded.lastScannedAt`,
[a.accountId, a.sourceId ?? null, a.address, a.chainIndex ?? null, a.derivationIndex ?? null,
a.automaticLabel ?? null, a.balanceSats ?? null, a.transactionCount ?? null, a.lastScannedAt ?? nowIso()]);
}
await db.persist();
}
export function sourceBalance(sourceId) {
return db.one(`SELECT COALESCE(SUM(balanceSats),0) AS sats, COUNT(*) AS n, MAX(lastScannedAt) AS scanned
FROM WalletAddress WHERE sourceId=?`, [sourceId]);
}
export function accountOnchainBalance(accountId) {
return db.one(`SELECT COALESCE(SUM(balanceSats),0) AS sats FROM WalletAddress WHERE accountId=?`, [accountId]).sats;
}
+111
View File
@@ -0,0 +1,111 @@
// Generated, do not edit. sha256:1aab7b8b0f1a8c9ae297214b001ed051798627cb16552a7cd6582bdf219ea871
import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js';
import { assertEnum, nowIso } from './_shared.js';
import { fail } from '../../shared/app-error.js';
import { trackDelete, trackUpdate } from './change-log.js';
import { hasFeature } from '../license.mjs';
import { services } from '../registers.mjs';
const nwc = () => services.get('nwc');
export async function purgeWalletSourceSecret(id) {
await nwc()?.deleteNwcUri(id);
}
export function assertSourceMatchesAccount(tabel, accountId, sourceId) {
if (sourceId == null) return;
const bron = db.one(`SELECT accountId FROM WalletSource WHERE id = ?`, [sourceId]);
if (!bron) throw new Error(`${tabel}: source ${sourceId} does not exist.`);
if (Number(bron.accountId) !== Number(accountId)) {
throw new Error(`${tabel}: source ${sourceId} belongs to account ${bron.accountId}, not to ${accountId}.`);
}
}
export function listWalletSources(accountId) {
return db.all(`SELECT s.*, c.name AS connection_name, c.url AS connection_url
FROM WalletSource s
LEFT JOIN Connection c ON c.id = s.connectionId
WHERE s.accountId = ? ORDER BY s.id`, [accountId]);
}
export function listAllWalletSources() {
return db.all(`SELECT s.*, a.name AS account_name FROM WalletSource s
JOIN Account a ON a.id = s.accountId ORDER BY a.name, s.id`);
}
function assertLndConnection(connectionId) {
const c = db.one(`SELECT id, kind FROM Connection WHERE id = ?`, [connectionId]);
if (!c) fail('accounts.lndNoConnection', null, 'Pick an LND connection.');
if (c.kind !== 'lnd') fail('accounts.sourceNotLnd', null, 'The chosen connection is not an LND connection.');
}
function connectionFor(kind, connectionId) {
if (kind === 'lnd') { assertLndConnection(Number(connectionId)); return Number(connectionId); }
if (ENUMS.ONCHAIN_SOURCE_KIND.includes(kind) && connectionId != null && connectionId !== '') {
assertLndConnection(Number(connectionId));
return Number(connectionId);
}
return null;
}
const LIGHTNING_SOURCE = new Set(['nwc', 'lnd']);
function assertLightningAllowed(kind) {
if (LIGHTNING_SOURCE.has(kind) && (!hasFeature('lightning') || (kind === 'nwc' && !nwc()))) {
fail('connections.lightningPaid', null, 'Lightning connections belong to the paid version.');
}
}
export async function createWalletSource({ accountId, kind, value, name, scriptType, connectionId = null, secret = null }) {
assertEnum('kind', ENUMS.WALLET_SOURCE_KIND, kind);
assertLightningAllowed(kind);
if (kind === 'xpub') assertEnum('scriptType', ENUMS.SCRIPT_TYPE, scriptType);
if (kind === 'nwc' && !secret) fail('accounts.sourceUriMissing', null, 'The NWC URI is missing.');
const verbinding = connectionFor(kind, connectionId);
db.run(`INSERT INTO WalletSource (accountId, kind, value, name, scriptType, connectionId, createdAt) VALUES (?,?,?,?,?,?,?)`,
[accountId, kind, kind === 'lnd' ? '' : value, name ?? null, kind === 'xpub' ? scriptType : null,
verbinding, nowIso()]);
const id = db.lastId();
if (kind === 'nwc') await nwc().setNwcUri(id, secret);
await db.persist(); return id;
}
export async function updateWalletSource(id, { kind, value, name, scriptType, connectionId = null, secret = null }) {
assertEnum('kind', ENUMS.WALLET_SOURCE_KIND, kind);
if (kind === 'xpub') assertEnum('scriptType', ENUMS.SCRIPT_TYPE, scriptType);
const oud = db.one(`SELECT kind FROM WalletSource WHERE id = ?`, [id]);
if (oud?.kind !== kind) assertLightningAllowed(kind);
const nieuwValue = kind === 'lnd' ? '' : value;
const nieuwConnectie = connectionFor(kind, connectionId);
trackUpdate('WalletSource', id, () =>
db.run(`UPDATE WalletSource SET kind=?, value=?, name=?, scriptType=?, connectionId=?,
balanceSats = CASE WHEN kind = ? AND value = ? AND connectionId IS ? THEN balanceSats END,
lastScannedAt = CASE WHEN kind = ? AND value = ? AND connectionId IS ? THEN lastScannedAt END
WHERE id=?`,
[kind, nieuwValue, name ?? null, kind === 'xpub' ? scriptType : null, nieuwConnectie,
kind, nieuwValue, nieuwConnectie, kind, nieuwValue, nieuwConnectie, id]));
if (kind === 'nwc' && secret) await nwc().setNwcUri(id, secret);
if (kind !== 'nwc') await nwc()?.deleteNwcUri(id);
await db.persist();
}
export async function recordSourceBalance(id, sats) {
const bron = db.one(`SELECT kind FROM WalletSource WHERE id = ?`, [Number(id)]);
if (!bron) throw new Error(`Wallet source ${id} does not exist.`);
if (bron.kind !== 'lnd' && bron.kind !== 'nwc') throw new Error(`Wallet source ${id} is on-chain; its balance comes from its addresses.`);
if (!Number.isInteger(sats) || sats < 0) throw new Error(`A balance is a whole number of sats from zero: '${sats}'.`);
db.run(`UPDATE WalletSource SET balanceSats = ?, lastScannedAt = ? WHERE id = ?`, [sats, nowIso(), Number(id)]);
await db.persist();
}
export function lightningBalances() {
return db.all(`SELECT WalletSource.accountId, Account.name AS account_name,
SUM(WalletSource.balanceSats) AS sats, MAX(WalletSource.lastScannedAt) AS scannedAt
FROM WalletSource
JOIN Account ON Account.id = WalletSource.accountId
WHERE WalletSource.lastScannedAt IS NOT NULL
AND (WalletSource.kind = 'lnd'
OR (WalletSource.kind = 'nwc' AND NOT EXISTS (
SELECT 1 FROM WalletSource node
WHERE node.accountId = WalletSource.accountId AND node.kind = 'lnd')))
GROUP BY WalletSource.accountId, Account.name
ORDER BY Account.name`);
}
export async function deleteWalletSource(id) {
await purgeWalletSourceSecret(id);
trackDelete('WalletSource', id, () => db.run(`DELETE FROM WalletSource WHERE id=?`, [id]));
await db.persist();
}
+103
View File
@@ -0,0 +1,103 @@
// Generated, do not edit. sha256:28bf467843efd410b1dacbe64352649ff4b982578fe5215b9da5061ebec43621
import * as db from '../db.js';
import { nowIso } from './_shared.js';
import { fail } from '../../shared/app-error.js';
import { assertSourceMatchesAccount } from './wallet-sources.js';
import { createLabel } from './labels.js';
import { toSearchName } from '../../shared/search-name.js';
import { CONFIG } from '../../shared/config.js';
import { compactMemo } from '../../shared/zap.js';
const UPSERT = Object.freeze({
onchain: `INSERT INTO WalletTransaction
(accountId, sourceId, layer, txid, direction, valueSats, feeSats, blockHeight, blockTime, address, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (accountId, txid) WHERE layer = 'onchain' DO UPDATE SET
sourceId=excluded.sourceId, direction=excluded.direction, valueSats=excluded.valueSats,
feeSats=excluded.feeSats, blockHeight=excluded.blockHeight, blockTime=excluded.blockTime,
address=excluded.address`,
lightning: `INSERT INTO WalletTransaction
(accountId, sourceId, layer, txid, direction, valueSats, feeSats, blockHeight, blockTime, address, kind, memo, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (sourceId, txid) WHERE layer = 'lightning' DO UPDATE SET
direction=excluded.direction, valueSats=excluded.valueSats,
feeSats=excluded.feeSats, blockHeight=excluded.blockHeight, blockTime=excluded.blockTime,
address=excluded.address, kind=excluded.kind, memo=excluded.memo`,
});
const TWIN_LABEL = `UPDATE WalletTransaction SET labelId = (
SELECT twin.labelId FROM WalletTransaction twin
WHERE twin.layer = 'lightning' AND twin.accountId = WalletTransaction.accountId
AND twin.txid = WalletTransaction.txid AND twin.labelId IS NOT NULL
ORDER BY twin.id LIMIT 1)
WHERE layer = 'lightning' AND labelId IS NULL
AND EXISTS (SELECT 1 FROM WalletTransaction twin
WHERE twin.layer = 'lightning' AND twin.accountId = WalletTransaction.accountId
AND twin.txid = WalletTransaction.txid AND twin.labelId IS NOT NULL)`;
function assertLayerShape(t) {
if (!Object.hasOwn(UPSERT, t.layer)) throw new Error(`Unknown wallet movement layer: '${t.layer}'.`);
if (t.layer !== 'lightning') return;
if (!Number.isInteger(t.sourceId)) throw new Error('A lightning movement needs its source.');
if (!t.blockTime) throw new Error('A lightning movement needs the moment it settled.');
if (t.blockHeight != null) throw new Error('A lightning movement has no block height.');
if (t.kind != null && t.kind !== 'self') throw new Error(`A lightning movement is ordinary or 'self', not '${t.kind}'.`);
}
export async function upsertWalletTxs(list) {
for (const given of list) {
const t = { ...given, layer: given.layer ?? 'onchain' };
assertLayerShape(t);
assertSourceMatchesAccount('WalletTransaction', t.accountId, t.sourceId);
const velden = [t.accountId, t.sourceId ?? null, t.layer, t.txid, t.direction, t.valueSats, t.feeSats ?? null,
t.blockHeight ?? null, t.blockTime ?? null, t.address ?? null];
db.run(UPSERT[t.layer], t.layer === 'lightning'
? [...velden, t.kind ?? null, compactMemo(t.memo), nowIso()] : [...velden, nowIso()]);
}
if (list.some(t => t.layer === 'lightning')) db.run(TWIN_LABEL);
await db.persist();
}
export function listSourceTxs(sourceId) {
return db.all(`SELECT t.*, l.name AS label_name
FROM WalletTransaction t
LEFT JOIN Label l ON l.id = t.labelId
WHERE t.sourceId = ?
ORDER BY (t.blockTime IS NULL) DESC, t.blockTime DESC, t.id DESC`, [sourceId]);
}
const CHANNEL_ROWS = `accountId IN (SELECT value FROM json_each(?1)) AND layer = 'onchain'
AND txid IN (SELECT value FROM json_each(?2))`;
export async function labelChannelTxs(accountId, txids) {
if (!Number.isInteger(accountId)) throw new Error('labelChannelTxs needs an account id.');
if (!Array.isArray(txids) || txids.some(x => typeof x !== 'string' || !/^[0-9a-f]{64}$/.test(x))) {
throw new Error('labelChannelTxs takes a list of transaction ids in lowercase hex.');
}
if (!txids.length) return 0;
const lijst = JSON.stringify(txids);
const accounts = JSON.stringify([accountId, ...db.all(
`SELECT DISTINCT onchain.accountId FROM WalletSource onchain
JOIN WalletSource node ON node.connectionId = onchain.connectionId AND node.kind = 'lnd'
WHERE node.accountId = ? AND onchain.kind IN ('xpub','address') AND onchain.accountId <> ?`,
[accountId, accountId]).map(r => r.accountId)]);
db.run(`UPDATE WalletTransaction SET kind = 'channel' WHERE ${CHANNEL_ROWS} AND kind IS NULL`, [accounts, lijst]);
const kandidaten = db.one(`SELECT COUNT(*) AS n FROM WalletTransaction WHERE ${CHANNEL_ROWS} AND labelId IS NULL`, [accounts, lijst]).n;
if (!kandidaten) { await db.persist(); return 0; }
const naam = CONFIG.LND.CHANNEL_LABEL;
const label = db.one(`SELECT id FROM Label WHERE searchName = ?`, [toSearchName(naam)])
|| { id: await createLabel({ name: naam }) };
db.run(`UPDATE WalletTransaction SET labelId = ?3 WHERE ${CHANNEL_ROWS} AND labelId IS NULL`, [accounts, lijst, label.id]);
await db.persist();
return kandidaten;
}
export async function setTxLabels(ids, labelId) {
if (!Array.isArray(ids) || ids.length === 0) fail('accounts.labelNoTxs', null, 'No transactions given for the label.');
db.run(`UPDATE WalletTransaction SET labelId = ?1
WHERE id IN (SELECT value FROM json_each(?2))
OR (layer = 'lightning' AND EXISTS (
SELECT 1 FROM WalletTransaction chosen
WHERE chosen.id IN (SELECT value FROM json_each(?2)) AND chosen.layer = 'lightning'
AND chosen.accountId = WalletTransaction.accountId AND chosen.txid = WalletTransaction.txid))`,
[labelId ?? null, JSON.stringify(ids)]);
await db.persist();
}
+154
View File
@@ -0,0 +1,154 @@
// Generated, do not edit. sha256:9577dee0ed20ca611f2d96db3a931311a0d672a76d95d80dda4d00726259c4be
import { randomBytes, scrypt as scryptCb, createCipheriv, createDecipheriv, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
import { readFile, writeFile, rm, mkdtemp, rename, access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { secretsFile } from './keys.mjs';
import { run } from './db/conn.mjs';
import { initDatabase, dbFile } from './db.js';
import { keurBestand, wisselDatabaseBestand } from './db-restore.mjs';
import { fail } from '../shared/app-error.js';
const scrypt = promisify(scryptCb);
const MAGIC = Buffer.from('ASSETAPP-BACKUP', 'latin1');
const VERSIE = 1;
const SALT_LEN = 16;
const IV_LEN = 12;
const TAG_LEN = 16;
const KOP_LEN = MAGIC.length + 1 + SALT_LEN + IV_LEN + TAG_LEN;
const SCRYPT = Object.freeze({ N: 2 ** 15, r: 8, p: 1, maxmem: 96 * 1024 * 1024 });
const sleutelUit = (wachtwoord, salt) => scrypt(wachtwoord, salt, 32, SCRYPT);
function eisWachtwoord(wachtwoord) {
const w = String(wachtwoord ?? '');
if (w.length < 8) fail('dbfile.passTooShort', null, 'Choose a password of at least 8 characters for the backup.');
return w;
}
const bestaat = async pad => { try { await access(pad); return true; } catch { return false; } };
async function databaseKopie() {
const dir = await mkdtemp(join(tmpdir(), 'assetapp-backup-'));
const uit = join(dir, 'db.sqlite');
try {
run(`VACUUM INTO '${uit.replace(/'/g, "''")}'`, []);
return { bytes: await readFile(uit), opruimen: () => rm(dir, { recursive: true, force: true }) };
} catch (e) {
await rm(dir, { recursive: true, force: true });
throw e;
}
}
export async function createBackup(wachtwoord) {
const w = eisWachtwoord(wachtwoord);
const kopie = await databaseKopie();
try {
const geheimenPad = secretsFile();
const geheimen = await bestaat(geheimenPad) ? await readFile(geheimenPad) : Buffer.from('{}', 'utf-8');
const manifest = Buffer.from(JSON.stringify({
v: 1, gemaakt: new Date().toISOString(), db: kopie.bytes.length, secrets: geheimen.length,
}) + '\n', 'utf-8');
const plat = Buffer.concat([manifest, kopie.bytes, geheimen]);
const salt = randomBytes(SALT_LEN);
const iv = randomBytes(IV_LEN);
const c = createCipheriv('aes-256-gcm', await sleutelUit(w, salt), iv);
const cijfer = Buffer.concat([c.update(plat), c.final()]);
const bytes = Buffer.concat([MAGIC, Buffer.of(VERSIE), salt, iv, c.getAuthTag(), cijfer]);
return { bytes, db: kopie.bytes.length, secrets: geheimen.length };
} finally {
await kopie.opruimen();
}
}
async function openBackup(bytes, wachtwoord) {
if (!bytes || bytes.length < KOP_LEN) fail('dbfile.notABackup', null, 'This file is not an AssetApp backup (too short).');
if (!bytes.subarray(0, MAGIC.length).equals(MAGIC)) {
fail('dbfile.notABackupUseRestore', null, 'This file is not an AssetApp backup; restore a loose .sqlite with "Terugzetten".');
}
const versie = bytes[MAGIC.length];
if (versie !== VERSIE) fail('dbfile.unknownBackupVersion', { versie, kent: VERSIE }, `Unknown backup version (${versie}); this app knows version ${VERSIE}.`);
let p = MAGIC.length + 1;
const salt = bytes.subarray(p, p += SALT_LEN);
const iv = bytes.subarray(p, p += IV_LEN);
const tag = bytes.subarray(p, p += TAG_LEN);
const cijfer = bytes.subarray(p);
const d = createDecipheriv('aes-256-gcm', await sleutelUit(String(wachtwoord ?? ''), salt), iv);
d.setAuthTag(tag);
let plat;
try {
plat = Buffer.concat([d.update(cijfer), d.final()]);
} catch {
fail('dbfile.decryptFailed', null, 'Decryption failed: wrong password, or the file is damaged.');
}
const nl = plat.indexOf(0x0a);
if (nl < 0) fail('dbfile.noManifest', null, 'Backup damaged: no manifest found.');
let manifest;
try { manifest = JSON.parse(plat.subarray(0, nl).toString('utf-8')); }
catch { fail('dbfile.manifestUnreadable', null, 'Backup damaged: the manifest is unreadable.'); }
const dbLen = Number(manifest.db), secretsLen = Number(manifest.secrets);
if (!Number.isInteger(dbLen) || !Number.isInteger(secretsLen)
|| nl + 1 + dbLen + secretsLen !== plat.length) {
fail('dbfile.manifestLengths', null, 'Backup damaged: the lengths in the manifest do not match the content.');
}
return {
manifest,
db: plat.subarray(nl + 1, nl + 1 + dbLen),
secrets: plat.subarray(nl + 1 + dbLen),
};
}
export async function restoreBackup(bytes, wachtwoord) {
const { manifest, db, secrets } = await openBackup(bytes, wachtwoord);
const doel = dbFile();
const geheimenPad = secretsFile();
const incoming = `${doel}.new`;
await writeFile(incoming, db, { mode: 0o600 });
let keuring;
try {
keuring = keurBestand(incoming);
} catch (e) {
await rm(incoming, { force: true });
throw e;
}
let aantalGeheimen;
try { aantalGeheimen = Object.keys(JSON.parse(secrets.toString('utf-8'))).length; }
catch {
await rm(incoming, { force: true });
fail('dbfile.secretsUnreadable', null, 'Backup damaged: the secrets file is not readable JSON.');
}
const previousDb = `${doel}.previous`;
const previousSecrets = `${geheimenPad}.previous`;
await wisselDatabaseBestand(incoming, previousDb, async () => {
if (await bestaat(geheimenPad)) {
await rm(previousSecrets, { force: true });
await rename(geheimenPad, previousSecrets);
}
await writeFile(geheimenPad, secrets, { mode: 0o600 });
});
const regel = initDatabase();
return {
gemaakt: manifest.gemaakt ?? null,
versie: keuring.versie,
wezen: keuring.wezen,
geheimen: aantalGeheimen,
previousDb,
previousSecrets,
regel,
};
}
export function isBackupBestand(bytes) {
return !!bytes && bytes.length >= MAGIC.length && bytes.subarray(0, MAGIC.length).equals(MAGIC);
}
export const FORMAAT = Object.freeze({ MAGIC, VERSIE, SALT_LEN, IV_LEN, TAG_LEN, KOP_LEN, SCRYPT });
+30
View File
@@ -0,0 +1,30 @@
// Generated, do not edit. sha256:c5d56ec66e3cd8cb38447fbd3bde3d13f334c82cd570f61ee957f449d17935a3
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { run } from './db/conn.mjs';
function bestandsnaam() {
const d = new Date();
const p = n => String(n).padStart(2, '0');
return `assetapp-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}.sqlite`;
}
export async function exportDatabase(res) {
const dir = await mkdtemp(join(tmpdir(), 'assetapp-export-'));
const uit = join(dir, 'export.sqlite');
try {
run(`VACUUM INTO '${uit.replace(/'/g, "''")}'`, []);
const bytes = await readFile(uit);
res.writeHead(200, {
'Content-Type': 'application/vnd.sqlite3',
'Content-Disposition': `attachment; filename="${bestandsnaam()}"`,
'Content-Length': bytes.length,
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
});
res.end(bytes);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
+47
View File
@@ -0,0 +1,47 @@
// Generated, do not edit. sha256:5bd22bc9def9eb9c8a4556854dfc31dcdfb39f064aeaa1b0b584f8b737953956
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { closeDatabase, run } from './db/conn.mjs';
import { initDatabase, dbFile } from './db.js';
import { restoreDatabase } from './db-restore.mjs';
import { getBaseCurrency } from './data/portfolio.js';
import { setupStatus } from './data/onboarding.js';
export async function startFreshDatabase({ baseCurrency: chosen = null } = {}) {
const dir = await mkdtemp(join(tmpdir(), 'assetapp-fresh-'));
try {
const nieuw = join(dir, 'nieuw.sqlite');
const baseCurrency = chosen ?? getBaseCurrency();
closeDatabase();
initDatabase(nieuw, { baseCurrency });
const kopie = join(dir, 'kopie.sqlite');
run(`VACUUM INTO '${kopie.replace(/'/g, "''")}'`, []);
closeDatabase();
const bytes = await readFile(kopie);
const uit = await restoreDatabase(bytes);
return {
...uit,
geheimenWaarschuwing: 'De geheimen (NWC-URI\'s, LND-macaroons, provider-sleutels) staan in '
+ 'secrets.json in de map van deze portefeuille en zijn NIET weggegooid: daar staat ook de sleutel van de toegangscontrole. '
+ 'Let op dat een geheim aan een bron-id hangt en die reeks in een lege administratie weer bij 1 '
+ 'begint, dus een nieuwe tracking-bron kan het geheim van een oude erven.',
};
} catch (e) {
try { initDatabase(dbFile()); } catch { }
throw e;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
export async function chooseSetupCurrency(baseCurrency) {
if (getBaseCurrency() === baseCurrency) return { changed: false };
if (!setupStatus().currencyOpen) {
throw new Error('The unit of account can only be chosen on a portfolio that holds nothing yet.');
}
await startFreshDatabase({ baseCurrency });
return { changed: true };
}
+127
View File
@@ -0,0 +1,127 @@
// Generated, do not edit. sha256:e1360b8b92e9fdeccd0b88044df16a08eafd2bf7c084bf7549eeac1712ff09fb
import { DatabaseSync } from 'node:sqlite';
import { writeFile, rename, rm, access } from 'node:fs/promises';
import { CONFIG } from '../shared/config.js';
import { BASELINE_VERSION } from './db/migrations.js';
import { closeDatabase } from './db/conn.mjs';
import { initDatabase, dbFile } from './db.js';
import { fail } from '../shared/app-error.js';
const BUREN = ['-wal', '-shm'];
const VERGRENDELD = new Set(['EBUSY', 'EPERM', 'EACCES']);
const WACHT_MS = [50, 150, 400];
async function hernoemVolhardend(van, naar) {
for (let poging = 0; ; poging++) {
try { return await rename(van, naar); }
catch (e) {
if (!VERGRENDELD.has(e.code) || poging >= WACHT_MS.length) throw vertaalVergrendeling(e, van);
await new Promise(r => setTimeout(r, WACHT_MS[poging]));
}
}
}
function vertaalVergrendeling(e, pad) {
if (!VERGRENDELD.has(e.code)) return e;
return new Error(
`Het databasebestand is door een ander programma in gebruik (${e.code}), dus het kon niet worden `
+ `verplaatst: ${pad}. Er is niets gewijzigd. Sluit wat er nog aan deze datamap hangt en probeer het `
+ `opnieuw: een tweede exemplaar van de app, de MCP-server (npm run mcp), of een database-viewer.`);
}
const bestaat = async pad => { try { await access(pad); return true; } catch { return false; } };
export function keurBestand(pad) {
let db;
try {
db = new DatabaseSync(pad, { readOnly: true });
} catch (e) {
fail('dbfile.notReadableSqlite', { reden: e.message }, `This is not a readable SQLite file (${e.message}).`);
}
try {
const heeftSchema = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='Schema'`).get();
if (!heeftSchema) {
fail('dbfile.noSchemaTable', null,
'This file has no Schema table; a database from before the baseline can no longer be converted '
+ '(git log --all -- scripts/baseline-convert.mjs).');
}
const rij = db.prepare(`SELECT version FROM Schema`).get();
const versie = rij && Number(rij.version);
if (!Number.isInteger(versie)) fail('dbfile.noReadableVersion', null, 'The Schema table holds no readable version.');
if (versie < BASELINE_VERSION) {
fail('dbfile.versionBeforeBaseline', { versie, baseline: BASELINE_VERSION },
`This database is version ${versie}, from before the new layout (version ${BASELINE_VERSION}); `
+ 'see git log --all -- scripts/baseline-convert.mjs.');
}
if (versie > CONFIG.SCHEMA_VERSION) {
fail('dbfile.versionNewerThanApp', { versie, app: CONFIG.SCHEMA_VERSION },
`This database is version ${versie} and thus newer than the app (${CONFIG.SCHEMA_VERSION}).`);
}
const wezen = db.prepare(`PRAGMA foreign_key_check`).all();
return { versie, wezen: wezen.length };
} finally {
db.close();
}
}
export async function restoreDatabase(bytes) {
if (!bytes || bytes.length === 0) fail('dbfile.emptyFile', null, 'Received an empty file.');
if (bytes.subarray(0, 15).toString('latin1') !== 'SQLite format 3') {
fail('dbfile.notSqlite', null, 'This is not a SQLite file (the file header does not match).');
}
const doel = dbFile();
const incoming = `${doel}.new`;
const previous = `${doel}.previous`;
await writeFile(incoming, bytes, { mode: 0o600 });
let keuring;
try {
keuring = keurBestand(incoming);
} catch (e) {
await rm(incoming, { force: true });
throw e;
}
await wisselDatabaseBestand(incoming, previous);
const regel = initDatabase();
return {
versie: keuring.versie,
wezen: keuring.wezen,
previous,
regel,
geheimenWaarschuwing: 'De geheimen (NWC-URI\'s, LND-macaroons, provider-sleutels) staan in '
+ 'secrets.json in de map van deze portefeuille en zijn NIET meeverhuisd. Komt deze database uit een andere administratie, '
+ 'controleer dan je tracking-bronnen: een geheim hangt aan een bron-id en dat id kan nu bij een '
+ 'andere bron horen.',
};
}
export async function wisselDatabaseBestand(incoming, previous, naWissel = async () => {}) {
let opzijGezet = false;
const doel = dbFile();
closeDatabase();
try {
await rm(previous, { force: true });
for (const b of BUREN) await rm(previous + b, { force: true });
if (await bestaat(doel)) {
await hernoemVolhardend(doel, previous);
opzijGezet = true;
for (const b of BUREN) if (await bestaat(doel + b)) await hernoemVolhardend(doel + b, previous + b);
}
await hernoemVolhardend(incoming, doel);
await naWissel();
} catch (e) {
if (opzijGezet && !(await bestaat(doel))) {
try {
await rename(previous, doel);
for (const b of BUREN) if (await bestaat(previous + b)) await rename(previous + b, doel + b);
} catch { }
}
await rm(incoming, { force: true });
try { initDatabase(); } catch { }
throw e;
}
}
+91
View File
@@ -0,0 +1,91 @@
// Generated, do not edit. sha256:0c36b25c75165df3de3ba590bc3b8b5e5c39b5db442fe5f91547c9706dc403d0
import { join } from 'node:path';
import { CONFIG } from '../shared/config.js';
import { activeDir } from './portfolios.mjs';
import { openDatabase, closeDatabase, database, pragmaStand, withOtherDatabase, run, all, one } from './db/conn.mjs';
import { SCHEMA } from './db/schema.js';
import {
seedPreferences, seedProviders, seedConnections, seedAssets,
seedPriceSource, seedDashboardProfile,
} from './db/seeds.js';
import { runMigrations, readVersion } from './db/migrations.js';
export { run, all, one, lastId } from './db/conn.mjs';
export { SCHEMA } from './db/schema.js';
export { seedPriceSource } from './db/seeds.js';
export function dbFile() {
return process.env.ASSETAPP_DB_FILE || join(activeDir(), 'db.sqlite');
}
export function initDatabase(pad = dbFile(), { baseCurrency = CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY } = {}) {
assertBaseCurrency(baseCurrency);
const bestaat = openBestaand(pad);
if (!bestaat) {
inrichten(baseCurrency);
} else {
runMigrations();
seedDashboardProfile();
}
return startregel(pad, bestaat);
}
export function createDatabase(pad, { baseCurrency = CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY } = {}) {
assertBaseCurrency(baseCurrency);
withOtherDatabase(pad, () => {
if (one(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'Schema'`)) {
throw new Error(`createDatabase: ${pad} already holds a database.`);
}
inrichten(baseCurrency);
});
}
function assertBaseCurrency(code) {
if (!CONFIG.PORTFOLIO.BASE_CURRENCIES.includes(code)) {
throw new Error(`Unknown unit of account '${code}': not in CONFIG.PORTFOLIO.BASE_CURRENCIES.`);
}
}
function inrichten(baseCurrency) {
run(SCHEMA);
run(`INSERT INTO Schema (version) VALUES (?)`, [CONFIG.SCHEMA_VERSION]);
run(`INSERT INTO Portfolio (id, baseCurrency, createdAt) VALUES (1, ?, ?)`, [baseCurrency, new Date().toISOString()]);
seedPreferences();
seedProviders();
seedConnections();
seedAssets();
seedPriceSource(null, 'confirmed');
seedDashboardProfile();
}
function openBestaand(pad) {
openDatabase(pad);
return !!one(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'Schema'`);
}
function startregel(pad, bestond) {
const p = pragmaStand();
const restjes = one(`SELECT COUNT(*) AS n FROM Preference WHERE scope = 'nwc'`);
const waarschuwing = restjes && restjes.n > 0
? `\n[assetapp] LET OP: er staan nog ${restjes.n} versleutelde NWC-rij(en) in Preference, uit de tijd `
+ 'dat geheimen in de database leefden. Ze zijn niet meer te ontsleutelen en niemand leest ze; '
+ 'opruimen kan via Systeem → Data.'
: '';
return `database: ${pad} (${bestond ? 'bestaand' : 'nieuw aangemaakt'}, versie ${readVersion()}) | `
+ `FK's: ${p.foreignKeys ? 'aan' : 'UIT'} | journal: ${p.journalMode}` + waarschuwing;
}
export function schemaVersion() { return readVersion(); }
export function databaseStatus() {
const ingericht = one(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'Schema'`);
if (!ingericht) return { klaar: false, reden: 'leeg', versie: null };
const versie = readVersion();
if (versie < CONFIG.SCHEMA_VERSION) return { klaar: false, reden: 'oud', versie };
if (versie > CONFIG.SCHEMA_VERSION) return { klaar: false, reden: 'nieuwer', versie };
return { klaar: true, reden: null, versie };
}
export async function persist() { }
export { closeDatabase, database };
+73
View File
@@ -0,0 +1,73 @@
// Generated, do not edit. sha256:95f28c26a77b79beac5f8c6818bd25243362641c1d8549949427a847e4f810f9
import { DatabaseSync } from 'node:sqlite';
export const BUSY_TIMEOUT_MS = 5000;
let db = null;
export function openDatabase(pad) {
if (db) throw new Error('A database connection is already open; close it first with closeDatabase().');
db = new DatabaseSync(pad, { enableForeignKeyConstraints: true });
db.exec(`PRAGMA journal_mode = WAL`);
db.exec(`PRAGMA synchronous = NORMAL`);
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
return db;
}
export function database() { return db; }
export function withOtherDatabase(pad, fn) {
const vorige = db;
db = null;
try {
openDatabase(pad);
const uit = fn();
if (uit && typeof uit.then === 'function') throw new Error('withOtherDatabase: fn must be synchronous.');
return uit;
} finally {
closeDatabase();
db = vorige;
}
}
export function closeDatabase() {
if (!db) return;
db.close();
db = null;
}
export function pragmaStand() {
const lees = (naam, veld) => database().prepare(`PRAGMA ${naam}`).get()[veld];
return {
foreignKeys: lees('foreign_keys', 'foreign_keys') === 1,
journalMode: lees('journal_mode', 'journal_mode'),
synchronous: lees('synchronous', 'synchronous'),
busyTimeout: lees('busy_timeout', 'timeout'),
};
}
const bind = params => params.map(v => {
if (v === undefined) return null;
if (typeof v === 'boolean') return v ? 1n : 0n;
if (typeof v === 'number' && Number.isInteger(v)) return BigInt(v);
return v;
});
export function run(sql, params) {
if (params === undefined) db.exec(sql);
else db.prepare(sql).run(...bind(params));
}
export function all(sql, params = []) {
return db.prepare(sql).all(...bind(params)).map(rij => ({ ...rij }));
}
export function one(sql, params = []) {
const rijen = all(sql, params);
return rijen.length ? rijen[0] : null;
}
export function lastId() {
const r = one(`SELECT last_insert_rowid() AS id`);
return r ? r.id : null;
}
File diff suppressed because it is too large. Load diff
+1239
View File
File diff suppressed because it is too large. Load diff
+189
View File
@@ -0,0 +1,189 @@
// Generated, do not edit. sha256:52135637bc4779b2b5f755ec50fedf41d2fa7b65d90a35f911c4e68f0b093874
import { CONFIG, PREFERENCES, SOURCE_CAPABILITIES, SOURCE_ENDPOINTS, DASHBOARD } from '../../shared/config.js';
import { toSearchName } from '../../shared/search-name.js';
import { run, one, all, lastId } from './conn.mjs';
function basisMunt() {
const r = one(`SELECT baseCurrency FROM Portfolio WHERE id = 1`);
if (!r) throw new Error('Seeds need the Portfolio row: write the unit of account first.');
return r.baseCurrency;
}
function fiatAssets(basis) {
const codes = [...new Set([basis, 'EUR', 'USD'])];
const namen = new Intl.DisplayNames(['nl-NL'], { type: 'currency' });
return codes.map(code => {
const naam = namen.of(code);
const decimals = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: code }).resolvedOptions().maximumFractionDigits;
return { symbol: code, name: naam.charAt(0).toUpperCase() + naam.slice(1), kind: 'fiat', unit: code,
subunit: code === 'EUR' || code === 'USD' ? 'cent' : null, decimals, displayUnit: 'unit' };
});
}
function startBron(symbol, basis) {
if (symbol === basis) return 'fixed';
if (symbol === 'EUR' || symbol === 'USD') return 'frankfurter';
return { BTC: 'mempool', ETH: 'bitvavo', XAU: 'goldapi', XAG: 'goldapi' }[symbol] ?? null;
}
export function seedAssets() {
for (const a of fiatAssets(basisMunt())) {
if (one(`SELECT 1 AS x FROM Asset WHERE symbol = ?`, [a.symbol])) continue;
run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES (?,?,?,?,?,?,?,?)`,
[a.symbol, a.name, toSearchName(a.name), a.kind, a.unit, a.subunit, a.decimals, a.displayUnit]
);
}
}
const SEED_METAL_PRODUCTS = [
{ symbol: 'XAU', name: 'Krugerrand 1 oz', form: 'coin', grams: CONFIG.METALS.GRAMS_PER_TROY_OUNCE },
{ symbol: 'XAU', name: 'Maple Leaf 1 oz', form: 'coin', grams: CONFIG.METALS.GRAMS_PER_TROY_OUNCE },
{ symbol: 'XAU', name: 'Wiener Philharmoniker 1 oz', form: 'coin', grams: CONFIG.METALS.GRAMS_PER_TROY_OUNCE },
{ symbol: 'XAU', name: 'Baar 1 g', form: 'bar', grams: 1 },
{ symbol: 'XAU', name: 'Baar 5 g', form: 'bar', grams: 5 },
{ symbol: 'XAU', name: 'Baar 10 g', form: 'bar', grams: 10 },
{ symbol: 'XAU', name: 'Baar 100 g', form: 'bar', grams: 100 },
{ symbol: 'XAU', name: 'Baar 1 kg', form: 'bar', grams: 1000 },
{ symbol: 'XAG', name: 'Maple Leaf 1 oz', form: 'coin', grams: CONFIG.METALS.GRAMS_PER_TROY_OUNCE },
{ symbol: 'XAG', name: 'Wiener Philharmoniker 1 oz', form: 'coin', grams: CONFIG.METALS.GRAMS_PER_TROY_OUNCE },
{ symbol: 'XAG', name: 'Baar 100 g', form: 'bar', grams: 100 },
{ symbol: 'XAG', name: 'Baar 1 kg', form: 'bar', grams: 1000 },
];
export function seedMetalProducts() {
const factor = Math.pow(10, CONFIG.METALS.BASE_DECIMALS);
for (const p of SEED_METAL_PRODUCTS) {
const a = one(`SELECT id FROM Asset WHERE symbol = ?`, [p.symbol]);
if (!a) continue;
if (one(`SELECT 1 AS x FROM MetalProduct WHERE assetId = ? AND name = ?`, [a.id, p.name])) continue;
run(`INSERT INTO MetalProduct (assetId, name, searchName, form, fineGrams) VALUES (?,?,?,?,?)`,
[a.id, p.name, toSearchName(p.name), p.form, Math.round(p.grams * factor)]);
}
}
const SEED_CONNECTIONS = [
{ kind: 'btc_explorer', name: 'mempool.space (publiek)', url: CONFIG.PRICE_SOURCES.MEMPOOL_PUBLIC_BASE, isActive: 1, isSystem: 1 },
];
export function seedConnections() {
const now = new Date().toISOString();
for (const c of SEED_CONNECTIONS) {
if (one(`SELECT 1 AS x FROM Connection WHERE kind = ? AND name = ?`, [c.kind, c.name])) continue;
const hasActive = !!one(`SELECT 1 AS x FROM Connection WHERE kind = ? AND isActive = 1`, [c.kind]);
run(`INSERT INTO Connection (name, searchName, url, kind, isActive, isSystem, lastStatus, lastCheckedAt, createdAt, updatedAt)
VALUES (?,?,?,?,?,?,NULL,NULL,?,?)`,
[c.name, toSearchName(c.name), c.url, c.kind, hasActive ? 0 : c.isActive, c.isSystem, now, now]);
}
}
function seedProviderList(basis) {
return [
{ key: 'manual', name: 'Handmatig', baseUrl: '', authenticationKind: 'none', authenticationRequired: 0, isSystem: true,
authenticationHeaderName: null, authenticationParameterName: null, hmac: null,
endpoints: [], symbols: [], coverage: [], outputs: [] },
{ key: CONFIG.MARKET_SERVICE.PROVIDER_KEY, name: 'Atreides Services', baseUrl: '', authenticationKind: 'none',
authenticationRequired: 0, isSystem: true, authenticationHeaderName: null, authenticationParameterName: null,
hmac: null, endpoints: [], symbols: [], coverage: [], outputs: [] },
{ key: 'fixed', name: 'Vaste koers', baseUrl: '', authenticationKind: 'none', authenticationRequired: 0, isSystem: true,
authenticationHeaderName: null, authenticationParameterName: null, hmac: null,
fixedRate: { fiatPrice: 1 },
endpoints: [
{ capability: 'spot', method: 'GET', pathTemplate: '', queryTemplate: null },
{ capability: 'history_point', method: 'GET', pathTemplate: '', queryTemplate: null },
],
symbols: [], coverage: [basis], outputs: [] },
];
}
export function seedProviders() {
for (const p of seedProviderList(basisMunt())) {
if (one(`SELECT 1 AS x FROM Provider WHERE key = ?`, [p.key])) continue;
const isActive = p.authenticationRequired ? 0 : 1;
run(`INSERT INTO Provider
(key, name, searchName, baseUrl, authenticationHeaderName, authenticationParameterName,
authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,1,?,?)`,
[p.key, p.name, toSearchName(p.name), p.baseUrl, p.authenticationHeaderName, p.authenticationParameterName,
p.authenticationKind, p.authenticationRequired, isActive, p.isSystem ? 1 : 0, new Date().toISOString()]);
const pid = lastId();
for (const symbool of p.coverage || []) {
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?,?)`, [pid, symbool]);
}
if (p.fixedRate) {
run(`INSERT INTO ProviderFixedRate (providerId, fiatPrice) VALUES (?,?)`, [pid, p.fixedRate.fiatPrice]);
}
if (p.callsPerMonth) {
run(`INSERT INTO ProviderCallLimit (providerId, callsPerMonth, createdAt) VALUES (?,?,?)`,
[pid, p.callsPerMonth, new Date().toISOString()]);
}
if (p.hmac) {
const h = p.hmac;
run(`INSERT INTO ProviderAuthenticationHmac
(providerId, messageParts, hashAlgorithm, encoding, keyHeader, signatureHeader, timestampHeader, windowHeader, windowMs)
VALUES (?,?,?,?,?,?,?,?,?)`,
[pid, h.messageParts, h.hashAlgorithm, h.encoding, h.keyHeader, h.signatureHeader, h.timestampHeader, h.windowHeader, h.windowMs]);
}
for (const e of p.endpoints)
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate, quoteCurrency) VALUES (?,?,?,?,?,?)`,
[pid, e.capability, e.method, e.pathTemplate, e.queryTemplate, e.quoteCurrency ?? null]);
for (const s of p.symbols)
run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, isCatchAll) VALUES (?,?,?,?,?)`,
[pid, s.isCatchAll ? null : s.symbol, s.externalId, s.quoteUnit ?? null, s.isCatchAll ? 1 : 0]);
for (const o of p.outputs)
run(`INSERT INTO ProviderOutputMap (providerId, capability, internalField, jsonPath, transform) VALUES (?,?,?,?,?)`,
[pid, o.capability, o.internalField, o.jsonPath, o.transform]);
}
}
export function seedPriceSource(assetId, status) {
const assets = assetId
? all(`SELECT id, symbol FROM Asset WHERE id = ?`, [assetId])
: all(`SELECT id, symbol FROM Asset`);
const basis = basisMunt();
for (const asset of assets) {
const srcKey = startBron(asset.symbol, basis);
if (!srcKey) continue;
const provider = one(`SELECT id FROM Provider WHERE key = ?`, [srcKey]);
if (!provider) continue;
for (const capability of SOURCE_CAPABILITIES) {
if (one(`SELECT 1 AS x FROM PriceSource WHERE assetId = ? AND capability = ?`,
[asset.id, capability])) continue;
const eindpunten = SOURCE_ENDPOINTS[capability];
if (!one(`SELECT 1 AS x FROM ProviderEndpoint WHERE providerId = ? AND capability IN (${eindpunten.map(() => '?').join(',')})`,
[provider.id, ...eindpunten])) continue;
run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) VALUES (?,?,?,1,?)`,
[asset.id, capability, provider.id, status]);
}
}
}
export function seedDashboardProfile() {
if (one(`SELECT 1 AS x FROM DashboardProfile WHERE isSystem = 1`)) return false;
const now = new Date().toISOString();
const hasActive = !!one(`SELECT 1 AS x FROM DashboardProfile WHERE isActive = 1`);
run(`INSERT INTO DashboardProfile (name, searchName, description, sortOrder, isActive, isSystem, createdAt, updatedAt)
VALUES (?,?,NULL,0,?,1,?,?)`,
['Standaard', toSearchName('Standaard'), hasActive ? 0 : 1, now, now]);
const profileId = lastId();
for (const w of DASHBOARD.DEFAULT_LAYOUT) {
const def = DASHBOARD.WIDGETS[w.type];
if (!def) continue;
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]);
}
return true;
}
const FRESH_VALUES = Object.freeze({ app: Object.freeze({ market_spot: 'first' }) });
export function seedPreferences() {
for (const [scope, keys] of Object.entries(PREFERENCES)) {
for (const [key, spec] of Object.entries(keys)) {
if (spec.secret) continue;
run(`INSERT INTO Preference (scope, key, value) VALUES (?,?,?)`,
[scope, key, JSON.stringify(FRESH_VALUES[scope]?.[key] ?? spec.initial)]);
}
}
}
+1
View File
@@ -0,0 +1 @@
// Generated, do not edit. sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+11
View File
@@ -0,0 +1,11 @@
{
"format": 1,
"license": {
"buyer": "72304ea4d273d6c685aaa76342975e7a001190c9f15369ed3d3b303b79c8c74c",
"level": "free",
"issued": "2026-10-27",
"majorFrom": 1,
"majorTo": "unlimited"
},
"signature": "29a3844dd0e6dbfc4a0dbe451e223797935f8db4acd1bb5ccc21d95fe0eff77cfc3134a4f7a00e9e09c53a805b6b6a90efd69942643b2dd0db77a09b7d9d660f"
}
+78
View File
@@ -0,0 +1,78 @@
// Generated, do not edit. sha256:b9a3a6b8f1e1fdbee54a4d9e67411a1659c1ea24437a706bbf1a46f443c3b26d
import { createServer as createHttpServer } from 'node:http';
import { createServer as createHttpsServer } from 'node:https';
import { spawn } from 'node:child_process';
import { createRequestListener, CLIENT } from './app.mjs';
import { loadCredentials, loadTlsOptions, resolveBinding } from './auth.mjs';
import { initDatabase, one } from './db.js';
import { getProvider } from './data/providers.js';
import { moveProviderKeys } from './keys.mjs';
import { prepareLayout } from './portfolios.mjs';
import { licenseState, appVersion, hasFeature } from './license.mjs';
import { getPref } from './data/preferences.js';
import { adoptInstallLocale } from './installation.mjs';
import { services } from './registers.mjs';
import './edition.mjs';
const PORT = Number(process.env.PORT) || 8080;
function openInBrowser(url) {
const [cmd, args] = process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
: process.platform === 'darwin' ? ['open', [url]]
: ['xdg-open', [url]];
try {
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
} catch (e) {
console.error('[assetapp] browser openen mislukt:', e.message);
}
}
let credentials, tls, binding, dbRegel, portefeuilleRegel;
try {
portefeuilleRegel = prepareLayout().regel;
credentials = await loadCredentials();
tls = await loadTlsOptions();
binding = resolveBinding({ tls: Boolean(tls), credentials, host: process.env.ASSETAPP_HOST || null });
dbRegel = initDatabase();
if (!getProvider('eulerpool_metal') && getProvider('eulerpool')) await moveProviderKeys('eulerpool_metal', 'eulerpool');
const oldLocale = one(`SELECT value FROM Preference WHERE scope = 'app' AND key = 'locale'`);
adoptInstallLocale(oldLocale ? JSON.parse(oldLocale.value) : undefined);
} catch (e) {
console.error(`[assetapp] kan niet starten: ${e.message}`);
process.exit(1);
}
if (process.channel) {
process.on('disconnect', () => {
console.log('[assetapp] de supervisor is weg; de server stopt.');
process.exit(0);
});
}
const server = tls
? createHttpsServer(tls, createRequestListener({ credentials, secure: true }))
: createHttpServer(createRequestListener({ credentials, secure: false }));
server.listen(PORT, binding.host, () => {
const schema = tls ? 'https' : 'http';
const url = `${schema}://localhost:${PORT}`;
console.log(`[assetapp] serveert ${CLIENT} op ${url}`);
const bron = credentials ? (process.env.ASSETAPP_PASSWORD ? 'omgeving' : 'portefeuille') : null;
console.log(`[assetapp] TLS: ${tls ? 'aan' : 'uit'} | toegangscontrole: ${credentials ? `aan (${bron})` : 'uit'}`
+ ` | luistert op: ${binding.host}`);
console.log(`[assetapp] ${portefeuilleRegel}`);
console.log(`[assetapp] ${dbRegel}`);
const lic = licenseState();
console.log(`[assetapp] licentie: ${lic.level === 'full' ? 'volledig' : 'gratis stand'}`
+ ` (${lic.why}) | versie: ${appVersion()}`);
if (binding.waarschuwing) console.warn(`[assetapp] LET OP: ${binding.waarschuwing}`);
else if (binding.host === '127.0.0.1') {
console.log('[assetapp] Alleen bereikbaar vanaf deze machine. Van een ander apparaat erbij kunnen'
+ ' vergt TLS én inloggegevens.');
}
if (process.env.ASSETAPP_OPEN === '1') {
const kiosk = services.get('kiosk');
kiosk?.forget();
if (!(kiosk && hasFeature('kiosk') && getPref('app', 'kiosk') && kiosk.start(url))) openInBrowser(url);
}
});
+34
View File
@@ -0,0 +1,34 @@
// Generated, do not edit. sha256:6cbe401d306ec892f1f72668153c40a7ef9e20306ce9e0d40df746e713359910
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { CONFIG } from '../shared/config.js';
import { installDir } from './portfolios.mjs';
const file = () => join(installDir(), CONFIG.INSTALLATION.SETTINGS_FILE_NAME);
function read() {
try { return JSON.parse(readFileSync(file(), 'utf8')); }
catch (e) { if (e.code === 'ENOENT') return {}; throw e; }
}
function write(settings) {
mkdirSync(installDir(), { recursive: true });
writeFileSync(file(), `${JSON.stringify(settings, null, 2)}\n`);
}
const known = code => Object.hasOwn(CONFIG.LOCALE_TAGS, code);
export function installLocale() {
const { locale } = read();
return known(locale) ? locale : CONFIG.INSTALLATION.DEFAULT_LOCALE;
}
export function setInstallLocale(code) {
if (!known(code)) throw new Error(`Unknown locale: '${code}'.`);
write({ ...read(), locale: code });
}
export function adoptInstallLocale(fromPortfolio) {
if (known(read().locale)) return;
setInstallLocale(known(fromPortfolio) ? fromPortfolio : CONFIG.INSTALLATION.DEFAULT_LOCALE);
}
+74
View File
@@ -0,0 +1,74 @@
// Generated, do not edit. sha256:f749bbb8aa6d30896aca521c750c1da8d88aef00151138c594cf54a5f6889b26
import { listProviders, getProvider } from './data/providers.js';
import { listAssets } from './data/assets.js';
import { getBaseCurrency } from './data/portfolio.js';
export const INTEGRATIONS_FORMAT = 'atreides-assets/integrations';
export const INTEGRATIONS_VERSION = 1;
const byKeys = (...keys) => (a, b) => {
for (const k of keys) {
const x = a[k] ?? '';
const y = b[k] ?? '';
if (x < y) return -1;
if (x > y) return 1;
}
return 0;
};
export function integrationsJson(providers, assets, baseCurrency) {
const kindOf = new Map(assets.map(a => [a.symbol, a.kind]));
return {
format: INTEGRATIONS_FORMAT,
version: INTEGRATIONS_VERSION,
baseCurrency,
providers: providers.filter(p => !p.isSystem).sort(byKeys('key')).map(p => ({
key: p.key,
name: p.name,
baseUrl: p.baseUrl,
isActive: p.isActive === 1,
callsPerMonth: p.callsPerMonth ?? null,
auth: {
kind: p.authenticationKind,
required: p.authenticationRequired === 1,
headerName: p.authenticationHeaderName ?? null,
parameterName: p.authenticationParameterName ?? null,
hmac: p.hmac ? {
messageParts: p.hmac.messageParts, keyHeader: p.hmac.keyHeader, signatureHeader: p.hmac.signatureHeader,
timestampHeader: p.hmac.timestampHeader, windowHeader: p.hmac.windowHeader ?? null, windowMs: p.hmac.windowMs ?? null,
hashAlgorithm: p.hmac.hashAlgorithm, encoding: p.hmac.encoding,
} : null,
},
endpoints: [...p.endpoints].sort(byKeys('capability')).map(e => ({
capability: e.capability, method: e.method, pathTemplate: e.pathTemplate, queryTemplate: e.queryTemplate ?? null,
quote: e.quoteCurrency ?? baseCurrency, minIntervalSeconds: e.minIntervalSeconds ?? null,
})),
inputMap: [...p.symbols].sort(byKeys('isCatchAll', 'symbol')).map(s => ({
symbol: s.symbol ?? null, externalId: s.externalId, quoteUnit: s.quoteUnit ?? null, isCatchAll: s.isCatchAll === 1,
})),
outputs: [...p.outputs].sort(byKeys('capability', 'internalField')).map(o => ({
capability: o.capability, internalField: o.internalField, jsonPath: o.jsonPath, transform: o.transform,
})),
coverage: [...p.coverage].sort().map(symbol => ({ symbol, kind: kindOf.get(symbol) ?? null })),
})),
};
}
function fileName() {
const d = new Date();
const p = n => String(n).padStart(2, '0');
return `integraties-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}.json`;
}
export function exportIntegrations(res) {
const providers = listProviders().map(p => getProvider(p.key));
const body = Buffer.from(JSON.stringify(integrationsJson(providers, listAssets(), getBaseCurrency()), null, 2) + '\n', 'utf-8');
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="${fileName()}"`,
'Content-Length': body.length,
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
});
res.end(body);
}
+66
View File
@@ -0,0 +1,66 @@
// Generated, do not edit. sha256:a1315d2d14082c64a18aae0cc7fe7a0348020a2be792c5953b7fb3ab918f8978
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { AUTH_KINDS } from '../shared/config.js';
import { activeDir } from './portfolios.mjs';
import { hasFeature } from './license.mjs';
import { fail } from '../shared/app-error.js';
export function secretsFile() {
return join(activeDir(), 'secrets.json');
}
export async function readSecretsStore() {
try { return JSON.parse(await readFile(secretsFile(), 'utf-8')); }
catch (e) { if (e.code === 'ENOENT') return {}; throw e; }
}
export async function writeSecretsStore(obj) {
const pad = secretsFile();
await mkdir(dirname(pad), { recursive: true });
await writeFile(pad, JSON.stringify(obj, null, 2), { mode: 0o600 });
}
function allowedFields(authKind) {
const k = AUTH_KINDS[authKind];
return k ? k.secret_fields : [];
}
export async function getKeys(provider) {
const all = await readSecretsStore();
return all[provider] || {};
}
export async function moveProviderKeys(from, to) {
const all = await readSecretsStore();
if (!all[from]) return false;
all[to] = { ...all[from], ...(all[to] || {}) };
delete all[from];
await writeSecretsStore(all);
return true;
}
export async function keyStatus(provider, authKind) {
const fields = allowedFields(authKind);
const stored = await getKeys(provider);
const configured = {};
for (const f of fields) configured[f] = String(stored[f] || '').length > 0;
return { provider, fields, configured, complete: fields.length > 0 && fields.every(f => configured[f]) };
}
export async function setKeys(provider, authKind, incoming) {
if (!hasFeature('providerKeys')) {
fail('integrations.keysPaid', null,
'Your own provider keys belong to the paid version; the standard sources keep working.');
}
const fields = allowedFields(authKind);
if (!fields.length) throw new Error(`Provider '${provider}' (auth '${authKind}') has no key fields.`);
const all = await readSecretsStore();
const cur = all[provider] || {};
for (const f of fields) {
const v = incoming && typeof incoming[f] === 'string' ? incoming[f].trim() : '';
if (v.length) cur[f] = v;
}
all[provider] = cur;
await writeSecretsStore(all);
return keyStatus(provider, authKind);
}
+173
View File
@@ -0,0 +1,173 @@
// Generated, do not edit. sha256:0a4af87a128e6b903b5ce63c00bf6f500061dd0feb2e18ca3a3d4791c738eff9
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { schnorr } from '@noble/curves/secp256k1.js';
import { CONFIG } from '../shared/config.js';
import { installDir } from './portfolios.mjs';
import { services } from './registers.mjs';
const L = CONFIG.LICENSE;
const HEX64 = /^[0-9a-f]{64}$/;
const HEX128 = /^[0-9a-f]{128}$/;
const DATE = /^\d{4}-\d{2}-\d{2}$/;
const FIELDS = ['buyer', 'level', 'issued', 'majorFrom', 'majorTo'];
export function licenseMessage(body) {
const lines = FIELDS.map((f) => `${f}=${body[f]}`);
return [L.MESSAGE_PREFIX, ...lines].join('\n');
}
function messageHash(body) {
return createHash('sha256').update(licenseMessage(body), 'utf-8').digest();
}
const ISSUER_FIELDS = ['key', 'validFrom', 'validTo'];
export function issuerMessage(issuer) {
return [L.ISSUER_PREFIX, ...ISSUER_FIELDS.map((f) => `${f}=${issuer[f]}`)].join('\n');
}
const sha256 = (text) => createHash('sha256').update(text, 'utf-8').digest();
const schnorrOk = (sig, hash, key) => {
try {
return schnorr.verify(Buffer.from(sig, 'hex'), hash, Buffer.from(key, 'hex'));
} catch {
return false;
}
};
class LicenseError extends Error {}
function refuse(reason) {
throw new LicenseError(reason);
}
export function parseLicense(text) {
let raw;
try {
raw = JSON.parse(text);
} catch {
refuse('The licence file is not valid JSON.');
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) refuse('The licence file is not an object.');
if (!L.FORMATS.includes(raw.format)) refuse(`The licence has format ${JSON.stringify(raw.format)}, this app reads format ${L.FORMATS.join(' and ')}.`);
if (typeof raw.signature !== 'string' || !HEX128.test(raw.signature)) refuse('The signature is missing or is not 128 hex characters.');
const body = raw.license;
if (!body || typeof body !== 'object' || Array.isArray(body)) refuse('The licence file has no `license` object.');
const extra = Object.keys(body).filter((k) => !FIELDS.includes(k));
if (extra.length) refuse(`The licence carries unknown fields: ${extra.join(', ')}.`);
const missing = FIELDS.filter((f) => !(f in body));
if (missing.length) refuse(`The licence is missing fields: ${missing.join(', ')}.`);
if (typeof body.buyer !== 'string' || !HEX64.test(body.buyer)) refuse('The buyer key is not 64 hex characters.');
if (!L.LEVELS.includes(body.level)) refuse(`Unknown level ${JSON.stringify(body.level)}.`);
if (typeof body.issued !== 'string' || !DATE.test(body.issued)) refuse('The issue date is not a YYYY-MM-DD date.');
if (!Number.isInteger(body.majorFrom) || body.majorFrom < 1) refuse('`majorFrom` is not a major version of 1 or higher.');
const unlimited = body.majorTo === L.UNLIMITED;
if (!unlimited && (!Number.isInteger(body.majorTo) || body.majorTo < body.majorFrom)) {
refuse(`\`majorTo\` is neither '${L.UNLIMITED}' nor a whole number from \`majorFrom\` up.`);
}
if (raw.format === 1) return { format: raw.format, license: body, signature: raw.signature };
const issuer = raw.issuer;
if (!issuer || typeof issuer !== 'object' || Array.isArray(issuer)) refuse('A format 2 licence has no `issuer` object.');
const issuerExtra = Object.keys(issuer).filter((k) => ![...ISSUER_FIELDS, 'signature'].includes(k));
if (issuerExtra.length) refuse(`The issuer carries unknown fields: ${issuerExtra.join(', ')}.`);
if (typeof issuer.key !== 'string' || !HEX64.test(issuer.key)) refuse('The issuer key is not 64 hex characters.');
if (typeof issuer.validFrom !== 'string' || !DATE.test(issuer.validFrom)) refuse('`validFrom` of the issuer is not a YYYY-MM-DD date.');
if (typeof issuer.validTo !== 'string' || !DATE.test(issuer.validTo)) refuse('`validTo` of the issuer is not a YYYY-MM-DD date.');
if (issuer.validTo < issuer.validFrom) refuse('The issuer is valid until before it is valid from.');
if (typeof issuer.signature !== 'string' || !HEX128.test(issuer.signature)) refuse('The signature of the issuer is missing or is not 128 hex characters.');
if (body.issued < issuer.validFrom || body.issued > issuer.validTo) refuse('The licence was issued outside the period of its issuer.');
return { format: raw.format, license: body, signature: raw.signature, issuer };
}
export function verifySignature(parsed, sellerKey = L.SELLER_KEY) {
if (!HEX64.test(sellerKey)) return false;
if (parsed.format === 2) {
if (!schnorrOk(parsed.issuer.signature, sha256(issuerMessage(parsed.issuer)), sellerKey)) return false;
return schnorrOk(parsed.signature, messageHash(parsed.license), parsed.issuer.key);
}
return schnorrOk(parsed.signature, messageHash(parsed.license), sellerKey);
}
export function coversVersion(license, appVersion) {
const m = /^(\d+)\.\d+\.\d+$/.exec(String(appVersion));
if (!m) return false;
const major = Number(m[1]);
if (major < 1) return false;
if (major < license.majorFrom) return false;
return license.majorTo === L.UNLIMITED ? true : major <= license.majorTo;
}
export function appVersion() {
const pad = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
return JSON.parse(readFileSync(pad, 'utf8')).version;
}
export function licenseState(version = appVersion(), dir = undefined) {
const major = Number(/^(\d+)\./.exec(String(version))?.[1]);
if (!Number.isInteger(major)) return { level: 'free', enforced: true, why: 'unknown-version' };
const forced = process.env.ASSETAPP_LICENSE_STATE;
if (forced === 'free') return { level: 'free', enforced: true, why: 'forced' };
const develop = major < 1 && services.get('develop') !== null;
if (forced === 'full' && develop) return { level: 'full', enforced: false, why: 'forced-develop' };
if (develop) return { level: 'full', enforced: false, why: 'develop' };
const uitkomst = dir === undefined ? readLicense() : readLicense(dir);
if (uitkomst.state === 'valid' && coversVersion(uitkomst.license, version)) {
return { level: uitkomst.license.level, enforced: true, why: 'licensed', license: uitkomst.license };
}
return { level: 'free', enforced: true, why: uitkomst.state === 'valid' ? 'out-of-range' : uitkomst.state };
}
export function maxFor(soort, state = licenseState()) {
const tabel = L.FREE.MAX;
if (!(soort in tabel)) throw new Error(`Unknown capacity: ${soort}. Known: ${Object.keys(tabel).join(', ')}.`);
return state.level === 'full' ? null : tabel[soort];
}
export function capacityReached(soort, huidig, state = licenseState()) {
const max = maxFor(soort, state);
if (max === null) return null;
return huidig >= max ? max : null;
}
export function allowedAssets(state = licenseState()) {
return state.level === 'full' ? null : L.FREE.ASSETS;
}
export function hasFeature(naam, state = licenseState()) {
const tabel = L.FREE.HAS;
if (!(naam in tabel)) throw new Error(`Unknown feature: ${naam}. Known: ${Object.keys(tabel).join(', ')}.`);
return state.level === 'full' ? true : tabel[naam];
}
export function readLicense(dir = installDir()) {
let text;
try {
text = readFileSync(join(dir, L.FILE_NAME), 'utf8');
} catch (err) {
if (err.code === 'ENOENT') return { state: 'none' };
return { state: 'unreadable', reason: err.message };
}
try {
const parsed = parseLicense(text);
if (!verifySignature(parsed)) return { state: 'invalid', reason: 'The signature does not belong to this licence.' };
return { state: 'valid', license: parsed.license };
} catch (err) {
if (err instanceof LicenseError) return { state: 'invalid', reason: err.message };
throw err;
}
}
+499
View File
@@ -0,0 +1,499 @@
// Generated, do not edit. sha256:31a0448818ec43a013271278ccfca034b12a6cd78e023ce301c87ce6d267b944
import { randomUUID, createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readSecretsStore, writeSecretsStore } from './keys.mjs';
import { installDir } from './portfolios.mjs';
import { parseLicense, verifySignature } from './license.mjs';
import { recordIntegrationCall, countServiceCall } from './data/integration-log.js';
import { CONFIG } from '../shared/config.js';
import { AppError, fail } from '../shared/app-error.js';
const LEGACY_STORE_KEY = 'market_service';
const RETRY_LATER = new Set([
'marketService.unreachable', 'marketService.timeout', 'marketService.unavailable',
'marketService.monthUnreachable', 'marketService.tooManyRequests',
]);
export const isRetryLater = (e) => !!e && RETRY_LATER.has(e.key);
function normalizeUrl(input) {
const url = String(input || '').trim().replace(/\/+$/, '');
if (!/^https?:\/\/[^/\s]+/i.test(url)) {
fail('marketService.urlScheme', null, 'The address of the service has to start with http:// or https://.');
}
return url;
}
const settingsFile = () => join(installDir(), CONFIG.MARKET_SERVICE.SETTINGS_FILE_NAME);
async function writeSettings(settings) {
await mkdir(installDir(), { recursive: true });
await writeFile(settingsFile(), `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
}
async function readSettings() {
let own = null;
try { own = JSON.parse(await readFile(settingsFile(), 'utf8')); }
catch (e) { if (e.code !== 'ENOENT') throw e; }
const secrets = await readSecretsStore();
const old = secrets[LEGACY_STORE_KEY];
if (old) {
if (!own) {
own = old.token ? { token: old.token } : {};
await writeSettings(own);
}
delete secrets[LEGACY_STORE_KEY];
await writeSecretsStore(secrets);
}
return own ?? {};
}
function serviceUrl() {
const override = process.env.ASSETAPP_SERVICE_URL;
return override ? normalizeUrl(override) : CONFIG.MARKET_SERVICE.URL;
}
const FREE_LICENSE = join(dirname(fileURLToPath(import.meta.url)), CONFIG.LICENSE.FREE_FILE_NAME);
function registrationLicense() {
for (const file of [join(installDir(), CONFIG.LICENSE.FILE_NAME), FREE_LICENSE]) {
let text;
try { text = readFileSync(file, 'utf8'); } catch { continue; }
try { if (verifySignature(parseLicense(text))) return text; } catch { }
}
return null;
}
const hashOf = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
export async function marketServiceStatus() {
const cur = await readSettings();
return {
url: serviceUrl(),
override: !!process.env.ASSETAPP_SERVICE_URL,
token_set: !!cur.token,
token_source: !cur.token ? null : cur.licenseHash ? 'license' : 'manual',
tier: cur.tier ?? null,
can_register: registrationLicense() !== null,
};
}
const SUPPORT_REFUSALS = {
'support.empty': 'marketService.supportEmpty',
'support.tooLong': 'marketService.supportTooLong',
'support.attachmentEmpty': 'marketService.supportAttachmentType',
'support.attachmentType': 'marketService.supportAttachmentType',
'support.attachmentTooLarge': 'marketService.supportAttachmentTooLarge',
'support.tooMany': 'marketService.supportTooMany',
};
const SUPPORT_PARAMS = {
'marketService.supportTooLong': { max: CONFIG.LICENSE.SUPPORT.MAX_TEXT },
'marketService.supportAttachmentTooLarge': { mb: CONFIG.LICENSE.SUPPORT.MAX_ATTACHMENT_BYTES / 1024 / 1024 },
};
function serviceError(status, json, headers) {
const key = json && typeof json.key === 'string' ? json.key : null;
const reason = (json && typeof json.error === 'string' && json.error) || `HTTP ${status}`;
if (status === 401 && key === 'api.badAuth') {
return new AppError('marketService.signatureRefused', { reason }, `The service refuses the signature: ${reason}`);
}
if (status === 401) {
return new AppError('marketService.unauthorized', null, 'The service refuses the token: it is missing, wrong or revoked.');
}
if (status === 503 && key === 'api.salesClosed') {
return new AppError('marketService.salesClosed', null, 'Nothing is for sale right now.');
}
if (status === 503 && key === 'api.checkoutUnavailable') {
return new AppError('marketService.checkoutUnavailable', null, 'The checkout of the service cannot make an invoice now; try again later.');
}
if (status === 429 && key === 'api.tooManyOrders') {
return new AppError('marketService.tooManyOrders', { reason }, `The service takes no more orders for this key now: ${reason}`);
}
if (status === 403 && key === 'api.licenseRevoked') {
return new AppError('marketService.licenseRevoked', null, 'The service has revoked this licence and gives it no token.');
}
if (status === 400 && key === 'api.badLicense') {
return new AppError('marketService.badLicense', { reason }, `The service does not accept this licence: ${reason}`);
}
if (status === 404 && key === 'api.unknownOrder') {
return new AppError('marketService.unknownOrder', null, 'The service does not know this order (any more).');
}
const support = SUPPORT_REFUSALS[key];
if (support) return new AppError(support, SUPPORT_PARAMS[support] ?? null, `The service refuses the message: ${reason}`);
if (status === 404 && key === 'api.unknownAttachment') {
return new AppError('marketService.unknownAttachment', null, 'The service does not know this screenshot.');
}
if (status === 429) {
const s = Number(headers?.get?.('retry-after')) || 60;
return new AppError('marketService.tooManyRequests', { s }, `Too many requests to the service; try again in ${s}s.`);
}
if (status === 503 && key === 'api.monthUnreachable') {
const month = (json && json.month) || '?';
return new AppError('marketService.monthUnreachable', { month },
`The service cannot read month ${month} right now; try again later.`);
}
if (status === 503) {
return new AppError('marketService.unavailable', null, 'The service is not ready (it may be starting); try again later.');
}
return new AppError('marketService.rejected', { status, key: key || '-', reason },
`The service refused the request (HTTP ${status}, ${key || 'no key'}): ${reason}`);
}
function describeBody(res, text) {
const type = String(res.headers?.get?.('content-type') || 'no type').split(';')[0].trim();
return `no JSON (${type}, ${Buffer.byteLength(text || '', 'utf8')} bytes)`;
}
const appJournal = {
failure: (entry, secrets) => recordIntegrationCall(entry, { secrets }),
count: (c) => countServiceCall(c),
};
const COUNTED = { '/v1/spot': 'spot', '/v1/series': 'daily_history' };
export function createMarketClient({
fetch: doFetch = (...a) => globalThis.fetch(...a),
config = readConfig,
renewOn401 = false,
timeoutMs = CONFIG.MARKET_SERVICE.REQUEST_TIMEOUT_MS,
now = Date.now,
journal = appJournal,
requestId: newRequestId = randomUUID,
} = {}) {
async function note(fn) {
if (!journal) return;
try { await fn(); } catch { }
}
const subjectOf = (params) => (Array.isArray(params?.instruments) ? String(params.instruments.length)
: typeof params?.instrument === 'string' ? params.instrument : null);
async function exchange(path, params = null, opts = {}) {
try {
return await exchangeOnce(path, params, opts, false);
} catch (e) {
const tokenCall = (opts.auth ?? true) && !opts.signature;
if (!(renewOn401 && tokenCall && e?.key === 'marketService.unauthorized')) throw e;
return exchangeOnce(path, params, opts, true);
}
}
async function exchangeOnce(path, params, { auth = true, signature = null, rawBody = null } = {}, renew) {
if (signature) auth = false;
const cfg = await config({ needToken: auth, renew });
const id = newRequestId();
const entry = { target: 'service', operation: path, subject: subjectOf(params), requestId: id, occurredAt: now() };
if (auth && !cfg.token) {
await note(() => journal.failure({ ...entry, outcome: 'refused', errorKey: 'marketService.noToken', requestId: null }, []));
fail('marketService.noToken', null, 'No token set for the market data service.');
}
const u = new URL(cfg.url + path);
const body = Object.fromEntries(Object.entries(params || {}).filter(([, v]) => v !== null && v !== undefined));
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
const t0 = performance.now();
let status = null;
let detail = null;
try {
let res;
try {
res = await doFetch(u.toString(), auth
? {
method: 'POST',
headers: {
Authorization: `Bearer ${cfg.token}`, Accept: 'application/json', 'Content-Type': 'application/json',
'X-Request-Id': id,
},
body: JSON.stringify(body),
signal: ctl.signal,
}
: {
method: rawBody !== null ? 'POST' : 'GET',
headers: {
Accept: 'application/json', 'X-Request-Id': id,
...(signature ? { Authorization: signature } : {}),
...(rawBody !== null ? { 'Content-Type': 'application/json' } : {}),
},
...(rawBody !== null ? { body: rawBody } : {}),
signal: ctl.signal,
});
} catch (e) {
if (ctl.signal.aborted) {
const s = timeoutMs / 1000;
throw new AppError('marketService.timeout', { s }, `The service did not answer within ${s}s.`);
}
const reason = e?.cause?.code || e?.cause?.message || e?.message || String(e);
detail = reason;
throw new AppError('marketService.unreachable', { url: cfg.url, reason },
`The service at ${cfg.url} is unreachable: ${reason}.`);
}
status = res.status;
const text = await res.text().catch(() => '');
let json = null;
try { json = JSON.parse(text); } catch { detail = describeBody(res, text); }
const back = res.headers?.get?.('x-request-id');
if (back && back !== id) detail = [detail, `service request id ${back}`].filter(Boolean).join('; ');
if (!res.ok) {
if (json && typeof json.error === 'string') detail = [json.error, detail].filter(Boolean).join('; ');
throw serviceError(res.status, json, res.headers);
}
if (!json || typeof json !== 'object') {
detail ??= 'not an object';
fail('marketService.badAnswer', { url: cfg.url }, `The answer from ${cfg.url} is not the API of the service.`);
}
if (COUNTED[path]) await note(() => journal.count({ capability: COUNTED[path], ok: true, latencyMs: performance.now() - t0 }));
return { json, requestId: id };
} catch (e) {
e.requestId ??= id;
const latencyMs = performance.now() - t0;
const serviceKey = e?.params?.key && e.params.key !== '-' ? ` ${e.params.key}` : '';
await note(() => journal.failure({
...entry, outcome: 'fail', httpStatus: status, errorKey: `${e?.key || 'unknown'}${serviceKey}`,
detail: detail ?? e?.params?.reason ?? null, latencyMs,
}, [cfg.token, signature].filter(Boolean)));
if (COUNTED[path]) await note(() => journal.count({ capability: COUNTED[path], ok: false, latencyMs }));
throw e;
} finally {
clearTimeout(timer);
}
}
const request = async (...a) => (await exchange(...a)).json;
const noteEmpty = (path, params, requestId) => note(() => journal.failure({
target: 'service', operation: path, subject: subjectOf(params), outcome: 'empty', httpStatus: 200,
requestId, occurredAt: now(),
}, []));
async function instruments() {
const json = await request('/v1/instruments');
if (!Array.isArray(json.instruments)) {
fail('marketService.badAnswer', { url: (await config()).url }, 'The register in the answer is not a list.');
}
return json.instruments;
}
async function testConnection() {
const { url } = await config();
const health = await request('/v1/health', null, { auth: false });
if (health.ok !== true || health.app !== CONFIG.MARKET_SERVICE.APP_NAME) {
fail('marketService.badAnswer', { url }, `The answer from ${url} is not the API of the service.`);
}
const list = await instruments();
return { url, app: health.app, version: health.version || null, instruments: list.length };
}
let cached = null;
async function register() {
const { url } = await config();
if (cached && cached.url === url && now() - cached.at < CONFIG.MARKET_SERVICE.REGISTER_TTL_MS) return cached.list;
const list = await instruments();
cached = { url, at: now(), list };
return list;
}
async function series({ instrument, quote, resolution = 'minute', from, to }) {
const points = [];
let meta = null;
let cursor = from;
let rid = null;
for (let page = 0; page < CONFIG.MARKET_SERVICE.MAX_SERIES_PAGES; page++) {
const { json, requestId } = await exchange('/v1/series', { instrument, quote, resolution, from: cursor, to });
rid = requestId;
const cols = Array.isArray(json.columns) ? json.columns : [];
const ti = cols.indexOf('t');
const pi = cols.includes('price') ? cols.indexOf('price') : cols.indexOf('close');
const si = cols.indexOf('source');
if (ti < 0 || pi < 0 || !Array.isArray(json.rows) || typeof json.currency !== 'string' || !json.currency) {
fail('marketService.badAnswer', { url: (await config()).url }, 'The series in the answer has no time, price or currency.');
}
if (meta && json.currency !== meta.currency) {
fail('marketService.badAnswer', { url: (await config()).url }, 'The currency changed between two pages of one series.');
}
meta ??= { instrument, currency: json.currency, source: json.source || null, sourceLabel: json.sourceLabel || null,
resolution: json.resolution || resolution, sourceLabels: {} };
if (json.sourceLabels && typeof json.sourceLabels === 'object') {
for (const [key, label] of Object.entries(json.sourceLabels)) if (typeof label === 'string' && label) meta.sourceLabels[key] = label;
}
for (const r of json.rows) {
const t = Number(r?.[ti]);
const p = Number(r?.[pi]);
if (!(Number.isFinite(t) && t > 0 && Number.isFinite(p) && p > 0)) continue;
const src = si >= 0 && typeof r[si] === 'string' && r[si] ? r[si] : null;
points.push(src ? [t, p, src] : [t, p]);
}
if (json.next == null || !(Number(json.next) > cursor)) break;
cursor = Number(json.next);
}
if (!points.length) await noteEmpty('/v1/series', { instrument }, rid);
return { ...meta, points };
}
async function history({ instrument, quote, from, to }) {
const points = [];
let meta = null;
let cursor = from;
let rid = null;
for (let page = 0; page < CONFIG.MARKET_SERVICE.MAX_SERIES_PAGES; page++) {
const { json, requestId } = await exchange('/v1/series', { instrument, quote, kind: 'history', from: cursor, to });
rid = requestId;
const cols = Array.isArray(json.columns) ? json.columns : [];
const [ti, pi, ri] = ['t', 'price', 'resolution'].map(c => cols.indexOf(c));
if (ti < 0 || pi < 0 || ri < 0 || !Array.isArray(json.rows) || typeof json.currency !== 'string' || !json.currency) {
fail('marketService.badAnswer', { url: (await config()).url }, 'The history in the answer has no time, price, resolution or currency.');
}
if (meta && json.currency !== meta.currency) {
fail('marketService.badAnswer', { url: (await config()).url }, 'The currency changed between two pages of one series.');
}
meta ??= { instrument, currency: json.currency, source: json.source || null };
for (const r of json.rows) {
const t = Number(r?.[ti]);
const p = Number(r?.[pi]);
if (Number.isFinite(t) && t > 0 && Number.isFinite(p) && p > 0) points.push([t, p, r[ri]]);
}
if (json.next == null || !(Number(json.next) > cursor)) break;
cursor = Number(json.next);
}
if (!points.length) await noteEmpty('/v1/series', { instrument }, rid);
return { ...meta, points };
}
let spotCache = null;
async function spots(keys, quote) {
const { url } = await config();
const pakket = [...new Set(keys)].sort();
const sleutel = `${url}|${quote}|${pakket.join(',')}`;
if (spotCache && spotCache.sleutel === sleutel && now() - spotCache.at < CONFIG.MARKET_SERVICE.SPOT_TTL_MS) {
return spotCache.result;
}
const result = { spot: [], missing: [], unreachable: [] };
const max = CONFIG.MARKET_SERVICE.MAX_INSTRUMENTS_PER_SPOT;
let rid = null;
for (let i = 0; i < pakket.length; i += max) {
const { json, requestId } = await exchange('/v1/spot', { instruments: pakket.slice(i, i + max), quote });
rid = requestId;
if (!Array.isArray(json.spot)) {
fail('marketService.badAnswer', { url }, 'The spot answer has no list of prices.');
}
for (const s of json.spot) {
const price = Number(s?.price);
if (typeof s?.instrument !== 'string' || !Number.isFinite(price) || price <= 0 || typeof s.currency !== 'string' || !s.currency) continue;
result.spot.push({
instrument: s.instrument, price, currency: s.currency,
observedAt: Number(s.observedAt) || null, source: s.source || null, sourceLabel: s.sourceLabel || null,
});
}
if (Array.isArray(json.missing)) result.missing.push(...json.missing);
if (Array.isArray(json.unreachable)) result.unreachable.push(...json.unreachable);
}
if (pakket.length && !result.spot.length) await noteEmpty('/v1/spot', { instruments: pakket }, rid);
spotCache = { sleutel, at: now(), result };
return result;
}
async function buyerLicenses(signature) {
const json = await request('/v1/license', null, { signature });
if (!Array.isArray(json.licenses)) {
fail('marketService.badAnswer', { url: (await config()).url }, 'The licences in the answer are not a list.');
}
return json.licenses;
}
async function sale() {
const json = await request('/v1/sale', null, { auth: false });
if (typeof json.open !== 'boolean') fail('marketService.badAnswer', { url: (await config()).url }, 'The sale in the answer has no open state.');
return { open: json.open, priceSat: Number.isInteger(json.priceSat) ? json.priceSat : null, major: json.major ?? null };
}
async function placeOrder(signature, rawBody) {
const json = await request('/v1/orders', null, { signature, rawBody });
if (!json.order || typeof json.order.id !== 'string') fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no order.');
return { order: json.order, readKey: typeof json.readKey === 'string' ? json.readKey : null };
}
async function orderStatus(orderId, readKey) {
const json = await request(`/v1/orders/${orderId}`, null, { signature: `Order ${readKey}` });
if (!json.order) fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no order.');
return { order: json.order, license: json.license ?? null };
}
async function supportMessages(signature) {
const json = await request('/v1/support/messages', null, { signature });
if (!Array.isArray(json.messages)) fail('marketService.badAnswer', { url: (await config()).url }, 'The messages in the answer are not a list.');
return json.messages;
}
async function supportSend(signature, rawBody) {
const json = await request('/v1/support/messages', null, { signature, rawBody });
if (!json.message || typeof json.message !== 'object') fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no message.');
return json.message;
}
async function supportAttachment(signature, id) {
const json = await request(`/v1/support/attachments/${id}`, null, { signature });
if (typeof json.mime !== 'string' || typeof json.data !== 'string') {
fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no screenshot.');
}
return { mime: json.mime, data: json.data };
}
async function registerLicense(text) {
const json = await request('/v1/license/register', null, { auth: false, rawBody: text });
if (typeof json.token !== 'string' || !json.token) fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no token.');
return { token: json.token, tier: typeof json.tier === 'string' ? json.tier : null };
}
return {
instruments, register, series, history, spots, testConnection, buyerLicenses, sale, placeOrder, orderStatus,
supportMessages, supportSend, supportAttachment, registerLicense,
};
}
let registering = null;
async function tokenFor({ renew = false } = {}) {
const cur = await readSettings();
const license = registrationLicense();
const hash = license ? hashOf(license) : null;
if (cur.token && !renew && (!cur.licenseHash || !license || cur.licenseHash === hash)) return cur.token;
if (!license) return renew ? '' : (cur.token || '');
registering ??= (async () => {
try {
const { token, tier } = await appClient.registerLicense(license);
await writeSettings({ token, tier, licenseHash: hash });
return token;
} finally {
registering = null;
}
})();
return registering;
}
async function readConfig({ needToken = false, renew = false } = {}) {
return { url: serviceUrl(), token: needToken ? await tokenFor({ renew }) : '' };
}
const appClient = createMarketClient({ renewOn401: true });
export const testMarketService = () => appClient.testConnection();
export const marketRegister = () => appClient.register();
export const marketSeries = (opts) => appClient.series(opts);
export const marketHistory = (opts) => appClient.history(opts);
export const marketSpots = (keys, quote) => appClient.spots(keys, quote);
export const marketBuyerLicenses = (signature) => appClient.buyerLicenses(signature);
export const marketSale = () => appClient.sale();
export const marketPlaceOrder = (signature, rawBody) => appClient.placeOrder(signature, rawBody);
export const marketOrderStatus = (orderId, readKey) => appClient.orderStatus(orderId, readKey);
export const marketSupportMessages = (signature) => appClient.supportMessages(signature);
export const marketSupportSend = (signature, rawBody) => appClient.supportSend(signature, rawBody);
export const marketSupportAttachment = (signature, id) => appClient.supportAttachment(signature, id);
export const marketServiceUrl = async () => (await readConfig()).url;
export const marketConfigured = async () => {
const st = await marketServiceStatus();
return st.token_set || st.can_register;
};
+19
View File
@@ -0,0 +1,19 @@
// Generated, do not edit. sha256:35230db92740b73c0985590b9f5ef4f49deba0d1a3c63e124e62b21c8b219a63
import { fileURLToPath } from 'node:url';
import { CONFIG } from '../../shared/config.js';
const STDIO_PAD = fileURLToPath(new URL('./stdio.mjs', import.meta.url));
export function mcpClientConfig() {
const document = {
mcpServers: {
[CONFIG.AI.SERVER_NAME]: { command: 'node', args: [STDIO_PAD] },
},
};
return {
serverKey: CONFIG.AI.SERVER_NAME,
path: STDIO_PAD,
snippet: JSON.stringify(document, null, 2),
};
}
+106
View File
@@ -0,0 +1,106 @@
// Generated, do not edit. sha256:588c505552ce949b88f55a05209c95d02e3be8d37929538a9fb8ef9f58643cd2
import { CONFIG, TARGETS, targetHash } from '../../shared/config.js';
import { placeLabel } from '../../shared/places.js';
import { installLocale } from '../installation.mjs';
import { assertScope } from '../ai/gate.js';
import { createCommand, getCommand } from '../data/commands.js';
import { flushChanges } from '../data/bus.js';
const DOELEN = Object.keys(TARGETS);
const doelenTekst = () => DOELEN
.map(naam => `${naam} - ${placeLabel(naam, installLocale())} (${targetHash(TARGETS[naam])})`).join(', ');
const KIJK_INTERVAL_MS = 400;
const wacht = (ms) => new Promise(r => setTimeout(r, ms));
async function wachtOpUitkomst(id, seconden) {
const einde = Date.now() + seconden * 1000;
let c = getCommand(id);
while (c && c.resolvedAt == null && Date.now() < einde) {
await wacht(KIJK_INTERVAL_MS);
c = getCommand(id);
}
return c;
}
async function stuurOpdracht(kind, payload = null) {
const verloopt = new Date(Date.now() + CONFIG.COMMANDS.CLAIM_SECONDS * 1000).toISOString();
const id = await createCommand({ kind, payload, expiresAt: verloopt });
flushChanges();
return wachtOpUitkomst(id, CONFIG.COMMANDS.CLAIM_SECONDS);
}
const NIET_OPEN =
'De app lijkt niet open te staan. Vraag de gebruiker of hij AssetApp opent en probeer het daarna '
+ 'opnieuw.';
const navigeerNaar = Object.freeze({
name: 'navigate_to',
title: 'Naar een plek in de app gaan, of zien waar de gebruiker is',
description:
'Brengt de gebruiker naar een plek in AssetApp; zonder target zegt hij waar de gebruiker nu kijkt, '
+ 'met de naam van die plek. Een sprong ziet de gebruiker aankomen en kan hij tegenhouden. Leest en '
+ 'wijzigt de administratie niet. Staat de app niet open, dan zegt het antwoord dat.',
inputSchema: Object.freeze({
type: 'object',
properties: {
target: {
type: 'string',
enum: DOELEN,
description: 'De plek. Weglaten om te horen waar de gebruiker is.',
},
},
additionalProperties: false,
}),
scope: 'control',
async run(args) {
assertScope('control');
if (!args || args.target === undefined) return waarKijktDeGebruiker();
const naam = typeof args.target === 'string' ? args.target : '';
if (!Object.hasOwn(TARGETS, naam)) {
return {
text: `'${naam}' is geen bekende plek. Kies er een uit: ${doelenTekst()}.`,
fieldsShared: [],
};
}
const doel = TARGETS[naam];
const uit = await stuurOpdracht('navigate',
{ route: doel.route, tab: doel.tab, sub: doel.sub, hash: targetHash(doel) });
const stand = uit ? uit.state : 'expired';
if (stand === 'done') {
return { text: `Gedaan. De gebruiker kijkt nu naar ${targetHash(doel)}.`, fieldsShared: [] };
}
if (stand === 'rejected') {
return {
text: `Niet uitgevoerd: ${uit.detail || 'de opdracht is afgewezen.'} Er is niet genavigeerd.`,
fieldsShared: [],
};
}
return { text: `Er is niet genavigeerd. ${NIET_OPEN}`, fieldsShared: [] };
},
});
const NAAM_PER_HASH = new Map(DOELEN.map(naam => [targetHash(TARGETS[naam]), naam]));
async function waarKijktDeGebruiker() {
const uit = await stuurOpdracht('locate');
if (!uit || uit.state !== 'done') return { text: NIET_OPEN, fieldsShared: [] };
const hash = typeof uit.detail === 'string' ? uit.detail : '';
const naam = NAAM_PER_HASH.get(hash);
if (naam) return { text: `De gebruiker kijkt naar ${naam}: ${placeLabel(naam, installLocale())} (${hash}).`, fieldsShared: [] };
return {
text: hash
? `De gebruiker kijkt naar ${hash}. Dat is geen van de plekken die navigate_to kent.`
: 'De app staat open, maar liet niet weten waar de gebruiker kijkt.',
fieldsShared: [],
};
}
export const CONTROL_TOOLS = Object.freeze([navigeerNaar]);
+179
View File
@@ -0,0 +1,179 @@
// Generated, do not edit. sha256:c8d6a3abd48730edcf0689606e46d90e911be6fb20a947fc1829093ad98e9800
import { listAccounts } from '../data/accounts.js';
import { listAssets } from '../data/assets.js';
import { listLabels } from '../data/labels.js';
import { accountStructure, assetStructure, labelStructure, eventRefs } from '../data/structure.js';
import { rowOrigin } from '../data/ai-proposals.js';
import { assertScope } from '../ai/gate.js';
import { limitedList } from '../ai/limits.js';
import { vertalerVoor } from './session.mjs';
import { HANDLE_VORM, idVoorHandle } from './handles.mjs';
function assetVoorSymbool(gevraagd) {
const gezocht = String(gevraagd ?? '').trim().toUpperCase();
if (!gezocht || gezocht.includes(':')) return null;
return listAssets().find(a => String(a.symbol).toUpperCase() === gezocht) || null;
}
const SOORTEN = Object.freeze({
account: Object.freeze({ ids: () => listAccounts().map(r => r.id), structuur: accountStructure }),
asset: Object.freeze({ ids: () => listAssets().map(r => r.id), structuur: assetStructure }),
label: Object.freeze({ ids: () => listLabels().map(r => r.id), structuur: labelStructure }),
});
const SOORT_NAMEN = Object.keys(SOORTEN);
const telRegel = (aantal, enkelvoud, meervoud) =>
`${aantal} ${aantal === 1 ? enkelvoud : meervoud}`;
const TELLING_TEKST = Object.freeze({
events: n => telRegel(n, 'gebeurtenis', 'gebeurtenissen'),
assets: n => telRegel(n, 'asset waarin geboekt is', 'assets waarin geboekt is'),
accounts: n => telRegel(n, 'account waarop hij voorkomt', 'accounts waarop hij voorkomt'),
walletSources: n => telRegel(n, 'wallet-bron', 'wallet-bronnen'),
addresses: n => telRegel(n, 'adres', 'adressen'),
walletTransactions: n => telRegel(n, 'on-chain beweging', 'on-chain bewegingen'),
valuations: n => telRegel(n, 'balans', 'balansen'),
pricePoints: n => telRegel(n, 'koerspunt', 'koerspunten'),
products: n => telRegel(n, 'metaalproduct', 'metaalproducten'),
coverage: n => telRegel(n, 'koersbron die dit symbool zegt te dekken', 'koersbronnen die dit symbool zeggen te dekken'),
});
function rijRegels(veilig) {
return Object.entries(veilig)
.filter(([naam]) => naam !== 'id')
.map(([naam, waarde]) => ` ${naam} = ${waarde === null ? '(leeg)' : waarde}`);
}
const recordDetail = Object.freeze({
name: 'record_detail',
title: 'Eén rij uitdiepen',
description:
'Geeft van één rij uit de administratie van AssetApp terug waar hij aan vasthangt: zijn eigen '
+ 'kenmerken en hoeveel gebeurtenissen, adressen, balansen en koerspunten eraan hangen. Bedoeld als '
+ 'vervolgvraag op een verwijzing die admin_overview of data_health heeft gegeven. Werkt op een '
+ 'account (account:...) of een label (label:...), ook als die verwijzing uit een eerder gesprek '
+ 'komt, en op een asset bij zijn SYMBOOL (BTC). Bij die twee ook de gebeurtenissen met hun '
+ 'verwijzing, soort en maand. Alleen kenmerken, aantallen en verwijzingen; geen bedragen, geen '
+ 'exacte datums en geen adressen, en wijzigt niets.',
inputSchema: Object.freeze({
type: 'object',
properties: {
handle: {
type: 'string',
maxLength: 64,
description:
'De verwijzing zoals een andere tool hem gaf (account:7f3a9c, label:9e8f7a), of het symbool '
+ 'van een asset (BTC).',
},
},
required: ['handle'],
additionalProperties: false,
}),
scope: 'read',
async run(args, context) {
assertScope(recordDetail.scope);
const t = await vertalerVoor(context);
t.beginCall();
const gevraagd = typeof args?.handle === 'string' ? args.handle.trim() : '';
const symbool = assetVoorSymbool(gevraagd);
if (symbool) return detailVan('asset', symbool.id, gevraagd, t);
const vorm = HANDLE_VORM.exec(gevraagd);
if (!vorm) {
return {
text: `'${gevraagd}' is geen verwijzing zoals deze app ze uitdeelt en geen asset-symbool. `
+ `Verwijzingen zien eruit als account:7f3a9c: een soort, een dubbele punt en zes tekens. `
+ `Staat onbewerkt delen aan, dan is het de soort met het rij-id erachter, zoals account:3. `
+ `Een asset wijs je aan met zijn symbool, zoals BTC.`,
fieldsShared: [],
};
}
const soort = vorm[1];
if (!Object.hasOwn(SOORTEN, soort)) {
return {
text: `Een verwijzing van de soort '${soort}' kan deze tool niet uitdiepen. Dat kan voor: `
+ `${SOORT_NAMEN.join(', ')}.`,
fieldsShared: [],
};
}
const kandidaten = SOORTEN[soort].ids();
const id = idVoorHandle(t, soort, gevraagd, kandidaten) ?? idVoorHandle(t, soort, vorm[2], kandidaten);
if (id === null) {
return {
text: `De verwijzing ${gevraagd} hoort bij geen enkele ${soort} in deze administratie. `
+ `Mogelijk is de rij verwijderd.`,
fieldsShared: [],
};
}
return detailVan(soort, id, gevraagd, t);
},
});
function detailVan(soort, id, gevraagd, t) {
{
const structuur = SOORTEN[soort].structuur(id);
if (!structuur) {
return { text: `De ${soort} achter ${gevraagd} bestaat niet meer.`, fieldsShared: [] };
}
const veilig = t.toAi(structuur.row, { entity: soort });
const tellingen = limitedList(
Object.entries(structuur.counts),
([naam, n]) => ` ${TELLING_TEKST[naam] ? TELLING_TEKST[naam](n) : `${naam} = ${n}`}`,
).lines;
const verwijzingen = Object.entries(structuur.refs)
.filter(([, ids]) => Array.isArray(ids) && ids.length)
.map(([naam, ids]) => {
const soortVanRef = naam === 'assetIds' ? 'asset' : 'account';
const { lines } = limitedList(ids, refId => ` ${t.toAi({ id: refId }, { entity: soortVanRef }).id}`);
return [`${naam === 'assetIds' ? 'Assets' : 'Accounts'}:`, ...lines].join('\n');
});
const gebeurtenissen = (soort === 'account' || soort === 'asset')
? limitedList(
eventRefs(soort === 'account' ? { accountId: id } : { assetId: id }),
(e) => {
const uit = rowOrigin('event', e.id);
const bron = uit ? ` · uit ${t.toAi({ id: uit.proposalId }, { entity: 'proposal' }).id}` : '';
return ` ${t.toAi({ id: e.id }, { entity: 'event' }).id} · ${e.type} · ${e.month}${bron}`;
},
).lines
: [];
const herkomst = soort === 'account' ? rowOrigin('account', id) : null;
const herkomstRegels = herkomst
? ['', `Ontstaan uit ${t.toAi({ id: herkomst.proposalId }, { entity: 'proposal' }).id}, `
+ `goedgekeurd op ${String(herkomst.resolvedAt || '').slice(0, 10)}.`]
: [];
return {
text: [
`${soort} ${gevraagd}:`,
...rijRegels(veilig),
'',
'Hangt eraan:',
...tellingen,
...herkomstRegels,
...(gebeurtenissen.length ? ['', 'Gebeurtenissen (nieuwste eerst, maand):', ...gebeurtenissen] : []),
...(verwijzingen.length ? ['', ...verwijzingen] : []),
].join('\n'),
fieldsShared: [...new Set([
...t.fieldsShared(),
'assetId', 'fromAccountId', 'toAccountId', 'accountId', 'labelId', 'providerId',
...(gebeurtenissen.length ? ['type', 'occurredAt'] : []),
...(gebeurtenissen.some(r => r.includes(' · uit ')) ? ['createdEventId'] : []),
...(herkomst ? ['createdAccountId'] : []),
])].sort(),
};
}
}
export const DETAIL_TOOLS = Object.freeze([recordDetail]);
+33
View File
@@ -0,0 +1,33 @@
// Generated, do not edit. sha256:a80e6dd97d8d48819bc999d4057ab86d1dddc182a1d9038097a88796139bb3df
import { logAiToolCall } from '../data/ai-audit.js';
import { createMcpServer } from './protocol.mjs';
import { allTools } from './tools.mjs';
let server = null;
function ensureServer() {
if (!server) {
server = createMcpServer({
tools: allTools(),
logCall: logAiToolCall,
onLogError: (e) => console.error('[assetapp] een MCP-aanroep is NIET vastgelegd:', e.message),
});
}
return server;
}
export function dryRunTools() {
return allTools().map(t => ({
name: t.name,
title: t.title,
description: t.description,
scope: t.scope,
inputSchema: t.inputSchema,
}));
}
export function dryRunTool(naam, args) {
return ensureServer().dryRun(naam, args);
}
export function _resetDryRunServer() { server = null; }
+12
View File
@@ -0,0 +1,12 @@
// Generated, do not edit. sha256:934f10111106ef1ed676c3715e4aeefd364cfa02688bedd903fe7320f775ae01
export const HANDLE_VORM = /^([a-zA-Z]+):([0-9a-f]{6}|[1-9][0-9]*)$/;
export function idVoorHandle(t, soort, gevraagd, ids) {
const gezocht = String(gevraagd ?? '').trim();
if (!gezocht) return null;
for (const id of ids) {
const uit = String(t.toAi({ id }, { entity: soort }).id);
if (uit === gezocht || `${soort}:${uit}` === gezocht) return id;
}
return null;
}
+35
View File
@@ -0,0 +1,35 @@
// Generated, do not edit. sha256:8dc9633b14245eff1f7c9bee179cd3a162123ee1e24f229791775df14dfa3934
import { TARGETS, targetHash } from '../../shared/config.js';
import { placeActions } from '../../shared/places.js';
import { installLocale } from '../installation.mjs';
import { assertScope } from '../ai/gate.js';
const regels = () => {
const acties = placeActions(installLocale());
return Object.keys(TARGETS)
.map(naam => `- ${naam} (${targetHash(TARGETS[naam])}): ${acties[naam] || '(geen beschrijving)'}`)
.join('\n');
};
const appPlaces = Object.freeze({
name: 'app_places',
title: 'Wat u waar in de app kunt doen',
description:
'Geeft de lijst van plekken in AssetApp met per plek wat de gebruiker daar kan doen: instellen, '
+ 'boeken, beoordelen of alleen bekijken. Bedoeld om te kunnen antwoorden op vragen als "waar vind ik '
+ 'dit" of "waar stel ik dat in", en om daarna de juiste plek te kiezen: de namen in het antwoord zijn '
+ 'dezelfde die navigate_to als plek aanvaardt. Leest de administratie niet en wijzigt niets; het '
+ 'antwoord is voor elke installatie gelijk.',
inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }),
scope: 'none',
async run() {
assertScope(appPlaces.scope);
return {
text: `De plekken in AssetApp en wat u er kunt doen:\n${regels()}`,
fieldsShared: [],
};
},
});
export const PLACES_TOOLS = Object.freeze([appPlaces]);
+217
View File
@@ -0,0 +1,217 @@
// Generated, do not edit. sha256:d62e710a5bda41c8a7b1a7ff304738dfd9331056f2936a826970a6092995f68b
import { CONFIG, SOURCE_CAPABILITIES } from '../../shared/config.js';
import { listAssets } from '../data/assets.js';
import { listPriceHistory, listPriceGaps, listIgnoredPriceGaps } from '../data/price-history.js';
import { listProviders, getProvider } from '../data/providers.js';
import { listPriceSource } from '../data/price-source.js';
import { apiMetricsTotals } from '../data/api-metrics.js';
import { dailyProvenance } from '../../shared/price-provenance.js';
import { diagnoseSources } from '../../shared/source-diagnosis.js';
import { serviceCoveredAssets } from './service-cover.mjs';
import { priceFromBase, priceUnitLabel } from '../../shared/units.js';
import { assertScope } from '../ai/gate.js';
import { limitedList } from '../ai/limits.js';
import { vertalerVoor } from './session.mjs';
import { getBaseCurrency } from '../data/portfolio.js';
const DAG = /^\d{4}-\d{2}-\d{2}$/;
const DAG_MS = 86400000;
const plusDagen = (dag, n) => new Date(Date.parse(`${dag}T00:00:00Z`) + n * DAG_MS).toISOString().slice(0, 10);
const dagenTussen = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / DAG_MS);
const STANDAARD_DAGEN = 14;
const MAX_DAGEN = 92;
const SOORT_TEKST = Object.freeze({
close: 'slotkoers', manual: 'met de hand', live: 'vandaag, beweegt nog', spot: 'afgeleid uit een spot',
carried: 'doorgetrokken', none: 'geen koers',
});
function getal(n) {
if (n == null || !Number.isFinite(n)) return '?';
const d = Math.abs(n) >= 100 ? 2 : Math.abs(n) >= 1 ? 4 : 6;
return n.toLocaleString('nl-NL', { maximumFractionDigits: d, useGrouping: false });
}
function assetVoorSymbool(gevraagd) {
const gezocht = String(gevraagd ?? '').trim().toUpperCase();
return gezocht ? listAssets().find(a => String(a.symbol).toUpperCase() === gezocht) || null : null;
}
const priceData = Object.freeze({
name: 'price_data',
title: 'Koersen van een asset',
description:
'Alle koersinformatie van één asset: per dag de gebruikte koers met herkomst en de koers per bron '
+ '(days), de gaten (gaps), of de bronnen met keuze, munt, limiet en verbruik (sources). '
+ 'Wijzigt niets.',
inputSchema: Object.freeze({
type: 'object',
properties: {
asset: { type: 'string', maxLength: 16, description: 'Symbool, zoals XAG.' },
view: { type: 'string', enum: ['days', 'gaps', 'sources'] },
from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
until: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
},
required: ['asset'],
additionalProperties: false,
}),
scope: 'read',
async run(args, context) {
assertScope(priceData.scope);
const t = await vertalerVoor(context);
t.beginCall();
const asset = assetVoorSymbool(args?.asset);
if (!asset) {
const symbolen = listAssets().map(a => a.symbol).join(', ');
return { text: `Geen asset met symbool '${String(args?.asset ?? '')}'. Er zijn: ${symbolen}.`, fieldsShared: ['symbol'] };
}
const view = args?.view || 'days';
const sleutelVan = providerSleutels(t);
if (view === 'gaps') return gatenAntwoord(asset, t);
if (view === 'sources') return bronnenAntwoord(asset, t, sleutelVan, (await serviceCoveredAssets()).has(asset.id));
return dagenAntwoord(asset, args, t, sleutelVan);
},
});
function providerSleutels(t) {
const m = new Map(listProviders().map(p => [p.id, t.toAi({ key: p.key }, { entity: 'provider' }).key]));
return id => m.get(id) ?? `bron ${id}`;
}
function dagenAntwoord(asset, args, t, sleutelVan) {
const vandaag = new Date().toISOString().slice(0, 10);
let until = DAG.test(String(args?.until ?? '')) ? args.until : vandaag;
let from = DAG.test(String(args?.from ?? '')) ? args.from : plusDagen(until, -(STANDAARD_DAGEN - 1));
if (from > until) [from, until] = [until, from];
let ingekort = false;
if (dagenTussen(from, until) + 1 > MAX_DAGEN) { from = plusDagen(until, -(MAX_DAGEN - 1)); ingekort = true; }
const rijen = listPriceHistory(asset.id, plusDagen(from, -31)).filter(r => r.observedAt.slice(0, 10) <= until);
const manual = listProviders().find(p => p.key === 'manual');
const dagen = dailyProvenance(rijen, { assetKind: asset.kind, manualProviderId: manual ? manual.id : null,
today: vandaag, from, until });
const eenheid = priceUnitLabel(asset);
const prijs = (fiatPrice) => getal(priceFromBase(t.toAi({ fiatPrice },{ entity: 'priceHistory' }).fiatPrice, asset));
const grens = CONFIG.HYGIENE.SOURCES_DISAGREE_SHARE;
const perDag = new Map();
for (const r of rijen) {
const dag = r.observedAt.slice(0, 10);
if (dag < from) continue;
if (!perDag.has(dag)) perDag.set(dag, []);
perDag.get(dag).push(r);
}
const regels = limitedList(dagen.length ? dagen : [], d => {
const gebruikt = d.kind === 'none'
? 'geen koers'
: `${prijs(d.fiatPrice)} (${SOORT_TEKST[d.kind] || d.kind}${d.row ? `, ${sleutelVan(d.row.providerId)}` : ''}`
+ `${d.kind === 'carried' ? ` van ${d.row.observedAt.slice(0, 10)}, ${d.ageDays} dagen` : ''})`;
const bronnen = (perDag.get(d.day) || []).map(r => {
const prijsTekst = r.fiatPrice != null && r.fiatPrice > 0 ? prijs(r.fiatPrice) : 'leeg';
const moment = r.resolution === 'timestamp' ? ` spot ${r.observedAt.slice(11, 16)} UTC` : '';
return `${sleutelVan(r.providerId)}${moment} ${prijsTekst}`;
});
const dagrijen = (perDag.get(d.day) || []).filter(r => r.resolution === 'day' && r.fiatPrice > 0);
let oneens = '';
if (new Set(dagrijen.map(r => r.providerId)).size > 1) {
const laag = Math.min(...dagrijen.map(r => r.fiatPrice)), hoog = Math.max(...dagrijen.map(r => r.fiatPrice));
const spreiding = hoog / laag - 1;
if (spreiding > grens) oneens = ` · BRONNEN ONEENS: ${getal(Math.round(spreiding * 1000) / 10)}%`;
}
return `${d.day}: ${gebruikt}${bronnen.length ? ` · ${bronnen.join('; ')}` : ''}${oneens}`;
}).lines;
return {
text: [
`Koersen van ${t.toAi(asset, { entity: 'asset' }).symbol} in ${getBaseCurrency()} per ${eenheid}, ${from} t/m ${until}`
+ `${ingekort ? ` (ingekort tot ${MAX_DAGEN} dagen)` : ''}. Per dag: de koers die de app gebruikt met`
+ ' herkomst en bron, daarna elke opgeslagen koers per bron. Een spot is een moment, geen slot.',
...(regels.length ? regels : ['Geen koersen in dit venster, en geen die ervoor doorgetrokken kan worden.']),
].join('\n'),
fieldsShared: [...new Set([...t.fieldsShared(), 'baseCurrency', 'symbol', 'kind', 'unit', 'observedAt', 'fiatPrice',
'resolution', 'key', 'updatedAt'])].sort(),
};
}
function gatenAntwoord(asset, t) {
const open = listPriceGaps().rows.filter(g => g.assetId === asset.id);
const genegeerd = listIgnoredPriceGaps().filter(g => g.assetId === asset.id);
const symbool = t.toAi(asset, { entity: 'asset' }).symbol;
return {
text: [
`Gaten in de koershistorie van ${symbool} (dagen zonder koers, vanaf de eerste boeking t/m gisteren,`
+ ` langer dan ${CONFIG.HYGIENE.PRICE_GAP_DAYS - 1} dagen):`,
...(open.length ? limitedList(open, g => `- ${g.from} t/m ${g.until}, ${g.days} dagen`).lines : ['- geen']),
'Door de gebruiker genegeerd (telt als gedekt):',
...(genegeerd.length ? limitedList(genegeerd, g => `- ${g.fromDay} t/m ${g.untilDay}`).lines : ['- geen']),
].join('\n'),
fieldsShared: ['symbol', 'observedAt', 'fromDay', 'untilDay'],
};
}
function bronnenAntwoord(asset, t, sleutelVan, uitDienst = false) {
const maand = `${new Date().toISOString().slice(0, 7)}-01T00:00`;
const etmaal = new Date(Date.now() - DAG_MS).toISOString().slice(0, 16);
const providers = listProviders().map(p => getProvider(p.key)).filter(Boolean);
const usage = {};
for (const p of providers) if (p.callsPerMonth) usage[p.id] = apiMetricsTotals(p.id, maand).calls;
const cellen = listPriceSource().filter(c => c.assetId === asset.id);
const rijen = listPriceHistory(asset.id);
const keten = SOURCE_CAPABILITIES.map(cap => {
const k = cellen.filter(c => c.capability === cap && c.rank != null).sort((a, b) => a.rank - b.rank)
.map(c => { const v = t.toAi({ rank: c.rank, status: c.status }, { entity: 'priceSource' });
return `${v.rank}. ${sleutelVan(c.providerId)} (${v.status})`; });
return `${cap === 'spot' ? 'Spot' : 'Historie'}: ${k.length ? k.join(', ') : 'geen bron gekozen'}`;
});
const dekkend = providers.filter(p => (p.coverage || []).includes(asset.symbol) || p.key === 'manual');
const regels = limitedList(dekkend, p => {
const rauw = (p.symbols || []).find(s => !s.isCatchAll && s.symbol === asset.symbol)
|| (p.symbols || []).find(s => s.isCatchAll) || null;
const kaart = rauw ? t.toAi({ externalId: rauw.externalId, quoteUnit: rauw.quoteUnit }, { entity: 'provider' }) : null;
const eindpunten = (p.endpoints || []).map(e => {
const v = t.toAi({ capability: e.capability, quoteCurrency: e.quoteCurrency }, { entity: 'provider' });
return `${v.capability}${v.quoteCurrency ? ` in ${v.quoteCurrency}` : ''}`;
});
const eigen = rijen.filter(r => r.providerId === p.id);
const laatste = eigen[0] ? eigen[0].observedAt.slice(0, 10) : null;
const eerste = eigen.length ? eigen[eigen.length - 1].observedAt.slice(0, 10) : null;
const m = apiMetricsTotals(p.id, etmaal);
const delen = [
p.isActive ? 'actief' : 'uit',
eindpunten.length ? eindpunten.join(', ') : 'geen eindpunten',
...(kaart ? [`invoer ${kaart.externalId}${kaart.quoteUnit ? `, per ${kaart.quoteUnit}` : ''}`] : []),
...(p.callsPerMonth ? [`${usage[p.id] ?? 0} van ${p.callsPerMonth} verzoeken deze maand`] : []),
`${m.calls} aanroepen in 24 uur, ${m.fail} mislukt`,
eigen.length ? `${eigen.length} koersen, ${eerste} t/m ${laatste}` : 'geen koersen opgeslagen',
];
return `- ${sleutelVan(p.id)}: ${delen.join('; ')}`;
}).lines;
const historyChain = cellen.filter(c => c.capability === 'history' && c.rank != null)
.sort((a, b) => a.rank - b.rank).map(c => providers.find(p => p.id === c.providerId)?.key).filter(Boolean);
const bevindingen = diagnoseSources(asset, { providers, historyChain, usage, serviceCovers: uitDienst })
.map(b => `- ${b.code}${(b.names || []).length ? ` (${b.names.join(', ')})` : ''}`
+ `${b.proposal ? `, voorstel: ${b.proposal.providerKey}` : ''}`);
return {
text: [
`Bronnen van ${t.toAi(asset, { entity: 'asset' }).symbol} (koersen worden per gram opgeslagen bij metaal):`,
...keten,
...(uitDienst ? ['Dienst: historie uit Atreides Services (stand niet uit, symbool gedekt).'] : []),
'Bronnen die dit symbool dekken:',
...(regels.length ? regels : ['- geen']),
'Diagnose van de bronkeuze:',
...(bevindingen.length ? bevindingen : ['- in orde']),
].join('\n'),
fieldsShared: [...new Set([...t.fieldsShared(), 'symbol', 'key', 'isActive', 'capability', 'rank', 'status',
'quoteCurrency', 'quoteUnit', 'externalId', 'callsPerMonth', 'okCount', 'failCount', 'observedAt',
...(uitDienst ? ['value'] : [])])].sort(),
};
}
export const PRICE_TOOLS = Object.freeze([priceData]);
+159
View File
@@ -0,0 +1,159 @@
// Generated, do not edit. sha256:6e59cd9bc5c34f22b316c129925de56d6e800bca08debd1308bf90cb9f449baa
import { randomUUID } from 'node:crypto';
import { CONFIG } from '../../shared/config.js';
import { AI_REFUSALS } from '../ai/gate.js';
import { limitText } from '../ai/limits.js';
import { ENUMS } from '../../shared/config.js';
const PARSE_ERROR = -32700;
const INVALID_REQUEST = -32600;
const METHOD_NOT_FOUND = -32601;
const INVALID_PARAMS = -32602;
const fout = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
const goed = (id, result) => ({ jsonrpc: '2.0', id, result });
export function createMcpServer({ tools, logCall, onLogError } = {}) {
if (typeof logCall !== 'function') {
throw new Error('An MCP server without an audit log is not allowed in this project.');
}
if (!Array.isArray(tools) || tools.length === 0) {
throw new Error('An MCP server without tools is meaningless.');
}
const zonderScope = tools.filter(t => !ENUMS.AI_SCOPE.includes(t?.scope));
if (zonderScope.length) {
const namen = zonderScope.map(t => t?.name ?? '(naamloos)').join(', ');
throw new Error(`Tool without a valid scope: ${namen}. Allowed is ${ENUMS.AI_SCOPE.join(', ')}.`);
}
const perNaam = new Map(tools.map(t => [t.name, t]));
const sessie = { id: randomUUID(), client: null, initialized: false };
async function log(regel, messages) {
try {
await logCall({
sessionId: sessie.id, client: sessie.client,
serverVersion: CONFIG.AI.SERVER_VERSION, ...regel,
...(messages ? { messages } : {}),
});
} catch (e) {
if (onLogError) onLogError(e);
else throw e;
}
}
const gesprek = (binnenAt, args, uit) => [
{ kind: 'request', text: JSON.stringify(args ?? {}), occurredAt: binnenAt },
{ kind: 'response', text: String(uit ?? ''), occurredAt: new Date().toISOString() },
];
async function voerUit(naam, args, droog) {
const binnenAt = new Date().toISOString();
const tool = perNaam.get(naam);
if (!tool) {
const regel = { tool: naam, arguments: args, outcome: 'refused', detail: 'Deze tool bestaat niet.' };
if (!droog) await log(regel, gesprek(binnenAt, args, regel.detail));
return { regel, text: regel.detail, onbekend: true };
}
try {
const { text, fieldsShared } = await tool.run(args, { session: sessie });
const { text: uitvoer, capped } = limitText(String(text ?? ''));
const regel = {
tool: naam, arguments: args, fieldsShared: fieldsShared ?? [],
charactersOut: uitvoer.length, outcome: 'ok',
detail: capped ? 'Antwoord liep tegen de tekengrens en is ingekort.' : undefined,
};
if (!droog) await log(regel, gesprek(binnenAt, args, uitvoer));
return { regel, text: uitvoer };
} catch (e) {
const melding = e && e.message ? e.message : String(e);
const soort = e && AI_REFUSALS.includes(e.code) ? 'refused' : 'error';
const regel = { tool: naam, arguments: args, outcome: soort, detail: melding };
if (!droog) await log(regel, gesprek(binnenAt, args, melding));
return { regel, text: melding, mislukt: true };
}
}
async function toolsCall(id, params) {
const naam = params && typeof params.name === 'string' ? params.name : null;
const args = params && params.arguments && typeof params.arguments === 'object' ? params.arguments : {};
if (!naam) {
await log({ tool: '(geen)', outcome: 'refused', detail: 'Verzoek zonder toolnaam.' });
return fout(id, INVALID_PARAMS, 'Een tools/call-verzoek noemt geen tool.');
}
const r = await voerUit(naam, args, false);
if (r.onbekend) return fout(id, INVALID_PARAMS, `Onbekende tool '${naam}'.`);
if (r.mislukt) return goed(id, { content: [{ type: 'text', text: r.text }], isError: true });
return goed(id, { content: [{ type: 'text', text: r.text }] });
}
async function dryRun(naam, args = {}) {
const veiligeArgs = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
const r = await voerUit(String(naam ?? ''), veiligeArgs, true);
return {
...r.regel,
fieldsShared: r.regel.fieldsShared ?? [],
charactersOut: r.regel.charactersOut ?? null,
detail: r.regel.detail ?? null,
text: r.text,
};
}
async function handle(bericht) {
if (!bericht || typeof bericht !== 'object' || Array.isArray(bericht)) {
return fout(null, INVALID_REQUEST, 'Geen JSON-RPC-object.');
}
const { id, method, params } = bericht;
const isNotificatie = id === undefined || id === null;
if (typeof method !== 'string') {
return isNotificatie ? null : fout(id, INVALID_REQUEST, 'Verzoek zonder methode.');
}
switch (method) {
case 'initialize': {
sessie.client = params?.clientInfo?.name ? String(params.clientInfo.name) : null;
sessie.initialized = true;
return goed(id, {
protocolVersion: typeof params?.protocolVersion === 'string'
? params.protocolVersion : CONFIG.AI.PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: CONFIG.AI.SERVER_NAME, version: CONFIG.AI.SERVER_VERSION },
});
}
case 'notifications/initialized':
case 'initialized':
return null;
case 'ping':
return isNotificatie ? null : goed(id, {});
case 'tools/list': {
if (isNotificatie) return null;
return goed(id, {
tools: tools.map(t => ({
name: t.name,
title: t.title,
description: t.description,
inputSchema: t.inputSchema,
})),
});
}
case 'tools/call':
if (isNotificatie) return null;
return toolsCall(id, params);
default:
return isNotificatie ? null : fout(id, METHOD_NOT_FOUND, `Onbekende methode '${method}'.`);
}
}
async function handleRegel(regel) {
let bericht;
try { bericht = JSON.parse(regel); } catch { return fout(null, PARSE_ERROR, 'Ongeldige JSON.'); }
return handle(bericht);
}
return { handle, handleRegel, dryRun, get session() { return { ...sessie }; } };
}
+414
View File
@@ -0,0 +1,414 @@
// Generated, do not edit. sha256:df37b3da57b8535420153ba9f7331337454235c42435f0fd9deff4f9829eb96b
import { listProviders, getProvider } from '../data/providers.js';
import { listPriceSource } from '../data/price-source.js';
import { diagnoseSources } from '../../shared/source-diagnosis.js';
import { serviceCoveredAssets } from './service-cover.mjs';
import { apiMetricsTotals } from '../data/api-metrics.js';
import { listConnections } from '../data/connections.js';
import { listAssets } from '../data/assets.js';
import { getBaseCurrency } from '../data/portfolio.js';
import { listAccounts } from '../data/accounts.js';
import { listLabels, labelUsage } from '../data/labels.js';
import { counts } from '../data/counts.js';
import { listMetalProducts } from '../data/metals.js';
import { listProfiles, listWidgets } from '../data/dashboard.js';
import { getPref } from '../data/preferences.js';
import { hygieneReport } from '../data/hygiene.js';
import { listScanLogs } from '../data/scan-logs.js';
import { listIntegrationCalls } from '../data/integration-log.js';
import { assertScope, mayScope } from '../ai/gate.js';
import { limitedList, limitRows, omittedNote } from '../ai/limits.js';
import { vertalerVoor } from './session.mjs';
import { CONFIG, PREFERENCES, SOURCE_CAPABILITIES, TAX_CATEGORY_BY_ASSET_KIND, balanceCaptureOn } from '../../shared/config.js';
import { fromBaseByFactor } from '../../shared/units.js';
const GEEN_BEDRAGEN = 'Zonder bedragen, saldi of adressen, en wijzigt niets.';
function sindsUren(uren) {
return new Date(Date.now() - uren * 3600_000).toISOString().slice(0, 16);
}
function pct(deel, geheel) {
return geheel ? `${Math.round((deel / geheel) * 1000) / 10}%` : '0%';
}
const VENSTER_UREN = CONFIG.AI.LIMITS.METRICS_WINDOW_HOURS;
function scanBlok(t) {
const regels = [];
for (const rekening of listAccounts()) {
const logs = listScanLogs(rekening.id);
if (!logs.length && !rekening.scanEnabled) continue;
const veiligeRekening = t.toAi(rekening, { entity: 'account' });
if (!logs.length) {
regels.push(`- ${veiligeRekening.id}: staat op automatisch scannen maar is nog nooit gescand.`);
continue;
}
const laatste = t.toAi({
status: logs[0].status,
scannedAt: logs[0].scannedAt,
addressesFound: logs[0].addressesFound,
proposalsCreated: logs[0].proposalsCreated,
}, { entity: 'scanLog' });
const mislukt = logs.filter(l => l.status === 'failed').length;
regels.push(
`- ${veiligeRekening.id}: laatste scan ${laatste.status} op ${laatste.scannedAt}`
+ `, ${laatste.addressesFound ?? 0} adressen, ${laatste.proposalsCreated ?? 0} voorstellen`
+ ` (${logs.length} scans in totaal, ${mislukt} mislukt).`);
}
return limitedList(regels, r => r).lines;
}
const DIAGNOSE_TEKST = Object.freeze({
noCoverage: () => 'geen enkele bron levert hier historie voor',
allInactive: n => `de dekkende bronnen staan uit (${n})`,
noChoice: () => 'geen bron gekozen voor de historie',
choiceUnusable: n => `de gekozen bron werkt niet (${n})`,
limitReached: n => `maandlimiet bereikt (${n})`,
choiceInactive: n => `gekozen maar uitgezet (${n})`,
});
function bronkeuzeBlok(t, uitDienst = new Set()) {
const maand = `${new Date().toISOString().slice(0, 7)}-01T00:00`;
const providers = listProviders().map(p => getProvider(p.key)).filter(Boolean);
const perId = new Map(providers.map(p => [p.id, p]));
const usage = {};
for (const p of providers) if (p.callsPerMonth) usage[p.id] = apiMetricsTotals(p.id, maand).calls;
const cellen = listPriceSource().filter(c => c.rank != null).sort((a, b) => a.rank - b.rank);
const regels = [];
const basis = getBaseCurrency();
for (const asset of listAssets()) {
if (asset.symbol === basis) continue;
const veilig = t.toAi(asset, { entity: 'asset' });
const keten = cap => cellen.filter(c => c.assetId === asset.id && c.capability === cap)
.map(c => perId.get(c.providerId)).filter(Boolean);
const naam = p => `${t.toAi({ key: p.key }, { entity: 'provider' }).key}${p.isActive ? '' : ' (uit)'}`;
const delen = SOURCE_CAPABILITIES.map(cap => {
const k = keten(cap);
return `${cap === 'spot' ? 'spot' : 'historie'}: ${k.length ? k.map(naam).join(', ') : 'geen'}`;
});
const gekozen = new Set(cellen.filter(c => c.assetId === asset.id).map(c => c.providerId));
const vrij = providers.filter(p => !p.isSystem && (p.coverage || []).includes(asset.symbol) && !gekozen.has(p.id));
if (vrij.length) delen.push(`dekt ook: ${vrij.map(naam).join(', ')}`);
const dienst = uitDienst.has(asset.id);
if (dienst) delen.push('historie uit Atreides Services');
const bevindingen = diagnoseSources(asset, { providers, historyChain: keten('history').map(p => p.key), usage,
serviceCovers: dienst });
for (const b of bevindingen) {
const namen = (b.names || []).join(', ');
const tekst = (DIAGNOSE_TEKST[b.code] || (() => b.code))(namen);
delen.push(b.proposal ? `${tekst}, voorstel: ${b.proposal.providerKey}` : tekst);
}
regels.push(`- ${veilig.symbol} (${veilig.kind}): ${delen.join('; ')}.`);
}
return limitedList(regels, r => r).lines;
}
const ILOG = CONFIG.INTEGRATION_LOG;
function foutenBlok(t, magAdministratie) {
const rijen = listIntegrationCalls({ days: ILOG.WINDOW_DAYS, limit: ILOG.MAX_ROWS });
const regels = rijen.slice(0, ILOG.AI_MAX_ROWS).map(r => {
const v = t.toAi({
occurredAt: r.occurredAt, providerKey: r.providerKey, operation: r.operation, outcome: r.outcome,
httpStatus: r.httpStatus, errorKey: r.errorKey, requestId: r.requestId,
...(magAdministratie ? { subject: r.subject } : {}),
}, { entity: 'integrationCall' });
const bron = r.target === 'service' ? 'atreides_services' : v.providerKey;
return `- ${v.occurredAt} ${bron} ${v.operation}${v.subject ? ` ${v.subject}` : ''}: ${v.outcome}`
+ `${v.httpStatus ? ` ${v.httpStatus}` : ''}${v.errorKey ? ` ${v.errorKey}` : ''}`
+ `${v.requestId ? ` (request-id ${v.requestId})` : ''}`;
});
if (rijen.length > regels.length) regels.push(`- en nog ${rijen.length - regels.length} oudere.`);
return regels;
}
const integrationHealth = Object.freeze({
name: 'integration_health',
title: 'Gezondheid van de koppelingen',
description:
'Geeft de technische toestand van de externe koppelingen van AssetApp: per koersbron de geslaagde '
+ 'en mislukte aanroepen van de afgelopen 24 uur met de gemiddelde en langste reactietijd, per '
+ 'verbinding de laatst gemeten status, per automatisch gescand account de uitslag van de '
+ 'laatste wallet-scan, per asset de bronkeuze voor spot en historie met wat eraan schort, en de recente '
+ 'fouten met request-id. Zegt dus of een koersbron onbetrouwbaar is, een verbinding stuk staat of een '
+ 'scan niet meer doorkomt. Verbindingen en accounts worden aangeduid met een verwijzing zoals '
+ 'connection:7f3a9c, zodat twee koppelingen van dezelfde soort te onderscheiden zijn. '
+ GEEN_BEDRAGEN,
inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }),
scope: 'none',
async run(_args, context) {
assertScope('none');
const t = await vertalerVoor(context);
const sinds = sindsUren(VENSTER_UREN);
const regels = limitedList(listProviders(), p => {
const veilig = t.toAi(p, { entity: 'provider' });
const m = apiMetricsTotals(p.id, sinds);
const status = veilig.isActive ? 'actief' : 'uitgeschakeld';
return m.calls === 0
? `- ${veilig.key} (${status}): geen aanroepen in de afgelopen ${VENSTER_UREN} uur.`
: `- ${veilig.key} (${status}): ${m.calls} aanroepen, ${m.fail} mislukt (${pct(m.fail, m.calls)}), `
+ `gemiddeld ${m.avg_latency_ms} ms, langste ${m.maxLatencyMs} ms.`;
}).lines;
const verbindingen = limitedList(listConnections(), c => {
const veilig = t.toAi(c, { entity: 'connection' });
const uitslag = veilig.lastStatus
? `${veilig.lastStatus} (gemeten ${veilig.lastCheckedAt})`
: 'nog nooit getest';
return `- ${veilig.id} (${veilig.kind}${veilig.isActive ? ', actief' : ''}): ${uitslag}`;
}).lines;
const magAdministratie = mayScope('read');
const scans = magAdministratie ? scanBlok(t) : [];
const uitDienst = magAdministratie ? await serviceCoveredAssets() : new Set();
const bronkeuze = magAdministratie ? bronkeuzeBlok(t, uitDienst) : [];
const fouten = foutenBlok(t, magAdministratie);
return {
text: [
`Koersbronnen, afgelopen ${VENSTER_UREN} uur:`,
regels.length ? regels.join('\n') : '- geen koersbronnen ingesteld.',
'',
'Verbindingen:',
verbindingen.length ? verbindingen.join('\n') : '- geen verbindingen ingesteld.',
'',
'Wallet-scans:',
!magAdministratie
? '- niet opgevraagd: de AI-koppeling van AssetApp staat uit, en scans horen bij een account.'
: (scans.length ? scans.join('\n') : '- geen account staat op automatisch scannen.'),
'',
'Bronkeuze per asset (in volgorde; wijzigen doet de gebruiker op Integraties, Bronkeuze):',
!magAdministratie
? '- niet opgevraagd: de AI-koppeling van AssetApp staat uit, en de assets horen bij de administratie.'
: (bronkeuze.length ? bronkeuze.join('\n') : '- geen assets.'),
'',
`Fouten, afgelopen ${ILOG.WINDOW_DAYS} dagen (nieuwste eerst):`,
fouten.length ? fouten.join('\n') : '- geen.',
].join('\n'),
fieldsShared: [
'key', 'isActive', 'okCount', 'failCount', 'sumLatencyMs', 'maxLatencyMs',
'kind', 'lastStatus', 'lastCheckedAt', 'id',
...(magAdministratie ? ['scanEnabled', 'status', 'scannedAt', 'addressesFound', 'proposalsCreated',
'symbol', 'capability', 'rank', 'callsPerMonth'] : []),
...(uitDienst.size ? ['value'] : []),
...(fouten.length ? ['occurredAt', 'providerKey', 'operation', 'outcome', 'httpStatus', 'errorKey', 'requestId',
...(magAdministratie ? ['subject'] : [])] : []),
],
};
},
});
const adminOverview = Object.freeze({
name: 'admin_overview',
title: 'Overzicht van de inrichting',
description:
'Geeft de inrichting van AssetApp: welke assets er zijn (met hun symbool, soort en eenheid), '
+ 'hoeveel accounts er zijn en van welke soort, welke labels bestaan en op hoeveel on-chain '
+ 'bewegingen elk label gebruikt wordt, en hoeveel gebeurtenissen '
+ 'en balansen er zijn vastgelegd. Bedoeld voor vragen over de opzet en om '
+ 'te zien wat er ontbreekt. Plus de configuratie: metaalproducten, dashboardprofielen, '
+ 'de app-instellingen en de timers. '
+ 'Accounts en labels krijgen een verwijzing zoals account:7f3a9c, '
+ 'ook in een later gesprek dezelfde; met onbewerkt delen aan is dat het rij-id met de naam '
+ 'erachter. ' + GEEN_BEDRAGEN,
inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }),
scope: 'read',
async run(_args, context) {
assertScope(adminOverview.scope);
const t = await vertalerVoor(context);
const alleAssets = listAssets();
const alleRekeningen = listAccounts();
const alleLabels = listLabels();
const gekozen = (waarde, afgeleid, bron) => (waarde != null ? `${waarde} (gekozen)` : `${afgeleid} (${bron})`);
const assets = limitedList(alleAssets, a => {
const v = t.toAi(a, { entity: 'asset' });
return `- ${v.symbol} (${v.kind}, eenheid ${v.unit}${v.subunit ? `, ondereenheid ${v.subunit}` : ''}; `
+ `categorie ${gekozen(v.taxCategory, TAX_CATEGORY_BY_ASSET_KIND[v.kind] ?? 'onbekend', 'volgt soort')}, `
+ `belastingen ${gekozen(v.taxScope, 'all', 'standaard')}, `
+ `saldo vastleggen ${gekozen(v.balanceCapture == null ? null : (v.balanceCapture ? 'aan' : 'uit'),
balanceCaptureOn({ kind: v.kind }) ? 'aan' : 'uit', 'volgt soort')})`;
}).lines;
const onbewerkt = t.precision.rawValues;
const naamErbij = v => (onbewerkt && v.name ? ` (${v.name})` : '');
const rekeningen = limitedList(alleRekeningen, a => {
const v = t.toAi(a, { entity: 'account' });
const eigen = v.isOwnCustody ? 'zelf in bewaring' : 'bij een derde partij';
return `- ${v.id}${naamErbij(v)}: soort ${v.kind}, ${eigen}, ${v.isActive ? 'actief' : 'afgesloten'}`
+ `${v.scanEnabled ? ', wordt automatisch gescand' : ''}`
+ `; categorie ${v.taxCategory ?? 'volgt asset'}, belastingen ${v.taxScope ?? 'volgt asset'}`
+ `, vermogen ${v.wealthScope ?? 'eigen'}`;
}).lines;
const gebruik = new Map(labelUsage().map(r => [r.labelId, Number(r.walletTransactions)]));
const labels = limitedList(alleLabels, l => {
const n = gebruik.get(l.id) ?? 0;
const hoe = n === 0 ? 'nergens gebruikt'
: n === 1 ? 'gebruikt op 1 beweging'
: `gebruikt op ${n} bewegingen`;
const v = t.toAi(l, { entity: 'label' });
return `- ${v.id}${naamErbij(v)}: ${hoe}`;
}).lines;
const c = counts();
const alleProducten = listMetalProducts();
const perAsset = new Map();
for (const p of alleProducten) {
const key = p.asset_symbol;
if (!perAsset.has(key)) perAsset.set(key, new Map());
const vormen = perAsset.get(key);
vormen.set(p.form, (vormen.get(p.form) ?? 0) + 1);
}
const producten = onbewerkt
? limitedList(alleProducten, p =>
`- ${p.asset_symbol} (${p.name}): ${p.form}, ${fromBaseByFactor(p.fineGrams, 'g')} g fijn`).lines
: [...perAsset].map(([symbool, vormen]) =>
`- ${symbool}: ${[...vormen].map(([vorm, n]) => `${n}× ${vorm}`).join(', ')}`);
const profielen = listProfiles().map(p =>
`- ${onbewerkt && p.name ? p.name : `profiel ${p.sortOrder}`}: `
+ `${p.isActive ? 'actief' : 'niet actief'}, ${listWidgets(p.id).length} widgets`);
const instellingen = [];
for (const scope of CONFIG.AI.SHARED_PREF_SCOPES) {
for (const key of Object.keys(PREFERENCES[scope] ?? {})) {
if (key === '*') continue;
const v = t.toAi({ scope, key, value: getPref(scope, key) }, { table: 'Preference' });
if (v.value === undefined) continue;
const waarde = v.value === null || v.value === '' ? 'leeg' : v.value;
instellingen.push(`- ${v.scope}.${v.key} = ${waarde}`);
}
}
return {
text: [
`Rekenvaluta: ${getBaseCurrency()}. Elke koers en elk bedrag in AssetApp staat in deze munt.`,
'',
`Assets (${alleAssets.length}):`,
assets.length ? assets.join('\n') : '- geen.',
'',
`Accounts (${alleRekeningen.length}):`,
rekeningen.length ? rekeningen.join('\n') : '- geen.',
'',
`Labels (${alleLabels.length}):`,
labels.length ? labels.join('\n') : '- geen.',
'',
`Vastgelegd: ${c.events} gebeurtenissen en ${c.valuations} balansen.`,
'',
`Metaalproducten (${alleProducten.length}):`,
producten.length ? producten.join('\n') : '- geen.',
'',
`Dashboardprofielen (${profielen.length}):`,
profielen.length ? profielen.join('\n') : '- geen.',
'',
`Instellingen (${instellingen.length}):`,
instellingen.length ? instellingen.join('\n') : '- geen.',
].join('\n'),
fieldsShared: ['baseCurrency', 'id', 'symbol', 'kind', 'unit', 'subunit', 'isOwnCustody', 'isActive', 'scanEnabled',
'taxCategory', 'taxScope', 'wealthScope', 'balanceCapture',
'labelId', 'form', 'sortOrder', 'profileId', 'scope', 'key', 'Preference.value',
...(onbewerkt ? ['name', 'fineGrams'] : [])],
};
},
});
const BEVINDING_TEKST = Object.freeze({
duplicate_events: 'gebeurtenissen die er twee of meer keer identiek in staan',
assets_without_balance: 'assets waarin geboekt is maar waarvoor nooit een balans is vastgelegd',
unreconciled_valuations: 'balansen die niet aansluiten op de boekingen eronder, en die nog niet zijn afgehandeld',
stale_prices: 'assets waarvan de koershistorie achterloopt op de laatste boeking, of ontbreekt',
unlinked_transactions: 'on-chain bewegingen zonder koppeling en zonder openstaand voorstel',
late_price_history: 'assets waarvan de koershistorie later begint dan de eerste boeking',
price_gaps: 'assets met een gat tussen twee koersen (dagen zonder koers, vanaf de eerste boeking), waardoor daar een oude koers wordt meegesleept',
price_stopped: 'assets in bezit waarvan de laatste koers ouder is dan de doortrekgrens van hun soort, dus nu niet te waarderen',
transfer_without_counterpart: 'verplaatsingen waarvan één kant ontbreekt zonder extern adres',
fee_without_effect: 'boekingen met kosten die geen berekening leest (geld bij rente, eenheden bij aankoop of rente)',
coverage_without_asset: 'koersbronnen die dekking claimen op een symbool dat hier geen asset is',
price_unit_swap: 'koersrijen die een factor 31 (gram tegen troy ounce) van de rij ervoor liggen',
price_outlier: 'koersrijen die een factor drie of meer van beide buren liggen, zoals een tikfout',
price_sources_disagree: 'dagen waarop twee bronnen een koers geven die ver uit elkaar ligt',
price_not_positive: 'koersrijen met een koers van nul of lager, die de app niet meetelt',
price_in_future: 'koersrijen met een moment in de toekomst',
booking_price_deviates: 'aan- en verkopen waarvan de prijs ver van de koers van die dag ligt (tikfout, datum of eenheid)',
price_origin_derivable: 'boekingen zonder herkomst waarvan de prijs de dagkoers is, in te vullen op Gezondheid',
});
const DAG_VELDEN = Object.freeze({
stale_prices: Object.freeze(['occurredAt', 'observedAt']),
unlinked_transactions: Object.freeze(['createdAt']),
price_unit_swap: Object.freeze(['observedAt']),
price_outlier: Object.freeze(['observedAt']),
price_sources_disagree: Object.freeze(['observedAt']),
price_not_positive: Object.freeze(['observedAt']),
price_in_future: Object.freeze(['observedAt']),
booking_price_deviates: Object.freeze(['occurredAt']),
price_origin_derivable: Object.freeze(['occurredAt']),
});
function opDag(waarde) {
return typeof waarde === 'string' && waarde.length >= 10 ? waarde.slice(0, 10) : waarde;
}
function grofPerBevinding(soort, rij) {
const velden = DAG_VELDEN[soort];
if (!velden) return rij;
const uit = { ...rij };
for (const veld of velden) if (veld in uit) uit[veld] = opDag(uit[veld]);
return uit;
}
const dataHealth = Object.freeze({
name: 'data_health',
title: 'Controle op de eigen administratie',
description:
'Zoekt inconsistenties in de administratie van AssetApp en geeft per soort het aantal en de '
+ 'betrokken rijen terug: dubbele boekingen, assets zonder vastgelegde balans, balansen die niet '
+ 'aansluiten op de boekingen eronder, koershistorie die achterloopt, on-chain bewegingen die '
+ 'nergens aan hangen, en verwijzingen die nergens heen wijzen. Elke bevinding is een aantal of '
+ 'een verwijzing zoals account:a1, stabiel binnen dit gesprek. ' + GEEN_BEDRAGEN,
inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }),
scope: 'read',
async run(_args, context) {
assertScope(dataHealth.scope);
const t = await vertalerVoor(context);
t.beginCall();
const rapport = hygieneReport();
const blokken = rapport.findings.map(f => {
const kop = `${BEVINDING_TEKST[f.kind] || f.kind} (${f.kind}, ${f.severity}): ${f.total}`;
if (!f.total) return `${kop}.`;
const { rows: zichtbaar, omitted, capped } = limitRows(f.rows);
const grof = zichtbaar.map(r => grofPerBevinding(f.kind, r.row));
const veilig = t.toAi(grof);
const rijen = veilig.map((r, i) => {
const delen = Object.entries(r).map(([k, v]) => `${k}=${v}`);
for (const [k, v] of Object.entries(zichtbaar[i].counts || {})) delen.push(`${k}=${v}`);
return ` - ${delen.join(', ')}`;
});
if (capped) rijen.push(omittedNote(omitted, CONFIG.AI.LIMITS.MAX_ROWS_PER_LIST));
return `${kop}:\n${rijen.join('\n')}`;
});
return {
text: [
rapport.total === 0
? 'Er zijn geen inconsistenties gevonden.'
: `Er zijn ${rapport.total} bevindingen, verdeeld over ${rapport.findings.filter(f => f.total).length} soorten.`,
'',
...blokken,
].join('\n'),
fieldsShared: [...new Set([
...t.fieldsShared(),
...rapport.findings.filter(f => f.rows.length).flatMap(f => f.countsFrom || []),
])].sort(),
};
},
});
export const READ_TOOLS = Object.freeze([dataHealth, integrationHealth, adminOverview]);
+13
View File
@@ -0,0 +1,13 @@
// Generated, do not edit. sha256:2e72a438d7feb6f942a8e08f41e0d5f4748ae7098b8f0f7cfcffb93bd8fcf9e0
import { getPref } from '../data/preferences.js';
import { serviceCoverage } from '../data/market.js';
export async function serviceCoveredAssets() {
let mode = 'off';
try { mode = getPref('app', 'market_spot') ?? 'off'; } catch { }
if (mode === 'off') return new Set();
try {
const r = await serviceCoverage();
return new Set(r.status === 'ok' ? r.assetIds : []);
} catch { return new Set(); }
}
+18
View File
@@ -0,0 +1,18 @@
// Generated, do not edit. sha256:a952ea9dd0f828339960be62a36693504bb931d6c638e950608c1e1b185bd27c
import { createTranslator } from '../ai/translate.js';
import { handleSecret } from '../ai/handle-secret.mjs';
import { activeName } from '../portfolios.mjs';
import { precisionSettings, precisionKey } from '../ai/precision.js';
const vertalers = new Map();
export async function vertalerVoor(context) {
const precision = precisionSettings();
const id = `${context?.session?.id ?? 'los'}::${activeName()}::${precisionKey(precision)}`;
if (!vertalers.has(id)) {
vertalers.set(id, createTranslator({ secret: await handleSecret(), precision }));
}
return vertalers.get(id);
}
export function _resetVertalers() { vertalers.clear(); }
+119
View File
@@ -0,0 +1,119 @@
// Generated, do not edit. sha256:b0dd28dca0d6350eaa3cdbb0db8611b54c802d92405f31a10da8e494e94fbaf6
import { createInterface } from 'node:readline';
import { CONFIG } from '../../shared/config.js';
import { initDatabase, closeDatabase, dbFile, databaseStatus } from '../db.js';
import { prepareLayout, activeName } from '../portfolios.mjs';
import { openDatabase } from '../db/conn.mjs';
import { logAiToolCall } from '../data/ai-audit.js';
import { isAiActive, AI_INACTIVE_TEXT } from '../ai/gate.js';
import { flushChanges } from '../data/bus.js';
import { createMcpServer } from './protocol.mjs';
import { allTools } from './tools.mjs';
import '../edition.mjs';
const naarGebruiker = (regel) => process.stderr.write(`[assetapp-mcp] ${regel}\n`);
function stopAlsUit() {
if (isAiActive()) return false;
naarGebruiker('de AI-koppeling staat uit (schuif "Actief" onder Systeem → AI-activiteit). '
+ 'Deze server doet niets en stopt; zet de schuif aan en start uw AI-programma opnieuw.');
return true;
}
try {
naarGebruiker(prepareLayout().regel);
naarGebruiker(initDatabase());
const uit = stopAlsUit();
closeDatabase();
if (uit) process.exit(0);
} catch (e) {
naarGebruiker(`kan de database niet openen: ${e.message}`);
process.exit(1);
}
const tools = allTools();
const server = createMcpServer({
tools,
logCall: logAiToolCall,
onLogError: (e) => naarGebruiker(`LET OP: een tool-aanroep is NIET vastgelegd: ${e.message}`),
});
naarGebruiker(`gereed als '${CONFIG.AI.SERVER_NAME}', ${tools.length} tool(s), sessie ${server.session.id}`);
const lezer = createInterface({ input: process.stdin, crlfDelay: Infinity });
function kijkIn(regel) {
try {
const bericht = JSON.parse(regel);
return {
id: Object.hasOwn(bericht, 'id') ? bericht.id : undefined,
method: typeof bericht.method === 'string' ? bericht.method : null,
};
} catch { return { id: undefined, method: null }; }
}
const HEEFT_DATABASE_NODIG = 'tools/call';
const INTERNE_FOUT = -32603;
function stuurFout(regel, melding) {
const { id } = kijkIn(regel);
if (id === undefined) return;
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code: INTERNE_FOUT, message: melding } })}\n`);
}
function uitlegBijStand(stand) {
const naam = activeName();
if (stand.reden === 'leeg') {
return `De portefeuille '${naam}' heeft nog geen ingerichte database. Dat gebeurt zodra de app hem `
+ 'een keer opent (Systeem → Portefeuilles → Openen). Tot dan is er hier niets te lezen.';
}
if (stand.reden === 'oud') {
return `De database van portefeuille '${naam}' staat op versie ${stand.versie} en deze app verwacht `
+ `${CONFIG.SCHEMA_VERSION}. De migraties draaien als de app die portefeuille opent; doe dat eerst.`;
}
return `De database van portefeuille '${naam}' staat op versie ${stand.versie}, en deze MCP-server kent `
+ `tot en met ${CONFIG.SCHEMA_VERSION}. Is de app net bijgewerkt, herstart dan de AI-koppeling in je `
+ 'AI-client: dit proces laadt nieuwe code pas bij een herstart. Anders is de app zelf ouder dan de '
+ 'database; werk hem dan bij.';
}
let wachtrij = Promise.resolve();
lezer.on('line', (regel) => {
const tekst = regel.trim();
if (!tekst) return;
wachtrij = wachtrij.then(async () => {
try { openDatabase(dbFile()); }
catch (e) {
naarGebruiker(`kan de database niet openen voor dit bericht: ${e.message}`);
stuurFout(tekst, `De database van de actieve portefeuille is nu niet te openen: ${e.message}`);
return;
}
const stand = databaseStatus();
if (stand.klaar && !isAiActive()) {
stuurFout(tekst, AI_INACTIVE_TEXT);
naarGebruiker('de AI-koppeling is uitgezet; deze server stopt.');
try { closeDatabase(); } catch { }
process.exit(0);
}
if (!stand.klaar && kijkIn(tekst).method === HEEFT_DATABASE_NODIG) {
naarGebruiker(`de database van deze portefeuille is niet bruikbaar (${stand.reden}); bericht geweigerd`);
stuurFout(tekst, uitlegBijStand(stand));
try { closeDatabase(); } catch { }
return;
}
try {
const antwoord = await server.handleRegel(tekst);
if (antwoord) process.stdout.write(`${JSON.stringify(antwoord)}\n`);
} catch (e) {
naarGebruiker(`onverwachte fout bij het afhandelen van een bericht: ${e.message}`);
} finally {
try { flushChanges(); } catch (e) { naarGebruiker(`signalen wegschrijven mislukte: ${e.message}`); }
try { closeDatabase(); } catch (e) { naarGebruiker(`database sluiten mislukte: ${e.message}`); }
}
});
});
lezer.on('close', () => { naarGebruiker('verbinding gesloten door de client'); process.exit(0); });
+74
View File
@@ -0,0 +1,74 @@
// Generated, do not edit. sha256:31e7aeb1ea48c942e3f7c7a1b1a04f9c89a81975a5bc92fbc88810da09fb4d6c
import { CONFIG } from '../../shared/config.js';
import { assertScope } from '../ai/gate.js';
import { precisionSettings } from '../ai/precision.js';
import { activeName } from '../portfolios.mjs';
import { READ_TOOLS } from './read-tools.mjs';
import { DETAIL_TOOLS } from './detail-tool.mjs';
import { PLACES_TOOLS } from './places-tool.mjs';
import { CONTROL_TOOLS } from './control-tools.mjs';
import { WRITE_TOOLS } from './write-tools.mjs';
import { WEALTH_TOOLS } from './wealth-tool.mjs';
import { PRICE_TOOLS } from './price-tool.mjs';
import { mcpTools } from '../registers.mjs';
const NOTE_MAX = 200;
export const TOOLS = Object.freeze([
...READ_TOOLS,
...DETAIL_TOOLS,
...PLACES_TOOLS,
...WEALTH_TOOLS,
...PRICE_TOOLS,
...CONTROL_TOOLS,
...WRITE_TOOLS,
Object.freeze({
name: 'connection_test',
title: 'Verbinding testen',
description:
'Controleert of de verbinding met AssetApp werkt. Deze tool leest geen gegevens uit het '
+ 'portfolio en verandert er niets aan: hij bevestigt alleen dat het bericht is aangekomen en '
+ 'legt de aanroep vast in het activiteitenlogboek van de app, waar de eigenaar hem terugvindt.',
inputSchema: Object.freeze({
type: 'object',
properties: {
note: {
type: 'string',
maxLength: NOTE_MAX,
description: 'Optionele korte notitie die in het logboek van de app komt te staan.',
},
},
additionalProperties: false,
}),
scope: 'none',
async run(args) {
assertScope('none');
const note = typeof args?.note === 'string' ? args.note.slice(0, NOTE_MAX) : null;
const portefeuille = activeName();
const onbewerkt = precisionSettings().rawValues;
return {
text: [
`Verbinding met ${CONFIG.AI.SERVER_NAME} werkt.`,
`Open portefeuille: ${portefeuille}.`,
onbewerkt
? 'Onbewerkt delen staat AAN voor deze portefeuille: namen en bedragen komen ongefilterd '
+ 'terug, zonder verwijzingen en marges. Dat hoort alleen zo te zijn in een portefeuille '
+ 'zonder echte gegevens.'
: 'Namen en bedragen komen als verwijzing en marge terug, niet als echte waarde.',
'Er zijn geen portfolio-gegevens opgevraagd of gewijzigd.',
note
? `De notitie "${note}" staat nu in het activiteitenlogboek van de app.`
: 'Deze aanroep staat nu in het activiteitenlogboek van de app, zonder notitie.',
].join('\n'),
fieldsShared: ['portfolio'],
};
},
}),
]);
export function allTools() {
const extra = mcpTools.list().map(e => e.tool);
const dubbel = extra.find(t => TOOLS.some(c => c.name === t.name));
if (dubbel) throw new Error(`mcp tool '${dubbel.name}' is registered and also a core tool`);
return Object.freeze([...TOOLS, ...extra]);
}
+164
View File
@@ -0,0 +1,164 @@
// Generated, do not edit. sha256:a35de9757194e9058e50a8d62faae8adcfd1b35f42f9b9b14cece1c6cab04e85
import { accountHoldings, positionsByAsset } from '../data/positions.js';
import { listAccounts } from '../data/accounts.js';
import { isOwnWealth } from '../../shared/wealth-scope.js';
import { latestPricePoint } from '../data/price-history.js';
import { assertScope } from '../ai/gate.js';
import { limitedList } from '../ai/limits.js';
import { shareBucket, shareLabel } from '../ai/buckets.js';
import { shareDisclosure } from '../ai/precision.js';
import { vertalerVoor } from './session.mjs';
import { assetMetrics } from '../../shared/wealth-metrics.js';
import { isMetal, metalFactor } from '../../shared/units.js';
import { getBaseCurrency } from '../data/portfolio.js';
function verdelingsblok(rijen, totaal, t) {
if (!(totaal > 0)) return [];
const vorm = shareDisclosure(t.precision);
if (vorm === 'omit') {
return [
'De verdeling over de assets komt niet mee: met een exact totaal én exacte percentages ligt de '
+ 'waarde per asset vast, en de bedragen per regel zijn juist wél in marges. Zet één van beide '
+ 'terug op marges (of zet onbewerkt delen aan) om hem te zien.',
'',
];
}
const pctUit = pct => (vorm === 'exact' ? `${pct.toFixed(1)}%` : shareLabel(pct));
const posten = [];
let overigPct = 0;
let overigN = 0;
let zonderKoers = 0;
for (const { p, m } of rijen) {
if (m.value == null) { zonderKoers += 1; continue; }
const pct = (m.value / totaal) * 100;
const b = shareBucket(pct);
if (!b || b.below) { overigPct += pct; overigN += 1; continue; }
posten.push({ symbool: t.toAi(p, { entity: 'asset' }).symbol, pct });
}
if (!posten.length && !overigN) return [];
posten.sort((a, b) => b.pct - a.pct);
const regels = limitedList(posten, x => `- ${x.symbool}: ${pctUit(x.pct)}`).lines;
if (overigN) {
regels.push(`- overig (${overigN} ${overigN === 1 ? 'asset' : 'assets'}): ${pctUit(overigPct)}`);
}
return [
vorm === 'exact'
? 'Verdeling over de portefeuille (samen dekken ze het geheel):'
: 'Verdeling over de portefeuille (marges; samen dekken ze het geheel):',
...regels,
...(zonderKoers ? [`(${zonderKoers} zonder koers, dus niet in deze verdeling.)`] : []),
'',
];
}
function buitenTotalen(decimalen) {
const buiten = listAccounts().filter(a => !isOwnWealth(a));
if (!buiten.length) return [];
const soort = new Map(buiten.map(a => [a.id, a.wealthScope]));
const som = new Map([...new Set(buiten.map(a => a.wealthScope))].map(s => [s, { waarde: 0, zonderKoers: 0 }]));
for (const r of accountHoldings(buiten.map(a => a.id))) {
const punt = latestPricePoint(r.assetId);
const post = som.get(soort.get(r.accountId));
if (!punt || !decimalen.has(r.assetId)) { post.zonderKoers += 1; continue; }
post.waarde += (r.saldo / Math.pow(10, decimalen.get(r.assetId))) * punt.fiatPrice;
}
return ['managed', 'none'].filter(s => som.has(s)).map(s => ({ soort: s, ...som.get(s) }));
}
const BUITEN_LABEL = { managed: 'vermogen van derden', none: 'telt niet mee' };
const wealthSummary = Object.freeze({
name: 'wealth_summary',
title: 'Vermogen in cijfers',
description:
'Geeft wat de portefeuille van AssetApp nu waard is: het totaal, en per asset de hoeveelheid, '
+ 'koers, waarde, inleg en het ongerealiseerde resultaat. Staat onbewerkt delen uit, dan komen '
+ 'het totaal en de verdeling per asset terug als marges, zonder bedrag per asset. De koers is '
+ 'het laatst opgeslagen punt en geen verse marktkoers. Wijzigt niets.',
inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }),
scope: 'read',
async run(_args, context) {
assertScope(wealthSummary.scope);
const t = await vertalerVoor(context);
t.beginCall();
const onbewerkt = t.precision.rawValues;
const posities = positionsByAsset(undefined, { ownOnly: true });
const cur = getBaseCurrency();
const money = n => `${cur} ${n.toFixed(2)}`;
const totaalTekst = n => {
const uit = t.toAi({ fiatValue: n }, { entity: 'wealth', role: 'total' }).fiatValue;
return typeof uit === 'number' ? money(uit) : `${cur} ${uit}`;
};
const buiten = buitenTotalen(new Map(posities.map(p => [p.id, p.decimals])));
const buitenRegel = !buiten.length ? [] : [
`Niet in dit totaal (Vermogen op het account): ${buiten.map(b => `${BUITEN_LABEL[b.soort]} ${totaalTekst(b.waarde)}`
+ (b.zonderKoers ? ` (${b.zonderKoers} zonder koers)` : '')).join('; ')}.`,
'',
];
const buitenVelden = buiten.length ? ['wealthScope'] : [];
let totaal = 0;
let zonderKoers = 0;
const rijen = [];
for (const p of posities) {
if (p.net === 0 && p.buy_events === 0) continue;
const punt = latestPricePoint(p.id);
const m = assetMetrics(p, punt ? punt.fiatPrice : null);
if (m.value != null) totaal += m.value; else zonderKoers += 1;
rijen.push({ p, m, punt });
}
if (!rijen.length) {
if (buiten.length) {
return { text: ['Deze portefeuille heeft geen eigen bezit.', '', ...buitenRegel].join('\n').trim(),
fieldsShared: ['baseCurrency', 'fiatValue', ...buitenVelden] };
}
return { text: 'Deze portefeuille heeft nog geen bezit: er is niets om een waarde van te geven.',
fieldsShared: [] };
}
const kop = `Totale waarde nu: ${totaalTekst(totaal)}`
+ ` over ${rijen.length} ${rijen.length === 1 ? 'asset' : 'assets'}`
+ (zonderKoers ? `, waarvan ${zonderKoers} zonder koers en dus zonder waarde` : '')
+ '.';
if (!onbewerkt) {
return {
text: [kop, '', ...buitenRegel, ...verdelingsblok(rijen, totaal, t),
'Per asset komen de BEDRAGEN niet mee zolang onbewerkt delen uit staat: een waarde per asset '
+ 'met de koers erbij legt de hoeveelheid vast. Zet die schuif aan voor een portefeuille '
+ 'zonder echte gegevens.',
].join('\n'),
fieldsShared: ['baseCurrency', 'fiatValue', 'symbol', ...buitenVelden],
};
}
const regels = limitedList(rijen, ({ p, m, punt }) => {
const v = t.toAi(p, { entity: 'asset' });
const f = isMetal(p) ? metalFactor(p.unit) : 1;
const stuk = [`${v.symbol}: ${+(m.units / f).toFixed(8)} ${v.unit}`];
if (m.value != null) stuk.push(`waarde ${money(m.value)} tegen ${money(punt.fiatPrice * f)} per ${v.unit}`);
else stuk.push('geen koers, dus geen waarde');
stuk.push(`ingelegd ${money(m.invested)}`);
if (m.unrealized != null) stuk.push(`ongerealiseerd ${money(m.unrealized)}`);
if (m.avgPrice != null) stuk.push(`gemiddelde aankoopkoers ${money(m.avgPrice * f)}`);
return `- ${stuk.join(', ')}`;
}).lines;
return {
text: [kop, '', ...buitenRegel, 'Per asset:', ...regels, '',
'De koers is het laatst opgeslagen koerspunt; dit proces haalt geen verse koersen op.',
].join('\n'),
fieldsShared: ['baseCurrency', 'symbol', 'unit', 'decimals', 'amount', 'balance', 'fiatPrice', 'fiatFee',
'fiatValue', ...buitenVelden],
};
},
});
export const WEALTH_TOOLS = Object.freeze([wealthSummary]);
+540
View File
@@ -0,0 +1,540 @@
// Generated, do not edit. sha256:46e628fbaa9521b9e3fa070e20e19f0d90e6d2eab65f98990ca26652e15c420e
import { CONFIG, ENUMS } from '../../shared/config.js';
import { unitToSubunit, unitPriceToBase } from '../../shared/units.js';
import { assertScope } from '../ai/gate.js';
import { limitedList } from '../ai/limits.js';
import { listAssets } from '../data/assets.js';
import { listAccounts } from '../data/accounts.js';
import { getBaseCurrency } from '../data/portfolio.js';
import { createAiProposal, listAiProposals } from '../data/ai-proposals.js';
import { vertalerVoor } from './session.mjs';
import { idVoorHandle } from './handles.mjs';
const NOTE_MAX = 500;
const AFHANDELEN = 'De gebruiker vindt het voorstel in AssetApp onder Gebeurtenissen, "Af te handelen", '
+ 'en keurt het daar goed of wijst het af. Er is nu nog niets gewijzigd.';
function onbekend(soort, gevraagd) {
return {
text: `De verwijzing '${gevraagd}' hoort bij geen ${soort} in deze administratie. `
+ `Vraag de huidige verwijzingen op met admin_overview; mogelijk is de rij verwijderd of komt de `
+ `verwijzing uit een andere installatie.`,
fieldsShared: [],
};
}
function klaargezet(t, id, omschrijving) {
const handle = t.toAi({ id }, { entity: 'proposal' }).id;
return {
text: [`Voorstel ${handle} staat klaar: ${omschrijving}`, AFHANDELEN].join('\n'),
fieldsShared: t.fieldsShared(),
};
}
const BELOFTE =
'Wijzigt niets: legt een voorstel vast dat de eigenaar van de app goedkeurt of afwijst.';
const NOTE_SCHEMA = Object.freeze({
type: 'string',
maxLength: NOTE_MAX,
description: 'Waarom je dit voorstelt; de gebruiker leest het op de kaart.',
});
const verwijzing = (soort, voorbeeld) => Object.freeze({
type: 'string',
maxLength: 64,
description: `De verwijzing naar ${soort} (${voorbeeld}).`,
});
const MOMENT_SCHEMA = (wat) => Object.freeze({
type: 'string',
maxLength: 32,
description: `${wat}: 2026-03-01 of een ISO-tijdstempel.`,
});
const SYMBOOL_SCHEMA = Object.freeze({
type: 'string',
maxLength: 16,
description: 'Het symbool van het asset zoals admin_overview het gaf: BTC, XAU, EUR.',
});
function assetVoorSymbool(gevraagd) {
const gezocht = String(gevraagd ?? '').trim().toUpperCase();
if (!gezocht) return null;
return listAssets().find(a => String(a.symbol).toUpperCase() === gezocht) || null;
}
function onbekendSymbool(gevraagd) {
const bestaat = listAssets().map(a => a.symbol).join(', ');
return {
text: `'${gevraagd}' is geen asset in deze administratie. Deze bestaan wel: ${bestaat}.`,
fieldsShared: ['symbol'],
};
}
const stelBronkeuzeVoor = Object.freeze({
name: 'propose_source_choice',
title: 'Een bronkeuze voorstellen',
description:
'Stelt voor een koersbron van één asset als eerste te zetten voor spot of historie, of eruit te '
+ 'halen; zie integration_health. ' + BELOFTE,
inputSchema: Object.freeze({
type: 'object',
properties: {
symbol: SYMBOOL_SCHEMA,
choice: { type: 'string', enum: ['spot', 'history'] },
provider: { type: 'string', maxLength: 60, description: 'De sleutel van de bron.' },
action: { type: 'string', enum: [...ENUMS.SOURCE_CHOICE_ACTION] },
note: NOTE_SCHEMA,
},
required: ['symbol', 'choice', 'provider', 'action'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const asset = assetVoorSymbool(args?.symbol);
if (!asset) return onbekendSymbool(args?.symbol);
const id = await createAiProposal({
kind: 'source_choice',
assetId: asset.id,
scope: String(args?.choice ?? ''),
key: String(args?.provider ?? ''),
value: String(args?.action ?? ''),
note: notitie(args?.note),
});
const wat = args.action === 'first' ? 'als eerste zetten voor' : 'weghalen uit';
return klaargezet(t, id, `${args.provider} ${wat} de ${args.choice === 'spot' ? 'spot' : 'historie'} van ${asset.symbol}.`);
},
});
const stelKoerspuntVoor = Object.freeze({
name: 'propose_price_point',
title: 'Een koerspunt voorstellen',
description:
'Stelt voor om één koers van één asset op één moment vast te leggen in AssetApp, voor een koers die '
+ 'de app zelf niet kan ophalen. Wordt bij goedkeuring een handmatige koers op dagniveau. ' + BELOFTE,
inputSchema: Object.freeze({
type: 'object',
properties: {
symbol: SYMBOOL_SCHEMA,
fiatPrice: {
type: 'number',
exclusiveMinimum: 0,
description: 'De koers in de rekenvaluta per hele eenheid (per BTC, per ozt goud).',
},
observedAt: MOMENT_SCHEMA('Wanneer deze koers gold'),
note: NOTE_SCHEMA,
},
required: ['symbol', 'fiatPrice', 'observedAt'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const asset = assetVoorSymbool(args?.symbol);
if (!asset) return onbekendSymbool(args?.symbol);
const id = await createAiProposal({
kind: 'price_point',
assetId: asset.id,
fiatPrice: unitPriceToBase(args.fiatPrice, asset),
observedAt: String(args.observedAt),
note: notitie(args?.note),
});
return klaargezet(t, id, `een koers voor ${asset.symbol} op ${args.observedAt}.`);
},
});
const stelRekeningVoor = Object.freeze({
name: 'propose_account',
title: 'Een account voorstellen',
description:
'Stelt voor om een nieuw account aan te maken in AssetApp: de plek waar bezit wordt aangehouden. '
+ BELOFTE + ' Een boeking kan pas naar dit account verwijzen nádat het voorstel is goedgekeurd; '
+ 'vraag daarna opnieuw admin_overview op om de verwijzing te krijgen.',
inputSchema: Object.freeze({
type: 'object',
properties: {
name: {
type: 'string',
maxLength: 100,
description: 'De naam zoals de gebruiker hem in de app wil zien, bijvoorbeeld "Kluis thuis".',
},
accountKind: {
type: 'string',
enum: [...ENUMS.ACCOUNT_KIND],
description: 'vault is een kluis voor edelmetaal.',
},
ownCustody: {
type: 'boolean',
description: 'True als de gebruiker het bezit zelf beheert, false als een derde het voor hem houdt.',
},
note: NOTE_SCHEMA,
},
required: ['name', 'accountKind', 'ownCustody'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const id = await createAiProposal({
kind: 'account',
name: String(args?.name ?? ''),
accountKind: String(args?.accountKind ?? ''),
isOwnCustody: !!args?.ownCustody,
note: notitie(args?.note),
});
return klaargezet(t, id, `een nieuw account van de soort ${args.accountKind}.`);
},
});
const stelBoekingVoor = Object.freeze({
name: 'propose_event',
title: 'Een boeking voorstellen',
description:
'Stelt één boeking voor. buy en interest: alleen toAccount; sell: alleen fromAccount; transfer: '
+ 'allebei. buy en sell vragen fiatPrice. ' + BELOFTE,
inputSchema: Object.freeze({
type: 'object',
properties: {
symbol: SYMBOOL_SCHEMA,
eventType: {
type: 'string',
enum: [...ENUMS.LEDGER_EVENT_TYPE],
description: 'transfer is tussen twee eigen accounts.',
},
amount: {
type: 'number',
exclusiveMinimum: 0,
description: 'De hoeveelheid in de HELE eenheid (0.25 = een kwart bitcoin), niet in de kleinste.',
},
occurredAt: MOMENT_SCHEMA('Wanneer de boeking plaatsvond'),
fromAccount: verwijzing('het account waar het vandaan komt', 'account:7f3a9c'),
toAccount: verwijzing('het account waar het heen gaat', 'account:7f3a9c'),
fiatPrice: {
type: 'number',
exclusiveMinimum: 0,
description: 'De koers in de rekenvaluta per hele eenheid op het moment van de boeking.',
},
fiatFee: {
type: 'number',
minimum: 0,
description: 'Kosten als bedrag in de rekenvaluta (niet per eenheid).',
},
unitFee: {
type: 'number',
minimum: 0,
description: 'Kosten in het asset zelf (hele eenheid), bovenop amount. Alleen bij sell en transfer.',
},
note: NOTE_SCHEMA,
},
required: ['symbol', 'eventType', 'amount', 'occurredAt'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const asset = assetVoorSymbool(args?.symbol);
if (!asset) return onbekendSymbool(args?.symbol);
const rekeningIds = listAccounts().map(a => a.id);
let fromAccountId = null;
let toAccountId = null;
if (args?.fromAccount != null) {
fromAccountId = idVoorHandle(t, 'account', args.fromAccount, rekeningIds);
if (fromAccountId === null) return onbekend('account', args.fromAccount);
}
if (args?.toAccount != null) {
toAccountId = idVoorHandle(t, 'account', args.toAccount, rekeningIds);
if (toAccountId === null) return onbekend('account', args.toAccount);
}
const amount = unitToSubunit(args.amount, asset);
const id = await createAiProposal({
kind: 'event',
assetId: asset.id,
eventType: String(args?.eventType ?? ''),
amount,
fiatPrice: args?.fiatPrice == null ? null : unitPriceToBase(args.fiatPrice, asset),
fiatFee: args?.fiatFee == null ? null : Number(args.fiatFee),
unitFee: args?.unitFee == null ? null : unitToSubunit(args.unitFee, asset),
observedAt: String(args.occurredAt),
fromAccountId,
toAccountId,
note: notitie(args?.note),
});
return klaargezet(t, id, `een boeking van de soort ${args.eventType} op ${args.occurredAt}.`);
},
});
function notitie(waarde) {
return typeof waarde === 'string' && waarde.trim() ? waarde.slice(0, NOTE_MAX) : null;
}
const stelVerwijderingVoor = Object.freeze({
name: 'propose_account_deletion',
title: 'Een account laten verwijderen',
description:
'Stelt voor om een bestaand account uit AssetApp te verwijderen. Dit is ONOMKEERBAAR zodra de '
+ 'eigenaar het goedkeurt, dus de app vraagt daar een aparte bevestiging voor en neemt het niet mee '
+ 'in "alles goedkeuren". Een account waar boekingen, balansen of scans aan hangen kan niet weg; het '
+ 'voorstel weigert dan bij het goedkeuren en zegt hoeveel er in de weg staat. ' + BELOFTE,
inputSchema: Object.freeze({
type: 'object',
properties: {
account: verwijzing('het account dat weg mag', 'account:7f3a9c'),
note: NOTE_SCHEMA,
},
required: ['account'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const accountId = idVoorHandle(t, 'account', args?.account, listAccounts().map(a => a.id));
if (accountId === null) return onbekend('account', args?.account);
const id = await createAiProposal({
kind: 'account_delete',
accountId,
note: notitie(args?.note),
});
return klaargezet(t, id, `${args.account} verwijderen. Dit is onomkeerbaar.`);
},
});
const stelPortefeuilleVoor = Object.freeze({
name: 'propose_portfolio',
title: 'Een portefeuille voorstellen',
description:
'Stelt voor om een nieuwe, LEGE portefeuille (administratie) aan te maken naast de huidige. Bedoeld '
+ 'om een demonstratie- of testadministratie op te bouwen zonder de echte aan te raken. Kopieert '
+ 'niets: er komt geen data en geen sleutel in. Wisselt ook niet, want dat is een herstart van de '
+ 'app; die keuze blijft bij de eigenaar, en jouw voorstellen landen tot dan in de huidige '
+ 'administratie. ' + BELOFTE,
inputSchema: Object.freeze({
type: 'object',
properties: {
name: {
type: 'string',
maxLength: 60,
description: 'De naam van de nieuwe portefeuille, zoals de eigenaar hem in de app ziet.',
},
currency: {
type: 'string',
description: 'ECB-code van de rekenvaluta (USD); ligt daarna vast. Standaard die van de huidige.',
},
note: NOTE_SCHEMA,
},
required: ['name'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const currency = args?.currency ? String(args.currency).toUpperCase() : getBaseCurrency();
const id = await createAiProposal({
kind: 'portfolio',
name: String(args?.name ?? ''),
currency,
note: notitie(args?.note),
});
return klaargezet(t, id, `een nieuwe lege portefeuille met de naam '${args.name}', in ${currency}.`);
},
});
const stelVoorkeurVoor = Object.freeze({
name: 'propose_preference',
title: 'Een instelling voorstellen',
description:
'Stelt voor één instelling van deze administratie te wijzigen: elke instelling die admin_overview '
+ 'toont, dus ook thema, accent, de vormgeving (glas, gloed) en de timers. Niet de AI-schuiven zelf. Een ongeldige waarde wordt '
+ 'bij het goedkeuren geweigerd. ' + BELOFTE,
inputSchema: Object.freeze({
type: 'object',
properties: {
scope: {
type: 'string',
enum: [...CONFIG.AI.PROPOSABLE_PREF_SCOPES],
description: 'De groep van de instelling.',
},
key: {
type: 'string',
maxLength: 60,
description: 'De sleutel zoals admin_overview hem toont, bijvoorbeeld currency.',
},
value: {
type: ['string', 'number', 'boolean'],
description: 'De nieuwe waarde.',
},
note: NOTE_SCHEMA,
},
required: ['scope', 'key', 'value'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const id = await createAiProposal({
kind: 'preference',
scope: String(args?.scope ?? ''),
key: String(args?.key ?? ''),
value: JSON.stringify(args?.value ?? null),
note: notitie(args?.note),
});
return klaargezet(t, id,
`de instelling ${args.scope}.${args.key} op ${JSON.stringify(args.value)} zetten.`);
},
});
const stelBalansVoor = Object.freeze({
name: 'propose_valuation',
title: 'Een balans voorstellen',
description:
'Stelt de STAND van één account in één asset op een peildatum voor. Dat is een ANKER: na goedkeuring '
+ 'IS de stand deze balans plus de boekingen erna, dus een verkeerde balans corrigeert het grootboek '
+ 'stil. Gebruik alleen een onafhankelijke bron (afschrift, handelsplatform, telling); een stand die '
+ 'je uit je eigen voorstellen optelt, klopt per constructie en controleert niets. Vergt een koers in '
+ 'de historie op die datum, anders blijft het voorstel staan met die reden. ' + BELOFTE,
inputSchema: Object.freeze({
type: 'object',
properties: {
symbol: SYMBOOL_SCHEMA,
account: verwijzing('het account waarvan dit de stand is', 'account:7f3a9c'),
balance: {
type: 'number',
minimum: 0,
description: 'De stand in de HELE eenheid (0.25 = een kwart bitcoin). Nul mag: leeg op die datum.',
},
valuedOn: MOMENT_SCHEMA('De peildatum'),
note: NOTE_SCHEMA,
},
required: ['symbol', 'account', 'balance', 'valuedOn'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const asset = assetVoorSymbool(args?.symbol);
if (!asset) return onbekendSymbool(args?.symbol);
const accountId = idVoorHandle(t, 'account', args?.account, listAccounts().map(a => a.id));
if (accountId === null) return onbekend('account', args?.account);
const balance = unitToSubunit(args.balance, asset);
const id = await createAiProposal({
kind: 'valuation',
assetId: asset.id,
accountId,
balance,
observedAt: String(args.valuedOn),
note: notitie(args?.note),
});
return klaargezet(t, id, `de stand van ${args.account} in ${asset.symbol} op ${args.valuedOn}.`);
},
});
const voorstelToestand = Object.freeze({
name: 'proposal_status',
title: 'De toestand van een voorstel',
description:
'Zegt of een voorstel is goedgekeurd, afgewezen of nog wacht, en waarom een uitvoering mislukte. '
+ 'Verandert niets. Vraag dit vóór je een boeking voorstelt naar een pas voorgesteld account.',
inputSchema: Object.freeze({
type: 'object',
properties: {
proposal: {
type: 'string',
maxLength: 32,
description: 'De verwijzing die een propose_-tool teruggaf (proposal:7f3a9c). Weglaten voor alles wat nog openstaat.',
},
},
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const alle = listAiProposals();
const gevraagd = String(args?.proposal ?? '').trim();
if (!gevraagd) {
const open = alle.filter(p => p.status === 'pending');
if (!open.length) {
return { text: 'Er staat geen voorstel meer open; alles is afgehandeld.', fieldsShared: t.fieldsShared() };
}
const { lines } = limitedList(open, p => `- ${voorstelHandle(t, p.id)} (${p.kind}): wacht op een oordeel.`);
return {
text: [`${open.length} voorstel(len) wachten nog op een oordeel:`, ...lines].join('\n'),
fieldsShared: [...t.fieldsShared(), 'kind', 'status'],
};
}
const rij = alle.find(p => voorstelHandle(t, p.id) === gevraagd);
if (!rij) {
return {
text: `'${gevraagd}' is geen voorstel dat ik terugvind. Ik kijk ${CONFIG.AI.PROPOSAL_PAGE_SIZE} voorstellen terug.`,
fieldsShared: [],
};
}
const uitleg = {
pending: 'wacht nog op een oordeel van de eigenaar.',
accepted: 'is goedgekeurd en uitgevoerd.',
rejected: 'is afgewezen en niet uitgevoerd.',
}[rij.status] ?? `heeft een onbekende status (${rij.status}).`;
const reden = rij.status === 'pending' && rij.detail
? ` Een eerdere poging om hem uit te voeren mislukte: ${rij.detail}`
: '';
return {
text: `Voorstel ${gevraagd} (${rij.kind}) ${uitleg}${reden}`,
fieldsShared: [...t.fieldsShared(), 'kind', 'status', ...(reden ? ['detail'] : [])],
};
},
});
const voorstelHandle = (t, id) => String(t.toAi({ id }, { entity: 'proposal' }).id);
export const WRITE_TOOLS = Object.freeze([
stelKoerspuntVoor, stelRekeningVoor, stelBoekingVoor, stelBalansVoor, stelVerwijderingVoor,
stelPortefeuilleVoor, stelVoorkeurVoor, stelBronkeuzeVoor, voorstelToestand,
]);
+165
View File
@@ -0,0 +1,165 @@
// Generated, do not edit. sha256:1c5123afde8b42e8b0c7944a90a9a3952b38ed44748eb715f09bb8aa35f593d5
import { mkdir, readdir, rename, rm, readFile, writeFile, copyFile, stat } from 'node:fs/promises';
import { join, sep, resolve } from 'node:path';
import {
installDir, portfolioDir, archiveDir, assertValidName, sameName,
activeName, writeActiveName, listPortfolios,
} from './portfolios.mjs';
import { run } from './db/conn.mjs';
import { createDatabase } from './db.js';
import { CONFIG } from '../shared/config.js';
import { fail } from '../shared/app-error.js';
import { capacityReached } from './license.mjs';
const BUREN = ['-wal', '-shm'];
const HANDLE_SLEUTEL = 'ai_handle_secret';
const bestaat = async (pad) => { try { await stat(pad); return true; } catch { return false; } };
function eisNietActief(naam, sleutel) {
if (sameName(naam, activeName())) {
fail(sleutel, { naam },
`'${naam}' is the portfolio that is open now; switch to another one first.`);
}
}
function eisBestaat(naam) {
const gevonden = listPortfolios().find(p => sameName(p.naam, naam));
if (!gevonden) fail('portfolios.unknownPortfolio', { naam }, `There is no portfolio '${naam}'.`);
return gevonden;
}
function eisVrijeNaam(naam) {
assertValidName(naam);
const botsing = listPortfolios().find(p => sameName(p.naam, naam));
if (botsing) {
fail('portfolios.nameInUse', { naam: botsing.naam },
`A portfolio '${botsing.naam}' already exists; names that differ only in case count as the same.`);
}
return naam;
}
export async function createPortfolio(naam, { kopieVan = null, baseCurrency = null } = {}) {
if (kopieVan && baseCurrency) {
throw new Error('A copy keeps the unit of account of its source; do not pass baseCurrency with kopieVan.');
}
const max = capacityReached('portfolios', listPortfolios().length);
if (max !== null) {
fail('portfolios.maxFree', { max },
`The free version holds ${max} portfolio(s). A licence lifts that; nothing is removed in the meantime.`);
}
eisVrijeNaam(naam);
const bron = kopieVan ? eisBestaat(kopieVan) : null;
const doel = portfolioDir(naam);
await mkdir(doel, { recursive: true });
try {
if (!bron) {
createDatabase(join(doel, 'db.sqlite'), { baseCurrency: baseCurrency ?? CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY });
return { naam, gekopieerdVan: null, database: true, geheimen: false };
}
const database = await kopieerDatabase(bron.naam, doel);
const geheimen = await kopieerGeheimen(bron.naam, doel);
return { naam, gekopieerdVan: bron.naam, database, geheimen };
} catch (e) {
await rm(doel, { recursive: true, force: true });
throw e;
}
}
async function kopieerDatabase(bronNaam, doelMap) {
const doel = join(doelMap, 'db.sqlite');
if (sameName(bronNaam, activeName())) {
run(`VACUUM INTO '${doel.replace(/'/g, "''")}'`, []);
return true;
}
const bron = join(portfolioDir(bronNaam), 'db.sqlite');
if (!await bestaat(bron)) return false;
await copyFile(bron, doel);
for (const b of BUREN) if (await bestaat(bron + b)) await copyFile(bron + b, doel + b);
return true;
}
async function kopieerGeheimen(bronNaam, doelMap) {
const bron = join(portfolioDir(bronNaam), 'secrets.json');
if (!await bestaat(bron)) return false;
const store = JSON.parse(await readFile(bron, 'utf-8'));
delete store[HANDLE_SLEUTEL];
await writeFile(join(doelMap, 'secrets.json'), JSON.stringify(store, null, 2), { mode: 0o600 });
return true;
}
export function activatePortfolio(naam) {
const doel = eisBestaat(naam);
if (sameName(doel.naam, activeName())) {
return { naam: doel.naam, gewisseld: false, supervised: isSupervised() };
}
writeActiveName(doel.naam);
return { naam: doel.naam, gewisseld: true, supervised: isSupervised() };
}
export const isSupervised = () => process.env.ASSETAPP_SUPERVISED === '1';
export async function renamePortfolio(vanNaam, naarNaam) {
const bron = eisBestaat(vanNaam);
eisNietActief(bron.naam, 'portfolios.activeCannotRename');
if (!sameName(bron.naam, naarNaam)) eisVrijeNaam(naarNaam);
else assertValidName(naarNaam);
await rename(portfolioDir(bron.naam), portfolioDir(naarNaam));
return { van: bron.naam, naar: naarNaam };
}
export async function archivePortfolio(naam) {
const bron = eisBestaat(naam);
eisNietActief(bron.naam, 'portfolios.activeCannotArchive');
await mkdir(archiveDir(), { recursive: true });
const doelNaam = await vrijeArchiefNaam(bron.naam);
await rename(portfolioDir(bron.naam), join(archiveDir(), doelNaam));
return { naam: bron.naam, archiefNaam: doelNaam, map: join(archiveDir(), doelNaam) };
}
async function vrijeArchiefNaam(naam) {
if (!await bestaat(join(archiveDir(), naam))) return naam;
for (let n = 2; n < 1000; n++) {
const kandidaat = `${naam} (${n})`;
if (!await bestaat(join(archiveDir(), kandidaat))) return kandidaat;
}
fail('portfolios.archiveFull', { naam }, `The archive is full of directories named '${naam}'.`);
}
export async function listArchived() {
let entries;
try { entries = await readdir(archiveDir(), { withFileTypes: true }); }
catch (e) { if (e.code === 'ENOENT') return []; throw e; }
const uit = [];
for (const e of entries) {
if (!e.isDirectory()) continue;
const map = join(archiveDir(), e.name);
let gearchiveerd = null;
try { gearchiveerd = (await stat(map)).mtime.toISOString(); } catch { }
uit.push({ naam: e.name, map, gearchiveerd });
}
return uit.sort((a, b) => String(b.gearchiveerd).localeCompare(String(a.gearchiveerd)));
}
export async function purgeArchived(naam) {
assertValidName(naam.replace(/ \(\d+\)$/, ''));
const map = resolve(join(archiveDir(), naam));
if (!map.startsWith(resolve(archiveDir()) + sep)) {
throw new Error(`'${naam}' points outside the archive.`);
}
if (!await bestaat(map)) fail('portfolios.notInArchive', { naam }, `There is no '${naam}' in the archive.`);
await rm(map, { recursive: true, force: true });
return { naam, map };
}
export async function overviewPortfolios() {
return {
actief: activeName(),
installatie: installDir(),
portefeuilles: listPortfolios(),
archief: await listArchived(),
};
}
+206
View File
@@ -0,0 +1,206 @@
// Generated, do not edit. sha256:82917722010d150a76bf3e84051a5132d98df9cd1713d95fe6238971925d39e8
import { mkdirSync, readFileSync, writeFileSync, readdirSync, renameSync, statSync, rmSync } from 'node:fs';
import { join, resolve, dirname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fail } from '../shared/app-error.js';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const POINTER_NAME = 'active.json';
const LEGACY_POINTER_NAME = 'actief.json';
const POINTER_FIELD = 'portfolio';
const LEGACY_POINTER_FIELD = 'portefeuille';
export const RESERVED_DIRS = Object.freeze(['tls', 'Archive']);
const ARCHIVE_NAME = 'Archive';
export const DEFAULT_NAME = 'MyAssets';
export const MIGRATION_NAME = 'Test';
const PORTFOLIO_FILE_PREFIXES = ['db.sqlite', 'secrets.json'];
export function installDir() {
return process.env.ASSETAPP_DATA_DIR || join(ROOT, 'data');
}
export function pointerFile() {
return join(installDir(), POINTER_NAME);
}
export function archiveDir() {
return join(installDir(), ARCHIVE_NAME);
}
export function portfolioDir(name) {
assertValidName(name);
const basis = resolve(installDir());
const doel = resolve(join(installDir(), name));
if (!doel.startsWith(basis + sep)) {
throw new Error(`Portfolio '${name}' would land outside the data directory.`);
}
return doel;
}
const FORBIDDEN_CHARS = /[<>:"/\\|?*]/;
const RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
const MAX_LENGTH = 64;
const hasControlChar = (name) => [...name].some(teken => teken.codePointAt(0) < 0x20);
export function assertValidName(name) {
if (typeof name !== 'string' || name.length === 0) {
fail('portfolios.nameRequired', null, 'A portfolio needs a name.');
}
if (name.length > MAX_LENGTH) {
fail('portfolios.nameTooLong', { max: MAX_LENGTH }, `A portfolio name is at most ${MAX_LENGTH} characters long.`);
}
if (name === '.' || name === '..') {
fail('portfolios.nameIsDirRef', { naam: name }, `'${name}' is not a name but a reference to a directory.`);
}
if (FORBIDDEN_CHARS.test(name) || hasControlChar(name)) {
fail('portfolios.nameForbiddenChars', null, 'A portfolio name may not contain any of these characters: < > : " / \\ | ? *');
}
if (/[ .]$/.test(name)) {
fail('portfolios.nameTrailing', null, 'A portfolio name may not end in a space or a dot.');
}
if (/^ /.test(name)) {
fail('portfolios.nameLeadingSpace', null, 'A portfolio name may not start with a space.');
}
if (RESERVED_NAMES.test(name.split('.')[0])) {
fail('portfolios.nameReserved', { naam: name }, `'${name}' is a reserved name on Windows and cannot become a directory.`);
}
if (RESERVED_DIRS.some(gereserveerd => normalizeName(gereserveerd) === normalizeName(name))) {
fail('portfolios.nameTaken', { naam: name }, `'${name}' is a name the app uses itself.`);
}
return name;
}
export function normalizeName(name) {
if (typeof name !== 'string') {
throw new TypeError(`Een portefeuillenaam is tekst; gekregen: ${typeof name}.`);
}
return name.normalize('NFC').toLocaleLowerCase('nl');
}
export function sameName(a, b) {
if (a == null || b == null) return false;
return normalizeName(a) === normalizeName(b);
}
export function readActiveName() {
let rauw;
try { rauw = readFileSync(pointerFile(), 'utf-8'); }
catch (e) {
if (e.code !== 'ENOENT') throw e;
try { rauw = readFileSync(join(installDir(), LEGACY_POINTER_NAME), 'utf-8'); }
catch (e2) { if (e2.code === 'ENOENT') return null; throw e2; }
}
let data;
try { data = JSON.parse(rauw); }
catch { throw new Error(`${pointerFile()} is not valid JSON; repair or remove the file.`); }
const naam = data && (data[POINTER_FIELD] ?? data[LEGACY_POINTER_FIELD]);
if (typeof naam !== 'string' || naam.length === 0) {
throw new Error(`${pointerFile()} names no portfolio (field '${POINTER_FIELD}').`);
}
return assertValidName(naam);
}
export function listPortfolios() {
let entries;
try { entries = readdirSync(installDir(), { withFileTypes: true }); }
catch (e) { if (e.code === 'ENOENT') return []; throw e; }
const actief = readActiveName();
return entries
.filter(e => e.isDirectory())
.map(e => e.name)
.filter(naam => !RESERVED_DIRS.some(g => normalizeName(g) === normalizeName(naam)))
.map(naam => {
const map = join(installDir(), naam);
const db = join(map, 'db.sqlite');
return {
naam,
actief: actief !== null && sameName(naam, actief),
heeftDatabase: bestaat(db),
heeftGeheimen: bestaat(join(map, 'secrets.json')),
laatstGebruikt: mtimeOf(db),
};
})
.sort((a, b) => a.naam.localeCompare(b.naam, 'nl'));
}
export function writeActiveName(name) {
assertValidName(name);
mkdirSync(installDir(), { recursive: true });
writeFileSync(pointerFile(), `${JSON.stringify({ [POINTER_FIELD]: name }, null, 2)}\n`, 'utf-8');
return name;
}
export function activeName() {
return readActiveName() || DEFAULT_NAME;
}
export function activeDir() {
return portfolioDir(activeName());
}
const bestaat = (pad) => { try { statSync(pad); return true; } catch { return false; } };
const mtimeOf = (pad) => { try { return statSync(pad).mtime.toISOString(); } catch { return null; } };
function looseFiles() {
let entries;
try { entries = readdirSync(installDir(), { withFileTypes: true }); }
catch (e) { if (e.code === 'ENOENT') return []; throw e; }
return entries
.filter(e => e.isFile())
.map(e => e.name)
.filter(naam => PORTFOLIO_FILE_PREFIXES.some(prefix => naam.startsWith(prefix)));
}
export function migrateLooseLayout() {
const losse = looseFiles();
if (!losse.length) return null;
const doelMap = portfolioDir(MIGRATION_NAME);
if (bestaat(join(doelMap, 'db.sqlite'))) {
const losseNamen = losse.join(', ');
throw new Error(
`There are loose database files in ${installDir()} (${losseNamen}) and there is already a database `
+ `in ${doelMap}. The app does not guess which one is the right one.\n`
+ 'Almost always a process from before this layout is still running: an MCP server or an old app '
+ 'server then recreates the loose file every time. Close those processes (and restart your AI '
+ 'client), check which file is the largest, and remove or rename the loose one so it no longer '
+ 'starts with "db.sqlite" or "secrets.json".');
}
mkdirSync(doelMap, { recursive: true });
for (const naam of losse) renameSync(join(installDir(), naam), join(doelMap, naam));
return MIGRATION_NAME;
}
function hernoemOudeWijzer() {
const oud = join(installDir(), LEGACY_POINTER_NAME);
if (!bestaat(oud) || bestaat(pointerFile())) return;
const naam = readActiveName();
if (naam) writeActiveName(naam);
rmSync(oud, { force: true });
}
export function prepareLayout() {
mkdirSync(installDir(), { recursive: true });
hernoemOudeWijzer();
const migrated = migrateLooseLayout();
let naam = readActiveName();
let herkomst = `uit ${POINTER_NAME}`;
if (!naam) {
naam = writeActiveName(migrated || DEFAULT_NAME);
herkomst = migrated ? `losse bestanden verhuisd naar '${migrated}'` : 'nieuw aangemaakt';
} else if (migrated) {
herkomst = `uit ${POINTER_NAME}; losse bestanden verhuisd naar '${migrated}'`;
}
mkdirSync(portfolioDir(naam), { recursive: true });
return { name: naam, migrated, regel: `portefeuille: ${naam} (${herkomst}) | map: ${portfolioDir(naam)}` };
}
+47
View File
@@ -0,0 +1,47 @@
// Generated, do not edit. sha256:2f3082db0160d4179616e98a296a8ab4a5647a67cfd48f2cc38d5f27a7ead0fa
import { priceSeriesExport } from './data/price-history.js';
import { getBaseCurrency } from './data/portfolio.js';
export const CSV_COLUMNS = ['instrument', 'source', 'quote', 'unit', 'resolution', 'observed_at_utc', 'fetched_at_utc', 'price'];
const iso = (v) => {
if (!v) return '';
const ms = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(v) || v.length === 10 ? v : `${v}Z`);
return Number.isFinite(ms) ? new Date(ms).toISOString() : '';
};
const cel = (v) => {
const s = v == null ? '' : String(v);
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
export function priceHistoryCsv(rows, quote) {
const regels = [CSV_COLUMNS.join(',')];
for (const r of rows) {
const observed = iso(r.observedAt);
const price = Number(r.fiatPrice);
if (!observed || !Number.isFinite(price) || price <= 0) continue;
regels.push([
`${r.kind}:${String(r.symbol || '').toUpperCase()}`, r.source, quote, r.kind === 'metal' ? 'g' : '',
r.resolution, observed, iso(r.createdAt), price,
].map(cel).join(','));
}
return regels.join('\n') + '\n';
}
function bestandsnaam() {
const d = new Date();
const p = n => String(n).padStart(2, '0');
return `koershistorie-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}.csv`;
}
export function exportPriceHistory(res) {
const body = Buffer.from(priceHistoryCsv(priceSeriesExport(), getBaseCurrency()), 'utf-8');
res.writeHead(200, {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${bestandsnaam()}"`,
'Content-Length': body.length,
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
});
res.end(body);
}
+267
View File
@@ -0,0 +1,267 @@
// Generated, do not edit. sha256:e3489a6085d47798380dafd4c4992473c5fdddb26fbfb6bf3b1a4ff191ab8437
import { AUTH_KINDS, CONFIG } from '../../shared/config.js';
import { AppError, fail } from '../../shared/app-error.js';
import { toLocalIso } from '../../shared/time.js';
const TIMEOUT_MS = 12000;
const CAPABILITIES = ['spot', 'daily_history', 'history_point'];
function tokenize(path, externalId) {
return path.split('.').map(seg => {
if (seg === '{external_id}') {
if (externalId == null) fail('integrations.price.externalIdMissing', null, 'The output path uses {external_id} but there is no external id.');
return externalId;
}
return /^\d+$/.test(seg) ? Number(seg) : seg;
});
}
export function resolveScalar(obj, path, externalId) {
let cur = obj;
for (const key of tokenize(path, externalId)) {
if (cur == null) return undefined;
cur = cur[key];
}
return cur;
}
export function resolveColumn(obj, path, externalId) {
const o = path.indexOf('{}');
if (o >= 0 && (path.indexOf('[]') < 0 || o < path.indexOf('[]'))) {
const before = path.slice(0, o);
const after = path.slice(o + 2).replace(/^\./, '');
const map = before ? resolveScalar(obj, before, externalId) : obj;
if (map == null || typeof map !== 'object' || Array.isArray(map)) {
fail('integrations.price.expectedObject', { pad: before || '(root)' }, `Expected an object at '${before || '(root)'}'.`);
}
return Object.entries(map).map(([key, value]) =>
(after === '@key' ? key : after ? resolveScalar(value, after, externalId) : value));
}
const i = path.indexOf('[]');
if (i < 0) fail('integrations.price.listPathWithoutBrackets', { pad: path }, `List path without []: '${path}'.`);
const before = path.slice(0, i);
const after = path.slice(i + 2).replace(/^\./, '');
const arr = before ? resolveScalar(obj, before, externalId) : obj;
if (!Array.isArray(arr)) fail('integrations.price.expectedArray', { pad: before || '(root)' }, `Expected an array at '${before || '(root)'}'.`);
return arr.map(el => (after ? resolveScalar(el, after, externalId) : el));
}
function applyScalarTransform(v, transform) {
switch (transform) {
case 'none': return v;
case 'to_number': { const n = Number(v); return Number.isFinite(n) ? n : null; }
case 'ms_to_s': { const n = Number(v); return Number.isFinite(n) ? Math.floor(n / 1000) : null; }
case 'last_per_day': return v;
case 'troy_ounce_to_gram': { const n = Number(v); const f = CONFIG.METALS.GRAMS_PER_TROY_OUNCE;
return (Number.isFinite(n) && f) ? n / f : null; }
case 'iso_date_to_s': {
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return null;
const ms = Date.parse(`${v}T00:00:00Z`);
return Number.isFinite(ms) ? Math.floor(ms / 1000) : null;
}
case 'ms_to_s_local_close': {
const n = Number(v);
if (!Number.isFinite(n)) return null;
const utc = new Date(n);
if (n % 86400000 === 0 && utc.getUTCDate() === 1) return null;
const lokaal = toLocalIso(utc.toISOString());
if (/T00:00:00(\.000)?$/.test(lokaal)) return Date.parse(`${lokaal.slice(0, 10)}T00:00:00Z`) / 1000;
return Math.floor(n / 1000);
}
default: fail('integrations.price.unknownTransform', { transform }, `Unknown transform: '${transform}'.`);
}
}
function outputsFor(provider, capability) {
const rows = provider.outputs.filter(o => o.capability === capability);
if (!rows.length) fail('integrations.price.noOutputMapping', { provider: provider.key, capability }, `Provider ${provider.key}: no output mapping for '${capability}'.`);
return rows;
}
function assertPriced(value, provider) {
if (Number.isFinite(value)) return;
fail('integrations.price.noValidPrice', { provider: provider.key }, `Provider ${provider.key}: no valid price in the response.`);
}
export function mapSpot(parsed, provider, externalId) {
const out = {};
for (const o of outputsFor(provider, 'spot'))
out[o.internalField] = applyScalarTransform(resolveScalar(parsed, o.jsonPath, externalId), o.transform);
assertPriced(out.price, provider);
return { price: out.price };
}
export function mapHistoryPoint(parsed, provider, externalId, dateIso) {
const out = {};
for (const o of outputsFor(provider, 'history_point'))
out[o.internalField] = applyScalarTransform(resolveScalar(parsed, o.jsonPath, externalId), o.transform);
const time = Number.isFinite(out.time) ? out.time : Math.floor(Date.parse(`${dateIso}T00:00:00Z`) / 1000);
if (!Number.isFinite(time)) fail('integrations.price.invalidDate', { provider: provider.key, datum: dateIso }, `Provider ${provider.key}: invalid date '${dateIso}'.`);
assertPriced(out.price, provider);
return { time, price: out.price };
}
export function mapDailyHistory(parsed, provider, externalId) {
const cols = {}; let len = null; let lastPerDay = false;
for (const o of outputsFor(provider, 'daily_history')) {
if (o.transform === 'last_per_day') lastPerDay = true;
const vals = resolveColumn(parsed, o.jsonPath, externalId)
.map(v => applyScalarTransform(v, o.transform === 'last_per_day' ? 'none' : o.transform));
cols[o.internalField] = vals;
if (len == null) len = vals.length;
else if (vals.length !== len) fail('integrations.price.columnsUneven', { provider: provider.key }, `Provider ${provider.key}: the output columns have unequal length.`);
}
let rows = [];
for (let i = 0; i < (len || 0); i++) {
const time = cols.time ? cols.time[i] : null;
const price = cols.price?.[i];
if (Number.isFinite(time) && Number.isFinite(price)) rows.push({ time, price });
}
rows.sort((a, b) => a.time - b.time);
if (lastPerDay) {
const byDay = new Map();
for (const r of rows) byDay.set(new Date(r.time * 1000).toISOString().slice(0, 10), r);
rows = [...byDay.values()].sort((a, b) => a.time - b.time);
}
return rows;
}
function mapResult(parsed, provider, capability, externalId, dateIso) {
if (capability === 'spot') return mapSpot(parsed, provider, externalId);
if (capability === 'history_point') return mapHistoryPoint(parsed, provider, externalId, dateIso);
return mapDailyHistory(parsed, provider, externalId);
}
function endpointFor(provider, capability) {
const e = provider.endpoints.find(ep => ep.capability === capability);
if (!e) fail('integrations.price.capabilityNotConfigured', { provider: provider.key, capability }, `Provider ${provider.key}: capability '${capability}' is not configured.`);
return e;
}
function mapRowFor(provider, symbol) {
const rows = provider.symbols || [];
return rows.find(s => !s.isCatchAll && s.symbol === symbol) || rows.find(s => s.isCatchAll) || null;
}
function externalIdFor(provider, symbol) {
const row = mapRowFor(provider, symbol);
return row ? row.externalId : (symbol != null ? symbol : null);
}
function subst(tpl, externalId, symbol, dateIso, providerKey) {
if (tpl == null) return tpl;
let s = tpl;
if (s.includes('{external_id}')) {
if (externalId == null) fail('integrations.price.symbolMissing', { provider: providerKey }, `Provider ${providerKey}: {external_id} is used but no symbol was given.`);
s = s.replaceAll('{external_id}', encodeURIComponent(externalId));
}
if (s.includes('{symbol}')) s = s.replaceAll('{symbol}', encodeURIComponent(symbol ?? ''));
if (s.includes('{date}')) {
const ymd = String(dateIso || '').slice(0, 10).replace(/-/g, '');
if (ymd.length !== 8) fail('integrations.price.templateDateInvalid', { provider: providerKey, datum: dateIso }, `Provider ${providerKey}: the template uses {date} but date '${dateIso}' is invalid.`);
s = s.replaceAll('{date}', ymd);
}
return s;
}
const toHex = buf => [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
export async function hmacHeaders(hmac, method, fullPath, body, creds) {
const ts = Date.now();
const tokens = { timestamp: String(ts), method, full_path: fullPath, body: body || '' };
const message = hmac.messageParts.split(',').map(tok => {
if (!(tok in tokens)) fail('integrations.price.unknownHmacToken', { token: tok }, `Unknown hmac message token: '${tok}'.`);
return tokens[tok];
}).join('');
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(creds.api_secret),
{ name: 'HMAC', hash: hmac.hashAlgorithm }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
const signature = hmac.encoding === 'hex' ? toHex(sig) : Buffer.from(sig).toString('base64');
const headers = { [hmac.keyHeader]: creds.api_key, [hmac.signatureHeader]: signature, [hmac.timestampHeader]: String(ts) };
if (hmac.windowHeader && hmac.windowMs != null) headers[hmac.windowHeader] = String(hmac.windowMs);
return headers;
}
export async function buildRequest(provider, capability, symbol, dateIso, creds) {
if (!CAPABILITIES.includes(capability)) fail('integrations.price.unknownCapability', { capability }, `Unknown capability: '${capability}'.`);
const e = endpointFor(provider, capability);
if (e.method !== 'GET') fail('integrations.price.onlyGet', { provider: provider.key }, `Provider ${provider.key}: only GET is supported.`);
const externalId = externalIdFor(provider, symbol);
const kind = provider.authenticationKind;
if (kind !== 'none' && !AUTH_KINDS[kind]) fail('integrations.price.unknownAuthKind', { soort: kind }, `Unknown authenticationKind: '${kind}'.`);
const fields = kind === 'none' ? [] : AUTH_KINDS[kind].secret_fields;
const hasCreds = kind !== 'none' && fields.every(f => creds && creds[f]);
if (provider.authenticationRequired && !hasCreds)
fail('integrations.price.keyMissing', { provider: provider.key }, `Provider ${provider.key}: a key is required but missing or incomplete.`);
const path = subst(e.pathTemplate, externalId, symbol, dateIso, provider.key);
let query = subst(e.queryTemplate, externalId, symbol, dateIso, provider.key) || '';
if (kind === 'query_key' && hasCreds) {
if (!provider.authenticationParameterName) fail('integrations.price.parameterNameMissing', { provider: provider.key }, `Provider ${provider.key}: authenticationParameterName is missing for query_key.`);
const qp = `${encodeURIComponent(provider.authenticationParameterName)}=${encodeURIComponent(creds.api_key)}`;
query = query ? `${query}&${qp}` : qp;
}
const fullPath = path + (query ? `?${query}` : '');
const headers = {};
if (hasCreds) {
if (kind === 'header_key') {
if (!provider.authenticationHeaderName) fail('integrations.price.headerNameMissing', { provider: provider.key }, `Provider ${provider.key}: authenticationHeaderName is missing for header_key.`);
headers[provider.authenticationHeaderName] = creds.api_key;
} else if (kind === 'bearer') {
headers['Authorization'] = `Bearer ${creds.api_key}`;
} else if (kind === 'basic') {
headers['Authorization'] = `Basic ${Buffer.from(`${creds.api_key}:${creds.api_secret}`).toString('base64')}`;
} else if (kind === 'hmac') {
if (!provider.hmac) fail('integrations.price.hmacConfigMissing', { provider: provider.key }, `Provider ${provider.key}: the hmac config is missing.`);
Object.assign(headers, await hmacHeaders(provider.hmac, e.method, fullPath, '', creds));
}
}
return { url: provider.baseUrl + fullPath, method: e.method, headers, externalId };
}
function describeBody(res, text) {
const type = String(res.headers?.get?.('content-type') || 'no type').split(';')[0].trim();
return `no JSON (${type}, ${Buffer.byteLength(text || '', 'utf8')} bytes)`;
}
function tagged(e, extra) { return Object.assign(e, extra); }
export async function priceCall(provider, capability, symbol, dateIso, creds, { withRaw = false } = {}) {
let built;
try { built = await buildRequest(provider, capability, symbol, dateIso, creds); } catch (e) { throw tagged(e, { notSent: true }); }
const { url, method, headers, externalId } = built;
let res;
try {
res = await fetch(url, { method, headers, signal: AbortSignal.timeout(TIMEOUT_MS) });
} catch (err) {
fail('integrations.price.networkError', { provider: provider.key, capability, reden: err.name === 'TimeoutError' ? 'timeout' : err.message },
`${provider.key} ${capability}: network error (${err.name === 'TimeoutError' ? 'timeout' : err.message}).`);
}
const text = await res.text().catch(() => '');
let parsed;
let notJson = null;
try { parsed = JSON.parse(text); } catch { notJson = describeBody(res, text); }
const httpStatus = res.status;
if (!res.ok) {
const m = parsed && (parsed.error || parsed.message);
const detail = typeof m === 'string' && m ? ` ${m}` : notJson ? ` ${notJson}` : '';
throw tagged(new AppError('integrations.price.httpFailed', { provider: provider.key, capability, status: res.status, detail },
`${provider.key} ${capability} failed (${res.status})${detail}.`), { httpStatus });
}
if (notJson) {
throw tagged(new AppError('integrations.price.notJson', { provider: provider.key, capability, detail: notJson },
`${provider.key} ${capability}: the answer is ${notJson}.`), { httpStatus });
}
let result;
try {
result = inStorageUnit(mapResult(parsed, provider, capability, externalId, dateIso), provider, symbol);
} catch (e) { throw tagged(e, { httpStatus }); }
return withRaw ? { result, raw: parsed } : result;
}
export function failureEntry(provider, capability, symbol, e, latencyMs) {
const outcome = e?.notSent ? 'refused' : e?.key === 'integrations.price.noValidPrice' ? 'empty' : 'fail';
return {
target: 'provider', providerKey: provider?.key, operation: capability,
subject: typeof symbol === 'string' && symbol ? symbol : null, outcome,
httpStatus: e?.httpStatus ?? null, errorKey: e?.key ?? null, detail: e?.message ?? String(e),
latencyMs: e?.notSent ? null : latencyMs,
};
}
function inStorageUnit(result, provider, symbol) {
const row = mapRowFor(provider, symbol);
if (!row || row.quoteUnit !== 'ozt') return result;
const f = CONFIG.METALS.GRAMS_PER_TROY_OUNCE;
const perGram = r => (Number.isFinite(r.price) ? { ...r, price: r.price / f } : r);
return Array.isArray(result) ? result.map(perGram) : perGram(result);
}
+21
View File
@@ -0,0 +1,21 @@
// Generated, do not edit. sha256:6d030f08e2df0805a10aaaf982941c7fff895ab138d87d0dfa035a84ed9a5d86
import { createRegistry } from '../shared/registry.js';
const needs = (keys) => (entry) => {
for (const k of keys) if (entry[k] === undefined) throw new Error(`'${entry.id}' lacks '${k}'`);
};
export const apiRoutes = createRegistry('api route', { validate: needs(['method', 'path', 'run']) });
export const dataModules = createRegistry('data module', { validate: needs(['module']) });
export const services = createRegistry('service');
export const mcpTools = createRegistry('mcp tool', {
validate: (e) => {
needs(['tool'])(e);
if (e.tool.name !== e.id) throw new Error(`mcp tool '${e.id}' carries the name '${e.tool.name}'`);
},
});
export const healthChecks = createRegistry('health check', { validate: needs(['severity', 'run', 'countsFrom']) });
+79
View File
@@ -0,0 +1,79 @@
// Generated, do not edit. sha256:267314982d9469bd348aa537982dfb5f7b94157d526aea6cbeeb8cefdfe06595
import { randomBytes } from 'node:crypto';
import { CONFIG } from '../shared/config.js';
const nieuwToken = () => randomBytes(32).toString('hex');
export function createSessionStore({ ttlMs = CONFIG.AUTH.SESSION_TTL_MS, now = Date.now } = {}) {
const sessies = new Map();
const verlopen = (aangemaakt) => now() - aangemaakt >= ttlMs;
return {
create() {
for (const [token, aangemaakt] of sessies) if (verlopen(aangemaakt)) sessies.delete(token);
const token = nieuwToken();
sessies.set(token, now());
return token;
},
isValid(token) {
if (typeof token !== 'string' || !token) return false;
const aangemaakt = sessies.get(token);
if (aangemaakt === undefined) return false;
if (verlopen(aangemaakt)) { sessies.delete(token); return false; }
return true;
},
drop(token) {
return sessies.delete(token);
},
size() {
return sessies.size;
},
};
}
export function createAttemptLimiter({
free = CONFIG.AUTH.LOGIN_ATTEMPTS.FREE,
baseLockMs = CONFIG.AUTH.LOGIN_ATTEMPTS.BASE_LOCK_MS,
maxLockMs = CONFIG.AUTH.LOGIN_ATTEMPTS.MAX_LOCK_MS,
now = Date.now,
} = {}) {
const pogingen = new Map();
const wachttijd = (missers) => {
if (missers <= free) return 0;
return Math.min(maxLockMs, baseLockMs * 2 ** (missers - free - 1));
};
return {
retryAfterMs(adres) {
const staat = pogingen.get(adres);
if (!staat) return 0;
const over = staat.laatste + wachttijd(staat.missers) - now();
if (over <= 0) {
if (over < -maxLockMs) pogingen.delete(adres);
return 0;
}
return over;
},
fail(adres) {
const staat = pogingen.get(adres) || { missers: 0, laatste: 0 };
staat.missers += 1;
staat.laatste = now();
pogingen.set(adres, staat);
return wachttijd(staat.missers);
},
succeed(adres) {
pogingen.delete(adres);
},
size() {
return pogingen.size;
},
};
}
+67
View File
@@ -0,0 +1,67 @@
// Generated, do not edit. sha256:9c19f153125a535056932eee985b7b6abf166ab4af48fe08f3cd2fb0dbf6edfd
import { spawn } from 'node:child_process';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { CONFIG } from '../shared/config.js';
const HIER = dirname(fileURLToPath(import.meta.url));
const SERVER = join(HIER, 'index.mjs');
const STOPSIGNALEN = ['SIGINT', 'SIGTERM', 'SIGHUP'];
let kind = null;
let stoppen = false;
let eersteStart = true;
function startServer() {
const env = { ...process.env, ASSETAPP_SUPERVISED: '1' };
if (!eersteStart) delete env.ASSETAPP_OPEN;
eersteStart = false;
kind = spawn(process.execPath, [SERVER], {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env,
});
kind.on('exit', (code, signal) => {
kind = null;
if (stoppen) return;
if (code === CONFIG.PORTFOLIO.RESTART_EXIT_CODE) {
console.log('[supervisor] portefeuille gewisseld; de server start opnieuw.');
startServer();
return;
}
if (signal) {
console.log(`[supervisor] de server stopte op signaal ${signal}.`);
process.exit(1);
}
process.exit(code ?? 0);
});
kind.on('error', (e) => {
console.error(`[supervisor] kan de server niet starten: ${e.message}`);
process.exit(1);
});
}
for (const signaal of STOPSIGNALEN) {
process.on(signaal, () => {
stoppen = true;
if (!kind) process.exit(0);
kind.kill(signaal);
const hard = setTimeout(() => { if (kind) kind.kill('SIGKILL'); }, 10_000);
hard.unref();
});
}
process.on('exit', () => { if (kind) kind.kill('SIGKILL'); });
process.on('uncaughtException', (e) => {
console.error(`[supervisor] onverwachte fout: ${e.message}`);
if (kind) kind.kill('SIGKILL');
process.exit(1);
});
if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) startServer();
export { STOPSIGNALEN, SERVER };