This commit is contained in:
null 2026-05-16 10:34:32 -05:00
parent 9174ec3290
commit 59d9d21d4c
18 changed files with 360 additions and 102 deletions

View File

@ -154,6 +154,7 @@ export const api = {
}, },
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data), updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
deleteBill: (id) => del(`/bills/${id}`), deleteBill: (id) => del(`/bills/${id}`),
restoreBill: (id) => post(`/bills/${id}/restore`),
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}`),
billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`), billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
@ -183,6 +184,7 @@ export const api = {
createCategory: (data) => post('/categories', data), createCategory: (data) => post('/categories', data),
updateCategory: (id, data) => put(`/categories/${id}`, data), updateCategory: (id, data) => put(`/categories/${id}`, data),
deleteCategory: (id) => del(`/categories/${id}`), deleteCategory: (id) => del(`/categories/${id}`),
restoreCategory: (id) => post(`/categories/${id}/restore`),
// Settings // Settings
settings: () => get('/settings'), settings: () => get('/settings'),

View File

@ -10,8 +10,8 @@ export const RELEASE_NOTES = {
highlights: [ highlights: [
{ {
icon: '🛡️', icon: '🛡️',
title: 'Safer payment and settings data', title: 'Safer financial history',
desc: 'Payment amounts and dates are now validated consistently, and regular-user settings are stored per user instead of globally.', desc: 'Bill, category, and payment deletes now use confirmations, recovery windows, and undo actions so accidental clicks do not immediately destroy important records.',
}, },
{ {
icon: '🔐', icon: '🔐',

View File

@ -504,7 +504,21 @@ export default function BillsPage() {
const bill = deleteTarget; const bill = deleteTarget;
await api.deleteBill(bill.id); await api.deleteBill(bill.id);
setBills(prev => prev.filter(b => b.id !== bill.id)); setBills(prev => prev.filter(b => b.id !== bill.id));
toast.success(`"${bill.name}" deleted permanently`); toast.success(`"${bill.name}" moved to recovery`, {
description: 'It will be permanently purged after 30 days.',
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restoreBill(bill.id);
toast.success(`"${bill.name}" restored`);
load();
} catch (err) {
toast.error(err.message || 'Failed to restore bill');
}
},
},
});
setDeleteTarget(null); setDeleteTarget(null);
setDeleteConfirmed(false); setDeleteConfirmed(false);
load(); load();
@ -670,7 +684,7 @@ export default function BillsPage() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* ── Permanent delete confirmation ── */} {/* ── Soft delete confirmation ── */}
<AlertDialog <AlertDialog
open={!!deleteTarget} open={!!deleteTarget}
onOpenChange={open => { onOpenChange={open => {
@ -682,15 +696,15 @@ export default function BillsPage() {
> >
<AlertDialogContent className="max-w-lg"> <AlertDialogContent className="max-w-lg">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete "{deleteTarget?.name}" permanently?</AlertDialogTitle> <AlertDialogTitle>Move "{deleteTarget?.name}" to recovery?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This permanently deletes the bill and all data for this bill, including payments, This hides the bill from normal tracking and keeps its payments, monthly history,
monthly history, notes, and history ranges. This cannot be undone. notes, and history ranges recoverable for 30 days.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="rounded-lg border border-destructive/25 bg-destructive/10 px-4 py-3 text-sm text-destructive"> <div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
Deactivate is the safer option if you only want to hide this bill from active tracking. Deactivate is still the best option if you want to retain the bill long-term but hide it from active tracking.
</div> </div>
<label className="flex items-start gap-3 rounded-lg border border-border bg-muted/25 px-4 py-3 text-sm"> <label className="flex items-start gap-3 rounded-lg border border-border bg-muted/25 px-4 py-3 text-sm">
@ -701,7 +715,7 @@ export default function BillsPage() {
disabled={deleteBusy} disabled={deleteBusy}
className="mt-0.5 h-4 w-4 rounded border-input bg-input accent-destructive" className="mt-0.5 h-4 w-4 rounded border-input bg-input accent-destructive"
/> />
<span>I understand this deletes all data for this bill and cannot be undone.</span> <span>I understand this removes the bill from normal views and will be purged after 30 days if not restored.</span>
</label> </label>
<AlertDialogFooter className="sm:justify-between"> <AlertDialogFooter className="sm:justify-between">
@ -726,7 +740,7 @@ export default function BillsPage() {
disabled={!deleteConfirmed || deleteBusy} disabled={!deleteConfirmed || deleteBusy}
onClick={handleDeleteConfirmed} onClick={handleDeleteConfirmed}
> >
{deleteBusy ? 'Deleting…' : 'Delete permanently'} {deleteBusy ? 'Moving…' : 'Move to recovery'}
</Button> </Button>
</div> </div>
</AlertDialogFooter> </AlertDialogFooter>

View File

@ -315,11 +315,26 @@ export default function CategoriesPage() {
async function handleDelete() { async function handleDelete() {
setDeleting(true); setDeleting(true);
try { try {
const category = deleteTarget;
await api.deleteCategory(deleteTarget.id); await api.deleteCategory(deleteTarget.id);
toast.success(`"${deleteTarget.name}" deleted`); toast.success(`"${category.name}" moved to recovery`, {
description: 'Bills keep their category if you restore it within 30 days.',
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restoreCategory(category.id);
toast.success(`"${category.name}" restored`);
load();
} catch (err) {
toast.error(err.message || 'Failed to restore category');
}
},
},
});
setExpanded(prev => { setExpanded(prev => {
const next = new Set(prev); const next = new Set(prev);
next.delete(deleteTarget.id); next.delete(category.id);
return next; return next;
}); });
setDeleteTarget(null); setDeleteTarget(null);
@ -488,9 +503,9 @@ export default function CategoriesPage() {
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}> <AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete {deleteTarget?.name}?</AlertDialogTitle> <AlertDialogTitle>Move {deleteTarget?.name} to recovery?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Bills in this category will become uncategorized. No bills or payments will be deleted. This hides the category from normal views. Bills keep their category link, and you can restore it for 30 days.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@ -500,7 +515,7 @@ export default function CategoriesPage() {
onClick={handleDelete} onClick={handleDelete}
disabled={deleting} disabled={deleting}
> >
{deleting ? 'Deleting...' : 'Delete Category'} {deleting ? 'Moving...' : 'Move to Recovery'}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View File

@ -14,6 +14,10 @@ import {
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog';
import { import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select'; } from '@/components/ui/select';
@ -690,6 +694,7 @@ function PaymentModal({ payment, onClose, onSave }) {
const [method, setMethod] = useState(payment.method || METHOD_NONE); const [method, setMethod] = useState(payment.method || METHOD_NONE);
const [notes, setNotes] = useState(payment.notes || ''); const [notes, setNotes] = useState(payment.notes || '');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
async function handleSave(e) { async function handleSave(e) {
e.preventDefault(); e.preventDefault();
@ -712,7 +717,20 @@ function PaymentModal({ payment, onClose, onSave }) {
setBusy(true); setBusy(true);
try { try {
await api.deletePayment(payment.id); await api.deletePayment(payment.id);
toast.success('Payment removed. Bill is now marked as unpaid.'); toast.success('Payment moved to recovery. Bill is now marked as unpaid.', {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restorePayment(payment.id);
toast.success('Payment restored');
onSave();
} catch (err) {
toast.error(err.message || 'Failed to restore payment');
}
},
},
});
onSave(); onClose(); onSave(); onClose();
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(err.message);
@ -720,8 +738,9 @@ function PaymentModal({ payment, onClose, onSave }) {
} }
return ( return (
<Dialog open onOpenChange={v => { if (!v) onClose(); }}> <>
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl"> <Dialog open onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">Edit Payment</DialogTitle> <DialogTitle className="text-base font-semibold tracking-tight">Edit Payment</DialogTitle>
</DialogHeader> </DialogHeader>
@ -764,7 +783,7 @@ function PaymentModal({ payment, onClose, onSave }) {
<DialogFooter className="flex-row gap-2 sm:justify-between mt-2"> <DialogFooter className="flex-row gap-2 sm:justify-between mt-2">
<Button <Button
type="button" variant="destructive" disabled={busy} onClick={handleDelete} type="button" variant="destructive" disabled={busy} onClick={() => setConfirmDelete(true)}
className="text-xs" className="text-xs"
title="Removes this payment record. The bill itself is NOT deleted." title="Removes this payment record. The bill itself is NOT deleted."
> >
@ -775,8 +794,30 @@ function PaymentModal({ payment, onClose, onSave }) {
<Button type="submit" form="payment-modal-form" disabled={busy} className="text-xs">Save</Button> <Button type="submit" form="payment-modal-form" disabled={busy} className="text-xs">Save</Button>
</div> </div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove this payment?</AlertDialogTitle>
<AlertDialogDescription>
This marks the payment as removed and reverses any debt balance update. You can undo it from the toast.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={busy}
onClick={handleDelete}
>
{busy ? 'Removing...' : 'Remove Payment'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
); );
} }
@ -785,6 +826,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
const amountRef = useRef(null); const amountRef = useRef(null);
const [editPayment, setEditPayment] = useState(null); const [editPayment, setEditPayment] = useState(null);
const [showMbs, setShowMbs] = useState(false); const [showMbs, setShowMbs] = useState(false);
const [confirmUnpay, setConfirmUnpay] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Effective amount threshold for this bill this month: // Effective amount threshold for this bill this month:
@ -820,7 +862,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
} }
} }
async function handleTogglePaid() { async function performTogglePaid() {
setLoading?.(true); setLoading?.(true);
try { try {
const result = await api.togglePaid(row.id, { const result = await api.togglePaid(row.id, {
@ -828,7 +870,24 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
year: year, year: year,
month: month, month: month,
}); });
toast.success(isPaid ? 'Payment removed' : 'Payment recorded'); if (isPaid && result.paymentId) {
toast.success('Payment moved to recovery', {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restorePayment(result.paymentId);
toast.success('Payment restored');
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to restore payment');
}
},
},
});
} else {
toast.success('Payment recorded');
}
refresh?.(); refresh?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to toggle payment status'); toast.error(err.message || 'Failed to toggle payment status');
@ -837,6 +896,14 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
} }
} }
function handleTogglePaid() {
if (isPaid) {
setConfirmUnpay(true);
return;
}
performTogglePaid();
}
return ( return (
<> <>
<TableRow <TableRow
@ -1006,7 +1073,26 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
/> />
)} )}
{/* Payment toggle confirmation dialog */} <AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark this bill unpaid?</AlertDialogTitle>
<AlertDialogDescription>
This removes the current payment record for this month and moves it into recovery.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={loading}
onClick={performTogglePaid}
>
{loading ? 'Removing...' : 'Remove Payment'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
); );
} }
@ -1015,6 +1101,7 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
const amountRef = useRef(null); const amountRef = useRef(null);
const [editPayment, setEditPayment] = useState(null); const [editPayment, setEditPayment] = useState(null);
const [showMbs, setShowMbs] = useState(false); const [showMbs, setShowMbs] = useState(false);
const [confirmUnpay, setConfirmUnpay] = useState(false);
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount; const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day); const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
@ -1041,20 +1128,45 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
} }
} }
async function handleTogglePaid() { async function performTogglePaid() {
try { try {
await api.togglePaid(row.id, { const result = await api.togglePaid(row.id, {
amount: isPaid ? undefined : threshold, amount: isPaid ? undefined : threshold,
year: year, year: year,
month: month, month: month,
}); });
toast.success(isPaid ? 'Payment removed' : 'Payment recorded'); if (isPaid && result.paymentId) {
toast.success('Payment moved to recovery', {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restorePayment(result.paymentId);
toast.success('Payment restored');
refresh();
} catch (err) {
toast.error(err.message || 'Failed to restore payment');
}
},
},
});
} else {
toast.success('Payment recorded');
}
refresh(); refresh();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to toggle payment status'); toast.error(err.message || 'Failed to toggle payment status');
} }
} }
function handleTogglePaid() {
if (isPaid) {
setConfirmUnpay(true);
return;
}
performTogglePaid();
}
return ( return (
<> <>
<div <div
@ -1200,6 +1312,26 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
onSaved={refresh} onSaved={refresh}
/> />
)} )}
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark this bill unpaid?</AlertDialogTitle>
<AlertDialogDescription>
This removes the current payment record for this month and moves it into recovery.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={performTogglePaid}
>
Remove Payment
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
); );
} }

