feat: payment accounting service, SQL schema + migration, backend route refactor, test updates

This commit is contained in:
null 2026-06-07 01:05:48 -05:00
parent 1ebb2da50a
commit 6811eb8be5
22 changed files with 429 additions and 59 deletions

View File

@ -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.

View File

@ -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
<div className="max-h-52 space-y-2 overflow-y-auto pr-1">
{payments.map(payment => {
const linkedPayment = isTransactionLinkedPayment(payment);
const historyOnly = isHistoryOnlyPayment(payment);
return (
<div key={payment.id} className="flex items-start justify-between gap-3 rounded-lg border border-border/60 bg-background/35 px-3 py-2.5">
<div key={payment.id} className={cn(
'flex items-start justify-between gap-3 rounded-lg border px-3 py-2.5',
historyOnly
? 'border-amber-500/25 bg-amber-500/[0.06] opacity-85'
: 'border-border/60 bg-background/35'
)}>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="font-mono text-sm font-semibold text-foreground">{fmt(payment.amount)}</p>
<p className={cn('font-mono text-sm font-semibold', historyOnly ? 'text-muted-foreground line-through decoration-amber-500/70' : 'text-foreground')}>{fmt(payment.amount)}</p>
<span className={cn(
'rounded-md border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
paymentSourceTone(payment.payment_source),
)}>
{paymentSourceLabel(payment.payment_source)}
</span>
{historyOnly && (
<span className="rounded-md border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-300">
History only
</span>
)}
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{fmtDate(payment.paid_date)} · {payment.method || 'manual'}
@ -1072,7 +1087,11 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
)}
</div>
<div className="flex shrink-0 gap-1">
{linkedPayment ? (
{historyOnly ? (
<span className="inline-flex h-8 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-2 text-[11px] font-medium text-amber-600 dark:text-amber-300">
Overridden
</span>
) : linkedPayment ? (
<span className="inline-flex h-8 items-center gap-1.5 rounded-md border border-primary/20 bg-primary/5 px-2 text-[11px] font-medium text-primary">
<Link2 className="h-3.5 w-3.5" />
Matched

View File

@ -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: [

View File

@ -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'))

View File

@ -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);
}
}
})();

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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) {

View File

@ -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

View File

@ -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);

View File

@ -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(`

View File

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

View File

@ -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);

View File

@ -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);
}
}
})();

View File

@ -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) {

View File

@ -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);

View File

@ -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,
};

View File

@ -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);

View File

@ -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')

View File

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