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