View File

@ -44,7 +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', 'snowball_exempt', 'deleted_at',
// sessions table columns // sessions table columns
'created_at', 'created_at',
]); ]);
@ -770,6 +770,28 @@ function reconcileLegacyMigrations() {
`); `);
console.log('[migration] user_login_history table created'); console.log('[migration] user_login_history table created');
} }
},
{
version: 'v0.56',
description: 'bills/categories: soft-delete columns',
check: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
return billCols.includes('deleted_at') && catCols.includes('deleted_at');
},
run: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
if (!billCols.includes('deleted_at')) {
db.exec('ALTER TABLE bills ADD COLUMN deleted_at TEXT');
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_deleted ON bills(user_id, deleted_at)');
}
if (!catCols.includes('deleted_at')) {
db.exec('ALTER TABLE categories ADD COLUMN deleted_at TEXT');
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(user_id, deleted_at)');
}
console.log('[migration] bills/categories deleted_at columns added');
}
} }
]; ];
@ -1397,6 +1419,24 @@ function runMigrations() {
} }
console.log('[migration] user_login_history device metadata columns ensured'); console.log('[migration] user_login_history device metadata columns ensured');
} }
},
{
version: 'v0.56',
description: 'bills/categories: soft-delete columns',
dependsOn: ['v0.55'],
run: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
if (!billCols.includes('deleted_at')) {
db.exec('ALTER TABLE bills ADD COLUMN deleted_at TEXT');
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_deleted ON bills(user_id, deleted_at)');
}
if (!catCols.includes('deleted_at')) {
db.exec('ALTER TABLE categories ADD COLUMN deleted_at TEXT');
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(user_id, deleted_at)');
}
console.log('[migration] bills/categories deleted_at columns added');
}
} }
]; ];
@ -1808,6 +1848,15 @@ const ROLLBACK_SQL_MAP = {
'ALTER TABLE user_login_history DROP COLUMN os', 'ALTER TABLE user_login_history DROP COLUMN os',
'ALTER TABLE user_login_history DROP COLUMN browser', 'ALTER TABLE user_login_history DROP COLUMN browser',
] ]
},
'v0.56': {
description: 'bills/categories soft-delete columns',
sql: [
'DROP INDEX IF EXISTS idx_categories_deleted',
'DROP INDEX IF EXISTS idx_bills_deleted',
'ALTER TABLE categories DROP COLUMN deleted_at',
'ALTER TABLE bills DROP COLUMN deleted_at',
]
} }
}; };

View File

@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
deleted_at 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'))
); );
@ -32,6 +33,7 @@ CREATE TABLE IF NOT EXISTS bills (
snowball_order INTEGER, snowball_order INTEGER,
snowball_include INTEGER NOT NULL DEFAULT 0, snowball_include INTEGER NOT NULL DEFAULT 0,
snowball_exempt INTEGER NOT NULL DEFAULT 0, snowball_exempt INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT,
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'))

View File

@ -81,7 +81,7 @@ function isMonthInPast(year, month) {
} }
function buildBillWhere({ userId, categoryId, billId, includeInactive }) { function buildBillWhere({ userId, categoryId, billId, includeInactive }) {
const clauses = ['b.user_id = ?']; const clauses = ['b.user_id = ?', 'b.deleted_at IS NULL'];
const params = [userId]; const params = [userId];
if (!includeInactive) clauses.push('b.active = 1'); if (!includeInactive) clauses.push('b.active = 1');
if (categoryId) { if (categoryId) {
@ -110,6 +110,7 @@ router.get('/summary', (req, res) => {
SELECT id, name SELECT id, name
FROM categories FROM categories
WHERE user_id = ? WHERE user_id = ?
AND deleted_at IS NULL
ORDER BY name COLLATE NOCASE ORDER BY name COLLATE NOCASE
`).all(userId); `).all(userId);
@ -117,7 +118,7 @@ router.get('/summary', (req, res) => {
SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at, SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at,
c.name AS category_name c.name AS category_name
FROM bills b FROM bills b
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
WHERE ${billWhere.where} WHERE ${billWhere.where}
ORDER BY b.name COLLATE NOCASE ORDER BY b.name COLLATE NOCASE
`).all(...billWhere.params); `).all(...billWhere.params);
@ -154,6 +155,7 @@ router.get('/summary', (req, res) => {
FROM payments p FROM payments p
JOIN bills b ON b.id = p.bill_id JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ? WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND p.bill_id IN (${placeholders}) AND p.bill_id IN (${placeholders})
AND p.paid_date BETWEEN ? AND ? AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL AND p.deleted_at IS NULL
@ -169,6 +171,7 @@ router.get('/summary', (req, res) => {
FROM monthly_bill_state m FROM monthly_bill_state m
JOIN bills b ON b.id = m.bill_id JOIN bills b ON b.id = m.bill_id
WHERE b.user_id = ? WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND m.bill_id IN (${placeholders}) AND m.bill_id IN (${placeholders})
AND (m.year * 100 + m.month) BETWEEN ? AND ? AND (m.year * 100 + m.month) BETWEEN ? AND ?
`).all( `).all(

View File

@ -17,8 +17,9 @@ router.get('/', (req, res) => {
SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id
) THEN 1 ELSE 0 END AS has_history_ranges ) THEN 1 ELSE 0 END AS has_history_ranges
FROM bills b FROM bills b
LEFT JOIN categories c ON b.category_id = c.id LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.user_id = ? WHERE b.user_id = ?
AND b.deleted_at IS NULL
${includeInactive ? '' : 'AND b.active = 1'} ${includeInactive ? '' : 'AND b.active = 1'}
ORDER BY b.due_day ASC, b.name ASC ORDER BY b.due_day ASC, b.name ASC
`).all(req.user.id); `).all(req.user.id);
@ -29,7 +30,7 @@ router.get('/', (req, res) => {
router.get('/:id/monthly-state', (req, res) => { router.get('/:id/monthly-state', (req, res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const year = parseInt(req.query.year, 10); const year = parseInt(req.query.year, 10);
@ -57,7 +58,7 @@ router.get('/:id/monthly-state', (req, res) => {
router.put('/:id/monthly-state', (req, res) => { router.put('/:id/monthly-state', (req, res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const { year, month, actual_amount, notes, is_skipped } = req.body; const { year, month, actual_amount, notes, is_skipped } = req.body;
@ -114,8 +115,8 @@ router.get('/:id', (req, res) => {
SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id
) THEN 1 ELSE 0 END AS has_history_ranges ) THEN 1 ELSE 0 END AS has_history_ranges
FROM bills b FROM bills b
LEFT JOIN categories c ON b.category_id = c.id LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.id = ? AND b.user_id = ? WHERE b.id = ? AND b.user_id = ? AND b.deleted_at IS NULL
`).get(req.params.id, req.user.id); `).get(req.params.id, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
res.json(bill); res.json(bill);
@ -140,7 +141,7 @@ router.post('/', (req, res) => {
const { normalized } = validation; const { normalized } = validation;
// Validate category_id exists for this user // Validate category_id exists for this user
if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(normalized.category_id, req.user.id)) { if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(normalized.category_id, req.user.id)) {
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id')); return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
} }
@ -185,7 +186,7 @@ router.post('/', (req, res) => {
// ── PUT /api/bills/:id ──────────────────────────────────────────────────────── // ── PUT /api/bills/:id ────────────────────────────────────────────────────────
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const db = getDb(); const db = getDb();
const existing = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); const existing = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
if (!existing) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); if (!existing) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
// Validate and normalize bill data // Validate and normalize bill data
@ -198,7 +199,7 @@ router.put('/:id', (req, res) => {
const { normalized } = validation; const { normalized } = validation;
// Validate category_id exists for this user if changed // Validate category_id exists for this user if changed
if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(normalized.category_id, req.user.id)) { if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(normalized.category_id, req.user.id)) {
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id')); return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
} }
@ -240,35 +241,43 @@ router.put('/:id', (req, res) => {
req.user.id, req.user.id,
); );
const updated = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); const updated = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
res.json(updated); res.json(updated);
}); });
// ── DELETE /api/bills/:id — destructive hard-delete ─────────────────────────── // ── DELETE /api/bills/:id — soft delete for 30-day recovery ───────────────────
// Permanently removes the bill and all associated data (payments, monthly state,
// history ranges). Inactivation (PUT with active:0) is the safer alternative.
// WARNING: this action is irreversible.
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
const db = getDb(); const db = getDb();
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
// ON DELETE CASCADE in the schema removes payments, monthly_bill_state, and db.prepare("UPDATE bills SET deleted_at = datetime('now'), active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
// bill_history_ranges automatically. Verify foreign_keys pragma is ON. .run(req.params.id, req.user.id);
db.prepare('DELETE FROM bills WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
res.json({ res.json({
success: true, success: true,
deleted_bill_id: bill.id, deleted_bill_id: bill.id,
deleted_bill_name: bill.name, deleted_bill_name: bill.name,
warning: 'Bill and all associated payments, monthly state, and history ranges were permanently deleted.', recoverable_until_days: 30,
}); });
}); });
// POST /api/bills/:id/restore — undo bill soft delete
router.post('/:id/restore', (req, res) => {
const db = getDb();
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL').get(req.params.id, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Deleted bill not found', 'NOT_FOUND', 'bill_id'));
db.prepare("UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
.run(req.params.id, req.user.id);
res.json(db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id));
});
// ── GET /api/bills/:id/payments?page=1&limit=20 ─────────────────────────────── // ── GET /api/bills/:id/payments?page=1&limit=20 ───────────────────────────────
router.get('/:id/payments', (req, res) => { router.get('/:id/payments', (req, res) => {
const db = getDb(); const db = getDb();
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const limit = Math.min(parseInt(req.query.limit || '20', 10), 100); const limit = Math.min(parseInt(req.query.limit || '20', 10), 100);
@ -300,7 +309,7 @@ router.post('/:id/toggle-paid', (req, res) => {
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
// Get bill - always scope to the requesting user // Get bill - always scope to the requesting user
const bill = db.prepare('SELECT id, expected_amount, user_id, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id); const bill = db.prepare('SELECT id, expected_amount, user_id, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
@ -398,14 +407,14 @@ router.post('/:id/toggle-paid', (req, res) => {
// ── GET /api/bills/:id/history-ranges ──────────────────────────────────────── // ── GET /api/bills/:id/history-ranges ────────────────────────────────────────
router.get('/:id/history-ranges', (req, res) => { router.get('/:id/history-ranges', (req, res) => {
const db = getDb(); const db = getDb();
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id)) if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const ranges = db.prepare( const ranges = db.prepare(
'SELECT * FROM bill_history_ranges WHERE bill_id = ? ORDER BY start_year ASC, start_month ASC' 'SELECT * FROM bill_history_ranges WHERE bill_id = ? ORDER BY start_year ASC, start_month ASC'
).all(req.params.id); ).all(req.params.id);
const bill = db.prepare('SELECT history_visibility FROM bills WHERE id = ?').get(req.params.id); const bill = db.prepare('SELECT history_visibility FROM bills WHERE id = ? AND deleted_at IS NULL').get(req.params.id);
res.json({ bill_id: parseInt(req.params.id, 10), history_visibility: bill.history_visibility, ranges }); res.json({ bill_id: parseInt(req.params.id, 10), history_visibility: bill.history_visibility, ranges });
}); });
@ -413,7 +422,7 @@ router.get('/:id/history-ranges', (req, res) => {
// ── POST /api/bills/:id/history-ranges ─────────────────────────────────────── // ── POST /api/bills/:id/history-ranges ───────────────────────────────────────
router.post('/:id/history-ranges', (req, res) => { router.post('/:id/history-ranges', (req, res) => {
const db = getDb(); const db = getDb();
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id)) if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const { start_year, start_month, end_year, end_month, label } = req.body; const { start_year, start_month, end_year, end_month, label } = req.body;
@ -458,7 +467,7 @@ router.post('/:id/history-ranges', (req, res) => {
// ── PUT /api/bills/:id/history-ranges/:rangeId ─────────────────────────────── // ── PUT /api/bills/:id/history-ranges/:rangeId ───────────────────────────────
router.put('/:id/history-ranges/:rangeId', (req, res) => { router.put('/:id/history-ranges/:rangeId', (req, res) => {
const db = getDb(); const db = getDb();
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id)) if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const range = db.prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?') const range = db.prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
@ -502,7 +511,7 @@ router.put('/:id/history-ranges/:rangeId', (req, res) => {
// ── DELETE /api/bills/:id/history-ranges/:rangeId ──────────────────────────── // ── DELETE /api/bills/:id/history-ranges/:rangeId ────────────────────────────
router.delete('/:id/history-ranges/:rangeId', (req, res) => { router.delete('/:id/history-ranges/:rangeId', (req, res) => {
const db = getDb(); const db = getDb();
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id)) if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const range = db.prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?') const range = db.prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
@ -519,7 +528,7 @@ router.delete('/:id/history-ranges/:rangeId', (req, res) => {
router.get('/:id/amortization', (req, res) => { router.get('/:id/amortization', (req, res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id); const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const balance = Number(bill.current_balance); const balance = Number(bill.current_balance);
@ -571,7 +580,7 @@ router.get('/:id/amortization', (req, res) => {
router.patch('/:id/snowball', (req, res) => { router.patch('/:id/snowball', (req, res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) { if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id)) {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
} }
const include = req.body.snowball_include !== undefined ? (req.body.snowball_include ? 1 : 0) : undefined; const include = req.body.snowball_include !== undefined ? (req.body.snowball_include ? 1 : 0) : undefined;
@ -590,7 +599,7 @@ router.patch('/:id/snowball', (req, res) => {
router.patch('/:id/balance', (req, res) => { router.patch('/:id/balance', (req, res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) { if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id)) {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
} }

View File

@ -56,8 +56,8 @@ router.get('/', (req, res) => {
const bills = db.prepare(` const bills = db.prepare(`
SELECT b.*, c.name AS category_name SELECT b.*, c.name AS category_name
FROM bills b FROM bills b
LEFT JOIN categories c ON b.category_id = c.id LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.active = 1 AND b.user_id = ? WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
ORDER BY b.due_day ASC, b.name ASC ORDER BY b.due_day ASC, b.name ASC
`).all(req.user.id); `).all(req.user.id);
@ -87,6 +87,7 @@ router.get('/', (req, res) => {
FROM payments p FROM payments p
JOIN bills b ON p.bill_id = b.id JOIN bills b ON p.bill_id = b.id
WHERE b.user_id = ? WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND p.paid_date BETWEEN ? AND ? AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL AND p.deleted_at IS NULL
ORDER BY p.paid_date ASC, b.name ASC ORDER BY p.paid_date ASC, b.name ASC

View File

@ -12,6 +12,7 @@ router.get('/', (req, res) => {
SELECT id, user_id, name, created_at, updated_at SELECT id, user_id, name, created_at, updated_at
FROM categories FROM categories
WHERE user_id = ? WHERE user_id = ?
AND deleted_at IS NULL
ORDER BY name COLLATE NOCASE ASC ORDER BY name COLLATE NOCASE ASC
`).all(req.user.id); `).all(req.user.id);
@ -32,6 +33,7 @@ router.get('/', (req, res) => {
AND p.deleted_at IS NULL AND p.deleted_at IS NULL
WHERE b.user_id = ? WHERE b.user_id = ?
AND b.category_id = ? AND b.category_id = ?
AND b.deleted_at IS NULL
GROUP BY b.id GROUP BY b.id
ORDER BY b.active DESC, b.due_day ASC, b.name COLLATE NOCASE ASC ORDER BY b.active DESC, b.due_day ASC, b.name COLLATE NOCASE ASC
`); `);
@ -87,13 +89,13 @@ router.put('/:id', (req, res) => {
const { name } = req.body; const { name } = req.body;
if (!name) return res.status(400).json(standardizeError('name is required', 'VALIDATION_ERROR', 'name')); if (!name) return res.status(400).json(standardizeError('name is required', 'VALIDATION_ERROR', 'name'));
const cat = db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); const cat = db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id')); if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id'));
try { try {
db.prepare("UPDATE categories SET name = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?") db.prepare("UPDATE categories SET name = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
.run(name.trim(), req.params.id, req.user.id); .run(name.trim(), req.params.id, req.user.id);
res.json(db.prepare('SELECT * FROM categories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id)); res.json(db.prepare('SELECT * FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id));
} catch (e) { } catch (e) {
if (e.message.includes('UNIQUE')) { if (e.message.includes('UNIQUE')) {
return res.status(409).json(standardizeError('Category already exists', 'CONFLICT', 'name')); return res.status(409).json(standardizeError('Category already exists', 'CONFLICT', 'name'));
@ -105,27 +107,30 @@ router.put('/:id', (req, res) => {
// DELETE /api/categories/:id // DELETE /api/categories/:id
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
const db = getDb(); const db = getDb();
const cat = db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); const cat = db.prepare('SELECT id, name FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id')); if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id'));
const deleteCategory = db.transaction(() => { const deleted = db.prepare("UPDATE categories SET deleted_at = datetime('now'), updated_at = datetime('now') WHERE id = ? AND user_id = ? AND deleted_at IS NULL")
const bills = db.prepare(` .run(req.params.id, req.user.id);
UPDATE bills
SET category_id = NULL, updated_at = datetime('now')
WHERE category_id = ? AND user_id = ?
`).run(req.params.id, req.user.id);
const deleted = db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?') res.json({
.run(req.params.id, req.user.id); success: true,
deleted: deleted.changes,
return { deleted_category_id: cat.id,
deleted: deleted.changes, deleted_category_name: cat.name,
uncategorized_bills: bills.changes, recoverable_until_days: 30,
};
}); });
});
const result = deleteCategory(); // POST /api/categories/:id/restore — undo category soft delete
res.json({ success: true, ...result }); router.post('/:id/restore', (req, res) => {
const db = getDb();
const cat = db.prepare('SELECT id, name FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL').get(req.params.id, req.user.id);
if (!cat) return res.status(404).json(standardizeError('Deleted category not found', 'NOT_FOUND', 'id'));
db.prepare("UPDATE categories SET deleted_at = NULL, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
.run(req.params.id, req.user.id);
res.json(db.prepare('SELECT * FROM categories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id));
}); });
module.exports = router; module.exports = router;

View File

@ -30,9 +30,10 @@ router.get('/', (req, res) => {
p.created_at p.created_at
FROM payments p FROM payments p
JOIN bills b ON b.id = p.bill_id JOIN bills b ON b.id = p.bill_id
LEFT JOIN categories c ON c.id = b.category_id LEFT JOIN categories c ON c.id = b.category_id AND c.deleted_at IS NULL
WHERE strftime('%Y', p.paid_date) = ? WHERE strftime('%Y', p.paid_date) = ?
AND b.user_id = ? AND b.user_id = ?
AND b.deleted_at IS NULL
AND p.deleted_at IS NULL AND p.deleted_at IS NULL
ORDER BY p.paid_date ASC, b.name ASC ORDER BY p.paid_date ASC, b.name ASC
`).all(String(year), req.user.id); `).all(String(year), req.user.id);
@ -87,27 +88,27 @@ router.get('/', (req, res) => {
function getUserExportData(userId) { function getUserExportData(userId) {
const db = getDb(); const db = getDb();
const categories = db.prepare('SELECT id, name, created_at, updated_at FROM categories WHERE user_id = ? ORDER BY name').all(userId); const categories = db.prepare('SELECT id, name, created_at, updated_at FROM categories WHERE user_id = ? AND deleted_at IS NULL ORDER BY name').all(userId);
const bills = db.prepare(` const bills = db.prepare(`
SELECT id, name, category_id, due_day, override_due_date, bucket, expected_amount, interest_rate, SELECT id, name, category_id, due_day, override_due_date, bucket, expected_amount, interest_rate,
billing_cycle, cycle_type, cycle_day, autopay_enabled, autodraft_status, website, username, billing_cycle, cycle_type, cycle_day, autopay_enabled, autodraft_status, website, username,
account_info, has_2fa, active, notes, created_at, updated_at account_info, has_2fa, active, notes, created_at, updated_at
FROM bills FROM bills
WHERE user_id = ? WHERE user_id = ? AND deleted_at IS NULL
ORDER BY active DESC, due_day ASC, name ASC ORDER BY active DESC, due_day ASC, name ASC
`).all(userId); `).all(userId);
const payments = db.prepare(` const payments = db.prepare(`
SELECT p.id, p.bill_id, p.amount, p.paid_date, p.method, p.notes, p.created_at, p.updated_at SELECT p.id, p.bill_id, p.amount, p.paid_date, p.method, p.notes, p.created_at, p.updated_at
FROM payments p FROM payments p
JOIN bills b ON b.id = p.bill_id JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ? AND p.deleted_at IS NULL WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.deleted_at IS NULL
ORDER BY p.paid_date ASC, p.id ASC ORDER BY p.paid_date ASC, p.id ASC
`).all(userId); `).all(userId);
const monthlyState = db.prepare(` const monthlyState = db.prepare(`
SELECT m.id, m.bill_id, m.year, m.month, m.actual_amount, m.notes, m.is_skipped, m.created_at, m.updated_at SELECT m.id, m.bill_id, m.year, m.month, m.actual_amount, m.notes, m.is_skipped, m.created_at, m.updated_at
FROM monthly_bill_state m FROM monthly_bill_state m
JOIN bills b ON b.id = m.bill_id JOIN bills b ON b.id = m.bill_id
WHERE b.user_id = ? WHERE b.user_id = ? AND b.deleted_at IS NULL
ORDER BY m.year, m.month, m.bill_id ORDER BY m.year, m.month, m.bill_id
`).all(userId); `).all(userId);
const monthlyStartingAmounts = db.prepare(` const monthlyStartingAmounts = db.prepare(`
@ -119,7 +120,7 @@ function getUserExportData(userId) {
const historyRanges = db.prepare(` const historyRanges = db.prepare(`
SELECT id, bill_id, start_year, start_month, end_year, end_month, label, created_at, updated_at SELECT id, bill_id, start_year, start_month, end_year, end_month, label, created_at, updated_at
FROM bill_history_ranges FROM bill_history_ranges
WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ?) WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)
ORDER BY bill_id, start_year, start_month ORDER BY bill_id, start_year, start_month
`).all(userId); `).all(userId);
const notes = [ const notes = [

View File

@ -29,7 +29,7 @@ router.get('/', (req, res) => {
} }
} }
let query = `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.${LIVE} AND b.user_id = ?`; let query = `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`;
const params = [req.user.id]; const params = [req.user.id];
if (bill_id) { query += ' AND p.bill_id = ?'; params.push(parseInt(bill_id, 10)); } if (bill_id) { query += ' AND p.bill_id = ?'; params.push(parseInt(bill_id, 10)); }
@ -50,7 +50,7 @@ router.get('/', (req, res) => {
// GET /api/payments/:id // GET /api/payments/:id
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
const db = getDb(); const db = getDb();
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id); const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
res.json(payment); res.json(payment);
}); });
@ -66,7 +66,7 @@ router.post('/', (req, res) => {
} }
const payment = validation.normalized; const payment = validation.normalized;
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(payment.bill_id, req.user.id)) if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const result = db.prepare( const result = db.prepare(
@ -86,7 +86,7 @@ router.post('/quick', (req, res) => {
return res.status(400).json(standardizeError(billValidation.error, 'VALIDATION_ERROR', billValidation.field)); return res.status(400).json(standardizeError(billValidation.error, 'VALIDATION_ERROR', billValidation.field));
} }
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(billValidation.normalized.bill_id, req.user.id); const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billValidation.normalized.bill_id, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const paymentValidation = validatePaymentInput( const paymentValidation = validatePaymentInput(
@ -152,7 +152,7 @@ router.post('/bulk', (req, res) => {
const insert = db.prepare( const insert = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
); );
const getBillForBalance = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ?'); const getBillForBalance = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL');
const applyBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?"); const applyBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
// Prepare statement for duplicate checking // Prepare statement for duplicate checking
@ -160,6 +160,7 @@ router.post('/bulk', (req, res) => {
`SELECT 1 FROM payments p `SELECT 1 FROM payments p
JOIN bills b ON b.id = p.bill_id JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ? WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND p.bill_id = ? AND p.bill_id = ?
AND p.paid_date = ? AND p.paid_date = ?
AND p.amount = ? AND p.amount = ?
@ -204,7 +205,7 @@ router.post('/bulk', (req, res) => {
// PUT /api/payments/:id // PUT /api/payments/:id
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const db = getDb(); const db = getDb();
const existing = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id); const existing = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
if (!existing) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); if (!existing) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
const { amount, paid_date, method, notes } = req.body; const { amount, paid_date, method, notes } = req.body;
@ -235,7 +236,7 @@ router.put('/:id', (req, res) => {
// DELETE /api/payments/:id — soft delete (sets deleted_at) // DELETE /api/payments/:id — soft delete (sets deleted_at)
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
const db = getDb(); const db = getDb();
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id); const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
// Reverse any balance delta that was stored when this payment was created // Reverse any balance delta that was stored when this payment was created
@ -254,7 +255,7 @@ router.delete('/:id', (req, res) => {
// POST /api/payments/:id/restore — undo soft delete // POST /api/payments/:id/restore — undo soft delete
router.post('/:id/restore', (req, res) => { router.post('/:id/restore', (req, res) => {
const db = getDb(); const db = getDb();
const payment = db.prepare('SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ?').get(req.params.id, req.user.id); const payment = db.prepare('SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ? AND b.deleted_at IS NULL').get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id')); if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
// Re-apply the balance delta (undo the reversal done on delete) // Re-apply the balance delta (undo the reversal done on delete)

View File

@ -64,9 +64,10 @@ function getDebtQuery(ramseyMode) {
return ` return `
SELECT b.*, c.name AS category_name SELECT b.*, c.name AS category_name
FROM bills b FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id AND c.deleted_at IS NULL
WHERE b.user_id = ? WHERE b.user_id = ?
AND b.active = 1 AND b.active = 1
AND b.deleted_at IS NULL
AND ${DEBT_LIKE_CLAUSES} AND ${DEBT_LIKE_CLAUSES}
ORDER BY${orderBy} ORDER BY${orderBy}
`; `;

View File

@ -48,6 +48,7 @@ function calculatePaidDeductions(db, userId, year, month) {
FROM payments p FROM payments p
JOIN bills b ON b.id = p.bill_id JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ? WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND p.paid_date BETWEEN ? AND ? AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL AND p.deleted_at IS NULL
AND b.due_day BETWEEN 1 AND 14 AND b.due_day BETWEEN 1 AND 14
@ -59,6 +60,7 @@ function calculatePaidDeductions(db, userId, year, month) {
FROM payments p FROM payments p
JOIN bills b ON b.id = p.bill_id JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ? WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND p.paid_date BETWEEN ? AND ? AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL AND p.deleted_at IS NULL
AND b.due_day BETWEEN 15 AND 31 AND b.due_day BETWEEN 15 AND 31
@ -70,6 +72,7 @@ function calculatePaidDeductions(db, userId, year, month) {
FROM payments p FROM payments p
JOIN bills b ON b.id = p.bill_id JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ? WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND p.paid_date BETWEEN ? AND ? AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL AND p.deleted_at IS NULL
AND (b.due_day < 1 OR b.due_day > 31) AND (b.due_day < 1 OR b.due_day > 31)
@ -80,6 +83,7 @@ function calculatePaidDeductions(db, userId, year, month) {
FROM payments p FROM payments p
JOIN bills b ON b.id = p.bill_id JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ? WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND p.paid_date BETWEEN ? AND ? AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL AND p.deleted_at IS NULL
`).get(userId, start, end); `).get(userId, start, end);
@ -145,9 +149,9 @@ function buildSummary(db, userId, year, month) {
m.actual_amount, m.actual_amount,
m.is_skipped m.is_skipped
FROM bills b FROM bills b
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ? LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
WHERE b.user_id = ? AND b.active = 1 WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
ORDER BY b.due_day ASC, b.name ASC ORDER BY b.due_day ASC, b.name ASC
`).all(year, month, userId); `).all(year, month, userId);
@ -161,6 +165,7 @@ function buildSummary(db, userId, year, month) {
FROM payments p FROM payments p
JOIN bills b ON b.id = p.bill_id JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ? WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND p.bill_id IN (${placeholders}) AND p.bill_id IN (${placeholders})
AND p.paid_date BETWEEN ? AND ? AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL AND p.deleted_at IS NULL

View File

@ -37,8 +37,8 @@ router.get('/', (req, res) => {
const bills = db.prepare(` const bills = db.prepare(`
SELECT b.*, c.name AS category_name SELECT b.*, c.name AS category_name
FROM bills b FROM bills b
LEFT JOIN categories c ON b.category_id = c.id LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.active = 1 AND b.user_id = ? WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
ORDER BY b.due_day ASC, b.name ASC ORDER BY b.due_day ASC, b.name ASC
`).all(req.user.id); `).all(req.user.id);
@ -156,7 +156,7 @@ router.get('/', (req, res) => {
SELECT SUM(p.amount) as total_paid, strftime('%Y-%m', p.paid_date) as month_key SELECT SUM(p.amount) as total_paid, strftime('%Y-%m', p.paid_date) as month_key
FROM payments p FROM payments p
JOIN bills b ON p.bill_id = b.id JOIN bills b ON p.bill_id = b.id
WHERE b.user_id = ? AND p.paid_date BETWEEN ? AND ? WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL AND p.deleted_at IS NULL
GROUP BY strftime('%Y-%m', p.paid_date) GROUP BY strftime('%Y-%m', p.paid_date)
`).all(req.user.id, threeMonthStart, currentMonthEnd); `).all(req.user.id, threeMonthStart, currentMonthEnd);
@ -255,8 +255,8 @@ router.get('/upcoming', (req, res) => {
const bills = db.prepare(` const bills = db.prepare(`
SELECT b.*, c.name AS category_name SELECT b.*, c.name AS category_name
FROM bills b FROM bills b
LEFT JOIN categories c ON b.category_id = c.id LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.active = 1 AND b.user_id = ? WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
`).all(req.user.id); `).all(req.user.id);
const cutoff = new Date(now); const cutoff = new Date(now);

View File

@ -119,6 +119,21 @@ function pruneImportHistory(maxAgeDays) {
return result.changes; return result.changes;
} }
/**
* Permanently purge soft-deleted bills and categories after a 30-day recovery
* window. Bill deletion cascades to bill-owned records via foreign keys.
*/
function pruneSoftDeletedFinancialRecords(maxAgeDays = 30) {
const db = getDb();
const cutoff = `-${maxAgeDays} days`;
const purge = db.transaction(() => {
const bills = db.prepare("DELETE FROM bills WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?)").run(cutoff).changes;
const categories = db.prepare("DELETE FROM categories WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?)").run(cutoff).changes;
return { bills, categories };
});
return purge();
}
// ─── Settings ───────────────────────────────────────────────────────────────── // ─── Settings ─────────────────────────────────────────────────────────────────
function readSettings() { function readSettings() {
@ -186,6 +201,8 @@ async function runAllCleanup() {
tasks.import_history = { pruned }; tasks.import_history = { pruned };
} }
tasks.soft_deleted_records = pruneSoftDeletedFinancialRecords(30);
const ran_at = new Date().toISOString(); const ran_at = new Date().toISOString();
setSetting('cleanup_last_run_at', ran_at); setSetting('cleanup_last_run_at', ran_at);
setSetting('cleanup_last_result', JSON.stringify(tasks)); setSetting('cleanup_last_result', JSON.stringify(tasks));
@ -218,4 +235,5 @@ module.exports = {
pruneStaleExportFiles, pruneStaleExportFiles,
pruneOrphanedBackupPartials, pruneOrphanedBackupPartials,
pruneImportHistory, pruneImportHistory,
pruneSoftDeletedFinancialRecords,
}; };

View File

@ -180,7 +180,7 @@ async function runNotifications() {
// Fetch all active bills. In global-notification mode, the single global recipient // Fetch all active bills. In global-notification mode, the single global recipient
// legitimately receives every bill. In per-user mode, each recipient must only // legitimately receives every bill. In per-user mode, each recipient must only
// see their own bills — the ownership filter is applied in the loop below. // see their own bills — the ownership filter is applied in the loop below.
const bills = db.prepare('SELECT * FROM bills WHERE active = 1').all(); const bills = db.prepare('SELECT * FROM bills WHERE active = 1 AND deleted_at IS NULL').all();
const allowUserConfig = getSetting('notify_allow_user_config') === 'true'; const allowUserConfig = getSetting('notify_allow_user_config') === 'true';
const globalRecipient = getSetting('notify_global_recipient'); const globalRecipient = getSetting('notify_global_recipient');