// Generated, do not edit. sha256:fb6df05be3eb789327d6d99ee25a2f208a1569610e302fe74105eb3607c7757a import { describe, it, beforeEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import { toUtcIso } from '../../shared/time.js'; import { createDbHarness } from '../helpers/db-node.mjs'; import { CONFIG } from '../../shared/config.js'; const harness = createDbHarness({ seedPreferences: true }); mock.module('../../server/db.js', { namedExports: harness.exports }); const { bookDueCredit, dismissDueCredit, dueCreditsAll, dueCreditsForAccount, } = await import('../../server/data/interest-credits.js'); const { createSchedule, deleteSchedule, listAllSchedules, listSchedulesForAccount, updateSchedule, } = await import('../../server/data/interest-schedules.js'); const { listTableRaw, tableColumns } = await import('../../server/data/table-viewer.js'); const { listWalletAddresses, upsertWalletAddresses } = await import('../../server/data/wallet-addresses.js'); const { deleteAllPendingProposals, listHandledProposals, listPendingProposals, revertProposal, } = await import('../../server/data/proposals.js'); const { deletePref, getPref, setPref } = await import('../../server/data/preferences.js'); const { listEvents } = await import('../../server/data/events.js'); let btc, accA; beforeEach(() => { harness.recreate(); harness.exports.run( `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`); btc = harness.exports.lastId(); harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Rente','rente','bank',0,0,1,NULL)`); accA = harness.exports.lastId(); }); const afspraak = (extra = {}) => ({ accountId: accA, rateKind: 'fixed', principalAssetId: btc, payoutAssetId: btc, principalAmount: 100_000_000, annualRate: 0.05, startsOn: '2024-01-01', endsOn: '2099-12-31', creditCadence: 'monthly', creditDayOfMonth: 1, note: null, ...extra, }); describe('bookDueCredit en dismissDueCredit', () => { async function eersteDue() { await createSchedule(afspraak()); const due = dueCreditsForAccount(accA); assert.ok(due.length > 0, 'voorwaarde: er staat iets te boeken'); return due[0]; } it('boekt een echt interest-event én legt de credit vast', async () => { const due = await eersteDue(); const eventId = await bookDueCredit(due, 4_100); const ev = harness.exports.one(`SELECT * FROM Event WHERE id = ?`, [eventId]); assert.equal(ev.type, 'interest'); assert.equal(ev.amount, 4_100); assert.equal(ev.toAccountId, accA, 'rente landt op het account van de afspraak'); assert.equal(ev.assetId, btc, 'in de payout-asset'); assert.equal(ev.occurredAt, toUtcIso(`${due.periodEnd}T00:00:00`), 'gedateerd op de crediteringsdag, niet op vandaag: anders valt hij in het verkeerde belastingjaar'); assert.match(ev.occurredAt, /Z$/, 'en in UTC, zoals elke andere schrijfweg'); const credit = harness.exports.one(`SELECT * FROM InterestCredit WHERE eventId = ?`, [eventId]); assert.equal(credit.status, 'booked'); assert.equal(credit.periodEnd, due.periodEnd); }); it('haalt de geboekte periode uit de due-lijst', async () => { const due = await eersteDue(); const voor = dueCreditsForAccount(accA).length; await bookDueCredit(due, 4_100); assert.equal(dueCreditsForAccount(accA).length, voor - 1); }); it('legt met dismissDueCredit alleen de afhandeling vast, zonder event', async () => { const due = await eersteDue(); const voorEvents = listEvents().length; await dismissDueCredit(due); assert.equal(listEvents().length, voorEvents, 'wegklikken boekt geen rente'); const credit = harness.exports.one( `SELECT * FROM InterestCredit WHERE scheduleId = ? AND periodEnd = ?`, [due.scheduleId, due.periodEnd]); assert.equal(credit.status, 'dismissed'); assert.equal(credit.eventId, null); assert.equal(dueCreditsForAccount(accA).some(d => d.periodEnd === due.periodEnd), false, 'ook een weggeklikte periode komt niet terug'); }); it('telt met dueCreditsAll over alle accounts en verrijkt met de accountnaam', async () => { await createSchedule(afspraak()); harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Tweede','tweede','bank',0,0,1,NULL)`); const tweede = harness.exports.lastId(); await createSchedule(afspraak({ accountId: tweede })); const alle = dueCreditsAll(); assert.ok(alle.length >= 2); assert.deepEqual([...new Set(alle.map(d => d.account_name))].sort(), ['Rente', 'Tweede']); }); it('biedt niets meer aan op een afgesloten account, maar laat het geboekte staan', async () => { const due = await eersteDue(); await bookDueCredit(due, 4_100); const geboekt = listEvents().filter(e => e.toAccountId === accA).length; assert.ok(dueCreditsAll().some(d => d.accountId === accA), 'voorwaarde: er staat nog iets open'); harness.exports.run(`UPDATE Account SET isActive = 0, closedAt = '2026-01-01T00:00:00Z' WHERE id = ?`, [accA]); assert.equal(dueCreditsAll().some(d => d.accountId === accA), false, 'geen nieuw aanbod meer'); assert.equal(listEvents().filter(e => e.toAccountId === accA).length, geboekt, 'de al geboekte rente blijft staan'); }); describe('boeken gaat van oud naar nieuw', () => { it('weigert een nieuwere bijschrijving zolang er een oudere openstaat', async () => { await createSchedule(afspraak()); const due = dueCreditsForAccount(accA); assert.ok(due.length >= 2, 'voorwaarde: er staan er minstens twee open'); await assert.rejects(() => bookDueCredit(due[1], 4_100), (e) => e.key === 'interest.bookOlderFirst'); await assert.rejects(() => bookDueCredit(due[1], 4_100), new RegExp(due[0].periodEnd)); assert.equal(listEvents().length, 0, 'en er is niets geboekt'); }); it('laat de oudste gewoon boeken', async () => { await createSchedule(afspraak()); const due = dueCreditsForAccount(accA); const eventId = await bookDueCredit(due[0], 4_100); assert.ok(eventId > 0); }); it('geeft de volgende vrij zodra de oudste geboekt is', async () => { await createSchedule(afspraak()); const due = dueCreditsForAccount(accA); await bookDueCredit(due[0], 4_100); assert.ok(await bookDueCredit(due[1], 4_200) > 0); }); it('geeft de volgende óók vrij als de oudste genegeerd is', async () => { await createSchedule(afspraak()); const due = dueCreditsForAccount(accA); await dismissDueCredit(due[0]); assert.ok(await bookDueCredit(due[1], 4_200) > 0); }); it('kijkt per ACCOUNT en niet per afspraak', async () => { await createSchedule(afspraak()); await createSchedule(afspraak({ startsOn: '2025-06-01' })); const due = dueCreditsForAccount(accA); const oudste = due.reduce((a, b) => (a.periodEnd <= b.periodEnd ? a : b)); const vanDeTweede = due.find(d => d.scheduleId !== oudste.scheduleId && d.periodEnd > oudste.periodEnd); assert.ok(vanDeTweede, 'voorwaarde: de tweede afspraak heeft een jongere bijschrijving'); await assert.rejects(() => bookDueCredit(vanDeTweede, 1_000), (e) => e.key === 'interest.bookOlderFirst'); }); }); }); describe('interest-schedules: listings en beheer', () => { it('geeft de afspraken van één account met asset-meta', async () => { const id = await createSchedule(afspraak()); const r = listSchedulesForAccount(accA); assert.equal(r.length, 1); assert.equal(r[0].id, id); assert.equal(r[0].payout_symbol, 'BTC'); assert.equal(r[0].payout_decimals, 8, 'de projectie rekent met deze schaal'); assert.equal(r[0].account_name, undefined, 'geen account-join: de aanroeper kent het account al'); }); it('geeft met listAllSchedules alle afspraken mét accountnaam', async () => { await createSchedule(afspraak()); const alle = listAllSchedules(); assert.equal(alle[0].account_name, 'Rente', 'de centrale tabel toont bij welk account de afspraak hoort'); assert.equal(alle[0].principal_symbol, 'BTC'); }); it('sorteert op startdatum, nieuwste eerst', async () => { const oud = await createSchedule(afspraak({ startsOn: '2020-01-01', endsOn: '2021-01-01' })); const nieuw = await createSchedule(afspraak({ startsOn: '2024-01-01' })); assert.deepEqual(listAllSchedules().map(s => s.id), [nieuw, oud]); }); it('verplaatst een afspraak naar een ander account bij bewerken', async () => { const id = await createSchedule(afspraak()); harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Ander','ander','bank',0,0,1,NULL)`); const ander = harness.exports.lastId(); await updateSchedule(id, afspraak({ accountId: ander, annualRate: 0.03 })); assert.deepEqual(listSchedulesForAccount(accA), []); assert.equal(listSchedulesForAccount(ander)[0].annualRate, 0.03); }); it('eist een account bij bewerken en valideert als bij aanmaken', async () => { const id = await createSchedule(afspraak()); await assert.rejects(() => updateSchedule(id, afspraak({ accountId: null })), (e) => e.key === 'interest.schAccountRequired'); await assert.rejects(() => updateSchedule(id, afspraak({ rateKind: 'onzin' })), /rateKind/); await assert.rejects(() => updateSchedule(id, afspraak({ endsOn: '2023-01-01' })), (e) => e.key === 'accounts.schEndBeforeStart'); }); it('verwijdert een afspraak', async () => { const id = await createSchedule(afspraak()); await deleteSchedule(id); assert.deepEqual(listSchedulesForAccount(accA), []); }); }); describe('table-viewer: tableColumns en listTableRaw', () => { it('geeft de kolomnamen van een tabel uit de whitelist', () => { const kolommen = tableColumns('Account'); assert.deepEqual(kolommen, ['id', 'name', 'searchName', 'description', 'kind', 'isOwnCustody', 'scanEnabled', 'isActive', 'closedAt', 'taxCategory', 'taxScope', 'wealthScope', 'createdAt', 'updatedAt'], 'in schema-volgorde, want de viewer toont ze zo'); }); it('weigert een tabel buiten de whitelist', () => { assert.throws(() => tableColumns('sqlite_master'), /Unknown table/); assert.throws(() => tableColumns('accounts; DROP TABLE Account'), /Unknown table/); assert.throws(() => listTableRaw('sqlite_master'), /Unknown table/); }); it('geeft de rijen nieuwste eerst', () => { harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Tweede','tweede','bank',0,0,1,NULL)`); const rijen = listTableRaw('Account'); assert.deepEqual(rijen.map(r => r.name), ['Tweede', 'Rente'], 'ORDER BY rowid DESC'); }); it('werkt voor elke tabel in de whitelist', () => { for (const naam of CONFIG.DB_TABLES) { assert.ok(Array.isArray(listTableRaw(naam)), `${naam} gaf geen rijen-array`); assert.ok(tableColumns(naam).length > 0, `${naam} gaf geen kolommen`); } }); }); describe('upsertWalletAddresses en listWalletAddresses', () => { const adres = (extra = {}) => ({ accountId: accA, address: 'bc1qtest', chainIndex: 0, derivationIndex: 3, automaticLabel: 'xpub', balanceSats: 500_000, transactionCount: 2, lastScannedAt: '2024-05-01T10:00:00', ...extra, }); const rij = (address = 'bc1qtest') => harness.exports.one( `SELECT * FROM WalletAddress WHERE address = ?`, [address]); it('schrijft een batch adressen weg met één persist', async () => { const voor = harness.calls.persist; await upsertWalletAddresses([adres(), adres({ address: 'bc1qtwee' }), adres({ address: 'bc1qdrie' })]); assert.equal(listWalletAddresses(accA).length, 3); assert.equal(harness.calls.persist, voor + 1, 'één write voor de hele scan'); }); it('werkt bij een rescan de scan-gegevens bij', async () => { await upsertWalletAddresses([adres({ balanceSats: 500_000, transactionCount: 2 })]); await upsertWalletAddresses([adres({ balanceSats: 750_000, transactionCount: 3, lastScannedAt: '2024-06-01T10:00:00' })]); assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM WalletAddress`).n, 1, 'UNIQUE op address'); assert.equal(rij().balanceSats, 750_000); assert.equal(rij().transactionCount, 3); assert.equal(rij().lastScannedAt, '2024-06-01T10:00:00'); }); it('vult een ontbrekend scanmoment zelf in', async () => { await upsertWalletAddresses([{ accountId: accA, address: 'bc1qkaal' }]); assert.ok(rij('bc1qkaal').lastScannedAt, 'een adres uit een scan heeft altijd een scanmoment'); assert.equal(rij('bc1qkaal').balanceSats, null, 'maar een onbekend saldo blijft leeg, niet 0'); }); it('sorteert de lijst op keten, index en adres', async () => { await upsertWalletAddresses([ adres({ address: 'bc1qb', chainIndex: 1, derivationIndex: 0 }), adres({ address: 'bc1qa', chainIndex: 0, derivationIndex: 5 }), adres({ address: 'bc1qc', chainIndex: 0, derivationIndex: 1 }), ]); assert.deepEqual(listWalletAddresses(accA).map(a => a.address), ['bc1qc', 'bc1qa', 'bc1qb'], 'keten 0 vóór 1, en daarbinnen op afgeleide index'); }); it('kijkt alleen naar het gevraagde account', async () => { harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Ander','ander','wallet',1,1,1,NULL)`); const ander = harness.exports.lastId(); await upsertWalletAddresses([adres(), adres({ address: 'bc1qander', accountId: ander })]); assert.equal(listWalletAddresses(accA).length, 1); assert.equal(listWalletAddresses(ander).length, 1); }); it('weigert een bron die bij een ander account hoort', async () => { harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Ander','ander','wallet',1,0,1,NULL)`); const ander = harness.exports.lastId(); harness.exports.run( `INSERT INTO WalletSource (accountId, kind, value, name, createdAt) VALUES (?, 'xpub', 'zpub-ander', 'Andere Ledger', '2024-01-01T00:00:00')`, [ander]); const andereBron = harness.exports.lastId(); await assert.rejects(() => upsertWalletAddresses([adres({ sourceId: andereBron })]), /source .* belongs to account/); assert.equal(rij(), null, 'en er blijft niets half ingevoerd staan'); }); it('laat een los toegevoegd adres zónder bron door', async () => { await upsertWalletAddresses([adres({ sourceId: null })]); assert.equal(rij().sourceId, null); }); }); describe('proposals: bulk verwijderen en terugdraaien', () => { function voorstel({ status = 'pending', amount = 100_000, resolved = null } = {}) { harness.exports.run( `INSERT INTO ReconcileProposal (accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, resolvedEventId, detectedAt) VALUES (?,?, NULL, NULL, ?, 'in', 'transfer', ?, ?, '2024-05-01T00:00:00')`, [accA, btc, amount, status, resolved]); return harness.exports.lastId(); } it('verwijdert alleen de openstaande voorstellen', async () => { voorstel({ status: 'pending' }); voorstel({ status: 'pending', amount: 2 }); voorstel({ status: 'ignored', amount: 3 }); await deleteAllPendingProposals(); assert.deepEqual(listPendingProposals(), []); assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM ReconcileProposal`).n, 1); }); it('draait een afgehandeld voorstel terug naar openstaand', async () => { const { createEvent } = await import('../../server/data/events.js'); const eventId = await createEvent({ assetId: btc, type: 'transfer', occurredAt: '2024-05-01T00:00:00', amount: 100_000, toAccountId: accA, source: 'manual', }); const id = voorstel({ status: 'accepted_transfer', resolved: eventId }); await revertProposal(id); const p = harness.exports.one(`SELECT * FROM ReconcileProposal WHERE id = ?`, [id]); assert.equal(p.status, 'pending', 'het voorstel staat weer open'); assert.equal(p.resolvedEventId, null); assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM Event WHERE id = ?`, [eventId]).n, 0, 'het geboekte event gaat mee weg: terugdraaien betekent ook de boeking ongedaan maken'); }); it('weigert terugdraaien van de twee standen die dat niet kunnen, elk met zijn eigen reden', async () => { const gelinkt = voorstel({ status: 'auto_linked' }); await assert.rejects(() => revertProposal(gelinkt), (e) => e.key === 'home.recAutoLinked'); const open = voorstel({ status: 'pending', amount: 2 }); await assert.rejects(() => revertProposal(open), (e) => e.key === 'home.recStillOpen'); }); it('geeft met listHandledProposals de afgehandelde, nieuwste eerst', () => { voorstel({ status: 'pending' }); const genegeerd = voorstel({ status: 'ignored', amount: 2 }); const gelinkt = voorstel({ status: 'auto_linked', amount: 3 }); const rijen = listHandledProposals(); assert.deepEqual(rijen.map(r => r.id), [gelinkt, genegeerd], 'alleen de niet-openstaande'); }); it('respecteert de limiet van listHandledProposals', () => { for (let i = 0; i < 5; i++) voorstel({ status: 'ignored', amount: i + 1 }); assert.equal(listHandledProposals(2).length, 2); }); }); describe('deletePref', () => { it('verwijdert één voorkeur en laat de rest staan', async () => { await setPref('table.trades', 'view', { sort: null }); await setPref('table.events', 'view', { sort: null }); await deletePref('table.trades', 'view'); assert.equal(harness.exports.one( `SELECT COUNT(*) AS n FROM Preference WHERE scope='table.trades'`).n, 0); assert.equal(harness.exports.one( `SELECT COUNT(*) AS n FROM Preference WHERE scope='table.events'`).n, 1); }); it('laat een verwijderde voorkeur terugvallen op de registry-waarde', async () => { await setPref('app', 'theme', 'light'); assert.equal(getPref('app', 'theme'), 'light'); await deletePref('app', 'theme'); assert.equal(getPref('app', 'theme'), 'dark', 'de initiële waarde uit PREFERENCES'); }); it('doet niets bij een onbekende sleutel', async () => { const voor = harness.exports.one(`SELECT COUNT(*) AS n FROM Preference`).n; await deletePref('bestaat', 'niet'); assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM Preference`).n, voor); }); });