Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+705
View File
@@ -0,0 +1,705 @@
// Generated, do not edit. sha256:3f3763318dbbed19b2856eb5ad9116f8f3871486bcca510f894e39694929a1e0
import { describe, it, beforeEach, mock } from 'node:test';
import { toSearchName } from '../../shared/search-name.js';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const {
createEvent, eventsCountByAccount, eventsNetByAccount,
eventsWithoutImportRef, findEventByRef, firstTaxEventDate, listAssetTrades, listEvents, listFlowLegs,
listTradeEvents, listTrades, listTransfers, updateEvent
} = await import('../../server/data/events.js');
const { listInterest, listInterestForAccount, scheduleLabel } = await import('../../server/data/interest-schedules.js');
const {
anchorBefore, listOpenValuationDiffs, listValuations, valuationDiff, valuationDiffs, valuationGap,
valuationReconcilable
} = await import('../../server/data/valuations.js');
let btc, eth, accA, accB;
beforeEach(() => {
harness.recreate();
btc = asset('BTC');
eth = asset('ETH');
accA = account('A');
accB = account('B');
});
function asset(symbol) {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,'crypto',?,NULL,8,'unit')`, [symbol, symbol, toSearchName(symbol), symbol]);
return harness.exports.lastId();
}
function account(naam) {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,'wallet',1,0,1,NULL)`, [naam, toSearchName(naam)]);
return harness.exports.lastId();
}
function event({ type = 'buy', assetId = null, from = null, to = null, amount = 1000,
at = '2024-01-01T00:00:00', fiatPrice = null, fiatFee = null, unitFee = null,
ref = null, source = 'manual' } = {}) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fiatFee, unitFee,
fromAccountId, toAccountId, source, importReference, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
[assetId ?? btc, type, at, amount,
fiatPrice ?? (type === 'buy' || type === 'sell' ? 30000 : null), fiatFee, unitFee,
from, to, source, ref, at]);
return harness.exports.lastId();
}
function waardering({ acc, onDate, balance, assetId = null, rateAt = null }) {
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,?,?, 'manual')`,
[assetId ?? btc, onDate, acc, balance, 50000, rateAt ?? `${onDate}T00:00:00`]);
return harness.exports.lastId();
}
describe('eventsNetByAccount', () => {
const net = (acc = accA, assetId = btc, until = '2030-01-01T00:00:00') =>
eventsNetByAccount(acc, assetId, until);
it('is 0 zonder events', () => {
assert.equal(net(), 0);
});
it('telt een aankoop op het ontvangende account op', () => {
event({ type: 'buy', to: accA, amount: 1000 });
assert.equal(net(), 1000);
});
it('trekt een verkoop van het afgevende account af', () => {
event({ type: 'sell', from: accA, amount: 400 });
assert.equal(net(), -400);
});
it('trekt bij een verkoop ook de fee af', () => {
event({ type: 'sell', from: accA, amount: 400, unitFee: 3 });
assert.equal(net(), -403);
});
it('telt rente op het ontvangende account op', () => {
event({ type: 'interest', to: accA, amount: 25 });
assert.equal(net(), 25);
});
it('telt een inkomende transfer vól mee, zonder fee-aftrek', () => {
event({ type: 'transfer', from: accB, to: accA, amount: 1000, unitFee: 20 });
assert.equal(net(), 1000);
});
it('trekt bij een uitgaande transfer het bedrag én de fee af', () => {
event({ type: 'transfer', from: accA, to: accB, amount: 1000, unitFee: 20 });
assert.equal(net(), -1020);
});
it('rekent een transfer naar hetzelfde account als alleen de fee', () => {
event({ type: 'transfer', from: accA, to: accA, amount: 1000, unitFee: 20 });
assert.equal(net(), -20);
});
it('behandelt een ontbrekende fee als 0', () => {
event({ type: 'transfer', from: accA, to: accB, amount: 1000, unitFee: null });
assert.equal(net(), -1000);
});
it('negeert events van een ander asset', () => {
event({ type: 'buy', to: accA, amount: 1000, assetId: eth });
assert.equal(net(accA, btc), 0);
assert.equal(net(accA, eth), 1000);
});
it('negeert events van een ander account', () => {
event({ type: 'buy', to: accB, amount: 1000 });
assert.equal(net(accA), 0);
});
it('neemt het peilmoment zelf mee (t/m, niet tot)', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-03-01T12:00:00' });
assert.equal(net(accA, btc, '2024-03-01T12:00:00'), 1000, 'gelijk aan het peilmoment telt mee');
assert.equal(net(accA, btc, '2024-03-01T11:59:59'), 0, 'daarvóór nog niet');
});
it('telt zonder peilmoment alles mee', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2030-06-01T00:00:00' });
assert.equal(eventsNetByAccount(accA, btc, null), 1000);
});
it('somt een reeks correct op', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'interest', to: accA, amount: 50, at: '2024-02-01T00:00:00' });
event({ type: 'transfer', from: accA, to: accB, amount: 300, unitFee: 10, at: '2024-03-01T00:00:00' });
event({ type: 'sell', from: accA, amount: 200, at: '2024-04-01T00:00:00' });
assert.equal(net(), 1000 + 50 - 310 - 200);
});
});
describe('listTradeEvents', () => {
beforeEach(() => {
event({ type: 'buy', to: accA, at: '2023-12-31T23:59:59', amount: 1 });
event({ type: 'buy', to: accA, at: '2024-01-01T00:00:00', amount: 2 });
event({ type: 'sell', from: accA, at: '2024-06-01T00:00:00', amount: 3 });
event({ type: 'buy', to: accA, at: '2024-12-31T23:59:59', amount: 4 });
event({ type: 'buy', to: accA, at: '2025-01-01T00:00:00', amount: 5 });
event({ type: 'transfer', from: accA, to: accB, at: '2024-05-01T00:00:00', amount: 99 });
event({ type: 'interest', to: accA, at: '2024-05-02T00:00:00', amount: 98 });
});
const bedragen = (van, tot) => listTradeEvents(van, tot).map(e => e.amount).sort((a, b) => a - b);
it('neemt alleen buy en sell mee', () => {
assert.deepEqual(bedragen(null, '2030-01-01T00:00:00'), [1, 2, 3, 4, 5]);
});
it('sluit de ondergrens uit en de bovengrens in', () => {
assert.deepEqual(bedragen('2024-01-01T00:00:00', '2024-12-31T23:59:59'), [3, 4],
'de aankoop exact op de ondergrens hoort bij het vorige jaar');
});
it('betekent van=null: vanaf het begin', () => {
assert.deepEqual(bedragen(null, '2024-01-01T00:00:00'), [1, 2]);
});
it('geeft niets terug voor een leeg venster', () => {
assert.deepEqual(bedragen('2024-07-01T00:00:00', '2024-07-31T00:00:00'), []);
});
it('neemt een verplaatsing tussen twee eigen accounts alleen mee als hij een euro-fee draagt', () => {
event({ type: 'transfer', from: accA, to: accB, at: '2024-08-01T00:00:00', amount: 97, fiatFee: 2 });
event({ type: 'transfer', from: accA, to: accB, at: '2024-08-02T00:00:00', amount: 96, fiatFee: 0 });
assert.deepEqual(bedragen('2024-07-31T00:00:00', '2024-08-31T00:00:00'), [97]);
});
it('neemt elke eenzijdige verplaatsing mee, naar buiten en van buiten', () => {
event({ type: 'transfer', from: accA, to: null, at: '2024-08-03T00:00:00', amount: 95 });
event({ type: 'transfer', from: null, to: accB, at: '2024-08-04T00:00:00', amount: 94 });
assert.deepEqual(bedragen('2024-07-31T00:00:00', '2024-08-31T00:00:00'), [94, 95]);
});
it('levert de velden die de aanwasberekening nodig heeft', () => {
const e = listTradeEvents('2024-05-31T00:00:00', '2024-06-02T00:00:00')[0];
assert.deepEqual(Object.keys(e).sort(),
['amount', 'assetId', 'decimals', 'fiatFee', 'fiatPrice', 'fromAccountId', 'occurredAt',
'priceOrigin', 'toAccountId', 'type']);
assert.equal(e.decimals, 8, 'de decimals van het asset komen mee voor de omrekening');
});
});
describe('listFlowLegs', () => {
const benen = (van, tot) => listFlowLegs(van, tot)
.map(l => `${l.accountId}:${l.type}:${l.direction}:${l.units}`).sort();
it('geeft een aankoop als één been op de ontvangende rekening', () => {
event({ type: 'buy', to: accA, amount: 100, fiatPrice: 20000, at: '2024-02-01T00:00:00' });
assert.deepEqual(benen(null, '2025-01-01T00:00:00'), [`${accA}:buy:in:100`]);
});
it('geeft een verkoop als één been op de leverende rekening', () => {
event({ type: 'sell', from: accA, amount: 100, fiatPrice: 20000, at: '2024-02-01T00:00:00' });
assert.deepEqual(benen(null, '2025-01-01T00:00:00'), [`${accA}:sell:out:100`]);
});
it('geeft een verplaatsing als TWEE benen, één per rekening', () => {
event({ type: 'transfer', from: accA, to: accB, amount: 300, at: '2024-03-01T00:00:00' });
assert.deepEqual(benen(null, '2025-01-01T00:00:00'),
[`${accA}:transfer:out:300`, `${accB}:transfer:in:300`].sort());
});
it('laat de unitFee van een verplaatsing BUITEN de stroom', () => {
event({ type: 'transfer', from: accA, to: accB, amount: 300, unitFee: 10, at: '2024-03-01T00:00:00' });
const uit = listFlowLegs(null, '2025-01-01T00:00:00').find(l => l.direction === 'out');
assert.equal(uit.units, 300, 'de fee zit niet in de stroom');
});
it('laat rente helemaal weg', () => {
event({ type: 'interest', to: accA, amount: 50, at: '2024-02-01T00:00:00' });
assert.deepEqual(benen(null, '2025-01-01T00:00:00'), []);
});
it('geeft bij een verplaatsing naar zichzelf twee benen die elkaar opheffen', () => {
event({ type: 'transfer', from: accA, to: accA, amount: 300, at: '2024-03-01T00:00:00' });
assert.deepEqual(benen(null, '2025-01-01T00:00:00'),
[`${accA}:transfer:in:300`, `${accA}:transfer:out:300`].sort());
});
it('houdt één been over bij een verplaatsing van of naar buiten', () => {
event({ type: 'transfer', from: accA, to: null, amount: 300, at: '2024-03-01T00:00:00' });
assert.deepEqual(benen(null, '2025-01-01T00:00:00'), [`${accA}:transfer:out:300`]);
});
it('scheidt de assets', () => {
event({ type: 'buy', to: accA, assetId: eth, amount: 7, fiatPrice: 3000, at: '2024-02-01T00:00:00' });
const l = listFlowLegs(null, '2025-01-01T00:00:00')[0];
assert.equal(l.assetId, eth);
});
it('sluit de ondergrens uit en de bovengrens in', () => {
event({ type: 'buy', to: accA, amount: 1, fiatPrice: 1, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: accA, amount: 2, fiatPrice: 1, at: '2024-12-31T23:59:59' });
assert.deepEqual(benen('2024-01-01T00:00:00', '2024-12-31T23:59:59'), [`${accA}:buy:in:2`]);
});
it('levert de velden die de waardering nodig heeft', () => {
event({ type: 'buy', to: accA, amount: 100, fiatPrice: 20000, fiatFee: 5, at: '2024-02-01T00:00:00' });
const l = listFlowLegs(null, '2025-01-01T00:00:00')[0];
assert.deepEqual(Object.keys(l).sort(),
['accountId', 'assetId', 'decimals', 'direction', 'fiatFee', 'fiatPrice', 'occurredAt', 'priceOrigin',
'type', 'units']);
assert.equal(l.decimals, 8, 'de decimals komen mee voor de omrekening');
assert.equal(l.occurredAt, '2024-02-01T00:00:00', 'de dag komt mee, want een verplaatsing wordt op de koers van die dag gewaardeerd');
});
});
describe('firstTaxEventDate', () => {
it('geeft null zonder events', () => {
assert.equal(firstTaxEventDate(), null);
});
it('geeft de vroegste gebeurtenis, niet de eerst ingevoerde', () => {
event({ at: '2024-06-01T00:00:00' });
event({ at: '2022-03-15T00:00:00' });
event({ at: '2023-01-01T00:00:00' });
assert.equal(firstTaxEventDate(), '2022-03-15T00:00:00');
});
});
describe('event-lijsten', () => {
it('sorteert listEvents nieuwste eerst', () => {
const oud = event({ at: '2024-01-01T00:00:00' });
const nieuw = event({ at: '2024-06-01T00:00:00' });
assert.deepEqual(listEvents().map(e => e.id), [nieuw, oud]);
});
it('splitst trades en transfers', () => {
const koop = event({ type: 'buy', to: accA });
const verkoop = event({ type: 'sell', from: accA });
const verplaatsing = event({ type: 'transfer', from: accA, to: accB });
event({ type: 'interest', to: accA });
assert.deepEqual(listTrades().map(e => e.id).sort((a, b) => a - b), [koop, verkoop]);
assert.deepEqual(listTransfers().map(e => e.id), [verplaatsing]);
});
it('geeft alleen de aan- en verkopen van het gevraagde asset', () => {
const koop = event({ type: 'buy', to: accA, at: '2024-03-01T00:00:00' });
const verkoop = event({ type: 'sell', from: accA, at: '2024-05-01T00:00:00' });
event({ type: 'transfer', from: accA, to: accB, at: '2024-04-01T00:00:00' });
event({ type: 'interest', to: accA, at: '2024-04-15T00:00:00' });
event({ type: 'buy', assetId: eth, to: accA, at: '2024-02-01T00:00:00' });
assert.deepEqual(listAssetTrades(btc).map(e => e.id), [koop, verkoop]);
});
it('sorteert oplopend, want hij staat naast een grafiek die van links naar rechts loopt', () => {
const laat = event({ type: 'buy', to: accA, at: '2024-06-01T00:00:00' });
const vroeg = event({ type: 'buy', to: accA, at: '2024-01-01T00:00:00' });
assert.deepEqual(listAssetTrades(btc).map(e => e.id), [vroeg, laat]);
});
it('geeft niets terug voor een asset zonder handel', () => {
event({ type: 'buy', to: accA });
assert.deepEqual(listAssetTrades(eth), []);
});
it('draagt de rekening als id én als naam, want de dialoog groepeert op het id', () => {
event({ type: 'buy', to: accA });
event({ type: 'sell', from: accB });
const [koop, verkoop] = listAssetTrades(btc);
assert.equal(koop.toAccountId, accA);
assert.equal(koop.fromAccountId, null, 'een aankoop komt ergens BINNEN, dus alleen de to-kant');
assert.equal(koop.to_name, 'A');
assert.equal(verkoop.fromAccountId, accB);
assert.equal(verkoop.toAccountId, null);
assert.equal(verkoop.from_name, 'B');
});
function label(naam) {
harness.exports.run(`INSERT INTO Label (name, searchName, createdAt) VALUES (?,?,?)`,
[naam, toSearchName(naam), '2024-01-01T00:00:00']);
return harness.exports.lastId();
}
function beweging({ labelId = null, txid = 'tx-1' } = {}) {
harness.exports.run(
`INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, labelId, blockHeight, blockTime, createdAt)
VALUES (?,'onchain',?,'in',?,?,?,?,?)`,
[accA, txid, 100_000, labelId, 840_000, '2024-03-01T00:00:00', '2024-03-02T00:00:00']);
return harness.exports.lastId();
}
const koppel = (txId, eventId) => harness.exports.run(
`INSERT INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt)
VALUES (?,?, 'manual', '2024-03-02T00:00:00')`, [txId, eventId]);
it('draagt het label van de gekoppelde beweging mee', () => {
const ev = event({ type: 'buy', to: accA, at: '2024-03-01T00:00:00' });
koppel(beweging({ labelId: label('Van Jan') }), ev);
assert.equal(listAssetTrades(btc)[0].label_names, 'Van Jan');
});
it('laat het label leeg als er geen beweging of geen label is', () => {
const zonderKoppeling = event({ type: 'buy', to: accA, at: '2024-03-01T00:00:00' });
const zonderLabel = event({ type: 'buy', to: accA, at: '2024-04-01T00:00:00' });
koppel(beweging({ txid: 'tx-2' }), zonderLabel);
const rijen = listAssetTrades(btc);
assert.equal(rijen.length, 2, 'beide aankopen staan er, met en zonder koppeling');
assert.equal(rijen[0].label_names, null, 'geen koppeling, geen label');
assert.equal(rijen[1].label_names, null, 'wel een beweging, maar die draagt geen label');
assert.equal(rijen[0].id, zonderKoppeling);
});
it('HOUDT ÉÉN RIJ bij twee gekoppelde bewegingen, met beide labels', () => {
const ev = event({ type: 'buy', to: accA, at: '2024-03-01T00:00:00' });
koppel(beweging({ txid: 'tx-1', labelId: label('Van Jan') }), ev);
koppel(beweging({ txid: 'tx-2', labelId: label('Van Piet') }), ev);
const rijen = listAssetTrades(btc);
assert.equal(rijen.length, 1, 'één aankoop blijft één rij, wat er ook aan hangt');
assert.deepEqual(rijen[0].label_names.split(',').sort(), ['Van Jan', 'Van Piet']);
});
it('verrijkt met asset- en accountnamen', () => {
event({ type: 'transfer', from: accA, to: accB });
const t = listTransfers()[0];
assert.equal(t.asset_symbol, 'BTC');
assert.equal(t.from_name, 'A');
assert.equal(t.to_name, 'B');
assert.equal(t.asset_decimals, 8);
});
it('geeft rente per account op basis van de bestemming', () => {
event({ type: 'interest', to: accA, amount: 10 });
event({ type: 'interest', to: accB, amount: 20 });
assert.deepEqual(listInterestForAccount(accA).map(e => e.amount), [10]);
});
it('vindt een event op import-referentie', () => {
const id = event({ ref: 'bitvavo-42' });
assert.equal(findEventByRef('bitvavo-42').id, id);
assert.equal(findEventByRef('bestaat-niet'), null);
assert.equal(findEventByRef(null), null, 'een lege referentie matcht nooit');
});
it('levert de pool zonder import-referentie voor de fuzzy match', () => {
const handmatig = event({ ref: null });
event({ ref: 'uit-import' });
assert.deepEqual(eventsWithoutImportRef().map(e => e.id), [handmatig]);
});
});
describe('listInterest: attributie aan een afspraak', () => {
function afspraak({ acc = accA, assetId = null, van, tot = null, rate = 0.05, type = 'fixed' }) {
harness.exports.run(
`INSERT INTO InterestSchedule
(accountId, rateKind, principalAssetId, payoutAssetId, principalAmount, annualRate,
startsOn, endsOn, creditCadence, compounding, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,0,?)`,
[acc, type, type === 'fixed' ? (assetId ?? btc) : null, assetId ?? btc,
type === 'fixed' ? 100000 : null, type === 'fixed' ? rate : null,
van, tot, type === 'fixed' ? 'term_end' : null, '2024-01-01T00:00:00']);
return harness.exports.lastId();
}
it('koppelt een rente-event aan de afspraak van dat account en asset', () => {
const s = afspraak({ van: '2024-01-01', tot: '2024-12-31' });
event({ type: 'interest', to: accA, at: '2024-06-01T00:00:00' });
assert.equal(listInterest()[0].scheduleId, s);
});
it('koppelt niet buiten het datumvenster', () => {
afspraak({ van: '2024-01-01', tot: '2024-05-31' });
event({ type: 'interest', to: accA, at: '2024-06-01T00:00:00' });
assert.equal(listInterest()[0].scheduleId, null);
});
it('koppelt niet aan een afspraak van een ander account of asset', () => {
afspraak({ acc: accB, van: '2024-01-01', tot: '2024-12-31' });
afspraak({ acc: accA, assetId: eth, van: '2024-01-01', tot: '2024-12-31' });
event({ type: 'interest', to: accA, at: '2024-06-01T00:00:00' });
assert.equal(listInterest()[0].scheduleId, null);
});
it('laat bij overlap de afspraak met de laatste startdatum winnen', () => {
afspraak({ van: '2024-01-01', tot: '2024-12-31' });
const nieuwer = afspraak({ van: '2024-03-01', tot: '2024-12-31' });
event({ type: 'interest', to: accA, at: '2024-06-01T00:00:00' });
assert.equal(listInterest()[0].scheduleId, nieuwer,
'de meest recente afspraak overschrijft de oudere');
});
it('koppelt aan een lopende afspraak zonder einddatum', () => {
const s = afspraak({ van: '2024-01-01', tot: null, type: 'variable' });
event({ type: 'interest', to: accA, at: '2029-06-01T00:00:00' });
assert.equal(listInterest()[0].scheduleId, s);
});
});
describe('scheduleLabel', () => {
const basis = { account_name: 'Spaarrekening', payout_symbol: 'BTC', rateKind: 'fixed',
annualRate: 0.05, startsOn: '2024-01-01', endsOn: '2024-12-31' };
it('zet accountnaam, asset, rente en periode achter elkaar', () => {
assert.equal(scheduleLabel(basis), 'Spaarrekening · BTC · 5% · 2024-01-01–2024-12-31');
});
it('schrijft een variabele afspraak als "variabel"', () => {
assert.equal(scheduleLabel({ ...basis, rateKind: 'variable' }),
'Spaarrekening · BTC · variabel · 2024-01-01–2024-12-31');
});
it('schrijft een lopende afspraak als "heden"', () => {
assert.ok(scheduleLabel({ ...basis, endsOn: null }).endsWith('2024-01-01–heden'));
});
it('haalt de nullen van een rond percentage weg', () => {
assert.equal(scheduleLabel({ ...basis, annualRate: 0.0725 }).split(' · ')[2], '7.25%');
});
});
describe('Valuation', () => {
it('sorteert listValuations op peildatum, nieuwste eerst', () => {
const oud = waardering({ acc: accA, onDate: '2024-01-01', balance: 1 });
const nieuw = waardering({ acc: accA, onDate: '2024-06-01', balance: 2 });
assert.deepEqual(listValuations().map(v => v.id), [nieuw, oud]);
});
it('verrijkt met account- en assetnaam', () => {
waardering({ acc: accA, onDate: '2024-01-01', balance: 1 });
const v = listValuations()[0];
assert.equal(v.account_name, 'A');
assert.equal(v.asset_symbol, 'BTC');
});
it('berekent het verschil als saldo min grootboek', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
assert.equal(valuationDiff(listValuations()[0]), 200);
});
it('geeft null als de waardering geen account heeft', () => {
waardering({ acc: null, onDate: '2024-02-01', balance: 1200 });
assert.equal(valuationDiff(listValuations()[0]), null);
});
it('kijkt alleen naar events t/m het rateAt-moment', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: accA, amount: 5000, at: '2024-09-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1000 });
assert.equal(valuationDiff(listValuations()[0]), 0,
'de latere aankoop valt buiten het venster van deze waardering');
});
it('listOpenValuationDiffs laat reconciled waarderingen weg', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1000 });
waardering({ acc: accA, onDate: '2024-03-01', balance: 1500 });
const open = listOpenValuationDiffs();
assert.equal(open.length, 1);
assert.equal(open[0].diff, 500);
});
it('listOpenValuationDiffs laat waarderingen zonder account weg', () => {
waardering({ acc: null, onDate: '2024-02-01', balance: 1200 });
assert.deepEqual(listOpenValuationDiffs(), [],
'diff is null en null is geen openstaand verschil');
});
it('ziet een negatief verschil ook als openstaand', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 800 });
assert.equal(listOpenValuationDiffs()[0].diff, -200);
});
it('is niet verzoenbaar zonder boekingen vóór het peilmoment', () => {
waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
assert.equal(valuationReconcilable(listValuations()[0]), false);
assert.equal(valuationDiff(listValuations()[0]), 1200, 'het verschil bestaat nog wél als getal');
assert.deepEqual(listOpenValuationDiffs(), [], 'maar het is geen werk');
});
it('is wél verzoenbaar met een boeking vóór het peilmoment', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
assert.equal(valuationReconcilable(listValuations()[0]), true);
assert.equal(listOpenValuationDiffs().length, 1);
});
it('kijkt voor de verzoenbaarheid naar hetzelfde venster als het verschil', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-09-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
assert.equal(valuationReconcilable(listValuations()[0]), false);
});
it('is niet verzoenbaar zonder account', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: null, onDate: '2024-02-01', balance: 1200 });
assert.equal(valuationReconcilable(listValuations()[0]), false);
});
it('telt een grootboek dat op nul uitkomt tóch als grootboek', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'sell', from: accA, amount: 1000, at: '2024-01-15T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
assert.equal(eventsNetByAccount(accA, btc, '2024-02-01T00:00:00'), 0, 'de som is nul');
assert.equal(valuationReconcilable(listValuations()[0]), true, 'en er is tóch een grootboek');
assert.equal(listOpenValuationDiffs().length, 1);
});
it('geeft per waardering het verschil én de verzoenbaarheid in één lijst', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
const met = waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
const zonder = waardering({ acc: accA, onDate: '2023-06-01', balance: 500 });
const perId = new Map(valuationDiffs().map(s => [s.id, s]));
assert.deepEqual(perId.get(met), { id: met, diff: 200, gap: -300, reconcilable: true, accepted: false, future: false });
assert.deepEqual(perId.get(zonder), { id: zonder, diff: 500, gap: 500, reconcilable: false, accepted: false, future: false });
});
it('haalt een AANVAARD verschil uit de werklijst en zegt het in de kolom', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
const id = waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
assert.equal(listOpenValuationDiffs().length, 1, 'vooraf staat hij op de werklijst');
harness.exports.run(`UPDATE Valuation SET gapAcceptedAt = '2024-03-01T00:00:00Z' WHERE id = ?`, [id]);
assert.deepEqual(listOpenValuationDiffs(), [], 'erna niet meer');
assert.equal(valuationDiffs().find(s => s.id === id).accepted, true, 'maar de kolom weet het wel');
assert.equal(valuationDiffs().find(s => s.id === id).gap, 200, 'en het bedrag blijft berekend');
});
});
describe('eventsCountByAccount', () => {
it('telt de boekingen van dit account voor dit asset', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'transfer', from: accA, to: accB, amount: 400, at: '2024-02-01T00:00:00' });
assert.equal(eventsCountByAccount(accA, btc, null), 2, 'in- én uitgaand tellen mee');
assert.equal(eventsCountByAccount(accB, btc, null), 1);
});
it('houdt zich aan het peilmoment', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: accA, amount: 1000, at: '2024-09-01T00:00:00' });
assert.equal(eventsCountByAccount(accA, btc, '2024-06-01T00:00:00'), 1);
});
it('telt niets voor een ander asset of een boeking zonder account', () => {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, decimals, displayUnit)
VALUES ('XMR','Monero','monero','crypto','XMR',8,'unit')`);
const ander = harness.exports.lastId();
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
assert.equal(eventsCountByAccount(accA, ander, null), 0, 'ander asset');
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, source, createdAt)
VALUES (?, 'buy', '2024-03-01T00:00:00', 500, 50000, 'manual', '2024-03-01T00:00:00')`, [btc]);
assert.equal(eventsCountByAccount(accA, btc, null), 1, 'een boeking zonder rekening hangt hier niet aan');
});
});
describe('waarderingen met een peildatum die nog moet komen', () => {
const morgen = new Date(Date.now() + 36 * 3600 * 1000).toISOString();
it('meldt géén openstaand verschil, ook al wijkt het van het grootboek af', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: morgen.slice(0, 10), balance: 5000, rateAt: morgen });
assert.deepEqual(listOpenValuationDiffs(), []);
});
it('markeert hem als toekomstig en rekent het verschil wél uit', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
const id = waardering({ acc: accA, onDate: morgen.slice(0, 10), balance: 5000, rateAt: morgen });
const s = valuationDiffs().find(x => x.id === id);
assert.deepEqual(s, { id, diff: 4000, gap: 4000, reconcilable: true, accepted: false, future: true });
});
it('gaat wél als werk gelden zodra het peilmoment voorbij is', () => {
const gisteren = new Date(Date.now() - 36 * 3600 * 1000).toISOString();
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: gisteren.slice(0, 10), balance: 5000, rateAt: gisteren });
assert.equal(listOpenValuationDiffs().length, 1);
});
});
describe('valuationGap', () => {
it('is gelijk aan valuationDiff zolang er geen eerdere balans is', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
const v = listValuations()[0];
assert.equal(valuationGap(v), valuationDiff(v));
assert.equal(valuationGap(v), 200);
});
it('meet vanaf de vorige balans en niet vanaf het begin van het grootboek', () => {
waardering({ acc: accA, onDate: '2024-01-01', balance: 1000 });
event({ type: 'interest', to: accA, amount: 50, at: '2024-02-01T00:00:00' });
const nieuw = waardering({ acc: accA, onDate: '2024-03-01', balance: 800 });
const v = listValuations().find(x => x.id === nieuw);
assert.equal(valuationGap(v), -250, 'wat er sinds de vorige meting ongeboekt uit is gegaan');
assert.equal(valuationDiff(v), 750, 'het oude getal, en dat is hier onbruikbaar');
});
it('laat een ongeboekt verschil niet bij élke volgende balans terugkomen', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1100 });
event({ type: 'buy', to: accA, amount: 500, at: '2024-03-01T00:00:00' });
const tweede = waardering({ acc: accA, onDate: '2024-04-01', balance: 1600 });
const v = listValuations().find(x => x.id === tweede);
assert.equal(valuationGap(v), 0, 'de tweede meting klopt met de boeking ertussen');
assert.equal(valuationDiff(v), 100, 'terwijl het oude getal die 100 blijft herhalen');
});
it('kijkt naar de vorige balans van dezelfde rekening en hetzelfde asset', () => {
waardering({ acc: accA, onDate: '2024-01-01', balance: 1000 });
waardering({ acc: accB, onDate: '2024-02-01', balance: 7777 });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1000, assetId: eth });
const v = listValuations().find(x => x.accountId === accA && x.assetId === btc);
assert.equal(anchorBefore(accA, btc, v.rateAt), null, 'er is er geen eerdere voor dit paar');
assert.equal(valuationGap(v), 1000);
});
it('geeft null zonder account', () => {
waardering({ acc: null, onDate: '2024-02-01', balance: 1200 });
assert.equal(valuationGap(listValuations()[0]), null);
});
});
describe('Event.priceOrigin', () => {
const payload = (extra = {}) => ({
assetId: btc, type: 'buy', occurredAt: '2026-05-01T10:00:00.000Z', amount: 100000,
fiatPrice: 50000, source: 'manual', toAccountId: accA, ...extra,
});
const rij = id => listEvents().find(e => e.id === id);
it('bewaart de herkomst en geeft hem terug op de leesweg', async () => {
const id = await createEvent(payload({ priceOrigin: 'market_day' }));
assert.equal(rij(id).priceOrigin, 'market_day');
assert.equal(listTrades().find(e => e.id === id).priceOrigin, 'market_day',
'ook via de join, want daar leest de pagina hem');
});
it('laat de herkomst NULL als niemand hem meegeeft, en dat betekent onbekend', async () => {
const id = await createEvent(payload());
assert.equal(rij(id).priceOrigin, null);
});
it('maakt van een leeg veld NULL en niet van een lege string', async () => {
const id = await createEvent(payload({ priceOrigin: '' }));
assert.equal(rij(id).priceOrigin, null);
});
it('weigert een herkomst die de enum niet kent', async () => {
await assert.rejects(() => createEvent(payload({ priceOrigin: 'guessed' })), /Invalid priceOrigin/);
assert.deepEqual(listEvents(), [], 'en er staat niets, dus de guard zit voor de INSERT');
});
it('LAAT DE HERKOMST STAAN bij een bewerking die er niet over gaat', async () => {
const id = await createEvent(payload({ priceOrigin: 'market_day' }));
await updateEvent(id, payload({ fiatFee: 12 }));
assert.equal(rij(id).priceOrigin, 'market_day', 'een bewerking elders mag dit niet wissen');
assert.equal(rij(id).fiatFee, 12, 'en de bewerking zelf is wel doorgevoerd');
});
it('wist hem WEL als de aanroeper dat expliciet zegt', async () => {
const id = await createEvent(payload({ priceOrigin: 'paid' }));
await updateEvent(id, payload({ priceOrigin: null }));
assert.equal(rij(id).priceOrigin, null);
});
});