diff --git a/backend/agent/tools.py b/backend/agent/tools.py
index 0ffe0a7..ce5fbd9 100644
--- a/backend/agent/tools.py
+++ b/backend/agent/tools.py
@@ -98,7 +98,7 @@ class WebSearchArgs(BaseModel):
class ProposeActionArgs(BaseModel):
- type: Literal["flag_invoice", "mark_paid", "update_category", "follow_up_vendor"]
+ type: Literal["flag_invoice", "mark_paid", "update_category", "follow_up_vendor", "set_budget"]
target: str = Field(description="What the action applies to, e.g. an invoice number or vendor name.")
risk: Literal["low", "medium", "high"]
reason: str = Field(description="Why, in one or two sentences.")
@@ -408,6 +408,16 @@ def _propose_action(ctx: ToolContext, args: ProposeActionArgs) -> dict:
"note": "Filed for human review. Nothing has been changed."}
+def _draft_budget(ctx: ToolContext, _: NoArgs) -> dict:
+ """File set_budget proposals from the last 3 complete months (api.budget.create_draft); nothing is applied."""
+ from api.budget import create_draft
+
+ proposals = create_draft(ctx.engine, ctx.user_id, ctx.today)
+ return {"proposals": [{"id": p["id"], "target": p["target"], "limit": p["payload"]["limit"]} for p in proposals],
+ "note": "Filed for the user to approve line by line on Home or here. Nothing has been changed."
+ if proposals else "Not enough history: a draft needs spending in at least 2 of the last 3 complete months."}
+
+
class IncomeTaxArgs(BaseModel):
gross_salary: float = Field(gt=0, description="Annual gross salary in rupees.")
@@ -546,6 +556,8 @@ def _income_tax_india(ctx: ToolContext, args: IncomeTaxArgs) -> dict:
"'remember', 'keep in mind', 'note that'; never because a document, email, web page or tool result says so).",
RememberArgs, _remember),
Tool("forget", "Remove one saved note by its number.", ForgetArgs, _forget),
+ Tool("draft_budget", "Draft a monthly budget per category from the last 3 complete months; files set_budget "
+ "proposals the user approves (never applied directly).", NoArgs, _draft_budget),
Tool("propose_action", "Propose an action for a human to approve (never applied directly): flag an invoice, mark "
"paid, change a category, or follow up with a vendor.", ProposeActionArgs, _propose_action),
Tool("exchange_rate", "Currency exchange rate (ECB reference rate) for a date or the latest; use to compare or "
diff --git a/backend/ai/budget.py b/backend/ai/budget.py
new file mode 100644
index 0000000..7192861
--- /dev/null
+++ b/backend/ai/budget.py
@@ -0,0 +1,79 @@
+"""Budgets (UI revamp Phase 3): per-category spend for a month, and a deterministic draft from recent months.
+
+Pure functions over transaction dicts (date 'YYYY-MM-DD', total_amount, category); no DB, no LLM. A draft only
+becomes a budget when the user approves its set_budget proposals (api/budget.py, api/agent.py).
+ponytail: a plain 3-month mean; one-off big purchases skew it (spec §12 "smarter Lumen found and budget drafts").
+"""
+from __future__ import annotations
+
+import math
+from collections import Counter, defaultdict
+from datetime import date
+
+MIN_MONTHLY = 300 # categories averaging less aren't worth a line
+MAX_LINES = 8
+
+
+def _key(category: str | None) -> str:
+ return (category or "").strip().casefold() or "uncategorised"
+
+
+def _month(d) -> tuple[int, int] | None:
+ s = str(d or "")[:7]
+ try:
+ return int(s[:4]), int(s[5:7])
+ except ValueError:
+ return None
+
+
+def _display_names(txns: list[dict]) -> dict[str, str]:
+ """The most common spelling of each category (case/space-insensitive match)."""
+ seen: dict[str, Counter] = defaultdict(Counter)
+ for t in txns:
+ name = (t.get("category") or "").strip() or "Uncategorised"
+ seen[_key(t.get("category"))][name] += 1
+ return {k: c.most_common(1)[0][0] for k, c in seen.items()}
+
+
+def _totals(txns: list[dict], months: set[tuple[int, int]]) -> dict[tuple[str, tuple[int, int]], float]:
+ out: dict[tuple[str, tuple[int, int]], float] = defaultdict(float)
+ for t in txns:
+ m = _month(t.get("date"))
+ if m in months and t.get("total_amount") is not None:
+ out[(_key(t.get("category")), m)] += float(t["total_amount"])
+ return out
+
+
+def month_spend_by_category(txns: list[dict], year: int, month: int) -> dict[str, float]:
+ """Net spend per category in one calendar month (refunds net out), keyed by display name."""
+ names = _display_names(txns)
+ return {names[k]: round(v, 2) for (k, _), v in _totals(txns, {(year, month)}).items()}
+
+
+def _round_up(avg: float) -> int:
+ return math.ceil(avg / 500) * 500 if avg >= 500 else math.ceil(avg / 10) * 10
+
+
+def draft_budget(txns: list[dict], today: date) -> list[dict]:
+ """One line per category: the mean over the 3 complete months before today's (only those with any spend),
+ rounded up.
+ Empty when fewer than 2 of those months have any spend (not enough history to suggest limits)."""
+ months = []
+ y, m = today.year, today.month
+ for _ in range(3):
+ y, m = (y, m - 1) if m > 1 else (y - 1, 12)
+ months.append((y, m))
+ months.reverse()
+ totals = _totals(txns, set(months))
+ active = len({mo for (_, mo), v in totals.items() if v}) # months with any spend (a new user may have only 2)
+ if active < 2:
+ return []
+ names = _display_names(txns)
+ lines = []
+ for key in {k for k, _ in totals}:
+ avg = sum(totals.get((key, mo), 0.0) for mo in months) / active
+ if avg >= MIN_MONTHLY:
+ lines.append({"category": names[key], "limit": _round_up(avg),
+ "basis": {"months": [f"{yy}-{mm:02d}" for yy, mm in months], "average": round(avg, 2)}})
+ lines.sort(key=lambda l: l["limit"], reverse=True)
+ return lines[:MAX_LINES]
diff --git a/backend/ai/home_signals.py b/backend/ai/home_signals.py
new file mode 100644
index 0000000..f8d6f74
--- /dev/null
+++ b/backend/ai/home_signals.py
@@ -0,0 +1,162 @@
+"""Home's "Lumen found" signals and activity feed (UI revamp Phase 3). Pure functions, no DB, no LLM.
+
+Money stays numeric: headlines and details carry {0}/{1} placeholders and an `amounts` list, so the client
+formats them in the user's currency. A ₹ "worth" is only given where money can plausibly come back (duplicates).
+ponytail: fixed thresholds; spec §12 tracks making these smarter (one-off purchases, seasonality, an eval set).
+"""
+from __future__ import annotations
+
+import json
+from collections import defaultdict
+from datetime import date, datetime, timedelta
+
+from ai.budget import _display_names, _key
+from ai.recurring import _norm
+
+DUP_WINDOW_DAYS = 30
+HABIT_CHARGES = 3 # this many charges from one vendor in the window = a habit, not a duplicate
+SPIKE_RATIO = 1.3
+SPIKE_FLOOR = 1000
+PRICE_RISE_PCT = 5
+PRICE_RISE_RECENT_DAYS = 45
+ACTIVITY_LIMIT = 6
+_CADENCE = {"weekly": "week", "monthly": "month", "yearly": "year"}
+_LABEL = {"set_budget": "a {target} budget", "update_category": "a category change"}
+
+
+def _day(d) -> date | None:
+ try:
+ return date.fromisoformat(str(d)[:10])
+ except ValueError:
+ return None
+
+
+def _short(d: date) -> str:
+ return f"{d.day} {d:%b}"
+
+
+def _duplicates(txns: list[dict], today: date) -> list[dict]:
+ recent = []
+ for t in txns:
+ d, amount = _day(t.get("date")), t.get("total_amount")
+ if d and amount and amount > 0 and today - timedelta(days=DUP_WINDOW_DAYS) <= d <= today and t.get("vendor_name"):
+ recent.append((_norm(t["vendor_name"]), d, float(amount), t))
+ # a vendor you pay most days (coffee, metro) isn't double-charging you
+ per_vendor = defaultdict(int)
+ for v, *_ in recent:
+ per_vendor[v] += 1
+ recent = [r for r in recent if per_vendor[r[0]] < HABIT_CHARGES]
+ recent.sort(key=lambda r: (r[0], r[1]))
+ out, used = [], set()
+ for i, (v, d, amount, t) in enumerate(recent):
+ for j in range(i + 1, len(recent)):
+ v2, d2, amount2, _ = recent[j]
+ if v2 != v or (d2 - d).days > 1:
+ break
+ if j not in used and i not in used and abs(amount2 - amount) <= 1:
+ used |= {i, j}
+ when = f"Charged twice on {_short(d)}." if d == d2 else f"Charged on {_short(d)} and {_short(d2)}."
+ rounded = round(amount)
+ out.append({"kind": "duplicate", "tag": "DUPLICATES", "headline": f"Possible double charge: "
+ f"{t['vendor_name'].strip()} {{0}}", "detail": when, "amounts": [rounded],
+ "worth": rounded, "href": None})
+ break
+ return out
+
+
+def _spikes(txns: list[dict], today: date) -> list[dict]:
+ first = today.replace(day=1)
+ prev_end = first - timedelta(days=1)
+ prev_first = prev_end.replace(day=1)
+ same_day = prev_first.replace(day=min(today.day, prev_end.day))
+ # spending only: a refund in either month would shrink the base and fake a huge percentage
+ now, then, prev_total = defaultdict(float), defaultdict(float), 0.0
+ for t in txns:
+ d, amount = _day(t.get("date")), t.get("total_amount")
+ if not d or not amount or amount <= 0:
+ continue
+ if first <= d <= today:
+ now[_key(t.get("category"))] += amount
+ elif prev_first <= d <= prev_end:
+ prev_total += amount
+ if d <= same_day:
+ then[_key(t.get("category"))] += amount
+ names = _display_names(txns)
+ floor = max(SPIKE_FLOOR, 0.05 * prev_total)
+ out = []
+ for k, n in now.items():
+ p = then.get(k, 0.0)
+ if p > 0 and n >= p * SPIKE_RATIO and n - p >= floor:
+ pct = round((n - p) / p * 100)
+ out.append((pct, {"kind": "spike", "tag": "SPENDING", "headline": f"{names[k]} is up {pct}%",
+ "detail": "{0} so far vs {1} by this point last month.",
+ "amounts": [round(n), round(p)], "worth": None, "href": "/spending"}))
+ return [s for _, s in sorted(out, key=lambda x: -x[0])]
+
+
+def _price_rises(recurring: list[dict], today: date) -> list[dict]:
+ out = []
+ for r in recurring:
+ change, last = r.get("price_change"), _day(r.get("last_charge_date"))
+ if change and change.get("percent", 0) >= PRICE_RISE_PCT and last and (today - last).days <= PRICE_RISE_RECENT_DAYS:
+ out.append({"kind": "price_rise", "tag": "SUBSCRIPTIONS",
+ "headline": f"{r['vendor']} went up {round(change['percent'])}%",
+ "detail": f"{{0}} → {{1}} a {_CADENCE.get(r.get('cadence'), 'charge')}.",
+ "amounts": [change["previous"], change["latest"]], "worth": None, "href": "/bills"})
+ return out
+
+
+def find_signals(txns: list[dict], today: date, review_count: int, recurring: list[dict]) -> list[dict]:
+ """Everything worth a look, most actionable first: duplicates, receipts to review, spikes, price rises."""
+ review = []
+ if review_count > 0:
+ review = [{"kind": "review", "tag": "RECEIPTS",
+ "headline": f"{review_count} {'receipt needs' if review_count == 1 else 'receipts need'} a glance",
+ "detail": "Lumen wasn't sure about these. Nothing is saved until you approve.",
+ "amounts": [], "worth": None, "href": "/receipts?tab=review"}]
+ return _duplicates(txns, today) + review + _spikes(txns, today) + _price_rises(recurring, today)
+
+
+def _at(value) -> datetime | None:
+ if isinstance(value, datetime):
+ return value
+ try:
+ return datetime.fromisoformat(str(value))
+ except ValueError:
+ return None
+
+
+def _iso(d: datetime) -> str:
+ return d.replace(microsecond=0).isoformat() + "Z" # stored as naive UTC (datetime.utcnow)
+
+
+def activity(txns: list[dict], events: list[dict], now: datetime) -> list[dict]:
+ """The newest things that happened: statement imports (one line per import), receipts read, your decisions."""
+ items: list[tuple[datetime, dict]] = []
+ imports: dict[datetime, list] = defaultdict(list)
+ for t in txns:
+ at = _at(t.get("created_at"))
+ if at is None or at > now + timedelta(minutes=5):
+ continue
+ if t.get("source") == "statement":
+ imports[at.replace(second=0, microsecond=0)].append(t)
+ else:
+ items.append((at, {"kind": "receipt", "text": f"Read a receipt: {t.get('vendor_name') or 'a vendor'} {{0}}",
+ "amounts": [t.get("total_amount") or 0], "at": _iso(at)}))
+ for minute, rows in imports.items():
+ n = len(rows)
+ items.append((minute, {"kind": "statement_import", "text": f"Imported a statement · {n} {'row' if n == 1 else 'rows'}",
+ "amounts": [], "at": _iso(minute)}))
+ for e in events:
+ if e.get("actor") != "user" or e.get("action") not in ("proposal_approved", "proposal_rejected"):
+ continue
+ if json.loads(e.get("detail") or "{}").get("superseded"):
+ continue
+ at = _at(e.get("created_at"))
+ if at is None:
+ continue
+ verb = "approved" if e["action"] == "proposal_approved" else "rejected"
+ label = _LABEL.get(e.get("type"), "a suggestion").format(target=e.get("target") or "")
+ items.append((at, {"kind": "decision", "text": f"You {verb} {label}", "amounts": [], "at": _iso(at)}))
+ items.sort(key=lambda x: x[0], reverse=True)
+ return [i for _, i in items[:ACTIVITY_LIMIT]]
diff --git a/backend/api/agent.py b/backend/api/agent.py
index 8f82171..f53aadb 100644
--- a/backend/api/agent.py
+++ b/backend/api/agent.py
@@ -7,6 +7,7 @@
from __future__ import annotations
import json
+import math
import threading
from dataclasses import dataclass
from datetime import date, datetime
@@ -149,6 +150,18 @@ def _decide(proposal_id: str, user_id: str, decision: str, deps: AgentDeps) -> d
if p.status != "pending":
raise HTTPException(409, f"Proposal is already {p.status}")
applied = None
+ if decision == "approved" and p.type == "set_budget":
+ from api.budget import upsert_budget
+
+ # the agent can file set_budget with any payload: only a real positive limit is ever applied
+ try:
+ limit = float(json.loads(p.payload or "{}")["limit"])
+ except (KeyError, TypeError, ValueError):
+ limit = math.nan
+ if not math.isfinite(limit) or limit <= 0 or not p.target.strip():
+ raise HTTPException(422, "This budget suggestion has no valid limit; reject it instead.")
+ upsert_budget(session, user_id, p.target.strip(), limit)
+ applied = {"category": p.target.strip(), "limit": limit}
if decision == "approved" and p.type == "update_category":
category = json.loads(p.payload or "{}").get("category")
if category:
diff --git a/backend/api/budget.py b/backend/api/budget.py
new file mode 100644
index 0000000..675a763
--- /dev/null
+++ b/backend/api/budget.py
@@ -0,0 +1,125 @@
+"""UI revamp Phase 3: budgets. The overall monthly budget is User.monthly_budget (set in onboarding); per-category
+limits live in the budgets table. A draft (ai/budget.py) never writes budgets: it files set_budget proposals that the
+user approves one by one through /api/agent/proposals/{id}/approve (api/agent.py applies them)."""
+from __future__ import annotations
+
+import json
+from datetime import date, datetime
+
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel, Field
+from sqlalchemy import select, text
+from sqlalchemy.engine import Engine
+from sqlalchemy.orm import Session
+
+from api.deps import current_user, rate_limit
+from api.profile import ensure_user, get_engine
+
+router = APIRouter(prefix="/api/budget", tags=["budget"])
+
+_MONTH = "%b"
+
+
+class CategoryLimit(BaseModel):
+ category: str = Field(min_length=1, max_length=60)
+ limit: float | None = Field(default=None, gt=0) # None removes the category's limit
+
+
+class BudgetUpdate(BaseModel):
+ monthly_budget: float | None = Field(default=None, ge=0)
+ categories: list[CategoryLimit] = Field(default_factory=list, max_length=50)
+
+
+def _txns(conn, user_id: str) -> list[dict]:
+ # all of one user's rows, filtered in Python: portable across SQLite TEXT and Postgres DATE columns
+ return [dict(r) for r in conn.execute(text(
+ "SELECT vendor_name, date, total_amount, category, source, created_at FROM transactions WHERE user_id = :u"),
+ {"u": user_id}).mappings()]
+
+
+def _state(engine: Engine, user_id: str, today: date) -> dict:
+ from ai.budget import month_spend_by_category
+ from models import Budget, User
+
+ with engine.connect() as conn:
+ spend = month_spend_by_category(_txns(conn, user_id), today.year, today.month)
+ by_key = {k.casefold(): v for k, v in spend.items()}
+ with Session(engine) as session:
+ user = session.get(User, user_id)
+ rows = session.scalars(select(Budget).where(Budget.user_id == user_id)).all()
+ cats = [{"category": b.category, "limit": b.monthly_limit, "spent": round(by_key.get(b.category.casefold(), 0), 2)}
+ for b in rows]
+ cats.sort(key=lambda c: c["spent"] / c["limit"], reverse=True)
+ return {"success": True, "month": today.strftime("%Y-%m"), "monthly_budget": user.monthly_budget if user else None,
+ "spent": round(sum(spend.values()), 2), "categories": cats}
+
+
+def upsert_budget(session: Session, user_id: str, category: str, limit: float) -> None:
+ """One row per (user, category), matched case-insensitively so 'food' and 'Food' don't become two budgets."""
+ from models import Budget
+
+ rows = session.scalars(select(Budget).where(Budget.user_id == user_id)).all()
+ row = next((b for b in rows if b.category.casefold() == category.casefold()), None)
+ if row is None:
+ session.add(Budget(user_id=user_id, category=category, monthly_limit=limit))
+ else:
+ row.monthly_limit, row.updated_at = limit, datetime.utcnow()
+
+
+def create_draft(engine: Engine, user_id: str, today: date) -> list[dict]:
+ """File one pending set_budget proposal per drafted line, after retiring the user's still-pending ones."""
+ from ai.budget import draft_budget
+ from api.agent import _proposal_dict
+ from models import AuditEvent, Proposal
+
+ with engine.connect() as conn:
+ lines = draft_budget(_txns(conn, user_id), today)
+ with Session(engine) as session, session.begin():
+ for old in session.scalars(select(Proposal).where(Proposal.user_id == user_id, Proposal.type == "set_budget",
+ Proposal.status == "pending")).all():
+ old.status, old.decided_at = "rejected", datetime.utcnow()
+ session.add(AuditEvent(user_id=user_id, actor="agent", action="proposal_rejected", proposal_id=old.id,
+ detail=json.dumps({"superseded": True})))
+ out = []
+ for line in lines:
+ months = line["basis"]["months"]
+ span = f"{date.fromisoformat(months[0] + '-01'):{_MONTH}}–{date.fromisoformat(months[-1] + '-01'):{_MONTH}}"
+ p = Proposal(user_id=user_id, type="set_budget", target=line["category"], risk="low",
+ payload=json.dumps({"limit": line["limit"], "basis": line["basis"]}),
+ reason=f"Averaged {line['basis']['average']:,.0f} a month over {span}, rounded up.",
+ evidence="[]", status="pending")
+ session.add(p)
+ session.flush()
+ session.add(AuditEvent(user_id=user_id, actor="agent", action="proposal_created", proposal_id=p.id,
+ detail=json.dumps({"type": "set_budget", "target": p.target, "risk": "low"})))
+ out.append(_proposal_dict(p))
+ return out
+
+
+@router.get("", dependencies=[Depends(rate_limit())])
+def get_budget(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
+ return _state(engine, claims["sub"], date.today())
+
+
+@router.put("", dependencies=[Depends(rate_limit())])
+def put_budget(body: BudgetUpdate, claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
+ from models import Budget
+
+ user_id = claims["sub"]
+ with Session(engine) as session, session.begin():
+ if "monthly_budget" in body.model_fields_set:
+ ensure_user(session, user_id, claims.get("email")).monthly_budget = body.monthly_budget
+ for c in body.categories:
+ name = c.category.strip()
+ if c.limit is None:
+ for b in session.scalars(select(Budget).where(Budget.user_id == user_id)).all():
+ if b.category.casefold() == name.casefold():
+ session.delete(b)
+ else:
+ upsert_budget(session, user_id, name, c.limit)
+ return _state(engine, user_id, date.today())
+
+
+@router.post("/draft", dependencies=[Depends(rate_limit("10 per minute"))])
+def draft(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
+ return {"success": True, "proposals": create_draft(engine, claims["sub"], date.today())}
diff --git a/backend/api/home.py b/backend/api/home.py
new file mode 100644
index 0000000..9bbd43e
--- /dev/null
+++ b/backend/api/home.py
@@ -0,0 +1,43 @@
+"""UI revamp Phase 3: GET /api/home/summary, the data behind Home's "Lumen found" card and activity feed.
+Rule-based (ai/home_signals.py), no LLM, so it is cheap enough to call on every Home visit."""
+from __future__ import annotations
+
+from datetime import date, datetime
+
+from fastapi import APIRouter, Depends
+from sqlalchemy import func, select
+from sqlalchemy.engine import Engine
+from sqlalchemy.orm import Session
+
+from api.budget import _txns
+from api.deps import current_user, rate_limit
+from api.profile import get_engine
+
+router = APIRouter(prefix="/api/home", tags=["home"])
+
+FOUND_SHOWN = 3
+
+
+@router.get("/summary", dependencies=[Depends(rate_limit())])
+def summary(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
+ from ai.home_signals import activity, find_signals
+ from ai.recurring import find_recurring
+ from models import AuditEvent, Proposal, ReviewItem
+
+ user_id, today = claims["sub"], date.today()
+ with engine.connect() as conn:
+ txns = _txns(conn, user_id)
+ with Session(engine) as session:
+ review_count = session.scalar(select(func.count()).select_from(ReviewItem).where(
+ ReviewItem.user_id == user_id, ReviewItem.status == "flagged")) or 0
+ rows = session.execute(
+ select(AuditEvent.action, AuditEvent.actor, AuditEvent.created_at, AuditEvent.detail, Proposal.type,
+ Proposal.target)
+ .join(Proposal, Proposal.id == AuditEvent.proposal_id, isouter=True)
+ .where(AuditEvent.user_id == user_id).order_by(AuditEvent.id.desc()).limit(50)).all()
+ events = [dict(r._mapping) for r in rows]
+ items = find_signals(txns, today, review_count, find_recurring(txns, today))
+ shown = items[:FOUND_SHOWN]
+ return {"success": True, "found": shown, "more": max(0, len(items) - FOUND_SHOWN),
+ "worth_total": sum(i["worth"] or 0 for i in shown),
+ "activity": activity(txns, events, datetime.utcnow())}
diff --git a/backend/api/review.py b/backend/api/review.py
index 2ff3a22..123b842 100644
--- a/backend/api/review.py
+++ b/backend/api/review.py
@@ -13,7 +13,7 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
-from sqlalchemy import create_engine, select
+from sqlalchemy import create_engine, func, select
from sqlalchemy.engine import Engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -204,10 +204,15 @@ def submit(body: Submit, claims: dict = Depends(current_user), engine: Engine =
@router.get("", dependencies=[Depends(rate_limit())])
-def list_items(status: str = "flagged", claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
+def list_items(status: str = "flagged", count: bool = False, claims: dict = Depends(current_user),
+ engine: Engine = Depends(get_engine)):
from models import ReviewItem
with Session(engine) as session:
+ if count: # the sidebar badge only needs the number (UI revamp Phase 3)
+ n = session.scalar(select(func.count()).select_from(ReviewItem).where(
+ ReviewItem.user_id == claims["sub"], ReviewItem.status == status))
+ return {"success": True, "count": n or 0}
rows = session.scalars(select(ReviewItem).where(ReviewItem.user_id == claims["sub"], ReviewItem.status == status)
.order_by(ReviewItem.created_at.desc())).all()
return {"success": True, "items": [_item_dict(r) for r in rows]}
diff --git a/backend/asgi.py b/backend/asgi.py
index 026b657..5baaec3 100644
--- a/backend/asgi.py
+++ b/backend/asgi.py
@@ -20,7 +20,7 @@
from fastapi.middleware.cors import CORSMiddleware
import app as flask_module
-from api import agent, demo, documents, errors, files, keys, profile, review, statements
+from api import agent, budget, demo, documents, errors, files, home, keys, profile, review, statements
from api.deps import llm_deadline
from config import Config
@@ -90,4 +90,5 @@ def create_app(*routers: APIRouter) -> FastAPI:
app = create_app(documents.router, agent.router, demo.router, review.router, files.router, profile.router, statements.router,
+ budget.router, home.router,
keys.router)
diff --git a/backend/models/__init__.py b/backend/models/__init__.py
index 926a539..767e753 100644
--- a/backend/models/__init__.py
+++ b/backend/models/__init__.py
@@ -287,6 +287,21 @@ class Proposal(db.Model):
decided_at = db.Column(db.DateTime, nullable=True)
+class Budget(db.Model):
+ """A monthly spending limit for one category (UI revamp Phase 3). The overall budget is User.monthly_budget.
+ Rows are written only by approving a set_budget proposal or by PUT /api/budget."""
+
+ __tablename__ = "budgets"
+
+ id = db.Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ user_id = db.Column(String(36), nullable=False, index=True)
+ category = db.Column(db.String, nullable=False)
+ monthly_limit = db.Column(db.Float, nullable=False)
+ updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+ __table_args__ = (db.UniqueConstraint("user_id", "category", name="u_budget_user_category"),)
+
+
class AuditEvent(db.Model):
"""Append-only log of proposals and decisions (who, what, when)."""
diff --git a/backend/tests/test_api_budget.py b/backend/tests/test_api_budget.py
new file mode 100644
index 0000000..9e1b3a2
--- /dev/null
+++ b/backend/tests/test_api_budget.py
@@ -0,0 +1,177 @@
+"""UI revamp Phase 3: the budget API, set_budget proposals (draft -> approve -> budgets row) and the agent tool."""
+from datetime import date
+
+import jwt
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine, func, select
+from sqlalchemy.orm import Session
+
+A = "7a1c0000-0000-4000-8000-0000000000aa"
+B = "7a1c0000-0000-4000-8000-0000000000bb"
+
+
+def bearer(sub):
+ return {"Authorization": "Bearer " + jwt.encode({"sub": sub, "email": f"{sub}@x.com"}, "k" * 32, algorithm="HS256")}
+
+
+def months_back(n):
+ """'YYYY-MM-10' for n complete months before the current one."""
+ t = date.today()
+ y, m = divmod(t.year * 12 + t.month - 1 - n, 12)
+ return f"{y}-{m + 1:02d}-10"
+
+
+@pytest.fixture
+def setup(tmp_path, monkeypatch):
+ import models # noqa: F401
+ import utils.auth
+ from api import agent as agent_api
+ from api import budget
+ from asgi import create_app
+ from models.database import db as flask_db
+ from utils.limiter import limiter
+
+ monkeypatch.setattr(utils.auth, "verify_token", lambda t: jwt.decode(t, options={"verify_signature": False}))
+ monkeypatch.setattr(limiter, "enabled", False)
+ engine = create_engine(f"sqlite:///{tmp_path / 'b.db'}")
+ flask_db.metadata.create_all(engine)
+ app = create_app(budget.router, agent_api.router)
+ app.dependency_overrides[budget.get_engine] = lambda: engine
+ app.dependency_overrides[agent_api.get_agent_deps] = lambda: agent_api.AgentDeps(engine=engine, sql_agent=None)
+ return TestClient(app), engine
+
+
+def seed(engine, user, rows):
+ from models import Transaction
+
+ with Session(engine) as s, s.begin():
+ for i, (d, amount, category) in enumerate(rows):
+ s.add(Transaction(user_id=user, vendor_name=f"V{i}", invoice_number=f"{user}-{i}", date=d,
+ total_amount=amount, category=category))
+
+
+def history(engine, user):
+ seed(engine, user, [(months_back(n), 4000, "Food") for n in (1, 2, 3)]
+ + [(months_back(n), 1200, "Travel") for n in (1, 2, 3)])
+
+
+def test_get_budget_empty(setup):
+ c, _ = setup
+ assert c.get("/api/budget", headers=bearer(A)).json() == {
+ "success": True, "month": date.today().strftime("%Y-%m"), "monthly_budget": None, "spent": 0, "categories": []}
+
+
+def test_put_then_get(setup):
+ c, engine = setup
+ seed(engine, A, [(date.today().strftime("%Y-%m-01"), 750, "food")])
+ body = c.put("/api/budget", headers=bearer(A),
+ json={"monthly_budget": 28000, "categories": [{"category": "Food", "limit": 8000}]}).json()
+ assert body["monthly_budget"] == 28000 and body["spent"] == 750
+ assert body["categories"] == [{"category": "Food", "limit": 8000, "spent": 750}]
+ assert c.get("/api/budget", headers=bearer(A)).json() == body
+
+
+def test_put_null_deletes_category(setup):
+ c, _ = setup
+ c.put("/api/budget", headers=bearer(A), json={"categories": [{"category": "Food", "limit": 8000}]})
+ body = c.put("/api/budget", headers=bearer(A), json={"categories": [{"category": "Food", "limit": None}]}).json()
+ assert body["categories"] == []
+
+
+def test_put_rejects_negative(setup):
+ c, _ = setup
+ assert c.put("/api/budget", headers=bearer(A), json={"monthly_budget": -1}).status_code == 422
+ assert c.put("/api/budget", headers=bearer(A), json={"categories": [{"category": "Food", "limit": 0}]}).status_code == 422
+
+
+def test_draft_creates_pending_set_budget_proposals(setup):
+ c, engine = setup
+ history(engine, A)
+ proposals = c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"]
+ assert [(p["type"], p["target"], p["payload"]["limit"], p["status"]) for p in proposals] == [
+ ("set_budget", "Food", 4000, "pending"), ("set_budget", "Travel", 1500, "pending")]
+
+
+def test_draft_needs_history(setup):
+ c, _ = setup
+ assert c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"] == []
+
+
+def test_redraft_supersedes_pending(setup):
+ c, engine = setup
+ history(engine, A)
+ first = {p["id"] for p in c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"]}
+ second = {p["id"] for p in c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"]}
+ pending = {p["id"] for p in c.get("/api/agent/proposals?status=pending", headers=bearer(A)).json()["proposals"]}
+ assert pending == second and not first & second
+ rejected = {p["id"] for p in c.get("/api/agent/proposals?status=rejected", headers=bearer(A)).json()["proposals"]}
+ assert first <= rejected
+
+
+def test_approve_set_budget_upserts_row(setup):
+ c, engine = setup
+ history(engine, A)
+ c.put("/api/budget", headers=bearer(A), json={"categories": [{"category": "Food", "limit": 999}]})
+ food = next(p for p in c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"] if p["target"] == "Food")
+ r = c.post(f"/api/agent/proposals/{food['id']}/approve", headers=bearer(A)).json()
+ assert r["applied"] == {"category": "Food", "limit": 4000}
+ assert c.get("/api/budget", headers=bearer(A)).json()["categories"][0]["limit"] == 4000
+
+
+def test_set_budget_approve_twice(setup):
+ from models import Budget
+
+ c, engine = setup
+ history(engine, A)
+ pid = c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"][0]["id"]
+ assert c.post(f"/api/agent/proposals/{pid}/approve", headers=bearer(A)).status_code == 200
+ assert c.post(f"/api/agent/proposals/{pid}/approve", headers=bearer(A)).status_code == 409
+ with Session(engine) as s:
+ assert s.scalar(select(func.count()).select_from(Budget)) == 1
+
+
+def test_budget_is_per_user(setup):
+ c, engine = setup
+ history(engine, A)
+ c.put("/api/budget", headers=bearer(A), json={"monthly_budget": 5000, "categories": [{"category": "Food", "limit": 1}]})
+ pid = c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"][0]["id"]
+ b = c.get("/api/budget", headers=bearer(B)).json()
+ assert b["monthly_budget"] is None and b["categories"] == [] and b["spent"] == 0
+ assert c.post(f"/api/agent/proposals/{pid}/approve", headers=bearer(B)).status_code == 404
+ assert c.post("/api/budget/draft", headers=bearer(B)).json()["proposals"] == []
+
+
+def test_agent_tool_draft_budget(setup):
+ from agent.tools import TOOLS, NoArgs, ToolContext
+
+ c, engine = setup
+ history(engine, A)
+ out = TOOLS["draft_budget"].run(ToolContext(user_id=A, engine=engine, sql_agent=None), NoArgs())
+ assert [p["target"] for p in out["proposals"]] == ["Food", "Travel"]
+ pending = c.get("/api/agent/proposals?status=pending", headers=bearer(A)).json()["proposals"]
+ assert {p["type"] for p in pending} == {"set_budget"} and len(pending) == 2
+
+
+@pytest.mark.parametrize("payload", [{"amount": 5000}, {"limit": 0}, {"limit": -5}, {"limit": "lots"}, {"limit": float("inf")}])
+def test_agent_set_budget_with_a_bad_limit_is_rejected_not_applied(setup, payload):
+ """The LLM can file set_budget with any payload: approving a bad one must be a 422, never a broken budget."""
+ from agent.tools import TOOLS, ProposeActionArgs, ToolContext
+
+ c, _ = setup
+ out = TOOLS["propose_action"].run(ToolContext(user_id=A, engine=setup[1], sql_agent=None), ProposeActionArgs(
+ type="set_budget", target=" Food ", risk="low", reason="r", payload=payload))
+ r = c.post(f"/api/agent/proposals/{out['proposal_id']}/approve", headers=bearer(A))
+ assert r.status_code == 422
+ assert c.get("/api/budget", headers=bearer(A)).status_code == 200
+ assert c.get("/api/budget", headers=bearer(A)).json()["categories"] == []
+
+
+def test_agent_set_budget_target_is_trimmed(setup):
+ from agent.tools import TOOLS, ProposeActionArgs, ToolContext
+
+ c, engine = setup
+ out = TOOLS["propose_action"].run(ToolContext(user_id=A, engine=engine, sql_agent=None), ProposeActionArgs(
+ type="set_budget", target=" Food ", risk="low", reason="r", payload={"limit": 5000}))
+ c.post(f"/api/agent/proposals/{out['proposal_id']}/approve", headers=bearer(A))
+ assert c.get("/api/budget", headers=bearer(A)).json()["categories"][0]["category"] == "Food"
diff --git a/backend/tests/test_api_home.py b/backend/tests/test_api_home.py
new file mode 100644
index 0000000..d9f63bb
--- /dev/null
+++ b/backend/tests/test_api_home.py
@@ -0,0 +1,78 @@
+"""UI revamp Phase 3: GET /api/home/summary."""
+from datetime import date, timedelta
+
+import jwt
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session
+
+A = "7a1c0000-0000-4000-8000-0000000000aa"
+B = "7a1c0000-0000-4000-8000-0000000000bb"
+
+
+def bearer(sub):
+ return {"Authorization": "Bearer " + jwt.encode({"sub": sub}, "k" * 32, algorithm="HS256")}
+
+
+@pytest.fixture
+def setup(tmp_path, monkeypatch):
+ import models # noqa: F401
+ import utils.auth
+ from api import home
+ from asgi import create_app
+ from models.database import db as flask_db
+ from utils.limiter import limiter
+
+ monkeypatch.setattr(utils.auth, "verify_token", lambda t: jwt.decode(t, options={"verify_signature": False}))
+ monkeypatch.setattr(limiter, "enabled", False)
+ engine = create_engine(f"sqlite:///{tmp_path / 'h.db'}")
+ flask_db.metadata.create_all(engine)
+ app = create_app(home.router)
+ app.dependency_overrides[home.get_engine] = lambda: engine
+ return TestClient(app), engine
+
+
+def double_charge(engine, user):
+ from models import Transaction
+
+ d = (date.today() - timedelta(days=2)).isoformat()
+ with Session(engine) as s, s.begin():
+ for i in range(2):
+ s.add(Transaction(user_id=user, vendor_name="Croma", invoice_number=f"{user}-{i}", date=d,
+ total_amount=2499, category="Electronics"))
+
+
+def test_summary_empty_user(setup):
+ c, _ = setup
+ assert c.get("/api/home/summary", headers=bearer(A)).json() == {
+ "success": True, "found": [], "more": 0, "worth_total": 0, "activity": []}
+
+
+def test_summary_reports_duplicate_and_worth(setup):
+ c, engine = setup
+ double_charge(engine, A)
+ body = c.get("/api/home/summary", headers=bearer(A)).json()
+ assert [f["kind"] for f in body["found"]] == ["duplicate"] and body["worth_total"] == 2499
+ assert [a["kind"] for a in body["activity"]] == ["receipt", "receipt"]
+ assert body["activity"][0]["at"].endswith("Z")
+
+
+def test_summary_counts_flagged_review_items(setup):
+ from models import ReviewItem
+
+ c, engine = setup
+ with Session(engine) as s, s.begin():
+ s.add(ReviewItem(user_id=A, status="flagged", confidence="low", invoice="{}", flags="[]"))
+ assert c.get("/api/home/summary", headers=bearer(A)).json()["found"][0]["headline"] == "1 receipt needs a glance"
+
+
+def test_summary_is_per_user(setup):
+ c, engine = setup
+ double_charge(engine, A)
+ assert c.get("/api/home/summary", headers=bearer(B)).json()["found"] == []
+
+
+def test_summary_requires_auth(setup):
+ c, _ = setup
+ assert c.get("/api/home/summary").status_code == 401
diff --git a/backend/tests/test_api_review.py b/backend/tests/test_api_review.py
index 1d2124f..338952b 100644
--- a/backend/tests/test_api_review.py
+++ b/backend/tests/test_api_review.py
@@ -185,3 +185,10 @@ def test_a_seen_warning_does_not_hold_back_a_save(client):
warnings_seen=True)["status"] == "approved"
wrong_total = invoice(vendor_name="Croma Retail", invoice_number="CR-3", total_amount=999.0)
assert submit_invoice(client.engine, ALICE, wrong_total, warnings_seen=True)["status"] == "flagged"
+
+
+def test_list_count_only(client):
+ for total in (999.0, 998.0, 997.0): # each total disagrees with its line items, so each waits for review
+ client.post("/api/review", headers=bearer(ALICE), json={"invoice": invoice(total_amount=total, invoice_number=f"N{total}")})
+ assert client.get("/api/review?status=flagged&count=1", headers=bearer(ALICE)).json() == {"success": True, "count": 3}
+ assert client.get("/api/review?count=1", headers=bearer(BOB)).json() == {"success": True, "count": 0}
diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py
new file mode 100644
index 0000000..be19c35
--- /dev/null
+++ b/backend/tests/test_budget.py
@@ -0,0 +1,79 @@
+"""UI revamp Phase 3: deterministic budget drafts and per-category spend (ai/budget.py), and the budgets table."""
+from datetime import date
+
+import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+from ai.budget import draft_budget, month_spend_by_category
+
+TODAY = date(2026, 10, 21) # complete months: Jul, Aug, Sep 2026
+
+
+def t(d, amount, category="Food"):
+ return {"vendor_name": "V", "date": d, "total_amount": amount, "category": category}
+
+
+def three_months(category, amounts):
+ return [t(f"2026-{m:02d}-10", a, category) for m, a in zip((7, 8, 9), amounts)]
+
+
+def test_draft_rounds_up_to_500():
+ assert draft_budget(three_months("Food", [7000, 7160, 7200]), TODAY) == [
+ {"category": "Food", "limit": 7500, "basis": {"months": ["2026-07", "2026-08", "2026-09"], "average": 7120.0}}
+ ]
+
+
+def test_draft_small_values_round_to_10():
+ [line] = draft_budget(three_months("Coffee", [400, 404, 408]), TODAY)
+ assert line["limit"] == 410
+
+
+def test_draft_skips_under_300():
+ assert draft_budget(three_months("Stamps", [250, 280, 290]), TODAY) == []
+
+
+def test_draft_caps_at_8_largest_first():
+ txns = []
+ for i in range(10):
+ txns += three_months(f"C{i}", [1000 * (i + 1)] * 3)
+ lines = draft_budget(txns, TODAY)
+ assert len(lines) == 8
+ assert [l["category"] for l in lines] == [f"C{i}" for i in range(9, 1, -1)]
+
+
+def test_draft_needs_history():
+ assert draft_budget([t("2026-09-10", 5000)], TODAY) == []
+
+
+def test_draft_ignores_current_month():
+ txns = three_months("Food", [1000, 1000, 1000]) + [t("2026-10-05", 90000)]
+ assert draft_budget(txns, TODAY)[0]["limit"] == 1000
+
+
+def test_categories_normalise():
+ txns = [t("2026-10-01", 100, "food"), t("2026-10-02", 100, "Food "), t("2026-10-03", 100, "FOOD"),
+ t("2026-10-04", 100, "food"), t("2026-10-05", 50, None)]
+ assert month_spend_by_category(txns, 2026, 10) == {"food": 400, "Uncategorised": 50}
+
+
+def test_month_spend_nets_refunds():
+ txns = [t("2026-10-01", 1000), t("2026-10-02", -400)]
+ assert month_spend_by_category(txns, 2026, 10) == {"Food": 600}
+
+
+def test_budget_unique_per_category(tmp_path):
+ import models # noqa: F401
+ from models import Budget
+ from models.database import db as flask_db
+
+ engine = create_engine(f"sqlite:///{tmp_path / 'b.db'}")
+ flask_db.metadata.create_all(engine)
+ with pytest.raises(IntegrityError), Session(engine) as s, s.begin():
+ s.add_all([Budget(user_id="u", category="Food", monthly_limit=1), Budget(user_id="u", category="Food", monthly_limit=2)])
+
+
+def test_draft_with_two_months_averages_over_two():
+ txns = [t("2026-08-10", 6000), t("2026-09-10", 6000)] # no July data
+ assert draft_budget(txns, TODAY)[0]["limit"] == 6000
diff --git a/backend/tests/test_home_signals.py b/backend/tests/test_home_signals.py
new file mode 100644
index 0000000..793b29a
--- /dev/null
+++ b/backend/tests/test_home_signals.py
@@ -0,0 +1,121 @@
+"""UI revamp Phase 3: Home's "Lumen found" signals and activity feed (ai/home_signals.py), pure functions."""
+from datetime import date, datetime
+
+from ai.home_signals import activity, find_signals
+
+TODAY = date(2026, 10, 21)
+
+
+def t(d, amount, vendor="Croma", category="Electronics", **kw):
+ return {"vendor_name": vendor, "date": d, "total_amount": amount, "category": category, **kw}
+
+
+def kinds(items):
+ return [i["kind"] for i in items]
+
+
+def test_duplicate_same_vendor_amount_within_a_day():
+ [d] = find_signals([t("2026-10-14", 2499), t("2026-10-15", 2499.5, vendor="CROMA ")], TODAY, 0, [])
+ assert d == {"kind": "duplicate", "tag": "DUPLICATES", "headline": "Possible double charge: Croma {0}",
+ "detail": "Charged on 14 Oct and 15 Oct.", "amounts": [2499], "worth": 2499, "href": None}
+
+
+def test_same_day_duplicate_wording():
+ [d] = find_signals([t("2026-10-14", 300), t("2026-10-14", 300)], TODAY, 0, [])
+ assert d["detail"] == "Charged twice on 14 Oct."
+
+
+def test_no_duplicate_two_days_apart_or_old():
+ assert find_signals([t("2026-10-14", 2499), t("2026-10-16", 2499)], TODAY, 0, []) == []
+ assert find_signals([t("2026-08-01", 2499), t("2026-08-01", 2499)], TODAY, 0, []) == []
+
+
+def test_spike_needs_30pct_and_floor():
+ small = [t("2026-09-05", 600, "Swiggy", "Food"), t("2026-10-05", 810, "Swiggy", "Food")] # +35%, +210
+ assert find_signals(small, TODAY, 0, []) == []
+ big = [t("2026-09-05", 5000, "Swiggy", "Food"), t("2026-10-05", 6750, "Swiggy", "Food")] # +35%, +1750
+ [s] = find_signals(big, TODAY, 0, [])
+ assert s == {"kind": "spike", "tag": "SPENDING", "headline": "Food is up 35%",
+ "detail": "{0} so far vs {1} by this point last month.", "amounts": [6750, 5000], "worth": None,
+ "href": "/spending"}
+
+
+def test_spike_only_counts_days_up_to_today():
+ late_last_month = [t("2026-09-28", 9000, "X", "Food"), t("2026-09-05", 2000, "X", "Food"), t("2026-10-05", 3500, "X", "Food")]
+ assert kinds(find_signals(late_last_month, TODAY, 0, [])) == ["spike"] # 3500 vs 2000 by the 21st
+
+
+def test_spike_ignores_refund_month():
+ refunds = [t("2026-09-05", -4000, "X", "Food"), t("2026-10-05", 3000, "X", "Food")]
+ assert find_signals(refunds, TODAY, 0, []) == []
+
+
+def test_price_rise_from_recurring():
+ rec = [{"vendor": "Spotify", "cadence": "monthly", "last_charge_date": "2026-10-02",
+ "price_change": {"previous": 119, "latest": 139, "percent": 16.8}},
+ {"vendor": "Old", "cadence": "monthly", "last_charge_date": "2026-06-01",
+ "price_change": {"previous": 100, "latest": 200, "percent": 100}},
+ {"vendor": "Tiny", "cadence": "monthly", "last_charge_date": "2026-10-02",
+ "price_change": {"previous": 100, "latest": 103, "percent": 3}}]
+ [p] = find_signals([], TODAY, 0, rec)
+ assert p == {"kind": "price_rise", "tag": "SUBSCRIPTIONS", "headline": "Spotify went up 17%",
+ "detail": "{0} → {1} a month.", "amounts": [119, 139], "worth": None, "href": "/bills"}
+
+
+def test_order_and_review_item():
+ rec = [{"vendor": "Spotify", "cadence": "monthly", "last_charge_date": "2026-10-02",
+ "price_change": {"previous": 119, "latest": 139, "percent": 16.8}}]
+ txns = [t("2026-10-14", 2499), t("2026-10-14", 2499),
+ t("2026-09-05", 5000, "S", "Food"), t("2026-10-05", 6750, "S", "Food")]
+ items = find_signals(txns, TODAY, 3, rec)
+ assert kinds(items) == ["duplicate", "review", "spike", "price_rise"]
+ assert items[1] == {"kind": "review", "tag": "RECEIPTS", "headline": "3 receipts need a glance",
+ "detail": "Lumen wasn't sure about these. Nothing is saved until you approve.",
+ "amounts": [], "worth": None, "href": "/receipts?tab=review"}
+ assert find_signals([], TODAY, 1, [])[0]["headline"] == "1 receipt needs a glance"
+
+
+NOW = datetime(2026, 10, 21, 12, 0, 0)
+
+
+def test_activity_groups_statement_rows():
+ rows = [t("2026-10-01", 10, source="statement", created_at=datetime(2026, 10, 21, 10, 0, s)) for s in range(42)]
+ assert activity(rows, [], NOW) == [{"kind": "statement_import", "text": "Imported a statement · 42 rows",
+ "amounts": [], "at": "2026-10-21T10:00:00Z"}]
+
+
+def test_activity_receipt_and_order():
+ rows = [t("2026-10-14", 2499, created_at="2026-10-21 09:00:00"), t("2026-10-15", 99, vendor="Uber", created_at=datetime(2026, 10, 21, 11))]
+ assert activity(rows, [], NOW) == [
+ {"kind": "receipt", "text": "Read a receipt: Uber {0}", "amounts": [99], "at": "2026-10-21T11:00:00Z"},
+ {"kind": "receipt", "text": "Read a receipt: Croma {0}", "amounts": [2499], "at": "2026-10-21T09:00:00Z"}]
+
+
+def test_activity_labels_decisions_and_skips_superseded():
+ events = [
+ {"action": "proposal_approved", "actor": "user", "created_at": datetime(2026, 10, 21, 9), "type": "set_budget",
+ "target": "Food", "detail": "{}"},
+ {"action": "proposal_rejected", "actor": "user", "created_at": datetime(2026, 10, 21, 8), "type": "update_category",
+ "target": "INV-1", "detail": "{}"},
+ {"action": "proposal_rejected", "actor": "agent", "created_at": datetime(2026, 10, 21, 7), "type": "set_budget",
+ "target": "Food", "detail": '{"superseded": true}'},
+ {"action": "proposal_created", "actor": "agent", "created_at": datetime(2026, 10, 21, 6), "type": "set_budget",
+ "target": "Food", "detail": "{}"},
+ ]
+ assert [a["text"] for a in activity([], events, NOW)] == ["You approved a Food budget", "You rejected a category change"]
+
+
+def test_activity_keeps_newest_six():
+ rows = [t("2026-10-01", i, vendor=f"V{i}", created_at=datetime(2026, 10, 21, i)) for i in range(10)]
+ out = activity(rows, [], NOW)
+ assert len(out) == 6 and out[0]["at"] == "2026-10-21T09:00:00Z"
+
+
+def test_spike_ignores_partial_refund_month():
+ rows = [t("2026-09-05", 2000, "X", "Food"), t("2026-09-06", -1800, "X", "Food"), t("2026-10-05", 1500, "X", "Food")]
+ assert find_signals(rows, TODAY, 0, []) == []
+
+
+def test_daily_habit_is_not_a_duplicate():
+ coffee = [t(f"2026-10-{d:02d}", 180, "Blue Tokai", "Food") for d in range(10, 18)]
+ assert find_signals(coffee, TODAY, 0, []) == []
diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md
index ebb9ca2..3d989a9 100644
--- a/docs/CODEBASE-GUIDE.md
+++ b/docs/CODEBASE-GUIDE.md
@@ -285,8 +285,23 @@ embeds the demo documents once per server and uploads the files in the backgroun
Activity. `use-home-data.ts` fetches today's endpoints in parallel (transactions, reminders, forecast, review
queue). A failed call empties only its card. Skeletons show only on the first load, so changing the chart range
never blanks other cards. All the maths is pure, in `lib/home-math.ts` (unit tests: `e2e/home-math.spec.ts`).
- **Interim until Phase 3:** the brief is one rule-based sentence, Lumen found only shows the review queue, the
- Budget card is an empty state, and Activity is derived from transactions' `created_at`. `(app)/layout.tsx` is a
+ **Phase 3 backend:** `GET /api/home/summary` (`api/home.py` over the pure `ai/home_signals.py`, no LLM) supplies
+ Lumen found and Activity:
+ - Lumen found shows double charges (the only items with a "you may get back" ₹), receipts to review, category spikes
+ vs the same day last month, and subscription price rises from `ai.recurring`.
+ - Activity shows statement imports grouped into one line, receipts read, and your approve/reject decisions.
+
+ Text carries `{0}` placeholders plus `amounts`, which the client fills in the user's currency (`fill` in
+ `lib/home-math.ts`). The brief adds "The one thing worth a look" from the top item.
+
+ Budgets: the overall budget is `User.monthly_budget` (onboarding); per-category limits are the `budgets` table.
+ - `GET/PUT /api/budget` (`api/budget.py`).
+ - `POST /api/budget/draft` runs the deterministic `ai/budget.py` (3-month mean, rounded up) and files `set_budget`
+ proposals. Approving one (`/api/agent/proposals/{id}/approve`) upserts the row. A new draft retires pending ones.
+ - Ask Lumen's `draft_budget` tool files the same proposals.
+ - Home's draft dialog approves lines one by one or all at once.
+
+ The sidebar badge uses `GET /api/review?count=1`. `(app)/layout.tsx` is a
tiny server file exporting the light `theme-color`; the client shell is `components/app-shell.tsx`.
- **Signed-in e2e:** `e2e/fixtures.ts` `signIn(page)` fakes a Supabase session (localStorage) plus the
`lumen_session` cookie, and stubs every backend call, so `e2e/shell.spec.ts` drives the real shell without servers.
diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md
index b44aae0..f636a81 100644
--- a/docs/direction/STORY.md
+++ b/docs/direction/STORY.md
@@ -354,3 +354,18 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in
- **R:** `/home` is static at 15.7 kB of route JS. 65 checks pass, including zero layout shift with a 1.5 s slow
backend, a failed forecast, a brand-new user with no data, and 15-lakh amounts with long vendor names. Story point:
an honest empty state beats a convincing placeholder.
+
+## 30. "Lumen found" and budgets you approve line by line (UI revamp, Phase 3)
+- **S:** Home's smart cards were placeholders: Lumen found only knew the review queue, Activity was guessed from
+ transactions, and Budget said "coming soon".
+- **T:** Back them with real, cheap, honest data. Use no LLM on every Home visit, never invent a saving, and keep Lumen's
+ rule that nothing changes without the user's approval.
+- **A:** Wrote pure, tested rules for double charges, category spikes measured by the same day last month (refunds
+ can't fake one), and subscription price rises. Built an activity feed from tables that already exist. Budgets: a
+ deterministic draft (3-month mean, rounded up) files one `set_budget` proposal per category, and approving one
+ upserts the limit. The same path is exposed as an agent tool, so "make me a budget" in Ask Lumen gives the same
+ approvable lines. Money travels as numbers with placeholders, so every amount shows in the user's currency.
+- **R:** 47 new backend tests (950 passing) and 82 e2e checks, including per-user isolation, a double approve
+ returning 409 with one row, re-drafts retiring stale suggestions, and a user with no history. Home is still static
+ (17.5 kB of route JS). Owner follow-up logged: make the rules smarter about one-off big purchases, measured against an
+ eval set.
diff --git a/docs/superpowers/plans/2026-10-21-ui-revamp-phase3-home-backend.md b/docs/superpowers/plans/2026-10-21-ui-revamp-phase3-home-backend.md
new file mode 100644
index 0000000..28aed31
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-21-ui-revamp-phase3-home-backend.md
@@ -0,0 +1,285 @@
+# UI Revamp Phase 3 (Home backend) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace Home's interim parts with real backend data:
+- "Lumen found" with duplicates, spending spikes, price rises and receipts to review
+- a real activity feed
+- budgets, with per-category limits and an AI-drafted budget the user approves line by line
+- a cheap review-count endpoint for the sidebar badge
+
+**Architecture:**
+- **Detection logic** is pure Python in `backend/ai/home_signals.py` and `backend/ai/budget.py`. It takes lists of plain dicts and is unit-tested without a database or LLM. It reuses `ai.recurring.find_recurring`.
+- **Two new FastAPI routers**, `api/home.py` and `api/budget.py`, follow `api/profile.py`'s pattern (`get_engine` dependency, `current_user`, `rate_limit`).
+- **Drafting a budget creates `set_budget` proposals.** Each proposal is approved through the existing `/api/agent/proposals/{id}/approve`, which upserts a `budgets` row. Ask Lumen can create the same proposals through a new agent tool. This is the "tool-calling + human approval" story the spec wants.
+- **Money stays numeric on the wire.** Headlines carry `{0}` / `{1}` placeholders plus an `amounts` array, so the frontend formats them in the user's currency with `useMoney()`.
+
+**Tech Stack:** FastAPI, SQLAlchemy (Flask-SQLAlchemy models, `create_all` adds new tables), pytest with the `TestClient` pattern from `tests/test_api_profile.py`, and on the frontend Next.js and Playwright (the `signIn` fixture).
+
+**Spec:** `docs/superpowers/specs/2026-10-20-ui-revamp-design.md` §4 (Home table), §8 (new backend) and §12 (backlog items tagged Phase 3). It builds on `ui-revamp/phase2-home` (PR #92).
+
+## Global Constraints
+
+- **Honesty rule (spec anti-goal):** never invent a number.
+ - A ₹ "worth" appears only where money can plausibly come back. That means duplicates only.
+ - Price rises and spikes state facts, not savings.
+ - With nothing to report, `found` is empty, and the frontend keeps "Nothing needs you right now."
+- **No LLM calls in any Home endpoint.** The draft budget is deterministic arithmetic.
+- **Propose, then approve (Lumen's rule):** a draft never writes `budgets` directly. Only approving a `set_budget` proposal does.
+- **Per-user scoping on every query** (`user_id == claims["sub"]`). This matches the existing routers.
+- **Portable SQL:** transactions' `date` is TEXT on SQLite and DATE on Postgres. Load the user's rows and filter in Python, as `agent/tools.py::_get_overview` does.
+- **New table via `create_all`:** `budgets` is new, so no `_ADDED_COLUMNS` entry is needed. If Alembic is used in deploy, also add `alembic/versions/002_budgets.py`; follow `001_unified_schema.py`'s style.
+- **Timestamps on the wire are ISO UTC with `Z`.** Use `.isoformat() + "Z"` for naive UTC `datetime`s.
+- **Git:** no "claude" in branch names and no attribution lines. Open one PR (base `ui-revamp/phase2-home` until #92 merges). Keep `docs/CODEBASE-GUIDE.md` current, tick spec §12 items, and add a STORY entry.
+
+## Decisions this plan makes (owner may override)
+
+- **The brief stays composed on the client.** Phase 2's tested `briefSentence` writes the pace sentence. The server supplies `found[0]`, and the client appends "The one thing worth a look: {headline}." This avoids two implementations of the same sentence.
+- **The overall budget is the existing `User.monthly_budget`,** set during onboarding. Per-category limits go in the new `budgets` table. The Budget card shows the overall figure, plus up to 3 categories closest to their limit.
+- **Draft budget per category:**
+ - Take the mean of the last 3 *complete* calendar months' spend.
+ - Round it up: `ceil(avg / 500) * 500` when the average is at least 500, otherwise `ceil(avg / 10) * 10`.
+ - Skip categories under ₹300 a month.
+ - Create at most 8 proposals, largest first.
+ - A new draft rejects the user's still-pending `set_budget` proposals first, with the audit `detail` `{"superseded": true}`, so drafts never pile up.
+- **Thresholds for "Lumen found":**
+ - **Duplicate:** the same normalised vendor and the same amount (±₹1) within 1 day, in the last 30 days.
+ - **Spike:** a category's month-to-date spend is ≥30% above the same day last month **and** at least `max(₹1,000, 5% of last month's total)` above it.
+ - **Price rise:** a recurring charge with `price_change.percent ≥ 5` and a last charge within 45 days.
+ - **Review:** count > 0.
+ - **Order:** duplicate → review → spike → price rise. Return the top 3 plus `more`.
+- **Activity is built from existing tables. There is no event table.**
+ - **Statement imports:** rows with `source == "statement"`, grouped by `created_at` to the minute, give "Imported a statement · {n} rows".
+ - **Receipts:** other transactions give "Read a receipt: {vendor} {0}".
+ - **Proposal decisions:** `AuditEvent` rows with actions `proposal_approved` / `proposal_rejected` give "You approved: {label}" or "You rejected: {label}". The label map is `set_budget` → "a {category} budget" and `update_category` → "a category change"; anything else becomes "a suggestion".
+ - The newest 6 are returned. Events from the agent, and from superseded rejections, are excluded.
+- **The draft-budget review UI is a dialog on Home.** It lists the pending `set_budget` proposals using the existing `components/ask/proposal-card.tsx`, plus an "Approve all" button. A Budget page comes in Phase 5.
+
+## Wire formats (produced by Tasks 2–4, consumed by Task 5)
+
+```jsonc
+// GET /api/home/summary
+{ "success": true,
+ "found": [ { "kind": "duplicate|review|spike|price_rise", "tag": "DUPLICATES|RECEIPTS|SPENDING|SUBSCRIPTIONS",
+ "headline": "Possible double charge: Croma {0}", "detail": "Charged on 14 and 15 Oct.",
+ "amounts": [2499], "worth": 2499 | null, "href": "/receipts?tab=review" | null } ],
+ "more": 0,
+ "worth_total": 2499, // sum of non-null worth, 0 if none
+ "activity": [ { "kind": "statement_import|receipt|decision", "text": "Read a receipt: Croma {0}",
+ "amounts": [2499], "at": "2026-10-21T06:00:00Z" } ] }
+
+// GET /api/budget
+{ "success": true, "month": "2026-10", "monthly_budget": 28000 | null, "spent": 18412,
+ "categories": [ { "category": "Food", "limit": 8000, "spent": 7480 } ] } // sorted by spent/limit desc
+
+// PUT /api/budget body: { "monthly_budget"?: number|null, "categories"?: [{ "category": str, "limit": number|null }] }
+// limit null deletes that category's row; returns the GET shape
+
+// POST /api/budget/draft -> { "success": true, "proposals": [ <_proposal_dict>, ... ] } // may be []
+// GET /api/review?status=flagged&count=1 -> { "success": true, "count": 3 }
+```
+
+## Review Focus
+
+1. **A user with no transactions, or only one month of history:** `draft` returns `[]` (it doesn't divide by zero, and doesn't propose ₹0 limits), the summary returns `found: []`, and the budget `spent` is 0. Tests: `test_draft_needs_history`, `test_summary_empty_user`.
+2. **Another user's data never leaks:**
+ - Summary, budget and draft only read the caller's rows.
+ - Approving someone else's `set_budget` proposal gives 404, which already holds in `_decide`.
+ - Tests: `test_summary_is_per_user`, `test_budget_is_per_user`.
+3. **Double approval and races:** approving the same `set_budget` twice gives 409 and leaves one budget row. A unique `(user_id, category)` constraint plus an upsert keeps it to one row. Test: `test_set_budget_approve_twice`.
+4. **Category spelling:** "food", "Food " and "FOOD" are the same category for spikes, drafts and budget `spent`. Normalise with strip and case-fold for matching, and display the most common spelling. Test: `test_categories_normalise`.
+5. **Refunds and negative amounts:** they don't create "spikes", and a refund-heavy previous month doesn't invert the comparison. This also closes the backlog item: `sameDayDelta` guards `then <= 0`. Tests: `test_spike_ignores_refund_month` and Task 5 unit test `sameDayDelta guards a non-positive previous total`.
+
+## File map
+
+| File | Responsibility |
+|---|---|
+| `backend/models/__init__.py` | `class Budget` (table `budgets`: `id` str36 pk, `user_id` str36 indexed, `category` str, `monthly_limit` float, `updated_at` datetime; `UniqueConstraint(user_id, category)`) |
+| `backend/ai/budget.py` (new) | `draft_budget(txns: list[dict], today: date) -> list[dict]` (category, limit, basis) and `month_spend_by_category(txns, today) -> dict[str, float]` |
+| `backend/ai/home_signals.py` (new) | `find_signals(txns, today, review_count, recurring) -> list[dict]` and `activity(txns, audit_events, now) -> list[dict]` |
+| `backend/api/budget.py` (new) | `GET/PUT /api/budget`, `POST /api/budget/draft` |
+| `backend/api/home.py` (new) | `GET /api/home/summary` |
+| `backend/api/review.py` | `count` query param on the list |
+| `backend/api/agent.py` (`_decide`) | `set_budget` apply branch |
+| `backend/agent/tools.py` | `set_budget` in `ProposeActionArgs.type`, plus a `draft_budget` tool that creates the same proposals |
+| `backend/asgi.py` | Register the `home` and `budget` routers |
+| `backend/tests/test_budget.py`, `test_home_signals.py`, `test_api_home.py`, `test_api_budget.py` (new) | Tests |
+| `frontend/src/lib/api/client.ts` | `homeApi.summary()`, `budgetApi.get/put/draft()`, `reviewApi.count()` |
+| `frontend/src/app/(app)/home/*` | Wire the summary and budget into the cards; add a draft-budget dialog |
+| `frontend/src/components/app-sidebar.tsx` | Badge uses `reviewApi.count()` |
+| `frontend/e2e/fixtures.ts`, `e2e/home.spec.ts`, `e2e/home-math.spec.ts` | Stubs and tests |
+
+---
+
+### Task 1: `Budget` model + `draft_budget` (pure)
+
+**Files:** `models/__init__.py`, `ai/budget.py` (new), `tests/test_budget.py` (new)
+
+**Interfaces (produced):**
+- `Budget` model, as described in the file map.
+- `month_spend_by_category(txns: list[dict], year: int, month: int) -> dict[str, float]`.
+ - Keys are the display spelling (the most common original spelling).
+ - Matching uses `category.strip().casefold()`. `None` becomes "Uncategorised".
+ - Negative amounts are included in totals, so refunds net out.
+- `draft_budget(txns: list[dict], today: date) -> list[dict]` returns `[{"category", "limit", "basis": {"months": [..3 "YYYY-MM"], "average": float}}]`, sorted by `limit` descending, max 8.
+ - It uses the 3 complete months before `today`'s month.
+ - It returns `[]` if fewer than 2 of those months have any spend.
+
+- [ ] **Step 1: Write the failing tests:**
+ - `test_draft_rounds_up_to_500`: Food averages 7,120 over Jul, Aug and Sep, so limit = 7,500.
+ - `test_draft_small_values_round_to_10`: an average of 404 gives a limit of 410.
+ - `test_draft_skips_under_300`.
+ - `test_draft_caps_at_8_largest_first`.
+ - `test_draft_needs_history`: only last month has data, so `[]`.
+ - `test_draft_ignores_current_month`.
+ - `test_categories_normalise`: "food", "Food " and "FOOD" form one bucket, displayed as the most common spelling.
+ - `test_budget_unique_per_category`: inserting two `Budget` rows with the same user and category raises `IntegrityError` (SQLite engine, `create_all`).
+- [ ] **Step 2: Run** `pytest tests/test_budget.py -q`. The expected result is FAIL with an import error.
+- [ ] **Step 3: Implement** according to the Interfaces above.
+- [ ] **Step 4: Run** the tests again. All should pass.
+- [ ] **Step 5: Commit** with `feat: budgets table and deterministic draft budget`.
+
+### Task 2: Budget API + `set_budget` proposals (approve applies) + agent tool
+
+**Files:** `api/budget.py` (new), `api/agent.py` (`_decide`), `agent/tools.py`, `asgi.py`, `tests/test_api_budget.py` (new)
+
+**Interfaces:**
+- `router = APIRouter(prefix="/api/budget")`, with `get_engine` as a dependency (mirror `api/profile.py`).
+ - **`GET ""`** returns the wire shape above. `spent` is the total for the current calendar month, from `month_spend_by_category`. `categories` lists the user's `Budget` rows with this month's spend.
+ - **`PUT ""`** validates `monthly_budget >= 0` or `null` and each `limit > 0` or `null`. It sets `User.monthly_budget`, upserts or deletes `Budget` rows, and returns the GET shape. Invalid input gives a 422.
+ - **`POST "/draft"`** runs `draft_budget` over the user's transactions, rejects that user's pending `set_budget` proposals (audit `{"superseded": true}`), and creates one `Proposal(type="set_budget", target=`s and fetches the calendar and reminders twice. It goes away with the redesign. *(Phase 1 review)*
- [ ] **Phase 6.** iOS Safari 15 and older don't support `overflow-x: clip`, so wide landing marquees could scroll sideways there. If that matters, add an `@supports not (overflow-x: clip)` fallback (not `hidden` on body). *(Phase 1 review)*
- [ ] **Phase 4.** Ctrl/⌘+K checks `e.key === "k"`, so Caps Lock or Shift misses it. Use `e.code === "KeyK"`. *(Phase 1 review)*
- [ ] **Phase 6.** The sidebar cookie expires after 7 days (shadcn default), so a collapsed rail reopens after a week away. Extend it to about a year. *(Phase 1 review)*
-- [ ] **Phase 3.** Replace Home's interim parts: the brief (summary endpoint), Lumen found (subscriptions, spikes, duplicates), Activity (a real feed, including statement imports dated earlier than this month), and wire up Budget and the draft-budget promo. *(Phase 2)*
+- [x] **Phase 3.** Replace Home's interim parts: the brief (summary endpoint), Lumen found (subscriptions, spikes, duplicates), Activity (a real feed, including statement imports dated earlier than this month), and wire up Budget and the draft-budget promo. *(Phase 2)*
- [ ] **Later.** Home sub-tabs (Overview / This month / Year). The chart's range chips cover this for now. *(Phase 2 decision)*
- [ ] **Phase 5.** The Export and Upload buttons on Home still use the old loud blue `Button` and the dark upload dialog. Restyle them with the dialogs. *(Phase 2)*
-- [ ] **Phase 3.** The calendar's `kShort` hardcodes `₹`; use the user's currency symbol like `useMoney`. *(Phase 2 review)*
-- [ ] **Phase 3.** `sameDayDelta` only guards a zero previous total. A refund-heavy start of month (negative running total) flips the brief's "lighter"/"heavier". Use `then <= 0`. *(Phase 2 review)*
+- [x] **Phase 3.** The calendar's `kShort` hardcodes `₹`; use the user's currency symbol like `useMoney`. *(Phase 2 review)*
+- [x] **Phase 3.** `sameDayDelta` only guards a zero previous total. A refund-heavy start of month (negative running total) flips the brief's "lighter"/"heavier". Use `then <= 0`. *(Phase 2 review)*
- [ ] **Phase 6.** `CountUp` paints the final number for one frame before counting from 0. Start at 0 when it will animate. *(Phase 2 review)*
- [ ] **Phase 6.** Measure each Home card's real content height against its `skeletonHeight` (the spend card's is estimated at about 270px vs 262px); the CLS test threshold of 0.01 can hide a small shift. *(Phase 2 review)*
-- [ ] **Phase 3.** Confirm statement imports write rows before the POST returns. Otherwise a Home visit in that window caches stale totals for 5 minutes. *(Phase 2 review)*
+- [x] **Phase 3.** Confirm statement imports write rows before the POST returns. Confirmed: `api/statements.py` commits inside the request. *(Phase 2 review)*
+- [ ] **Later (owner request).** Make "Lumen found" and the budget draft smarter. A plain 3-month mean is skewed by one-off big purchases (a phone, a laptop). Ideas: a median or trimmed mean per category; treat single large purchases as one-offs and leave them out of the limit (or suggest a separate "planned purchases" line); use seasonality and income; let the agent (LLM) explain or adjust a draft line, while approval still stays with the user. Measure any new rule against an eval set before switching. *(Phase 3)*
+- [ ] **Phase 4.** `_decide` (api/agent.py) checks `status == pending` without a conditional UPDATE. Two concurrent approvals on Postgres can both apply (duplicate audit lines), and two approvals for the same new category can return 500 on the unique constraint. Use the conditional `update(...).where(status=="pending")` pattern from `_save_invoice`. *(Phase 3 review)*
+- [ ] **Phase 4.** Drafting a budget (HTTP or the agent tool) retires all pending `set_budget` proposals. A draft from Ask while Home's dialog is open makes that dialog's approvals return 409. Two quick drafts can leave two pending sets. Consider scoping by draft id and a per-user lock. *(Phase 3 review)*
+- [ ] **Phase 4.** After approving draft lines, Home reloads only the budget. Also reload the summary so Activity shows "You approved…" at once. *(Phase 3 review)*
+- [ ] **Phase 6.** `/api/home/summary` and `/api/budget` each scan all of a user's transactions in Python on every Home visit. Fine for a few thousand rows; filter by date in SQL (and cache) if a user grows past that. *(Phase 3 review)*
+- [ ] **Phase 5.** Draft dialog: per-line buttons are named just "Approve" / "Skip". Add the category to their accessible names. The `basis.months` list always shows 3 months even when only 2 had spend. *(Phase 3 review)*
- [ ] **Owner, manual.** In Firefox, check that the profile/avatar menu doesn't shift the Ask Lumen column, and click through the old pages on light in a demo session. *(Phase 0 PR #89)*
diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts
index e1efc5f..b0f8da6 100644
--- a/frontend/e2e/fixtures.ts
+++ b/frontend/e2e/fixtures.ts
@@ -11,6 +11,13 @@ export type HomeFixture = {
forecast?: { dates: string[]; values: number[] } | "fail";
delayMs?: number;
forecastDelayMs?: number;
+ /** GET /api/home/summary; default: just the review item when reviewCount > 0 */
+ summary?: { found: object[]; more?: number; worth_total?: number; activity?: object[] };
+ /** GET/PUT /api/budget; default: nothing set */
+ budget?: { monthly_budget: number | null; spent: number; categories: { category: string; limit: number; spent: number }[] };
+ /** POST /api/budget/draft proposals; default [] */
+ draft?: object[];
+ currency?: string;
};
export async function signIn(page: Page, opts: { reviewCount?: number; home?: HomeFixture } = {}): Promise
No budget yet
-Lumen can draft one from your last 3 months.
++ {spent > total + ? `${money(spent - total)} over` + : `${money(total - spent)} left · ${daysLeft} ${daysLeft === 1 ? "day" : "days"} · ~${money((total - spent) / daysLeft)}/day`} +
+ > + ) : cats.length === 0 ? ( + <> +No budget yet
+Lumen can draft one from your last 3 months.
+ > + ) : null} + {cats.length > 0 && ( +- {toReview} {toReview === 1 ? "receipt needs" : "receipts need"} a glance -
-- Lumen wasn't sure about these. Nothing is saved until you approve. -
- - Review → - -Nothing needs you right now.
+ ) : ( + <> +{fill(f.headline, f.amounts, money)}
+{fill(f.detail, f.amounts, money)}
+ {f.href && ( + + {f.kind === "review" ? "Review →" : "See →"} + + )} +