fix: prevent duplicate payment prompts

This commit is contained in:
null 2026-05-11 16:04:21 -05:00
parent 22f9a570aa
commit 98ede20cd3
5 changed files with 58 additions and 15 deletions

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.24.5",
"version": "0.24.6",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {

View File

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

View File

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