diff --git a/HISTORY.md b/HISTORY.md index 6a97745..305b1a6 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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. diff --git a/client/api.js b/client/api.js index b42fe51..ef9eb51 100644 --- a/client/api.js +++ b/client/api.js @@ -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}`), diff --git a/client/pages/SpendingPage.jsx b/client/pages/SpendingPage.jsx index 75d0a51..71f5bc2 100644 --- a/client/pages/SpendingPage.jsx +++ b/client/pages/SpendingPage.jsx @@ -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'; @@ -72,13 +72,26 @@ function CategoryPicker({ categories, current, onSelect }) { // ── Transaction row ────────────────────────────────────────────────────────── function TxRow({ tx, categories, onCategorize }) { - const [saving, setSaving] = useState(false); + 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,19 +99,50 @@ 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 ( -
-
-

{tx.payee}

-

{tx.date}

+
+
+
+

{tx.payee}

+

{tx.date}

+
+ + -{fmt(tx.amount)} + + {saving + ? Saving… + : + }
- - -{fmt(tx.amount)} - - {saving - ? Saving… - : - } + {rememberPrompt && ( +
+ + + Always categorize {tx.payee} as{' '} + {rememberPrompt.categoryName}? + + + +
+ )}
); } @@ -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 ( +
+ + + {open && ( +
+ {loading ? ( +

Loading…

+ ) : rows.length === 0 ? ( +

No income transactions found.

+ ) : ( + <> +
+ {rows.map(tx => ( +
+
+

{tx.payee}

+

{tx.date}

+
+ + +{fmt(tx.amount)} + +
+ ))} +
+ {pages > 1 && ( +
+ + {page} / {pages} + +
+ )} + + )} +

+ Positive unmatched transactions — deposits, refunds, transfers in. Bill-matched payments are excluded. +

+
+ )} +
+ ); +} + +// ── 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 ( +
+ + + {open && ( +
+ {/* Add new rule */} +
+ 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" + /> + + +
+ + {/* Existing rules */} + {loading ? ( +

Loading…

+ ) : rules.length === 0 ? ( +

+ No rules saved yet. Categorize a transaction and click "Save rule" to create one. +

+ ) : ( +
+ {rules.map(r => ( +
+ {r.merchant} + + {r.category_name} + +
+ ))} +
+ )} +
+ )} +
+ ); +} + // ── 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() { )}
+ {/* Income & deposits */} + + + {/* Merchant rules manager */} + +
); diff --git a/routes/spending.js b/routes/spending.js index 8196c41..d0feca5 100644 --- a/routes/spending.js +++ b/routes/spending.js @@ -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 }); - res.json({ budgets: getSpendingBudgets(getDb(), req.user.id, ym.year, ym.month) }); + 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) => { - res.json({ rules: getSpendingCategoryRules(getDb(), req.user.id) }); + 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) => { - deleteSpendingCategoryRule(getDb(), req.user.id, parseInt(req.params.id, 10)); - res.json({ ok: true }); + 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; diff --git a/services/spendingService.js b/services/spendingService.js index 4fec080..3c66122 100644 --- a/services/spendingService.js +++ b/services/spendingService.js @@ -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, };