-
{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');