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