v0.28.0
This commit is contained in:
parent
9174ec3290
commit
59d9d21d4c
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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: '🔐',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue