Files

630 lines
31 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:f9e5d8040f9c1061da0dda73705bf0d7c641408f2ad4f292363d7850e191ceff
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { toSearchName } from '../../shared/search-name.js';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { assetPositions, eventsNetByAccount } = await import('../../server/data/events.js');
const { accountHoldings, accountPositions, assetPositionsByAccount, firstPositionDate, positionAt, positionsByAsset,
unexplainedByPair, unexplainedChange, lightningBalanceDiffs, recordLightningBalance } = await import('../../server/data/positions.js');
const { anchorAt, latestAnchors } = await import('../../server/data/valuations.js');
const { lightningBalances } = await import('../../server/data/wallet-sources.js');
const { run, lastId } = harness.exports;
const net = (symbol) => positionsByAsset().find(p => p.symbol === symbol).net;
let btc, eur, kluis, bank;
beforeEach(() => {
harness.recreate();
btc = asset('BTC', 'Bitcoin', 'crypto', 8);
eur = asset('EUR', 'Euro', 'fiat', 2);
kluis = account('Kluis');
bank = account('Spaarrekening');
});
function asset(symbol, naam, kind, decimals) {
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES (?,?,?,?,?,NULL,?,'unit')`, [symbol, naam, toSearchName(naam), kind, symbol, decimals]);
return lastId();
}
function account(naam) {
run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt)
VALUES (?,?,'bank',1,0,1,NULL)`, [naam, toSearchName(naam)]);
return lastId();
}
function event({ assetId = null, type = 'buy', to = null, from = null, amount = 1000, unitFee = null,
at = '2026-03-01T12:00:00.000Z' } = {}) {
run(`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, unitFee, fromAccountId, toAccountId, source, createdAt)
VALUES (?,?,?,?,?,?,?,?,'manual',?)`,
[assetId ?? btc, type, at, amount, type === 'buy' || type === 'sell' ? 50000 : null, unitFee, from, to, at]);
return lastId();
}
function balans({ assetId = null, acc = undefined, balance = 5000, at = '2026-06-30T00:00:00.000Z' } = {}) {
const accountId = acc === undefined ? bank : acc;
run(`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,1.0,?, 'manual')`,
[assetId ?? eur, at.slice(0, 10), accountId, balance, at]);
return lastId();
}
describe('positionAt zonder anker', () => {
it('is gelijk aan de som van de gebeurtenissen, met en zonder peilmoment', () => {
event({ to: kluis, amount: 100_000, at: '2026-01-01T00:00:00.000Z' });
event({ to: kluis, amount: 50_000, at: '2026-05-01T00:00:00.000Z' });
for (const peil of [null, '2026-03-01T00:00:00.000Z', '2026-12-31T00:00:00.000Z']) {
assert.equal(positionAt(kluis, btc, peil), eventsNetByAccount(kluis, btc, peil), `peil ${peil}`);
}
assert.equal(positionAt(kluis, btc, null), 150_000, 'en dat is ook echt het saldo');
});
it('geeft nul voor een rekening zonder enige beweging', () => {
assert.equal(positionAt(bank, eur, null), 0);
});
it('ziet een waardering van een ánder asset of een ándere rekening niet als anker', () => {
balans({ acc: bank, assetId: eur, balance: 999_999 });
event({ to: kluis, amount: 100_000 });
assert.equal(positionAt(kluis, btc, null), 100_000, 'ander asset én andere rekening');
assert.equal(anchorAt(kluis, btc, null), null);
assert.equal(anchorAt(bank, btc, null), null, 'zelfde rekening, ander asset');
assert.equal(anchorAt(kluis, eur, null), null, 'zelfde asset, andere rekening');
});
it('negeert een portefeuille-brede waardering, want die zegt niets over één rekening', () => {
balans({ acc: null, assetId: eur, balance: 999_999 });
event({ assetId: eur, to: bank, amount: 2500 });
assert.equal(anchorAt(bank, eur, null), null);
assert.equal(positionAt(bank, eur, null), 2500);
});
});
describe('positionAt met een anker', () => {
it('geeft de balans zelf als er niets ná het anker gebeurde', () => {
balans({ balance: 123_456 });
assert.equal(positionAt(bank, eur, null), 123_456);
assert.equal(eventsNetByAccount(bank, eur, null), 0, 'en het grootboek is nog steeds leeg');
});
it('telt de bewegingen ná het anker erbovenop', () => {
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
event({ assetId: eur, type: 'interest', to: bank, amount: 500, at: '2026-07-31T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), 100_500, 'rente ná de balans komt erbij');
});
it('negeert de bewegingen vóór het anker, ook als ze niet kloppen', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-01-01T00:00:00.000Z' });
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), 100_000);
});
it('rekent een gebeurtenis exact op rateAt ín het anker, en niet erbovenop', () => {
const moment = '2026-06-30T00:00:00.000Z';
event({ assetId: eur, to: bank, amount: 7_000, at: moment });
balans({ balance: 100_000, at: moment });
assert.equal(positionAt(bank, eur, null), 100_000);
});
it('kijkt naar rateAt en niet naar valuedOn', () => {
run(`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?, '2026-06-30', ?, 100000, 1.0, '2026-07-01T12:00:00.000Z', 'manual')`, [eur, bank]);
event({ assetId: eur, to: bank, amount: 7_000, at: '2026-07-01T09:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), 100_000);
});
it('neemt bij twee ankers de nieuwste vóór het peilmoment', () => {
balans({ balance: 100_000, at: '2026-03-31T00:00:00.000Z' });
balans({ balance: 200_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, '2026-05-01T00:00:00.000Z'), 100_000, 'de oudste geldt in mei');
assert.equal(positionAt(bank, eur, null), 200_000, 'en nu de nieuwste');
});
it('kijkt daarbij naar het peilmoment en niet naar de invoervolgorde', () => {
balans({ balance: 200_000, at: '2026-06-30T00:00:00.000Z' });
balans({ balance: 100_000, at: '2026-03-31T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), 200_000, 'juni is het nieuwste anker, ook al is maart later ingevoerd');
assert.equal(anchorAt(bank, eur, null).balance, 200_000);
});
it('laat een anker exact óp het peilmoment gelden', () => {
const moment = '2026-06-30T00:00:00.000Z';
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-01-01T00:00:00.000Z' });
balans({ balance: 100_000, at: moment });
assert.equal(positionAt(bank, eur, moment), 100_000);
});
it('negeert een anker dat ná het peilmoment ligt', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-01-01T00:00:00.000Z' });
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, '2026-02-01T00:00:00.000Z'), 40_000);
});
it('kan een negatieve uitkomst geven als er ná het anker meer af gaat dan erop staat', () => {
balans({ balance: 1_000 });
event({ assetId: eur, type: 'sell', from: bank, amount: 3_000, at: '2026-08-01T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), -2_000);
});
});
describe('positionAt hergebruikt de tekenregels van het grootboek', () => {
it('laat de unitFee van een uitgaande verplaatsing ná het anker meedrukken', () => {
balans({ balance: 100_000 });
event({ assetId: eur, type: 'transfer', from: bank, to: kluis, amount: 10_000, unitFee: 250,
at: '2026-08-01T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), 89_750);
});
it('rekent een zelf-transfer ná het anker als alleen de fee', () => {
balans({ balance: 100_000 });
event({ assetId: eur, type: 'transfer', from: bank, to: bank, amount: 10_000, unitFee: 250,
at: '2026-08-01T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), 99_750);
});
it('laat een boeking zónder rekening buiten de stand van elke rekening', () => {
balans({ balance: 100_000 });
event({ assetId: eur, amount: 60_000, at: '2026-08-01T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), 100_000);
assert.equal(positionAt(kluis, eur, null), 0);
});
});
describe('latestAnchors', () => {
it('geeft per rekening en asset de nieuwste, en laat de portefeuille-brede rijen weg', () => {
balans({ acc: bank, balance: 100_000, at: '2026-03-31T00:00:00.000Z' });
balans({ acc: bank, balance: 200_000, at: '2026-06-30T00:00:00.000Z' });
balans({ acc: kluis, assetId: btc, balance: 50_000, at: '2026-01-01T00:00:00.000Z' });
balans({ acc: null, balance: 999_999 });
assert.deepEqual(
latestAnchors().map(a => ({ accountId: a.accountId, assetId: a.assetId, balance: a.balance })),
[{ accountId: kluis, assetId: btc, balance: 50_000 }, { accountId: bank, assetId: eur, balance: 200_000 }]);
});
it('is leeg zonder waarderingen', () => {
assert.deepEqual(latestAnchors(), []);
});
});
describe('positionsByAsset', () => {
it('is gelijk aan het rauwe aggregaat zolang er geen anker is', () => {
event({ to: kluis, amount: 100_000 });
event({ assetId: eur, to: bank, amount: 2_500 });
assert.deepEqual(positionsByAsset(), assetPositions());
});
it('corrigeert de stand van een asset met een anker', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-01-01T00:00:00.000Z' });
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
event({ assetId: eur, type: 'interest', to: bank, amount: 500, at: '2026-07-31T00:00:00.000Z' });
assert.equal(net('EUR'), 100_500, 'de balans plus de rente erna');
assert.equal(assetPositions().find(p => p.symbol === 'EUR').net, 40_500, 'het rauwe grootboek zegt iets anders');
});
it('telt de ankers van twee rekeningen bij elkaar op', () => {
const tweede = account('Tweede rekening');
balans({ acc: bank, balance: 100_000 });
balans({ acc: tweede, balance: 25_000 });
assert.equal(net('EUR'), 125_000);
});
it('houdt een boeking zónder rekening in het vermogenstotaal', () => {
event({ assetId: eur, amount: 60_000, at: '2026-01-01T00:00:00.000Z' });
balans({ acc: bank, balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(net('EUR'), 160_000, 'het anker van de bank plus de rekeningloze boeking');
});
it('laat een portefeuille-brede balans het totaal niet verschuiven', () => {
event({ assetId: eur, to: bank, amount: 2_500 });
balans({ acc: null, balance: 999_999 });
assert.equal(net('EUR'), 2_500);
});
it('laat de koop-aggregaten ongemoeid', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-01-01T00:00:00.000Z' });
balans({ balance: 100_000 });
const rij = positionsByAsset().find(p => p.symbol === 'EUR');
const rauw = assetPositions().find(p => p.symbol === 'EUR');
assert.equal(rij.buy_events, rauw.buy_events);
assert.equal(rij.buy_amount, rauw.buy_amount);
assert.equal(rij.buy_spot_raw, rauw.buy_spot_raw);
});
it('komt uit op de som van de standen per rekening plus wat geen rekening heeft', () => {
const tweede = account('Tweede rekening');
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-01-01T00:00:00.000Z' });
event({ assetId: eur, to: tweede, amount: 7_000, at: '2026-02-01T00:00:00.000Z' });
event({ assetId: eur, amount: 60_000, at: '2026-02-15T00:00:00.000Z' });
balans({ acc: bank, balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
event({ assetId: eur, type: 'interest', to: bank, amount: 500, at: '2026-07-31T00:00:00.000Z' });
const perRekening = positionAt(bank, eur, null) + positionAt(tweede, eur, null);
assert.equal(net('EUR'), perRekening + 60_000);
});
});
describe('accountPositions', () => {
it('laat een asset weg waar niets meer op staat', () => {
event({ assetId: eur, to: bank, amount: 1_000, at: '2026-01-01T00:00:00.000Z' });
event({ assetId: eur, type: 'sell', from: bank, amount: 1_000, at: '2026-02-01T00:00:00.000Z' });
assert.deepEqual(accountPositions(bank), []);
});
it('ziet een rekening die alleen een balans heeft, en dat is de hele reden', () => {
balans({ balance: 123_456 });
assert.deepEqual(accountPositions(bank), [{ assetId: eur, saldo: 123_456 }]);
});
it('geeft meerdere assets terug, op id gesorteerd', () => {
event({ to: bank, amount: 100_000 });
balans({ balance: 5_000 });
assert.deepEqual(accountPositions(bank), [{ assetId: btc, saldo: 100_000 }, { assetId: eur, saldo: 5_000 }]);
});
it('houdt de rekeningen gescheiden', () => {
balans({ acc: bank, balance: 5_000 });
event({ to: kluis, amount: 100_000 });
assert.deepEqual(accountPositions(kluis), [{ assetId: btc, saldo: 100_000 }]);
});
});
describe('accountHoldings', () => {
it('geeft het saldo van nu en de nieuwste balans eronder, niet de boeking erna', () => {
balans({ balance: 5_000, at: '2026-06-30T00:00:00.000Z' });
event({ assetId: eur, type: 'sell', from: bank, amount: 1_000, at: '2026-07-15T12:00:00.000Z' });
assert.deepEqual(accountHoldings([bank]), [{ accountId: bank, assetId: eur, saldo: 4_000,
anchor: { valuedOn: '2026-06-30', rateAt: '2026-06-30T00:00:00.000Z', balance: 5_000 } }]);
});
it('geeft geen anker voor een stand uit alleen boekingen, en volgt de volgorde van de vraag', () => {
event({ to: kluis, amount: 100_000 });
balans({ acc: bank, balance: 7_000 });
const rijen = accountHoldings([kluis, bank]);
assert.deepEqual(rijen.map(r => r.accountId), [kluis, bank]);
assert.equal(rijen[0].anchor, null);
for (const id of [kluis, bank]) {
assert.deepEqual(rijen.filter(r => r.accountId === id).map(({ assetId, saldo }) => ({ assetId, saldo })),
accountPositions(id), `hetzelfde saldo als accountPositions voor ${id}`);
}
});
it('laat een leeg paar weg, ook als er een balans van nul onder staat', () => {
balans({ balance: 0 });
event({ to: kluis, amount: 1_000, at: '2026-01-01T00:00:00.000Z' });
event({ type: 'sell', from: kluis, amount: 1_000, at: '2026-02-01T00:00:00.000Z' });
assert.deepEqual(accountHoldings([bank, kluis]), []);
});
it('ziet een balans in de toekomst nog niet als anker van nu', () => {
balans({ balance: 5_000, at: '2026-01-01T00:00:00.000Z' });
balans({ balance: 9_999, at: '2099-01-01T00:00:00.000Z' });
const [rij] = accountHoldings([bank]);
assert.equal(rij.saldo, 5_000);
assert.equal(rij.anchor.valuedOn, '2026-01-01');
});
});
describe('assetPositionsByAccount', () => {
const som = rijen => rijen.reduce((s, r) => s + r.saldo, 0);
it('splitst per rekening, de grootste eerst', () => {
event({ to: kluis, amount: 100_000 });
event({ to: bank, amount: 300_000 });
const rijen = assetPositionsByAccount(btc);
assert.deepEqual(rijen, [{ accountId: bank, saldo: 300_000 }, { accountId: kluis, saldo: 100_000 }]);
assert.equal(som(rijen), net('BTC'), 'telt op tot de stand van het asset');
});
it('geeft een lege lijst voor een asset waar niets van is', () => {
assert.deepEqual(assetPositionsByAccount(btc), []);
});
it('laat een rekening weg die per saldo leeg is', () => {
event({ to: kluis, amount: 100_000, at: '2026-01-01T00:00:00.000Z' });
event({ type: 'sell', from: kluis, amount: 100_000, at: '2026-02-01T00:00:00.000Z' });
assert.deepEqual(assetPositionsByAccount(btc), []);
});
it('ziet een rekening die alleen een balans heeft', () => {
balans({ acc: bank, assetId: eur, balance: 123_456 });
const rijen = assetPositionsByAccount(eur);
assert.deepEqual(rijen, [{ accountId: bank, saldo: 123_456 }]);
assert.equal(som(rijen), net('EUR'));
});
it('houdt een verplaatsing tussen twee eigen rekeningen kloppend', () => {
event({ to: kluis, amount: 100_000, at: '2026-01-01T00:00:00.000Z' });
event({ type: 'transfer', from: kluis, to: bank, amount: 40_000, at: '2026-02-01T00:00:00.000Z' });
const rijen = assetPositionsByAccount(btc);
assert.deepEqual(rijen, [{ accountId: kluis, saldo: 60_000 }, { accountId: bank, saldo: 40_000 }]);
assert.equal(som(rijen), net('BTC'), 'een verplaatsing verandert het totaal niet');
});
it('zet een aankoop zonder rekening in een eigen rij, achteraan', () => {
event({ to: kluis, amount: 100_000 });
event({ amount: 50_000 });
const rijen = assetPositionsByAccount(btc);
assert.deepEqual(rijen, [{ accountId: kluis, saldo: 100_000 }, { accountId: null, saldo: 50_000 }]);
assert.equal(som(rijen), net('BTC'));
});
it('verbergt een negatieve rest niet', () => {
event({ to: kluis, amount: 100_000, at: '2026-01-01T00:00:00.000Z' });
event({ type: 'sell', amount: 40_000, at: '2026-02-01T00:00:00.000Z' });
const rijen = assetPositionsByAccount(btc);
assert.deepEqual(rijen, [{ accountId: kluis, saldo: 100_000 }, { accountId: null, saldo: -40_000 }]);
assert.equal(som(rijen), net('BTC'), 'en de som klopt nog steeds');
});
it('blijft kloppen met een anker erbij', () => {
event({ assetId: eur, to: bank, amount: 1_000, at: '2026-01-01T00:00:00.000Z' });
balans({ acc: bank, assetId: eur, balance: 5_000, at: '2026-06-30T00:00:00.000Z' });
const rijen = assetPositionsByAccount(eur);
assert.deepEqual(rijen, [{ accountId: bank, saldo: 5_000 }]);
assert.equal(som(rijen), net('EUR'), 'geen restrij, want het anker zit in allebei de getallen');
});
it('respecteert het peilmoment', () => {
event({ to: kluis, amount: 100_000, at: '2026-01-01T00:00:00.000Z' });
event({ to: bank, amount: 300_000, at: '2026-05-01T00:00:00.000Z' });
assert.deepEqual(assetPositionsByAccount(btc, '2026-03-01T00:00:00.000Z'),
[{ accountId: kluis, saldo: 100_000 }], 'de tweede rekening bestond toen nog niet');
});
});
describe('firstPositionDate', () => {
it('is null als er niets is', () => {
assert.equal(firstPositionDate(), null);
});
it('neemt de eerste gebeurtenis als er geen anker is', () => {
event({ to: kluis, amount: 1_000, at: '2022-06-01T00:00:00' });
assert.equal(firstPositionDate(), '2022-06-01T00:00:00');
});
it('neemt het eerste anker als er geen gebeurtenis is', () => {
balans({ at: '2026-06-30T00:00:00.000Z' });
assert.equal(firstPositionDate(), '2026-06-30T00:00:00.000Z');
});
it('neemt de vroegste van de twee, welke van de twee dat ook is', () => {
event({ to: kluis, amount: 1_000, at: '2022-06-01T00:00:00' });
balans({ at: '2026-06-30T00:00:00.000Z' });
assert.equal(firstPositionDate(), '2022-06-01T00:00:00', 'de gebeurtenis was eerder');
harness.exports.run(`DELETE FROM Event`);
event({ to: kluis, amount: 1_000, at: '2027-01-01T00:00:00' });
assert.equal(firstPositionDate(), '2026-06-30T00:00:00.000Z', 'nu was het anker eerder');
});
it('kijkt niet naar een portefeuille-brede balans', () => {
balans({ acc: null, at: '2020-01-01T00:00:00.000Z' });
event({ to: kluis, amount: 1_000, at: '2022-06-01T00:00:00' });
assert.equal(firstPositionDate(), '2022-06-01T00:00:00');
});
});
describe('unexplainedChange', () => {
const jaar = ['2026-01-01T00:00:00.000Z', '2027-01-01T00:00:00.000Z'];
it('is nul zonder anker, wat er ook geboekt is', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
event({ assetId: eur, type: 'sell', from: bank, amount: 10_000, at: '2026-05-01T00:00:00.000Z' });
assert.equal(unexplainedChange(bank, eur, ...jaar), 0);
});
it('is de sprong die het anker maakt', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(unexplainedChange(bank, eur, ...jaar), 60_000);
});
it('meldt niets als de balans het grootboek bevestigt', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
balans({ balance: 40_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(unexplainedChange(bank, eur, ...jaar), 0);
});
it('rekent de sprong toe aan het jaar waarin hij valt en niet aan het jaar erna', () => {
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(unexplainedChange(bank, eur, ...jaar), 100_000, 'in 2026 wél');
assert.equal(unexplainedChange(bank, eur, '2027-01-01T00:00:00.000Z', '2028-01-01T00:00:00.000Z'), 0,
'in 2027 niet meer');
});
it('telt de rente ná het anker niet als onverklaard', () => {
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
event({ assetId: eur, type: 'interest', to: bank, amount: 500, at: '2026-07-31T00:00:00.000Z' });
assert.equal(unexplainedChange(bank, eur, ...jaar), 100_000, 'alleen de sprong van het anker zelf');
});
it('houdt rekeningen en assets gescheiden', () => {
balans({ acc: bank, balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(unexplainedChange(kluis, eur, ...jaar), 0, 'andere rekening');
assert.equal(unexplainedChange(bank, btc, ...jaar), 0, 'ander asset');
});
});
describe('unexplainedByPair', () => {
const jaar = ['2026-01-01T00:00:00.000Z', '2027-01-01T00:00:00.000Z'];
it('geeft niets terug als er niets onverklaard is', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
balans({ balance: 40_000, at: '2026-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar), [], 'de balans bevestigt het grootboek');
});
it('noemt het paar, het aantal eenheden en wat daarvan uit een beginbalans komt', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar),
[{ accountId: bank, assetId: eur, units: 60_000, openingUnits: 0, hasLedger: true }],
'er stond een boeking vóór deze balans, dus dit is een verschil dat te boeken valt');
});
it('rekent een balans zónder boekingen ervóór toe aan `openingUnits`', () => {
balans({ acc: kluis, assetId: btc, balance: 5_000_000, at: '2026-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar),
[{ accountId: kluis, assetId: btc, units: 5_000_000, openingUnits: 5_000_000, hasLedger: false }]);
});
it('blijft een beginbalans noemen als er ná die balans wél geboekt is', () => {
balans({ balance: 159_743, at: '2026-02-01T00:00:00.000Z' });
event({ assetId: eur, to: bank, amount: 380_000, at: '2026-03-01T00:00:00.000Z' });
balans({ balance: 539_743, at: '2026-04-01T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar),
[{ accountId: bank, assetId: eur, units: 159_743, openingUnits: 159_743, hasLedger: true }]);
});
it('kijkt naar het grootboek van dát paar en niet van de rekening', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
balans({ acc: bank, assetId: btc, balance: 5_000_000, at: '2026-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar),
[{ accountId: bank, assetId: btc, units: 5_000_000, openingUnits: 5_000_000, hasLedger: false }]);
});
it('geeft een negatief bedrag als de boekingen méér verklaren dan de stand veranderde', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
event({ assetId: eur, to: bank, amount: 60_000, at: '2027-03-01T00:00:00.000Z' });
balans({ balance: 100_000, at: '2027-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair('2027-01-01T00:00:00.000Z', '2028-01-01T00:00:00.000Z'),
[{ accountId: bank, assetId: eur, units: -60_000, openingUnits: 0, hasLedger: true }]);
});
it('rekent een balans exact op het beginmoment aan het jaar ervóór toe', () => {
balans({ balance: 100_000, at: '2026-01-01T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar), [], 'in 2026 niet meer');
assert.deepEqual(unexplainedByPair('2025-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'),
[{ accountId: bank, assetId: eur, units: 100_000, openingUnits: 100_000, hasLedger: false }],
'maar in 2025 wél, want dáár sprong de stand');
});
it('laat die balans ook het afgesplitste deel van het nieuwe jaar niet vervuilen', () => {
balans({ balance: 100_000, at: '2026-01-01T00:00:00.000Z' });
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
balans({ balance: 200_000, at: '2026-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar),
[{ accountId: bank, assetId: eur, units: 60_000, openingUnits: 0, hasLedger: true }]);
});
it('telt een AANVAARD verschil net zo goed als instap', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
const id = balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar),
[{ accountId: bank, assetId: eur, units: 60_000, openingUnits: 0, hasLedger: true }],
'vooraf: gewoon een verschil');
run(`UPDATE Valuation SET gapAcceptedAt = '2026-07-01T00:00:00.000Z' WHERE id = ?`, [id]);
assert.deepEqual(unexplainedByPair(...jaar),
[{ accountId: bank, assetId: eur, units: 60_000, openingUnits: 60_000, hasLedger: true }],
'erna: hetzelfde bedrag, maar nu als stroom');
});
it('laat een portefeuille-brede balans buiten de uitsplitsing', () => {
balans({ acc: null, balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar), []);
});
});
describe('een balans in de toekomst', () => {
const morgen = new Date(Date.now() + 36 * 3600 * 1000).toISOString();
const gisteren = new Date(Date.now() - 36 * 3600 * 1000).toISOString();
it('raakt de stand van nu niet, en geldt wél op een peilmoment erná', () => {
event({ assetId: eur, to: bank, amount: 10_000, at: gisteren });
balans({ balance: 999_999, at: morgen });
assert.equal(positionAt(bank, eur, null), 999_999, 'zonder peilmoment is het "alles", inclusief de toekomst');
assert.equal(net('EUR'), 10_000, 'maar het portefeuille-totaal van nu kent hem niet');
assert.deepEqual(accountPositions(bank), [{ assetId: eur, saldo: 10_000 }], 'en de stand per rekening ook niet');
});
it('wordt wél het anker als je een peilmoment ná die datum vraagt', () => {
balans({ balance: 999_999, at: morgen });
assert.equal(net('EUR'), 0, 'nu nog niet');
const later = new Date(Date.now() + 72 * 3600 * 1000).toISOString();
assert.equal(positionsByAsset(later).find(p => p.symbol === 'EUR').net, 999_999, 'straks wel');
});
it('houdt ook een boeking met een datum in de toekomst buiten het totaal van nu', () => {
event({ assetId: eur, to: bank, amount: 10_000, at: gisteren });
event({ assetId: eur, to: bank, amount: 500_000, at: morgen });
assert.equal(net('EUR'), 10_000);
assert.deepEqual(accountPositions(bank), [{ assetId: eur, saldo: 10_000 }]);
});
it('laat de assets zonder boeking binnen het venster gewoon in de lijst staan', () => {
event({ assetId: eur, to: bank, amount: 500_000, at: morgen });
assert.deepEqual(positionsByAsset().map(p => p.symbol), ['BTC', 'EUR'], 'beide assets staan er nog');
assert.equal(net('EUR'), 0);
});
});
describe('lightningBalanceDiffs en recordLightningBalance', () => {
const GEMETEN = '2026-10-19T10:00:00.000Z';
function bron(kind, sats, acc = kluis) {
let conn = null;
if (kind === 'lnd') {
run(`INSERT INTO Connection (kind, name, searchName, url, isActive, isSystem, createdAt, updatedAt)
VALUES ('lnd', ?, ?, 'https://node.test', 1, 0, '2024-01-01', '2024-01-01')`, [`Node ${acc}`, `node ${acc}`]);
conn = lastId();
}
run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, balanceSats, lastScannedAt, createdAt)
VALUES (?, ?, ?, ?, ?, ?, '2026-01-01')`, [acc, conn, kind, `${kind}-${acc}`, sats, GEMETEN]);
}
function adres(sats, acc = kluis) {
run(`INSERT INTO WalletAddress (accountId, address, balanceSats, createdAt) VALUES (?, ?, ?, '2026-01-01')`,
[acc, `bc1q-${acc}-${sats}`, sats]);
}
it('lightningBalances: per account de node, en NWC alleen zonder node', () => {
bron('lnd', 70_000);
bron('nwc', 21_000);
bron('nwc', 5_000, bank);
assert.deepEqual(lightningBalances().map(r => [r.accountId, r.sats, r.scannedAt]),
[[kluis, 70_000, GEMETEN], [bank, 5_000, GEMETEN]]);
});
it('meet on-chain plus de node tegen het grootboek, zonder het NWC-saldo erbovenop', () => {
bron('lnd', 70_000);
bron('nwc', 21_000);
adres(30_000);
event({ to: kluis, amount: 90_000, at: '2026-10-01T00:00:00.000Z' });
assert.deepEqual(lightningBalanceDiffs().map(d => [d.accountId, d.measured, d.ledger, d.gap]),
[[kluis, 100_000, 90_000, 10_000]]);
});
it('een NWC-wallet zonder LND-node telt wel, en een gelijk grootboek geeft geen rij', () => {
bron('nwc', 21_000, bank);
assert.deepEqual(lightningBalanceDiffs().map(d => [d.accountId, d.measured]), [[bank, 21_000]]);
event({ to: bank, amount: 21_000, at: '2026-10-01T00:00:00.000Z' });
assert.deepEqual(lightningBalanceDiffs(), []);
});
it('vastleggen maakt een balans met het GEMETEN bedrag, waarna de rij verdwijnt', async () => {
bron('lnd', 70_000);
const id = await recordLightningBalance(kluis, { valuedOn: '2026-10-19', fiatRate: 60_000, balance: 1 });
const v = harness.exports.one(`SELECT * FROM Valuation WHERE id = ?`, [id]);
assert.deepEqual([v.accountId, v.assetId, v.balance, v.rateAt, v.source], [kluis, btc, 70_000, GEMETEN, 'onchain']);
assert.deepEqual(lightningBalanceDiffs(), []);
});
it('vervangt een eerdere meting van dezelfde dag, maar nooit een getypte balans', async () => {
bron('lnd', 70_000);
const eerste = await recordLightningBalance(kluis, { valuedOn: '2026-10-19', fiatRate: 60_000 });
run(`UPDATE WalletSource SET balanceSats = 71000`);
assert.equal(await recordLightningBalance(kluis, { valuedOn: '2026-10-19', fiatRate: 60_000 }), eerste);
assert.equal(harness.exports.one(`SELECT balance FROM Valuation WHERE id = ?`, [eerste]).balance, 71_000);
run(`UPDATE Valuation SET source = 'manual'`);
await assert.rejects(recordLightningBalance(kluis, { valuedOn: '2026-10-19', fiatRate: 60_000 }),
e => e.key === 'valuations.lightningSameDay');
});
it('weigert een account zonder bijgewerkte lightning-bron, een dag zonder vorm en een koers van nul', async () => {
await assert.rejects(recordLightningBalance(kluis, { valuedOn: '2026-10-19', fiatRate: 1 }), /no updated lightning source/);
bron('lnd', 70_000);
await assert.rejects(recordLightningBalance(kluis, { valuedOn: '19-10-2026', fiatRate: 1 }), /needs its day/);
await assert.rejects(recordLightningBalance(kluis, { valuedOn: '2026-10-19', fiatRate: 0 }), /rate above zero/);
});
});