302 lines
14 KiB
JavaScript
302 lines
14 KiB
JavaScript
// Generated, do not edit. sha256:ba6482c6f8221f445c935cfd65e76c09e943da662ff5dab04e50d8a76e004316
|
||||
|
|
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';
|
|||
|
|
import { ACCOUNT_SOURCE_KINDS } from '../../shared/config.js';
|
|||
|
|
|
|||
|
|
const harness = createDbHarness();
|
|||
|
|
mock.module('../../server/db.js', { namedExports: harness.exports });
|
|||
|
|
|
|||
|
|
const { mergeAccounts, mergeCheck } = await import('../../server/data/accounts-merge.js');
|
|||
|
|
const { eventsNetByAccount } = await import('../../server/data/events.js');
|
|||
|
|
|
|||
|
|
let btc;
|
|||
|
|
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,'unit')`);
|
|||
|
|
btc = harness.exports.lastId();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function account(naam, kind = 'wallet') {
|
|||
|
|
const eigen = ACCOUNT_SOURCE_KINDS[kind].length > 0 ? 1 : 0;
|
|||
|
|
harness.exports.run(
|
|||
|
|
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,?,?,?,1,NULL)`, [naam, toSearchName(naam), kind, eigen, 0]);
|
|||
|
|
return harness.exports.lastId();
|
|||
|
|
}
|
|||
|
|
function event({ type = 'buy', from = null, to = null, amount = 1000, at = '2024-01-01T00:00:00', unitFee = null }) {
|
|||
|
|
harness.exports.run(
|
|||
|
|
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, unitFee, fromAccountId, toAccountId, source, createdAt)
|
|||
|
|
VALUES (?,?,?,?,?,?,?,?,'manual',?)`,
|
|||
|
|
[btc, type, at, amount, type === 'buy' || type === 'sell' ? 1 : null, unitFee, from, to, at]);
|
|||
|
|
return harness.exports.lastId();
|
|||
|
|
}
|
|||
|
|
function waardering({ acc, onDate, balance, rateAt = null }) {
|
|||
|
|
harness.exports.run(
|
|||
|
|
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
|
|||
|
|
VALUES (?,?,?,?,?,?, 'manual')`,
|
|||
|
|
[btc, onDate, acc, balance, 50000, rateAt ?? `${onDate}T00:00:00`]);
|
|||
|
|
return harness.exports.lastId();
|
|||
|
|
}
|
|||
|
|
const tel = (tabel, kolom, id) =>
|
|||
|
|
harness.exports.one(`SELECT COUNT(*) AS n FROM ${tabel} WHERE ${kolom} = ?`, [id]).n;
|
|||
|
|
const bestaatAccount = id => harness.exports.one(`SELECT 1 AS x FROM Account WHERE id=?`, [id]) != null;
|
|||
|
|
|
|||
|
|
describe('mergeCheck: compatibiliteit', () => {
|
|||
|
|
it('vraagt om twee accounts', () => {
|
|||
|
|
const a = account('A');
|
|||
|
|
assert.equal(mergeCheck(a, null).reason, 'pickTwo');
|
|||
|
|
assert.equal(mergeCheck(null, a).reason, 'pickTwo');
|
|||
|
|
assert.equal(mergeCheck(a, null).counts, null);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('weigert een account met zichzelf', () => {
|
|||
|
|
const a = account('A');
|
|||
|
|
assert.equal(mergeCheck(a, a).reason, 'sameAccount');
|
|||
|
|
assert.equal(mergeCheck(String(a), a).reason, 'sameAccount',
|
|||
|
|
'ook als het id als string binnenkomt (uit een <select>)');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('weigert een onbekend account', () => {
|
|||
|
|
const a = account('A');
|
|||
|
|
assert.equal(mergeCheck(a, 9999).reason, 'notFound');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('weigert custodial × trackable', () => {
|
|||
|
|
const beurs = account('Beurs', 'exchange');
|
|||
|
|
const wallet = account('Wallet', 'wallet');
|
|||
|
|
assert.equal(mergeCheck(beurs, wallet).reason, 'sourceMismatch');
|
|||
|
|
assert.equal(mergeCheck(wallet, beurs).reason, 'sourceMismatch',
|
|||
|
|
'de regel geldt in beide richtingen');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('weigert een overlever die de bron-soorten van de opheffer niet kan dragen', () => {
|
|||
|
|
const wallet = account('Wallet', 'wallet');
|
|||
|
|
const node = account('Node', 'lightning');
|
|||
|
|
assert.equal(mergeCheck(wallet, node).reason, 'kindCannotCarry');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('staat de andere richting wél toe', () => {
|
|||
|
|
const wallet = account('Wallet', 'wallet');
|
|||
|
|
const node = account('Node', 'lightning');
|
|||
|
|
assert.equal(mergeCheck(node, wallet).ok, true,
|
|||
|
|
'lightning kan alles dragen wat wallet kan');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('staat twee custodial soorten toe', () => {
|
|||
|
|
const bank = account('Bank', 'bank');
|
|||
|
|
const kluis = account('Kluis', 'vault');
|
|||
|
|
assert.equal(mergeCheck(bank, kluis).ok, true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('telt wat er zou verhuizen, en kijkt naar beide event-kanten', () => {
|
|||
|
|
const s = account('S');
|
|||
|
|
const v = account('V');
|
|||
|
|
event({ type: 'buy', to: v });
|
|||
|
|
event({ type: 'sell', from: v });
|
|||
|
|
event({ type: 'buy', to: s });
|
|||
|
|
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
|
|||
|
|
VALUES (?, 'address', 'bc1qtest', '2024-01-01T00:00:00')`, [v]);
|
|||
|
|
waardering({ acc: v, onDate: '2024-02-01', balance: 1000 });
|
|||
|
|
|
|||
|
|
const chk = mergeCheck(s, v);
|
|||
|
|
assert.equal(chk.ok, true);
|
|||
|
|
assert.deepEqual(chk.counts, { sources: 1, events: 2, schedules: 0, valuations: 1 });
|
|||
|
|
assert.equal(chk.survivorName, 'S');
|
|||
|
|
assert.equal(chk.victimName, 'V');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('verandert niets aan de database', () => {
|
|||
|
|
const s = account('S');
|
|||
|
|
const v = account('V');
|
|||
|
|
event({ type: 'buy', to: v });
|
|||
|
|
mergeCheck(s, v);
|
|||
|
|
assert.equal(tel('Event', 'toAccountId', v), 1, 'een preview hoort niets te muteren');
|
|||
|
|
assert.ok(bestaatAccount(v));
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('mergeAccounts: verhuizing van kinddata', () => {
|
|||
|
|
it('weigert een incompatibele merge en laat alles staan', async () => {
|
|||
|
|
const beurs = account('Beurs', 'exchange');
|
|||
|
|
const wallet = account('Wallet', 'wallet');
|
|||
|
|
event({ type: 'buy', to: wallet });
|
|||
|
|
|
|||
|
|
await assert.rejects(() => mergeAccounts(beurs, wallet), (e) => e.key === 'accounts.mergeReason_sourceMismatch');
|
|||
|
|
assert.ok(bestaatAccount(wallet), 'de opheffer bestaat nog');
|
|||
|
|
assert.equal(tel('Event', 'toAccountId', wallet), 1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('verhuist alle acht soorten kinddata en verwijdert de opheffer', async () => {
|
|||
|
|
const s = account('Overlever');
|
|||
|
|
const v = account('Opheffer');
|
|||
|
|
|
|||
|
|
event({ type: 'buy', to: v });
|
|||
|
|
event({ type: 'sell', from: v });
|
|||
|
|
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
|
|||
|
|
VALUES (?, 'address', 'bc1qbron', '2024-01-01T00:00:00')`, [v]);
|
|||
|
|
harness.exports.run(`INSERT INTO WalletAddress (accountId, address) VALUES (?, 'bc1qadres')`, [v]);
|
|||
|
|
harness.exports.run(`INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, createdAt)
|
|||
|
|
VALUES (?, 'onchain', 'tx1', 'in', 100, '2024-01-01T00:00:00')`, [v]);
|
|||
|
|
harness.exports.run(`INSERT INTO ScanLog (accountId, scannedAt, status)
|
|||
|
|
VALUES (?, '2024-01-01T00:00:00', 'success')`, [v]);
|
|||
|
|
harness.exports.run(`INSERT INTO ReconcileProposal
|
|||
|
|
(accountId, assetId, amount, direction, suggestedType, status, detectedAt)
|
|||
|
|
VALUES (?,?, 100, 'in', 'transfer', 'pending', '2024-01-01T00:00:00')`, [v, btc]);
|
|||
|
|
harness.exports.run(`INSERT INTO ImportBatch (accountId, filename, importedAt, rowCount)
|
|||
|
|
VALUES (?, 'x.csv', '2024-01-01T00:00:00', 3)`, [v]);
|
|||
|
|
harness.exports.run(`INSERT INTO InterestSchedule
|
|||
|
|
(accountId, rateKind, payoutAssetId, startsOn, compounding, createdAt)
|
|||
|
|
VALUES (?, 'variable', ?, '2024-01-01', 0, '2024-01-01T00:00:00')`, [v, btc]);
|
|||
|
|
|
|||
|
|
const counts = await mergeAccounts(s, v);
|
|||
|
|
|
|||
|
|
assert.equal(tel('Event', 'toAccountId', s), 1);
|
|||
|
|
assert.equal(tel('Event', 'fromAccountId', s), 1);
|
|||
|
|
assert.equal(tel('WalletSource', 'accountId', s), 1);
|
|||
|
|
assert.equal(tel('WalletAddress', 'accountId', s), 1);
|
|||
|
|
assert.equal(tel('WalletTransaction', 'accountId', s), 1);
|
|||
|
|
assert.equal(tel('ScanLog', 'accountId', s), 1);
|
|||
|
|
assert.equal(tel('ReconcileProposal', 'accountId', s), 1);
|
|||
|
|
assert.equal(tel('ImportBatch', 'accountId', s), 1);
|
|||
|
|
assert.equal(tel('InterestSchedule', 'accountId', s), 1);
|
|||
|
|
|
|||
|
|
for (const [tabel, kolom] of [['Event', 'toAccountId'], ['Event', 'fromAccountId'],
|
|||
|
|
['WalletSource', 'accountId'], ['WalletAddress', 'accountId'], ['WalletTransaction', 'accountId'],
|
|||
|
|
['ScanLog', 'accountId'], ['ReconcileProposal', 'accountId'], ['ImportBatch', 'accountId'],
|
|||
|
|
['InterestSchedule', 'accountId']]) {
|
|||
|
|
assert.equal(tel(tabel, kolom, v), 0, `${tabel}.${kolom} verwijst nog naar de opheffer`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
assert.equal(bestaatAccount(v), false, 'de opheffer hoort verdwenen te zijn');
|
|||
|
|
assert.ok(bestaatAccount(s));
|
|||
|
|
assert.equal(counts.events, 2, 'de teruggegeven counts komen uit de voorbeschouwing');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("laat de bron-id's ongemoeid, zodat NWC-secrets geldig blijven", async () => {
|
|||
|
|
const s = account('S');
|
|||
|
|
const v = account('V');
|
|||
|
|
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
|
|||
|
|
VALUES (?, 'address', 'bc1qbron', '2024-01-01T00:00:00')`, [v]);
|
|||
|
|
const bronId = harness.exports.lastId();
|
|||
|
|
|
|||
|
|
await mergeAccounts(s, v);
|
|||
|
|
const bron = harness.exports.one(`SELECT id, accountId FROM WalletSource`);
|
|||
|
|
assert.equal(bron.id, bronId, 'het bron-id mag niet wijzigen');
|
|||
|
|
assert.equal(bron.accountId, s);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('mergeAccounts: waarderingen', () => {
|
|||
|
|
const verschil = (valId) => {
|
|||
|
|
const v = harness.exports.one(`SELECT accountId, assetId, rateAt, balance FROM Valuation WHERE id=?`, [valId]);
|
|||
|
|
return v.balance - eventsNetByAccount(v.accountId, v.assetId, v.rateAt);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
it('houdt het verschil van elke waardering gelijk', async () => {
|
|||
|
|
const s = account('S');
|
|||
|
|
const v = account('V');
|
|||
|
|
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
|
|||
|
|
event({ type: 'buy', to: s, amount: 500, at: '2024-01-01T00:00:00' });
|
|||
|
|
|
|||
|
|
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
|
|||
|
|
const valS = waardering({ acc: s, onDate: '2024-03-01', balance: 500 });
|
|||
|
|
|
|||
|
|
assert.equal(verschil(valV), 200);
|
|||
|
|
assert.equal(verschil(valS), 0);
|
|||
|
|
|
|||
|
|
await mergeAccounts(s, v);
|
|||
|
|
|
|||
|
|
assert.equal(verschil(valV), 200, 'een bestaand verschil moet behouden blijven');
|
|||
|
|
assert.equal(verschil(valS), 0, 'reconciled (0) moet 0 blijven');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('corrigeert de balance daarvoor, ook van de overlever zelf', async () => {
|
|||
|
|
const s = account('S');
|
|||
|
|
const v = account('V');
|
|||
|
|
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
|
|||
|
|
event({ type: 'buy', to: s, amount: 500, at: '2024-01-01T00:00:00' });
|
|||
|
|
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
|
|||
|
|
const valS = waardering({ acc: s, onDate: '2024-03-01', balance: 500 });
|
|||
|
|
|
|||
|
|
await mergeAccounts(s, v);
|
|||
|
|
|
|||
|
|
const balans = id => harness.exports.one(`SELECT balance FROM Valuation WHERE id=?`, [id]).balance;
|
|||
|
|
assert.equal(balans(valV), 1700, '1500 grootboek + 200 verschil');
|
|||
|
|
assert.equal(balans(valS), 1500, '1500 grootboek + 0 verschil, dus herschreven van 500');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('verhuist een niet-botsende waardering naar de overlever', async () => {
|
|||
|
|
const s = account('S');
|
|||
|
|
const v = account('V');
|
|||
|
|
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 10 });
|
|||
|
|
await mergeAccounts(s, v);
|
|||
|
|
assert.equal(harness.exports.one(`SELECT accountId FROM Valuation WHERE id=?`, [val]).accountId, s);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('houdt bij een botsing precies één waardering over', async () => {
|
|||
|
|
const s = account('S');
|
|||
|
|
const v = account('V');
|
|||
|
|
waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
|
|||
|
|
waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
|
|||
|
|
|
|||
|
|
await mergeAccounts(s, v);
|
|||
|
|
|
|||
|
|
const over = harness.exports.all(`SELECT accountId, balance FROM Valuation`);
|
|||
|
|
assert.equal(over.length, 1, 'geen dubbele rij, geen UNIQUE-fout');
|
|||
|
|
assert.equal(over[0].accountId, s, 'de overgebleven rij hangt aan de overlever');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('wist bij een botsing de rij van de opheffer, niet die van de overlever', async () => {
|
|||
|
|
const s = account('S');
|
|||
|
|
const v = account('V');
|
|||
|
|
const valS = waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
|
|||
|
|
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
|
|||
|
|
|
|||
|
|
await mergeAccounts(s, v);
|
|||
|
|
|
|||
|
|
assert.ok(harness.exports.one(`SELECT 1 AS x FROM Valuation WHERE id=?`, [valS]),
|
|||
|
|
'de waardering van de overlever blijft');
|
|||
|
|
assert.equal(harness.exports.one(`SELECT 1 AS x FROM Valuation WHERE id=?`, [valV]), null,
|
|||
|
|
'de botsende rij van de opheffer wijkt');
|
|||
|
|
assert.equal(harness.exports.one(`SELECT balance FROM Valuation`).balance, 500,
|
|||
|
|
'en dus blijft de gemeten balans van het behouden account staan');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('botst alleen op dezelfde datum, niet op hetzelfde asset alleen', async () => {
|
|||
|
|
const s = account('S');
|
|||
|
|
const v = account('V');
|
|||
|
|
waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
|
|||
|
|
waardering({ acc: v, onDate: '2024-02-02', balance: 1200 });
|
|||
|
|
await mergeAccounts(s, v);
|
|||
|
|
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM Valuation`).n, 2);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('rekent het verschil met transfers en fees mee', async () => {
|
|||
|
|
const s = account('S');
|
|||
|
|
const v = account('V');
|
|||
|
|
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
|
|||
|
|
event({ type: 'transfer', from: v, to: null, amount: 300, unitFee: 20, at: '2024-01-15T00:00:00' });
|
|||
|
|
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 700 });
|
|||
|
|
|
|||
|
|
assert.equal(verschil(val), 20);
|
|||
|
|
await mergeAccounts(s, v);
|
|||
|
|
assert.equal(verschil(val), 20);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('negeert waarderingen ná het rateAt-moment bij de correctie', async () => {
|
|||
|
|
const s = account('S');
|
|||
|
|
const v = account('V');
|
|||
|
|
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
|
|||
|
|
event({ type: 'buy', to: s, amount: 9999, at: '2024-06-01T00:00:00' });
|
|||
|
|
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 1000 });
|
|||
|
|
|
|||
|
|
assert.equal(verschil(val), 0);
|
|||
|
|
await mergeAccounts(s, v);
|
|||
|
|
assert.equal(verschil(val), 0, 'de late aankoop hoort buiten dit venster te vallen');
|
|||
|
|
assert.equal(harness.exports.one(`SELECT balance FROM Valuation WHERE id=?`, [val]).balance, 1000,
|
|||
|
|
'balance blijft 1000: het grootboek t/m die datum is niet veranderd');
|
|||
|
|
});
|
|||
|
|
});
|