diff --git a/HISTORY.md b/HISTORY.md index c398e37..52ab1b7 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -28,6 +28,8 @@ ### 🐛 Fixed +- **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. - **Snowball order PATCH validates all rows before writing** — `PATCH /api/snowball/order` previously iterated through the submitted array with a `continue` on invalid entries, silently skipping bad rows and always returning `{ success: true }`. Any item with a non-integer or negative `id`/`snowball_order` now immediately returns `400` with the specific index and value that failed. The transaction only runs after all items pass validation. Response now includes `updated` count. Soft-deleted bills are also excluded from the UPDATE (`deleted_at IS NULL`), which simultaneously closes issue #53. diff --git a/workers/dailyWorker.js b/workers/dailyWorker.js index 44c7cab..0f31331 100644 --- a/workers/dailyWorker.js +++ b/workers/dailyWorker.js @@ -25,17 +25,27 @@ async function runDailyTasks() { const year = now.getFullYear(); const month = now.getMonth() + 1; const todayStr = now.toISOString().slice(0, 10); - const { start, end } = getCycleRange(year, month); const bills = db.prepare('SELECT * FROM bills WHERE active = 1').all(); for (const bill of bills) { + // Use the bill's own cycle range so quarterly/annual bills look at the + // correct payment window — not just the calendar month. + const range = getCycleRange(year, month, bill); + + // Bill does not apply this month (quarterly/annual in a non-due month). + if (!range) continue; + const payments = db.prepare( 'SELECT * FROM payments WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL' - ).all(bill.id, start, end); + ).all(bill.id, range.start, range.end); const row = buildTrackerRow(bill, payments, year, month, todayStr); + // row is null when the bill's cycle produces no due date this month. + // Guard defensively in case getCycleRange and resolveDueDate ever disagree. + if (!row) continue; + // Auto-mark autopay bills as assumed_paid on due date if ( bill.autopay_enabled &&