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),
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'),

View File

@ -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: '🔐',

View File

@ -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() {
</AlertDialogContent>
</AlertDialog>
{/* ── Permanent delete confirmation ── */}
{/* ── Soft delete confirmation ── */}
<AlertDialog
open={!!deleteTarget}
onOpenChange={open => {
@ -682,15 +696,15 @@ export default function BillsPage() {
>
<AlertDialogContent className="max-w-lg">
<AlertDialogHeader>
<AlertDialogTitle>Delete "{deleteTarget?.name}" permanently?</AlertDialogTitle>
<AlertDialogTitle>Move "{deleteTarget?.name}" to recovery?</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="rounded-lg border border-destructive/25 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Deactivate is the safer option if you only want to hide this bill from active tracking.
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
Deactivate is still the best option if you want to retain the bill long-term but hide it from active tracking.
</div>
<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}
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>
<AlertDialogFooter className="sm:justify-between">
@ -726,7 +740,7 @@ export default function BillsPage() {
disabled={!deleteConfirmed || deleteBusy}
onClick={handleDeleteConfirmed}
>
{deleteBusy ? 'Deleting…' : 'Delete permanently'}
{deleteBusy ? 'Moving…' : 'Move to recovery'}
</Button>
</div>
</AlertDialogFooter>

View File

@ -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() {
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleteTarget?.name}?</AlertDialogTitle>
<AlertDialogTitle>Move {deleteTarget?.name} to recovery?</AlertDialogTitle>
<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>
</AlertDialogHeader>
<AlertDialogFooter>
@ -500,7 +515,7 @@ export default function CategoriesPage() {
onClick={handleDelete}
disabled={deleting}
>
{deleting ? 'Deleting...' : 'Delete Category'}
{deleting ? 'Moving...' : 'Move to Recovery'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@ -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 (
<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>
<DialogTitle className="text-base font-semibold tracking-tight">Edit Payment</DialogTitle>
</DialogHeader>
@ -764,7 +783,7 @@ function PaymentModal({ payment, onClose, onSave }) {
<DialogFooter className="flex-row gap-2 sm:justify-between mt-2">
<Button
type="button" variant="destructive" disabled={busy} onClick={handleDelete}
type="button" variant="destructive" disabled={busy} onClick={() => setConfirmDelete(true)}
className="text-xs"
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>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</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 [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 (
<>
<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 [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 (
<>
<div
@ -1200,6 +1312,26 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
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
'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',
]
}
};

View File

@ -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'))

View File

@ -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(

View File

@ -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'));
}

View File

@ -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

View File

@ -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;

View File

@ -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 = [

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];
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)

View File

@ -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}
`;

View File

@ -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

View File

@ -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);

View File

@ -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,
};

View File

@ -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');