Files
2026-10-29 11:57:29 +01:00

723 lines
33 KiB
JavaScript

// Generated, do not edit. sha256:e9ccb4b4784d24c1c464248bf37dc50c0e76a1e76874e1d0edd11a9b9edf6572
import { describe, it, beforeEach, mock } from 'node:test';
import { toSearchName } from '../../shared/search-name.js';
import { toUtcIso } from '../../shared/time.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 { createAsset } = await import('../../server/data/assets.js');
const { dueCreditsForAccount, realizedInterestSub, scheduleForecast, bookDueCredit, dismissDueCredit } = await import('../../server/data/interest-credits.js');
const { createSchedule, createPeriod, listPeriods, updatePeriod, deletePeriod,
listSchedulesForAccount, listInterest, realizedForSchedule } = await import('../../server/data/interest-schedules.js');
const SATS = 8;
const EEN_BTC = 100_000_000;
let ctx;
beforeEach(() => {
harness.recreate();
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,?,?,?,?,?)`, ['BTC', 'Bitcoin', toSearchName('Bitcoin'), 'crypto', 'BTC', 'sat', SATS, 'subunit']);
const assetId = harness.exports.lastId();
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,?,?,?,1,NULL)`, ['Rentekening', toSearchName('Rentekening'), 'bank', 0, 0]);
const accountId = harness.exports.lastId();
ctx = { assetId, accountId };
});
const afspraak = (extra) => ({
accountId: ctx.accountId,
rateKind: 'fixed',
principalAssetId: ctx.assetId,
payoutAssetId: ctx.assetId,
principalAmount: EEN_BTC,
principal_decimals: SATS,
payout_decimals: SATS,
annualRate: 0.05,
creditWeekday: null,
creditDayOfMonth: null,
...extra,
});
function renteEvent(dagIso, bedragSub) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, toAccountId, source, createdAt)
VALUES (?,?,?,?,?,?,?)`,
[ctx.assetId, 'interest', `${dagIso}T00:00:00`, bedragSub, ctx.accountId, 'manual', '2026-01-01T00:00:00']);
}
describe('crediteringsmomenten per cadans', () => {
const aantalPerioden = (extra) => {
const s = afspraak(extra);
return scheduleForecast(s, s.startsOn).remainingCount;
};
it('term_end levert één periode over de hele looptijd', () => {
assert.equal(aantalPerioden({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2026-12-31',
}), 1);
});
it('annual rekent per jaarultimo (1 januari van het volgende jaar)', () => {
assert.equal(aantalPerioden({
creditCadence: 'annual', startsOn: '2020-03-01', endsOn: '2023-01-01',
}), 3);
});
it('annual sluit af op de einddatum en niet op een ultimo erna', () => {
assert.equal(aantalPerioden({
creditCadence: 'annual', startsOn: '2020-03-01', endsOn: '2022-12-31',
}), 3);
});
it('schrijft het laatste stuk van een deposito bij op de einddatum', async () => {
await createSchedule(afspraak({
creditCadence: 'annual', annualRate: 0.028, startsOn: '2015-06-15', endsOn: '2019-06-15',
}));
const dues = dueCreditsForAccount(ctx.accountId);
const laatste = dues.at(-1);
assert.deepEqual([laatste.periodStart, laatste.periodEnd], ['2019-01-01', '2019-06-15']);
assert.equal(laatste.projected_sub, Math.round(EEN_BTC * 0.028 * 165 / 365));
const s = listSchedulesForAccount(ctx.accountId)[0];
const totaal = scheduleForecast(s, '2015-06-15').termTotalSub;
assert.ok(Math.abs(totaal - EEN_BTC * 0.028 * 1461 / 365) <= dues.length, `termijntotaal ${totaal}`);
});
it('quarterly rekent per KALENDERkwartaal en niet per drie maanden vanaf de start', () => {
assert.equal(aantalPerioden({
creditCadence: 'quarterly', startsOn: '2024-02-15', endsOn: '2025-01-01',
}), 4);
});
it('quarterly sluit af op de einddatum en niet op een kwartaal erna', () => {
assert.equal(aantalPerioden({
creditCadence: 'quarterly', startsOn: '2024-02-15', endsOn: '2024-09-30',
}), 3);
});
it('quarterly begint óp een kwartaalgrens zonder een periode van nul dagen', () => {
assert.equal(aantalPerioden({
creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: '2025-01-01',
}), 4);
});
it('monthly slaat de startdag over en neemt de einddatum wél mee', () => {
assert.equal(aantalPerioden({
creditCadence: 'monthly', creditDayOfMonth: 15,
startsOn: '2024-01-15', endsOn: '2024-04-15',
}), 3);
});
it('weekly begint bij het eerste voorkomen strikt ná de start', () => {
assert.equal(aantalPerioden({
creditCadence: 'weekly', creditWeekday: 1,
startsOn: '2024-01-01', endsOn: '2024-01-29',
}), 4);
});
it('loopt zonder einddatum door tot vandaag', () => {
const s = afspraak({ creditCadence: 'monthly', creditDayOfMonth: 1, startsOn: '2024-01-01', endsOn: null });
const tot = scheduleForecast(s, '2024-06-15');
assert.equal(tot.termTotalSub, null, 'geen termijntotaal zonder einddatum');
assert.equal(tot.remainingCount, null);
assert.equal(dueCreditsForAccount(ctx.accountId).length, 0, 'deze afspraak staat nog niet in de database');
});
it('geeft zonder einddatum wél te-boeken bijschrijvingen', async () => {
await createSchedule(afspraak({
creditCadence: 'monthly', creditDayOfMonth: 1, startsOn: '2024-01-01', endsOn: null,
}));
const due = dueCreditsForAccount(ctx.accountId);
assert.ok(due.length >= 12, `verwacht minstens een jaar aan maandmomenten, kreeg ${due.length}`);
assert.ok(due.every(d => d.periodEnd <= new Date().toISOString().slice(0, 10)),
'geen crediteringsmoment dat nog moet komen');
assert.ok(due.every(d => d.periodEnd.endsWith('-01')), 'alleen maandmomenten, geen stuk tot vandaag');
});
it('remainingCount telt alleen perioden ná de peildatum', () => {
const s = afspraak({
creditCadence: 'monthly', creditDayOfMonth: 15,
startsOn: '2024-01-15', endsOn: '2024-04-15',
});
assert.equal(scheduleForecast(s, '2024-01-15').remainingCount, 3);
assert.equal(scheduleForecast(s, '2024-02-15').remainingCount, 2);
assert.equal(scheduleForecast(s, '2024-04-15').remainingCount, 0);
});
});
describe('crediteringsdatums (via de due-lijst)', () => {
const eersteDues = async (extra, n) => {
await createSchedule(afspraak({ endsOn: '2099-12-31', ...extra }));
return dueCreditsForAccount(ctx.accountId).slice(0, n);
};
it('klemt dag 31 op de laatste dag van de maand, inclusief schrikkeljaar', async () => {
const dues = await eersteDues({
creditCadence: 'monthly', creditDayOfMonth: 31, startsOn: '2024-01-31',
}, 3);
assert.deepEqual(dues.map(d => d.periodEnd), ['2024-02-29', '2024-03-31', '2024-04-30']);
});
it('klemt februari op 28 in een niet-schrikkeljaar', async () => {
const dues = await eersteDues({
creditCadence: 'monthly', creditDayOfMonth: 31, startsOn: '2023-01-31',
}, 2);
assert.deepEqual(dues.map(d => d.periodEnd), ['2023-02-28', '2023-03-31']);
});
it('perioden sluiten op elkaar aan en beginnen bij de startdatum', async () => {
const dues = await eersteDues({
creditCadence: 'monthly', creditDayOfMonth: 10, startsOn: '2024-05-10',
}, 4);
assert.equal(dues[0].periodStart, '2024-05-10');
for (let i = 1; i < dues.length; i++) {
assert.equal(dues[i].periodStart, dues[i - 1].periodEnd,
'een periode moet beginnen waar de vorige eindigde, anders valt er rente tussen');
}
});
it('wekelijkse momenten liggen zeven dagen uit elkaar op de gekozen weekdag', async () => {
const dues = await eersteDues({
creditCadence: 'weekly', creditWeekday: 3, startsOn: '2024-01-01',
}, 3);
assert.deepEqual(dues.map(d => d.periodEnd), ['2024-01-03', '2024-01-10', '2024-01-17']);
});
it("geeft geen due's voor een variabele afspraak", async () => {
await createSchedule({
accountId: ctx.accountId, rateKind: 'variable', payoutAssetId: ctx.assetId,
principalAssetId: null, principalAmount: null, annualRate: null,
startsOn: '2024-01-01', endsOn: '2099-12-31',
creditCadence: null, creditWeekday: null, creditDayOfMonth: null,
});
assert.deepEqual(dueCreditsForAccount(ctx.accountId), []);
});
it("blijft de bijschrijvingen van een afgelopen afspraak aanbieden", async () => {
await createSchedule(afspraak({
creditCadence: 'monthly', creditDayOfMonth: 1,
startsOn: '2020-01-01', endsOn: '2020-12-31',
}));
const dues = dueCreditsForAccount(ctx.accountId);
assert.equal(dues.length, 12, 'elf maandmomenten en de afsluiting, allemaal nog te boeken');
assert.equal(dues.at(-1).periodEnd, '2020-12-31', 'en niets ná de einddatum');
});
it('biedt de rente van een verstreken deposito precies één keer aan', async () => {
const eind = new Date(Date.now() - 5 * 24 * 3600 * 1000).toISOString().slice(0, 10);
const start = new Date(Date.now() - 370 * 24 * 3600 * 1000).toISOString().slice(0, 10);
await createSchedule(afspraak({ creditCadence: 'term_end', startsOn: start, endsOn: eind }));
const dues = dueCreditsForAccount(ctx.accountId);
assert.equal(dues.length, 1);
assert.equal(dues[0].periodEnd, eind);
assert.ok(dues[0].projected_sub > 0, 'met een bedrag erbij, anders is er niets te boeken');
});
});
describe('due-detectie en dubbeltelling', () => {
async function maandelijkseAfspraak() {
const id = await createSchedule(afspraak({
creditCadence: 'monthly', creditDayOfMonth: 15,
startsOn: '2024-01-15', endsOn: '2099-12-31',
}));
return id;
}
it('een geboekte credit haalt precies die periode van de lijst', async () => {
const scheduleId = await maandelijkseAfspraak();
const voor = dueCreditsForAccount(ctx.accountId);
const eerste = voor[0];
harness.exports.run(
`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, status, createdAt)
VALUES (?,?,?, 'booked', ?)`,
[scheduleId, eerste.periodStart, eerste.periodEnd, '2026-01-01T00:00:00']);
const na = dueCreditsForAccount(ctx.accountId);
assert.equal(na.length, voor.length - 1);
assert.ok(!na.some(d => d.periodEnd === eerste.periodEnd));
});
it('een genegeerde credit haalt de periode ook van de lijst', async () => {
const scheduleId = await maandelijkseAfspraak();
const eerste = dueCreditsForAccount(ctx.accountId)[0];
harness.exports.run(
`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, status, createdAt)
VALUES (?,?,?, 'dismissed', ?)`,
[scheduleId, eerste.periodStart, eerste.periodEnd, '2026-01-01T00:00:00']);
assert.ok(!dueCreditsForAccount(ctx.accountId).some(d => d.periodEnd === eerste.periodEnd));
});
it('rente die al via import binnenkwam onderdrukt de due (geen dubbeltelling)', async () => {
await maandelijkseAfspraak();
const eerste = dueCreditsForAccount(ctx.accountId)[0];
renteEvent(eerste.periodEnd, 12_345);
assert.ok(!dueCreditsForAccount(ctx.accountId).some(d => d.periodEnd === eerste.periodEnd),
'een uitkering óp de crediteringsdag hoort bij de periode die dan eindigt');
});
it('een uitkering op de startdag van een periode onderdrukt die periode NIET', async () => {
await maandelijkseAfspraak();
const eerste = dueCreditsForAccount(ctx.accountId)[0];
renteEvent(eerste.periodStart, 12_345);
assert.ok(dueCreditsForAccount(ctx.accountId).some(d => d.periodEnd === eerste.periodEnd));
});
});
describe('bedragen: de prognose is de som van de bijschrijvingen', () => {
it('rekent één vol jaar op 5% van 1 BTC als 0,05 BTC', () => {
const s = afspraak({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31',
});
assert.equal(scheduleForecast(s, '2024-01-01').termTotalSub, 5_000_000);
});
it('rekent enkelvoudig over meerdere jaren, net als de bijschrijving', () => {
const s = afspraak({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2025-12-31',
});
assert.equal(scheduleForecast(s, '2024-01-01').termTotalSub, 10_000_000);
});
it('telt op tot precies wat de te-boeken-lijst aanbiedt', async () => {
await createSchedule(afspraak({
creditCadence: 'annual', annualRate: 0.028, startsOn: '2020-03-01', endsOn: '2023-01-01',
}));
const dues = dueCreditsForAccount(ctx.accountId);
assert.equal(dues.length, 3);
const s = listSchedulesForAccount(ctx.accountId)[0];
assert.equal(scheduleForecast(s, '2020-03-01').termTotalSub, dues.reduce((n, d) => n + d.projected_sub, 0));
});
it('trekt al ontvangen rente af van het resterende bedrag', () => {
const s = afspraak({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31',
});
renteEvent('2024-06-01', 2_000_000);
const f = scheduleForecast(s, '2024-12-31');
assert.equal(f.realizedSub, 2_000_000);
assert.equal(f.remainingSub, 3_000_000);
});
it('laat het resterende bedrag niet onder nul zakken', () => {
const s = afspraak({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31',
});
renteEvent('2024-06-01', 9_999_999);
assert.equal(scheduleForecast(s, '2024-12-31').remainingSub, 0);
});
it('merkt op wanneer hoofdsom en uitkering hetzelfde asset zijn', () => {
const s = afspraak({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31',
});
assert.equal(scheduleForecast(s, '2024-01-01').sameAsset, true);
assert.equal(scheduleForecast({ ...s, principalAssetId: 999 }, '2024-01-01').sameAsset, false);
});
});
describe('komende bijschrijvingen', () => {
it('noemt elke resterende periode met zijn bedrag, de lopende inbegrepen', () => {
const s = afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: '2027-01-01' });
const { upcoming } = scheduleForecast(s, '2025-06-15');
assert.deepEqual(upcoming, [
{ periodStart: '2025-01-01', periodEnd: '2026-01-01', projectedSub: 5_000_000 },
{ periodStart: '2026-01-01', periodEnd: '2027-01-01', projectedSub: 5_000_000 },
]);
});
it('laat een periode die op de peildatum eindigt weg, want die staat bij te boeken', () => {
const s = afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: '2027-01-01' });
assert.deepEqual(scheduleForecast(s, '2026-01-01').upcoming.map(p => p.periodEnd), ['2027-01-01']);
});
it('is leeg na de einddatum', () => {
const s = afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: '2027-01-01' });
assert.deepEqual(scheduleForecast(s, '2027-01-01').upcoming, []);
});
it('geeft zonder einddatum alleen de volgende, en die ligt ná vandaag', () => {
const s = afspraak({ creditCadence: 'monthly', creditDayOfMonth: 1, startsOn: '2024-01-01', endsOn: null });
const f = scheduleForecast(s, '2024-06-15');
assert.deepEqual(f.upcoming, [
{ periodStart: '2024-06-01', periodEnd: '2024-07-01', projectedSub: Math.round(EEN_BTC * 0.05 * 30 / 365) },
]);
assert.equal(f.termTotalSub, null);
});
it('vindt zonder einddatum ook de volgende jaarbijschrijving', () => {
const s = afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: null });
assert.deepEqual(scheduleForecast(s, '2024-03-02').upcoming.map(p => p.periodEnd), ['2025-01-01']);
assert.deepEqual(scheduleForecast(s, '2025-01-01').upcoming.map(p => p.periodEnd), ['2026-01-01']);
});
it('telt een rentewijziging mee in het bedrag van de komende periode', async () => {
const id = await createSchedule(afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: '2027-01-01' }));
await createPeriod(id, { startsOn: '2025-07-01', annualRate: 0.01 });
const s = listSchedulesForAccount(ctx.accountId)[0];
assert.equal(scheduleForecast(s, '2025-06-15').upcoming[0].projectedSub,
Math.round(EEN_BTC * (0.05 * 181 / 365 + 0.01 * 184 / 365)));
});
});
describe('rente op rente', () => {
const drieJaar = (extra) => afspraak({
creditCadence: 'annual', startsOn: '2021-01-01', endsOn: '2024-01-01', ...extra,
});
it('rekent elke periode ook over de bijschrijvingen ervóór', () => {
const f = scheduleForecast(drieJaar({ compounding: 1 }), '2021-01-01');
assert.deepEqual(f.upcoming.map(p => p.projectedSub), [5_000_000, 5_250_000, 5_512_500]);
assert.equal(f.termTotalSub, 15_762_500);
});
it('rekent zonder de keuze enkelvoudig, zoals voorheen', () => {
const f = scheduleForecast(drieJaar({ compounding: 0 }), '2021-01-01');
assert.deepEqual(f.upcoming.map(p => p.projectedSub), [5_000_000, 5_000_000, 5_000_000]);
});
it('biedt op te boeken dezelfde bedragen aan als de prognose', async () => {
await createSchedule(drieJaar({ compounding: 1 }));
assert.deepEqual(dueCreditsForAccount(ctx.accountId).map(d => d.projected_sub), [5_000_000, 5_250_000, 5_512_500]);
});
it('telt een genegeerde bijschrijving gewoon mee, want de bank schrijft hem toch bij', async () => {
const id = await createSchedule(drieJaar({ compounding: 1 }));
harness.exports.run(
`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, status, createdAt)
VALUES (?, '2021-01-01', '2022-01-01', 'dismissed', '2026-01-01T00:00:00')`, [id]);
assert.deepEqual(dueCreditsForAccount(ctx.accountId).map(d => d.projected_sub), [5_250_000, 5_512_500]);
});
it('weigert rente op rente op een hoofdsom die het saldo volgt', async () => {
await assert.rejects(() => createSchedule(drieJaar({ compounding: 1, principalAmount: null })),
(e) => e.key === 'interest.compoundingNeedsPrincipal');
});
it('weigert rente op rente als de rente in een ander asset uitkeert', async () => {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,?,?,?,?,?)`,
['EUR', 'Euro', toSearchName('Euro'), 'fiat', 'EUR', 'cent', 2, 'unit']);
const eur = harness.exports.lastId();
await assert.rejects(() => createSchedule(drieJaar({ compounding: 1, payoutAssetId: eur })),
(e) => e.key === 'interest.compoundingNeedsSameAsset');
});
it('bewaart de keuze en geeft hem terug', async () => {
await createSchedule(drieJaar({ compounding: 1 }));
assert.equal(listSchedulesForAccount(ctx.accountId)[0].compounding, 1);
});
});
describe('realizedInterestSub', () => {
it('telt vanaf `from` tot en met de dag vóór `to`', () => {
renteEvent('2024-03-01', 100);
renteEvent('2024-03-31', 200);
const som = realizedInterestSub(ctx.accountId, ctx.assetId, '2024-03-01', '2024-03-31');
assert.equal(som, 100, 'de bovengrens hoort er niet bij te vallen');
});
it('is 0 zonder rente-events', () => {
assert.equal(realizedInterestSub(ctx.accountId, ctx.assetId, '2024-01-01', '2025-01-01'), 0);
});
it('telt alleen rente van het gevraagde account en asset', () => {
renteEvent('2024-03-01', 100);
assert.equal(realizedInterestSub(ctx.accountId + 99, ctx.assetId, '2024-01-01', '2025-01-01'), 0);
assert.equal(realizedInterestSub(ctx.accountId, ctx.assetId + 99, '2024-01-01', '2025-01-01'), 0);
});
});
describe('ontvangen per afspraak', () => {
const tweeAfspraken = async () => {
await createSchedule(afspraak({ creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31', note: 'deposito' }));
await createSchedule(afspraak({ creditCadence: 'annual', principalAmount: null, startsOn: '2024-01-01', endsOn: null, note: 'spaar' }));
const lijst = listSchedulesForAccount(ctx.accountId);
return { deposito: lijst.find(s => s.note === 'deposito'), spaar: lijst.find(s => s.note === 'spaar') };
};
it('telt één rente-boeking onder twee overlappende afspraken maar één keer', async () => {
const { deposito, spaar } = await tweeAfspraken();
renteEvent('2024-06-01', 2_000_000);
const som = scheduleForecast(deposito, '2024-12-31').realizedSub + scheduleForecast(spaar, '2024-12-31').realizedSub;
assert.equal(som, 2_000_000, 'opgeteld over de afspraken is het ontvangen bedrag er één keer');
assert.equal(realizedForSchedule(spaar, '2024-12-31'), 2_000_000);
});
it('rekent een geboekte bijschrijving toe aan de afspraak waaronder hij geboekt is', async () => {
const { deposito, spaar } = await tweeAfspraken();
renteEvent('2024-06-01', 2_000_000);
const eventId = harness.exports.lastId();
harness.exports.run(`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, eventId, status, createdAt)
VALUES (?,?,?,?, 'booked', ?)`, [deposito.id, '2024-01-01', '2024-06-01', eventId, '2024-06-01T00:00:00']);
assert.equal(scheduleForecast(deposito, '2024-12-31').realizedSub, 2_000_000);
assert.equal(scheduleForecast(spaar, '2024-12-31').realizedSub, 0);
assert.equal(listInterest().find(r => r.id === eventId).scheduleId, deposito.id);
});
const tweeInHetVerleden = async () => {
await createSchedule(afspraak({ creditCadence: 'monthly', creditDayOfMonth: 1, startsOn: '2020-01-01', endsOn: '2020-12-31', note: 'deposito' }));
await createSchedule(afspraak({ creditCadence: 'annual', startsOn: '2020-03-01', endsOn: '2020-12-31', note: 'spaar' }));
const lijst = listSchedulesForAccount(ctx.accountId);
return { deposito: lijst.find(s => s.note === 'deposito'), spaar: lijst.find(s => s.note === 'spaar') };
};
const dueVan = (s) => dueCreditsForAccount(ctx.accountId).filter(d => d.scheduleId === s.id);
it('laat een geboekte bijschrijving van de ene afspraak de andere niet van Te boeken halen', async () => {
const { deposito, spaar } = await tweeInHetVerleden();
const julDue = dueVan(deposito).find(d => d.periodEnd === '2020-07-01');
for (const d of dueVan(deposito).filter(d => d.periodEnd < '2020-07-01')) await dismissDueCredit(d);
await bookDueCredit(julDue, julDue.projected_sub);
assert.deepEqual(dueVan(spaar).map(d => d.periodEnd), ['2020-12-31'], 'de spaarrekening houdt haar bijschrijving');
assert.ok(!dueVan(deposito).some(d => d.periodEnd === '2020-07-01'), 'de geboekte periode is weg');
});
it('laat een losse rente-boeking alleen de afspraak raken waaronder hij valt', async () => {
const { deposito, spaar } = await tweeInHetVerleden();
renteEvent('2020-06-15', 1_000);
assert.deepEqual(dueVan(spaar), [], 'de import vult de spaarperiode');
assert.ok(dueVan(deposito).some(d => d.periodEnd === '2020-07-01'), 'en niet de periode van het deposito');
});
});
describe('createSchedule: validatie', () => {
it('weigert een einddatum vóór de startdatum', async () => {
await assert.rejects(() => createSchedule(afspraak({
creditCadence: 'term_end', startsOn: '2024-06-01', endsOn: '2024-01-01',
})), (e) => e.key === 'accounts.schEndBeforeStart');
});
it('eist een weekdag bij een wekelijkse cadans', async () => {
await assert.rejects(() => createSchedule(afspraak({
creditCadence: 'weekly', startsOn: '2024-01-01', endsOn: '2024-12-31',
})), (e) => e.key === 'interest.weekdayRequired');
});
it('staat geen dag-van-de-maand toe bij een wekelijkse cadans', async () => {
await assert.rejects(() => createSchedule(afspraak({
creditCadence: 'weekly', creditWeekday: 1, creditDayOfMonth: 15,
startsOn: '2024-01-01', endsOn: '2024-12-31',
})), /has no day of the month/);
});
it('laat een vaste afspraak zónder einddatum toe (doorlopend)', async () => {
const id = await createSchedule(afspraak({
creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: null,
}));
assert.ok(id > 0);
});
it('laat een vaste afspraak zónder hoofdsom toe (volgt het saldo)', async () => {
const id = await createSchedule(afspraak({
creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: null, principalAmount: null,
}));
assert.ok(id > 0);
});
it('weigert een hoofdsom van nul, want dat is een vergissing en geen keuze', async () => {
await assert.rejects(() => createSchedule(afspraak({
creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: null, principalAmount: 0,
})), (e) => e.key === 'interest.principalPositive');
});
it('weigert hoofdsom of cadans op een variabele afspraak', async () => {
await assert.rejects(() => createSchedule({
...afspraak({ creditCadence: null, startsOn: '2024-01-01', endsOn: '2024-12-31' }),
rateKind: 'variable',
}), /manual or import/);
});
});
describe('due-bijschrijvingen van nul', () => {
it('biedt een periode zonder bedrag niet aan', async () => {
await createSchedule(afspraak({
creditCadence: 'annual', annualRate: 0,
startsOn: '2020-01-01', endsOn: '2022-01-01',
}));
assert.deepEqual(dueCreditsForAccount(ctx.accountId), []);
});
it('biedt de perioden mét een bedrag nog steeds aan', async () => {
await createSchedule(afspraak({
creditCadence: 'annual', annualRate: 0.05,
startsOn: '2020-01-01', endsOn: '2022-01-01',
}));
const dues = dueCreditsForAccount(ctx.accountId);
assert.equal(dues.length, 2);
assert.ok(dues.every(d => d.projected_sub > 0));
});
});
describe('renteperioden: een rente die per datum wijzigt', () => {
async function jaarAfspraak(extra) {
return createSchedule(afspraak({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31', ...extra,
}));
}
const dueBedrag = () => dueCreditsForAccount(ctx.accountId)[0].projected_sub;
it('rekent zonder perioden precies zoals voorheen', async () => {
await jaarAfspraak();
assert.equal(dueBedrag(), 5_000_000);
});
it('knipt de bijschrijving op de ingangsdatum van een wijziging', async () => {
const id = await jaarAfspraak();
await createPeriod(id, { startsOn: '2024-07-01', annualRate: 0.01 });
assert.equal(dueBedrag(), 2_994_521);
});
it('telt twee wijzigingen in één periode allebei mee', async () => {
const id = await jaarAfspraak();
await createPeriod(id, { startsOn: '2024-04-01', annualRate: 0.04 });
await createPeriod(id, { startsOn: '2024-07-01', annualRate: 0.03 });
const verwacht = Math.round(100_000_000 * (0.05 * 91 / 365 + 0.04 * 91 / 365 + 0.03 * 183 / 365));
assert.equal(dueBedrag(), verwacht);
assert.notEqual(dueBedrag(), Math.round(100_000_000 * (0.05 * 182 / 365 + 0.03 * 183 / 365)),
'de middelste periode mag niet overgeslagen worden');
});
it('gebruikt de laatste wijziging vóór het venster als beginrente', async () => {
const id = await createSchedule(afspraak({
creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: '2024-12-31',
}));
await createPeriod(id, { startsOn: '2024-02-01', annualRate: 0.01 });
const derde = dueCreditsForAccount(ctx.accountId).find(d => d.periodEnd === '2024-10-01');
assert.equal(derde.projected_sub, Math.round(100_000_000 * 0.01 * 92 / 365));
});
it('telt in de prognose elk tarief over zijn eigen dagen', async () => {
const id = await createSchedule(afspraak({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2025-12-31',
}));
await createPeriod(id, { startsOn: '2024-12-31', annualRate: 0.03 });
const s = listSchedulesForAccount(ctx.accountId)[0];
assert.equal(scheduleForecast(s, '2024-01-01').termTotalSub, 8_000_000);
});
it('weigert een ingangsdatum op of vóór de start van de afspraak', async () => {
const id = await jaarAfspraak();
await assert.rejects(() => createPeriod(id, { startsOn: '2024-01-01', annualRate: 0.01 }), (e) => e.key === 'interest.periodStartBeforeSchedule');
await assert.rejects(() => createPeriod(id, { startsOn: '2023-12-31', annualRate: 0.01 }), (e) => e.key === 'interest.periodStartBeforeSchedule');
});
it('weigert een ingangsdatum ná het einde van de afspraak', async () => {
const id = await jaarAfspraak();
await assert.rejects(() => createPeriod(id, { startsOn: '2025-06-01', annualRate: 0.01 }), (e) => e.key === 'interest.periodStartAfterEnd');
});
it('weigert een periode op een variabele afspraak', async () => {
const id = await createSchedule({
accountId: ctx.accountId, rateKind: 'variable', payoutAssetId: ctx.assetId,
principalAssetId: null, principalAmount: null, annualRate: null,
startsOn: '2024-01-01', endsOn: null, creditCadence: null, creditWeekday: null, creditDayOfMonth: null,
});
await assert.rejects(() => createPeriod(id, { startsOn: '2024-06-01', annualRate: 0.01 }), /manual or import/);
});
it('laat een periode bijwerken en verwijderen, en dat verandert het bedrag terug', async () => {
const id = await jaarAfspraak();
const pid = await createPeriod(id, { startsOn: '2024-07-01', annualRate: 0.01 });
assert.equal(dueBedrag(), 2_994_521);
await updatePeriod(pid, { startsOn: '2024-07-01', annualRate: 0.05 });
assert.equal(dueBedrag(), 5_000_000, 'zelfde rente als de afspraak: hetzelfde bedrag als zonder periode');
await deletePeriod(pid);
assert.deepEqual(listPeriods(id), []);
assert.equal(dueBedrag(), 5_000_000);
});
});
describe('hoofdsom leeg: de rente volgt het saldo van de rekening', () => {
const dueVoor = (eind) => dueCreditsForAccount(ctx.accountId).find(d => d.periodEnd === eind);
function balansOp(dagIso, balanceSub) {
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,1.0,?, 'manual')`,
[ctx.assetId, dagIso, ctx.accountId, balanceSub, toUtcIso(`${dagIso}T00:00:00`)]);
}
function storting(dagIso, bedragSub) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'buy', ?, ?, 1, ?, 'manual', ?)`,
[ctx.assetId, `${dagIso}T00:00:00`, bedragSub, ctx.accountId, `${dagIso}T00:00:00`]);
}
async function saldoAfspraak() {
return createSchedule(afspraak({
creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: null, principalAmount: null,
}));
}
it('rekent over de stand en niet over een vast bedrag', async () => {
storting('2024-01-01', EEN_BTC);
await saldoAfspraak();
assert.equal(dueVoor('2024-04-01').projected_sub, Math.round(EEN_BTC * 0.05 * 91 / 365));
});
it('laat een storting halverwege vanaf díe dag meetellen', async () => {
storting('2024-01-01', EEN_BTC);
storting('2024-03-01', EEN_BTC);
await saldoAfspraak();
const verwacht = Math.round(EEN_BTC * 0.05 * 60 / 365 + 2 * EEN_BTC * 0.05 * 31 / 365);
assert.equal(dueVoor('2024-04-01').projected_sub, verwacht);
assert.notEqual(dueVoor('2024-04-01').projected_sub, Math.round(EEN_BTC * 0.05 * 91 / 365),
'de storting van maart hoort mee te tellen');
});
it('knipt óók op een balans halverwege de periode', async () => {
balansOp('2024-01-01', EEN_BTC);
balansOp('2024-03-01', EEN_BTC / 4);
await saldoAfspraak();
const verwacht = Math.round(EEN_BTC * 0.05 * 60 / 365 + (EEN_BTC / 4) * 0.05 * 31 / 365);
assert.equal(dueVoor('2024-04-01').projected_sub, verwacht);
assert.notEqual(dueVoor('2024-04-01').projected_sub, Math.round(EEN_BTC * 0.05 * 91 / 365),
'de balans van maart hoort mee te tellen');
});
it('laat een balans van middernacht niet op de dag ervóór meetellen', async () => {
balansOp('2024-09-02', 12_000_000);
balansOp('2024-09-03', 14_150_000);
await createSchedule(afspraak({
creditCadence: 'quarterly', startsOn: '2024-09-02', endsOn: null, principalAmount: null,
}));
const verwacht = Math.round(12_000_000 * 0.05 * 1 / 365 + 14_150_000 * 0.05 * 28 / 365);
assert.equal(dueVoor('2024-10-01').projected_sub, verwacht);
assert.notEqual(dueVoor('2024-10-01').projected_sub, Math.round(14_150_000 * 0.05 * 29 / 365),
'de balans van 3 september mag niet op 2 september al gelden');
});
it('volgt ook een balans, want dat is dezelfde stand', async () => {
balansOp('2024-01-01', EEN_BTC);
await saldoAfspraak();
assert.equal(dueVoor('2024-04-01').projected_sub, Math.round(EEN_BTC * 0.05 * 91 / 365));
});
it('combineert een saldowijziging met een rentewijziging', async () => {
storting('2024-01-01', EEN_BTC);
storting('2024-03-01', EEN_BTC);
const id = await saldoAfspraak();
await createPeriod(id, { startsOn: '2024-02-01', annualRate: 0.02 });
const verwacht = Math.round(
EEN_BTC * 0.05 * 31 / 365 + EEN_BTC * 0.02 * 29 / 365 + 2 * EEN_BTC * 0.02 * 31 / 365);
assert.equal(dueVoor('2024-04-01').projected_sub, verwacht);
});
it('geeft geen prognose, want er is geen termijn om naar toe te rekenen', async () => {
storting('2024-01-01', EEN_BTC);
await saldoAfspraak();
const s = listSchedulesForAccount(ctx.accountId)[0];
const f = scheduleForecast(s, '2024-06-01');
assert.equal(f.termTotalSub, null, 'zonder hoofdsom én einddatum bestaat een termijntotaal niet');
assert.equal(f.remainingSub, null);
assert.equal(typeof f.realizedSub, 'number', 'wat er ontvangen is, is er wél');
});
});