From 840620efe22c2e6f847b6f952f62f81d0d556d2d Mon Sep 17 00:00:00 2001 From: null Date: Sat, 6 Jun 2026 16:34:20 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20v0.93=20=E2=80=94=20stable=20provider?= =?UTF-8?q?=20keys,=20per-payment=20interest=20tracking=20with=20once-per-?= =?UTF-8?q?month=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/database.js | 85 +++++++++++++++++++++++++ routes/bills.js | 21 +++---- routes/matches.js | 11 ++-- routes/payments.js | 92 +++++++++++++++++----------- services/bankSyncService.js | 2 +- services/billMerchantRuleService.js | 24 ++++---- services/billsService.js | 49 +++++++++++---- services/simplefinService.js | 11 ++-- services/spreadsheetImportService.js | 28 ++++----- services/trackerService.js | 10 +-- services/transactionMatchService.js | 35 ++++++----- 11 files changed, 245 insertions(+), 123 deletions(-) diff --git a/db/database.js b/db/database.js index 8766a1b..66d4af6 100644 --- a/db/database.js +++ b/db/database.js @@ -2984,6 +2984,78 @@ function runMigrations() { `); console.log('[v0.92] WebAuthn tables + users columns added'); } + }, + { + version: 'v0.93', + description: 'bills: interest_accrued_month; payments: interest_delta; transactions: stable provider key + dedupe index', + dependsOn: ['v0.92'], + run: function() { + // 1. Track the calendar month when interest was last applied to a debt bill + // so computeBalanceDelta can skip interest if it was already charged this month. + const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); + if (!billCols.includes('interest_accrued_month')) { + db.exec('ALTER TABLE bills ADD COLUMN interest_accrued_month TEXT'); + console.log('[v0.93] bills.interest_accrued_month column added'); + } + + // 2. Track the interest component of each payment separately so delete/restore + // can handle it without double-charging interest. + const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); + if (!paymentCols.includes('interest_delta')) { + db.exec('ALTER TABLE payments ADD COLUMN interest_delta REAL'); + console.log('[v0.93] payments.interest_delta column added'); + } + + // 3. Strip the data_source_id from existing provider_transaction_id keys so + // they survive disconnect/reconnect. Old: "simplefin:{dsId}:{acctId}:{txId}" + // New: "simplefin:{acctId}:{txId}". + // Only rows where the segment after "simplefin:" is a numeric id are migrated. + db.exec(` + UPDATE transactions + SET provider_transaction_id = + 'simplefin:' || SUBSTR( + provider_transaction_id, + INSTR(SUBSTR(provider_transaction_id, 11), ':') + 11 + ) + WHERE provider_transaction_id LIKE 'simplefin:%' + AND CAST( + SUBSTR(provider_transaction_id, 11, + INSTR(SUBSTR(provider_transaction_id, 11), ':') - 1) + AS INTEGER) > 0 + `); + console.log('[v0.93] transactions: stripped data_source_id from provider_transaction_id'); + + // 4. Dedup: after the key change, users who disconnected and reconnected now + // have duplicate (user_id, provider_transaction_id) pairs. Keep the best row + // (prefer linked rows; break ties by most-recent created_at). + db.exec(` + DELETE FROM transactions + WHERE id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY user_id, provider_transaction_id + ORDER BY (data_source_id IS NULL) ASC, created_at DESC + ) AS rn + FROM transactions + WHERE provider_transaction_id IS NOT NULL + ) + WHERE rn > 1 + ) + `); + console.log('[v0.93] transactions: removed duplicate provider keys from disconnect/reconnect'); + + // 5. Replace the old dedupe index (data_source_id, provider_transaction_id) + // with a user-scoped one (user_id, provider_transaction_id) so reconnect + // with a new data_source_id still deduplicates correctly. + db.exec(` + DROP INDEX IF EXISTS idx_transactions_provider_dedupe; + CREATE UNIQUE INDEX idx_transactions_provider_dedupe + ON transactions (user_id, provider_transaction_id) + WHERE provider_transaction_id IS NOT NULL; + `); + console.log('[v0.93] transactions: dedupe index changed to (user_id, provider_transaction_id)'); + } } ]; @@ -3327,6 +3399,19 @@ function getDbPath() { // Rollback SQL definitions const ROLLBACK_SQL_MAP = { + 'v0.93': { + description: 'bills: interest_accrued_month; payments: interest_delta; transactions: stable provider key', + sql: [ + 'ALTER TABLE bills DROP COLUMN IF EXISTS interest_accrued_month', + 'ALTER TABLE payments DROP COLUMN IF EXISTS interest_delta', + // Restore the old (data_source_id, provider_transaction_id) dedupe index. + // The key format change and deleted duplicates cannot be reversed. + 'DROP INDEX IF EXISTS idx_transactions_provider_dedupe', + `CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_provider_dedupe + ON transactions (data_source_id, provider_transaction_id) + WHERE provider_transaction_id IS NOT NULL`, + ] + }, 'v0.44': { description: 'performance: add missing indexes for frequently queried columns', sql: [ diff --git a/routes/bills.js b/routes/bills.js index 927bbbf..77b0978 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -9,6 +9,7 @@ const { sanitizeTemplateData, validateBillData, computeBalanceDelta, + applyBalanceDelta, } = require('../services/billsService'); const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService'); const { standardizeError } = require('../middleware/errorFormatter'); @@ -683,13 +684,10 @@ router.post('/:id/toggle-paid', (req, res) => { const balCalc = computeBalanceDelta(bill, payment.amount); const result = db.prepare( - 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(billId, payment.amount, payment.paid_date, method, notes, balCalc?.balance_delta ?? null, payment.payment_source); + 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ).run(billId, payment.amount, payment.paid_date, method, notes, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment.payment_source); - if (balCalc) { - db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") - .run(balCalc.new_balance, billId); - } + applyBalanceDelta(db, billId, balCalc); res.status(201).json({ success: true, isPaid: true, @@ -1138,13 +1136,12 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => { if (validIds.length === 0) return res.status(400).json(standardizeError('No valid transaction ids provided', 'VALIDATION_ERROR')); - const getBill = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL'); + const getBill = db.prepare('SELECT current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND deleted_at IS NULL'); const getTx = db.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ? AND amount < 0'); const insertPayment = db.prepare(` - INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta) - VALUES (?, ?, ?, 'provider_sync', ?, ?) + INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta, interest_delta) + VALUES (?, ?, ?, 'provider_sync', ?, ?, ?) `); - const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?"); const updateTx = db.prepare(` UPDATE transactions SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now') WHERE id = ? `); @@ -1165,9 +1162,9 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => { const billRow = getBill.get(billId); const balCalc = billRow ? computeBalanceDelta(billRow, amount) : null; - const result = insertPayment.run(billId, amount, paidDate, txId, balCalc?.balance_delta ?? null); + const result = insertPayment.run(billId, amount, paidDate, txId, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null); if (result.changes > 0) { - if (balCalc) updateBalance.run(balCalc.new_balance, billId); + applyBalanceDelta(db, billId, balCalc); updateTx.run(billId, txId); imported++; diff --git a/routes/matches.js b/routes/matches.js index 45f35ee..2bcec8f 100644 --- a/routes/matches.js +++ b/routes/matches.js @@ -1,7 +1,7 @@ const router = require('express').Router(); const { standardizeError } = require('../middleware/errorFormatter'); const { getDb } = require('../db/database'); -const { computeBalanceDelta } = require('../services/billsService'); +const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService'); const { listMatchSuggestions, rejectMatchSuggestion, @@ -62,13 +62,10 @@ router.post('/confirm', (req, res) => { db.exec('BEGIN'); const payResult = db.prepare( - "INSERT INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta) VALUES (?, ?, ?, 'transaction_match', ?, ?)" - ).run(billId, amount, paidDate, txId, balCalc?.balance_delta ?? null); + "INSERT INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta, interest_delta) VALUES (?, ?, ?, 'transaction_match', ?, ?, ?)" + ).run(billId, amount, paidDate, txId, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null); - if (balCalc) { - db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") - .run(balCalc.new_balance, billId); - } + applyBalanceDelta(db, billId, balCalc); db.prepare(` UPDATE transactions diff --git a/routes/payments.js b/routes/payments.js index b610306..76a3dc9 100644 --- a/routes/payments.js +++ b/routes/payments.js @@ -2,7 +2,7 @@ const express = require('express'); const { standardizeError } = require('../middleware/errorFormatter'); const router = require('express').Router(); const { getDb } = require('../db/database'); -const { computeBalanceDelta } = require('../services/billsService'); +const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService'); const { validatePaymentInput } = require('../services/paymentValidation'); const { getCycleRange, resolveDueDate } = require('../services/statusService'); @@ -130,13 +130,10 @@ router.post('/', (req, res) => { const balCalc = computeBalanceDelta(bill, payment.amount); const result = db.prepare( - 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, payment.payment_source); + 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment.payment_source); - if (balCalc) { - db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") - .run(balCalc.new_balance, bill.id); - } + applyBalanceDelta(db, bill.id, balCalc); res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)); }); @@ -172,13 +169,10 @@ router.post('/quick', (req, res) => { const balCalc = computeBalanceDelta(bill, payAmount); const result = db.prepare( - 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(bill.id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null, paySource); + 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ).run(bill.id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, paySource); - if (balCalc) { - db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") - .run(balCalc.new_balance, bill.id); - } + applyBalanceDelta(db, bill.id, balCalc); res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)); }); @@ -230,8 +224,8 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => { const balCalc = computeBalanceDelta(bill, suggestedPayment.amount); const result = db.prepare(` - INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run( bill.id, suggestedPayment.amount, @@ -239,13 +233,11 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => { 'autopay', 'Confirmed autopay suggestion', balCalc?.balance_delta ?? null, + balCalc?.interest_delta ?? null, 'manual', ); - if (balCalc) { - db.prepare("UPDATE bills SET current_balance = ?, updated_at=datetime('now') WHERE id=?") - .run(balCalc.new_balance, bill.id); - } + applyBalanceDelta(db, bill.id, balCalc); db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?') .run(req.user.id, bill.id, ym.year, ym.month); @@ -309,10 +301,9 @@ router.post('/bulk', (req, res) => { } const insert = db.prepare( - 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ); - const getBillForBalance = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL'); - const applyBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?"); + const getBillForBalance = db.prepare('SELECT current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL'); // Prepare statement for duplicate checking const duplicateCheckStmt = db.prepare( @@ -350,8 +341,8 @@ router.post('/bulk', (req, res) => { } const balCalc = computeBalanceDelta(billRow, parsedAmt); - const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, payment_source); - if (balCalc) applyBalance.run(balCalc.new_balance, bill_id); + const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment_source); + applyBalanceDelta(db, bill_id, balCalc); created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid)); } @@ -383,18 +374,27 @@ router.put('/:id', (req, res) => { let nextBalanceDelta = existing.balance_delta; const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(existing.bill_id, req.user.id); + let nextInterestDelta = existing.interest_delta ?? null; if (bill) { + // Reverse only the *payment* portion of the stored delta (not the interest component) + // so that interest already charged this month is not double-counted. For legacy rows + // where interest_delta is NULL, fall back to reversing the full delta as before. + const interestPortion = existing.interest_delta ?? 0; + const paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null; let restoredBalance = bill.current_balance; - if (existing.balance_delta != null && bill.current_balance != null) { - restoredBalance = Math.max(0, Math.round((bill.current_balance - existing.balance_delta) * 100) / 100); + if (paymentPortion != null && bill.current_balance != null) { + restoredBalance = Math.max(0, Math.round((bill.current_balance - paymentPortion) * 100) / 100); } + // interest_accrued_month is still set to this month (if interest was charged) so + // computeBalanceDelta will skip interest when the payment is within the same month, + // and charge a fresh month of interest if editing into a new calendar month. const balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount); - nextBalanceDelta = balCalc?.balance_delta ?? null; + nextBalanceDelta = balCalc?.balance_delta ?? null; + nextInterestDelta = balCalc?.interest_delta ?? null; if (balCalc) { - db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") - .run(balCalc.new_balance, existing.bill_id); - } else if (existing.balance_delta != null && restoredBalance != null) { + applyBalanceDelta(db, existing.bill_id, balCalc); + } else if (paymentPortion != null && restoredBalance != null) { db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") .run(restoredBalance, existing.bill_id); } @@ -402,8 +402,8 @@ router.put('/:id', (req, res) => { db.prepare(` UPDATE payments SET - amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, payment_source = ?, - updated_at = datetime('now') + amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, interest_delta = ?, + payment_source = ?, updated_at = datetime('now') WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL) `).run( @@ -412,6 +412,7 @@ router.put('/:id', (req, res) => { method !== undefined ? (method || null) : existing.method, notes !== undefined ? (notes || null) : existing.notes, nextBalanceDelta, + nextInterestDelta, nextPaymentSource, req.params.id, req.user.id, @@ -427,12 +428,20 @@ router.delete('/:id', (req, res) => { if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res); - // Reverse any balance delta that was stored when this payment was created + // Reverse any balance delta that was stored when this payment was created. + // If this payment was the one that charged interest this month, clear + // interest_accrued_month so the next payment can re-accrue correctly. if (payment.balance_delta != null) { const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id); if (bill?.current_balance != null) { const restored = Math.max(0, Math.round((bill.current_balance - payment.balance_delta) * 100) / 100); - db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, payment.bill_id); + db.prepare(` + UPDATE bills + SET current_balance = ?, + interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END, + updated_at = datetime('now') + WHERE id = ? + `).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id); } } @@ -447,12 +456,21 @@ router.post('/:id/restore', (req, res) => { if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id')); if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res); - // Re-apply the balance delta (undo the reversal done on delete) + // Re-apply the balance delta (undo the reversal done on delete). + // If this payment originally charged interest, restore interest_accrued_month + // to the month of the payment so future same-month payments skip interest. if (payment.balance_delta != null) { const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id); if (bill?.current_balance != null) { - const reapplied = Math.max(0, Math.round((bill.current_balance + payment.balance_delta) * 100) / 100); - db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(reapplied, payment.bill_id); + const reapplied = Math.max(0, Math.round((bill.current_balance + payment.balance_delta) * 100) / 100); + const interestMonth = payment.interest_delta != null ? (payment.paid_date?.slice(0, 7) ?? null) : null; + db.prepare(` + UPDATE bills + SET current_balance = ?, + interest_accrued_month = CASE WHEN ? IS NOT NULL THEN ? ELSE interest_accrued_month END, + updated_at = datetime('now') + WHERE id = ? + `).run(reapplied, interestMonth, interestMonth, payment.bill_id); } } diff --git a/services/bankSyncService.js b/services/bankSyncService.js index 971d73d..1e3533d 100644 --- a/services/bankSyncService.js +++ b/services/bankSyncService.js @@ -109,7 +109,7 @@ async function runSync(db, userId, dataSource, { days } = {}) { for (const rawTx of (rawAccount.transactions || [])) { const txRow = normalizeTransaction( - rawTx, localAccount.id, dataSource.id, userId, dataSource.id, rawAccount.id, + rawTx, localAccount.id, dataSource.id, userId, rawAccount.id, rawAccount.currency, ); const outcome = insertTransactionIfNew(db, txRow); if (outcome === 'inserted') transactionsNew += 1; diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.js index 3cc51c0..392e737 100644 --- a/services/billMerchantRuleService.js +++ b/services/billMerchantRuleService.js @@ -1,7 +1,7 @@ 'use strict'; const { normalizeMerchant } = require('./subscriptionService'); -const { computeBalanceDelta } = require('./billsService'); +const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { getUserSettings } = require('./userSettings'); // Word-boundary merchant match — requires the rule to appear as complete word(s) @@ -87,12 +87,11 @@ function applyMerchantRules(db, userId) { const userSettings = (() => { try { return getUserSettings(userId); } catch { return {}; } })(); const globalGraceDays = parseInt(userSettings.bank_late_attribution_days, 10) || 0; - const getBill = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL'); + const getBill = db.prepare('SELECT current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND deleted_at IS NULL'); const insertPayment = db.prepare(` - INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta) - VALUES (?, ?, ?, 'provider_sync', ?, ?) + INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta, interest_delta) + VALUES (?, ?, ?, 'provider_sync', ?, ?, ?) `); - const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?"); const updateTx = db.prepare(` UPDATE transactions SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now') @@ -121,9 +120,9 @@ function applyMerchantRules(db, userId) { const bill = getBill.get(rule.bill_id); const balCalc = bill ? computeBalanceDelta(bill, amount) : null; - const result = insertPayment.run(rule.bill_id, amount, paidDate, tx.id, balCalc?.balance_delta ?? null); + const result = insertPayment.run(rule.bill_id, amount, paidDate, tx.id, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null); if (result.changes > 0) { - if (balCalc) updateBalance.run(balCalc.new_balance, rule.bill_id); + applyBalanceDelta(db, rule.bill_id, balCalc); updateTx.run(rule.bill_id, tx.id, userId); matched++; matchedBills.set(rule.bill_id, rule.bill_name || `Bill #${rule.bill_id}`); @@ -221,12 +220,11 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { if (txRows.length === 0) return { added: 0 }; const billMeta = db.prepare('SELECT name, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL').get(billId); - const getBill = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL'); + const getBill = db.prepare('SELECT current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND deleted_at IS NULL'); const insertPayment = db.prepare(` - INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta) - VALUES (?, ?, ?, 'provider_sync', ?, ?) + INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta, interest_delta) + VALUES (?, ?, ?, 'provider_sync', ?, ?, ?) `); - const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?"); const updateTx = db.prepare(` UPDATE transactions SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now') @@ -249,9 +247,9 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { const bill = getBill.get(billId); const balCalc = bill ? computeBalanceDelta(bill, amount) : null; - const result = insertPayment.run(billId, amount, paidDate, tx.id, balCalc?.balance_delta ?? null); + const result = insertPayment.run(billId, amount, paidDate, tx.id, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null); if (result.changes > 0) { - if (balCalc) updateBalance.run(balCalc.new_balance, billId); + applyBalanceDelta(db, billId, balCalc); updateTx.run(billId, tx.id, userId); added++; diff --git a/services/billsService.js b/services/billsService.js index c7cd30d..2e18431 100644 --- a/services/billsService.js +++ b/services/billsService.js @@ -456,14 +456,15 @@ function validateCycleDayOnly(cycleType, cycleDay) { return validateCycleDay(cycleType, cycleDay); } -/** - * Computes how a payment affects a debt bill's current_balance, accounting for - * one month of interest accrual. - * - * Returns { new_balance, balance_delta } where balance_delta is negative when - * the balance was reduced (typical case). Returns null when the bill has no - * trackable balance. - */ +// Computes how a payment affects a debt bill's current_balance. +// Interest is applied at most once per calendar month: if bill.interest_accrued_month +// already equals the current month, no interest is added this call. +// +// Returns null when the bill has no trackable balance. +// Otherwise returns: +// { new_balance, balance_delta, interest_delta, interest_accrued_month } +// where interest_delta and interest_accrued_month are null when no interest +// was charged this call (so callers can use COALESCE to leave the DB column alone). function computeBalanceDelta(bill, paymentAmount) { const bal = Number(bill.current_balance); const rate = Number(bill.interest_rate) || 0; @@ -472,12 +473,33 @@ function computeBalanceDelta(bill, paymentAmount) { if (!Number.isFinite(bal) || bal <= 0) return null; if (!Number.isFinite(amt) || amt <= 0) return null; - const monthlyInterest = bal * (rate / 100 / 12); - const raw = bal + monthlyInterest - amt; - const newBalance = Math.round(Math.max(0, raw) * 100) / 100; - const delta = Math.round((newBalance - bal) * 100) / 100; + const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM" + const applyInterest = rate > 0 && bill.interest_accrued_month !== currentMonth; + const interestDelta = applyInterest ? Math.round(bal * (rate / 100 / 12) * 100) / 100 : 0; - return { new_balance: newBalance, balance_delta: delta }; + const raw = bal + interestDelta - amt; + const newBalance = Math.round(Math.max(0, raw) * 100) / 100; + const delta = Math.round((newBalance - bal) * 100) / 100; + + return { + new_balance: newBalance, + balance_delta: delta, + interest_delta: applyInterest ? interestDelta : null, + interest_accrued_month: applyInterest ? currentMonth : null, + }; +} + +// Updates current_balance (and interest_accrued_month when interest was charged) +// after a payment. Uses COALESCE so a null interest_accrued_month leaves the column alone. +function applyBalanceDelta(db, billId, balCalc) { + if (!balCalc) return; + db.prepare(` + UPDATE bills + SET current_balance = ?, + interest_accrued_month = COALESCE(?, interest_accrued_month), + updated_at = datetime('now') + WHERE id = ? + `).run(balCalc.new_balance, balCalc.interest_accrued_month, billId); } module.exports = { @@ -498,4 +520,5 @@ module.exports = { validateBillData, validateCycleDayOnly, computeBalanceDelta, + applyBalanceDelta, }; diff --git a/services/simplefinService.js b/services/simplefinService.js index 899fda8..31411be 100644 --- a/services/simplefinService.js +++ b/services/simplefinService.js @@ -162,7 +162,10 @@ function normalizeAccount(rawAccount, dataSourceId, userId) { }; } -function normalizeTransaction(rawTx, localAccountId, dataSourceId, userId, connId, accountId) { +// accountCurrency: currency string from the parent account (e.g. "USD", "EUR"). +// accountId: raw SimpleFIN account id — used in the stable dedup key so the key +// survives disconnect/reconnect (data_source_id is intentionally omitted). +function normalizeTransaction(rawTx, localAccountId, dataSourceId, userId, accountId, accountCurrency) { const amount = Math.round(parseFloat(rawTx.amount) * 100); const postedDate = rawTx.posted ? new Date(rawTx.posted * 1000).toISOString().slice(0, 10) @@ -171,8 +174,8 @@ function normalizeTransaction(rawTx, localAccountId, dataSourceId, userId, connI ? new Date(rawTx['transacted_at'] * 1000).toISOString() : null; - // Format: simplefin:{data_source_id}:{simplefin_account_id}:{tx.id} - const providerTxId = `simplefin:${connId}:${accountId}:${rawTx.id}`; + // Format: simplefin:{simplefin_account_id}:{tx.id} (no data_source_id — stable across reconnects) + const providerTxId = `simplefin:${accountId}:${rawTx.id}`; return { user_id: userId, @@ -183,7 +186,7 @@ function normalizeTransaction(rawTx, localAccountId, dataSourceId, userId, connI posted_date: postedDate, transacted_at: transactedAt, amount: Number.isFinite(amount) ? amount : 0, - currency: 'USD', + currency: accountCurrency ? String(accountCurrency).slice(0, 10) : 'USD', description: rawTx.description ? String(rawTx.description).slice(0, 500) : null, payee: rawTx.payee ? String(rawTx.payee).slice(0, 255) : null, memo: rawTx.memo ? String(rawTx.memo).slice(0, 500) : null, diff --git a/services/spreadsheetImportService.js b/services/spreadsheetImportService.js index df093d7..144bdff 100644 --- a/services/spreadsheetImportService.js +++ b/services/spreadsheetImportService.js @@ -14,7 +14,7 @@ const xlsx = require('xlsx'); const crypto = require('crypto'); const { getDb, ensureUserDefaultCategories } = require('../db/database'); -const { computeBalanceDelta } = require('./billsService'); +const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); // ─── Constants ──────────────────────────────────────────────────────────────── @@ -1439,20 +1439,17 @@ function createPaymentFromImport(db, billId, amount, paidDate, notes, allowOverw // Read the bill fresh so sequential imports for the same bill chain correctly // (each payment reduces current_balance before the next one is computed). const bill = db.prepare( - 'SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL' + 'SELECT current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND deleted_at IS NULL' ).get(billId); const balCalc = bill ? computeBalanceDelta(bill, amount) : null; db.prepare(` - INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) - VALUES (?, ?, ?, ?, ?, ?, 'file_import') - `).run(billId, amount, paidDate, null, notes, balCalc?.balance_delta ?? null); + INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) + VALUES (?, ?, ?, ?, ?, ?, ?, 'file_import') + `).run(billId, amount, paidDate, null, notes, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null); - if (balCalc) { - db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") - .run(balCalc.new_balance, billId); - } + applyBalanceDelta(db, billId, balCalc); return { result: 'created', existing_created_at: null }; } @@ -1650,7 +1647,7 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv } else if (action === 'create_payment') { const billId = decision.bill_id; - const bill = db.prepare('SELECT id, current_balance, interest_rate FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId); + const bill = db.prepare('SELECT id, current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId); if (!bill) throw new Error(`Bill id=${billId} not found or inactive`); const payAmount = decision.payment_amount ?? amount; @@ -1686,14 +1683,11 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv const balCalcCp = computeBalanceDelta(bill, payAmount); db.prepare(` - INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) - VALUES (?, ?, ?, ?, ?, ?, 'file_import') - `).run(billId, payAmount, payDate, decision.payment_method ?? null, decision.payment_notes ?? null, balCalcCp?.balance_delta ?? null); + INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) + VALUES (?, ?, ?, ?, ?, ?, ?, 'file_import') + `).run(billId, payAmount, payDate, decision.payment_method ?? null, decision.payment_notes ?? null, balCalcCp?.balance_delta ?? null, balCalcCp?.interest_delta ?? null); - if (balCalcCp) { - db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") - .run(balCalcCp.new_balance, billId); - } + applyBalanceDelta(db, billId, balCalcCp); summary.created++; summary.details.push({ row_id, action, result: 'created', bill_id: billId, paid_date: payDate, amount: payAmount }); diff --git a/services/trackerService.js b/services/trackerService.js index 3cb502d..048f7cd 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -3,7 +3,7 @@ const { getDb } = require('../db/database'); const { buildTrackerRow, getCycleRange, resolveDueDate, roundMoney } = require('./statusService'); const { getUserSettings } = require('./userSettings'); -const { computeBalanceDelta } = require('./billsService'); +const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { computeAmountSuggestion } = require('./amountSuggestionService'); const DEFAULT_PENDING_DAYS = 3; @@ -216,8 +216,8 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, const balCalc = computeBalanceDelta(bill, suggestedAmount); const result = db.prepare(` - INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run( bill.id, suggestedAmount, @@ -225,12 +225,12 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, 'autopay', 'Auto-marked paid on due date', balCalc?.balance_delta ?? null, + balCalc?.interest_delta ?? null, 'manual', ); if (balCalc) { - db.prepare("UPDATE bills SET current_balance = ?, updated_at=datetime('now') WHERE id=?") - .run(balCalc.new_balance, bill.id); + applyBalanceDelta(db, bill.id, balCalc); bill.current_balance = balCalc.new_balance; } payments.push(db.prepare(` diff --git a/services/transactionMatchService.js b/services/transactionMatchService.js index 103bef4..a027a73 100644 --- a/services/transactionMatchService.js +++ b/services/transactionMatchService.js @@ -1,7 +1,7 @@ 'use strict'; const { getDb } = require('../db/database'); -const { computeBalanceDelta } = require('./billsService'); +const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { decorateTransaction, getTransactionForUser, @@ -105,18 +105,22 @@ function restorePaymentBalance(db, payment) { if (bill?.current_balance == null) return; const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100); - db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") - .run(restored, bill.id); + // Clear interest_accrued_month when reversing a payment that charged interest, + // so the re-applied payment can accrue interest fresh. + db.prepare(` + UPDATE bills + SET current_balance = ?, + interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END, + updated_at = datetime('now') + WHERE id = ? + `).run(restored, payment.interest_delta != null ? 1 : 0, bill.id); } function applyPaymentBalance(db, bill, amount) { const freshBill = db.prepare('SELECT * FROM bills WHERE id = ?').get(bill.id) || bill; const balCalc = computeBalanceDelta(freshBill, amount); - if (balCalc) { - db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") - .run(balCalc.new_balance, bill.id); - } - return balCalc?.balance_delta ?? null; + applyBalanceDelta(db, bill.id, balCalc); + return { balance_delta: balCalc?.balance_delta ?? null, interest_delta: balCalc?.interest_delta ?? null }; } function buildMatchPaymentNotes(transaction, bill) { @@ -141,7 +145,7 @@ function createOrUpdateMatchPayment(db, userId, transaction, bill) { if (existingPayment) { restorePaymentBalance(db, existingPayment); - const balanceDelta = applyPaymentBalance(db, bill, amount); + const { balance_delta, interest_delta } = applyPaymentBalance(db, bill, amount); db.prepare(` UPDATE payments SET bill_id = ?, @@ -150,6 +154,7 @@ function createOrUpdateMatchPayment(db, userId, transaction, bill) { method = ?, notes = ?, balance_delta = ?, + interest_delta = ?, payment_source = ?, updated_at = datetime('now') WHERE id = ? @@ -159,25 +164,27 @@ function createOrUpdateMatchPayment(db, userId, transaction, bill) { paidDate, MATCH_PAYMENT_METHOD, notes, - balanceDelta, + balance_delta, + interest_delta, MATCH_PAYMENT_SOURCE, existingPayment.id, ); return existingPayment.id; } - const balanceDelta = applyPaymentBalance(db, bill, amount); + const { balance_delta, interest_delta } = applyPaymentBalance(db, bill, amount); const result = db.prepare(` INSERT INTO payments - (bill_id, amount, paid_date, method, notes, balance_delta, payment_source, transaction_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source, transaction_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( bill.id, amount, paidDate, MATCH_PAYMENT_METHOD, notes, - balanceDelta, + balance_delta, + interest_delta, MATCH_PAYMENT_SOURCE, transaction.id, );