const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const dbPath = path.join(os.tmpdir(), `bill-tracker-bank-sync-test-${process.pid}.sqlite`); process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); const { encryptSecret } = require('../services/encryptionService'); const { syncDataSource } = require('../services/bankSyncService'); function createUser(db, suffix) { return db.prepare(` INSERT INTO users (username, password_hash, role, active, email, created_at, updated_at) VALUES (?, 'x', 'user', 1, ?, datetime('now'), datetime('now')) `).run(`bank-sync-${suffix}`, `bank-sync-${suffix}@local`).lastInsertRowid; } function createSource(db, userId) { return db.prepare(` INSERT INTO data_sources (user_id, type, provider, name, status, encrypted_secret) VALUES (?, 'provider_sync', 'simplefin', 'SimpleFIN', 'active', ?) `).run(userId, encryptSecret('https://user:pass@example.com/simplefin')).lastInsertRowid; } function createAccount(db, userId, dataSourceId, providerAccountId, monitored) { return db.prepare(` INSERT INTO financial_accounts (user_id, data_source_id, provider_account_id, name, currency, monitored) VALUES (?, ?, ?, ?, 'USD', ?) `).run(userId, dataSourceId, providerAccountId, providerAccountId, monitored ? 1 : 0).lastInsertRowid; } test.after(() => { closeDb(); for (const suffix of ['', '-wal', '-shm']) { fs.rmSync(`${dbPath}${suffix}`, { force: true }); } }); test('SimpleFIN sync skips storing transactions for unmonitored accounts', async () => { const db = getDb(); const userId = createUser(db, 'skip-unmonitored'); const dataSourceId = createSource(db, userId); const mutedAccountId = createAccount(db, userId, dataSourceId, 'muted-account', false); const originalFetch = global.fetch; global.fetch = async () => ({ ok: true, status: 200, json: async () => ({ accounts: [ { id: 'muted-account', name: 'Muted Account', currency: 'USD', balance: '100.00', transactions: [ { id: 'muted-tx-1', amount: '-12.34', posted: 1772323200, description: 'Muted charge' }, ], }, { id: 'tracked-account', name: 'Tracked Account', currency: 'USD', balance: '200.00', transactions: [ { id: 'tracked-tx-1', amount: '-56.78', posted: 1772323200, description: 'Tracked charge' }, ], }, ], }), }); try { const result = await syncDataSource(db, userId, dataSourceId); assert.equal(result.transactionsNew, 1); } finally { global.fetch = originalFetch; } const mutedTransactions = db.prepare('SELECT COUNT(*) AS count FROM transactions WHERE account_id = ?').get(mutedAccountId).count; assert.equal(mutedTransactions, 0); const trackedAccount = db.prepare(` SELECT id, monitored FROM financial_accounts WHERE user_id = ? AND data_source_id = ? AND provider_account_id = 'tracked-account' `).get(userId, dataSourceId); assert.equal(trackedAccount.monitored, 1); const trackedTransactions = db.prepare('SELECT COUNT(*) AS count FROM transactions WHERE account_id = ?').get(trackedAccount.id).count; assert.equal(trackedTransactions, 1); }); test('SimpleFIN pending transactions: insert, settle in place, then prune orphans', async () => { const db = getDb(); const userId = createUser(db, 'pending-lifecycle'); const dataSourceId = createSource(db, userId); const accountId = createAccount(db, userId, dataSourceId, 'pending-acct', true); const originalFetch = global.fetch; const mockAccounts = (transactions) => { global.fetch = async () => ({ ok: true, status: 200, json: async () => ({ accounts: [{ id: 'pending-acct', name: 'Pending Acct', currency: 'USD', balance: '500.00', transactions }], }), }); }; try { // 1. First sync — one pending charge (posted=0, pending:true) mockAccounts([ { id: 'tx-1', amount: '-42.00', posted: 0, pending: true, description: 'Coffee (pending)' }, ]); let result = await syncDataSource(db, userId, dataSourceId); assert.equal(result.transactionsNew, 1); let row = db.prepare('SELECT pending, posted_date FROM transactions WHERE provider_transaction_id = ?') .get('simplefin:pending-acct:tx-1'); assert.equal(row.pending, 1, 'stored as pending'); assert.equal(row.posted_date, null, 'pending has no posted_date'); // 2. Second sync — same id now settled (posted timestamp, pending gone) mockAccounts([ { id: 'tx-1', amount: '-42.50', posted: 1772323200, description: 'Coffee' }, ]); result = await syncDataSource(db, userId, dataSourceId); assert.equal(result.transactionsNew, 0, 'no new row — updated in place'); assert.equal(result.transactionsPosted, 1, 'counted as settled'); const rows = db.prepare('SELECT pending, posted_date, amount FROM transactions WHERE account_id = ?').all(accountId); assert.equal(rows.length, 1, 'no duplicate row created on settle'); assert.equal(rows[0].pending, 0, 'flipped to posted'); assert.equal(rows[0].posted_date, '2026-03-01', 'gained posted_date'); assert.equal(rows[0].amount, -4250, 'amount refreshed to settled value'); // 3. Add a fresh pending charge, then a sync where it vanishes (re-posted under a new id) mockAccounts([ { id: 'tx-2', amount: '-9.99', posted: 0, pending: true, description: 'Snack (pending)' }, ]); await syncDataSource(db, userId, dataSourceId); assert.equal( db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE account_id = ? AND pending = 1").get(accountId).n, 1, 'pending tx-2 stored', ); // tx-2 disappears from the feed (settled under a new id); orphan prune should clear it mockAccounts([ { id: 'tx-3', amount: '-9.99', posted: 1772323200, description: 'Snack' }, ]); result = await syncDataSource(db, userId, dataSourceId); assert.equal(result.pendingCleared, 1, 'orphaned pending row pruned'); assert.equal( db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE account_id = ? AND pending = 1").get(accountId).n, 0, 'no stale pending rows remain', ); } finally { global.fetch = originalFetch; } });