// 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/); }); });