diff --git a/client/api.js b/client/api.js
index 0ce2506..54be6c9 100644
--- a/client/api.js
+++ b/client/api.js
@@ -143,6 +143,7 @@ export const api = {
createBill: (data) => post('/bills', data),
updateBill: (id, data) => put(`/bills/${id}`, data),
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}`),
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx
index d8d2fe6..8aa614f 100644
--- a/client/components/BillModal.jsx
+++ b/client/components/BillModal.jsx
@@ -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 [minimumPayment, setMinimumPayment] = useState(bill?.minimum_payment == null ? '' : String(bill.minimum_payment));
const [snowballInclude, setSnowballInclude] = useState(!!bill?.snowball_include);
+ const [snowballExempt, setSnowballExempt] = useState(!!bill?.snowball_exempt);
const [showDebtSection, setShowDebtSection] = useState(
() => 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 [errors, setErrors] = useState({});
const isDebtCategory = isDebtCat(categories, categoryId);
+ const showOnSnowball = snowballInclude || (isDebtCategory && !snowballExempt);
const validateName = (val) => {
if (!val || val.trim() === '') return 'Name is required';
@@ -128,7 +134,21 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
const handleCategoryChange = (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) {
@@ -170,6 +190,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
current_balance: currentBalance === '' ? null : parseFloat(currentBalance),
minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment),
snowball_include: snowballInclude,
+ snowball_exempt: snowballExempt,
};
setBusy(true);
try {
@@ -355,7 +376,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
- {/* Debt / Credit Details — collapsible */}
+ {/* Debt / Snowball Details — collapsible */}
- Debt / Credit Details
+ Debt / Snowball Details
{isDebtCategory && (
· auto-detected
)}
+ {!showOnSnowball && isDebtCategory && (
+
+ · exempt
+
+ )}
{showDebtSection && (
@@ -438,16 +464,16 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
- Force this bill onto the debt snowball page.
+ Uncheck to exempt an auto-detected debt bill, or check to include a non-debt bill.
diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx
index 46d6e81..e7df064 100644
--- a/client/pages/SnowballPage.jsx
+++ b/client/pages/SnowballPage.jsx
@@ -1,5 +1,5 @@
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 { api } from '@/api';
import { Button } from '@/components/ui/button';
@@ -166,6 +166,30 @@ function useSortable(items, setItems, setDirty) {
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) => {
// Only trigger on the grip handle (data-grip attr)
if (!e.currentTarget.dataset.grip) return;
@@ -190,18 +214,16 @@ function useSortable(items, setItems, setDirty) {
const onPointerMove = useCallback((e) => {
if (state.current.fromIdx === null) return;
- const { containerEl, startY, itemHeight, currentIdx } = state.current;
+ const { containerEl, currentIdx } = state.current;
if (!containerEl) return;
- const dy = e.clientY - startY;
- const shift = Math.round(dy / itemHeight);
- const newIdx = Math.max(0, Math.min(items.length - 1, state.current.fromIdx + shift));
+ const newIdx = Math.max(0, Math.min(items.length - 1, indexFromPointer(e.clientX, e.clientY)));
if (newIdx !== currentIdx) {
state.current.currentIdx = newIdx;
setDraggingIdx(newIdx); // visual feedback on where card will land
}
- }, [items.length]);
+ }, [indexFromPointer, items.length]);
const onPointerUp = useCallback((e) => {
const { fromIdx, currentIdx } = state.current;
@@ -331,6 +353,18 @@ export default function SnowballPage() {
} 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 ─────────────────────────────────────────────────────────────────
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0);
const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0);
@@ -442,6 +476,7 @@ export default function SnowballPage() {
Edit
+
{/* Stats row */}
diff --git a/db/database.js b/db/database.js
index 44cced4..0822c02 100644
--- a/db/database.js
+++ b/db/database.js
@@ -44,6 +44,7 @@ const COLUMN_WHITELIST = new Set([
// bills table columns
'history_visibility', 'interest_rate', 'user_id',
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
+ 'snowball_exempt',
// sessions table columns
'created_at',
]);
@@ -716,6 +717,21 @@ function reconcileLegacyMigrations() {
}
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');
}
+ },
+ {
+ 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': {
description: 'payments: balance_delta column',
sql: ['ALTER TABLE payments DROP COLUMN balance_delta']
+ },
+ 'v0.51': {
+ description: 'bills: snowball_exempt column',
+ sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt']
}
};
diff --git a/db/schema.sql b/db/schema.sql
index 66f4795..224905b 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -27,6 +27,11 @@ CREATE TABLE IF NOT EXISTS bills (
account_info TEXT,
has_2fa INTEGER NOT NULL DEFAULT 0,
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,
created_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,
method TEXT,
notes TEXT,
+ balance_delta REAL,
created_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,
must_change_password INTEGER NOT NULL DEFAULT 0,
first_login INTEGER NOT NULL DEFAULT 1,
+ snowball_extra_payment REAL NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
diff --git a/routes/bills.js b/routes/bills.js
index 8dba089..fab3a7c 100644
--- a/routes/bills.js
+++ b/routes/bills.js
@@ -147,8 +147,8 @@ router.post('/', (req, res) => {
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount,
interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username,
account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day,
- current_balance, minimum_payment, snowball_order, snowball_include)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
+ current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
`).run(
req.user.id,
normalized.name,
@@ -173,6 +173,7 @@ router.post('/', (req, res) => {
normalized.minimum_payment,
normalized.snowball_order,
normalized.snowball_include,
+ normalized.snowball_exempt,
);
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 = ?,
website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?,
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')
WHERE id = ? AND user_id = ?
`).run(
@@ -232,6 +233,7 @@ router.put('/:id', (req, res) => {
normalized.minimum_payment,
normalized.snowball_order,
normalized.snowball_include,
+ normalized.snowball_exempt,
req.params.id,
req.user.id,
);
@@ -519,4 +521,24 @@ router.patch('/:id/balance', (req, res) => {
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;
diff --git a/routes/snowball.js b/routes/snowball.js
index 2056775..b288b8e 100644
--- a/routes/snowball.js
+++ b/routes/snowball.js
@@ -6,11 +6,16 @@ const { calculateSnowball, calculateAvalanche } = require('../services/snowballS
const DEBT_LIKE_CLAUSES = `(
b.snowball_include = 1
- OR LOWER(c.name) LIKE '%credit%'
- OR LOWER(c.name) LIKE '%loan%'
- OR LOWER(c.name) LIKE '%mortgage%'
- OR LOWER(c.name) LIKE '%housing%'
- OR LOWER(c.name) LIKE '%debt%'
+ OR (
+ COALESCE(b.snowball_exempt, 0) = 0
+ AND (
+ LOWER(c.name) LIKE '%credit%'
+ 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
diff --git a/scripts/seedDemoData.js b/scripts/seedDemoData.js
index 6250aaf..83686fa 100644
--- a/scripts/seedDemoData.js
+++ b/scripts/seedDemoData.js
@@ -128,9 +128,9 @@ function seedDemoData(userId = null) {
const insertBill = db.prepare(`
INSERT INTO bills (user_id, name, category_id, due_day, billing_cycle,
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)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
`);
for (const billData of BILLS) {
@@ -152,7 +152,8 @@ function seedDemoData(userId = null) {
billData.currentBalance ?? null,
billData.minPayment ?? null,
billData.snowballOrder ?? null,
- billData.snowballInclude ?? 0
+ billData.snowballInclude ?? 0,
+ billData.snowballExempt ?? 0
);
billsCreated++;
} catch (err) {
diff --git a/services/billsService.js b/services/billsService.js
index a527857..07647e0 100644
--- a/services/billsService.js
+++ b/services/billsService.js
@@ -226,6 +226,11 @@ function validateBillData(data, existingBill = null) {
? (data.snowball_include ? 1 : 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 {
errors,
normalized: {