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),
|
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
|
||||||
deleteBill: (id) => del(`/bills/${id}`),
|
deleteBill: (id) => del(`/bills/${id}`),
|
||||||
|
restoreBill: (id) => post(`/bills/${id}/restore`),
|
||||||
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
|
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
|
||||||
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
|
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
|
||||||
billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
|
billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
|
||||||
|
|
@ -183,6 +184,7 @@ export const api = {
|
||||||
createCategory: (data) => post('/categories', data),
|
createCategory: (data) => post('/categories', data),
|
||||||
updateCategory: (id, data) => put(`/categories/${id}`, data),
|
updateCategory: (id, data) => put(`/categories/${id}`, data),
|
||||||
deleteCategory: (id) => del(`/categories/${id}`),
|
deleteCategory: (id) => del(`/categories/${id}`),
|
||||||
|
restoreCategory: (id) => post(`/categories/${id}/restore`),
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
settings: () => get('/settings'),
|
settings: () => get('/settings'),
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ export const RELEASE_NOTES = {
|
||||||
highlights: [
|
highlights: [
|
||||||
{
|
{
|
||||||
icon: '🛡️',
|
icon: '🛡️',
|
||||||
title: 'Safer payment and settings data',
|
title: 'Safer financial history',
|
||||||
desc: 'Payment amounts and dates are now validated consistently, and regular-user settings are stored per user instead of globally.',
|
desc: 'Bill, category, and payment deletes now use confirmations, recovery windows, and undo actions so accidental clicks do not immediately destroy important records.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🔐',
|
icon: '🔐',
|
||||||
|
|
|
||||||
|
|
@ -504,7 +504,21 @@ export default function BillsPage() {
|
||||||
const bill = deleteTarget;
|
const bill = deleteTarget;
|
||||||
await api.deleteBill(bill.id);
|
await api.deleteBill(bill.id);
|
||||||
setBills(prev => prev.filter(b => b.id !== bill.id));
|
setBills(prev => prev.filter(b => b.id !== bill.id));
|
||||||
toast.success(`"${bill.name}" deleted permanently`);
|
toast.success(`"${bill.name}" moved to recovery`, {
|
||||||
|
description: 'It will be permanently purged after 30 days.',
|
||||||
|
action: {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await api.restoreBill(bill.id);
|
||||||
|
toast.success(`"${bill.name}" restored`);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to restore bill');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
setDeleteConfirmed(false);
|
setDeleteConfirmed(false);
|
||||||
load();
|
load();
|
||||||
|
|
@ -670,7 +684,7 @@ export default function BillsPage() {
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* ── Permanent delete confirmation ── */}
|
{/* ── Soft delete confirmation ── */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
onOpenChange={open => {
|
onOpenChange={open => {
|
||||||
|
|
@ -682,15 +696,15 @@ export default function BillsPage() {
|
||||||
>
|
>
|
||||||
<AlertDialogContent className="max-w-lg">
|
<AlertDialogContent className="max-w-lg">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete "{deleteTarget?.name}" permanently?</AlertDialogTitle>
|
<AlertDialogTitle>Move "{deleteTarget?.name}" to recovery?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This permanently deletes the bill and all data for this bill, including payments,
|
This hides the bill from normal tracking and keeps its payments, monthly history,
|
||||||
monthly history, notes, and history ranges. This cannot be undone.
|
notes, and history ranges recoverable for 30 days.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
|
|
||||||
<div className="rounded-lg border border-destructive/25 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
|
||||||
Deactivate is the safer option if you only want to hide this bill from active tracking.
|
Deactivate is still the best option if you want to retain the bill long-term but hide it from active tracking.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-start gap-3 rounded-lg border border-border bg-muted/25 px-4 py-3 text-sm">
|
<label className="flex items-start gap-3 rounded-lg border border-border bg-muted/25 px-4 py-3 text-sm">
|
||||||
|
|
@ -701,7 +715,7 @@ export default function BillsPage() {
|
||||||
disabled={deleteBusy}
|
disabled={deleteBusy}
|
||||||
className="mt-0.5 h-4 w-4 rounded border-input bg-input accent-destructive"
|
className="mt-0.5 h-4 w-4 rounded border-input bg-input accent-destructive"
|
||||||
/>
|
/>
|
||||||
<span>I understand this deletes all data for this bill and cannot be undone.</span>
|
<span>I understand this removes the bill from normal views and will be purged after 30 days if not restored.</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<AlertDialogFooter className="sm:justify-between">
|
<AlertDialogFooter className="sm:justify-between">
|
||||||
|
|
@ -726,7 +740,7 @@ export default function BillsPage() {
|
||||||
disabled={!deleteConfirmed || deleteBusy}
|
disabled={!deleteConfirmed || deleteBusy}
|
||||||
onClick={handleDeleteConfirmed}
|
onClick={handleDeleteConfirmed}
|
||||||
>
|
>
|
||||||
{deleteBusy ? 'Deleting…' : 'Delete permanently'}
|
{deleteBusy ? 'Moving…' : 'Move to recovery'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -315,11 +315,26 @@ export default function CategoriesPage() {
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
|
const category = deleteTarget;
|
||||||
await api.deleteCategory(deleteTarget.id);
|
await api.deleteCategory(deleteTarget.id);
|
||||||
toast.success(`"${deleteTarget.name}" deleted`);
|
toast.success(`"${category.name}" moved to recovery`, {
|
||||||
|
description: 'Bills keep their category if you restore it within 30 days.',
|
||||||
|
action: {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await api.restoreCategory(category.id);
|
||||||
|
toast.success(`"${category.name}" restored`);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to restore category');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
setExpanded(prev => {
|
setExpanded(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(deleteTarget.id);
|
next.delete(category.id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
|
|
@ -488,9 +503,9 @@ export default function CategoriesPage() {
|
||||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete {deleteTarget?.name}?</AlertDialogTitle>
|
<AlertDialogTitle>Move {deleteTarget?.name} to recovery?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Bills in this category will become uncategorized. No bills or payments will be deleted.
|
This hides the category from normal views. Bills keep their category link, and you can restore it for 30 days.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
@ -500,7 +515,7 @@ export default function CategoriesPage() {
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
{deleting ? 'Deleting...' : 'Delete Category'}
|
{deleting ? 'Moving...' : 'Move to Recovery'}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ import {
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||||
|
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
|
@ -690,6 +694,7 @@ function PaymentModal({ payment, onClose, onSave }) {
|
||||||
const [method, setMethod] = useState(payment.method || METHOD_NONE);
|
const [method, setMethod] = useState(payment.method || METHOD_NONE);
|
||||||
const [notes, setNotes] = useState(payment.notes || '');
|
const [notes, setNotes] = useState(payment.notes || '');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
async function handleSave(e) {
|
async function handleSave(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -712,7 +717,20 @@ function PaymentModal({ payment, onClose, onSave }) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.deletePayment(payment.id);
|
await api.deletePayment(payment.id);
|
||||||
toast.success('Payment removed. Bill is now marked as unpaid.');
|
toast.success('Payment moved to recovery. Bill is now marked as unpaid.', {
|
||||||
|
action: {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await api.restorePayment(payment.id);
|
||||||
|
toast.success('Payment restored');
|
||||||
|
onSave();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to restore payment');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
onSave(); onClose();
|
onSave(); onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message);
|
toast.error(err.message);
|
||||||
|
|
@ -720,6 +738,7 @@ function PaymentModal({ payment, onClose, onSave }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Dialog open onOpenChange={v => { if (!v) onClose(); }}>
|
<Dialog open onOpenChange={v => { if (!v) onClose(); }}>
|
||||||
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl">
|
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
@ -764,7 +783,7 @@ function PaymentModal({ payment, onClose, onSave }) {
|
||||||
|
|
||||||
<DialogFooter className="flex-row gap-2 sm:justify-between mt-2">
|
<DialogFooter className="flex-row gap-2 sm:justify-between mt-2">
|
||||||
<Button
|
<Button
|
||||||
type="button" variant="destructive" disabled={busy} onClick={handleDelete}
|
type="button" variant="destructive" disabled={busy} onClick={() => setConfirmDelete(true)}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
title="Removes this payment record. The bill itself is NOT deleted."
|
title="Removes this payment record. The bill itself is NOT deleted."
|
||||||
>
|
>
|
||||||
|
|
@ -777,6 +796,28 @@ function PaymentModal({ payment, onClose, onSave }) {
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove this payment?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This marks the payment as removed and reverses any debt balance update. You can undo it from the toast.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{busy ? 'Removing...' : 'Remove Payment'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -785,6 +826,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
||||||
const amountRef = useRef(null);
|
const amountRef = useRef(null);
|
||||||
const [editPayment, setEditPayment] = useState(null);
|
const [editPayment, setEditPayment] = useState(null);
|
||||||
const [showMbs, setShowMbs] = useState(false);
|
const [showMbs, setShowMbs] = useState(false);
|
||||||
|
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// Effective amount threshold for this bill this month:
|
// Effective amount threshold for this bill this month:
|
||||||
|
|
@ -820,7 +862,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTogglePaid() {
|
async function performTogglePaid() {
|
||||||
setLoading?.(true);
|
setLoading?.(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.togglePaid(row.id, {
|
const result = await api.togglePaid(row.id, {
|
||||||
|
|
@ -828,7 +870,24 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
||||||
year: year,
|
year: year,
|
||||||
month: month,
|
month: month,
|
||||||
});
|
});
|
||||||
toast.success(isPaid ? 'Payment removed' : 'Payment recorded');
|
if (isPaid && result.paymentId) {
|
||||||
|
toast.success('Payment moved to recovery', {
|
||||||
|
action: {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await api.restorePayment(result.paymentId);
|
||||||
|
toast.success('Payment restored');
|
||||||
|
refresh?.();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to restore payment');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success('Payment recorded');
|
||||||
|
}
|
||||||
refresh?.();
|
refresh?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to toggle payment status');
|
toast.error(err.message || 'Failed to toggle payment status');
|
||||||
|
|
@ -837,6 +896,14 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTogglePaid() {
|
||||||
|
if (isPaid) {
|
||||||
|
setConfirmUnpay(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performTogglePaid();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TableRow
|
<TableRow
|
||||||
|
|
@ -1006,7 +1073,26 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Payment toggle confirmation dialog */}
|
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Mark this bill unpaid?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This removes the current payment record for this month and moves it into recovery.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={performTogglePaid}
|
||||||
|
>
|
||||||
|
{loading ? 'Removing...' : 'Remove Payment'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1015,6 +1101,7 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
||||||
const amountRef = useRef(null);
|
const amountRef = useRef(null);
|
||||||
const [editPayment, setEditPayment] = useState(null);
|
const [editPayment, setEditPayment] = useState(null);
|
||||||
const [showMbs, setShowMbs] = useState(false);
|
const [showMbs, setShowMbs] = useState(false);
|
||||||
|
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
||||||
|
|
||||||
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
||||||
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
|
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
|
||||||
|
|
@ -1041,20 +1128,45 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTogglePaid() {
|
async function performTogglePaid() {
|
||||||
try {
|
try {
|
||||||
await api.togglePaid(row.id, {
|
const result = await api.togglePaid(row.id, {
|
||||||
amount: isPaid ? undefined : threshold,
|
amount: isPaid ? undefined : threshold,
|
||||||
year: year,
|
year: year,
|
||||||
month: month,
|
month: month,
|
||||||
});
|
});
|
||||||
toast.success(isPaid ? 'Payment removed' : 'Payment recorded');
|
if (isPaid && result.paymentId) {
|
||||||
|
toast.success('Payment moved to recovery', {
|
||||||
|
action: {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await api.restorePayment(result.paymentId);
|
||||||
|
toast.success('Payment restored');
|
||||||
|
refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to restore payment');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success('Payment recorded');
|
||||||
|
}
|
||||||
refresh();
|
refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to toggle payment status');
|
toast.error(err.message || 'Failed to toggle payment status');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTogglePaid() {
|
||||||
|
if (isPaid) {
|
||||||
|
setConfirmUnpay(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performTogglePaid();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|
@ -1200,6 +1312,26 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
||||||
onSaved={refresh}
|
onSaved={refresh}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Mark this bill unpaid?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This removes the current payment record for this month and moves it into recovery.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={performTogglePaid}
|
||||||
|
>
|
||||||
|
Remove Payment
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ const COLUMN_WHITELIST = new Set([
|
||||||
// bills table columns
|
// bills table columns
|
||||||
'history_visibility', 'interest_rate', 'user_id',
|
'history_visibility', 'interest_rate', 'user_id',
|
||||||
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
|
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
|
||||||
'snowball_exempt',
|
'snowball_exempt', 'deleted_at',
|
||||||
// sessions table columns
|
// sessions table columns
|
||||||
'created_at',
|
'created_at',
|
||||||
]);
|
]);
|
||||||
|
|
@ -770,6 +770,28 @@ function reconcileLegacyMigrations() {
|
||||||
`);
|
`);
|
||||||
console.log('[migration] user_login_history table created');
|
console.log('[migration] user_login_history table created');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.56',
|
||||||
|
description: 'bills/categories: soft-delete columns',
|
||||||
|
check: function() {
|
||||||
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||||
|
const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
||||||
|
return billCols.includes('deleted_at') && catCols.includes('deleted_at');
|
||||||
|
},
|
||||||
|
run: function() {
|
||||||
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||||
|
const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
||||||
|
if (!billCols.includes('deleted_at')) {
|
||||||
|
db.exec('ALTER TABLE bills ADD COLUMN deleted_at TEXT');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_deleted ON bills(user_id, deleted_at)');
|
||||||
|
}
|
||||||
|
if (!catCols.includes('deleted_at')) {
|
||||||
|
db.exec('ALTER TABLE categories ADD COLUMN deleted_at TEXT');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(user_id, deleted_at)');
|
||||||
|
}
|
||||||
|
console.log('[migration] bills/categories deleted_at columns added');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -1397,6 +1419,24 @@ function runMigrations() {
|
||||||
}
|
}
|
||||||
console.log('[migration] user_login_history device metadata columns ensured');
|
console.log('[migration] user_login_history device metadata columns ensured');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.56',
|
||||||
|
description: 'bills/categories: soft-delete columns',
|
||||||
|
dependsOn: ['v0.55'],
|
||||||
|
run: function() {
|
||||||
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||||
|
const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
||||||
|
if (!billCols.includes('deleted_at')) {
|
||||||
|
db.exec('ALTER TABLE bills ADD COLUMN deleted_at TEXT');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_deleted ON bills(user_id, deleted_at)');
|
||||||
|
}
|
||||||
|
if (!catCols.includes('deleted_at')) {
|
||||||
|
db.exec('ALTER TABLE categories ADD COLUMN deleted_at TEXT');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(user_id, deleted_at)');
|
||||||
|
}
|
||||||
|
console.log('[migration] bills/categories deleted_at columns added');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -1808,6 +1848,15 @@ const ROLLBACK_SQL_MAP = {
|
||||||
'ALTER TABLE user_login_history DROP COLUMN os',
|
'ALTER TABLE user_login_history DROP COLUMN os',
|
||||||
'ALTER TABLE user_login_history DROP COLUMN browser',
|
'ALTER TABLE user_login_history DROP COLUMN browser',
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
'v0.56': {
|
||||||
|
description: 'bills/categories soft-delete columns',
|
||||||
|
sql: [
|
||||||
|
'DROP INDEX IF EXISTS idx_categories_deleted',
|
||||||
|
'DROP INDEX IF EXISTS idx_bills_deleted',
|
||||||
|
'ALTER TABLE categories DROP COLUMN deleted_at',
|
||||||
|
'ALTER TABLE bills DROP COLUMN deleted_at',
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS categories (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
deleted_at TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
@ -32,6 +33,7 @@ CREATE TABLE IF NOT EXISTS bills (
|
||||||
snowball_order INTEGER,
|
snowball_order INTEGER,
|
||||||
snowball_include INTEGER NOT NULL DEFAULT 0,
|
snowball_include INTEGER NOT NULL DEFAULT 0,
|
||||||
snowball_exempt INTEGER NOT NULL DEFAULT 0,
|
snowball_exempt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted_at TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ function isMonthInPast(year, month) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBillWhere({ userId, categoryId, billId, includeInactive }) {
|
function buildBillWhere({ userId, categoryId, billId, includeInactive }) {
|
||||||
const clauses = ['b.user_id = ?'];
|
const clauses = ['b.user_id = ?', 'b.deleted_at IS NULL'];
|
||||||
const params = [userId];
|
const params = [userId];
|
||||||
if (!includeInactive) clauses.push('b.active = 1');
|
if (!includeInactive) clauses.push('b.active = 1');
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
|
|
@ -110,6 +110,7 @@ router.get('/summary', (req, res) => {
|
||||||
SELECT id, name
|
SELECT id, name
|
||||||
FROM categories
|
FROM categories
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
|
AND deleted_at IS NULL
|
||||||
ORDER BY name COLLATE NOCASE
|
ORDER BY name COLLATE NOCASE
|
||||||
`).all(userId);
|
`).all(userId);
|
||||||
|
|
||||||
|
|
@ -117,7 +118,7 @@ router.get('/summary', (req, res) => {
|
||||||
SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at,
|
SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at,
|
||||||
c.name AS category_name
|
c.name AS category_name
|
||||||
FROM bills b
|
FROM bills b
|
||||||
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id
|
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
|
||||||
WHERE ${billWhere.where}
|
WHERE ${billWhere.where}
|
||||||
ORDER BY b.name COLLATE NOCASE
|
ORDER BY b.name COLLATE NOCASE
|
||||||
`).all(...billWhere.params);
|
`).all(...billWhere.params);
|
||||||
|
|
@ -154,6 +155,7 @@ router.get('/summary', (req, res) => {
|
||||||
FROM payments p
|
FROM payments p
|
||||||
JOIN bills b ON b.id = p.bill_id
|
JOIN bills b ON b.id = p.bill_id
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
AND p.bill_id IN (${placeholders})
|
AND p.bill_id IN (${placeholders})
|
||||||
AND p.paid_date BETWEEN ? AND ?
|
AND p.paid_date BETWEEN ? AND ?
|
||||||
AND p.deleted_at IS NULL
|
AND p.deleted_at IS NULL
|
||||||
|
|
@ -169,6 +171,7 @@ router.get('/summary', (req, res) => {
|
||||||
FROM monthly_bill_state m
|
FROM monthly_bill_state m
|
||||||
JOIN bills b ON b.id = m.bill_id
|
JOIN bills b ON b.id = m.bill_id
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
AND m.bill_id IN (${placeholders})
|
AND m.bill_id IN (${placeholders})
|
||||||
AND (m.year * 100 + m.month) BETWEEN ? AND ?
|
AND (m.year * 100 + m.month) BETWEEN ? AND ?
|
||||||
`).all(
|
`).all(
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,9 @@ router.get('/', (req, res) => {
|
||||||
SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id
|
SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id
|
||||||
) THEN 1 ELSE 0 END AS has_history_ranges
|
) THEN 1 ELSE 0 END AS has_history_ranges
|
||||||
FROM bills b
|
FROM bills b
|
||||||
LEFT JOIN categories c ON b.category_id = c.id
|
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
${includeInactive ? '' : 'AND b.active = 1'}
|
${includeInactive ? '' : 'AND b.active = 1'}
|
||||||
ORDER BY b.due_day ASC, b.name ASC
|
ORDER BY b.due_day ASC, b.name ASC
|
||||||
`).all(req.user.id);
|
`).all(req.user.id);
|
||||||
|
|
@ -29,7 +30,7 @@ router.get('/', (req, res) => {
|
||||||
router.get('/:id/monthly-state', (req, res) => {
|
router.get('/:id/monthly-state', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const billId = parseInt(req.params.id, 10);
|
const billId = parseInt(req.params.id, 10);
|
||||||
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id))
|
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id))
|
||||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
const year = parseInt(req.query.year, 10);
|
const year = parseInt(req.query.year, 10);
|
||||||
|
|
@ -57,7 +58,7 @@ router.get('/:id/monthly-state', (req, res) => {
|
||||||
router.put('/:id/monthly-state', (req, res) => {
|
router.put('/:id/monthly-state', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const billId = parseInt(req.params.id, 10);
|
const billId = parseInt(req.params.id, 10);
|
||||||
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id))
|
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id))
|
||||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
const { year, month, actual_amount, notes, is_skipped } = req.body;
|
const { year, month, actual_amount, notes, is_skipped } = req.body;
|
||||||
|
|
@ -114,8 +115,8 @@ router.get('/:id', (req, res) => {
|
||||||
SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id
|
SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id
|
||||||
) THEN 1 ELSE 0 END AS has_history_ranges
|
) THEN 1 ELSE 0 END AS has_history_ranges
|
||||||
FROM bills b
|
FROM bills b
|
||||||
LEFT JOIN categories c ON b.category_id = c.id
|
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
|
||||||
WHERE b.id = ? AND b.user_id = ?
|
WHERE b.id = ? AND b.user_id = ? AND b.deleted_at IS NULL
|
||||||
`).get(req.params.id, req.user.id);
|
`).get(req.params.id, req.user.id);
|
||||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
res.json(bill);
|
res.json(bill);
|
||||||
|
|
@ -140,7 +141,7 @@ router.post('/', (req, res) => {
|
||||||
const { normalized } = validation;
|
const { normalized } = validation;
|
||||||
|
|
||||||
// Validate category_id exists for this user
|
// Validate category_id exists for this user
|
||||||
if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(normalized.category_id, req.user.id)) {
|
if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(normalized.category_id, req.user.id)) {
|
||||||
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,7 +186,7 @@ router.post('/', (req, res) => {
|
||||||
// ── PUT /api/bills/:id ────────────────────────────────────────────────────────
|
// ── PUT /api/bills/:id ────────────────────────────────────────────────────────
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const existing = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
const existing = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
|
||||||
if (!existing) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
if (!existing) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
// Validate and normalize bill data
|
// Validate and normalize bill data
|
||||||
|
|
@ -198,7 +199,7 @@ router.put('/:id', (req, res) => {
|
||||||
const { normalized } = validation;
|
const { normalized } = validation;
|
||||||
|
|
||||||
// Validate category_id exists for this user if changed
|
// Validate category_id exists for this user if changed
|
||||||
if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(normalized.category_id, req.user.id)) {
|
if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(normalized.category_id, req.user.id)) {
|
||||||
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,35 +241,43 @@ router.put('/:id', (req, res) => {
|
||||||
req.user.id,
|
req.user.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updated = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
const updated = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DELETE /api/bills/:id — destructive hard-delete ───────────────────────────
|
// ── DELETE /api/bills/:id — soft delete for 30-day recovery ───────────────────
|
||||||
// Permanently removes the bill and all associated data (payments, monthly state,
|
|
||||||
// history ranges). Inactivation (PUT with active:0) is the safer alternative.
|
|
||||||
// WARNING: this action is irreversible.
|
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
|
||||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
// ON DELETE CASCADE in the schema removes payments, monthly_bill_state, and
|
db.prepare("UPDATE bills SET deleted_at = datetime('now'), active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
|
||||||
// bill_history_ranges automatically. Verify foreign_keys pragma is ON.
|
.run(req.params.id, req.user.id);
|
||||||
db.prepare('DELETE FROM bills WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
deleted_bill_id: bill.id,
|
deleted_bill_id: bill.id,
|
||||||
deleted_bill_name: bill.name,
|
deleted_bill_name: bill.name,
|
||||||
warning: 'Bill and all associated payments, monthly state, and history ranges were permanently deleted.',
|
recoverable_until_days: 30,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/bills/:id/restore — undo bill soft delete
|
||||||
|
router.post('/:id/restore', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL').get(req.params.id, req.user.id);
|
||||||
|
if (!bill) return res.status(404).json(standardizeError('Deleted bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
|
db.prepare("UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
|
||||||
|
.run(req.params.id, req.user.id);
|
||||||
|
|
||||||
|
res.json(db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id));
|
||||||
|
});
|
||||||
|
|
||||||
// ── GET /api/bills/:id/payments?page=1&limit=20 ───────────────────────────────
|
// ── GET /api/bills/:id/payments?page=1&limit=20 ───────────────────────────────
|
||||||
router.get('/:id/payments', (req, res) => {
|
router.get('/:id/payments', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
|
||||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
const limit = Math.min(parseInt(req.query.limit || '20', 10), 100);
|
const limit = Math.min(parseInt(req.query.limit || '20', 10), 100);
|
||||||
|
|
@ -300,7 +309,7 @@ router.post('/:id/toggle-paid', (req, res) => {
|
||||||
const billId = parseInt(req.params.id, 10);
|
const billId = parseInt(req.params.id, 10);
|
||||||
|
|
||||||
// Get bill - always scope to the requesting user
|
// Get bill - always scope to the requesting user
|
||||||
const bill = db.prepare('SELECT id, expected_amount, user_id, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id);
|
const bill = db.prepare('SELECT id, expected_amount, user_id, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
|
||||||
|
|
||||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
|
|
@ -398,14 +407,14 @@ router.post('/:id/toggle-paid', (req, res) => {
|
||||||
// ── GET /api/bills/:id/history-ranges ────────────────────────────────────────
|
// ── GET /api/bills/:id/history-ranges ────────────────────────────────────────
|
||||||
router.get('/:id/history-ranges', (req, res) => {
|
router.get('/:id/history-ranges', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id))
|
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id))
|
||||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
const ranges = db.prepare(
|
const ranges = db.prepare(
|
||||||
'SELECT * FROM bill_history_ranges WHERE bill_id = ? ORDER BY start_year ASC, start_month ASC'
|
'SELECT * FROM bill_history_ranges WHERE bill_id = ? ORDER BY start_year ASC, start_month ASC'
|
||||||
).all(req.params.id);
|
).all(req.params.id);
|
||||||
|
|
||||||
const bill = db.prepare('SELECT history_visibility FROM bills WHERE id = ?').get(req.params.id);
|
const bill = db.prepare('SELECT history_visibility FROM bills WHERE id = ? AND deleted_at IS NULL').get(req.params.id);
|
||||||
|
|
||||||
res.json({ bill_id: parseInt(req.params.id, 10), history_visibility: bill.history_visibility, ranges });
|
res.json({ bill_id: parseInt(req.params.id, 10), history_visibility: bill.history_visibility, ranges });
|
||||||
});
|
});
|
||||||
|
|
@ -413,7 +422,7 @@ router.get('/:id/history-ranges', (req, res) => {
|
||||||
// ── POST /api/bills/:id/history-ranges ───────────────────────────────────────
|
// ── POST /api/bills/:id/history-ranges ───────────────────────────────────────
|
||||||
router.post('/:id/history-ranges', (req, res) => {
|
router.post('/:id/history-ranges', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id))
|
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id))
|
||||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
const { start_year, start_month, end_year, end_month, label } = req.body;
|
const { start_year, start_month, end_year, end_month, label } = req.body;
|
||||||
|
|
@ -458,7 +467,7 @@ router.post('/:id/history-ranges', (req, res) => {
|
||||||
// ── PUT /api/bills/:id/history-ranges/:rangeId ───────────────────────────────
|
// ── PUT /api/bills/:id/history-ranges/:rangeId ───────────────────────────────
|
||||||
router.put('/:id/history-ranges/:rangeId', (req, res) => {
|
router.put('/:id/history-ranges/:rangeId', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id))
|
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id))
|
||||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
const range = db.prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
|
const range = db.prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
|
||||||
|
|
@ -502,7 +511,7 @@ router.put('/:id/history-ranges/:rangeId', (req, res) => {
|
||||||
// ── DELETE /api/bills/:id/history-ranges/:rangeId ────────────────────────────
|
// ── DELETE /api/bills/:id/history-ranges/:rangeId ────────────────────────────
|
||||||
router.delete('/:id/history-ranges/:rangeId', (req, res) => {
|
router.delete('/:id/history-ranges/:rangeId', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id))
|
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id))
|
||||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
const range = db.prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
|
const range = db.prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
|
||||||
|
|
@ -519,7 +528,7 @@ router.delete('/:id/history-ranges/:rangeId', (req, res) => {
|
||||||
router.get('/:id/amortization', (req, res) => {
|
router.get('/:id/amortization', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const billId = parseInt(req.params.id, 10);
|
const billId = parseInt(req.params.id, 10);
|
||||||
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id);
|
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
|
||||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
const balance = Number(bill.current_balance);
|
const balance = Number(bill.current_balance);
|
||||||
|
|
@ -571,7 +580,7 @@ router.get('/:id/amortization', (req, res) => {
|
||||||
router.patch('/:id/snowball', (req, res) => {
|
router.patch('/:id/snowball', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const billId = parseInt(req.params.id, 10);
|
const billId = parseInt(req.params.id, 10);
|
||||||
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) {
|
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id)) {
|
||||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
}
|
}
|
||||||
const include = req.body.snowball_include !== undefined ? (req.body.snowball_include ? 1 : 0) : undefined;
|
const include = req.body.snowball_include !== undefined ? (req.body.snowball_include ? 1 : 0) : undefined;
|
||||||
|
|
@ -590,7 +599,7 @@ router.patch('/:id/snowball', (req, res) => {
|
||||||
router.patch('/:id/balance', (req, res) => {
|
router.patch('/:id/balance', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const billId = parseInt(req.params.id, 10);
|
const billId = parseInt(req.params.id, 10);
|
||||||
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) {
|
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id)) {
|
||||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,8 @@ router.get('/', (req, res) => {
|
||||||
const bills = db.prepare(`
|
const bills = db.prepare(`
|
||||||
SELECT b.*, c.name AS category_name
|
SELECT b.*, c.name AS category_name
|
||||||
FROM bills b
|
FROM bills b
|
||||||
LEFT JOIN categories c ON b.category_id = c.id
|
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
|
||||||
WHERE b.active = 1 AND b.user_id = ?
|
WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
|
||||||
ORDER BY b.due_day ASC, b.name ASC
|
ORDER BY b.due_day ASC, b.name ASC
|
||||||
`).all(req.user.id);
|
`).all(req.user.id);
|
||||||
|
|
||||||
|
|
@ -87,6 +87,7 @@ router.get('/', (req, res) => {
|
||||||
FROM payments p
|
FROM payments p
|
||||||
JOIN bills b ON p.bill_id = b.id
|
JOIN bills b ON p.bill_id = b.id
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
AND p.paid_date BETWEEN ? AND ?
|
AND p.paid_date BETWEEN ? AND ?
|
||||||
AND p.deleted_at IS NULL
|
AND p.deleted_at IS NULL
|
||||||
ORDER BY p.paid_date ASC, b.name ASC
|
ORDER BY p.paid_date ASC, b.name ASC
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ router.get('/', (req, res) => {
|
||||||
SELECT id, user_id, name, created_at, updated_at
|
SELECT id, user_id, name, created_at, updated_at
|
||||||
FROM categories
|
FROM categories
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
|
AND deleted_at IS NULL
|
||||||
ORDER BY name COLLATE NOCASE ASC
|
ORDER BY name COLLATE NOCASE ASC
|
||||||
`).all(req.user.id);
|
`).all(req.user.id);
|
||||||
|
|
||||||
|
|
@ -32,6 +33,7 @@ router.get('/', (req, res) => {
|
||||||
AND p.deleted_at IS NULL
|
AND p.deleted_at IS NULL
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
AND b.category_id = ?
|
AND b.category_id = ?
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
GROUP BY b.id
|
GROUP BY b.id
|
||||||
ORDER BY b.active DESC, b.due_day ASC, b.name COLLATE NOCASE ASC
|
ORDER BY b.active DESC, b.due_day ASC, b.name COLLATE NOCASE ASC
|
||||||
`);
|
`);
|
||||||
|
|
@ -87,13 +89,13 @@ router.put('/:id', (req, res) => {
|
||||||
const { name } = req.body;
|
const { name } = req.body;
|
||||||
if (!name) return res.status(400).json(standardizeError('name is required', 'VALIDATION_ERROR', 'name'));
|
if (!name) return res.status(400).json(standardizeError('name is required', 'VALIDATION_ERROR', 'name'));
|
||||||
|
|
||||||
const cat = db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
const cat = db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
|
||||||
if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id'));
|
if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db.prepare("UPDATE categories SET name = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
|
db.prepare("UPDATE categories SET name = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
|
||||||
.run(name.trim(), req.params.id, req.user.id);
|
.run(name.trim(), req.params.id, req.user.id);
|
||||||
res.json(db.prepare('SELECT * FROM categories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id));
|
res.json(db.prepare('SELECT * FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.message.includes('UNIQUE')) {
|
if (e.message.includes('UNIQUE')) {
|
||||||
return res.status(409).json(standardizeError('Category already exists', 'CONFLICT', 'name'));
|
return res.status(409).json(standardizeError('Category already exists', 'CONFLICT', 'name'));
|
||||||
|
|
@ -105,27 +107,30 @@ router.put('/:id', (req, res) => {
|
||||||
// DELETE /api/categories/:id
|
// DELETE /api/categories/:id
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const cat = db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
const cat = db.prepare('SELECT id, name FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
|
||||||
if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id'));
|
if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id'));
|
||||||
|
|
||||||
const deleteCategory = db.transaction(() => {
|
const deleted = db.prepare("UPDATE categories SET deleted_at = datetime('now'), updated_at = datetime('now') WHERE id = ? AND user_id = ? AND deleted_at IS NULL")
|
||||||
const bills = db.prepare(`
|
|
||||||
UPDATE bills
|
|
||||||
SET category_id = NULL, updated_at = datetime('now')
|
|
||||||
WHERE category_id = ? AND user_id = ?
|
|
||||||
`).run(req.params.id, req.user.id);
|
|
||||||
|
|
||||||
const deleted = db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?')
|
|
||||||
.run(req.params.id, req.user.id);
|
.run(req.params.id, req.user.id);
|
||||||
|
|
||||||
return {
|
res.json({
|
||||||
|
success: true,
|
||||||
deleted: deleted.changes,
|
deleted: deleted.changes,
|
||||||
uncategorized_bills: bills.changes,
|
deleted_category_id: cat.id,
|
||||||
};
|
deleted_category_name: cat.name,
|
||||||
|
recoverable_until_days: 30,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const result = deleteCategory();
|
// POST /api/categories/:id/restore — undo category soft delete
|
||||||
res.json({ success: true, ...result });
|
router.post('/:id/restore', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const cat = db.prepare('SELECT id, name FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL').get(req.params.id, req.user.id);
|
||||||
|
if (!cat) return res.status(404).json(standardizeError('Deleted category not found', 'NOT_FOUND', 'id'));
|
||||||
|
|
||||||
|
db.prepare("UPDATE categories SET deleted_at = NULL, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
|
||||||
|
.run(req.params.id, req.user.id);
|
||||||
|
res.json(db.prepare('SELECT * FROM categories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,10 @@ router.get('/', (req, res) => {
|
||||||
p.created_at
|
p.created_at
|
||||||
FROM payments p
|
FROM payments p
|
||||||
JOIN bills b ON b.id = p.bill_id
|
JOIN bills b ON b.id = p.bill_id
|
||||||
LEFT JOIN categories c ON c.id = b.category_id
|
LEFT JOIN categories c ON c.id = b.category_id AND c.deleted_at IS NULL
|
||||||
WHERE strftime('%Y', p.paid_date) = ?
|
WHERE strftime('%Y', p.paid_date) = ?
|
||||||
AND b.user_id = ?
|
AND b.user_id = ?
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
AND p.deleted_at IS NULL
|
AND p.deleted_at IS NULL
|
||||||
ORDER BY p.paid_date ASC, b.name ASC
|
ORDER BY p.paid_date ASC, b.name ASC
|
||||||
`).all(String(year), req.user.id);
|
`).all(String(year), req.user.id);
|
||||||
|
|
@ -87,27 +88,27 @@ router.get('/', (req, res) => {
|
||||||
|
|
||||||
function getUserExportData(userId) {
|
function getUserExportData(userId) {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const categories = db.prepare('SELECT id, name, created_at, updated_at FROM categories WHERE user_id = ? ORDER BY name').all(userId);
|
const categories = db.prepare('SELECT id, name, created_at, updated_at FROM categories WHERE user_id = ? AND deleted_at IS NULL ORDER BY name').all(userId);
|
||||||
const bills = db.prepare(`
|
const bills = db.prepare(`
|
||||||
SELECT id, name, category_id, due_day, override_due_date, bucket, expected_amount, interest_rate,
|
SELECT id, name, category_id, due_day, override_due_date, bucket, expected_amount, interest_rate,
|
||||||
billing_cycle, cycle_type, cycle_day, autopay_enabled, autodraft_status, website, username,
|
billing_cycle, cycle_type, cycle_day, autopay_enabled, autodraft_status, website, username,
|
||||||
account_info, has_2fa, active, notes, created_at, updated_at
|
account_info, has_2fa, active, notes, created_at, updated_at
|
||||||
FROM bills
|
FROM bills
|
||||||
WHERE user_id = ?
|
WHERE user_id = ? AND deleted_at IS NULL
|
||||||
ORDER BY active DESC, due_day ASC, name ASC
|
ORDER BY active DESC, due_day ASC, name ASC
|
||||||
`).all(userId);
|
`).all(userId);
|
||||||
const payments = db.prepare(`
|
const payments = db.prepare(`
|
||||||
SELECT p.id, p.bill_id, p.amount, p.paid_date, p.method, p.notes, p.created_at, p.updated_at
|
SELECT p.id, p.bill_id, p.amount, p.paid_date, p.method, p.notes, p.created_at, p.updated_at
|
||||||
FROM payments p
|
FROM payments p
|
||||||
JOIN bills b ON b.id = p.bill_id
|
JOIN bills b ON b.id = p.bill_id
|
||||||
WHERE b.user_id = ? AND p.deleted_at IS NULL
|
WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.deleted_at IS NULL
|
||||||
ORDER BY p.paid_date ASC, p.id ASC
|
ORDER BY p.paid_date ASC, p.id ASC
|
||||||
`).all(userId);
|
`).all(userId);
|
||||||
const monthlyState = db.prepare(`
|
const monthlyState = db.prepare(`
|
||||||
SELECT m.id, m.bill_id, m.year, m.month, m.actual_amount, m.notes, m.is_skipped, m.created_at, m.updated_at
|
SELECT m.id, m.bill_id, m.year, m.month, m.actual_amount, m.notes, m.is_skipped, m.created_at, m.updated_at
|
||||||
FROM monthly_bill_state m
|
FROM monthly_bill_state m
|
||||||
JOIN bills b ON b.id = m.bill_id
|
JOIN bills b ON b.id = m.bill_id
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ? AND b.deleted_at IS NULL
|
||||||
ORDER BY m.year, m.month, m.bill_id
|
ORDER BY m.year, m.month, m.bill_id
|
||||||
`).all(userId);
|
`).all(userId);
|
||||||
const monthlyStartingAmounts = db.prepare(`
|
const monthlyStartingAmounts = db.prepare(`
|
||||||
|
|
@ -119,7 +120,7 @@ function getUserExportData(userId) {
|
||||||
const historyRanges = db.prepare(`
|
const historyRanges = db.prepare(`
|
||||||
SELECT id, bill_id, start_year, start_month, end_year, end_month, label, created_at, updated_at
|
SELECT id, bill_id, start_year, start_month, end_year, end_month, label, created_at, updated_at
|
||||||
FROM bill_history_ranges
|
FROM bill_history_ranges
|
||||||
WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ?)
|
WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)
|
||||||
ORDER BY bill_id, start_year, start_month
|
ORDER BY bill_id, start_year, start_month
|
||||||
`).all(userId);
|
`).all(userId);
|
||||||
const notes = [
|
const notes = [
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ router.get('/', (req, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.${LIVE} AND b.user_id = ?`;
|
let query = `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`;
|
||||||
const params = [req.user.id];
|
const params = [req.user.id];
|
||||||
|
|
||||||
if (bill_id) { query += ' AND p.bill_id = ?'; params.push(parseInt(bill_id, 10)); }
|
if (bill_id) { query += ' AND p.bill_id = ?'; params.push(parseInt(bill_id, 10)); }
|
||||||
|
|
@ -50,7 +50,7 @@ router.get('/', (req, res) => {
|
||||||
// GET /api/payments/:id
|
// GET /api/payments/:id
|
||||||
router.get('/:id', (req, res) => {
|
router.get('/:id', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id);
|
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
|
||||||
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
||||||
res.json(payment);
|
res.json(payment);
|
||||||
});
|
});
|
||||||
|
|
@ -66,7 +66,7 @@ router.post('/', (req, res) => {
|
||||||
}
|
}
|
||||||
const payment = validation.normalized;
|
const payment = validation.normalized;
|
||||||
|
|
||||||
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(payment.bill_id, req.user.id))
|
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id))
|
||||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
|
|
@ -86,7 +86,7 @@ router.post('/quick', (req, res) => {
|
||||||
return res.status(400).json(standardizeError(billValidation.error, 'VALIDATION_ERROR', billValidation.field));
|
return res.status(400).json(standardizeError(billValidation.error, 'VALIDATION_ERROR', billValidation.field));
|
||||||
}
|
}
|
||||||
|
|
||||||
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(billValidation.normalized.bill_id, req.user.id);
|
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billValidation.normalized.bill_id, req.user.id);
|
||||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
const paymentValidation = validatePaymentInput(
|
const paymentValidation = validatePaymentInput(
|
||||||
|
|
@ -152,7 +152,7 @@ router.post('/bulk', (req, res) => {
|
||||||
const insert = db.prepare(
|
const insert = db.prepare(
|
||||||
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
|
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
const getBillForBalance = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ?');
|
const getBillForBalance = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL');
|
||||||
const applyBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
|
const applyBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
|
||||||
|
|
||||||
// Prepare statement for duplicate checking
|
// Prepare statement for duplicate checking
|
||||||
|
|
@ -160,6 +160,7 @@ router.post('/bulk', (req, res) => {
|
||||||
`SELECT 1 FROM payments p
|
`SELECT 1 FROM payments p
|
||||||
JOIN bills b ON b.id = p.bill_id
|
JOIN bills b ON b.id = p.bill_id
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
AND p.bill_id = ?
|
AND p.bill_id = ?
|
||||||
AND p.paid_date = ?
|
AND p.paid_date = ?
|
||||||
AND p.amount = ?
|
AND p.amount = ?
|
||||||
|
|
@ -204,7 +205,7 @@ router.post('/bulk', (req, res) => {
|
||||||
// PUT /api/payments/:id
|
// PUT /api/payments/:id
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const existing = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id);
|
const existing = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
|
||||||
if (!existing) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
if (!existing) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
||||||
|
|
||||||
const { amount, paid_date, method, notes } = req.body;
|
const { amount, paid_date, method, notes } = req.body;
|
||||||
|
|
@ -235,7 +236,7 @@ router.put('/:id', (req, res) => {
|
||||||
// DELETE /api/payments/:id — soft delete (sets deleted_at)
|
// DELETE /api/payments/:id — soft delete (sets deleted_at)
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id);
|
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
|
||||||
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
||||||
|
|
||||||
// Reverse any balance delta that was stored when this payment was created
|
// Reverse any balance delta that was stored when this payment was created
|
||||||
|
|
@ -254,7 +255,7 @@ router.delete('/:id', (req, res) => {
|
||||||
// POST /api/payments/:id/restore — undo soft delete
|
// POST /api/payments/:id/restore — undo soft delete
|
||||||
router.post('/:id/restore', (req, res) => {
|
router.post('/:id/restore', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const payment = db.prepare('SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ?').get(req.params.id, req.user.id);
|
const payment = db.prepare('SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ? AND b.deleted_at IS NULL').get(req.params.id, req.user.id);
|
||||||
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
|
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
|
||||||
|
|
||||||
// Re-apply the balance delta (undo the reversal done on delete)
|
// Re-apply the balance delta (undo the reversal done on delete)
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,10 @@ function getDebtQuery(ramseyMode) {
|
||||||
return `
|
return `
|
||||||
SELECT b.*, c.name AS category_name
|
SELECT b.*, c.name AS category_name
|
||||||
FROM bills b
|
FROM bills b
|
||||||
LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id
|
LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id AND c.deleted_at IS NULL
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
AND b.active = 1
|
AND b.active = 1
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
AND ${DEBT_LIKE_CLAUSES}
|
AND ${DEBT_LIKE_CLAUSES}
|
||||||
ORDER BY${orderBy}
|
ORDER BY${orderBy}
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ function calculatePaidDeductions(db, userId, year, month) {
|
||||||
FROM payments p
|
FROM payments p
|
||||||
JOIN bills b ON b.id = p.bill_id
|
JOIN bills b ON b.id = p.bill_id
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
AND p.paid_date BETWEEN ? AND ?
|
AND p.paid_date BETWEEN ? AND ?
|
||||||
AND p.deleted_at IS NULL
|
AND p.deleted_at IS NULL
|
||||||
AND b.due_day BETWEEN 1 AND 14
|
AND b.due_day BETWEEN 1 AND 14
|
||||||
|
|
@ -59,6 +60,7 @@ function calculatePaidDeductions(db, userId, year, month) {
|
||||||
FROM payments p
|
FROM payments p
|
||||||
JOIN bills b ON b.id = p.bill_id
|
JOIN bills b ON b.id = p.bill_id
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
AND p.paid_date BETWEEN ? AND ?
|
AND p.paid_date BETWEEN ? AND ?
|
||||||
AND p.deleted_at IS NULL
|
AND p.deleted_at IS NULL
|
||||||
AND b.due_day BETWEEN 15 AND 31
|
AND b.due_day BETWEEN 15 AND 31
|
||||||
|
|
@ -70,6 +72,7 @@ function calculatePaidDeductions(db, userId, year, month) {
|
||||||
FROM payments p
|
FROM payments p
|
||||||
JOIN bills b ON b.id = p.bill_id
|
JOIN bills b ON b.id = p.bill_id
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
AND p.paid_date BETWEEN ? AND ?
|
AND p.paid_date BETWEEN ? AND ?
|
||||||
AND p.deleted_at IS NULL
|
AND p.deleted_at IS NULL
|
||||||
AND (b.due_day < 1 OR b.due_day > 31)
|
AND (b.due_day < 1 OR b.due_day > 31)
|
||||||
|
|
@ -80,6 +83,7 @@ function calculatePaidDeductions(db, userId, year, month) {
|
||||||
FROM payments p
|
FROM payments p
|
||||||
JOIN bills b ON b.id = p.bill_id
|
JOIN bills b ON b.id = p.bill_id
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
AND p.paid_date BETWEEN ? AND ?
|
AND p.paid_date BETWEEN ? AND ?
|
||||||
AND p.deleted_at IS NULL
|
AND p.deleted_at IS NULL
|
||||||
`).get(userId, start, end);
|
`).get(userId, start, end);
|
||||||
|
|
@ -145,9 +149,9 @@ function buildSummary(db, userId, year, month) {
|
||||||
m.actual_amount,
|
m.actual_amount,
|
||||||
m.is_skipped
|
m.is_skipped
|
||||||
FROM bills b
|
FROM bills b
|
||||||
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id
|
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
|
||||||
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
|
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
|
||||||
WHERE b.user_id = ? AND b.active = 1
|
WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
|
||||||
ORDER BY b.due_day ASC, b.name ASC
|
ORDER BY b.due_day ASC, b.name ASC
|
||||||
`).all(year, month, userId);
|
`).all(year, month, userId);
|
||||||
|
|
||||||
|
|
@ -161,6 +165,7 @@ function buildSummary(db, userId, year, month) {
|
||||||
FROM payments p
|
FROM payments p
|
||||||
JOIN bills b ON b.id = p.bill_id
|
JOIN bills b ON b.id = p.bill_id
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
AND p.bill_id IN (${placeholders})
|
AND p.bill_id IN (${placeholders})
|
||||||
AND p.paid_date BETWEEN ? AND ?
|
AND p.paid_date BETWEEN ? AND ?
|
||||||
AND p.deleted_at IS NULL
|
AND p.deleted_at IS NULL
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,8 @@ router.get('/', (req, res) => {
|
||||||
const bills = db.prepare(`
|
const bills = db.prepare(`
|
||||||
SELECT b.*, c.name AS category_name
|
SELECT b.*, c.name AS category_name
|
||||||
FROM bills b
|
FROM bills b
|
||||||
LEFT JOIN categories c ON b.category_id = c.id
|
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
|
||||||
WHERE b.active = 1 AND b.user_id = ?
|
WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
|
||||||
ORDER BY b.due_day ASC, b.name ASC
|
ORDER BY b.due_day ASC, b.name ASC
|
||||||
`).all(req.user.id);
|
`).all(req.user.id);
|
||||||
|
|
||||||
|
|
@ -156,7 +156,7 @@ router.get('/', (req, res) => {
|
||||||
SELECT SUM(p.amount) as total_paid, strftime('%Y-%m', p.paid_date) as month_key
|
SELECT SUM(p.amount) as total_paid, strftime('%Y-%m', p.paid_date) as month_key
|
||||||
FROM payments p
|
FROM payments p
|
||||||
JOIN bills b ON p.bill_id = b.id
|
JOIN bills b ON p.bill_id = b.id
|
||||||
WHERE b.user_id = ? AND p.paid_date BETWEEN ? AND ?
|
WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ?
|
||||||
AND p.deleted_at IS NULL
|
AND p.deleted_at IS NULL
|
||||||
GROUP BY strftime('%Y-%m', p.paid_date)
|
GROUP BY strftime('%Y-%m', p.paid_date)
|
||||||
`).all(req.user.id, threeMonthStart, currentMonthEnd);
|
`).all(req.user.id, threeMonthStart, currentMonthEnd);
|
||||||
|
|
@ -255,8 +255,8 @@ router.get('/upcoming', (req, res) => {
|
||||||
const bills = db.prepare(`
|
const bills = db.prepare(`
|
||||||
SELECT b.*, c.name AS category_name
|
SELECT b.*, c.name AS category_name
|
||||||
FROM bills b
|
FROM bills b
|
||||||
LEFT JOIN categories c ON b.category_id = c.id
|
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
|
||||||
WHERE b.active = 1 AND b.user_id = ?
|
WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
|
||||||
`).all(req.user.id);
|
`).all(req.user.id);
|
||||||
|
|
||||||
const cutoff = new Date(now);
|
const cutoff = new Date(now);
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,21 @@ function pruneImportHistory(maxAgeDays) {
|
||||||
return result.changes;
|
return result.changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently purge soft-deleted bills and categories after a 30-day recovery
|
||||||
|
* window. Bill deletion cascades to bill-owned records via foreign keys.
|
||||||
|
*/
|
||||||
|
function pruneSoftDeletedFinancialRecords(maxAgeDays = 30) {
|
||||||
|
const db = getDb();
|
||||||
|
const cutoff = `-${maxAgeDays} days`;
|
||||||
|
const purge = db.transaction(() => {
|
||||||
|
const bills = db.prepare("DELETE FROM bills WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?)").run(cutoff).changes;
|
||||||
|
const categories = db.prepare("DELETE FROM categories WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?)").run(cutoff).changes;
|
||||||
|
return { bills, categories };
|
||||||
|
});
|
||||||
|
return purge();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Settings ─────────────────────────────────────────────────────────────────
|
// ─── Settings ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function readSettings() {
|
function readSettings() {
|
||||||
|
|
@ -186,6 +201,8 @@ async function runAllCleanup() {
|
||||||
tasks.import_history = { pruned };
|
tasks.import_history = { pruned };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.soft_deleted_records = pruneSoftDeletedFinancialRecords(30);
|
||||||
|
|
||||||
const ran_at = new Date().toISOString();
|
const ran_at = new Date().toISOString();
|
||||||
setSetting('cleanup_last_run_at', ran_at);
|
setSetting('cleanup_last_run_at', ran_at);
|
||||||
setSetting('cleanup_last_result', JSON.stringify(tasks));
|
setSetting('cleanup_last_result', JSON.stringify(tasks));
|
||||||
|
|
@ -218,4 +235,5 @@ module.exports = {
|
||||||
pruneStaleExportFiles,
|
pruneStaleExportFiles,
|
||||||
pruneOrphanedBackupPartials,
|
pruneOrphanedBackupPartials,
|
||||||
pruneImportHistory,
|
pruneImportHistory,
|
||||||
|
pruneSoftDeletedFinancialRecords,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ async function runNotifications() {
|
||||||
// Fetch all active bills. In global-notification mode, the single global recipient
|
// Fetch all active bills. In global-notification mode, the single global recipient
|
||||||
// legitimately receives every bill. In per-user mode, each recipient must only
|
// legitimately receives every bill. In per-user mode, each recipient must only
|
||||||
// see their own bills — the ownership filter is applied in the loop below.
|
// see their own bills — the ownership filter is applied in the loop below.
|
||||||
const bills = db.prepare('SELECT * FROM bills WHERE active = 1').all();
|
const bills = db.prepare('SELECT * FROM bills WHERE active = 1 AND deleted_at IS NULL').all();
|
||||||
const allowUserConfig = getSetting('notify_allow_user_config') === 'true';
|
const allowUserConfig = getSetting('notify_allow_user_config') === 'true';
|
||||||
const globalRecipient = getSetting('notify_global_recipient');
|
const globalRecipient = getSetting('notify_global_recipient');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue