diff --git a/client/api.js b/client/api.js index bcb9e53..bf077f2 100644 --- a/client/api.js +++ b/client/api.js @@ -154,6 +154,7 @@ export const api = { }, updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data), deleteBill: (id) => del(`/bills/${id}`), + restoreBill: (id) => post(`/bills/${id}/restore`), togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data), 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}`), @@ -183,6 +184,7 @@ export const api = { createCategory: (data) => post('/categories', data), updateCategory: (id, data) => put(`/categories/${id}`, data), deleteCategory: (id) => del(`/categories/${id}`), + restoreCategory: (id) => post(`/categories/${id}/restore`), // Settings settings: () => get('/settings'), diff --git a/client/lib/version.js b/client/lib/version.js index 6a0f8f2..a0d2ed8 100644 --- a/client/lib/version.js +++ b/client/lib/version.js @@ -10,8 +10,8 @@ export const RELEASE_NOTES = { highlights: [ { icon: 'πŸ›‘οΈ', - title: 'Safer payment and settings data', - desc: 'Payment amounts and dates are now validated consistently, and regular-user settings are stored per user instead of globally.', + title: 'Safer financial history', + desc: 'Bill, category, and payment deletes now use confirmations, recovery windows, and undo actions so accidental clicks do not immediately destroy important records.', }, { icon: 'πŸ”', diff --git a/client/pages/BillsPage.jsx b/client/pages/BillsPage.jsx index 686935f..fb5bed5 100644 --- a/client/pages/BillsPage.jsx +++ b/client/pages/BillsPage.jsx @@ -504,7 +504,21 @@ export default function BillsPage() { const bill = deleteTarget; await api.deleteBill(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); setDeleteConfirmed(false); load(); @@ -670,7 +684,7 @@ export default function BillsPage() { - {/* ── Permanent delete confirmation ── */} + {/* ── Soft delete confirmation ── */} { @@ -682,15 +696,15 @@ export default function BillsPage() { > - Delete "{deleteTarget?.name}" permanently? + Move "{deleteTarget?.name}" to recovery? - This permanently deletes the bill and all data for this bill, including payments, - monthly history, notes, and history ranges. This cannot be undone. + This hides the bill from normal tracking and keeps its payments, monthly history, + notes, and history ranges recoverable for 30 days. -
- 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.
@@ -726,7 +740,7 @@ export default function BillsPage() { disabled={!deleteConfirmed || deleteBusy} onClick={handleDeleteConfirmed} > - {deleteBusy ? 'Deleting…' : 'Delete permanently'} + {deleteBusy ? 'Moving…' : 'Move to recovery'}
diff --git a/client/pages/CategoriesPage.jsx b/client/pages/CategoriesPage.jsx index 33b956b..3e48ccf 100644 --- a/client/pages/CategoriesPage.jsx +++ b/client/pages/CategoriesPage.jsx @@ -315,11 +315,26 @@ export default function CategoriesPage() { async function handleDelete() { setDeleting(true); try { + const category = deleteTarget; 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 => { const next = new Set(prev); - next.delete(deleteTarget.id); + next.delete(category.id); return next; }); setDeleteTarget(null); @@ -488,9 +503,9 @@ export default function CategoriesPage() { { if (!open) setDeleteTarget(null); }}> - Delete {deleteTarget?.name}? + Move {deleteTarget?.name} to recovery? - 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. @@ -500,7 +515,7 @@ export default function CategoriesPage() { onClick={handleDelete} disabled={deleting} > - {deleting ? 'Deleting...' : 'Delete Category'} + {deleting ? 'Moving...' : 'Move to Recovery'} diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index 9c55d5f..679910f 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -14,6 +14,10 @@ import { import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; +import { + AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, + AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, +} from '@/components/ui/alert-dialog'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; @@ -690,6 +694,7 @@ function PaymentModal({ payment, onClose, onSave }) { const [method, setMethod] = useState(payment.method || METHOD_NONE); const [notes, setNotes] = useState(payment.notes || ''); const [busy, setBusy] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); async function handleSave(e) { e.preventDefault(); @@ -712,7 +717,20 @@ function PaymentModal({ payment, onClose, onSave }) { setBusy(true); try { 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(); } catch (err) { toast.error(err.message); @@ -720,8 +738,9 @@ function PaymentModal({ payment, onClose, onSave }) { } return ( - { if (!v) onClose(); }}> - + <> + { if (!v) onClose(); }}> + Edit Payment @@ -764,7 +783,7 @@ function PaymentModal({ payment, onClose, onSave }) { - - + + + + + + + Remove this payment? + + This marks the payment as removed and reverses any debt balance update. You can undo it from the toast. + + + + Cancel + + {busy ? 'Removing...' : 'Remove Payment'} + + + + + ); } @@ -785,6 +826,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) { const amountRef = useRef(null); const [editPayment, setEditPayment] = useState(null); const [showMbs, setShowMbs] = useState(false); + const [confirmUnpay, setConfirmUnpay] = useState(false); const [loading, setLoading] = useState(false); // 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); try { const result = await api.togglePaid(row.id, { @@ -828,7 +870,24 @@ function Row({ row, year, month, refresh, index, onEditBill }) { year: year, 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?.(); } catch (err) { 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 ( <> )} - {/* Payment toggle confirmation dialog */} + + + + Mark this bill unpaid? + + This removes the current payment record for this month and moves it into recovery. + + + + Cancel + + {loading ? 'Removing...' : 'Remove Payment'} + + + + ); } @@ -1015,6 +1101,7 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) { const amountRef = useRef(null); const [editPayment, setEditPayment] = useState(null); const [showMbs, setShowMbs] = useState(false); + const [confirmUnpay, setConfirmUnpay] = useState(false); const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount; 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 { - await api.togglePaid(row.id, { + const result = await api.togglePaid(row.id, { amount: isPaid ? undefined : threshold, year: year, 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(); } catch (err) { toast.error(err.message || 'Failed to toggle payment status'); } } + function handleTogglePaid() { + if (isPaid) { + setConfirmUnpay(true); + return; + } + performTogglePaid(); + } + return ( <>
)} + + + + + Mark this bill unpaid? + + This removes the current payment record for this month and moves it into recovery. + + + + Cancel + + Remove Payment + + + + ); } diff --git a/db/database.js b/db/database.js index 2cd54a9..3ac84da 100644 --- a/db/database.js +++ b/db/database.js @@ -44,7 +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', + 'snowball_exempt', 'deleted_at', // sessions table columns 'created_at', ]); @@ -770,6 +770,28 @@ function reconcileLegacyMigrations() { `); 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'); } + }, + { + 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 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', + ] } }; diff --git a/db/schema.sql b/db/schema.sql index eaf2c32..961c67e 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, + deleted_at TEXT, created_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_include INTEGER NOT NULL DEFAULT 0, snowball_exempt INTEGER NOT NULL DEFAULT 0, + deleted_at TEXT, notes TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) diff --git a/routes/analytics.js b/routes/analytics.js index 0238da4..4ac2de7 100644 --- a/routes/analytics.js +++ b/routes/analytics.js @@ -81,7 +81,7 @@ function isMonthInPast(year, month) { } function buildBillWhere({ userId, categoryId, billId, includeInactive }) { - const clauses = ['b.user_id = ?']; + const clauses = ['b.user_id = ?', 'b.deleted_at IS NULL']; const params = [userId]; if (!includeInactive) clauses.push('b.active = 1'); if (categoryId) { @@ -110,6 +110,7 @@ router.get('/summary', (req, res) => { SELECT id, name FROM categories WHERE user_id = ? + AND deleted_at IS NULL ORDER BY name COLLATE NOCASE `).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, c.name AS category_name 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} ORDER BY b.name COLLATE NOCASE `).all(...billWhere.params); @@ -154,6 +155,7 @@ router.get('/summary', (req, res) => { FROM payments p JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ? + AND b.deleted_at IS NULL AND p.bill_id IN (${placeholders}) AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL @@ -169,6 +171,7 @@ router.get('/summary', (req, res) => { FROM monthly_bill_state m JOIN bills b ON b.id = m.bill_id WHERE b.user_id = ? + AND b.deleted_at IS NULL AND m.bill_id IN (${placeholders}) AND (m.year * 100 + m.month) BETWEEN ? AND ? `).all( diff --git a/routes/bills.js b/routes/bills.js index 725de42..62c7120 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -17,8 +17,9 @@ router.get('/', (req, res) => { SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id ) THEN 1 ELSE 0 END AS has_history_ranges 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 = ? + AND b.deleted_at IS NULL ${includeInactive ? '' : 'AND b.active = 1'} ORDER BY b.due_day ASC, b.name ASC `).all(req.user.id); @@ -29,7 +30,7 @@ router.get('/', (req, res) => { router.get('/:id/monthly-state', (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)) + 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')); 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) => { 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)) + 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')); 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 ) THEN 1 ELSE 0 END AS has_history_ranges FROM bills b - LEFT JOIN categories c ON b.category_id = c.id - WHERE b.id = ? AND b.user_id = ? + LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL + WHERE b.id = ? AND b.user_id = ? AND b.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')); res.json(bill); @@ -140,7 +141,7 @@ router.post('/', (req, res) => { const { normalized } = validation; // 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')); } @@ -185,7 +186,7 @@ router.post('/', (req, res) => { // ── PUT /api/bills/:id ──────────────────────────────────────────────────────── router.put('/:id', (req, res) => { 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')); // Validate and normalize bill data @@ -198,7 +199,7 @@ router.put('/:id', (req, res) => { const { normalized } = validation; // 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')); } @@ -240,35 +241,43 @@ router.put('/:id', (req, res) => { 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); }); -// ── DELETE /api/bills/:id β€” destructive hard-delete ─────────────────────────── -// 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. +// ── DELETE /api/bills/:id β€” soft delete for 30-day recovery ─────────────────── router.delete('/:id', (req, res) => { 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')); - // ON DELETE CASCADE in the schema removes payments, monthly_bill_state, and - // bill_history_ranges automatically. Verify foreign_keys pragma is ON. - db.prepare('DELETE FROM bills WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id); + db.prepare("UPDATE bills SET deleted_at = datetime('now'), active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?") + .run(req.params.id, req.user.id); res.json({ success: true, deleted_bill_id: bill.id, 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 ─────────────────────────────── router.get('/:id/payments', (req, res) => { 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')); 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); // 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')); @@ -398,14 +407,14 @@ router.post('/:id/toggle-paid', (req, res) => { // ── GET /api/bills/:id/history-ranges ──────────────────────────────────────── router.get('/:id/history-ranges', (req, res) => { 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')); const ranges = db.prepare( 'SELECT * FROM bill_history_ranges WHERE bill_id = ? ORDER BY start_year ASC, start_month ASC' ).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 }); }); @@ -413,7 +422,7 @@ router.get('/:id/history-ranges', (req, res) => { // ── POST /api/bills/:id/history-ranges ─────────────────────────────────────── router.post('/:id/history-ranges', (req, res) => { 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')); 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 ─────────────────────────────── router.put('/:id/history-ranges/:rangeId', (req, res) => { 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')); 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 ──────────────────────────── router.delete('/:id/history-ranges/:rangeId', (req, res) => { 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')); 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) => { const db = getDb(); 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')); const balance = Number(bill.current_balance); @@ -571,7 +580,7 @@ router.get('/:id/amortization', (req, res) => { 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)) { + 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')); } 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) => { 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)) { + 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')); } diff --git a/routes/calendar.js b/routes/calendar.js index 503839b..9f02f9d 100644 --- a/routes/calendar.js +++ b/routes/calendar.js @@ -56,8 +56,8 @@ router.get('/', (req, res) => { const bills = db.prepare(` SELECT b.*, c.name AS category_name FROM bills b - LEFT JOIN categories c ON b.category_id = c.id - WHERE b.active = 1 AND b.user_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 = ? AND b.deleted_at IS NULL ORDER BY b.due_day ASC, b.name ASC `).all(req.user.id); @@ -87,6 +87,7 @@ router.get('/', (req, res) => { FROM payments p JOIN bills b ON p.bill_id = b.id WHERE b.user_id = ? + AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL ORDER BY p.paid_date ASC, b.name ASC diff --git a/routes/categories.js b/routes/categories.js index c021f55..3d4bc87 100644 --- a/routes/categories.js +++ b/routes/categories.js @@ -12,6 +12,7 @@ router.get('/', (req, res) => { SELECT id, user_id, name, created_at, updated_at FROM categories WHERE user_id = ? + AND deleted_at IS NULL ORDER BY name COLLATE NOCASE ASC `).all(req.user.id); @@ -32,6 +33,7 @@ router.get('/', (req, res) => { AND p.deleted_at IS NULL WHERE b.user_id = ? AND b.category_id = ? + AND b.deleted_at IS NULL GROUP BY b.id 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; 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')); try { db.prepare("UPDATE categories SET name = ?, updated_at = datetime('now') WHERE id = ? AND 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) { if (e.message.includes('UNIQUE')) { return res.status(409).json(standardizeError('Category already exists', 'CONFLICT', 'name')); @@ -105,27 +107,30 @@ router.put('/:id', (req, res) => { // DELETE /api/categories/:id router.delete('/:id', (req, res) => { 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')); - const deleteCategory = db.transaction(() => { - const bills = db.prepare(` - 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("UPDATE categories SET deleted_at = datetime('now'), updated_at = datetime('now') WHERE id = ? AND user_id = ? AND deleted_at IS NULL") + .run(req.params.id, req.user.id); - const deleted = db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?') - .run(req.params.id, req.user.id); - - return { - deleted: deleted.changes, - uncategorized_bills: bills.changes, - }; + res.json({ + success: true, + deleted: deleted.changes, + deleted_category_id: cat.id, + deleted_category_name: cat.name, + recoverable_until_days: 30, }); +}); - const result = deleteCategory(); - res.json({ success: true, ...result }); +// POST /api/categories/:id/restore β€” undo category soft delete +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; diff --git a/routes/export.js b/routes/export.js index 92bac51..f252276 100644 --- a/routes/export.js +++ b/routes/export.js @@ -30,9 +30,10 @@ router.get('/', (req, res) => { p.created_at FROM payments p 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) = ? AND b.user_id = ? + AND b.deleted_at IS NULL AND p.deleted_at IS NULL ORDER BY p.paid_date ASC, b.name ASC `).all(String(year), req.user.id); @@ -87,27 +88,27 @@ router.get('/', (req, res) => { function getUserExportData(userId) { 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(` 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, account_info, has_2fa, active, notes, created_at, updated_at FROM bills - WHERE user_id = ? + WHERE user_id = ? AND deleted_at IS NULL ORDER BY active DESC, due_day ASC, name ASC `).all(userId); 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 FROM payments p 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 `).all(userId); 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 FROM monthly_bill_state m 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 `).all(userId); const monthlyStartingAmounts = db.prepare(` @@ -119,7 +120,7 @@ function getUserExportData(userId) { const historyRanges = db.prepare(` SELECT id, bill_id, start_year, start_month, end_year, end_month, label, created_at, updated_at 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 `).all(userId); const notes = [ diff --git a/routes/payments.js b/routes/payments.js index 08330d0..d7f276e 100644 --- a/routes/payments.js +++ b/routes/payments.js @@ -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]; 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 router.get('/:id', (req, res) => { 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')); res.json(payment); }); @@ -66,7 +66,7 @@ router.post('/', (req, res) => { } 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')); 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)); } - 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')); const paymentValidation = validatePaymentInput( @@ -152,7 +152,7 @@ router.post('/bulk', (req, res) => { const insert = db.prepare( '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 = ?"); // Prepare statement for duplicate checking @@ -160,6 +160,7 @@ router.post('/bulk', (req, res) => { `SELECT 1 FROM payments p JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ? + AND b.deleted_at IS NULL AND p.bill_id = ? AND p.paid_date = ? AND p.amount = ? @@ -204,7 +205,7 @@ router.post('/bulk', (req, res) => { // PUT /api/payments/:id router.put('/:id', (req, res) => { 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')); 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) router.delete('/:id', (req, res) => { 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')); // 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 router.post('/:id/restore', (req, res) => { 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')); // Re-apply the balance delta (undo the reversal done on delete) diff --git a/routes/snowball.js b/routes/snowball.js index 8a07f2b..f21f458 100644 --- a/routes/snowball.js +++ b/routes/snowball.js @@ -64,9 +64,10 @@ function getDebtQuery(ramseyMode) { return ` SELECT b.*, c.name AS category_name 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 = ? AND b.active = 1 + AND b.deleted_at IS NULL AND ${DEBT_LIKE_CLAUSES} ORDER BY${orderBy} `; diff --git a/routes/summary.js b/routes/summary.js index 7987d2e..80940ef 100644 --- a/routes/summary.js +++ b/routes/summary.js @@ -48,6 +48,7 @@ function calculatePaidDeductions(db, userId, year, month) { FROM payments p JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ? + AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL AND b.due_day BETWEEN 1 AND 14 @@ -59,6 +60,7 @@ function calculatePaidDeductions(db, userId, year, month) { FROM payments p JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ? + AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL AND b.due_day BETWEEN 15 AND 31 @@ -70,6 +72,7 @@ function calculatePaidDeductions(db, userId, year, month) { FROM payments p JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ? + AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL AND (b.due_day < 1 OR b.due_day > 31) @@ -80,6 +83,7 @@ function calculatePaidDeductions(db, userId, year, month) { FROM payments p JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ? + AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL `).get(userId, start, end); @@ -145,9 +149,9 @@ function buildSummary(db, userId, year, month) { m.actual_amount, m.is_skipped 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 = ? - 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 `).all(year, month, userId); @@ -161,6 +165,7 @@ function buildSummary(db, userId, year, month) { FROM payments p JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ? + AND b.deleted_at IS NULL AND p.bill_id IN (${placeholders}) AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL diff --git a/routes/tracker.js b/routes/tracker.js index 6e8f47c..392c8da 100644 --- a/routes/tracker.js +++ b/routes/tracker.js @@ -37,8 +37,8 @@ router.get('/', (req, res) => { const bills = db.prepare(` SELECT b.*, c.name AS category_name FROM bills b - LEFT JOIN categories c ON b.category_id = c.id - WHERE b.active = 1 AND b.user_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 = ? AND b.deleted_at IS NULL ORDER BY b.due_day ASC, b.name ASC `).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 FROM payments p 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 GROUP BY strftime('%Y-%m', p.paid_date) `).all(req.user.id, threeMonthStart, currentMonthEnd); @@ -255,8 +255,8 @@ router.get('/upcoming', (req, res) => { const bills = db.prepare(` SELECT b.*, c.name AS category_name FROM bills b - LEFT JOIN categories c ON b.category_id = c.id - WHERE b.active = 1 AND b.user_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 = ? AND b.deleted_at IS NULL `).all(req.user.id); const cutoff = new Date(now); diff --git a/services/cleanupService.js b/services/cleanupService.js index 3665250..0fb8791 100644 --- a/services/cleanupService.js +++ b/services/cleanupService.js @@ -119,6 +119,21 @@ function pruneImportHistory(maxAgeDays) { 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 ───────────────────────────────────────────────────────────────── function readSettings() { @@ -186,6 +201,8 @@ async function runAllCleanup() { tasks.import_history = { pruned }; } + tasks.soft_deleted_records = pruneSoftDeletedFinancialRecords(30); + const ran_at = new Date().toISOString(); setSetting('cleanup_last_run_at', ran_at); setSetting('cleanup_last_result', JSON.stringify(tasks)); @@ -218,4 +235,5 @@ module.exports = { pruneStaleExportFiles, pruneOrphanedBackupPartials, pruneImportHistory, + pruneSoftDeletedFinancialRecords, }; diff --git a/services/notificationService.js b/services/notificationService.js index cd1cee0..28194bd 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -180,7 +180,7 @@ async function runNotifications() { // Fetch all active bills. In global-notification mode, the single global recipient // 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. - 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 globalRecipient = getSetting('notify_global_recipient');