diff --git a/HISTORY.md b/HISTORY.md index 3146be8..7168df4 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -5,6 +5,8 @@ - **Tracker table sorting** — The Tracker page now supports URL-backed sorting by bill name, due date, expected amount, last-month paid, paid amount, remaining amount, paid date, and status. Desktop table headers are clickable with active direction indicators, while the filter bar provides a compact sort selector for mobile and tablet. Status sorting uses the tracker lifecycle order (missed, late, due soon, upcoming, autodraft, paid, skipped) instead of plain alphabetical labels, and manual bill reordering is paused while a sorted view is active. +- **Bank payments override provisional manual tracker payments** — Manual payments entered from the Tracker count immediately while waiting for bank sync. When a matching bank-backed payment clears for the same bill cycle, the bank payment becomes the accounting source of truth and the manual payment is preserved as history only with override metadata and a BillModal badge/note. Overridden manual payments are excluded consistently from Tracker, Summary, Calendar, Analytics, Categories, starting amount summaries, drift checks, notifications, status counts, bank pending deductions, trends, overdue checks, and debt balance deltas. If the bank match is undone, the provisional manual payment is reactivated. + - **Improved unmatch flow — choice dialog + bulk deselect** — Clicking Unmatch on a linked transaction in the Bill modal now opens a two-option choice dialog instead of immediately removing the match. Option 1 ("Unmatch this payment only") confirms via a single AlertDialog and removes only that transaction. Option 2 ("Review all similar matches") fetches all linked transactions for the bill whose payee normalizes to the same prefix and opens a checklist dialog where each similar match is pre-checked. Users can deselect individual transactions to keep them matched, use All/None quick-selects, and optionally check a "Remove merchant rule" checkbox (shown only when a merchant rule matching the payee pattern exists on the bill). The confirm button shows the count of selected transactions and is disabled when nothing is selected. New backend endpoint `POST /api/transactions/unmatch-bulk` handles mixed `provider_sync` (restores balance + soft-deletes payment) and `transaction_match` (standard unmatch service) entries in a single database transaction. - **Service Catalog page for subscription matching** — The full known-service catalog moved out of the main Subscriptions page and into its own dedicated route at `/subscriptions/catalog`. The catalog now acts as an advanced matching tool instead of a second subscriptions list: tracked entries appear under a "Tracking" header with price drift indicators, each linked entry can be edited in BillModal, Re-link opens a searchable dialog to swap or remove the catalog link, and Custom bank descriptors let users add exact payee strings from their bank statements to improve future matching. Untracked catalog entries remain searchable/filterable and can still be tracked individually or in bulk. The Subscriptions page now shows a compact "Improve Matching" card that links to the Service Catalog when users need to tune descriptors, fix a wrong service link, or connect an existing bill to a known service. Catalog load failures now show both inline error state and toast feedback. New migration v0.96 adds `bills.catalog_id FK` (backfilled for existing subscriptions via name matching) and the `user_catalog_descriptors` table for per-user custom payee strings; user descriptors are merged into `loadCatalog` so they improve auto-matching for only that user's account. diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index 3bb88a9..9293548 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -83,6 +83,10 @@ function isTransactionLinkedPayment(payment) { return payment?.payment_source === 'transaction_match' || payment?.transaction_id != null; } +function isHistoryOnlyPayment(payment) { + return !!payment?.accounting_excluded; +} + function paymentSourceLabel(source) { const labels = { manual: 'Manual', @@ -1052,17 +1056,28 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
{payments.map(payment => { const linkedPayment = isTransactionLinkedPayment(payment); + const historyOnly = isHistoryOnlyPayment(payment); return ( -
+
-

{fmt(payment.amount)}

+

{fmt(payment.amount)}

{paymentSourceLabel(payment.payment_source)} + {historyOnly && ( + + History only + + )}

{fmtDate(payment.paid_date)} · {payment.method || 'manual'} @@ -1072,7 +1087,11 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa )}

- {linkedPayment ? ( + {historyOnly ? ( + + Overridden + + ) : linkedPayment ? ( Matched diff --git a/db/database.js b/db/database.js index 326c130..1ea2d8d 100644 --- a/db/database.js +++ b/db/database.js @@ -3271,6 +3271,33 @@ function runMigrations() { console.log('[v0.97] subscription recommendation feedback table ensured'); } }, + { + version: 'v0.98', + description: 'payments: bank override metadata for provisional manual payments', + run() { + const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); + if (!cols.includes('accounting_excluded')) { + db.exec('ALTER TABLE payments ADD COLUMN accounting_excluded INTEGER NOT NULL DEFAULT 0'); + } + if (!cols.includes('exclusion_reason')) { + db.exec('ALTER TABLE payments ADD COLUMN exclusion_reason TEXT'); + } + if (!cols.includes('excluded_at')) { + db.exec('ALTER TABLE payments ADD COLUMN excluded_at TEXT'); + } + if (!cols.includes('overridden_by_payment_id')) { + db.exec('ALTER TABLE payments ADD COLUMN overridden_by_payment_id INTEGER'); + } + db.exec(` + CREATE INDEX IF NOT EXISTS idx_payments_accounting_active + ON payments(bill_id, paid_date, deleted_at, accounting_excluded); + CREATE INDEX IF NOT EXISTS idx_payments_overridden_by + ON payments(overridden_by_payment_id) + WHERE overridden_by_payment_id IS NOT NULL; + `); + console.log('[v0.98] payment accounting override columns ensured'); + } + }, ]; // ── users: notification columns ─────────────────────────────────────────── @@ -3615,6 +3642,17 @@ function getDbPath() { // Rollback SQL definitions const ROLLBACK_SQL_MAP = { + 'v0.98': { + description: 'payments: bank override metadata for provisional manual payments', + sql: [ + 'DROP INDEX IF EXISTS idx_payments_overridden_by', + 'DROP INDEX IF EXISTS idx_payments_accounting_active', + 'ALTER TABLE payments DROP COLUMN IF EXISTS overridden_by_payment_id', + 'ALTER TABLE payments DROP COLUMN IF EXISTS excluded_at', + 'ALTER TABLE payments DROP COLUMN IF EXISTS exclusion_reason', + 'ALTER TABLE payments DROP COLUMN IF EXISTS accounting_excluded', + ] + }, 'v0.97': { description: 'subscription recommendation feedback: per-user learning signals', sql: [ diff --git a/db/schema.sql b/db/schema.sql index a5ab7f5..5ff26dc 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -60,6 +60,10 @@ CREATE TABLE IF NOT EXISTS payments ( balance_delta REAL, payment_source TEXT NOT NULL DEFAULT 'manual', transaction_id INTEGER, + accounting_excluded INTEGER NOT NULL DEFAULT 0, + exclusion_reason TEXT, + excluded_at TEXT, + overridden_by_payment_id INTEGER, deleted_at TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) diff --git a/routes/bills.js b/routes/bills.js index cfa5874..e66773d 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -17,6 +17,10 @@ const { validatePaymentInput } = require('../services/paymentValidation'); const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService'); const { normalizeMerchant } = require('../services/subscriptionService'); const { decorateTransaction } = require('../services/transactionService'); +const { + accountingActiveSql, + applyBankPaymentAsSourceOfTruth, +} = require('../services/paymentAccountingService'); // ── GET /api/bills ──────────────────────────────────────────────────────────── router.get('/', (req, res) => { @@ -632,11 +636,11 @@ router.post('/:id/toggle-paid', (req, res) => { let currentPayment; if (year !== null && month !== null) { currentPayment = db.prepare( - 'SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL AND strftime(\'%Y\', paid_date) = ? AND strftime(\'%m\', paid_date) = ? ORDER BY paid_date DESC LIMIT 1' + `SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL AND ${accountingActiveSql()} AND strftime('%Y', paid_date) = ? AND strftime('%m', paid_date) = ? ORDER BY paid_date DESC LIMIT 1` ).get(billId, String(year), String(month).padStart(2, '0')); } else { currentPayment = db.prepare( - 'SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL ORDER BY paid_date DESC LIMIT 1' + `SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL AND ${accountingActiveSql()} ORDER BY paid_date DESC LIMIT 1` ).get(billId); } @@ -1143,11 +1147,11 @@ 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, interest_accrued_month FROM bills WHERE id = ? AND deleted_at IS NULL'); + const getBill = db.prepare('SELECT * 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, interest_delta) - VALUES (?, ?, ?, 'provider_sync', ?, ?, ?) + INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id) + VALUES (?, ?, ?, 'provider_sync', ?) `); const updateTx = db.prepare(` UPDATE transactions SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now') WHERE id = ? @@ -1167,11 +1171,10 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => { const amount = Math.round(Math.abs(tx.amount)) / 100; const billRow = getBill.get(billId); - const balCalc = billRow ? computeBalanceDelta(billRow, amount) : null; - const result = insertPayment.run(billId, amount, paidDate, txId, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null); + const result = insertPayment.run(billId, amount, paidDate, txId); if (result.changes > 0) { - applyBalanceDelta(db, billId, balCalc); + const insertedPayment = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL').get(txId, billId); updateTx.run(billId, txId); imported++; @@ -1187,13 +1190,13 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => { const prevEnd = new Date(paid.getFullYear(), paid.getMonth(), 0); if (rules2.due_day <= prevEnd.getDate()) { const suggested = prevEnd.toISOString().slice(0, 10); - const inserted = db.prepare('SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL').get(txId, billId); - if (inserted) { - lateAttributions.push({ payment_id: inserted.id, bill_name: bill.name, original_date: paidDate, suggested_date: suggested, amount }); + if (insertedPayment) { + lateAttributions.push({ payment_id: insertedPayment.id, bill_name: bill.name, original_date: paidDate, suggested_date: suggested, amount }); } } } } + if (billRow && insertedPayment) applyBankPaymentAsSourceOfTruth(db, billRow, insertedPayment); } } })(); diff --git a/routes/calendar.js b/routes/calendar.js index e361060..758ac14 100644 --- a/routes/calendar.js +++ b/routes/calendar.js @@ -4,6 +4,7 @@ const router = express.Router(); const { getDb } = require('../db/database'); const { buildTrackerRow, getCycleRange } = require('../services/statusService'); const { getUserSettings } = require('../services/userSettings'); +const { accountingActiveSql } = require('../services/paymentAccountingService'); function clampDay(year, month, day) { const daysInMonth = new Date(year, month, 0).getDate(); @@ -66,6 +67,7 @@ router.get('/', (req, res) => { FROM payments WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL + AND ${accountingActiveSql()} ORDER BY paid_date DESC `); @@ -90,6 +92,7 @@ router.get('/', (req, res) => { AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL + AND ${accountingActiveSql('p')} ORDER BY p.paid_date ASC, b.name ASC `).all(req.user.id, start, end); diff --git a/routes/categories.js b/routes/categories.js index d10b1a0..5632cca 100644 --- a/routes/categories.js +++ b/routes/categories.js @@ -2,6 +2,7 @@ const express = require('express'); const { standardizeError } = require('../middleware/errorFormatter'); const router = express.Router(); const { getDb, ensureUserDefaultCategories } = require('../db/database'); +const { accountingActiveSql } = require('../services/paymentAccountingService'); // GET /api/categories router.get('/', (req, res) => { @@ -34,6 +35,7 @@ router.get('/', (req, res) => { LEFT JOIN payments p ON p.bill_id = b.id AND p.deleted_at IS NULL + AND ${accountingActiveSql('p')} WHERE b.user_id = ? AND b.category_id = ? AND b.deleted_at IS NULL diff --git a/routes/matches.js b/routes/matches.js index ea7868a..a78eaec 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, applyBalanceDelta } = require('../services/billsService'); +const { applyBankPaymentAsSourceOfTruth, reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService'); const { listMatchSuggestions, rejectMatchSuggestion, @@ -59,14 +59,13 @@ router.post('/confirm', (req, res) => { const amount = Math.round(Math.abs(tx.amount)) / 100; // cents → dollars try { - const balCalc = computeBalanceDelta(bill, amount); - db.exec('BEGIN'); const payResult = db.prepare( - "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); + "INSERT INTO payments (bill_id, amount, paid_date, payment_source, transaction_id) VALUES (?, ?, ?, 'transaction_match', ?)" + ).run(billId, amount, paidDate, txId); - applyBalanceDelta(db, billId, balCalc); + const paymentForAccounting = db.prepare('SELECT * FROM payments WHERE id = ?').get(payResult.lastInsertRowid); + applyBankPaymentAsSourceOfTruth(db, bill, paymentForAccounting); db.prepare(` UPDATE transactions @@ -110,6 +109,14 @@ router.post('/:transactionId/unmatch', (req, res) => { try { db.exec('BEGIN'); + const matchedPayments = db.prepare(` + SELECT * + FROM payments + WHERE transaction_id = ? AND payment_source = 'transaction_match' AND deleted_at IS NULL + `).all(txId); + for (const payment of matchedPayments) { + reactivatePaymentsOverriddenBy(db, payment.id); + } db.prepare(` UPDATE payments SET deleted_at = datetime('now'), updated_at = datetime('now') WHERE transaction_id = ? AND payment_source = 'transaction_match' AND deleted_at IS NULL diff --git a/routes/monthly-starting-amounts.js b/routes/monthly-starting-amounts.js index bf98cfe..f18b53b 100644 --- a/routes/monthly-starting-amounts.js +++ b/routes/monthly-starting-amounts.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const { getDb } = require('../db/database'); const { getCycleRange } = require('../services/statusService'); +const { accountingActiveSql } = require('../services/paymentAccountingService'); function parseYearMonth(source) { const now = new Date(); @@ -48,6 +49,7 @@ function calculatePaidDeductions(db, userId, year, month) { WHERE b.user_id = ? AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL + AND ${accountingActiveSql('p')} AND b.due_day BETWEEN 1 AND 14 `).get(userId, start, end); @@ -59,6 +61,7 @@ function calculatePaidDeductions(db, userId, year, month) { WHERE b.user_id = ? AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL + AND ${accountingActiveSql('p')} AND b.due_day BETWEEN 15 AND 31 `).get(userId, start, end); @@ -70,6 +73,7 @@ function calculatePaidDeductions(db, userId, year, month) { WHERE b.user_id = ? AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL + AND ${accountingActiveSql('p')} AND (b.due_day < 1 OR b.due_day > 31) `).get(userId, start, end); @@ -80,6 +84,7 @@ function calculatePaidDeductions(db, userId, year, month) { WHERE b.user_id = ? AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL + AND ${accountingActiveSql('p')} `).get(userId, start, end); return { diff --git a/routes/payments.js b/routes/payments.js index 895fd43..bf1ea94 100644 --- a/routes/payments.js +++ b/routes/payments.js @@ -5,6 +5,10 @@ const { getDb } = require('../db/database'); const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService'); const { validatePaymentInput } = require('../services/paymentValidation'); const { getCycleRange, resolveDueDate } = require('../services/statusService'); +const { + markProvisionalManualPaymentsOverridden, + reactivatePaymentsOverriddenBy, +} = require('../services/paymentAccountingService'); // SQL_NOT_DELETED is a compile-time constant SQL fragment, never user-supplied. // It cannot be a bind parameter (SQL fragments are not parameterisable — only @@ -156,7 +160,7 @@ router.post('/:id/undo-auto', (req, res) => { try { db.transaction(() => { // Restore balance (same logic as DELETE /:id) - if (payment.balance_delta != null) { + if (!payment.accounting_excluded && payment.balance_delta != null) { const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id); if (bill?.current_balance != null) { const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100); @@ -169,6 +173,7 @@ router.post('/:id/undo-auto', (req, res) => { `).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id); } } + reactivatePaymentsOverriddenBy(db, payment.id); db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id); db.prepare(` UPDATE transactions @@ -501,7 +506,7 @@ router.delete('/:id', (req, res) => { // 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) { + if (!payment.accounting_excluded && 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); @@ -529,7 +534,7 @@ router.post('/:id/restore', (req, res) => { // 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) { + if (!payment.accounting_excluded && 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); @@ -597,8 +602,14 @@ router.patch('/:id/attribute-to-month', (req, res) => { )); } - db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?") - .run(paid_date, paymentId); + db.transaction(() => { + db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?") + .run(paid_date, paymentId); + + const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') + .get(payment.bill_id, req.user.id); + if (bill) markProvisionalManualPaymentsOverridden(db, bill, { ...payment, paid_date }); + })(); res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(paymentId, req.user.id)); } catch (err) { diff --git a/routes/status.js b/routes/status.js index 455ab40..5b03321 100644 --- a/routes/status.js +++ b/routes/status.js @@ -9,6 +9,7 @@ const { getScheduleStatus } = require('../services/backupScheduler'); const { checkForUpdates } = require('../services/updateCheckService'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); const { getBankSyncConfig } = require('../services/bankSyncConfigService'); +const { accountingActiveSql } = require('../services/paymentAccountingService'); const startTime = Date.now(); let pkg; @@ -207,7 +208,7 @@ router.get('/', async (req, res) => { const todayDay = now.getDate(); const billCount = db.prepare('SELECT COUNT(*) AS n FROM bills WHERE active = 1').get().n; const paymentCount = db.prepare( - 'SELECT COUNT(*) AS n FROM payments WHERE paid_date BETWEEN ? AND ? AND deleted_at IS NULL' + `SELECT COUNT(*) AS n FROM payments WHERE paid_date BETWEEN ? AND ? AND deleted_at IS NULL AND ${accountingActiveSql()}` ).get(range.start, range.end).n; const skippedCount = db.prepare( 'SELECT COUNT(*) AS n FROM monthly_bill_state WHERE year = ? AND month = ? AND is_skipped = 1' @@ -219,7 +220,7 @@ router.get('/', async (req, res) => { AND CAST(b.due_day AS INTEGER) < ? AND NOT EXISTS ( SELECT 1 FROM payments p - WHERE p.bill_id = b.id AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL + WHERE p.bill_id = b.id AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL AND ${accountingActiveSql('p')} ) AND NOT EXISTS ( SELECT 1 FROM monthly_bill_state mbs diff --git a/routes/summary.js b/routes/summary.js index 0697c41..1feac9b 100644 --- a/routes/summary.js +++ b/routes/summary.js @@ -3,6 +3,7 @@ const router = express.Router(); const { getDb } = require('../db/database'); const { getCycleRange } = require('../services/statusService'); const { getUserSettings } = require('../services/userSettings'); +const { accountingActiveSql } = require('../services/paymentAccountingService'); const DEFAULT_INCOME_LABEL = 'Salary'; const DEFAULT_PENDING_DAYS = 3; @@ -41,7 +42,8 @@ function buildBankTrackingSummary(db, userId, year, month) { AND p.paid_date >= date('now', '-' || ? || ' days') AND p.paid_date <= date('now') AND b.deleted_at IS NULL - AND (p.payment_source IS NULL OR p.payment_source != 'provider_sync') + AND ${accountingActiveSql('p')} + AND (p.payment_source IS NULL OR p.payment_source NOT IN ('provider_sync', 'transaction_match', 'auto_match')) `).get(userId, effectivePendingDays) : { pending_total: 0 }; @@ -60,6 +62,8 @@ function buildBankTrackingSummary(db, userId, year, month) { SELECT bill_id, SUM(amount) AS paid_sum FROM payments WHERE paid_date BETWEEN ? AND ? + AND deleted_at IS NULL + AND ${accountingActiveSql()} GROUP BY bill_id ) pay ON pay.bill_id = b.id WHERE b.user_id = ? @@ -141,6 +145,7 @@ function calculatePaidDeductions(db, userId, year, month) { AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL + AND ${accountingActiveSql('p')} AND b.due_day BETWEEN 1 AND 14 `).get(userId, start, end); @@ -153,6 +158,7 @@ function calculatePaidDeductions(db, userId, year, month) { AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL + AND ${accountingActiveSql('p')} AND b.due_day BETWEEN 15 AND 31 `).get(userId, start, end); @@ -165,6 +171,7 @@ function calculatePaidDeductions(db, userId, year, month) { AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL + AND ${accountingActiveSql('p')} AND (b.due_day < 1 OR b.due_day > 31) `).get(userId, start, end); @@ -176,6 +183,7 @@ function calculatePaidDeductions(db, userId, year, month) { AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL + AND ${accountingActiveSql('p')} `).get(userId, start, end); return { @@ -269,6 +277,7 @@ function buildSummary(db, userId, year, month) { AND p.bill_id IN (${placeholders}) AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL + AND ${accountingActiveSql('p')} GROUP BY p.bill_id `).all(userId, ...billIds, start, end); diff --git a/routes/transactions.js b/routes/transactions.js index 38345c8..6b85ca1 100644 --- a/routes/transactions.js +++ b/routes/transactions.js @@ -13,6 +13,7 @@ const { unignoreTransaction, unmatchTransaction, } = require('../services/transactionMatchService'); +const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService'); const MATCH_STATUSES = new Set(['unmatched', 'matched', 'ignored']); const SOURCE_TYPES = new Set(['manual', 'file_import', 'provider_sync']); @@ -548,7 +549,7 @@ router.post('/unmatch-bulk', (req, res) => { `).get(m.payment_id, userId); if (payment) { - if (payment.balance_delta != null) { + if (!payment.accounting_excluded && payment.balance_delta != null) { const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id); if (bill?.current_balance != null) { const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100); @@ -561,6 +562,7 @@ router.post('/unmatch-bulk', (req, res) => { `).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id); } } + reactivatePaymentsOverriddenBy(db, payment.id); db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id); } db.prepare(` diff --git a/services/amountSuggestionService.js b/services/amountSuggestionService.js index 343f781..21499d0 100644 --- a/services/amountSuggestionService.js +++ b/services/amountSuggestionService.js @@ -1,5 +1,7 @@ 'use strict'; +const { accountingActiveSql } = require('./paymentAccountingService'); + /** * Computes a suggested expected amount for a bill based on the rolling median * of the last 6 months of actual data. Prefers monthly_bill_state.actual_amount @@ -28,6 +30,7 @@ function computeAmountSuggestion(db, billId, year, month) { FROM payments WHERE bill_id = ? AND deleted_at IS NULL + AND ${accountingActiveSql()} AND strftime('%Y', paid_date) = ? AND strftime('%m', paid_date) = ? `).get(billId, String(y), String(m).padStart(2, '0')); diff --git a/services/analyticsService.js b/services/analyticsService.js index 72317de..7bc42cb 100644 --- a/services/analyticsService.js +++ b/services/analyticsService.js @@ -1,6 +1,7 @@ 'use strict'; const { getDb } = require('../db/database'); +const { accountingActiveSql } = require('./paymentAccountingService'); function parseInteger(value, fallback) { if (value === undefined || value === null || value === '') return fallback; @@ -157,6 +158,7 @@ function getAnalyticsSummary(userId, query = {}) { AND p.bill_id IN (${placeholders}) AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL + AND ${accountingActiveSql('p')} GROUP BY p.bill_id, substr(p.paid_date, 1, 7) `).all(userId, ...billIds, startDate, endDate); diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.js index 24eec05..a5dd208 100644 --- a/services/billMerchantRuleService.js +++ b/services/billMerchantRuleService.js @@ -1,8 +1,8 @@ 'use strict'; const { normalizeMerchant } = require('./subscriptionService'); -const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { getUserSettings } = require('./userSettings'); +const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService'); // Word-boundary merchant match — requires the rule to appear as complete word(s) // within the transaction string (or vice versa), not just as a substring. @@ -125,10 +125,10 @@ 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, interest_accrued_month FROM bills WHERE id = ? AND deleted_at IS NULL'); + const getBill = db.prepare('SELECT * 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, interest_delta) - VALUES (?, ?, ?, 'provider_sync', ?, ?, ?) + INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id) + VALUES (?, ?, ?, 'provider_sync', ?) `); const updateTx = db.prepare(` UPDATE transactions @@ -165,11 +165,10 @@ function applyMerchantRules(db, userId) { const amount = Math.round(Math.abs(tx.amount)) / 100; 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, balCalc?.interest_delta ?? null); + const result = insertPayment.run(rule.bill_id, amount, paidDate, tx.id); if (result.changes > 0) { - applyBalanceDelta(db, rule.bill_id, balCalc); + const inserted = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL').get(tx.id, rule.bill_id); updateTx.run(rule.bill_id, tx.id, userId); matched++; matchedBills.set(rule.bill_id, rule.bill_name || `Bill #${rule.bill_id}`); @@ -186,13 +185,14 @@ function applyMerchantRules(db, userId) { if (autoApply) { db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL") .run(suggestedDate, tx.id, rule.bill_id); + if (inserted) inserted.paid_date = suggestedDate; } else { - const inserted = db.prepare( + const insertedForPrompt = db.prepare( 'SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL' ).get(tx.id, rule.bill_id); - if (inserted) { + if (insertedForPrompt) { lateAttributions.push({ - payment_id: inserted.id, + payment_id: insertedForPrompt.id, bill_name: rule.bill_name || `Bill #${rule.bill_id}`, original_date: paidDate, suggested_date: suggestedDate, @@ -201,6 +201,7 @@ function applyMerchantRules(db, userId) { } } } + if (bill && inserted) applyBankPaymentAsSourceOfTruth(db, bill, inserted); } } })(); @@ -267,17 +268,17 @@ 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, interest_accrued_month FROM bills WHERE id = ? AND deleted_at IS NULL'); + const getBill = db.prepare('SELECT * 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, interest_delta) - VALUES (?, ?, ?, 'provider_sync', ?, ?, ?) + INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id) + VALUES (?, ?, ?, 'provider_sync', ?) `); const updateTx = db.prepare(` UPDATE transactions SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now') WHERE id = ? AND user_id = ? AND match_status = 'unmatched' `); - const getPaymentId = db.prepare('SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL'); + const getPaymentId = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL'); let added = 0; const lateAttributions = []; @@ -292,11 +293,10 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { if (!paidDate) continue; const amount = Math.round(Math.abs(tx.amount)) / 100; 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, balCalc?.interest_delta ?? null); + const result = insertPayment.run(billId, amount, paidDate, tx.id); if (result.changes > 0) { - applyBalanceDelta(db, billId, balCalc); + const inserted = getPaymentId.get(tx.id, billId); updateTx.run(billId, tx.id, userId); added++; @@ -312,11 +312,12 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { if (autoApply) { db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL") .run(suggestedDate, tx.id, billId); + if (inserted) inserted.paid_date = suggestedDate; } else { - const inserted = getPaymentId.get(tx.id, billId); - if (inserted) { + const insertedForPrompt = getPaymentId.get(tx.id, billId); + if (insertedForPrompt) { lateAttributions.push({ - payment_id: inserted.id, + payment_id: insertedForPrompt.id, bill_name: billMeta.name || `Bill #${billId}`, original_date: paidDate, suggested_date: suggestedDate, @@ -325,6 +326,7 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { } } } + if (bill && inserted) applyBankPaymentAsSourceOfTruth(db, bill, inserted); } } })(); diff --git a/services/driftService.js b/services/driftService.js index 98c88d3..11c3c3c 100644 --- a/services/driftService.js +++ b/services/driftService.js @@ -2,6 +2,7 @@ const { getDb } = require('../db/database'); const { getCycleRange } = require('./statusService'); +const { accountingActiveSql } = require('./paymentAccountingService'); const { getUserSettings } = require('./userSettings'); const MONTHS_BACK = 3; @@ -46,6 +47,7 @@ function getDriftReport(userId, now = new Date()) { SELECT COALESCE(SUM(amount), 0) AS total FROM payments WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL + AND ${accountingActiveSql()} `); for (const bill of bills) { diff --git a/services/notificationService.js b/services/notificationService.js index d4e1d96..d0e6c78 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -1,6 +1,7 @@ const nodemailer = require('nodemailer'); const { getDb, getSetting } = require('../db/database'); const { decryptSecret, encryptSecret } = require('./encryptionService'); +const { accountingActiveSql } = require('./paymentAccountingService'); const { markNotificationError, markNotificationSuccess, @@ -324,6 +325,7 @@ async function runNotifications() { WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL + AND ${accountingActiveSql()} GROUP BY bill_id `).all(...billIds, monthStart, monthEnd); for (const row of paidRows) paidMap.set(row.bill_id, row.paid_sum); diff --git a/services/paymentAccountingService.js b/services/paymentAccountingService.js new file mode 100644 index 0000000..84fdce9 --- /dev/null +++ b/services/paymentAccountingService.js @@ -0,0 +1,159 @@ +'use strict'; + +const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); +const { getCycleRange } = require('./statusService'); + +const ACCOUNTING_ACTIVE_SQL = 'COALESCE(accounting_excluded, 0) = 0'; +const BANK_PAYMENT_SOURCES = new Set(['provider_sync', 'transaction_match', 'auto_match']); +const OVERRIDE_REASON = 'overridden_by_bank'; + +function accountingActiveSql(alias = null) { + const prefix = alias ? `${alias}.` : ''; + return `COALESCE(${prefix}accounting_excluded, 0) = 0`; +} + +function isBankBackedPayment(payment = {}) { + return BANK_PAYMENT_SOURCES.has(payment.payment_source) || payment.transaction_id != null; +} + +function appendNote(existing, line) { + const current = String(existing || '').trim(); + if (current.includes(line)) return current || line; + return current ? `${current}\n${line}`.slice(0, 500) : line.slice(0, 500); +} + +function paymentMonth(paidDate) { + const match = String(paidDate || '').match(/^(\d{4})-(\d{2})-\d{2}$/); + if (!match) return null; + return { year: Number(match[1]), month: Number(match[2]) }; +} + +function cycleRangeForPayment(bill, paidDate) { + const ym = paymentMonth(paidDate); + if (!ym) return null; + return getCycleRange(ym.year, ym.month, bill); +} + +function reversePaymentBalance(db, payment) { + if (!payment || payment.balance_delta == null) return; + const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id); + 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 = ?, + 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 applyPaymentBalanceFromFreshBill(db, billId, amount) { + const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL').get(billId); + if (!bill) return { balance_delta: null, interest_delta: null }; + const balCalc = computeBalanceDelta(bill, amount); + applyBalanceDelta(db, billId, balCalc); + return { + balance_delta: balCalc?.balance_delta ?? null, + interest_delta: balCalc?.interest_delta ?? null, + }; +} + +function markProvisionalManualPaymentsOverridden(db, bill, bankPayment) { + if (!bill || !bankPayment || !isBankBackedPayment(bankPayment)) return { overridden: 0 }; + const range = cycleRangeForPayment(bill, bankPayment.paid_date); + if (!range) return { overridden: 0 }; + + const provisionalPayments = db.prepare(` + SELECT * + FROM payments + WHERE bill_id = ? + AND id != ? + AND payment_source = 'manual' + AND transaction_id IS NULL + AND deleted_at IS NULL + AND ${ACCOUNTING_ACTIVE_SQL} + AND paid_date BETWEEN ? AND ? + ORDER BY paid_date DESC, id DESC + `).all(bill.id, bankPayment.id, range.start, range.end); + + const note = `History only: overridden by bank payment #${bankPayment.id} on ${bankPayment.paid_date}.`; + const update = db.prepare(` + UPDATE payments + SET accounting_excluded = 1, + exclusion_reason = ?, + excluded_at = datetime('now'), + overridden_by_payment_id = ?, + notes = ?, + balance_delta = NULL, + interest_delta = NULL, + updated_at = datetime('now') + WHERE id = ? + `); + + for (const payment of provisionalPayments) { + reversePaymentBalance(db, payment); + update.run(OVERRIDE_REASON, bankPayment.id, appendNote(payment.notes, note), payment.id); + } + + return { overridden: provisionalPayments.length }; +} + +function reactivatePaymentsOverriddenBy(db, bankPaymentId) { + const rows = db.prepare(` + SELECT * + FROM payments + WHERE overridden_by_payment_id = ? + AND accounting_excluded = 1 + AND deleted_at IS NULL + `).all(bankPaymentId); + + const update = db.prepare(` + UPDATE payments + SET accounting_excluded = 0, + exclusion_reason = NULL, + excluded_at = NULL, + overridden_by_payment_id = NULL, + balance_delta = ?, + interest_delta = ?, + notes = ?, + updated_at = datetime('now') + WHERE id = ? + `); + + for (const payment of rows) { + const deltas = applyPaymentBalanceFromFreshBill(db, payment.bill_id, payment.amount); + update.run( + deltas.balance_delta, + deltas.interest_delta, + appendNote(payment.notes, 'Bank override removed; this manual payment counts again.'), + payment.id, + ); + } + + return { reactivated: rows.length }; +} + +function applyBankPaymentAsSourceOfTruth(db, bill, bankPayment) { + markProvisionalManualPaymentsOverridden(db, bill, bankPayment); + const deltas = applyPaymentBalanceFromFreshBill(db, bill.id, bankPayment.amount); + db.prepare(` + UPDATE payments + SET balance_delta = ?, + interest_delta = ?, + updated_at = datetime('now') + WHERE id = ? + `).run(deltas.balance_delta, deltas.interest_delta, bankPayment.id); + return deltas; +} + +module.exports = { + ACCOUNTING_ACTIVE_SQL, + OVERRIDE_REASON, + accountingActiveSql, + isBankBackedPayment, + markProvisionalManualPaymentsOverridden, + reactivatePaymentsOverriddenBy, + applyBankPaymentAsSourceOfTruth, +}; diff --git a/services/trackerService.js b/services/trackerService.js index b6e92b6..618385d 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -5,6 +5,7 @@ const { buildTrackerRow, getCycleRange, resolveDueDate, roundMoney } = require(' const { getUserSettings } = require('./userSettings'); const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { computeAmountSuggestion } = require('./amountSuggestionService'); +const { accountingActiveSql } = require('./paymentAccountingService'); const DEFAULT_PENDING_DAYS = 3; @@ -35,7 +36,8 @@ function buildBankTracking(db, userId, year, month) { WHERE b.user_id = ? AND p.paid_date >= date('now', '-' || ? || ' days') AND p.paid_date <= date('now') AND b.deleted_at IS NULL - AND (p.payment_source IS NULL OR p.payment_source != 'provider_sync') + AND ${accountingActiveSql('p')} + AND (p.payment_source IS NULL OR p.payment_source NOT IN ('provider_sync', 'transaction_match', 'auto_match')) `).get(userId, days) : { pending_total: 0 }; @@ -49,7 +51,10 @@ function buildBankTracking(db, userId, year, month) { LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ? LEFT JOIN ( SELECT bill_id, SUM(amount) AS paid_sum FROM payments - WHERE paid_date BETWEEN ? AND ? GROUP BY bill_id + WHERE paid_date BETWEEN ? AND ? + AND deleted_at IS NULL + AND ${accountingActiveSql()} + GROUP BY bill_id ) pay ON pay.bill_id = b.id WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL AND COALESCE(m.is_skipped, 0) = 0 AND COALESCE(pay.paid_sum, 0) = 0 @@ -146,6 +151,7 @@ function fetchPaymentsForBillCycle(db, bill, year, month) { FROM payments WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL + AND ${accountingActiveSql()} ORDER BY paid_date DESC `).all(bill.id, range.start, range.end); } @@ -158,6 +164,7 @@ function fetchPreviousMonthPaid(db, billIds, range) { FROM payments WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL + AND ${accountingActiveSql()} GROUP BY bill_id `).all(...billIds, range.start, range.end); return Object.fromEntries(rows.map(row => [row.bill_id, row.total_paid])); @@ -208,6 +215,7 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, FROM payments WHERE bill_id = ? AND deleted_at IS NULL + AND ${accountingActiveSql()} AND paid_date BETWEEN ? AND ? ORDER BY paid_date DESC LIMIT 1 @@ -263,6 +271,7 @@ function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) { JOIN bills b ON p.bill_id = b.id WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL + AND ${accountingActiveSql('p')} GROUP BY strftime('%Y-%m', p.paid_date) `).all(userId, threeMonthStart, end); @@ -546,6 +555,7 @@ function getOverdueCount(userId, now = new Date()) { ON mbs.bill_id = b.id AND mbs.year = ? AND mbs.month = ? LEFT JOIN payments p ON p.bill_id = b.id AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL + AND ${accountingActiveSql('p')} WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL GROUP BY b.id `).all(year, month, rangeStart, rangeEnd, userId); diff --git a/services/transactionMatchService.js b/services/transactionMatchService.js index f6c8101..375ae1a 100644 --- a/services/transactionMatchService.js +++ b/services/transactionMatchService.js @@ -2,6 +2,10 @@ const { getDb } = require('../db/database'); const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); +const { + applyBankPaymentAsSourceOfTruth, + reactivatePaymentsOverriddenBy, +} = require('./paymentAccountingService'); const { decorateTransaction, getTransactionForUser, @@ -123,6 +127,16 @@ function applyPaymentBalance(db, bill, amount) { return { balance_delta: balCalc?.balance_delta ?? null, interest_delta: balCalc?.interest_delta ?? null }; } +function updatePaymentBalanceDeltas(db, paymentId, deltas) { + db.prepare(` + UPDATE payments + SET balance_delta = ?, + interest_delta = ?, + updated_at = datetime('now') + WHERE id = ? + `).run(deltas.balance_delta, deltas.interest_delta, paymentId); +} + function buildMatchPaymentNotes(transaction, bill) { const label = transaction.payee || transaction.description || `transaction ${transaction.id}`; return `Matched transaction to ${bill.name}: ${label}`.slice(0, 500); @@ -145,7 +159,6 @@ function createOrUpdateMatchPayment(db, userId, transaction, bill) { if (existingPayment) { restorePaymentBalance(db, existingPayment); - const { balance_delta, interest_delta } = applyPaymentBalance(db, bill, amount); db.prepare(` UPDATE payments SET bill_id = ?, @@ -153,8 +166,8 @@ function createOrUpdateMatchPayment(db, userId, transaction, bill) { paid_date = ?, method = ?, notes = ?, - balance_delta = ?, - interest_delta = ?, + balance_delta = NULL, + interest_delta = NULL, payment_source = ?, updated_at = datetime('now') WHERE id = ? @@ -164,15 +177,15 @@ function createOrUpdateMatchPayment(db, userId, transaction, bill) { paidDate, MATCH_PAYMENT_METHOD, notes, - balance_delta, - interest_delta, MATCH_PAYMENT_SOURCE, existingPayment.id, ); + const updatedPayment = db.prepare('SELECT * FROM payments WHERE id = ?').get(existingPayment.id); + const deltas = applyBankPaymentAsSourceOfTruth(db, bill, updatedPayment); + updatePaymentBalanceDeltas(db, existingPayment.id, deltas); return existingPayment.id; } - 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, interest_delta, payment_source, transaction_id) @@ -183,11 +196,14 @@ function createOrUpdateMatchPayment(db, userId, transaction, bill) { paidDate, MATCH_PAYMENT_METHOD, notes, - balance_delta, - interest_delta, + null, + null, MATCH_PAYMENT_SOURCE, transaction.id, ); + const insertedPayment = db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid); + const deltas = applyBankPaymentAsSourceOfTruth(db, bill, insertedPayment); + updatePaymentBalanceDeltas(db, result.lastInsertRowid, deltas); return result.lastInsertRowid; } @@ -197,6 +213,7 @@ function unlinkPaymentForTransaction(db, userId, transactionId) { if (existingPayment.payment_source === MATCH_PAYMENT_SOURCE) { restorePaymentBalance(db, existingPayment); + reactivatePaymentsOverriddenBy(db, existingPayment.id); db.prepare(` UPDATE payments SET deleted_at = datetime('now'), updated_at = datetime('now') diff --git a/tests/transactionMatchService.test.js b/tests/transactionMatchService.test.js index d415674..48d32a7 100644 --- a/tests/transactionMatchService.test.js +++ b/tests/transactionMatchService.test.js @@ -422,6 +422,73 @@ test('manual payment history remains visible and suppresses duplicate suggestion assert.equal(transactionsRes.data.transactions[0].linked_payment.id, matched.payment.id); }); +test('bank-backed match overrides same-cycle manual tracker payment but keeps it in history', async () => { + const db = getDb(); + const userId = createUser(db, 'bank-override'); + const billId = createBill(db, userId, 'Internet Override'); + const manualPaymentId = createManualPayment(db, billId, { + amount: 85, + notes: 'Marked paid while waiting for bank clear', + }); + const transactionId = createTransaction(db, userId, { + amount: -9000, + description: 'Internet Override', + payee: 'Internet Override', + }); + + const beforeMatch = trackerRow(userId, billId); + assert.equal(beforeMatch.total_paid, 85); + assert.equal(beforeMatch.status, 'paid'); + + const matched = matchTransactionToBill(userId, transactionId, billId); + const afterMatch = trackerRow(userId, billId); + assert.equal(afterMatch.total_paid, 90); + assert.equal(afterMatch.payments.length, 1); + assert.equal(afterMatch.payments[0].id, matched.payment.id); + assert.equal(afterMatch.payments.some(payment => payment.id === manualPaymentId), false); + + const manual = db.prepare('SELECT * FROM payments WHERE id = ?').get(manualPaymentId); + assert.equal(manual.accounting_excluded, 1); + assert.equal(manual.exclusion_reason, 'overridden_by_bank'); + assert.equal(manual.overridden_by_payment_id, matched.payment.id); + assert.match(manual.notes, /History only: overridden by bank payment/); + + const paymentsRes = await callBillsRoute('/:id/payments', { + userId, + params: { id: String(billId) }, + query: { limit: '100' }, + }); + assert.equal(paymentsRes.status, 200); + assert.equal(paymentsRes.data.payments.some(payment => payment.id === manualPaymentId && payment.accounting_excluded === 1), true); + assert.equal(paymentsRes.data.payments.some(payment => payment.id === matched.payment.id && payment.accounting_excluded === 0), true); +}); + +test('unmatching a bank-backed payment reactivates the provisional manual payment', () => { + const db = getDb(); + const userId = createUser(db, 'bank-override-unmatch'); + const billId = createBill(db, userId, 'Internet Unmatch'); + const manualPaymentId = createManualPayment(db, billId); + const transactionId = createTransaction(db, userId, { + description: 'Internet Unmatch', + payee: 'Internet Unmatch', + }); + + matchTransactionToBill(userId, transactionId, billId); + assert.equal(db.prepare('SELECT accounting_excluded FROM payments WHERE id = ?').get(manualPaymentId).accounting_excluded, 1); + + unmatchTransaction(userId, transactionId); + const manual = db.prepare('SELECT accounting_excluded, overridden_by_payment_id, exclusion_reason, notes FROM payments WHERE id = ?').get(manualPaymentId); + assert.equal(manual.accounting_excluded, 0); + assert.equal(manual.overridden_by_payment_id, null); + assert.equal(manual.exclusion_reason, null); + assert.match(manual.notes, /manual payment counts again/i); + + const row = trackerRow(userId, billId); + assert.equal(row.status, 'paid'); + assert.equal(row.total_paid, 85); + assert.equal(row.payments.some(payment => payment.id === manualPaymentId), true); +}); + test('manual match learns a merchant rule; generic descriptors and background auto-match do not', () => { const db = getDb(); const userId = createUser(db, 'learn');