Files
AtreidesAssetsFree/tests/wealth/data.test.mjs
T
2026-10-29 11:57:29 +01:00

1363 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Generated, do not edit. sha256:992d8ca6dcf32528e84e3de9e67ad72a7550a9fbea3e6b15efe619915b129368
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import {
VERMOGENSCATEGORIE, CATEGORIE_PER_ASSET_KIND, categorieGekozen, categorieVoor, categorieVoorAsset, reikwijdteVoor,
} from '../../client/tax/constants.js';
import { ENUMS } from '../../shared/config.js';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
let liveSpot = null;
mock.module('../../client/price/providers.js', {
namedExports: {
ensurePriceHistoryFor: async () => {},
ensurePointsAtFor: async () => {},
liveSpotPriceFor: async () => liveSpot,
lastSpotSource: () => (liveSpot != null ? 'Spotbron' : null),
},
});
for (const mod of ['accounts', 'assets', 'events', 'positions', 'preferences', 'price-history', 'providers', 'valuations']) {
mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) });
}
const { setPref } = await import('../../server/data/preferences.js');
const wealth = await import('../../client/wealth/data.js');
const HUIDIG_JAAR = new Date().getFullYear();
let btc, acc;
beforeEach(() => {
harness.recreate();
testProviderId = null;
liveSpot = null;
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'unit')`);
btc = harness.exports.lastId();
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Kluis','kluis','vault',1,0,1,NULL)`);
acc = harness.exports.lastId();
});
function koop({ sats, fiatPrice, at, fiatFee = null, priceOrigin = null }) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fiatFee, priceOrigin, toAccountId, source, createdAt)
VALUES (?, 'buy', ?, ?, ?, ?, ?, ?, 'manual', ?)`,
[btc, at, sats, fiatPrice, fiatFee, priceOrigin, acc, at]);
}
function verkoop({ sats, fiatPrice, at, fiatFee = null, priceOrigin = null }) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fiatFee, priceOrigin, fromAccountId, source, createdAt)
VALUES (?, 'sell', ?, ?, ?, ?, ?, ?, 'manual', ?)`,
[btc, at, sats, fiatPrice, fiatFee, priceOrigin, acc, at]);
}
let testProviderId = null;
function testProvider() {
if (testProviderId === null) {
harness.exports.run(
`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
isActive, isSeed, isSystem, createdAt)
VALUES ('test','test','test','','none',0,1,1,0,'2024-01-01T00:00:00Z')`);
testProviderId = harness.exports.lastId();
for (const symbool of ['BTC', 'EUR', 'XAU'])
harness.exports.run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?,?)`,
[testProviderId, symbool]);
}
return testProviderId;
}
function koersPerBtc(datum, eur) {
harness.exports.run(
`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, updatedAt)
VALUES (?,?, 'day', ?, ?, ?)`, [btc, `${datum}T00:00:00Z`, eur, testProvider(), `${datum}T00:00:00Z`]);
}
const jaarRij = (model, jaar) => model.perJaar.find(b => b.year === jaar);
const aanwasRij = (model, jaar) => model.aanwas.find(a => a.year === jaar);
describe('buildWealthModel: de herkomst van een cel', () => {
function tweedeBron() {
harness.exports.run(
`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
isActive, isSeed, isSystem, createdAt)
VALUES ('tweede','Tweede','tweede','','none',0,1,1,0,'2024-01-01T00:00:00Z')`);
const id = harness.exports.lastId();
harness.exports.run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'BTC')`, [id]);
return id;
}
function slotkoers(datum, eur, providerId = testProvider()) {
harness.exports.run(
`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, createdAt)
VALUES (?,?, 'day', ?, ?, '2026-01-15T00:00:00Z')`, [btc, `${datum}T00:00:00Z`, eur, providerId]);
}
it('noemt de soort, de bron en de dag van de koers op een peildatum', async () => {
koop({ sats: 1e8, fiatPrice: 40000, at: '2024-06-01T00:00:00Z' });
slotkoers('2025-01-01', 90000);
const cel = (await wealth.buildWealthModel()).rows[0].perYear[2025];
assert.equal(cel.price, 90000);
assert.deepEqual({ kind: cel.origin.kind, source: cel.origin.source, day: cel.origin.day, fiatPrice: cel.origin.fiatPrice },
{ kind: 'close', source: 'test', day: '2025-01-01', fiatPrice: 90000 });
});
it('noemt een doorgetrokken koers met zijn brondag en leeftijd', async () => {
koop({ sats: 1e8, fiatPrice: 40000, at: '2024-06-01T00:00:00Z' });
slotkoers('2024-12-29', 88000);
const cel = (await wealth.buildWealthModel()).rows[0].perYear[2025];
assert.equal(cel.origin.kind, 'carried');
assert.equal(cel.origin.sourceDay, '2024-12-29');
assert.equal(cel.origin.ageDays, 3);
});
it('heeft geen herkomst zonder koers, en ook niet bij een positie van nul', async () => {
koop({ sats: 1e8, fiatPrice: 40000, at: '2024-06-01T00:00:00Z' });
const m = await wealth.buildWealthModel();
assert.equal(m.rows[0].perYear[2025].price, null);
assert.equal(m.rows[0].perYear[2025].origin, null);
});
it('geeft ook de stand van nu (het dashboard) een herkomst, met de bron van een live spot', async () => {
koop({ sats: 1e8, fiatPrice: 40000, at: '2024-06-01T00:00:00Z' });
slotkoers('2025-01-01', 90000);
const zonderSpot = (await wealth.currentStanding()).rows[0].origin;
assert.equal(zonderSpot.kind, 'carried', 'de laatste koers, doorgetrokken tot vandaag');
assert.equal(zonderSpot.source, 'test');
liveSpot = 95000;
const metSpot = (await wealth.currentStanding()).rows[0].origin;
assert.deepEqual({ kind: metSpot.kind, fiatPrice: metSpot.fiatPrice, source: metSpot.source }, { kind: 'live', fiatPrice: 95000, source: 'Spotbron' });
});
it('beschrijft bij twee bronnen op één dag de koers die gebruikt is', async () => {
koop({ sats: 1e8, fiatPrice: 40000, at: '2024-06-01T00:00:00Z' });
slotkoers('2025-01-01', 90000);
slotkoers('2025-01-01', 91000, tweedeBron());
const cel = (await wealth.buildWealthModel()).rows[0].perYear[2025];
assert.equal(cel.origin.fiatPrice, cel.price, 'de herkomst beschrijft hetzelfde getal als de cel');
assert.equal(cel.origin.source, cel.price === 90000 ? 'test' : 'Tweede');
});
});
describe('buildWealthModel: zonder gegevens', () => {
it('levert een leeg model zonder events', async () => {
const m = await wealth.buildWealthModel();
assert.deepEqual(m, { years: [], rows: [], totals: null });
});
it('levert een leeg model als het asset verdwijnt', async () => {
koop({ sats: 100, fiatPrice: 1, at: '2024-01-01T00:00:00' });
harness.exports.run(`DELETE FROM Asset`);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM Event`).n, 0, 'CASCADE ruimde de events op');
const m = await wealth.buildWealthModel();
assert.deepEqual(m.years, []);
});
});
describe('peiljaren', () => {
it('begint het jaar ná de eerste gebeurtenis en loopt t/m dit jaar', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2022-06-01T00:00:00' });
const m = await wealth.buildWealthModel();
assert.equal(m.years[0], 2023);
assert.equal(m.years.at(-1), HUIDIG_JAAR);
assert.equal(m.years.length, HUIDIG_JAAR - 2022);
});
});
describe('peildata waarop er niets stond', () => {
it('noemt een peildatum leeg als geen enkele rij dan iets had', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
verkoop({ sats: 100_000_000, fiatPrice: 25000, at: '2023-09-01T00:00:00' });
koop({ sats: 50_000_000, fiatPrice: 60000, at: `${HUIDIG_JAAR - 1}-06-01T00:00:00` });
koersPerBtc('2023-01-01', 20000);
const m = await wealth.buildWealthModel();
assert.ok(m.emptyYears.includes(2024), '1 januari 2024: alles was verkocht');
assert.ok(m.emptyYears.includes(HUIDIG_JAAR - 1),
'en op 1 januari vorig jaar ook nog niet: die aankoop kwam in juni');
assert.ok(!m.emptyYears.includes(HUIDIG_JAAR), 'maar op 1 januari van dit jaar staat er wél iets');
});
it('noemt een peildatum NIET leeg als er wel iets stond maar zonder koers', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc(`${HUIDIG_JAAR}-01-01`, 50000);
const m = await wealth.buildWealthModel();
assert.equal(m.rows[0].perYear[2024].value, null, 'de waarde is onbekend');
assert.ok(!m.emptyYears.includes(2024), 'maar de kolom is niet leeg: er staat een positie');
});
it('laat een post BUITEN box 3 de kolom niet bezet houden', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2023-01-01', 20000);
harness.exports.run(`UPDATE Account SET taxScope = 'none' WHERE id = ?`, [acc]);
const m = await wealth.buildWealthModel();
assert.ok(m.emptyYears.includes(2024), 'alleen buiten-box-3-bezit is voor deze pagina leeg');
assert.equal(m.buitenBox3.length, 1, 'en het wordt wel genoemd, met zijn bedrag');
});
});
describe('stromen over de grens van het overzicht', () => {
let pensioen;
beforeEach(() => {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt, taxScope)
VALUES ('Pensioen','pensioen','vault',1,0,1,NULL,'none')`);
pensioen = harness.exports.lastId();
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2024-06-01T00:00:00' });
koersPerBtc('2024-01-01', 20000);
for (const d of ['2024-06-01', '2025-01-01', '2025-03-01', '2026-01-01']) koersPerBtc(d, 30000);
});
const resultaat2025 = m => m.aanwas.find(a => a.year === 2025);
it('telt een verkoop op een rekening buiten het overzicht niet als onttrekking', async () => {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'buy', '2024-06-01T00:00:00', 50000000, 20000, ?, 'manual', '2024-06-01T00:00:00')`, [btc, pensioen]);
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fromAccountId, source, createdAt)
VALUES (?, 'sell', '2025-03-01T00:00:00', 50000000, 30000, ?, 'manual', '2025-03-01T00:00:00')`, [btc, pensioen]);
const a = resultaat2025(await wealth.buildWealthModel());
assert.equal(a.withdrawals, 0, 'die verkoop ligt buiten deze pagina');
assert.equal(Math.round(a.result), 0, 'en maakt het resultaat dus niet hoger');
});
it('telt een verplaatsing naar een rekening buiten het overzicht als onttrekking en niet als verlies', async () => {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fromAccountId, toAccountId, source, createdAt)
VALUES (?, 'transfer', '2025-03-01T00:00:00', 50000000, ?, ?, 'manual', '2025-03-01T00:00:00')`, [btc, acc, pensioen]);
const a = resultaat2025(await wealth.buildWealthModel());
assert.equal(Math.round(a.withdrawals), 15000, 'een halve BTC tegen de dagkoers verliet het overzicht');
assert.equal(Math.round(a.result), 0);
});
it('trekt dezelfde grens bij "alleen per categorie": tabel en resultaat zien die rekening niet', async () => {
harness.exports.run(`UPDATE Account SET taxScope = 'category' WHERE id = ?`, [pensioen]);
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fromAccountId, toAccountId, source, createdAt)
VALUES (?, 'transfer', '2025-03-01T00:00:00', 50000000, ?, ?, 'manual', '2025-03-01T00:00:00')`, [btc, acc, pensioen]);
const m = await wealth.buildWealthModel();
const a = resultaat2025(m);
assert.equal(Math.round(a.withdrawals), 15000);
assert.equal(Math.round(a.result), 0);
assert.equal(m.rows.some(r => r.account.id === pensioen), false, 'niet in de tabel');
assert.equal(m.categoryRows.some(r => r.account.id === pensioen), true, 'wel in de splitsing');
assert.equal(m.buitenBox3.length, 0, 'en niet genoemd als weggelaten: hij staat in het kader per categorie');
});
it('negeert met ignoreTaxSettings de reikwijdte en categorie van account en asset', async () => {
harness.exports.run(`UPDATE Asset SET taxCategory = 'bank' WHERE id = ?`, [btc]);
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fromAccountId, toAccountId, source, createdAt)
VALUES (?, 'transfer', '2025-03-01T00:00:00', 50000000, ?, ?, 'manual', '2025-03-01T00:00:00')`, [btc, acc, pensioen]);
const eigen = await wealth.buildWealthModel();
const standaard = await wealth.buildWealthModel({ ignoreTaxSettings: true });
assert.equal(eigen.rows.some(r => r.account.id === pensioen), false, 'met de instelling buiten de tabel');
assert.equal(standaard.rows.some(r => r.account.id === pensioen), true, 'zonder de instelling erin');
assert.equal(standaard.buitenBox3.length, 0, 'en dus niet als weggelaten genoemd');
assert.equal(resultaat2025(standaard).withdrawals, 0, 'de verplaatsing blijft binnen de pagina');
assert.equal(categorieGekozen(eigen.categoryRows), true);
assert.equal(categorieGekozen(standaard.categoryRows), false, 'de gekozen categorie telt ook niet');
assert.equal(standaard.rows.every(r => r.categorie === VERMOGENSCATEGORIE.OVERIG), true, 'BTC volgt zijn soort');
});
it('meldt hasTaxSettings alleen bij een afwijking, ook in de standaardweergave', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2024-06-01T00:00:00' });
harness.exports.run(`UPDATE Account SET taxScope = NULL WHERE id = ?`, [pensioen]);
assert.equal((await wealth.buildWealthModel()).hasTaxSettings, false, 'alles op de standaard');
harness.exports.run(`UPDATE Asset SET taxCategory = 'bank' WHERE id = ?`, [btc]);
assert.equal((await wealth.buildWealthModel()).hasTaxSettings, true, 'een categorie op een asset');
assert.equal((await wealth.buildWealthModel({ ignoreTaxSettings: true })).hasTaxSettings, true);
});
});
describe('sumReturn: een rekening met een asset uit het jaar zelf', () => {
let xau;
beforeEach(() => {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('XAU','Goud','goud','metal','g',NULL,4,'unit')`);
xau = harness.exports.lastId();
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2024-06-01T00:00:00' });
koersPerBtc('2024-06-01', 20000);
koersPerBtc('2025-01-01', 30000);
koersPerBtc('2025-07-01', 33000);
koersPerBtc('2026-01-01', 33000);
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'buy', '2025-05-26T23:00:00Z', 1000000, 50, ?, 'manual', '2025-05-26T23:00:00Z')`, [xau, acc]);
for (const [d, eur] of [['2025-05-01', 50], ['2025-10-01', 60], ['2026-01-01', 60]]) {
harness.exports.run(
`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, updatedAt)
VALUES (?,?, 'day', ?, ?, ?)`, [xau, `${d}T00:00:00Z`, eur, testProvider(), `${d}T00:00:00Z`]);
}
});
const cellen = (m, ids) => m.rows.filter(r => r.account.id === acc && ids.includes(r.asset.id))
.map(r => ({ begin: r.perYear[2025], eind: r.perYear[2026] }));
it('telt het goud mee in resultaat én percentage van de rekening', async () => {
const m = await wealth.buildWealthModel();
const goud = m.rows.find(r => r.asset.id === xau);
assert.equal(goud.perYear[2026].pct, null, 'het goud zelf heeft geen percentage: het begon bij nul');
const res = wealth.sumReturn(cellen(m, [btc, xau]));
assert.equal(Math.round(res.gain), 4000);
assert.ok(Math.abs(res.pct - 4000 / 33000) < 1e-4, `percentage ${res.pct}`);
});
it('geeft wel het resultaat maar geen percentage als de rekening op 1 januari leeg was', async () => {
const m = await wealth.buildWealthModel();
const res = wealth.sumReturn(cellen(m, [xau]));
assert.equal(Math.round(res.gain), 1000);
assert.equal(res.pct, null);
});
});
describe('startjaar van de administratie', () => {
it('begrenst de peiljaren zonder de stand aan te tasten', async () => {
koop({ sats: 100_000_000, fiatPrice: 5000, at: '2018-06-01T00:00:00' });
koersPerBtc('2024-01-01', 40000);
await setPref('app', 'start_year', 2024);
const m = await wealth.buildWealthModel();
assert.equal(m.years[0], 2024, 'de reeks begint bij het gekozen jaar');
assert.equal(m.years.length, HUIDIG_JAAR - 2023);
assert.equal(m.ingekort, true);
assert.equal(m.firstYear, 2018, 'het écht eerste jaar gaat mee terug, voor de melding op de pagina');
assert.equal(jaarRij(m, 2024).value, 40000, '1 BTC uit 2018 × de koers van 1-1-2024');
});
it('doet niets bij een startjaar vóór de eerste peildatum', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2022-06-01T00:00:00' });
await setPref('app', 'start_year', 1990);
const m = await wealth.buildWealthModel();
assert.equal(m.years[0], 2023, 'de eerste positie blijft de ondergrens');
assert.equal(m.ingekort, false, 'er is niets afgesneden, dus de pagina meldt niets');
});
it('houdt minstens één kolom over bij een startjaar in de toekomst', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2022-06-01T00:00:00' });
await setPref('app', 'start_year', HUIDIG_JAAR + 5);
const m = await wealth.buildWealthModel();
assert.deepEqual(m.years, [HUIDIG_JAAR]);
});
it('gebruikt zonder keuze de eerste positie', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2022-06-01T00:00:00' });
const m = await wealth.buildWealthModel();
assert.equal(m.years[0], 2023);
assert.equal(m.ingekort, false);
});
});
describe('koerstoekenning per peildatum', () => {
it('gebruikt de koers van de peildatum zelf', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 40000);
const m = await wealth.buildWealthModel();
assert.equal(jaarRij(m, 2024).value, 40000, '1 BTC × 40.000');
});
it('geeft geen waarde vóór het begin van de historie', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2025-01-01', 50000);
const m = await wealth.buildWealthModel();
assert.equal(jaarRij(m, 2024).value, 0, 'geen koers → deze rij telt niet mee in het totaal');
assert.equal(m.rows[0].perYear[2024].price, null);
assert.equal(m.rows[0].perYear[2024].value, null);
});
it('trekt een koers vooruit tot de OUDERDOMSGRENS en daarna niet meer', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2022-06-01T00:00:00' });
koersPerBtc('2023-12-28', 30000);
const m = await wealth.buildWealthModel();
assert.equal(jaarRij(m, 2024).value, 30000, 'binnen de grens wordt hij wél doorgetrokken');
const cel = m.rows[0].perYear[2025];
assert.equal(cel.price, null, 'een jaar later geldt die koers niet meer, en dat is de grens');
assert.equal(cel.value, null, 'dus geen waarde, en null betekent hier "wel bezit, niet te waarderen"');
assert.equal(cel.sats, 100_000_000, 'de positie staat er nog wel');
});
it('geeft metaal een RUIMERE grens dan crypto, met dezelfde koersdatum', async () => {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES ('XAU','Goud','goud','metal','g','mg',3,'unit')`);
const goud = harness.exports.lastId();
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'buy', '2022-06-01T00:00:00', 1000, 80, ?, 'manual', '2022-06-01T00:00:00')`,
[goud, acc]);
harness.exports.run(
`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, updatedAt)
VALUES (?, '2023-12-20T00:00:00Z', 'day', 100, ?, '2023-12-20T00:00:00Z')`, [goud, testProvider()]);
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2022-06-01T00:00:00' });
koersPerBtc('2023-12-20', 30000);
const m = await wealth.buildWealthModel();
const cel = (assetId) => m.rows.find(r => r.asset.id === assetId).perYear[2024];
assert.equal(cel(goud).price, 100, 'twaalf dagen valt binnen de grens van metaal');
assert.equal(cel(btc).price, null, 'en buiten die van crypto');
});
it('kent fiat GEEN grens, want een vaste koers veroudert niet', async () => {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES ('EUR','Euro','euro','fiat','EUR','cent',2,'unit')`);
const eur = harness.exports.lastId();
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'interest', '2022-06-01T00:00:00', 100000, NULL, ?, 'manual', '2022-06-01T00:00:00')`,
[eur, acc]);
harness.exports.run(
`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, updatedAt)
VALUES (?, '2022-06-01T00:00:00Z', 'day', 1, ?, '2022-06-01T00:00:00Z')`, [eur, testProvider()]);
const m = await wealth.buildWealthModel();
const rij = m.rows.find(r => r.asset.id === eur);
assert.equal(rij.perYear[2024].price, 1, 'anderhalf jaar later nog steeds 1,00');
assert.equal(rij.perYear[2024].value, 1000, 'en dus 1.000 euro op de rekening');
});
it('gebruikt een live spot voor de huidige stand als die er is', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 40000);
liveSpot = 90000;
const m = await wealth.buildWealthModel();
assert.equal(m.totals.current, 90000, 'de verse spot gaat vóór het laatste gridpunt');
});
it('valt zonder live spot terug op het laatste gridpunt', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 40000);
koersPerBtc('2024-06-01', 55000);
const m = await wealth.buildWealthModel();
assert.equal(m.totals.current, 55000);
});
it('geeft NUL en niet "geen waarde" als de positie nul is', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 40000);
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES ('XAU','Goud','goud','metal','g','mg',3,'unit')`);
const goud = harness.exports.lastId();
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'buy', ?, ?, ?, ?, 'manual', ?)`,
[goud, `${HUIDIG_JAAR}-03-01T00:00:00`, 10_000, 80, acc, `${HUIDIG_JAAR}-03-01T00:00:00`]);
const m = await wealth.buildWealthModel();
const goudRij = m.rows.find(r => r.asset.id === goud);
assert.ok(goudRij, 'de rij bestaat, want er is later wél een positie');
assert.equal(goudRij.perYear[2024].sats, 0);
assert.equal(goudRij.perYear[2024].price, null, 'er is geen koers op die peildatum');
assert.equal(goudRij.perYear[2024].value, 0, 'en nul eenheden is nul euro, ook zonder koers');
assert.equal(goudRij.current.sats, 10_000);
assert.equal(goudRij.current.value, null, 'wél een positie en géén koers blijft onbekend');
});
});
describe('rijen per account en asset', () => {
it('laat een combinatie zonder enig saldo weg', async () => {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Leeg','leeg','bank',0,0,1,NULL)`);
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 40000);
const m = await wealth.buildWealthModel();
assert.equal(m.rows.length, 1);
assert.equal(m.rows[0].account.name, 'Kluis');
});
it('noemt de rij naar het account bij één asset en met symbool bij meerdere', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 40000);
assert.equal((await wealth.buildWealthModel()).rows[0].name, 'Kluis');
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('XAU','Goud','goud','metal','g',NULL,7,'unit')`);
assert.equal((await wealth.buildWealthModel()).rows[0].name, 'Kluis · BTC');
});
});
describe('vermogensaanwas', () => {
it('rekent eind − begin − stortingen + onttrekkingen', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
koersPerBtc('2025-01-01', 50000);
const a = aanwasRij(await wealth.buildWealthModel(), 2024);
assert.equal(a.begin, 30000);
assert.equal(a.eind, 50000);
assert.equal(a.deposits, 0);
assert.equal(a.result, 20000);
});
it('trekt een storting binnen het jaar van het resultaat af', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
koop({ sats: 100_000_000, fiatPrice: 35000, at: '2024-07-01T00:00:00' });
koersPerBtc('2025-01-01', 50000);
const a = aanwasRij(await wealth.buildWealthModel(), 2024);
assert.equal(a.eind, 100000, '2 BTC × 50.000');
assert.equal(a.deposits, 35000);
assert.equal(a.result, 100000 - 30000 - 35000, 'alleen de waardegroei blijft over');
});
it('telt de fee van een aankoop mee in de storting', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
koop({ sats: 100_000_000, fiatPrice: 35000, at: '2024-07-01T00:00:00', fiatFee: 250 });
koersPerBtc('2025-01-01', 50000);
assert.equal(aanwasRij(await wealth.buildWealthModel(), 2024).deposits, 35250,
'de kosten van de aankoop horen bij het ingelegde bedrag');
});
it('telt een verkoop als onttrekking, ná aftrek van de fee', async () => {
koop({ sats: 200_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
verkoop({ sats: 100_000_000, fiatPrice: 40000, at: '2024-07-01T00:00:00', fiatFee: 100 });
koersPerBtc('2025-01-01', 50000);
const a = aanwasRij(await wealth.buildWealthModel(), 2024);
assert.equal(a.begin, 60000);
assert.equal(a.eind, 50000, 'nog 1 BTC over × 50.000');
assert.equal(a.withdrawals, 40000 - 100, 'netto ontvangen, dus na kosten');
assert.equal(a.result, 50000 - 60000 + 39900);
});
it('telt van een interne verplaatsing met "niets tegenover" alleen de euro-fee', async () => {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Tweede','tweede','vault',1,0,1,NULL)`);
const tweede = harness.exports.lastId();
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
koersPerBtc('2024-07-01', 40000);
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatFee, priceOrigin, fromAccountId, toAccountId, source, createdAt)
VALUES (?, 'transfer', '2024-07-01T00:00:00', 50000000, 2, 'none', ?, ?, 'manual', '2024-07-01T00:00:00')`,
[btc, acc, tweede]);
koersPerBtc('2025-01-01', 50000);
const a = aanwasRij(await wealth.buildWealthModel(), 2024);
assert.equal(a.deposits, 2, 'alleen de fee');
assert.equal(a.giften, 0, 'een interne verplaatsing is geen gift');
assert.equal(a.withdrawals, 0);
});
it('rekent een ONTVANGEN GIFT uit het rendement, op de dagkoers', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
koersPerBtc('2024-07-01', 40000);
koop({ sats: 100_000_000, fiatPrice: 0, at: '2024-07-01T00:00:00', priceOrigin: 'none' });
koersPerBtc('2025-01-01', 50000);
const a = aanwasRij(await wealth.buildWealthModel(), 2024);
assert.equal(a.deposits, 0, 'er is niets betaald, dus dit is geen inleg');
assert.equal(a.giften, 40000, 'wel waarde binnen, tegen de koers van die dag');
assert.equal(a.result, 100000 - 30000 - 40000, 'en dus blijft alleen de waardegroei over');
});
it('boekt een WEGGEGEVEN gift terug, anders leest hij als verlies', async () => {
koop({ sats: 200_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
koersPerBtc('2024-07-01', 40000);
verkoop({ sats: 100_000_000, fiatPrice: 0, at: '2024-07-01T00:00:00', priceOrigin: 'none' });
koersPerBtc('2025-01-01', 50000);
const a = aanwasRij(await wealth.buildWealthModel(), 2024);
assert.equal(a.withdrawals, 0, 'er is niets ontvangen, dus geen onttrekking');
assert.equal(a.giften, -40000);
assert.equal(a.result, 50000 - 60000 + 40000, 'de waardegroei van wat je hield, en geen verlies');
});
it('laat een gift zonder koers op die dag in het rendement staan', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2025-01-01', 50000);
koop({ sats: 100_000_000, fiatPrice: 0, at: '2024-07-01T00:00:00', priceOrigin: 'none' });
const a = aanwasRij(await wealth.buildWealthModel(), 2024);
assert.equal(a.giften, 0, 'niet te waarderen, dus geen correctie');
});
it('levert het rendement en géén belastingbedrag', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
koersPerBtc('2025-01-01', 50000);
const a = aanwasRij(await wealth.buildWealthModel(), 2024);
assert.equal(a.result, 20000, 'het rendement zelf is wél een kengetal');
for (const veld of ['vrijstelling', 'belastbaar', 'belasting']) {
assert.equal(a[veld], undefined, `${veld} hoort geen onderdeel meer te zijn van het model`);
}
});
it('laat een verlies als negatief rendement staan', async () => {
koop({ sats: 100_000_000, fiatPrice: 60000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 60000);
koersPerBtc('2025-01-01', 40000);
const a = aanwasRij(await wealth.buildWealthModel(), 2024);
assert.equal(a.result, -20000, 'het verlies zelf blijft zichtbaar, en dat is het kengetal');
});
it('markeert het lopende jaar en meet dat tot nu in plaats van tot 1 januari', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: `${HUIDIG_JAAR - 1}-06-01T00:00:00` });
koersPerBtc(`${HUIDIG_JAAR}-01-01`, 30000);
koersPerBtc(`${HUIDIG_JAAR}-06-01`, 45000);
const a = aanwasRij(await wealth.buildWealthModel(), HUIDIG_JAAR);
assert.equal(a.running, true);
assert.equal(a.begin, 30000);
assert.equal(a.eind, 45000, 'het eind van een lopend jaar is de huidige stand');
});
});
describe('kengetallen per belastingjaar', () => {
const scenario2024 = async (waarde2024, waarde2025 = waarde2024) => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', waarde2024);
koersPerBtc('2025-01-01', waarde2025);
return jaarRij(await wealth.buildWealthModel(), 2024);
};
it('geeft de waarde op de peildatum en het rendement over dat jaar', async () => {
const b = await scenario2024(100000, 101000);
assert.equal(b.value, 100000);
assert.equal(b.rendement, 1000, 'eind − begin, gecorrigeerd voor stortingen en onttrekkingen');
});
it('splitst de waarde uit per vermogenscategorie', async () => {
const b = await scenario2024(100000);
assert.deepEqual(b.perCategorie, { [VERMOGENSCATEGORIE.OVERIG]: 100000 });
assert.equal(b.categorieCompleet, true);
});
it('telt de categorieën op tot het totaal', async () => {
const b = await scenario2024(100000);
const som = Object.values(b.perCategorie).reduce((s, v) => s + v, 0);
assert.equal(som, b.value);
});
it('rekent geen belasting meer, en dat hoort zo te blijven', async () => {
const b = await scenario2024(100000);
for (const veld of ['params', 'hvv', 'grondslag', 'voordeel', 'belasting', 'tegenbewijs']) {
assert.equal(b[veld], undefined, `${veld} hoort geen onderdeel van het model te zijn`);
}
});
it('geeft ook voor een jaar zonder bekend regime gewoon de waarde', async () => {
koop({ sats: 100_000_000, fiatPrice: 5000, at: '2019-06-01T00:00:00' });
koersPerBtc('2020-01-01', 6000);
const b = jaarRij(await wealth.buildWealthModel(), 2020);
assert.equal(b.value, 6000);
assert.deepEqual(b.perCategorie, { [VERMOGENSCATEGORIE.OVERIG]: 6000 });
});
});
describe('vermogenscategorie per asset-soort', () => {
it('kent elke soort uit de asset-enum een categorie toe', async () => {
const { ENUMS } = await import('../../shared/config.js');
for (const kind of ENUMS.ASSET_KIND) {
assert.ok(CATEGORIE_PER_ASSET_KIND[kind],
`asset-soort '${kind}' heeft geen vermogenscategorie; voeg hem toe in tax/constants.js`);
}
});
it('koppelt fiat aan banktegoeden en de rest aan overige bezittingen', () => {
assert.equal(categorieVoorAsset({ kind: 'fiat' }), VERMOGENSCATEGORIE.BANK);
assert.equal(categorieVoorAsset({ kind: 'crypto' }), VERMOGENSCATEGORIE.OVERIG);
assert.equal(categorieVoorAsset({ kind: 'metal' }), VERMOGENSCATEGORIE.OVERIG);
});
it('ziet een gekozen categorie alleen als iemand hem zette, op account of asset', () => {
const rij = (acc, asset) => ({ account: { taxCategory: acc }, asset: { kind: 'fiat', taxCategory: asset } });
assert.equal(categorieGekozen([rij(null, null), rij(null, null)]), false, 'alles volgt de soort');
assert.equal(categorieGekozen([rij(null, null), rij('bank', null)]), true, 'op een account');
assert.equal(categorieGekozen([rij(null, 'overig')]), true, 'op een asset');
assert.equal(categorieGekozen([{ account: { id: null, taxCategory: null }, asset: { taxCategory: null } }]), false);
assert.equal(categorieGekozen([]), false);
});
it('geeft null voor een onbekende soort in plaats van te gokken', () => {
assert.equal(categorieVoorAsset({ kind: 'onzin' }), null);
assert.equal(categorieVoorAsset({ kind: '' }), null);
assert.equal(categorieVoorAsset(null), null);
});
it('kan vandaag niet vóórkomen in de database, en dat is de tweede helft van de guard', () => {
assert.throws(
() => harness.exports.run(`UPDATE Asset SET kind = 'onzin' WHERE id = ?`, [btc]),
/CHECK constraint failed/);
});
it('legt een fiat-asset onder banktegoeden in het model', async () => {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES ('EUR','Euro','euro','fiat','EUR','cent',2,'unit')`);
const eur = harness.exports.lastId();
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'interest', '2023-06-01T00:00:00', 100000, NULL, ?, 'manual', '2023-06-01T00:00:00')`,
[eur, acc]);
harness.exports.run(
`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, updatedAt)
VALUES (?, '2024-01-01T00:00:00Z', 'day', 1, ?, '2024-01-01T00:00:00Z')`, [eur, testProvider()]);
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 100000);
koersPerBtc('2025-01-01', 100000);
const b = jaarRij(await wealth.buildWealthModel(), 2024);
assert.deepEqual(b.perCategorie, {
[VERMOGENSCATEGORIE.OVERIG]: 100000,
[VERMOGENSCATEGORIE.BANK]: 1000,
});
assert.equal(b.value, 101000, 'het totaal is de som van beide categorieën');
assert.equal(b.categorieCompleet, true);
});
it('haalt een asset met reikwijdte "alleen per categorie" uit tabel en resultaat, niet uit de splitsing', async () => {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES ('EUR','Euro','euro','fiat','EUR','cent',2,'unit')`);
const eur = harness.exports.lastId();
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'interest', '2023-06-01T00:00:00', 100000, NULL, ?, 'manual', '2023-06-01T00:00:00')`,
[eur, acc]);
harness.exports.run(
`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, updatedAt)
VALUES (?, '2024-01-01T00:00:00Z', 'day', 1, ?, '2024-01-01T00:00:00Z')`, [eur, testProvider()]);
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 100000);
koersPerBtc('2025-01-01', 100000);
const gewoon = await wealth.buildWealthModel();
harness.exports.run(`UPDATE Asset SET taxScope = 'category' WHERE id = ?`, [eur]);
const apart = await wealth.buildWealthModel();
assert.equal(gewoon.totals.perYear[2024], 101000);
assert.deepEqual(gewoon.categorySplit.perYear[2024], gewoon.totals.perYearPerCategorie[2024],
'zonder aparte reikwijdte is de splitsing die van de tabel');
assert.equal(apart.totals.perYear[2024], 100000, 'het banktegoed staat niet meer in de totalen');
assert.equal(apart.rows.some(r => r.asset.id === eur), false, 'en niet in de tabel');
assert.deepEqual(apart.categorySplit.perYear[2024],
{ [VERMOGENSCATEGORIE.OVERIG]: 100000, [VERMOGENSCATEGORIE.BANK]: 1000 }, 'maar wel in de splitsing');
});
});
describe('rendementspercentage', () => {
it('is null voor de eerste kolom', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
const m = await wealth.buildWealthModel();
assert.equal(m.pct.perYear[m.years[0]], null, 'er is geen vorige kolom om tegen te meten');
});
it('corrigeert voor stortingen, zodat bijstorten geen rendement lijkt', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
koop({ sats: 100_000_000, fiatPrice: 30000, at: '2024-07-01T00:00:00' });
koersPerBtc('2025-01-01', 30000);
const m = await wealth.buildWealthModel();
assert.equal(m.pct.perYear[2025], 0,
'waarde verdubbeld, maar volledig door de storting → 0% rendement');
});
it('meet de huidige waarde tegen het gemiddeld werkzame vermogen', async (t) => {
const start = new Date(HUIDIG_JAAR, 0, 1);
const storting = new Date(HUIDIG_JAAR, 0, 1, 1);
if (Date.now() - storting.getTime() < 24 * 3600 * 1000) return t.skip('te vroeg in het jaar');
koop({ sats: 100_000_000, fiatPrice: 20000, at: `${HUIDIG_JAAR - 1}-06-01T00:00:00` });
koersPerBtc(`${HUIDIG_JAAR}-01-01`, 30000);
koop({ sats: 100_000_000, fiatPrice: 30000, at: storting.toISOString() });
koersPerBtc(new Date().toISOString().slice(0, 10), 33000);
const m = await wealth.buildWealthModel();
const a = aanwasRij(m, HUIDIG_JAAR);
assert.equal(a.result, 6000);
const nu = Date.now();
const gewicht = (nu - storting.getTime()) / (nu - start.getTime());
const verwacht = 6000 / (30000 + gewicht * 30000);
assert.ok(Math.abs(m.pct.current - verwacht) < 1e-4, `verwacht ${verwacht}, kreeg ${m.pct.current}`);
assert.ok(Math.abs(a.pct - verwacht) < 1e-4, 'het aangifteblok leest hetzelfde getal');
});
it('volgt de keuze voor IRR ook op de totaalregel', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: `${HUIDIG_JAAR - 2}-06-01T00:00:00` });
koersPerBtc(`${HUIDIG_JAAR - 1}-01-01`, 30000);
koop({ sats: 100_000_000, fiatPrice: 30000, at: `${HUIDIG_JAAR - 1}-07-01T00:00:00` });
koersPerBtc(`${HUIDIG_JAAR}-01-01`, 36000);
await setPref('app', 'mwr_method', 'irr');
const m = await wealth.buildWealthModel();
const rij = m.rows[0].perYear[HUIDIG_JAAR];
assert.ok(Math.abs(rij.pct - 0.2715) < 0.002, `de IRR van de rij: verwacht 27,15%, kreeg ${rij.pct}`);
assert.ok(Math.abs(m.pct.perYear[HUIDIG_JAAR] - rij.pct) < 1e-6,
`totaal ${m.pct.perYear[HUIDIG_JAAR]} tegen rij ${rij.pct}`);
});
function rekening(naam) {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,'vault',1,0,1,NULL)`,
[naam, naam.toLowerCase()]);
return harness.exports.lastId();
}
function balans({ op, sats, at }) {
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,1.0,?, 'manual')`, [btc, at.slice(0, 10), op, sats, at]);
}
function verplaats({ sats, at, naar }) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fromAccountId, toAccountId, source, createdAt)
VALUES (?, 'transfer', ?, ?, ?, ?, 'manual', ?)`, [btc, at, sats, acc, naar, at]);
}
it('deelt door het gemiddeld werkzame vermogen en niet meer door de beginwaarde', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2024-06-01T00:00:00' });
koersPerBtc('2025-01-01', 20000);
koop({ sats: 100_000_000, fiatPrice: 24000, at: '2025-07-01T00:00:00' });
koersPerBtc('2026-01-01', 24000);
const m = await wealth.buildWealthModel();
const cel = m.rows[0].perYear[2026];
assert.equal(Math.round(cel.gain), 4000, 'de teller blijft wat hij was');
assert.equal(Math.round((4000 / 20000) * 1000) / 10, 20, 'de oude som, ter vergelijking');
assert.ok(Math.abs(cel.pct - 0.1246) < 0.002, `verwacht ongeveer 12,5%, kreeg ${cel.pct}`);
assert.ok(cel.pct < 0.2, 'en in elk geval fors onder de oude 20%');
});
it('zet de noemer op de cel, zodat een regel die assets optelt hem kan sommeren', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2024-06-01T00:00:00' });
koersPerBtc('2025-01-01', 20000);
koop({ sats: 100_000_000, fiatPrice: 24000, at: '2025-07-01T00:00:00' });
koersPerBtc('2026-01-01', 24000);
const cel = (await wealth.buildWealthModel()).rows[0].perYear[2026];
assert.ok(Math.abs(cel.basis - 32100) < 100, `verwacht ongeveer 32.100, kreeg ${cel.basis}`);
assert.equal(Math.round((cel.gain / cel.basis) * 1000) / 1000, Math.round(cel.pct * 1000) / 1000,
'en het percentage op de cel is precies die deling');
});
it('geeft exact het oude getal zolang er niets stroomt', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2025-01-01', 20000);
koersPerBtc('2026-01-01', 25000);
const m = await wealth.buildWealthModel();
assert.equal(m.rows[0].perYear[2026].pct, 0.25, '5.000 op 20.000, precies zoals voorheen');
});
it('staat per rij op de kolom waar dat jaar op eindigt', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
koersPerBtc('2025-01-01', 60000);
const m = await wealth.buildWealthModel();
assert.equal(m.rows[0].perYear[2025].pct, 1, 'koers verdubbeld, niets bijgekocht → +100%');
});
it('is per rij leeg in de eerste kolom', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
const m = await wealth.buildWealthModel();
assert.equal(m.rows[0].perYear[m.years[0]].pct, null);
});
it('corrigeert per rij voor een aankoop halverwege het jaar', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
koop({ sats: 100_000_000, fiatPrice: 30000, at: '2024-07-01T00:00:00' });
koersPerBtc('2025-01-01', 30000);
const m = await wealth.buildWealthModel();
assert.equal(m.rows[0].perYear[2025].pct, 0, 'de waarde verdubbelde, maar volledig door de aankoop');
});
it('leest een verplaatsing tussen eigen rekeningen niet als verlies', async () => {
const tweede = rekening('Beurs');
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
koersPerBtc('2024-07-01', 30000);
verplaats({ sats: 100_000_000, at: '2024-07-01T00:00:00', naar: tweede });
koersPerBtc('2025-01-01', 30000);
const m = await wealth.buildWealthModel();
const vertrek = m.rows.find(r => r.account.id === acc);
const aankomst = m.rows.find(r => r.account.id === tweede);
assert.equal(vertrek.perYear[2025].pct, 0, 'de rekening die leegliep heeft niets verloren');
assert.equal(aankomst.perYear[2025].pct, null, 'en zonder beginwaarde is er geen percentage te geven');
});
it('geeft een positie die vanaf nul is opgebouwd wél een resultaat en géén percentage', async () => {
const tweede = rekening('Beurs');
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
koersPerBtc('2024-07-01', 30000);
verplaats({ sats: 100_000_000, at: '2024-07-01T00:00:00', naar: tweede });
koersPerBtc('2025-01-01', 45000);
const m = await wealth.buildWealthModel();
const aankomst = m.rows.find(r => r.account.id === tweede).perYear[2025];
assert.equal(aankomst.pct, null, 'er was geen beginwaarde om tegen af te meten');
assert.equal(aankomst.gain, 15000, 'maar de koersstijging ná de verplaatsing is een echt resultaat');
});
it('trekt per RIJ een ontvangen gift van het resultaat af', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
koersPerBtc('2024-07-01', 40000);
koop({ sats: 100_000_000, fiatPrice: 0, at: '2024-07-01T00:00:00', priceOrigin: 'none' });
koersPerBtc('2025-01-01', 50000);
const m = await wealth.buildWealthModel();
const cel = m.rows.find(r => r.account.id === acc).perYear[2025];
assert.equal(cel.gain, 100000 - 30000 - 40000, 'alleen de koersgroei, niet de gift zelf');
});
it('trekt per rij af wat uit een balans komt in plaats van uit een boeking', async () => {
const spaar = rekening('Spaar');
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2023-06-01T00:00:00' });
koersPerBtc('2024-01-01', 30000);
koersPerBtc('2025-01-01', 30000);
balans({ op: spaar, sats: 100_000_000, at: '2023-12-01T00:00:00' });
balans({ op: spaar, sats: 200_000_000, at: '2024-07-01T00:00:00' });
const m = await wealth.buildWealthModel();
const rij = m.rows.find(r => r.account.id === spaar);
assert.equal(rij.perYear[2024].value, 30000, 'de stand op de peildatum komt uit de eerste balans');
assert.equal(rij.perYear[2025].value, 60000, 'en verdubbelt door de tweede');
assert.equal(rij.perYear[2025].pct, 0, 'maar dat is een meting en geen rendement');
});
});
describe('currentStanding', () => {
it('is leeg zonder assets', async () => {
harness.exports.run(`DELETE FROM Asset`);
assert.deepEqual(await wealth.currentStanding(), { rows: [], total: 0, totalSats: 0, today: null });
});
it('geeft het resultaat van vandaag, zonder wat er op een beheerd account gebeurt', async () => {
const gisteren = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt, wealthScope)
VALUES ('Spaarpot','spaarpot','bank',0,0,1,NULL,'managed')`);
const spaarpot = harness.exports.lastId();
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2024-01-01T00:00:00' });
const nu = new Date().toISOString().slice(0, 19);
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'buy', ?, 50000000, 20000, ?, 'manual', ?)`, [btc, nu, spaarpot, nu]);
koersPerBtc('2024-01-01', 40000);
koersPerBtc(gisteren, 40000);
liveSpot = 44000;
const s = await wealth.currentStanding();
assert.equal(Math.round(s.today.total), 4000);
assert.equal(Math.round(s.today.perAsset.get(btc)), 4000);
});
it('laat een account zonder saldo weg', async () => {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Leeg','leeg','bank',0,0,1,NULL)`);
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2024-01-01T00:00:00' });
koersPerBtc('2024-01-01', 40000);
const s = await wealth.currentStanding();
assert.equal(s.rows.length, 1);
assert.equal(s.total, 40000);
assert.equal(s.totalSats, 100_000_000);
});
it('sorteert op waarde, grootste eerst', async () => {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Tweede','tweede','vault',1,0,1,NULL)`);
const acc2 = harness.exports.lastId();
koop({ sats: 50_000_000, fiatPrice: 20000, at: '2024-01-01T00:00:00' });
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'buy', '2024-01-02T00:00:00', 300000000, 20000, ?, 'manual', '2024-01-02T00:00:00')`,
[btc, acc2]);
koersPerBtc('2024-01-01', 40000);
const s = await wealth.currentStanding();
assert.deepEqual(s.rows.map(r => r.account.name), ['Tweede', 'Kluis']);
});
it('geeft een rij zonder koers een waarde van null maar telt het saldo mee', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2024-01-01T00:00:00' });
const s = await wealth.currentStanding();
assert.equal(s.rows[0].value, null, 'geen koershistorie → geen waarde');
assert.equal(s.total, 0);
assert.equal(s.totalSats, 100_000_000, 'de hoeveelheid is er wel');
});
it('slaat een beheerd account over, zonder dat het in de restpost belandt', async () => {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt, wealthScope)
VALUES ('Spaarpot','spaarpot','bank',0,0,1,NULL,'managed')`);
const spaarpot = harness.exports.lastId();
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2024-01-01T00:00:00' });
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'buy', '2024-01-02T00:00:00', 50000000, 20000, ?, 'manual', '2024-01-02T00:00:00')`,
[btc, spaarpot]);
koersPerBtc('2024-01-01', 40000);
const s = await wealth.currentStanding();
assert.deepEqual(s.rows.map(r => r.account.name), ['Kluis']);
assert.equal(s.total, 40000);
assert.equal(s.totalSats, 100_000_000);
});
});
describe('peilmoment: de jaargrens ligt op lokale middernacht, niet op UTC-middernacht', () => {
const NET_VOOR = '2023-12-31T22:30:00Z';
const NET_NA = '2023-12-31T23:30:00Z';
it('telt een aankoop van vlak vóór lokale middernacht nog mee in het oude jaar', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2022-06-01T00:00:00Z' });
koop({ sats: 50_000_000, fiatPrice: 30000, at: NET_VOOR });
koersPerBtc('2022-06-01', 20000);
koersPerBtc('2024-01-01', 40000);
const m = await wealth.buildWealthModel();
const rij = m.rows[0];
assert.equal(rij.perYear[2024].sats, 150_000_000,
'een aankoop om 23:30 lokaal hoort nog bij het jaar dat dan afloopt');
});
it('laat een aankoop van vlak ná lokale middernacht buiten het oude jaar', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: '2022-06-01T00:00:00Z' });
koop({ sats: 50_000_000, fiatPrice: 30000, at: NET_NA });
koersPerBtc('2022-06-01', 20000);
koersPerBtc('2024-01-01', 40000);
const m = await wealth.buildWealthModel();
const rij = m.rows[0];
assert.equal(rij.perYear[2024].sats, 100_000_000,
'een aankoop om 00:30 lokaal valt in het nieuwe jaar; zou de grens op UTC-middernacht ' +
'liggen, dan telde hij hier ten onrechte mee');
});
});
describe('buildWealthModel: met een balans als anker', () => {
function balans({ sats, at }) {
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,1.0,?, 'manual')`, [btc, at.slice(0, 10), acc, sats, at]);
}
it('zet een rekening met alléén een balans in het model', async () => {
balans({ sats: 100_000_000, at: `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z` });
koersPerBtc(`${HUIDIG_JAAR - 1}-01-01`, 30000);
const m = await wealth.buildWealthModel();
assert.equal(m.rows.length, 1, 'er is een rij, zonder één gebeurtenis');
assert.equal(m.years[0], HUIDIG_JAAR - 1, 'de peiljaren beginnen ná het jaar van de balans');
assert.equal(m.rows[0].perYear[HUIDIG_JAAR - 1].sats, 100_000_000);
assert.equal(m.rows[0].perYear[HUIDIG_JAAR - 1].value, 30000);
});
it('laat het anker de boekingen van vóór dat moment overrulen', async () => {
koop({ sats: 40_000_000, fiatPrice: 20000, at: `${HUIDIG_JAAR - 2}-01-01T00:00:00` });
balans({ sats: 100_000_000, at: `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z` });
koersPerBtc(`${HUIDIG_JAAR - 1}-01-01`, 30000);
const m = await wealth.buildWealthModel();
assert.equal(m.rows[0].perYear[HUIDIG_JAAR - 1].sats, 100_000_000, 'de balans, niet de 0,4 BTC uit het grootboek');
});
it('telt een boeking ná het anker er wél bij op', async () => {
balans({ sats: 100_000_000, at: `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z` });
koop({ sats: 50_000_000, fiatPrice: 25000, at: `${HUIDIG_JAAR - 2}-08-01T00:00:00` });
koersPerBtc(`${HUIDIG_JAAR - 1}-01-01`, 30000);
const m = await wealth.buildWealthModel();
assert.equal(m.rows[0].perYear[HUIDIG_JAAR - 1].sats, 150_000_000);
});
it('gebruikt per peiljaar het anker dat op dát moment gold', async () => {
balans({ sats: 100_000_000, at: `${HUIDIG_JAAR - 3}-06-30T00:00:00.000Z` });
balans({ sats: 200_000_000, at: `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z` });
koersPerBtc(`${HUIDIG_JAAR - 2}-01-01`, 30000);
koersPerBtc(`${HUIDIG_JAAR - 1}-01-01`, 30000);
const m = await wealth.buildWealthModel();
assert.equal(m.rows[0].perYear[HUIDIG_JAAR - 2].sats, 100_000_000, 'het oudste anker in het eerste jaar');
assert.equal(m.rows[0].perYear[HUIDIG_JAAR - 1].sats, 200_000_000, 'en het nieuwste daarna');
});
it('laat een portefeuille-brede balans het model niet raken', async () => {
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,NULL,?,1.0,?, 'manual')`,
[btc, `${HUIDIG_JAAR - 2}-06-30`, 100_000_000, `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z`]);
const m = await wealth.buildWealthModel();
assert.deepEqual(m, { years: [], rows: [], totals: null });
});
it('geeft de huidige stand van een rekening met alleen een balans', async () => {
balans({ sats: 100_000_000, at: `${HUIDIG_JAAR}-01-02T00:00:00.000Z` });
koersPerBtc(`${HUIDIG_JAAR}-01-02`, 30000);
const stand = await wealth.currentStanding();
assert.equal(stand.totalSats, 100_000_000);
assert.equal(stand.rows.length, 1);
assert.equal(stand.rows[0].value, 30000);
});
});
describe('buildWealthModel: wat de boekingen niet verklaren', () => {
function balans({ sats, at }) {
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,1.0,?, 'manual')`, [btc, at.slice(0, 10), acc, sats, at]);
}
function koersen() {
koersPerBtc(`${HUIDIG_JAAR - 3}-01-01`, 30000);
koersPerBtc(`${HUIDIG_JAAR - 2}-01-01`, 30000);
koersPerBtc(`${HUIDIG_JAAR - 1}-01-01`, 40000);
koersPerBtc(`${HUIDIG_JAAR}-01-01`, 40000);
}
it('houdt het op nul zolang er geen balans is', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: `${HUIDIG_JAAR - 3}-06-01T00:00:00` });
koersen();
const m = await wealth.buildWealthModel();
for (const a of m.aanwas) assert.equal(a.unexplained, 0, `jaar ${a.year}`);
});
it('zet de sprong van een balans náást het rendement in plaats van erin', async () => {
koop({ sats: 40_000_000, fiatPrice: 20000, at: `${HUIDIG_JAAR - 3}-06-01T00:00:00` });
balans({ sats: 100_000_000, at: `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z` });
koersen();
const jaar = aanwasRij(await wealth.buildWealthModel(), HUIDIG_JAAR - 2);
assert.equal(jaar.unexplained, 24000, '0,6 BTC tegen de 40.000 van de peildatum aan het eind');
assert.equal(jaar.deposits, 0, 'en het is géén storting geworden');
assert.equal(jaar.result, 28000, 'het rendement staat er onveranderd naast, mét de sprong erin');
assert.equal(jaar.begin, 12000, '0,4 BTC tegen 30.000');
assert.equal(jaar.eind, 40000, '1 BTC tegen 40.000');
});
it('meldt niets als de balans het grootboek bevestigt', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: `${HUIDIG_JAAR - 3}-06-01T00:00:00` });
balans({ sats: 100_000_000, at: `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z` });
koersen();
assert.equal(aanwasRij(await wealth.buildWealthModel(), HUIDIG_JAAR - 2).unexplained, 0);
});
it('rekent de sprong aan één jaar toe en niet ook aan het jaar erna', async () => {
koop({ sats: 40_000_000, fiatPrice: 20000, at: `${HUIDIG_JAAR - 3}-06-01T00:00:00` });
balans({ sats: 100_000_000, at: `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z` });
koersen();
const m = await wealth.buildWealthModel();
assert.equal(aanwasRij(m, HUIDIG_JAAR - 2).unexplained, 24000);
assert.equal(aanwasRij(m, HUIDIG_JAAR - 1).unexplained, 0);
});
it('noemt per rekening wie het veroorzaakt, met en zonder grootboek eronder', async () => {
koop({ sats: 40_000_000, fiatPrice: 20000, at: `${HUIDIG_JAAR - 3}-06-01T00:00:00` });
balans({ sats: 100_000_000, at: `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z` });
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt)
VALUES ('Node','node','lightning',1,0,1,NULL)`);
const node = harness.exports.lastId();
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,1.0,?, 'manual')`,
[btc, `${HUIDIG_JAAR - 2}-06-30`, node, 5_000_000, `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z`]);
koersen();
const jaar = aanwasRij(await wealth.buildWealthModel(), HUIDIG_JAAR - 2);
assert.equal(jaar.unexplained, 24000, '0,6 BTC uit de kluis, tegen de koers van 40.000');
assert.equal(jaar.uitBalansen, 2000, '0,05 BTC uit de beginbalans van de node');
assert.deepEqual(jaar.unexplainedRows, [
{ name: 'Kluis', units: 60_000_000, value: 24000 },
], 'alleen de rijen waar nog iets te doen valt');
});
it('laat een beginbalans niet als rendement gelden', async () => {
koop({ sats: 40_000_000, fiatPrice: 20000, at: `${HUIDIG_JAAR - 3}-06-01T00:00:00` });
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt)
VALUES ('Node','node','lightning',1,0,1,NULL)`);
const node = harness.exports.lastId();
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,1.0,?, 'manual')`,
[btc, `${HUIDIG_JAAR - 2}-06-30`, node, 5_000_000, `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z`]);
koersen();
const jaar = aanwasRij(await wealth.buildWealthModel(), HUIDIG_JAAR - 2);
assert.equal(jaar.begin, 12000, '0,4 BTC tegen 30.000');
assert.equal(jaar.eind, 18000, '0,45 BTC tegen 40.000');
assert.equal(jaar.uitBalansen, 2000, 'de beginbalans van de node');
assert.equal(jaar.result, 4000, 'alleen de koerswinst op wat er al stond');
});
it('telt een aanvaard verschil net zo goed uit het rendement', async () => {
koop({ sats: 40_000_000, fiatPrice: 20000, at: `${HUIDIG_JAAR - 3}-06-01T00:00:00` });
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,1.0,?, 'manual')`,
[btc, `${HUIDIG_JAAR - 2}-06-30`, acc, 45_000_000, `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z`]);
koersen();
const voor = aanwasRij(await wealth.buildWealthModel(), HUIDIG_JAAR - 2);
assert.equal(voor.unexplained, 2000, 'vooraf: 0,05 BTC die nergens vandaan komt');
assert.equal(voor.result, 6000, 'en die zit in het rendement');
harness.exports.run(`UPDATE Valuation SET gapAcceptedAt = ? WHERE accountId = ?`,
[`${HUIDIG_JAAR - 2}-07-01T00:00:00.000Z`, acc]);
const na = aanwasRij(await wealth.buildWealthModel(), HUIDIG_JAAR - 2);
assert.equal(na.unexplained, 0, 'erna: niets meer onverklaard');
assert.equal(na.uitBalansen, 2000, 'het staat nu bij de stromen');
assert.equal(na.result, 4000, 'en het rendement is weer alleen de koerswinst');
});
it('houdt de uitsplitsing leeg als er niets onverklaard is', async () => {
koop({ sats: 100_000_000, fiatPrice: 20000, at: `${HUIDIG_JAAR - 3}-06-01T00:00:00` });
balans({ sats: 100_000_000, at: `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z` });
koersen();
for (const a of (await wealth.buildWealthModel()).aanwas) assert.deepEqual(a.unexplainedRows, [], `jaar ${a.year}`);
});
it('laat een rekening buiten box 3 ook buiten de uitsplitsing', async () => {
koop({ sats: 40_000_000, fiatPrice: 20000, at: `${HUIDIG_JAAR - 3}-06-01T00:00:00` });
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt, taxScope)
VALUES ('Pensioenpotje','pensioenpotje','bank',1,0,1,NULL,'none')`);
const pensioen = harness.exports.lastId();
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,1.0,?, 'manual')`,
[btc, `${HUIDIG_JAAR - 2}-06-30`, pensioen, 5_000_000, `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z`]);
koersen();
const jaar = aanwasRij(await wealth.buildWealthModel(), HUIDIG_JAAR - 2);
assert.deepEqual(jaar.unexplainedRows, []);
assert.equal(jaar.unexplained, 0);
});
it('rapporteert een sprong van vóór de eerste peildatum niet, en dat hoort zo', async () => {
balans({ sats: 100_000_000, at: `${HUIDIG_JAAR - 2}-06-30T00:00:00.000Z` });
koersen();
const m = await wealth.buildWealthModel();
assert.equal(m.years[0], HUIDIG_JAAR - 1, 'de reeks begint ná het jaar van de balans');
for (const a of m.aanwas) assert.equal(a.unexplained, 0, `jaar ${a.year}`);
assert.equal(m.rows[0].perYear[HUIDIG_JAAR - 1].sats, 100_000_000, 'de stand klopt wél vanaf dag één');
});
});
describe('een rekening buiten het belaste vermogen', () => {
function pensioenrekening() {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt, taxScope)
VALUES ('Pensioenpotje','pensioenpotje','bank',0,0,1,NULL,'none')`);
return harness.exports.lastId();
}
function koop({ sats, at, acc }) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'buy', ?, ?, 20000, ?, 'manual', ?)`, [btc, at, sats, acc, at]);
}
it('laat de rekening uit de cijfers, en noemt hem bij naam', async () => {
const pensioen = pensioenrekening();
koop({ sats: 100_000_000, at: `${HUIDIG_JAAR - 2}-06-01T00:00:00`, acc });
koop({ sats: 100_000_000, at: `${HUIDIG_JAAR - 2}-06-01T00:00:00`, acc: pensioen });
koersPerBtc(`${HUIDIG_JAAR - 1}-01-01`, 30000);
const m = await wealth.buildWealthModel();
assert.deepEqual(m.rows.map(r => r.account.name), ['Kluis'], 'de pensioenrij staat niet in de tabel');
assert.equal(m.totals.perYear[HUIDIG_JAAR - 1], 30000, 'en telt dus niet in het totaal mee');
assert.deepEqual(m.buitenBox3.map(x => x.name), ['Pensioenpotje'],
'maar hij verdwijnt niet stil: de pagina kan hem noemen');
});
it('houdt de uitsplitsing per categorie compleet', async () => {
const pensioen = pensioenrekening();
koop({ sats: 100_000_000, at: `${HUIDIG_JAAR - 2}-06-01T00:00:00`, acc });
koop({ sats: 50_000_000, at: `${HUIDIG_JAAR - 2}-06-01T00:00:00`, acc: pensioen });
koersPerBtc(`${HUIDIG_JAAR - 1}-01-01`, 30000);
const m = await wealth.buildWealthModel();
assert.equal(jaarRij(m, HUIDIG_JAAR - 1).categorieCompleet, true);
assert.deepEqual(Object.keys(jaarRij(m, HUIDIG_JAAR - 1).perCategorie), ['overig']);
});
it('laat een categorie op het asset zelf ook werken, en de rekening gaat vóór', async () => {
assert.equal(categorieVoor({ taxCategory: 'bank' }, { kind: 'crypto' }), 'bank', 'rekening');
assert.equal(categorieVoor({}, { kind: 'crypto', taxCategory: 'bank' }), 'bank', 'asset');
assert.equal(categorieVoor({ taxCategory: 'overig' }, { kind: 'fiat', taxCategory: 'bank' }), 'overig',
'de rekening gaat vóór het asset');
assert.equal(categorieVoor({}, { kind: 'fiat' }), 'bank', 'en zonder override de asset-soort');
});
it('volgt voor de reikwijdte dezelfde volgorde: rekening, asset, dan overal', async () => {
assert.equal(reikwijdteVoor({ taxScope: 'none' }, { taxScope: 'category' }), 'none', 'de rekening gaat vóór');
assert.equal(reikwijdteVoor({ taxScope: null }, { taxScope: 'category' }), 'category', 'dan het asset');
assert.equal(reikwijdteVoor({}, {}), 'all', 'en zonder keuze telt het overal');
assert.equal(reikwijdteVoor(null, null), 'all');
});
it('telt een banktegoed gewoon mee in de jaarcijfers', async () => {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES ('EUR','Euro','euro','fiat','EUR','cent',2,'unit')`);
const eur = harness.exports.lastId();
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'interest', ?, 500000, NULL, ?, 'manual', ?)`,
[eur, `${HUIDIG_JAAR - 3}-06-01T00:00:00`, acc, `${HUIDIG_JAAR - 3}-06-01T00:00:00`]);
for (const jaar of [HUIDIG_JAAR - 2, HUIDIG_JAAR - 1, HUIDIG_JAAR]) {
harness.exports.run(
`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, updatedAt)
VALUES (?, ?, 'day', 1, ?, ?)`,
[eur, `${jaar}-01-01T00:00:00Z`, testProvider(), `${jaar}-01-01T00:00:00Z`]);
}
const jaar = aanwasRij(await wealth.buildWealthModel(), HUIDIG_JAAR - 2);
assert.equal(jaar.begin, 5000, 'de beginwaarde kent het banktegoed');
assert.equal(jaar.eind, 5000, 'en de eindwaarde ook');
assert.equal(jaar.result, 0, 'dus geen rendement, en geen gat');
});
it('heeft dezelfde categorieën als de registry in shared/config.js', () => {
assert.deepEqual([...Object.values(VERMOGENSCATEGORIE)].sort(), [...ENUMS.TAX_CATEGORY].sort());
});
});
describe('de stand van nu, langs twee wegen', () => {
const somPerAsset = async () => (await import('../../server/data/positions.js'))
.positionsByAsset().reduce((s, r) => s + r.net, 0);
const koopZonderRekening = (sats, at) => harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, source, createdAt)
VALUES (?, 'buy', ?, ?, 30000, 'manual', ?)`, [btc, at, sats, at]);
it('geeft hetzelfde getal als elke boeking een rekening noemt', async () => {
koop({ sats: 100_000, fiatPrice: 30_000, at: `${HUIDIG_JAAR - 1}-03-01T00:00:00` });
koop({ sats: 40_000, fiatPrice: 40_000, at: `${HUIDIG_JAAR}-01-15T00:00:00` });
verkoop({ sats: 25_000, fiatPrice: 50_000, at: `${HUIDIG_JAAR}-02-01T00:00:00` });
const stand = await wealth.currentStanding();
assert.equal(stand.totalSats, 115_000);
assert.equal(stand.totalSats, await somPerAsset(), 'beide pagina\'s tellen dezelfde eenheden');
});
it('geeft ook hetzelfde getal als een boeking GEEN rekening noemt', async () => {
koop({ sats: 100_000, fiatPrice: 30_000, at: `${HUIDIG_JAAR}-01-15T00:00:00` });
koopZonderRekening(50_000, `${HUIDIG_JAAR}-02-01T00:00:00`);
const stand = await wealth.currentStanding();
assert.equal(await somPerAsset(), 150_000);
assert.equal(stand.totalSats, 150_000, 'de rest telt mee in plaats van weg te vallen');
});
it('zet die rest op een eigen rij, herkenbaar en zonder rekening-id', async () => {
koop({ sats: 100_000, fiatPrice: 30_000, at: `${HUIDIG_JAAR}-01-15T00:00:00` });
koopZonderRekening(50_000, `${HUIDIG_JAAR}-02-01T00:00:00`);
const rest = (await wealth.currentStanding()).rows.find(r => r.account.isUnassigned);
assert.ok(rest, 'er is een rij zonder rekening');
assert.equal(rest.sats, 50_000);
assert.equal(rest.account.id, null, 'geen id, zodat een opzoeking hard faalt in plaats van stil');
});
it('telt de rest ook mee in het jaarmodel, in de kolom van zijn eigen jaar', async () => {
koop({ sats: 100_000, fiatPrice: 30_000, at: `${HUIDIG_JAAR - 2}-03-01T00:00:00` });
koopZonderRekening(50_000, `${HUIDIG_JAAR}-06-01T00:00:00`);
koersPerBtc(`${HUIDIG_JAAR - 2}-01-01`, 30_000);
const m = await wealth.buildWealthModel();
const restRij = m.rows.find(r => r.account.isUnassigned);
assert.ok(restRij, 'de restrij staat in het model');
assert.equal(restRij.perYear[HUIDIG_JAAR].sats, 0, 'op 1 januari bestond hij nog niet');
assert.equal(restRij.current.sats, 50_000, 'en nu wel');
});
it('telt ook een NEGATIEVE rest mee, want die kant bestaat net zo goed', async () => {
koop({ sats: 100_000, fiatPrice: 30_000, at: `${HUIDIG_JAAR}-01-15T00:00:00` });
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, source, createdAt)
VALUES (?, 'sell', ?, 40000, 30000, 'manual', ?)`,
[btc, `${HUIDIG_JAAR}-02-01T00:00:00`, `${HUIDIG_JAAR}-02-01T00:00:00`]);
const stand = await wealth.currentStanding();
assert.equal(await somPerAsset(), 60_000, 'per asset is de verkoop eraf');
assert.equal(stand.totalSats, 60_000, 'en de stand volgt, in plaats van op 100.000 te blijven staan');
assert.equal(stand.rows.find(r => r.account.isUnassigned).sats, -40_000);
});
it('laat de rest de categorie van het ASSET volgen, want er is geen rekening', async () => {
koopZonderRekening(50_000, `${HUIDIG_JAAR}-06-01T00:00:00`);
const restRij = (await wealth.buildWealthModel()).rows.find(r => r.account.isUnassigned);
assert.equal(restRij.categorie, VERMOGENSCATEGORIE.OVERIG, 'bitcoin is een overige bezitting');
});
it('houdt de rest BUITEN box 3 als het asset daar staat, net als bij een echte rekening', async () => {
harness.exports.run(`UPDATE Asset SET taxScope = 'none' WHERE id = ?`, [btc]);
koopZonderRekening(50_000, `${HUIDIG_JAAR}-06-01T00:00:00`);
koersPerBtc(`${HUIDIG_JAAR}-01-01`, 30_000);
const m = await wealth.buildWealthModel();
assert.equal(m.rows.find(r => r.account.isUnassigned), undefined,
'de restrij zit niet in de fiscale rijen');
assert.equal(m.buitenBox3.length, 1, 'maar hij wordt wél bij naam genoemd');
assert.match(m.buitenBox3[0].name, /Zonder account/);
});
});