fix: prevent duplicate payment prompts
This commit is contained in:
parent
22f9a570aa
commit
98ede20cd3
|
|
@ -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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
|
||||||
|
|
@ -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.' },
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue