// Generated, do not edit. sha256:eb712800d03552fa4079bd778c098f3fc2fb75b87a3722d0d27803f76118c2b9 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 { deleteProposal, ignoreAllPendingProposals, ignoreProposal, listPendingProposals, resolveProposalAsInterest, resolveProposalAsInternal, resolveProposalAsTrade, resolveProposalAsTransfer, revertProposal, syncTxProposals } = await import('../../server/data/proposals.js'); const { linkTxEvent } = await import('../../server/data/tx-links.js'); const { eventsNetByAccount } = await import('../../server/data/events.js'); let ctx; beforeEach(() => { harness.recreate(); ctx = { btc: asset('BTC'), eigen: account('Hardware wallet', { ownCustody: true }), beurs: account('Exchange', { ownCustody: false }), }; }); function asset(symbol) { harness.exports.run( `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,?,?,?,?,?)`, [symbol, symbol, toSearchName(symbol), 'crypto', symbol, null, 8, 'unit']); return harness.exports.lastId(); } function account(naam, { ownCustody = true } = {}) { harness.exports.run( `INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,?,?,?,1,NULL)`, [naam, toSearchName(naam), ownCustody ? 'wallet' : 'exchange', ownCustody ? 1 : 0, 1]); return harness.exports.lastId(); } function beweging({ account: acc = null, direction = 'in', valueSats = 250_000, txid = 'tx-1', blockTime = '2024-06-01T10:00:00', feeSats = null } = {}) { harness.exports.run( `INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, feeSats, address, blockHeight, blockTime, createdAt) VALUES (?,'onchain',?,?,?,?,?,?,?,?)`, [acc ?? ctx.eigen, txid, direction, valueSats, feeSats, null, blockTime ? 840_000 : null, blockTime, '2024-06-02T00:00:00']); return harness.exports.lastId(); } const voorstellen = () => harness.exports.all( `SELECT id, walletTransactionId, amount, direction, suggestedType, status, resolvedEventId FROM ReconcileProposal ORDER BY id`); const events = () => harness.exports.all(`SELECT * FROM Event ORDER BY id`); describe('syncTxProposals', () => { it('maakt één voorstel per beweging zonder koppeling', async () => { beweging({ txid: 'a' }); beweging({ txid: 'b' }); assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 2); assert.equal(voorstellen().length, 2); assert.ok(voorstellen().every(p => p.status === 'pending')); }); describe('lightning', () => { let nodes = 0; function bron(acc, kind) { let connectionId = null; if (kind === 'lnd') { harness.exports.run(`INSERT INTO Connection (kind, name, searchName, url, isActive, isSystem, createdAt, updatedAt) VALUES ('lnd', ?, ?, 'https://node.test', 1, 0, '2024-01-01', '2024-01-01')`, [`Node ${++nodes}`, `node ${nodes}`]); connectionId = harness.exports.lastId(); } harness.exports.run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, name, createdAt) VALUES (?, ?, ?, ?, ?, '2026-01-01')`, [acc, connectionId, kind, `${kind}-${acc}`, kind === 'nwc' ? 'Zaps' : null]); return harness.exports.lastId(); } function betaling(acc, sourceId, txid = 'hash-zap', feeSats = 1) { harness.exports.run(`INSERT INTO WalletTransaction (accountId, sourceId, layer, txid, direction, valueSats, feeSats, blockTime, createdAt) VALUES (?, ?, 'lightning', ?, 'out', 21, ?, '2026-10-19T10:00:00Z', '2026-10-19')`, [acc, sourceId, txid, feeSats]); return harness.exports.lastId(); } it('één voorstel voor een betaling op LND en NWC: vanaf de node', async () => { const lnd = betaling(ctx.eigen, bron(ctx.eigen, 'lnd')); betaling(ctx.eigen, bron(ctx.eigen, 'nwc')); assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1); assert.deepEqual(voorstellen().map(p => p.walletTransactionId), [lnd]); }); it('een NWC-wallet op een account zonder LND-bron krijgt wel een voorstel', async () => { const nwc = betaling(ctx.eigen, bron(ctx.eigen, 'nwc')); assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1); assert.deepEqual(voorstellen().map(p => p.walletTransactionId), [nwc]); }); it('een LND-node op een ander account telt niet voor deze NWC-wallet', async () => { bron(ctx.beurs, 'lnd'); const nwc = betaling(ctx.eigen, bron(ctx.eigen, 'nwc')); await syncTxProposals(ctx.eigen, ctx.btc); assert.deepEqual(voorstellen().map(p => p.walletTransactionId), [nwc]); }); it('de lijst zegt via welke NWC-wallet de betaling liep', async () => { betaling(ctx.eigen, bron(ctx.eigen, 'lnd')); betaling(ctx.eigen, bron(ctx.eigen, 'nwc')); betaling(ctx.eigen, bron(ctx.eigen, 'lnd'), 'alleen-node'); await syncTxProposals(ctx.eigen, ctx.btc); const rijen = listPendingProposals().map(p => [p.transactionHash, p.tx_layer, p.via_wallet]); assert.deepEqual(rijen.sort(), [['alleen-node', 'lightning', null], ['hash-zap', 'lightning', 'Zaps']]); }); it('neemt de memo van de NWC-tweeling mee, en zet de zap in de notitie van de boeking', async () => { betaling(ctx.eigen, bron(ctx.eigen, 'lnd')); const nwc = betaling(ctx.eigen, bron(ctx.eigen, 'nwc')); const zap = JSON.stringify({ kind: 9734, pubkey: 'c'.repeat(64), content: 'Dank!', tags: [['p', '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d']] }); harness.exports.run(`UPDATE WalletTransaction SET memo = ? WHERE id = ?`, [zap, nwc]); await syncTxProposals(ctx.eigen, ctx.btc); assert.equal(listPendingProposals()[0].tx_memo, zap); const eventId = await resolveProposalAsTrade(voorstellen()[0].id, { fiatPrice: 60_000 }); assert.equal(events().find(x => x.id === eventId).note, 'zap to npub180cv…h6w6: "Dank!" · reconcile tx hash-zap'); }); it('als verkoop zet hij de fee op de gebeurtenis', async () => { betaling(ctx.eigen, bron(ctx.eigen, 'lnd'), 'hash-zap', 3); await syncTxProposals(ctx.eigen, ctx.btc); const eventId = await resolveProposalAsTrade(voorstellen()[0].id, { fiatPrice: 60_000 }); const e = events().find(x => x.id === eventId); assert.equal(e.type, 'sell'); assert.equal(e.amount, 21); assert.equal(e.unitFee, 3); }); }); it('slaat bewegingen over die al gekoppeld zijn', async () => { const tx = beweging(); harness.exports.run( `INSERT INTO Event (assetId, type, occurredAt, amount, toAccountId, source, createdAt) VALUES (?,?,?,?,?,?,?)`, [ctx.btc, 'transfer', '2024-06-01T10:00:00', 250_000, ctx.eigen, 'manual', '2024-06-01T10:00:00']); const ev = harness.exports.lastId(); await linkTxEvent(tx, ev, 'manual'); assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 0, 'een beweging met een link is geen gat meer'); }); it('stelt transfer voor bij eigen custody en trade bij een beurs', async () => { beweging({ account: ctx.eigen, txid: 'eigen' }); await syncTxProposals(ctx.eigen, ctx.btc); assert.equal(voorstellen()[0].suggestedType, 'transfer'); beweging({ account: ctx.beurs, txid: 'beurs' }); await syncTxProposals(ctx.beurs, ctx.btc); const bijBeurs = voorstellen().find(p => p.suggestedType === 'trade'); assert.ok(bijBeurs, 'zonder eigen custody hoort een trade voorgesteld te worden'); }); it('is idempotent: een herscan levert geen dubbele voorstellen', async () => { beweging(); await syncTxProposals(ctx.eigen, ctx.btc); await syncTxProposals(ctx.eigen, ctx.btc); assert.equal(voorstellen().length, 1, 'pending wordt eerst opgeruimd, dan geregenereerd'); }); it('raakt de voorstellen van een ánder account niet aan', async () => { beweging({ account: ctx.eigen, txid: 'a' }); beweging({ account: ctx.beurs, txid: 'b' }); await syncTxProposals(ctx.eigen, ctx.btc); await syncTxProposals(ctx.beurs, ctx.btc); assert.equal(voorstellen().length, 2); }); describe('suppressie van genegeerde voorstellen', () => { it('laat een genegeerd voorstel niet terugkomen (match op walletTransactionId)', async () => { beweging(); await syncTxProposals(ctx.eigen, ctx.btc); await ignoreProposal(voorstellen()[0].id); assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 0); assert.deepEqual(voorstellen().map(p => p.status), ['ignored'], 'het genegeerde voorstel blijft, er komt niets nieuws bij'); }); it('onderdrukt op (account, asset, richting, bedrag), ook zonder tx-verwijzing', async () => { harness.exports.run( `INSERT INTO ReconcileProposal (accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, detectedAt) VALUES (?,?, NULL, NULL, ?, ?, 'transfer', 'ignored', ?)`, [ctx.eigen, ctx.btc, 777_000, 'in', '2024-05-01T00:00:00']); beweging({ txid: 'nieuw-na-herimport', valueSats: 777_000, direction: 'in' }); assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 0, 'zelfde account/asset/richting/bedrag → nog steeds onderdrukt'); }); it('een genegeerde beweging onderdrukt geen andere beweging met hetzelfde bedrag', async () => { beweging({ txid: 'a', valueSats: 21, direction: 'out' }); await syncTxProposals(ctx.eigen, ctx.btc); await ignoreProposal(voorstellen()[0].id); const tweede = beweging({ txid: 'b', valueSats: 21, direction: 'out' }); assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1); assert.deepEqual(voorstellen().filter(p => p.status === 'pending').map(p => p.walletTransactionId), [tweede]); }); it('onderdrukt niet bij een ander bedrag', async () => { beweging({ txid: 'a', valueSats: 100 }); await syncTxProposals(ctx.eigen, ctx.btc); await ignoreProposal(voorstellen()[0].id); beweging({ txid: 'b', valueSats: 200 }); assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1, 'een ander bedrag is een ander gat'); }); it('onderdrukt na hergebruik van een tx-id GEEN onverwante beweging meer', async () => { beweging({ txid: 'oud', valueSats: 100 }); await syncTxProposals(ctx.eigen, ctx.btc); await ignoreProposal(voorstellen()[0].id); assert.equal(voorstellen().length, 1); harness.exports.run(`DELETE FROM WalletTransaction`); assert.deepEqual(voorstellen(), [], 'CASCADE: het voorstel gaat mee met zijn beweging'); beweging({ txid: 'volstrekt-andere-tx', valueSats: 999_999 }); assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1, 'een onverwante beweging krijgt weer een gewoon voorstel'); }); }); }); describe('listPendingProposals', () => { it('toont alleen pending, verrijkt met account- en assetnaam', async () => { beweging(); await syncTxProposals(ctx.eigen, ctx.btc); const lijst = listPendingProposals(); assert.equal(lijst.length, 1); assert.equal(lijst[0].account_name, 'Hardware wallet'); assert.equal(lijst[0].asset_symbol, 'BTC'); assert.equal(lijst[0].asset_decimals, 8); }); it('verbergt een voorstel zodra de beweging gekoppeld raakt', async () => { const tx = beweging(); await syncTxProposals(ctx.eigen, ctx.btc); assert.equal(listPendingProposals().length, 1); harness.exports.run( `INSERT INTO Event (assetId, type, occurredAt, amount, toAccountId, source, createdAt) VALUES (?,?,?,?,?,?,?)`, [ctx.btc, 'transfer', '2024-06-01T10:00:00', 250_000, ctx.eigen, 'manual', '2024-06-01T10:00:00']); await linkTxEvent(tx, harness.exports.lastId(), 'manual'); assert.deepEqual(listPendingProposals(), [], 'een gekoppelde beweging is geen openstaand gat meer'); }); it('verbergt afgehandelde voorstellen', async () => { beweging(); await syncTxProposals(ctx.eigen, ctx.btc); await ignoreProposal(listPendingProposals()[0].id); assert.deepEqual(listPendingProposals(), []); }); it('draagt het moment van de TRANSACTIE mee en niet dat van het voorstel', async () => { beweging({ blockTime: '2021-03-04T12:00:00' }); await syncTxProposals(ctx.eigen, ctx.btc); const p = listPendingProposals()[0]; assert.equal(p.tx_block_time, '2021-03-04T12:00:00'); assert.notEqual(p.tx_block_time, p.detectedAt, 'anders bewijst deze test niets'); }); it('houdt een ONBEVESTIGDE beweging in de lijst, zonder moment', async () => { beweging({ blockTime: null }); await syncTxProposals(ctx.eigen, ctx.btc); const lijst = listPendingProposals(); assert.equal(lijst.length, 1, 'een onbevestigde beweging hoort nog steeds afgehandeld te worden'); assert.equal(lijst[0].tx_block_time, null); assert.ok(lijst[0].detectedAt, 'dan is het moment van het voorstel het enige dat er is'); }); it('houdt een GAT-voorstel in de lijst, dat aan geen transactie hangt', async () => { harness.exports.run( `INSERT INTO ReconcileProposal (accountId, assetId, walletTransactionId, amount, direction, suggestedType, status, detectedAt) VALUES (?,?,NULL,?,?,?,'pending',?)`, [ctx.eigen, ctx.btc, 100_000, 'in', 'transfer', '2024-06-02T00:00:00']); const lijst = listPendingProposals(); assert.equal(lijst.length, 1); assert.equal(lijst[0].walletTransactionId, null); assert.equal(lijst[0].tx_block_time, null); }); it('sorteert op het moment van de beweging, nieuwste eerst', async () => { beweging({ txid: 'oud', blockTime: '2019-01-01T00:00:00' }); beweging({ txid: 'nieuw', blockTime: '2025-01-01T00:00:00' }); beweging({ txid: 'midden', blockTime: '2022-01-01T00:00:00' }); await syncTxProposals(ctx.eigen, ctx.btc); assert.deepEqual(listPendingProposals().map(p => p.transactionHash), ['nieuw', 'midden', 'oud']); }); }); describe('resolveProposalAsTrade', () => { const eersteVoorstel = async (opts) => { beweging(opts); await syncTxProposals(opts?.account ?? ctx.eigen, ctx.btc); return voorstellen()[0].id; }; it('maakt van een inkomende beweging een buy op het account', async () => { const id = await eersteVoorstel({ direction: 'in' }); const eventId = await resolveProposalAsTrade(id, { fiatPrice: 50_000, fiatFee: null, occurredAt: null }); const e = events()[0]; assert.equal(e.id, eventId); assert.equal(e.type, 'buy'); assert.equal(e.toAccountId, ctx.eigen); assert.equal(e.fromAccountId, null); assert.equal(e.amount, 250_000); assert.equal(e.source, 'reconcile'); }); it('maakt van een uitgaande beweging een sell vanaf het account', async () => { const id = await eersteVoorstel({ direction: 'out' }); await resolveProposalAsTrade(id, { fiatPrice: 50_000, fiatFee: null, occurredAt: null }); const e = events()[0]; assert.equal(e.type, 'sell'); assert.equal(e.fromAccountId, ctx.eigen); assert.equal(e.toAccountId, null); }); it('eist een fiatPrice', async () => { const id = await eersteVoorstel(); await assert.rejects(() => resolveProposalAsTrade(id, { fiatPrice: null }), /fiatPrice/); assert.deepEqual(events(), [], 'zonder prijs mag er geen event ontstaan'); assert.equal(voorstellen()[0].status, 'pending', 'en het voorstel blijft open'); }); it('zet het voorstel op accepted_trade met een verwijzing naar het event', async () => { const id = await eersteVoorstel(); const eventId = await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null }); const p = voorstellen()[0]; assert.equal(p.status, 'accepted_trade'); assert.equal(p.resolvedEventId, eventId); }); it('legt een soft-koppeling met de beweging', async () => { const id = await eersteVoorstel(); const eventId = await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null }); const l = harness.exports.all(`SELECT method, eventId FROM WalletTransactionEventLink`); assert.deepEqual(l, [{ method: 'soft', eventId: eventId }]); }); it('weigert een tweede afhandeling van hetzelfde voorstel', async () => { const id = await eersteVoorstel(); await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null }); await assert.rejects(() => resolveProposalAsTrade(id, { fiatPrice: 1 }), (e) => e.key === 'home.recHandled_accepted_trade'); assert.equal(events().length, 1, 'geen tweede event'); }); it('weigert een onbekend voorstel', async () => { await assert.rejects(() => resolveProposalAsTrade(9999, { fiatPrice: 1 }), (e) => e.key === 'home.recUnknownProposal'); }); describe('welke datum wordt gekozen', () => { it('gebruikt een expliciete occurredAt als die er is', async () => { const id = await eersteVoorstel({ blockTime: '2024-06-01T10:00:00' }); await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: '2024-01-01T00:00:00' }); assert.equal(events()[0].occurredAt, '2024-01-01T00:00:00'); }); it('valt terug op de blockTime van de beweging', async () => { const id = await eersteVoorstel({ blockTime: '2024-06-01T10:00:00' }); await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null }); assert.equal(events()[0].occurredAt, '2024-06-01T10:00:00'); }); it('valt daarna terug op het detectiemoment van het voorstel', async () => { const id = await eersteVoorstel({ blockTime: null }); await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null }); const detected = harness.exports.one(`SELECT detectedAt FROM ReconcileProposal`).detectedAt; assert.equal(events()[0].occurredAt, detected); }); }); }); describe('resolveProposalAsTransfer', () => { const eersteVoorstel = async (opts) => { beweging(opts); await syncTxProposals(ctx.eigen, ctx.btc); return voorstellen()[0].id; }; it('zet bij uitgaand het eigen account als bron en de tegenrekening als bestemming', async () => { const id = await eersteVoorstel({ direction: 'out' }); await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null }); const e = events()[0]; assert.equal(e.type, 'transfer'); assert.equal(e.fromAccountId, ctx.eigen); assert.equal(e.toAccountId, ctx.beurs); }); it('draait de richting om bij inkomend', async () => { const id = await eersteVoorstel({ direction: 'in' }); await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null }); const e = events()[0]; assert.equal(e.fromAccountId, ctx.beurs); assert.equal(e.toAccountId, ctx.eigen); }); it('eist een tegenrekening', async () => { const id = await eersteVoorstel(); await assert.rejects(() => resolveProposalAsTransfer(id, { other_account_id: null }), (e) => e.key === 'home.recNoCounter'); assert.deepEqual(events(), []); }); it('neemt de netwerkfee over bij een uitgaande beweging', async () => { const id = await eersteVoorstel({ direction: 'out', feeSats: 1_200 }); await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null }); assert.equal(events()[0].unitFee, 1_200); }); it('neemt de fee NIET over bij een inkomende beweging', async () => { const id = await eersteVoorstel({ direction: 'in', feeSats: 1_200 }); await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null }); assert.equal(events()[0].unitFee, null); }); it('laat een expliciete unitFee voorgaan op de netwerkfee', async () => { const id = await eersteVoorstel({ direction: 'out', feeSats: 1_200 }); await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: 99, occurredAt: null }); assert.equal(events()[0].unitFee, 99); }); it('zet het voorstel op accepted_transfer', async () => { const id = await eersteVoorstel(); const eventId = await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null }); const p = voorstellen()[0]; assert.equal(p.status, 'accepted_transfer'); assert.equal(p.resolvedEventId, eventId); }); }); describe('resolveProposalAsInternal', () => { const voorstelVoor = async (wtx, kind) => { harness.exports.run(`UPDATE WalletTransaction SET kind = ? WHERE id = ?`, [kind, wtx]); await syncTxProposals(ctx.eigen, ctx.btc); return voorstellen().find(p => p.walletTransactionId === wtx).id; }; it('boekt een kanaalopening als verplaatsing naar hetzelfde account: netto alleen de fee', async () => { const wtx = beweging({ direction: 'out', valueSats: 1_000_000, feeSats: 450 }); const eventId = await resolveProposalAsInternal(await voorstelVoor(wtx, 'channel')); const e = events().find(x => x.id === eventId); assert.equal(e.type, 'transfer'); assert.equal(e.fromAccountId, ctx.eigen); assert.equal(e.toAccountId, ctx.eigen); assert.equal(e.amount, 1_000_000); assert.equal(e.unitFee, 450); assert.equal(eventsNetByAccount(ctx.eigen, ctx.btc), -450); assert.equal(voorstellen()[0].status, 'accepted_transfer'); assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM WalletTransactionEventLink WHERE walletTransactionId = ?`, [wtx]).n, 1); }); it('een kanaalsluiting komt binnen zonder fee: netto nul', async () => { const wtx = beweging({ direction: 'in', valueSats: 990_000, feeSats: 300 }); await resolveProposalAsInternal(await voorstelVoor(wtx, 'channel')); assert.equal(events()[0].unitFee, null); assert.equal(eventsNetByAccount(ctx.eigen, ctx.btc), 0); }); it('een kanaal van een gekoppelde wallet op een eigen account gaat naar het account van de node', async () => { harness.exports.run(`INSERT INTO Connection (kind, name, searchName, url, isActive, isSystem, createdAt, updatedAt) VALUES ('lnd', 'Node', 'node', 'https://node.test', 1, 0, '2024-01-01', '2024-01-01')`); const conn = harness.exports.lastId(); const node = account('Lightning'); harness.exports.run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, createdAt) VALUES (?, ?, 'lnd', '', '2026-01-01')`, [node, conn]); harness.exports.run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, scriptType, createdAt) VALUES (?, ?, 'xpub', 'zpub', 'p2wpkh', '2026-01-01')`, [ctx.eigen, conn]); const wtx = beweging({ direction: 'out', valueSats: 500_000, feeSats: 200 }); const id = await voorstelVoor(wtx, 'channel'); assert.equal(listPendingProposals()[0].tx_node_account, 'Lightning'); const eventId = await resolveProposalAsInternal(id); const e = events().find(x => x.id === eventId); assert.deepEqual([e.type, e.fromAccountId, e.toAccountId, e.amount, e.unitFee], ['transfer', ctx.eigen, node, 500_000, 200]); assert.equal(eventsNetByAccount(ctx.eigen, ctx.btc), -500_200); assert.equal(eventsNetByAccount(node, ctx.btc), 500_000); }); it('weigert een gewone beweging, die heeft een tegenpartij', async () => { beweging({ direction: 'out' }); await syncTxProposals(ctx.eigen, ctx.btc); await assert.rejects(() => resolveProposalAsInternal(voorstellen()[0].id), (e) => e.key === 'home.recNotInternal'); assert.deepEqual(events(), []); assert.equal(voorstellen()[0].status, 'pending'); }); it('de lijst draagt de soort en de fee mee', async () => { const wtx = beweging({ direction: 'out', feeSats: 12 }); await voorstelVoor(wtx, 'channel'); const p = listPendingProposals()[0]; assert.equal(p.tx_kind, 'channel'); assert.equal(p.tx_fee_sats, 12); }); }); describe('resolveProposalAsInterest', () => { function forward(txid = 'fwd:1:2:3') { harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt) VALUES (?, 'nwc', 'x', '2026-01-01')`, [ctx.eigen]); harness.exports.run(`INSERT INTO WalletTransaction (accountId, sourceId, layer, txid, direction, valueSats, blockTime, createdAt) VALUES (?, ?, 'lightning', ?, 'in', 7, '2026-10-19T10:00:00Z', '2026-10-19')`, [ctx.eigen, harness.exports.lastId(), txid]); return harness.exports.lastId(); } it('boekt een forward als rente op het account, en terugdraaien haalt hem weg', async () => { const wtx = forward(); await syncTxProposals(ctx.eigen, ctx.btc); const id = voorstellen().find(p => p.walletTransactionId === wtx).id; const eventId = await resolveProposalAsInterest(id); const e = events().find(x => x.id === eventId); assert.deepEqual([e.type, e.amount, e.toAccountId, e.fromAccountId, e.fiatPrice], ['interest', 7, ctx.eigen, null, null]); assert.equal(e.occurredAt, '2026-10-19T10:00:00Z'); assert.equal(voorstellen()[0].status, 'accepted_interest'); await revertProposal(id); assert.deepEqual(events(), []); assert.equal(voorstellen()[0].status, 'pending'); }); it('weigert een gewone inkomende betaling', async () => { const wtx = forward('hash-gewoon'); await syncTxProposals(ctx.eigen, ctx.btc); const id = voorstellen().find(p => p.walletTransactionId === wtx).id; await assert.rejects(() => resolveProposalAsInterest(id), (e) => e.key === 'home.recNotForward'); assert.deepEqual(events(), []); }); }); describe('negeren en verwijderen', () => { it('ignoreProposal zet één voorstel op ignored', async () => { beweging(); await syncTxProposals(ctx.eigen, ctx.btc); await ignoreProposal(voorstellen()[0].id); assert.equal(voorstellen()[0].status, 'ignored'); }); it('ignoreProposal weigert een al afgehandeld voorstel', async () => { beweging(); await syncTxProposals(ctx.eigen, ctx.btc); const id = voorstellen()[0].id; await ignoreProposal(id); await assert.rejects(() => ignoreProposal(id), (e) => e.key === 'home.recHandled_ignored'); }); it('ignoreAllPendingProposals negeert er meerdere en meldt het aantal', async () => { beweging({ txid: 'a' }); beweging({ txid: 'b' }); beweging({ txid: 'c' }); await syncTxProposals(ctx.eigen, ctx.btc); assert.equal(await ignoreAllPendingProposals(), 3); assert.ok(voorstellen().every(p => p.status === 'ignored')); }); it('ignoreAllPendingProposals schrijft niet als er niets openstaat', async () => { const voor = harness.calls.persist; assert.equal(await ignoreAllPendingProposals(), 0); assert.equal(harness.calls.persist, voor, 'geen wijziging, geen schrijfactie'); }); it('deleteProposal laat het aangemaakte event en de koppeling staan', async () => { beweging(); await syncTxProposals(ctx.eigen, ctx.btc); const id = voorstellen()[0].id; const eventId = await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null }); await deleteProposal(id); assert.deepEqual(voorstellen(), []); assert.equal(events().length, 1); assert.equal(events()[0].id, eventId); assert.equal(harness.exports.all(`SELECT * FROM WalletTransactionEventLink`).length, 1); }); });