Versie 0.3.3

This commit is contained in:
Atreides Assets committed 2026-10-30 12:28:48 +01:00
1 parent 1cf6eeed29
commit 6e741a51fe
57 files changed
+1362 -2135

No files matched your search

+1 -4
View File
@@ -1,7 +1,6 @@
// Generated, do not edit. sha256:37f1d463b03d2b5d702238cb632740786fc53e2072e4691719327d700c59d56c
// Generated, do not edit. sha256:fd977989705c07dc15f67feef3edd971f7d3fa3c94e064e7618ee1e171f4ce7e
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';
@@ -15,9 +14,7 @@ function assetCount() {
}
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.`);
}
+3 -1
View File
@@ -1,7 +1,8 @@
// Generated, do not edit. sha256:ebad02dba29ae50bcbaf9c21bb59936cf43f6be868e86ac00d0e6055f404b26f
// Generated, do not edit. sha256:36119ec87b42a3428d74ef9e16a174eb6f028b536ffdcea60da0bd3e970bac77
import * as db from '../db.js';
import { ACCOUNT_SOURCE_KINDS } from '../../shared/config.js';
import { fail } from '../../shared/app-error.js';
import { hasFeature } from '../license.mjs';
import { eventsNetByAccount } from './events.js';
import { trackUpdate, trackUpdateWhere, trackDelete, trackDeleteWhere, withOrigin } from './change-log.js';
@@ -34,6 +35,7 @@ export function mergeCheck(survivorId, victimId) {
}
export async function mergeAccounts(survivorId, victimId) {
if (!hasFeature('accountMerge')) fail('accounts.mergePaid', null, 'Merging accounts belongs to the full edition.');
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}).`);
+7 -3
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:ebca807e87ebabedc0e0ca214ca3bec781ace05d2be4dc4ed8d664c767fe1d63
// Generated, do not edit. sha256:1cab6c030597184175a0db5bb668aea9074d983a3f04b5255055f6a7df3c07d8
import * as db from '../db.js';
import { DASHBOARD } from '../../shared/config.js';
import { toSearchName } from '../../shared/search-name.js';
@@ -84,16 +84,20 @@ function assertWidgetType(type) {
}
}
export async function addWidget(profileId, { instanceKey, type, gridX = 0, gridY = 0, assetId = null, settings = null }) {
export async function addWidget(profileId, { instanceKey, type, gridX = 0, gridY = 0, assetId = null, settings = null, spanWidth = 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 span = spanWidth ?? DASHBOARD.WIDGETS[type].defaultSpan;
if (!Number.isInteger(span) || span < 1 || span > DASHBOARD.GRID_COLUMNS) {
throw new Error(`Widget width ${spanWidth} is not 1 to ${DASHBOARD.GRID_COLUMNS} columns.`);
}
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]);
VALUES (?,?,?,?,?,?,NULL,?,?)`, [profileId, assetId, key, gridX, gridY, span, type, json]);
await db.persist();
return true;
}
+5 -3
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:329990ff910102e84cb67048624a913db4948fa6ef673de3eb40c65c633864ba
// Generated, do not edit. sha256:bf3b89b4669e19b78df9f4741f695e6025ab616c2c0a5f205b84e84c1ce21f92
import * as db from '../db.js';
import { ASSET_DEFINITIONS, DASHBOARD } from '../../shared/config.js';
import { toSearchName } from '../../shared/search-name.js';
@@ -38,6 +38,8 @@ async function ensureAccount({ name, kind }) {
return createAccount({ name, kind, isOwnCustody: own, scanEnabled: false, taxCategory: null });
}
const SETUP_SPAN = 4;
function placeInFreeSpace(rows, items) {
const cols = DASHBOARD.GRID_COLUMNS;
const heightOf = (type, h) => (h > 0 ? h : DASHBOARD.WIDGETS[type]?.defaultH || 1);
@@ -47,7 +49,7 @@ function placeInFreeSpace(rows, items) {
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;
const span = w.spanWidth ?? 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));
@@ -74,7 +76,7 @@ export async function completeSetup({ assets = [], persona = 'broad', accounts =
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 } }));
if (widgets.includes('spot')) assetIds.forEach(id => add.push({ type: 'spot', instanceKey: `spot-${id}`, assetId: id, prefer: 0, settings: { momentum: false }, spanWidth: SETUP_SPAN }));
let widgetCount = 0;
for (const w of placeInFreeSpace(listWidgets(profileId), add)) if (await addWidget(profileId, w)) widgetCount++;
+1 -7
View File
@@ -1,9 +1,8 @@
// Generated, do not edit. sha256:297c7a1d9ed86e15053cd37bb7126f5fb5b54d94b67e8da901fb66f370ca9301
// Generated, do not edit. sha256:bd828bb299e2fd6141c6257cc9c20d5c7b2b7cc88932040bd9b98ce4dfeddd09
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';
@@ -123,11 +122,6 @@ export async function deleteValuation(id) {
}
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]);
+11 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:95f28c26a77b79beac5f8c6818bd25243362641c1d8549949427a847e4f810f9
// Generated, do not edit. sha256:232164f95c0eb5137f0c20ab218bdb9ffd81d26142889e12d2caf11dfbc181a3
import { DatabaseSync } from 'node:sqlite';
export const BUSY_TIMEOUT_MS = 5000;
@@ -30,6 +30,16 @@ export function withOtherDatabase(pad, fn) {
}
}
export function peekDatabase(pad, fn) {
const peek = new DatabaseSync(pad, { readOnly: true });
const all = (sql, params = []) => peek.prepare(sql).all(...bind(params)).map(rij => ({ ...rij }));
try {
return fn({ all, one: (sql, params = []) => all(sql, params)[0] ?? null });
} finally {
peek.close();
}
}
export function closeDatabase() {
if (!db) return;
db.close();
+5 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:52135637bc4779b2b5f755ec50fedf41d2fa7b65d90a35f911c4e68f0b093874
// Generated, do not edit. sha256:9946c137bb48925908ab17af61fbcdfb543a87d4be25e34812a95d4084af9425
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';
@@ -176,7 +176,10 @@ export function seedDashboardProfile() {
return true;
}
const FRESH_VALUES = Object.freeze({ app: Object.freeze({ market_spot: 'first' }) });
const FRESH_VALUES = Object.freeze({
app: Object.freeze({ market_spot: 'first' }),
ai: Object.freeze({ enabled: 0 }),
});
export function seedPreferences() {
for (const [scope, keys] of Object.entries(PREFERENCES)) {
+131
View File
@@ -0,0 +1,131 @@
// Generated, do not edit. sha256:c7cd149bf7a3cd10c21ce3f8a14833aa6c606b7953811447bfbf09af17e22088
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { CONFIG } from '../shared/config.js';
import { priceFromTotal } from '../shared/units.js';
import { toSearchName } from '../shared/search-name.js';
import { initDatabase, closeDatabase, one } from './db.js';
import { portfolioDir, listPortfolios } from './portfolios.mjs';
import { hasFeature } from './license.mjs';
import { completeSetup } from './data/onboarding.js';
import { createEvent } from './data/events.js';
import { createValuation } from './data/valuations.js';
export const DEMO_MONTHS = 30;
const BTC_EUR = {
'2023-01': 21000, '2023-04': 27000, '2023-07': 26500, '2023-10': 32000,
'2024-01': 39000, '2024-02': 48000, '2024-03': 63000, '2024-04': 59000, '2024-05': 62000, '2024-06': 58000,
'2024-07': 59000, '2024-08': 54000, '2024-09': 57000, '2024-10': 63000, '2024-11': 88000, '2024-12': 92000,
'2025-01': 97000, '2025-02': 84000, '2025-03': 78000, '2025-04': 82000, '2025-05': 92000, '2025-06': 91000,
'2025-07': 100000, '2025-08': 97000, '2025-09': 96000, '2025-10': 98000, '2025-11': 82000, '2025-12': 76000,
'2026-01': 79000, '2026-02': 74000, '2026-03': 72000, '2026-04': 78000, '2026-05': 84000, '2026-06': 88000,
'2026-07': 92000, '2026-08': 90000, '2026-09': 94000, '2026-10': 96000,
};
const MONTHS = Object.keys(BTC_EUR).sort();
const SAT = 1e8;
const CENT = 100;
const ACCOUNTS = Object.freeze({
exchange: { name: 'Exchange', kind: 'exchange' },
wallet: { name: 'Hardware wallet', kind: 'wallet' },
bank: { name: 'Bank', kind: 'bank' },
});
const iso = (y, m, day) => new Date(Date.UTC(y, m, day, 10, 0, 0)).toISOString();
function priceOn(at) {
const key = at.slice(0, 7);
const idx = MONTHS.findIndex(k => k >= key);
const here = BTC_EUR[MONTHS[idx < 0 ? MONTHS.length - 1 : idx]];
const next = BTC_EUR[MONTHS[Math.min((idx < 0 ? MONTHS.length - 1 : idx) + 1, MONTHS.length - 1)]];
const frac = (Number(at.slice(8, 10)) - 1) / 30;
return Math.round(here + (next - here) * frac);
}
export function demoRows(today = new Date()) {
const rows = [];
const pos = { exchange: 0, wallet: 0, bank: 0 };
const nu = today.toISOString();
const start = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - (DEMO_MONTHS - 1), 1));
const add = (r) => { if (r.at <= nu) rows.push(r); return r.at <= nu; };
for (let i = 0; i < DEMO_MONTHS; i++) {
const y = start.getUTCFullYear(), m = start.getUTCMonth() + i;
if (i === 0 && add({ kind: 'event', type: 'buy', asset: 'EUR', to: 'bank', at: iso(y, m, 1), amount: 4000 * CENT, fiatPrice: 1 })) pos.bank += 4000 * CENT;
if (add({ kind: 'event', type: 'buy', asset: 'EUR', to: 'bank', at: iso(y, m, 1), amount: 900 * CENT, fiatPrice: 1 })) pos.bank += 900 * CENT;
for (const day of [2, 9, 16, 23]) {
const at = iso(y, m, day);
const price = priceOn(at);
const fee = 0.4;
const amount = Math.round((75 - fee) / price * SAT);
if (add({ kind: 'event', type: 'buy', asset: 'BTC', to: 'exchange', at, amount, fiatPrice: priceFromTotal(75, amount, 8, fee, 'buy'), fiatFee: fee })) {
pos.exchange += amount;
}
}
if (i % 4 === 3) {
const amount = pos.exchange - 100_000;
if (amount > 0 && add({ kind: 'event', type: 'transfer', asset: 'BTC', from: 'exchange', to: 'wallet', at: iso(y, m, 26), amount })) {
pos.exchange -= amount; pos.wallet += amount;
}
}
if (i === 20) {
const amount = 2_000_000;
if (add({ kind: 'event', type: 'transfer', asset: 'BTC', from: 'wallet', to: 'exchange', at: iso(y, m, 10), amount })) {
pos.wallet -= amount; pos.exchange += amount;
const at = iso(y, m, 11);
if (add({ kind: 'event', type: 'sell', asset: 'BTC', from: 'exchange', at, amount, fiatPrice: priceOn(at), fiatFee: 2 })) pos.exchange -= amount;
}
}
if (m % 3 === 2) {
const amount = Math.round(pos.bank * 0.015 / 4);
if (amount > 0 && add({ kind: 'event', type: 'interest', asset: 'EUR', to: 'bank', at: iso(y, m, 28), amount, fiatPrice: 1 })) pos.bank += amount;
}
const peil = (account, asset) => {
const at = new Date(Date.UTC(y, m, 28, 22, 0, 0)).toISOString();
add({ kind: 'valuation', asset, account, at, valuedOn: at.slice(0, 10), balance: pos[account],
fiatRate: asset === 'EUR' ? 1 : priceOn(at) });
};
if (m % 6 === 5) { peil('bank', 'EUR'); if (pos.wallet > 0) peil('wallet', 'BTC'); }
if (m % 12 === 11 && pos.exchange > 0) peil('exchange', 'BTC');
}
return rows;
}
export async function seedDemo(today = new Date()) {
const accounts = Object.values(ACCOUNTS);
await completeSetup({ assets: ['BTC'], persona: 'broad', accounts, widgets: ['standing', 'spot'] });
const assetId = (symbol) => one(`SELECT id FROM Asset WHERE symbol = ?`, [symbol]).id;
const accountId = (key) => one(`SELECT id FROM Account WHERE searchName = ?`, [toSearchName(ACCOUNTS[key].name)]).id;
const ids = { BTC: assetId('BTC'), EUR: assetId('EUR') };
for (const r of demoRows(today)) {
if (r.kind === 'event') {
await createEvent({
assetId: ids[r.asset], type: r.type, occurredAt: r.at, amount: r.amount, fiatPrice: r.fiatPrice ?? null,
fiatFee: r.fiatFee ?? null, fromAccountId: r.from ? accountId(r.from) : null,
toAccountId: r.to ? accountId(r.to) : null, source: 'manual',
});
} else {
await createValuation({ assetId: ids[r.asset], accountId: accountId(r.account), balance: r.balance,
fiatRate: r.fiatRate, valuedOn: r.valuedOn, rateAt: r.at, source: 'manual' });
}
}
}
export async function createDemoPortfolio(today = new Date()) {
const map = portfolioDir(CONFIG.PORTFOLIO.DEMO_NAME);
mkdirSync(map, { recursive: true });
initDatabase(join(map, 'db.sqlite'));
try {
await seedDemo(today);
} finally {
closeDatabase();
}
}
export async function ensureDemo(layout) {
if (!layout.fresh || hasFeature('portfolios') || listPortfolios().some(p => p.demo)) return false;
await createDemoPortfolio();
return true;
}
+10 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:b9a3a6b8f1e1fdbee54a4d9e67411a1659c1ea24437a706bbf1a46f443c3b26d
// Generated, do not edit. sha256:a2dbaa6a0b5bd22d02f0f5bce9b00466d953e01821cc546e4e9315325da7765a
import { createServer as createHttpServer } from 'node:http';
import { createServer as createHttpsServer } from 'node:https';
import { spawn } from 'node:child_process';
@@ -8,6 +8,8 @@ import { initDatabase, one } from './db.js';
import { getProvider } from './data/providers.js';
import { moveProviderKeys } from './keys.mjs';
import { prepareLayout } from './portfolios.mjs';
import { ensureDemo } from './demo.mjs';
import { CONFIG } from '../shared/config.js';
import { licenseState, appVersion, hasFeature } from './license.mjs';
import { getPref } from './data/preferences.js';
import { adoptInstallLocale } from './installation.mjs';
@@ -29,7 +31,13 @@ function openInBrowser(url) {
let credentials, tls, binding, dbRegel, portefeuilleRegel;
try {
portefeuilleRegel = prepareLayout().regel;
const layout = prepareLayout();
portefeuilleRegel = layout.regel;
try {
if (await ensureDemo(layout)) portefeuilleRegel += ` | demo: ${CONFIG.PORTFOLIO.DEMO_NAME} aangemaakt`;
} catch (e) {
console.error(`[assetapp] demo niet aangemaakt: ${e.message}`);
}
credentials = await loadCredentials();
tls = await loadTlsOptions();
binding = resolveBinding({ tls: Boolean(tls), credentials, host: process.env.ASSETAPP_HOST || null });
+33 -4
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:1c5123afde8b42e8b0c7944a90a9a3952b38ed44748eb715f09bb8aa35f593d5
// Generated, do not edit. sha256:a56d0789a1d23362a10302d4a28be7384372f8aa39766c83b8df92fa4456f11e
import { mkdir, readdir, rename, rm, readFile, writeFile, copyFile, stat } from 'node:fs/promises';
import { join, sep, resolve } from 'node:path';
@@ -6,7 +6,7 @@ import {
installDir, portfolioDir, archiveDir, assertValidName, sameName,
activeName, writeActiveName, listPortfolios,
} from './portfolios.mjs';
import { run } from './db/conn.mjs';
import { run, peekDatabase } from './db/conn.mjs';
import { createDatabase } from './db.js';
import { CONFIG } from '../shared/config.js';
import { fail } from '../shared/app-error.js';
@@ -45,7 +45,7 @@ export async function createPortfolio(naam, { kopieVan = null, baseCurrency = nu
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);
const max = capacityReached('portfolios', listPortfolios().filter(p => !p.demo).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.`);
@@ -155,11 +155,40 @@ export async function purgeArchived(naam) {
return { naam, map };
}
function keyFigures(naam) {
const pad = join(portfolioDir(naam), 'db.sqlite');
const perSoort = rijen => Object.fromEntries(rijen.map(r => [r.kind, r.n]));
try {
return peekDatabase(pad, ({ one, all }) => {
const backup = one(`SELECT value FROM Preference WHERE scope = 'app' AND key = 'last_backup_at'`);
const eerste = [
one(`SELECT MIN(occurredAt) AS d FROM Event WHERE type <> 'reservation'`).d,
one(`SELECT MIN(rateAt) AS d FROM Valuation WHERE accountId IS NOT NULL`).d,
].filter(Boolean).sort()[0] || null;
const portfolio = one(`SELECT baseCurrency, createdAt FROM Portfolio WHERE id = 1`);
return {
munt: portfolio?.baseCurrency ?? null,
aangemaakt: portfolio?.createdAt ?? null,
boekingen: one(`SELECT COUNT(*) AS n FROM Event WHERE type <> 'reservation'`).n,
balansen: one(`SELECT COUNT(*) AS n FROM Valuation`).n,
assets: perSoort(all(`SELECT kind, COUNT(*) AS n FROM Asset WHERE id IN (
SELECT assetId FROM Event WHERE type <> 'reservation' UNION SELECT assetId FROM Valuation)
GROUP BY kind`)),
accounts: perSoort(all(`SELECT kind, COUNT(*) AS n FROM Account WHERE isActive = 1 GROUP BY kind`)),
eersteJaar: eerste ? Number(eerste.slice(0, 4)) : null,
laatsteBackup: (backup && JSON.parse(backup.value)) || null,
};
});
} catch {
return null;
}
}
export async function overviewPortfolios() {
return {
actief: activeName(),
installatie: installDir(),
portefeuilles: listPortfolios(),
portefeuilles: listPortfolios().map(p => ({ ...p, kerncijfers: p.heeftDatabase ? keyFigures(p.naam) : null })),
archief: await listArchived(),
};
}
+5 -2
View File
@@ -1,8 +1,9 @@
// Generated, do not edit. sha256:82917722010d150a76bf3e84051a5132d98df9cd1713d95fe6238971925d39e8
// Generated, do not edit. sha256:ac3a8e4322658b62e93903e91a12ebe573e29b3520917e4d09d4d77210b67abb
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';
import { CONFIG } from '../shared/config.js';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -124,6 +125,7 @@ export function listPortfolios() {
return {
naam,
actief: actief !== null && sameName(naam, actief),
demo: sameName(naam, CONFIG.PORTFOLIO.DEMO_NAME),
heeftDatabase: bestaat(db),
heeftGeheimen: bestaat(join(map, 'secrets.json')),
laatstGebruikt: mtimeOf(db),
@@ -194,6 +196,7 @@ export function prepareLayout() {
hernoemOudeWijzer();
const migrated = migrateLooseLayout();
let naam = readActiveName();
const fresh = !naam && !migrated && listPortfolios().length === 0;
let herkomst = `uit ${POINTER_NAME}`;
if (!naam) {
naam = writeActiveName(migrated || DEFAULT_NAME);
@@ -202,5 +205,5 @@ export function prepareLayout() {
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)}` };
return { name: naam, migrated, fresh, regel: `portefeuille: ${naam} (${herkomst}) | map: ${portfolioDir(naam)}` };
}