From 98ede20cd3be53bbac6808fd04433170fcfbe1a1 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 11 May 2026 16:04:21 -0500 Subject: [PATCH] fix: prevent duplicate payment prompts --- DEVELOPMENT_LOG.md | 31 +++++++++++++++++++++++++++++++ client/lib/version.js | 6 +++--- package.json | 2 +- routes/tracker.js | 3 ++- services/statusService.js | 31 +++++++++++++++++++++---------- 5 files changed, 58 insertions(+), 15 deletions(-) diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index e16c627..278da0b 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1411,3 +1411,34 @@ Users found: 1 --- **Last Updated:** 2026-05-11 12:15 CDT + +## v0.24.6 — Duplicate Payment Paid-State Hotfix + +**Date:** 2026-05-11 16:05 CDT +**Coordinator:** Ripley +**Agents:** Neo, Bishop +**Status:** ✅ COMPLETED + +**Issue:** +Rows with an existing payment below the estimated expected amount could still show `DUE SOON` and an active `Pay` button, creating a duplicate-payment risk. Example: Discover (Tilynn) paid `$251` against an estimated `$255` still appeared payable. + +**Files modified:** +- `services/statusService.js` +- `routes/tracker.js` +- `package.json` +- `client/lib/version.js` + +**Fix:** +- Treat any non-deleted payment in the current billing cycle as paid/settled, even when it is below the estimate. +- Added tracker row flags `has_payment` and `is_settled`. +- Zero settled row balances so lower-than-estimate actual payments do not create phantom remaining debt. +- Summary remaining now uses summed outstanding row balances when no starting amount is configured. +- Bumped version to `0.24.6` with release notes. + +**Verification:** +- Targeted Node regression: partial payment below expected returns `paid`; no payment remains due/late as appropriate. +- `npm run build` passed. +- Bishop verification approved. +- `docker compose build` passed. + +--- diff --git a/client/lib/version.js b/client/lib/version.js index 0442793..fdc4949 100644 --- a/client/lib/version.js +++ b/client/lib/version.js @@ -1,12 +1,12 @@ -export const APP_VERSION = '0.24.5'; +export const APP_VERSION = '0.24.6'; export const APP_NAME = 'BillTracker'; export const RELEASE_NOTES = { - version: '0.24.5', + version: '0.24.6', date: '2026-05-11', highlights: [ + { icon: '🛡️', title: 'Duplicate Payment Fix', desc: 'Partial payments below the estimated amount are now correctly treated as paid — no more phantom Pay button after recording a payment.' }, { icon: '🔧', title: 'Starting Amounts Fix', desc: 'Paid deductions now correctly factor in the "other" bucket for remaining balance calculations.' }, { icon: '🎨', title: 'Pay Badge Alignment', desc: 'Amount input and Pay button now stay inline and centered, no more wrapping on tight layouts.' }, - { icon: '🌱', title: 'Demo Data Persistence', desc: 'Seed/unseed button state persists across tab switches — always checks actual DB state on mount.' }, ], }; \ No newline at end of file diff --git a/package.json b/package.json index 3d0fcd3..b353415 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.24.5", + "version": "0.24.6", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/tracker.js b/routes/tracker.js index 1edacba..d002fcb 100644 --- a/routes/tracker.js +++ b/routes/tracker.js @@ -125,6 +125,7 @@ router.get('/', (req, res) => { const hasStartingAmounts = !!startingAmounts; const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0); const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 0); + const activeOutstandingBalance = activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0); // Calculate previous month total const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0); @@ -197,7 +198,7 @@ router.get('/', (req, res) => { total_starting: totalStarting, has_starting_amounts: hasStartingAmounts, total_paid: activeTotalPaid, - remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : Math.max(0, activeTotalExpected - activeTotalPaid), + remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : activeOutstandingBalance, overdue: totalOverdue, count_paid: activeRows.filter(r => r.status === 'paid').length, count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length, diff --git a/services/statusService.js b/services/statusService.js index 6466ece..3c2db31 100644 --- a/services/statusService.js +++ b/services/statusService.js @@ -34,7 +34,8 @@ function getCycleRange(year, month) { * Returns status for a bill given its payments and due date. * * Statuses: - * paid — total payments >= expected_amount + * paid — has a non-deleted payment in this billing cycle + * — OR total paid >= expected_amount (fully settled) * autodraft — autopay_enabled and assumed_paid (no confirmed payment yet) * upcoming — due_date in the future * due_soon — due within 3 days @@ -43,10 +44,13 @@ function getCycleRange(year, month) { */ function calculateStatus(bill, payments, dueDate, today) { const gracePeriodDays = parseInt(getSetting('grace_period_days') || '5', 10); - const totalPaid = payments.reduce((sum, p) => sum + p.amount, 0); - const isPaid = totalPaid >= bill.expected_amount; + const safePayments = Array.isArray(payments) ? payments : []; + const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0); - if (isPaid) return 'paid'; + // A recorded payment is the user's confirmation that this cycle is handled. + // Expected amounts are estimates, so a lower actual payment must not leave a Pay + // button visible and invite duplicate payments. + if (safePayments.length > 0 || totalPaid >= bill.expected_amount) return 'paid'; if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') { return 'autodraft'; @@ -68,10 +72,15 @@ function calculateStatus(bill, payments, dueDate, today) { function buildTrackerRow(bill, payments, year, month, todayStr) { const dueDate = resolveDueDate(bill, year, month); const bucket = resolveBucket(bill); - const status = calculateStatus(bill, payments, dueDate, todayStr); - const totalPaid = payments.reduce((sum, p) => sum + p.amount, 0); - const lastPayment = payments.length - ? payments.sort((a, b) => b.paid_date.localeCompare(a.paid_date))[0] + const safePayments = Array.isArray(payments) ? payments : []; + const status = calculateStatus(bill, safePayments, dueDate, todayStr); + const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0); + const hasPayment = safePayments.length > 0; + const isSettled = status === 'paid' || status === 'autodraft'; + const rawBalance = bill.expected_amount - totalPaid; + const balance = isSettled ? 0 : Math.max(rawBalance, 0); + const lastPayment = hasPayment + ? [...safePayments].sort((a, b) => b.paid_date.localeCompare(a.paid_date))[0] : null; return { @@ -85,14 +94,16 @@ function buildTrackerRow(bill, payments, year, month, todayStr) { expected_amount: bill.expected_amount, notes: bill.notes || null, // Bill-level notes (always available) total_paid: totalPaid, - balance: bill.expected_amount - totalPaid, + balance, + has_payment: hasPayment, + is_settled: isSettled, last_paid_date: lastPayment ? lastPayment.paid_date : null, last_payment_amount: lastPayment ? lastPayment.amount : null, status, autopay_enabled: !!bill.autopay_enabled, autodraft_status: bill.autodraft_status, billing_cycle: bill.billing_cycle, - payments, + payments: safePayments, }; }