feat: spending income section, rules manager, error handling improvements
This commit is contained in:
parent
ac5d6c6625
commit
3f0078b930
|
|
@ -10,8 +10,12 @@
|
|||
|
||||
- **Login history for single-user mode** — In single-user mode the app bypasses the session system entirely, so `recordLogin` was never called and login history was always empty. Fixed by issuing a `bt_single_session` presence cookie (httpOnly, 30-day, same security flags as the regular session cookie) on the first request from each new browser. `recordLogin` fires once per new cookie via `setImmediate` so it never delays page load. Return visits reuse the cookie and don't create duplicate entries. The login-history route now uses `bt_single_session` when `req.singleUserMode` is set so the "This session" fingerprint comparison works correctly without a real session cookie. OIDC logins were also missing the `sessionId` parameter passed to `recordLogin`, so "This session" never matched for OIDC sessions; fixed by passing `session.sessionId` through the OIDC callback.
|
||||
|
||||
- **TOTP / Authenticator App 2FA for local login** — Migration v0.86 adds `totp_enabled`, `totp_secret` (encrypted), and `totp_recovery_codes` (encrypted) columns to the `users` table, and a `totp_challenges` table for short-lived (5-minute) challenge tokens. The new `totpService.js` handles secret generation, QR code rendering via `qrcode`, token verification via `otplib`, and recovery code lifecycle. Setup lives in the Profile page under a new "Two-Factor Authentication" section: scan a QR code with Google Authenticator, Authy, 1Password, Bitwarden, or any TOTP app; enter the 6-digit code to confirm; receive 8 one-time recovery codes (shown once, stored as SHA-256 hashes encrypted at rest). Once enabled, the login flow adds a second step after password verification — the server issues a challenge token instead of creating a session, the client shows a TOTP code input, and the session is only created after the code is verified via `POST /api/auth/totp/challenge`. Recovery codes can be used in place of the authenticator app. Disabling 2FA requires a valid TOTP code. OIDC and single-user mode are unaffected. `otplib` and `qrcode` are included in the Docker image.
|
||||
|
||||
- **No Login mode — admin UI redesign** — The `LoginModeCard` in the Admin page is now a clean radio-group selector with two first-class options: **Require Login** (multi-user, shows the OIDC/local-login settings card below) and **No Login — Single User** (hides the auth methods card, shows a user picker and an amber security warning). An inline confirmation dialog is shown before enabling No Login mode. The `AuthMethodsCard` is conditionally hidden when single-user mode is active since OIDC and local-login settings are irrelevant when there is no login screen. The backend lockout validation (which previously blocked saving when all login methods were disabled) now skips entirely when `auth_mode === 'single'`. The `LoginPage` no longer flashes the sign-in form in single-user mode — it renders a neutral loading state while the auth-mode check is in-flight and redirects before the form ever appears.
|
||||
|
||||
- **Spending page — bank transaction categorization and budgets** — Migration v0.87 adds a `spending_category_id` column to `transactions`, a `spending_category_rules` table (merchant → category auto-assignment rules), and a `spending_budgets` table (per-category monthly budgets). Eight default spending categories (Groceries, Dining, Fuel & Transport, Shopping, Entertainment, Health, Travel, Other) are seeded per user on first migration. A new `/spending` page appears in the sidebar (between Categories and Snowball) showing: a month navigator; a three-card overview strip (total spending, uncategorized, income received); a category breakdown list where each row shows amount, transaction count, a budget progress bar (red when over), and an inline budget editor; and a paginated transaction list with an inline category picker dropdown per row. Selecting a category in the breakdown filters the transaction list. Categorizing a transaction can optionally save a merchant rule — the same word-boundary matching used for bill rules — which immediately back-fills all existing unmatched transactions from that merchant and auto-categorizes new ones on every future sync. The bank sync worker now calls `applySpendingCategoryRules` after `applyMerchantRules` on every sync. The `api.js` helper layer gained a `patch` shorthand and `get` now accepts query-param objects via `queryString`.
|
||||
|
||||
- **`payments.js` SQL fragment renamed for clarity** — `const LIVE = 'deleted_at IS NULL'` was renamed to `const SQL_NOT_DELETED` and given a 4-line comment explaining why SQL fragment interpolation is safe here, why parameterisation is not applicable to SQL fragments (only values can be bound, not column conditions), and explicitly warning future developers not to replace the pattern with dynamic input.
|
||||
|
||||
- **Migration version sync assertion** — `_runMigrationVersions` module-level variable is now populated by `runMigrations()` before its loop runs. `reconcileLegacyMigrations()` — which runs after `runMigrations()` on legacy-DB upgrade paths — compares its own version array against the stored list and throws a descriptive error if any version appears in one array but not the other. Catches drift between the two migration arrays at startup rather than silently misconfiguring a legacy schema.
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export const api = {
|
|||
categorizeTransaction: (id, d) => patch(`/spending/transactions/${id}/category`, d),
|
||||
spendingBudgets: (p) => get('/spending/budgets', p),
|
||||
setSpendingBudget: (d) => put('/spending/budgets', d),
|
||||
spendingIncome: (p) => get('/spending/income', p),
|
||||
spendingCategoryRules: () => get('/spending/category-rules'),
|
||||
addSpendingRule: (d) => post('/spending/category-rules', d),
|
||||
deleteSpendingRule: (id) => del(`/spending/category-rules/${id}`),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ChevronLeft, ChevronRight, Tag, ReceiptText, TrendingDown, CircleDollarSign, Pencil, Check, X } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, ChevronDown, Tag, ReceiptText, TrendingDown, Pencil, Check, X, Trash2, BookmarkPlus, Settings2 } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
|
@ -73,12 +73,25 @@ function CategoryPicker({ categories, current, onSelect }) {
|
|||
|
||||
function TxRow({ tx, categories, onCategorize }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [rememberPrompt, setRememberPrompt] = useState(null); // { categoryId, categoryName }
|
||||
const dismissTimer = useRef(null);
|
||||
|
||||
const handleSelect = async (categoryId, saveRule) => {
|
||||
// Auto-dismiss the "remember" prompt after 7 seconds
|
||||
useEffect(() => {
|
||||
if (!rememberPrompt) return;
|
||||
dismissTimer.current = setTimeout(() => setRememberPrompt(null), 7000);
|
||||
return () => clearTimeout(dismissTimer.current);
|
||||
}, [rememberPrompt]);
|
||||
|
||||
const handleSelect = async (categoryId) => {
|
||||
setSaving(true);
|
||||
setRememberPrompt(null);
|
||||
try {
|
||||
await api.categorizeTransaction(tx.id, { category_id: categoryId, save_rule: saveRule });
|
||||
onCategorize(tx.id, categoryId, categories.find(c => c.id === categoryId)?.name ?? null);
|
||||
await api.categorizeTransaction(tx.id, { category_id: categoryId, save_rule: false });
|
||||
const catName = categories.find(c => c.id === categoryId)?.name ?? null;
|
||||
onCategorize(tx.id, categoryId, catName);
|
||||
// Offer to remember the merchant rule (only when assigning a real category)
|
||||
if (categoryId) setRememberPrompt({ categoryId, categoryName: catName });
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to categorize');
|
||||
} finally {
|
||||
|
|
@ -86,8 +99,21 @@ function TxRow({ tx, categories, onCategorize }) {
|
|||
}
|
||||
};
|
||||
|
||||
const saveRule = async () => {
|
||||
if (!rememberPrompt) return;
|
||||
clearTimeout(dismissTimer.current);
|
||||
setRememberPrompt(null);
|
||||
try {
|
||||
await api.categorizeTransaction(tx.id, { category_id: rememberPrompt.categoryId, save_rule: true });
|
||||
toast.success(`Rule saved — future ${tx.payee} transactions will be auto-categorized.`);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to save rule');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border/30 last:border-0 hover:bg-muted/20 transition-colors">
|
||||
<div className="border-b border-border/30 last:border-0">
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm truncate font-medium">{tx.payee}</p>
|
||||
<p className="text-xs text-muted-foreground">{tx.date}</p>
|
||||
|
|
@ -100,6 +126,24 @@ function TxRow({ tx, categories, onCategorize }) {
|
|||
: <CategoryPicker categories={categories} current={tx.spending_category_id} onSelect={handleSelect} />
|
||||
}
|
||||
</div>
|
||||
{rememberPrompt && (
|
||||
<div className="mx-4 mb-2 flex items-center gap-2 rounded-md border border-primary/20 bg-primary/5 px-3 py-1.5">
|
||||
<BookmarkPlus className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
<span className="text-xs text-muted-foreground flex-1">
|
||||
Always categorize <span className="font-medium text-foreground">{tx.payee}</span> as{' '}
|
||||
<span className="font-medium text-foreground">{rememberPrompt.categoryName}</span>?
|
||||
</span>
|
||||
<button type="button" onClick={saveRule}
|
||||
className="text-xs font-medium text-primary hover:text-primary/80 transition-colors">
|
||||
Save rule
|
||||
</button>
|
||||
<button type="button" onClick={() => { clearTimeout(dismissTimer.current); setRememberPrompt(null); }}
|
||||
className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -147,6 +191,197 @@ function BudgetEditor({ categoryId, year, month, initial, onSaved }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Income section ───────────────────────────────────────────────────────────
|
||||
|
||||
function IncomeSection({ year, month, totalIncome }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [rows, setRows] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async (p = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const d = await api.spendingIncome({ year, month, page: p });
|
||||
setRows(d.transactions || []);
|
||||
setTotal(d.total || 0);
|
||||
setPages(d.pages || 1);
|
||||
setPage(p);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to load income transactions');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [year, month]);
|
||||
|
||||
useEffect(() => { if (open) load(1); }, [open, load]);
|
||||
|
||||
if (!totalIncome) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-emerald-500/25 bg-card/80 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<TrendingDown className="h-4 w-4 text-emerald-500 rotate-180" />
|
||||
<span className="text-sm font-medium flex-1">Income & Deposits</span>
|
||||
<span className="text-sm font-mono font-semibold text-emerald-500">{fmt(totalIncome)}</span>
|
||||
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ml-2 ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-emerald-500/20">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">Loading…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">No income transactions found.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="divide-y divide-border/30 max-h-72 overflow-y-auto">
|
||||
{rows.map(tx => (
|
||||
<div key={tx.id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm truncate font-medium">{tx.payee}</p>
|
||||
<p className="text-xs text-muted-foreground">{tx.date}</p>
|
||||
</div>
|
||||
<span className="text-sm font-mono font-semibold text-emerald-500 shrink-0">
|
||||
+{fmt(tx.amount)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-2.5 border-t border-border/30">
|
||||
<Button variant="ghost" size="sm" disabled={page <= 1} onClick={() => load(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">{page} / {pages}</span>
|
||||
<Button variant="ghost" size="sm" disabled={page >= pages} onClick={() => load(page + 1)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<p className="px-4 py-2 text-[11px] text-muted-foreground border-t border-border/30">
|
||||
Positive unmatched transactions — deposits, refunds, transfers in. Bill-matched payments are excluded.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Rules manager ────────────────────────────────────────────────────────────
|
||||
|
||||
function RulesManager({ categories }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [rules, setRules] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newMerchant, setNewMerchant] = useState('');
|
||||
const [newCategory, setNewCategory] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setRules((await api.spendingCategoryRules()).rules || []); }
|
||||
catch { toast.error('Failed to load rules'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (open) load(); }, [open, load]);
|
||||
|
||||
const deleteRule = async (id) => {
|
||||
try {
|
||||
await api.deleteSpendingRule(id);
|
||||
setRules(prev => prev.filter(r => r.id !== id));
|
||||
} catch { toast.error('Failed to delete rule'); }
|
||||
};
|
||||
|
||||
const addRule = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newMerchant.trim() || !newCategory) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
await api.addSpendingRule({ merchant: newMerchant.trim(), category_id: parseInt(newCategory, 10) });
|
||||
setNewMerchant(''); setNewCategory('');
|
||||
await load();
|
||||
toast.success('Rule saved.');
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to save rule');
|
||||
} finally { setAdding(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium flex-1">Merchant Rules</span>
|
||||
{rules.length > 0 && !open && (
|
||||
<Badge variant="secondary" className="text-xs">{rules.length}</Badge>
|
||||
)}
|
||||
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-border/40">
|
||||
{/* Add new rule */}
|
||||
<form onSubmit={addRule} className="flex items-center gap-2 px-4 py-3 border-b border-border/30">
|
||||
<input
|
||||
value={newMerchant}
|
||||
onChange={e => setNewMerchant(e.target.value)}
|
||||
placeholder="Merchant name (e.g. walmart)"
|
||||
className="flex-1 min-w-0 rounded-md border border-border/60 bg-background px-2.5 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<select
|
||||
value={newCategory}
|
||||
onChange={e => setNewCategory(e.target.value)}
|
||||
className="rounded-md border border-border/60 bg-background px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">Category…</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<Button type="submit" size="sm" disabled={adding || !newMerchant.trim() || !newCategory}>
|
||||
{adding ? 'Saving…' : 'Add'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Existing rules */}
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">Loading…</p>
|
||||
) : rules.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
No rules saved yet. Categorize a transaction and click "Save rule" to create one.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border/30 max-h-64 overflow-y-auto">
|
||||
{rules.map(r => (
|
||||
<div key={r.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span className="font-mono text-xs text-muted-foreground flex-1 truncate">{r.merchant}</span>
|
||||
<span className="text-xs shrink-0">→</span>
|
||||
<span className="text-xs font-medium shrink-0">{r.category_name}</span>
|
||||
<button type="button" onClick={() => deleteRule(r.id)}
|
||||
className="ml-1 text-muted-foreground hover:text-destructive transition-colors shrink-0">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SpendingPage() {
|
||||
|
|
@ -169,7 +404,9 @@ export default function SpendingPage() {
|
|||
try {
|
||||
const d = await api.categories();
|
||||
setCategories((d.categories || d || []).filter(c => !c.deleted_at));
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to load categories');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
|
|
@ -417,6 +654,12 @@ export default function SpendingPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Income & deposits */}
|
||||
<IncomeSection year={year} month={month} totalIncome={summary?.income} />
|
||||
|
||||
{/* Merchant rules manager */}
|
||||
<RulesManager categories={categories} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const {
|
|||
getSpendingSummary, getSpendingTransactions, categorizeTransaction,
|
||||
getSpendingBudgets, setSpendingBudget,
|
||||
getSpendingCategoryRules, addSpendingCategoryRule, deleteSpendingCategoryRule,
|
||||
getIncomeTransactions,
|
||||
} = require('../services/spendingService');
|
||||
|
||||
function parseYM(source) {
|
||||
|
|
@ -73,7 +74,12 @@ router.patch('/transactions/:id/category', (req, res) => {
|
|||
router.get('/budgets', (req, res) => {
|
||||
const ym = parseYM(req.query);
|
||||
if (ym.error) return res.status(400).json({ error: ym.error });
|
||||
try {
|
||||
res.json({ budgets: getSpendingBudgets(getDb(), req.user.id, ym.year, ym.month) });
|
||||
} catch (err) {
|
||||
console.error('[spending/budgets GET]', err.message);
|
||||
res.status(500).json({ error: 'Failed to load budgets' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/spending/budgets — { category_id, year, month, amount }
|
||||
|
|
@ -87,13 +93,19 @@ router.put('/budgets', (req, res) => {
|
|||
setSpendingBudget(getDb(), req.user.id, parseInt(category_id, 10), ym.year, ym.month, amount ?? null);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[spending/budgets PUT]', err.message);
|
||||
res.status(500).json({ error: 'Failed to save budget' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/spending/category-rules
|
||||
router.get('/category-rules', (req, res) => {
|
||||
try {
|
||||
res.json({ rules: getSpendingCategoryRules(getDb(), req.user.id) });
|
||||
} catch (err) {
|
||||
console.error('[spending/category-rules GET]', err.message);
|
||||
res.status(500).json({ error: 'Failed to load rules' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/spending/category-rules — { category_id, merchant }
|
||||
|
|
@ -104,14 +116,35 @@ router.post('/category-rules', (req, res) => {
|
|||
addSpendingCategoryRule(getDb(), req.user.id, parseInt(category_id, 10), merchant);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[spending/category-rules POST]', err.message);
|
||||
res.status(err.status || 500).json({ error: err.message || 'Failed to save rule' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/spending/category-rules/:id
|
||||
router.delete('/category-rules/:id', (req, res) => {
|
||||
try {
|
||||
deleteSpendingCategoryRule(getDb(), req.user.id, parseInt(req.params.id, 10));
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[spending/category-rules DELETE]', err.message);
|
||||
res.status(500).json({ error: 'Failed to delete rule' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/spending/income?year=&month=&page=
|
||||
router.get('/income', (req, res) => {
|
||||
const ym = parseYM(req.query);
|
||||
if (ym.error) return res.status(400).json({ error: ym.error });
|
||||
try {
|
||||
res.json(getIncomeTransactions(getDb(), req.user.id, ym.year, ym.month, {
|
||||
page: parseInt(req.query.page || '1', 10),
|
||||
limit: Math.min(parseInt(req.query.limit || '50', 10), 200),
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('[spending/income]', err.message);
|
||||
res.status(500).json({ error: 'Failed to load income transactions' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -264,6 +264,45 @@ function deleteSpendingCategoryRule(db, userId, ruleId) {
|
|||
db.prepare('DELETE FROM spending_category_rules WHERE id=? AND user_id=?').run(ruleId, userId);
|
||||
}
|
||||
|
||||
// ── Income ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function getIncomeTransactions(db, userId, year, month, { page = 1, limit = 50 } = {}) {
|
||||
const { start, end } = monthRange(year, month);
|
||||
const offset = (Math.max(1, page) - 1) * limit;
|
||||
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, amount, payee, description, memo, posted_date, transacted_at
|
||||
FROM transactions
|
||||
WHERE user_id = ? AND amount > 0 AND ignored = 0 AND match_status != 'matched'
|
||||
AND (posted_date BETWEEN ? AND ? OR (posted_date IS NULL AND transacted_at BETWEEN ? AND ?))
|
||||
ORDER BY COALESCE(posted_date, DATE(transacted_at)) DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(userId, start, end, start + 'T00:00:00', end + 'T23:59:59', limit, offset);
|
||||
|
||||
const total = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM transactions
|
||||
WHERE user_id = ? AND amount > 0 AND ignored = 0 AND match_status != 'matched'
|
||||
AND (posted_date BETWEEN ? AND ? OR (posted_date IS NULL AND transacted_at BETWEEN ? AND ?))
|
||||
`).get(userId, start, end, start + 'T00:00:00', end + 'T23:59:59').n;
|
||||
|
||||
return {
|
||||
transactions: rows.map(r => ({
|
||||
id: r.id,
|
||||
amount: Math.abs(Number(r.amount)) / 100,
|
||||
payee: r.payee || r.description || r.memo || '(Unknown)',
|
||||
date: r.posted_date || (r.transacted_at ? String(r.transacted_at).slice(0, 10) : null),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pages: Math.ceil(total / limit),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[getIncomeTransactions]', err.message);
|
||||
return { transactions: [], total: 0, page: 1, pages: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSpendingSummary,
|
||||
getSpendingTransactions,
|
||||
|
|
@ -274,4 +313,5 @@ module.exports = {
|
|||
getSpendingCategoryRules,
|
||||
addSpendingCategoryRule,
|
||||
deleteSpendingCategoryRule,
|
||||
getIncomeTransactions,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue