fix: imported payments now update debt balance via balance_delta and current_balance
This commit is contained in:
parent
37cf24f5a0
commit
b7c855e570
|
|
@ -30,6 +30,8 @@
|
|||
|
||||
### 🐛 Fixed
|
||||
|
||||
- **Imported payments now update debt balance** — Every payment creation path except spreadsheet import correctly computed `balance_delta` and updated `bills.current_balance`. Imported payments were permanently orphaned from debt tracking: the snowball page balance stayed wrong after an Excel import, delete/restore was broken (the restore path checks `balance_delta IS NULL` and silently skips the reversal), and any debt-related reporting was incorrect. Three paths were fixed. In `spreadsheetImportService.js` `createPaymentFromImport()`: added `computeBalanceDelta` import, fetches the bill fresh on every call (critical for sequential month imports so each payment sees the post-previous-payment balance), includes `balance_delta` in the INSERT, updates `bills.current_balance`, and sets `payment_source = 'import'` (was null). The `create_payment` action path in the same file had the identical gap and received the same treatment. `routes/matches.js` manual transaction confirm also had no `balance_delta` or `current_balance` update — fixed with the same `computeBalanceDelta` call inside the existing transaction block.
|
||||
|
||||
- **Daily worker cycle range bugs for quarterly and annual bills** — `dailyWorker.js` had two bugs affecting non-monthly billing cycles. First, `getCycleRange(year, month)` was called without a bill argument, always producing the calendar-month range for payment lookups. A quarterly bill paid in January would be invisible to the autopay check in February and March because the worker only searched that calendar month — a payment that existed was treated as missing. Fixed by calling `getCycleRange(year, month, bill)` per-bill so quarterly bills look at their full 3-month window and annual bills look at the full year. Second, `buildTrackerRow()` returns `null` for bills whose cycle does not apply in the current month (quarterly/annual bills in non-due months), and the code immediately accessed `row.due_date` with no null check. JavaScript's `&&` short-circuit masked the crash for non-autopay bills, but any autopay-enabled quarterly or annual bill in a non-applicable month would throw `TypeError: Cannot read properties of null`. Fixed with an early `continue` when `getCycleRange` returns null and a defensive guard after `buildTrackerRow()`. The issue description incorrectly stated that `resolveDueDate()` and `getCycleRange()` ignore cycle types — both functions already handle quarterly, annual, biweekly, and weekly correctly; the tracker already filters non-applicable bills via `.filter(Boolean)`; no new scheduler was needed.
|
||||
|
||||
- **Client snowball projection replaced with server call** — `computeLiveProjection()` in `SnowballPage.jsx` was an 86-line client-side reimplementation of `snowballService.js`. A comment acknowledged the duplication but there was no mechanism to detect drift — a bug fix on the server would silently diverge from the client preview. The function has been deleted. `GET /api/snowball/projection` now accepts an optional `?extra=N` query parameter that overrides the stored extra payment for that request without saving it, giving the client a way to preview an unsaved amount using the authoritative server simulation. The `useMemo` live projection is replaced with a 220 ms debounced `useEffect` that calls the endpoint; the existing `projectionLoading` state and loading indicator fire naturally. Drift between client and server projections is now mechanically impossible.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const router = require('express').Router();
|
||||
const { standardizeError } = require('../middleware/errorFormatter');
|
||||
const { getDb } = require('../db/database');
|
||||
const { computeBalanceDelta } = require('../services/billsService');
|
||||
const {
|
||||
listMatchSuggestions,
|
||||
rejectMatchSuggestion,
|
||||
|
|
@ -57,10 +58,17 @@ 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) VALUES (?, ?, ?, 'transaction_match', ?)"
|
||||
).run(billId, amount, paidDate, txId);
|
||||
"INSERT INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta) VALUES (?, ?, ?, 'transaction_match', ?, ?)"
|
||||
).run(billId, amount, paidDate, txId, balCalc?.balance_delta ?? null);
|
||||
|
||||
if (balCalc) {
|
||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(balCalc.new_balance, billId);
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE transactions
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
const xlsx = require('xlsx');
|
||||
const crypto = require('crypto');
|
||||
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
||||
const { computeBalanceDelta } = require('./billsService');
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -1435,10 +1436,23 @@ function createPaymentFromImport(db, billId, amount, paidDate, notes, allowOverw
|
|||
|
||||
if (dup && !allowOverwrite) return { result: 'skipped_duplicate', existing_created_at: dup.created_at ?? null };
|
||||
|
||||
// Read the bill fresh so sequential imports for the same bill chain correctly
|
||||
// (each payment reduces current_balance before the next one is computed).
|
||||
const bill = db.prepare(
|
||||
'SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL'
|
||||
).get(billId);
|
||||
|
||||
const balCalc = bill ? computeBalanceDelta(bill, amount) : null;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO payments (bill_id, amount, paid_date, method, notes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(billId, amount, paidDate, null, notes);
|
||||
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'import')
|
||||
`).run(billId, amount, paidDate, null, notes, balCalc?.balance_delta ?? null);
|
||||
|
||||
if (balCalc) {
|
||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(balCalc.new_balance, billId);
|
||||
}
|
||||
|
||||
return { result: 'created', existing_created_at: null };
|
||||
}
|
||||
|
|
@ -1636,7 +1650,7 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
|||
|
||||
} else if (action === 'create_payment') {
|
||||
const billId = decision.bill_id;
|
||||
const bill = db.prepare('SELECT id FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId);
|
||||
const bill = db.prepare('SELECT id, current_balance, interest_rate FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId);
|
||||
if (!bill) throw new Error(`Bill id=${billId} not found or inactive`);
|
||||
|
||||
const payAmount = decision.payment_amount ?? amount;
|
||||
|
|
@ -1669,10 +1683,17 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
|||
return;
|
||||
}
|
||||
|
||||
const balCalcCp = computeBalanceDelta(bill, payAmount);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO payments (bill_id, amount, paid_date, method, notes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(billId, payAmount, payDate, decision.payment_method ?? null, decision.payment_notes ?? null);
|
||||
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'import')
|
||||
`).run(billId, payAmount, payDate, decision.payment_method ?? null, decision.payment_notes ?? null, balCalcCp?.balance_delta ?? null);
|
||||
|
||||
if (balCalcCp) {
|
||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(balCalcCp.new_balance, billId);
|
||||
}
|
||||
|
||||
summary.created++;
|
||||
summary.details.push({ row_id, action, result: 'created', bill_id: billId, paid_date: payDate, amount: payAmount });
|
||||
|
|
|
|||
Loading…
Reference in New Issue