feat: copy last month budgets, monthly income section on summary page

This commit is contained in:
null 2026-06-04 21:57:42 -05:00
parent 3a19303d4d
commit 2c9cc37593
5 changed files with 193 additions and 6 deletions

View File

@ -16,6 +16,24 @@
- **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`.
- **Spending page: merchant rules manager and "remember merchant" prompt** — The spending category picker (shown on each transaction row) now prompts "Always categorize [payee] as [category]? Save rule / Dismiss" immediately after a category is chosen. The prompt auto-dismisses after 7 seconds. Saving a rule back-fills all existing unmatched transactions from that merchant, marks the category as spending-enabled, and auto-categorizes all future syncs. A collapsible "Merchant Rules" section at the bottom of the spending page lists all saved rules grouped by merchant name with delete buttons and an "Add rule" form. Error handling: all load/delete/add operations have try/catch with toast feedback.
- **Spending categories separated from bill categories** — Migration v0.88 adds a `spending_enabled INTEGER DEFAULT 0` column to `categories`. Only categories with `spending_enabled = 1` appear in the spending page category picker and breakdown. Migration v0.89 seeds the eight default spending categories (Groceries, Dining, Fuel & Transport, Shopping, Entertainment, Health, Travel, Other) for any user who already had their own bill categories before v0.87 ran (v0.87 only seeded for users with no categories). On the Categories page each category now has a shopping-cart icon button — green means it shows in spending, grey means it doesn't; hover tooltip explains the toggle. Auto-enables a category when a spending merchant rule is saved against it. Empty state on the spending page links to the Categories page when no spending-enabled categories exist.
- **SimpleFIN matching pipeline fixes** — Migration v0.90 bundles four corrections to the matching pipeline: (1) `normalizeMerchant()` now strips `&` without adding a space — "AT&T" previously normalized to `"at t"` (two words) while banks report "ATT" → `"att"` (one word), so AT&T bills never auto-matched; (2) `matchSuggestionService.addNameScore()` replaced bidirectional `.includes()` with word-boundary regex matching (`(^|\s)TERM(\s|$)`) to align with the fix already applied to `billMerchantRuleService` — the score pipeline fed `autoMatchForUser()` and could silently create wrong payments at score ≥ 80; (3) merchant rules are now sorted by length descending before matching so the longer (more specific) rule always wins when multiple rules could match a transaction; (4) `lateAttributionCandidate()` hardcoded a 5-day window ignoring the user's `bank_late_attribution_days` setting for days 6+. Existing stored merchant rules in `bill_merchant_rules` and `spending_category_rules` are re-normalized using the updated function. `match_suggestion_rejections` gains a `created_at` column and rejections older than 90 days are now filtered out and pruned in the daily cleanup worker. `GET /api/bills/merchant-rules` endpoint added — returns all bill merchant rules across all bills grouped by bill name. `BillRulesManager` component added to the DataPage "Sync & Match" tab showing all rules with merchant name, per-rule auto-late toggle, and delete button.
- **Database performance: composite indexes** — Migration v0.91 adds four indexes that were missing on frequently queried columns: `idx_categories_user_deleted ON categories(user_id, deleted_at)`, `idx_bills_user_deleted ON bills(user_id, deleted_at)`, `idx_bills_user_active ON bills(user_id, active, deleted_at)`, and `idx_payments_bill_deleted ON payments(bill_id, deleted_at)`. Without these, every category listing, bill listing, and payment query was a full table scan.
- **Worker health improvements** — (1) `workers/dailyWorker.js` N+1 query fixed: the autopay-marking loop previously ran one `SELECT payments WHERE bill_id = ?` per bill. Replaced with a single batch query fetching all payments for active bills within a 90-day window, then grouped in memory and filtered per bill's cycle range. (2) `statusRuntime.js` now persists worker state to the settings table (`_worker_last_run_at`, `_worker_next_run_at`, `_worker_started_at`, `_worker_last_error`) and seeds in-memory state from those keys on startup. The admin Status page now correctly shows the last run time and next scheduled run after a container restart instead of showing "never / unknown". (3) `notificationService.runNotifications()` N+1 fixed: replaced per-bill payment query and per-bill-per-recipient `hasNotification()` calls with two batch queries (one for all payments, one for all notifications sent today), both checked in-memory via a Set. Reduces notification run from O(N + N×M) to O(3) DB calls. (4) The backup status route was reading `backup_enabled` (a legacy key, always null) instead of `backup_schedule_enabled` (what the admin UI writes), causing scheduled backups to always show as "Disabled" even when running correctly. Fixed by reading the correct key. (5) Status page timezone bug fixed: SQLite `datetime('now')` returns `"YYYY-MM-DD HH:MM:SS"` (no timezone marker); JS Date parses this as local time while `next_run_at` is a proper ISO-Z string parsed as UTC — mixing baselines made "Next Check" appear before "Last Sync". A `toIso()` helper appends `Z` to all SQLite-format timestamps before they leave the server.
- **SpendingPage double-fetch fix** — Two `useEffect` hooks both depended on `useCallback` function references that were recreated when year/month changed, causing back-to-back duplicate API calls on month navigation. Replaced with a single effect depending on primitive values (`year`, `month`, `activeCat`). Added a `cancelled` flag so in-flight requests are discarded when the month changes before they resolve.
- **Income breakdown modal on TrackerPage** — When SimpleFIN bank tracking is enabled, the green bank balance card on the main Tracker page is now a clickable button. Clicking it opens an "Income Breakdown" modal showing: how the effective starting balance was calculated (raw bank balance → pending deduction → effective balance); all positive unmatched SimpleFIN transactions for the month (paychecks, deposits, etc.) with date, payee, and amount; an eye-off icon to exclude a transaction (marks it ignored — useful for internal transfers); a "Show excluded (N)" toggle to view and restore previously excluded transactions. Error handling: load failures show an error state with a Retry button instead of the misleading "No transactions found" empty state; ignore/restore actions fail cleanly without corrupting the list.
- **Monthly income tracking UI on Summary page** — The `monthly_income` table and `PUT /api/summary/income` endpoint have existed since early development but were never connected to a UI. A new "Monthly Income" section now appears on the Summary page above Expenses. It shows the current income label and amount in green, and when both income and total expenses are set it shows "After expenses" remainder right-aligned in the same card. An Edit button reveals an inline form with a label field and amount field. Saves via the existing endpoint. Validates non-negative amount, toasts on error.
- **Copy last month's budgets** — A "Copy last month" button in the spending page category breakdown header copies all spending budget entries from the prior month into the current month in a single `POST /api/spending/budgets/copy` request. Non-destructive: existing budgets for the current month are overwritten (they're already visible so the user knows what they're replacing). Updates the UI immediately from the server response. Shows count toast ("3 budgets copied") or info toast if the previous month had no budgets.
- **`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.

