snowball bug fixes
This commit is contained in:
parent
cd61c2ef7f
commit
440f872d97
|
|
@ -143,6 +143,7 @@ export const api = {
|
||||||
createBill: (data) => post('/bills', data),
|
createBill: (data) => post('/bills', data),
|
||||||
updateBill: (id, data) => put(`/bills/${id}`, data),
|
updateBill: (id, data) => put(`/bills/${id}`, data),
|
||||||
updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }),
|
updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }),
|
||||||
|
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
|
||||||
deleteBill: (id) => del(`/bills/${id}`),
|
deleteBill: (id) => del(`/bills/${id}`),
|
||||||
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
|
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
|
||||||
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
|
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
|
||||||
|
|
|
||||||
|
|
@ -54,13 +54,19 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||||
const [currentBalance, setCurrentBalance] = useState(bill?.current_balance == null ? '' : String(bill.current_balance));
|
const [currentBalance, setCurrentBalance] = useState(bill?.current_balance == null ? '' : String(bill.current_balance));
|
||||||
const [minimumPayment, setMinimumPayment] = useState(bill?.minimum_payment == null ? '' : String(bill.minimum_payment));
|
const [minimumPayment, setMinimumPayment] = useState(bill?.minimum_payment == null ? '' : String(bill.minimum_payment));
|
||||||
const [snowballInclude, setSnowballInclude] = useState(!!bill?.snowball_include);
|
const [snowballInclude, setSnowballInclude] = useState(!!bill?.snowball_include);
|
||||||
|
const [snowballExempt, setSnowballExempt] = useState(!!bill?.snowball_exempt);
|
||||||
const [showDebtSection, setShowDebtSection] = useState(
|
const [showDebtSection, setShowDebtSection] = useState(
|
||||||
() => isDebtCat(categories, bill?.category_id ? String(bill.category_id) : CAT_NONE)
|
() => isDebtCat(categories, bill?.category_id ? String(bill.category_id) : CAT_NONE)
|
||||||
|
|| !!bill?.snowball_include
|
||||||
|
|| !!bill?.snowball_exempt
|
||||||
|
|| bill?.current_balance != null
|
||||||
|
|| bill?.minimum_payment != null
|
||||||
);
|
);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
const isDebtCategory = isDebtCat(categories, categoryId);
|
const isDebtCategory = isDebtCat(categories, categoryId);
|
||||||
|
const showOnSnowball = snowballInclude || (isDebtCategory && !snowballExempt);
|
||||||
|
|
||||||
const validateName = (val) => {
|
const validateName = (val) => {
|
||||||
if (!val || val.trim() === '') return 'Name is required';
|
if (!val || val.trim() === '') return 'Name is required';
|
||||||
|
|
@ -128,7 +134,21 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||||
|
|
||||||
const handleCategoryChange = (val) => {
|
const handleCategoryChange = (val) => {
|
||||||
setCategoryId(val);
|
setCategoryId(val);
|
||||||
if (isDebtCat(categories, val)) setShowDebtSection(true);
|
if (isDebtCat(categories, val)) {
|
||||||
|
setShowDebtSection(true);
|
||||||
|
} else {
|
||||||
|
setSnowballExempt(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSnowballVisibilityChange = (checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setSnowballExempt(false);
|
||||||
|
setSnowballInclude(!isDebtCategory);
|
||||||
|
} else {
|
||||||
|
setSnowballInclude(false);
|
||||||
|
setSnowballExempt(isDebtCategory);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
|
|
@ -170,6 +190,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||||
current_balance: currentBalance === '' ? null : parseFloat(currentBalance),
|
current_balance: currentBalance === '' ? null : parseFloat(currentBalance),
|
||||||
minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment),
|
minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment),
|
||||||
snowball_include: snowballInclude,
|
snowball_include: snowballInclude,
|
||||||
|
snowball_exempt: snowballExempt,
|
||||||
};
|
};
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -355,7 +376,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Debt / Credit Details — collapsible */}
|
{/* Debt / Snowball Details — collapsible */}
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -365,12 +386,17 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn('h-3.5 w-3.5 transition-transform duration-150', !showDebtSection && '-rotate-90')}
|
className={cn('h-3.5 w-3.5 transition-transform duration-150', !showDebtSection && '-rotate-90')}
|
||||||
/>
|
/>
|
||||||
Debt / Credit Details
|
Debt / Snowball Details
|
||||||
{isDebtCategory && (
|
{isDebtCategory && (
|
||||||
<span className="ml-1 text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
|
<span className="ml-1 text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
|
||||||
· auto-detected
|
· auto-detected
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{!showOnSnowball && isDebtCategory && (
|
||||||
|
<span className="ml-1 text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
|
||||||
|
· exempt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showDebtSection && (
|
{showDebtSection && (
|
||||||
|
|
@ -438,16 +464,16 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={snowballInclude}
|
checked={showOnSnowball}
|
||||||
onChange={e => setSnowballInclude(e.target.checked)}
|
onChange={e => handleSnowballVisibilityChange(e.target.checked)}
|
||||||
className="h-4 w-4 rounded border-border accent-emerald-500"
|
className="h-4 w-4 rounded border-border accent-emerald-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
|
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
|
||||||
Include in Snowball
|
Show on Debt Snowball
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-[10px] text-muted-foreground/70 pl-6">
|
<p className="text-[10px] text-muted-foreground/70 pl-6">
|
||||||
Force this bill onto the debt snowball page.
|
Uncheck to exempt an auto-detected debt bill, or check to include a non-debt bill.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle } from 'lucide-react';
|
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, X } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -166,6 +166,30 @@ function useSortable(items, setItems, setDirty) {
|
||||||
containerEl: null,
|
containerEl: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const indexFromPointer = useCallback((clientX, clientY) => {
|
||||||
|
const direct = document.elementFromPoint(clientX, clientY)?.closest?.('[data-card-index]');
|
||||||
|
if (direct?.dataset?.cardIndex != null) {
|
||||||
|
const idx = Number(direct.dataset.cardIndex);
|
||||||
|
if (Number.isInteger(idx)) return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = [...(state.current.containerEl?.querySelectorAll('[data-card-index]') || [])];
|
||||||
|
if (cards.length === 0) return state.current.currentIdx;
|
||||||
|
|
||||||
|
let nearestIdx = state.current.currentIdx;
|
||||||
|
let nearestDistance = Infinity;
|
||||||
|
for (const card of cards) {
|
||||||
|
const rect = card.getBoundingClientRect();
|
||||||
|
const centerY = rect.top + rect.height / 2;
|
||||||
|
const distance = Math.abs(clientY - centerY);
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearestDistance = distance;
|
||||||
|
nearestIdx = Number(card.dataset.cardIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Number.isInteger(nearestIdx) ? nearestIdx : state.current.currentIdx;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onPointerDown = useCallback((e, index) => {
|
const onPointerDown = useCallback((e, index) => {
|
||||||
// Only trigger on the grip handle (data-grip attr)
|
// Only trigger on the grip handle (data-grip attr)
|
||||||
if (!e.currentTarget.dataset.grip) return;
|
if (!e.currentTarget.dataset.grip) return;
|
||||||
|
|
@ -190,18 +214,16 @@ function useSortable(items, setItems, setDirty) {
|
||||||
|
|
||||||
const onPointerMove = useCallback((e) => {
|
const onPointerMove = useCallback((e) => {
|
||||||
if (state.current.fromIdx === null) return;
|
if (state.current.fromIdx === null) return;
|
||||||
const { containerEl, startY, itemHeight, currentIdx } = state.current;
|
const { containerEl, currentIdx } = state.current;
|
||||||
if (!containerEl) return;
|
if (!containerEl) return;
|
||||||
|
|
||||||
const dy = e.clientY - startY;
|
const newIdx = Math.max(0, Math.min(items.length - 1, indexFromPointer(e.clientX, e.clientY)));
|
||||||
const shift = Math.round(dy / itemHeight);
|
|
||||||
const newIdx = Math.max(0, Math.min(items.length - 1, state.current.fromIdx + shift));
|
|
||||||
|
|
||||||
if (newIdx !== currentIdx) {
|
if (newIdx !== currentIdx) {
|
||||||
state.current.currentIdx = newIdx;
|
state.current.currentIdx = newIdx;
|
||||||
setDraggingIdx(newIdx); // visual feedback on where card will land
|
setDraggingIdx(newIdx); // visual feedback on where card will land
|
||||||
}
|
}
|
||||||
}, [items.length]);
|
}, [indexFromPointer, items.length]);
|
||||||
|
|
||||||
const onPointerUp = useCallback((e) => {
|
const onPointerUp = useCallback((e) => {
|
||||||
const { fromIdx, currentIdx } = state.current;
|
const { fromIdx, currentIdx } = state.current;
|
||||||
|
|
@ -331,6 +353,18 @@ export default function SnowballPage() {
|
||||||
} catch (err) { toast.error(err.message || 'Failed to update balance'); }
|
} catch (err) { toast.error(err.message || 'Failed to update balance'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeFromSnowball = async (bill) => {
|
||||||
|
try {
|
||||||
|
await api.updateBillSnowball(bill.id, { snowball_include: false, snowball_exempt: true });
|
||||||
|
setBills(prev => prev.filter(b => b.id !== bill.id));
|
||||||
|
setDirty(true);
|
||||||
|
toast.success(`${bill.name} removed from Snowball`);
|
||||||
|
loadProjection();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to remove bill from Snowball');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ── stats ─────────────────────────────────────────────────────────────────
|
// ── stats ─────────────────────────────────────────────────────────────────
|
||||||
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0);
|
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0);
|
||||||
const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0);
|
const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0);
|
||||||
|
|
@ -442,6 +476,7 @@ export default function SnowballPage() {
|
||||||
<div
|
<div
|
||||||
key={bill.id}
|
key={bill.id}
|
||||||
data-card
|
data-card
|
||||||
|
data-card-index={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none',
|
'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none',
|
||||||
isAttack ? 'border-emerald-500/40' : 'border-border/40',
|
isAttack ? 'border-emerald-500/40' : 'border-border/40',
|
||||||
|
|
@ -490,6 +525,15 @@ export default function SnowballPage() {
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFromSnowball(bill)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-amber-400 transition-colors shrink-0"
|
||||||
|
title="Remove from Snowball"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats row */}
|
{/* Stats row */}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const COLUMN_WHITELIST = new Set([
|
||||||
// bills table columns
|
// bills table columns
|
||||||
'history_visibility', 'interest_rate', 'user_id',
|
'history_visibility', 'interest_rate', 'user_id',
|
||||||
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
|
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
|
||||||
|
'snowball_exempt',
|
||||||
// sessions table columns
|
// sessions table columns
|
||||||
'created_at',
|
'created_at',
|
||||||
]);
|
]);
|
||||||
|
|
@ -716,6 +717,21 @@ function reconcileLegacyMigrations() {
|
||||||
}
|
}
|
||||||
console.log('[migration] payments: balance_delta column added');
|
console.log('[migration] payments: balance_delta column added');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.51',
|
||||||
|
description: 'bills: snowball_exempt column for hiding debt-like bills',
|
||||||
|
check: function() {
|
||||||
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||||
|
return cols.includes('snowball_exempt');
|
||||||
|
},
|
||||||
|
run: function() {
|
||||||
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||||
|
if (!cols.includes('snowball_exempt')) {
|
||||||
|
db.exec('ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
console.log('[migration] bills: snowball_exempt column added');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -1236,6 +1252,18 @@ function runMigrations() {
|
||||||
}
|
}
|
||||||
console.log('[migration] payments: balance_delta column added');
|
console.log('[migration] payments: balance_delta column added');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.51',
|
||||||
|
description: 'bills: snowball_exempt column for hiding debt-like bills',
|
||||||
|
dependsOn: ['v0.50'],
|
||||||
|
run: function() {
|
||||||
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||||
|
if (!cols.includes('snowball_exempt')) {
|
||||||
|
db.exec('ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
console.log('[migration] bills: snowball_exempt column added');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -1622,6 +1650,10 @@ const ROLLBACK_SQL_MAP = {
|
||||||
'v0.50': {
|
'v0.50': {
|
||||||
description: 'payments: balance_delta column',
|
description: 'payments: balance_delta column',
|
||||||
sql: ['ALTER TABLE payments DROP COLUMN balance_delta']
|
sql: ['ALTER TABLE payments DROP COLUMN balance_delta']
|
||||||
|
},
|
||||||
|
'v0.51': {
|
||||||
|
description: 'bills: snowball_exempt column',
|
||||||
|
sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt']
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ CREATE TABLE IF NOT EXISTS bills (
|
||||||
account_info TEXT,
|
account_info TEXT,
|
||||||
has_2fa INTEGER NOT NULL DEFAULT 0,
|
has_2fa INTEGER NOT NULL DEFAULT 0,
|
||||||
active INTEGER NOT NULL DEFAULT 1,
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
current_balance REAL,
|
||||||
|
minimum_payment REAL,
|
||||||
|
snowball_order INTEGER,
|
||||||
|
snowball_include INTEGER NOT NULL DEFAULT 0,
|
||||||
|
snowball_exempt INTEGER NOT NULL DEFAULT 0,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
|
@ -39,6 +44,7 @@ CREATE TABLE IF NOT EXISTS payments (
|
||||||
paid_date TEXT NOT NULL,
|
paid_date TEXT NOT NULL,
|
||||||
method TEXT,
|
method TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
|
balance_delta REAL,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
@ -58,6 +64,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||||
is_default_admin INTEGER NOT NULL DEFAULT 0,
|
is_default_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||||
first_login INTEGER NOT NULL DEFAULT 1,
|
first_login INTEGER NOT NULL DEFAULT 1,
|
||||||
|
snowball_extra_payment REAL NOT NULL DEFAULT 0,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -147,8 +147,8 @@ router.post('/', (req, res) => {
|
||||||
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount,
|
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount,
|
||||||
interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username,
|
interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username,
|
||||||
account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day,
|
account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day,
|
||||||
current_balance, minimum_payment, snowball_order, snowball_include)
|
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
req.user.id,
|
req.user.id,
|
||||||
normalized.name,
|
normalized.name,
|
||||||
|
|
@ -173,6 +173,7 @@ router.post('/', (req, res) => {
|
||||||
normalized.minimum_payment,
|
normalized.minimum_payment,
|
||||||
normalized.snowball_order,
|
normalized.snowball_order,
|
||||||
normalized.snowball_include,
|
normalized.snowball_include,
|
||||||
|
normalized.snowball_exempt,
|
||||||
);
|
);
|
||||||
|
|
||||||
const created = db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid);
|
const created = db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
|
@ -205,7 +206,7 @@ router.put('/:id', (req, res) => {
|
||||||
expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?,
|
expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?,
|
||||||
website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?,
|
website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?,
|
||||||
history_visibility = ?, cycle_type = ?, cycle_day = ?,
|
history_visibility = ?, cycle_type = ?, cycle_day = ?,
|
||||||
current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?,
|
current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, snowball_exempt = ?,
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
WHERE id = ? AND user_id = ?
|
WHERE id = ? AND user_id = ?
|
||||||
`).run(
|
`).run(
|
||||||
|
|
@ -232,6 +233,7 @@ router.put('/:id', (req, res) => {
|
||||||
normalized.minimum_payment,
|
normalized.minimum_payment,
|
||||||
normalized.snowball_order,
|
normalized.snowball_order,
|
||||||
normalized.snowball_include,
|
normalized.snowball_include,
|
||||||
|
normalized.snowball_exempt,
|
||||||
req.params.id,
|
req.params.id,
|
||||||
req.user.id,
|
req.user.id,
|
||||||
);
|
);
|
||||||
|
|
@ -519,4 +521,24 @@ router.patch('/:id/balance', (req, res) => {
|
||||||
res.json({ id: billId, current_balance: val });
|
res.json({ id: billId, current_balance: val });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── PATCH /api/bills/:id/snowball — lightweight snowball visibility update ───
|
||||||
|
router.patch('/:id/snowball', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const billId = parseInt(req.params.id, 10);
|
||||||
|
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) {
|
||||||
|
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const include = req.body.snowball_include ? 1 : 0;
|
||||||
|
const exempt = req.body.snowball_exempt ? 1 : 0;
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE bills
|
||||||
|
SET snowball_include = ?, snowball_exempt = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
`).run(include, exempt, billId, req.user.id);
|
||||||
|
|
||||||
|
res.json({ id: billId, snowball_include: include, snowball_exempt: exempt });
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,16 @@ const { calculateSnowball, calculateAvalanche } = require('../services/snowballS
|
||||||
|
|
||||||
const DEBT_LIKE_CLAUSES = `(
|
const DEBT_LIKE_CLAUSES = `(
|
||||||
b.snowball_include = 1
|
b.snowball_include = 1
|
||||||
OR LOWER(c.name) LIKE '%credit%'
|
OR (
|
||||||
OR LOWER(c.name) LIKE '%loan%'
|
COALESCE(b.snowball_exempt, 0) = 0
|
||||||
OR LOWER(c.name) LIKE '%mortgage%'
|
AND (
|
||||||
OR LOWER(c.name) LIKE '%housing%'
|
LOWER(c.name) LIKE '%credit%'
|
||||||
OR LOWER(c.name) LIKE '%debt%'
|
OR LOWER(c.name) LIKE '%loan%'
|
||||||
|
OR LOWER(c.name) LIKE '%mortgage%'
|
||||||
|
OR LOWER(c.name) LIKE '%housing%'
|
||||||
|
OR LOWER(c.name) LIKE '%debt%'
|
||||||
|
)
|
||||||
|
)
|
||||||
)`;
|
)`;
|
||||||
|
|
||||||
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
|
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
|
||||||
|
|
|
||||||
|
|
@ -128,9 +128,9 @@ function seedDemoData(userId = null) {
|
||||||
const insertBill = db.prepare(`
|
const insertBill = db.prepare(`
|
||||||
INSERT INTO bills (user_id, name, category_id, due_day, billing_cycle,
|
INSERT INTO bills (user_id, name, category_id, due_day, billing_cycle,
|
||||||
expected_amount, autopay_enabled, interest_rate,
|
expected_amount, autopay_enabled, interest_rate,
|
||||||
current_balance, minimum_payment, snowball_order, snowball_include,
|
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt,
|
||||||
active, is_seeded)
|
active, is_seeded)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
for (const billData of BILLS) {
|
for (const billData of BILLS) {
|
||||||
|
|
@ -152,7 +152,8 @@ function seedDemoData(userId = null) {
|
||||||
billData.currentBalance ?? null,
|
billData.currentBalance ?? null,
|
||||||
billData.minPayment ?? null,
|
billData.minPayment ?? null,
|
||||||
billData.snowballOrder ?? null,
|
billData.snowballOrder ?? null,
|
||||||
billData.snowballInclude ?? 0
|
billData.snowballInclude ?? 0,
|
||||||
|
billData.snowballExempt ?? 0
|
||||||
);
|
);
|
||||||
billsCreated++;
|
billsCreated++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,11 @@ function validateBillData(data, existingBill = null) {
|
||||||
? (data.snowball_include ? 1 : 0)
|
? (data.snowball_include ? 1 : 0)
|
||||||
: (existingBill?.snowball_include ?? 0);
|
: (existingBill?.snowball_include ?? 0);
|
||||||
|
|
||||||
|
// snowball_exempt — manual override to hide an auto-detected debt-like bill
|
||||||
|
normalized.snowball_exempt = data.snowball_exempt !== undefined
|
||||||
|
? (data.snowball_exempt ? 1 : 0)
|
||||||
|
: (existingBill?.snowball_exempt ?? 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
errors,
|
errors,
|
||||||
normalized: {
|
normalized: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue