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 **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 APP_NAME = 'BillTracker';
export const RELEASE_NOTES = { export const RELEASE_NOTES = {
version: '0.24.5', version: '0.24.6',
date: '2026-05-11', date: '2026-05-11',
highlights: [ 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: '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: '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", "name": "bill-tracker",
"version": "0.24.5", "version": "0.24.6",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View File

@ -125,6 +125,7 @@ router.get('/', (req, res) => {
const hasStartingAmounts = !!startingAmounts; const hasStartingAmounts = !!startingAmounts;
const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0); const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 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 // Calculate previous month total
const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0); const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0);
@ -197,7 +198,7 @@ router.get('/', (req, res) => {
total_starting: totalStarting, total_starting: totalStarting,
has_starting_amounts: hasStartingAmounts, has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid, total_paid: activeTotalPaid,
remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : Math.max(0, activeTotalExpected - activeTotalPaid), remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : activeOutstandingBalance,
overdue: totalOverdue, overdue: totalOverdue,
count_paid: activeRows.filter(r => r.status === 'paid').length, count_paid: activeRows.filter(r => r.status === 'paid').length,
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').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. * Returns status for a bill given its payments and due date.
* *
* Statuses: * 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) * autodraft autopay_enabled and assumed_paid (no confirmed payment yet)
* upcoming due_date in the future * upcoming due_date in the future
* due_soon due within 3 days * due_soon due within 3 days
@ -43,10 +44,13 @@ function getCycleRange(year, month) {
*/ */
function calculateStatus(bill, payments, dueDate, today) { function calculateStatus(bill, payments, dueDate, today) {
const gracePeriodDays = parseInt(getSetting('grace_period_days') || '5', 10); const gracePeriodDays = parseInt(getSetting('grace_period_days') || '5', 10);
const totalPaid = payments.reduce((sum, p) => sum + p.amount, 0); const safePayments = Array.isArray(payments) ? payments : [];
const isPaid = totalPaid >= bill.expected_amount; 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') { if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') {
return 'autodraft'; return 'autodraft';
@ -68,10 +72,15 @@ function calculateStatus(bill, payments, dueDate, today) {
function buildTrackerRow(bill, payments, year, month, todayStr) { function buildTrackerRow(bill, payments, year, month, todayStr) {
const dueDate = resolveDueDate(bill, year, month); const dueDate = resolveDueDate(bill, year, month);
const bucket = resolveBucket(bill); const bucket = resolveBucket(bill);
const status = calculateStatus(bill, payments, dueDate, todayStr); const safePayments = Array.isArray(payments) ? payments : [];
const totalPaid = payments.reduce((sum, p) => sum + p.amount, 0); const status = calculateStatus(bill, safePayments, dueDate, todayStr);
const lastPayment = payments.length const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
? payments.sort((a, b) => b.paid_date.localeCompare(a.paid_date))[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; : null;
return { return {
@ -85,14 +94,16 @@ function buildTrackerRow(bill, payments, year, month, todayStr) {
expected_amount: bill.expected_amount, expected_amount: bill.expected_amount,
notes: bill.notes || null, // Bill-level notes (always available) notes: bill.notes || null, // Bill-level notes (always available)
total_paid: totalPaid, total_paid: totalPaid,
balance: bill.expected_amount - totalPaid, balance,
has_payment: hasPayment,
is_settled: isSettled,
last_paid_date: lastPayment ? lastPayment.paid_date : null, last_paid_date: lastPayment ? lastPayment.paid_date : null,
last_payment_amount: lastPayment ? lastPayment.amount : null, last_payment_amount: lastPayment ? lastPayment.amount : null,
status, status,
autopay_enabled: !!bill.autopay_enabled, autopay_enabled: !!bill.autopay_enabled,
autodraft_status: bill.autodraft_status, autodraft_status: bill.autodraft_status,
billing_cycle: bill.billing_cycle, billing_cycle: bill.billing_cycle,
payments, payments: safePayments,
}; };
} }