View File

@ -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),
copySpendingBudgets: (d) => post('/spending/budgets/copy', d),
spendingIncome: (p) => get('/spending/income', p),
spendingCategoryRules: () => get('/spending/category-rules'),
addSpendingRule: (d) => post('/spending/category-rules', d),

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { toast } from 'sonner';
import { ChevronLeft, ChevronRight, ChevronDown, Tag, ReceiptText, TrendingDown, Pencil, Check, X, Trash2, BookmarkPlus, Settings2 } from 'lucide-react';
import { ChevronLeft, ChevronRight, ChevronDown, Tag, ReceiptText, TrendingDown, Pencil, Check, X, Trash2, BookmarkPlus, Settings2, Copy, DollarSign } from 'lucide-react';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@ -403,6 +403,7 @@ export default function SpendingPage() {
const [loading, setLoading] = useState(true);
const [txLoading, setTxLoading] = useState(false);
const [budgets, setBudgets] = useState({}); // categoryId amount
const [copying, setCopying] = useState(false);
// loadCategories is stable categories don't vary by month
const loadCategories = useCallback(async () => {
@ -463,6 +464,34 @@ export default function SpendingPage() {
return () => { cancelled = true; };
}, [year, month, activeCat]); // eslint-disable-line react-hooks/exhaustive-deps
const handleCopyBudgets = async () => {
setCopying(true);
try {
const d = await api.copySpendingBudgets({ year, month });
if (d.copied === 0) {
toast.info('No budgets found in the previous month to copy.');
} else {
// Update local budget state from server response
const bmap = {};
(d.budgets || []).forEach(b => { bmap[b.category_id] = b.amount; });
setBudgets(bmap);
setSummary(prev => prev ? {
...prev,
by_category: prev.by_category.map(c =>
c.category_id && bmap[c.category_id] != null
? { ...c, budget: bmap[c.category_id] }
: c
),
} : prev);
toast.success(`${d.copied} budget${d.copied !== 1 ? 's' : ''} copied from last month.`);
}
} catch (err) {
toast.error(err.message || 'Failed to copy budgets');
} finally {
setCopying(false);
}
};
const navMonth = (dir) => {
let m = month + dir, y = year;
if (m > 12) { m = 1; y++; }
@ -565,12 +594,24 @@ export default function SpendingPage() {
<div className="px-4 py-3 border-b border-border/40 flex items-center gap-2">
<TrendingDown className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">By Category</span>
<div className="ml-auto flex items-center gap-2">
{activeCat !== undefined && (
<button type="button" onClick={() => setActiveCat(undefined)}
className="ml-auto text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline">
className="text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline">
Show all
</button>
)}
<button
type="button"
onClick={handleCopyBudgets}
disabled={copying}
title="Copy budgets from last month"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40"
>
<Copy className="h-3.5 w-3.5" />
{copying ? 'Copying…' : 'Copy last month'}
</button>
</div>
</div>
{catEntries.length === 0 && !uncatEntry ? (

View File

@ -208,6 +208,9 @@ export default function SummaryPage() {
const [startingFifteenth, setStartingFifteenth] = useState('0');
const [startingOther, setStartingOther] = useState('0');
const [editingStarting, setEditingStarting] = useState(false);
const [incomeAmount, setIncomeAmount] = useState('0');
const [incomeLabel, setIncomeLabel] = useState('Salary');
const [editingIncome, setEditingIncome] = useState(false);
const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const [movingBillId, setMovingBillId] = useState(null);
@ -222,6 +225,9 @@ export default function SummaryPage() {
setStartingFifteenth(String(result.starting_amounts?.fifteenth_amount ?? 0));
setStartingOther(String(result.starting_amounts?.other_amount ?? 0));
setEditingStarting(false);
setIncomeAmount(String(result.income?.amount ?? 0));
setIncomeLabel(result.income?.label || 'Salary');
setEditingIncome(false);
setDraggingId(null);
setDropTargetId(null);
setMovingBillId(null);
@ -274,6 +280,26 @@ export default function SummaryPage() {
}
}
async function saveIncome() {
const amount = Number(incomeAmount);
if (!Number.isFinite(amount) || amount < 0) {
toast.error('Enter a valid income amount.');
return;
}
const label = incomeLabel.trim() || 'Salary';
setSaving(true);
try {
await api.saveSummaryIncome({ year: selected.year, month: selected.month, amount, label });
toast.success('Income saved.');
setEditingIncome(false);
await loadSummary();
} catch (err) {
toast.error(err.message || 'Income could not be saved.');
} finally {
setSaving(false);
}
}
function moveMonth(delta) {
setSelected(current => shiftMonth(current.year, current.month, delta));
}
@ -507,6 +533,68 @@ export default function SummaryPage() {
)}
</section>
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xs font-semibold uppercase tracking-normal text-muted-foreground">Monthly Income</h2>
<Button
type="button"
variant="ghost"
size="sm"
className="summary-edit-actions h-7 px-2"
onClick={() => setEditingIncome(v => !v)}
>
<Edit3 className="h-3.5 w-3.5" />
{editingIncome ? 'Close' : 'Edit'}
</Button>
</div>
<div className="flex items-center justify-between rounded-2xl bg-muted/45 px-4 py-3">
<div>
<div className="text-xs font-medium text-muted-foreground">{data?.income?.label || 'Salary'}</div>
<div className="tracker-number mt-1 text-2xl font-bold text-emerald-600 dark:text-emerald-400">
{fmt(data?.income?.amount ?? 0)}
</div>
</div>
{Number(data?.income?.amount) > 0 && Number(summary?.expense_total) > 0 && (
<div className="text-right">
<div className="text-xs text-muted-foreground">After expenses</div>
<div className={cn('tracker-number mt-1 text-lg font-bold', moneyClass(Number(data.income.amount) - Number(summary.expense_total)))}>
{fmt(Number(data.income.amount) - Number(summary.expense_total))}
</div>
</div>
)}
</div>
{editingIncome && (
<div className="grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[1fr_2fr_auto] md:items-end">
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Label</span>
<Input
value={incomeLabel}
onChange={e => setIncomeLabel(e.target.value)}
placeholder="Salary"
maxLength={80}
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Amount</span>
<Input
type="number"
min="0"
step="0.01"
value={incomeAmount}
onChange={e => setIncomeAmount(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') saveIncome(); }}
/>
</label>
<Button onClick={saveIncome} disabled={saving} className="summary-edit-actions w-full md:w-auto">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save
</Button>
</div>
)}
</section>
<section className="space-y-3">
<div className="flex items-end justify-between gap-3">
<div>

View File

@ -82,6 +82,45 @@ router.get('/budgets', (req, res) => {
}
});
// POST /api/spending/budgets/copy — copy all budgets from previous month into target month
router.post('/budgets/copy', (req, res) => {
const ym = parseYM(req.body || {});
if (ym.error) return res.status(400).json({ error: ym.error });
// Previous month
let prevYear = ym.year, prevMonth = ym.month - 1;
if (prevMonth < 1) { prevMonth = 12; prevYear--; }
try {
const db = getDb();
const prev = db.prepare(`
SELECT category_id, amount FROM spending_budgets
WHERE user_id = ? AND year = ? AND month = ?
`).all(req.user.id, prevYear, prevMonth);
if (prev.length === 0) {
return res.json({ copied: 0, budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month) });
}
const upsert = db.prepare(`
INSERT INTO spending_budgets (user_id, category_id, year, month, amount, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, category_id, year, month) DO UPDATE SET
amount = excluded.amount,
updated_at = datetime('now')
`);
db.transaction(() => {
for (const row of prev) upsert.run(req.user.id, row.category_id, ym.year, ym.month, row.amount);
})();
res.json({ copied: prev.length, budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month) });
} catch (err) {
console.error('[spending/budgets/copy]', err.message);
res.status(500).json({ error: 'Failed to copy budgets' });
}
});
// PUT /api/spending/budgets — { category_id, year, month, amount }
router.put('/budgets', (req, res) => {
const { category_id, year, month, amount } = req.body || {};