Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
7bc006f
fix: owner feedback on the revamp shell, labels and Ask chips
coderconnoisseur Oct 21, 2026
43e7476
docs: Phase 5 design addendum (Home v2, Spending, Bills, Budget, Rece…
coderconnoisseur Oct 21, 2026
d13376c
docs: Phase 5 implementation plan (5a detailed, 5b-5e outlined)
coderconnoisseur Oct 21, 2026
ff30d4f
feat: shared page header and card for the Phase 5 pages
coderconnoisseur Oct 21, 2026
ce1416e
feat: smooth monotone chart path and tick picking
coderconnoisseur Oct 21, 2026
877f444
feat: Home hero card in the Origin style
coderconnoisseur Oct 21, 2026
1b0c2b4
fix: Ask rail busy/focus/touch, palette outside the sheet, one-commit…
coderconnoisseur Oct 21, 2026
8568550
feat: day-by-day calendar with amounts and bill names
coderconnoisseur Oct 21, 2026
a60c4de
feat: Lumen found list with real actions; /ask?draft= prefills
coderconnoisseur Oct 22, 2026
9c13ab8
feat: Home v2 layout, budget and coming-up cards
coderconnoisseur Oct 22, 2026
09f27ca
fix: calendar amounts readable on phones
coderconnoisseur Oct 22, 2026
201a570
fix: e2e dev server builds into .next-e2e so it can't break npm run dev
coderconnoisseur Oct 22, 2026
94916a4
chore: include the e2e build's route types in tsconfig
coderconnoisseur Oct 22, 2026
1dbaf13
fix: Home reads reminders in the shape the API sends (crash on a date…
coderconnoisseur Oct 22, 2026
ca08865
fix: drop the Spending/Bills cards that read fields the backend never…
coderconnoisseur Oct 22, 2026
74995e9
feat: quiet Upload/Export and light upload and export dialogs
coderconnoisseur Oct 22, 2026
b9b9e49
docs: STORY 32, codebase guide and spec backlog for Phase 5a
coderconnoisseur Oct 22, 2026
79d84bb
fix: axis labels compare real edges; 3M/YTD labels name the month
coderconnoisseur Oct 22, 2026
43613a7
fix: an answer for an older chat can't take over the chat on screen
coderconnoisseur Oct 22, 2026
bd632eb
docs: Phase 5b (Spending) plan in detail
coderconnoisseur Oct 22, 2026
77902d4
feat: transactions list includes source
coderconnoisseur Oct 22, 2026
aa983ce
feat: spending maths (periods, categories, merchants, monthly totals)
coderconnoisseur Oct 22, 2026
26c9b98
feat: Spending page shell, period control and data
coderconnoisseur Oct 22, 2026
77db87c
feat: Spending hero with the shared running-total chart and month bars
coderconnoisseur Oct 22, 2026
89783ee
feat: Spending by-category filter and Lumen noticed; spikes link to t…
coderconnoisseur Oct 22, 2026
db2bceb
feat: Spending transactions table with in-place category edits, top m…
coderconnoisseur Oct 22, 2026
a7b2d22
chore: remove the old Analytics/AI Insights pages and the API calls o…
coderconnoisseur Oct 22, 2026
7d3fdcd
fix: Spending period chips stay on one line on phones
coderconnoisseur Oct 22, 2026
10f9d24
docs: STORY 33, codebase guide and spec backlog for Phase 5b
coderconnoisseur Oct 22, 2026
6d74799
docs: Phase 5c (Bills) plan in detail
coderconnoisseur Oct 22, 2026
58f58f4
feat: recurring charges API with saved cancelled / not-a-bill choices
coderconnoisseur Oct 22, 2026
d8a5fd4
feat: Home's bills come from the recurring charges API
coderconnoisseur Oct 22, 2026
1fa94ff
feat: bills maths (monthly equivalents, due days, upcoming groups, ti…
coderconnoisseur Oct 22, 2026
35dd34c
feat: Bills & subscriptions page (tiles, bill calendar, upcoming, can…
coderconnoisseur Oct 22, 2026
7d4b471
fix: Bills tabs stay on one line on phones; drop the old reminders cl…
coderconnoisseur Oct 22, 2026
ea286f2
docs: STORY 34, codebase guide and spec backlog for Phase 5c
coderconnoisseur Oct 22, 2026
9d97853
docs: detail the Phase 5d plan (Budget page)
coderconnoisseur Oct 22, 2026
8a2eff0
feat: budgets are saved per month; month and history endpoints; draft…
coderconnoisseur Oct 22, 2026
be83621
feat: Budget page (pace hero, 6-month history, inline category edits,…
coderconnoisseur Oct 22, 2026
990f969
docs: STORY 35, codebase guide and spec backlog for Phase 5d
coderconnoisseur Oct 22, 2026
b08666f
docs: detail the Phase 5e plan (Receipts & statements)
coderconnoisseur Oct 22, 2026
d32db18
feat: keep a history of statement imports (GET /api/statements/imports)
coderconnoisseur Oct 22, 2026
cf5d2af
feat: Receipts & statements page (review cards, one library, statemen…
coderconnoisseur Oct 22, 2026
748dc97
docs: STORY 36, codebase guide and spec backlog for Phase 5e
coderconnoisseur Oct 22, 2026
b3f1fa5
fix: Phase 5 review findings (receipts filter, past-month and stale-d…
coderconnoisseur Oct 22, 2026
0a509fa
fix: Phase 5 review minors (refund bars, bill calendar range, UTC tim…
coderconnoisseur Oct 22, 2026
80dc90c
docs: tick the Phase 5 review minors in the backlog
coderconnoisseur Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion QUICK_START.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ alembic upgrade head
```

```bash
python app.py
python -m uvicorn asgi:app --port 5000
```

API at http://localhost:5000
Expand Down
6 changes: 5 additions & 1 deletion backend/agent/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ class AgentState(TypedDict):
_ASKS_USER = re.compile(r"(would you|do you (want|need|wish)|shall i|want me|would it help|is there anything|"
r"anything else|let me know|how about i|you could|you can|tell me (which|if|whether)|if you('d| would) like|"
r"feel free)\b", re.IGNORECASE)
# A step the user does themselves ("Connect your Gmail…") isn't a request to Lumen either: as a chip it was sent to the
# chat (owner report, 2026-10-22). Uploads are the exception: the UI turns an "Upload…" chip into the upload button.
_USER_STEP = re.compile(r"(connect|go to|log ?in|sign ?in|visit|click|tap|open (the|your)|check your|forward)\b",
re.IGNORECASE)


def split_follow_up(text: str) -> tuple[str, str | None]:
Expand All @@ -65,7 +69,7 @@ def split_follow_up(text: str) -> tuple[str, str | None]:
answer, follow_up = (text[:m.start()] + text[m.end():]).strip(), m.group(1).strip(" \t*_\"'“”‘’")
if not follow_up: # a bare "Next:"
return answer, None
if _ASKS_USER.match(follow_up):
if _ASKS_USER.match(follow_up) or _USER_STEP.match(follow_up):
return f"{answer}\n\n{follow_up}", None
return answer, follow_up

Expand Down
2 changes: 1 addition & 1 deletion backend/agent/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
- Write naturally, like a knowledgeable friend: lead with the answer, then a sentence or two of useful context.
Use a short list or table only when it helps.
- If you need to ask the user something, ask it in the answer itself. Then end with one short request the user
might send next, in their own words (e.g. "Show last month by category"), never a question to them, on its own
might send next, in their own words (e.g. "Show last month by category"), never a question to them or a step they must do in the app, on its own
last line, starting "Next: "."""

FORCE_ANSWER = ("You have used the maximum number of tool calls. Answer now from the information above, "
Expand Down
12 changes: 6 additions & 6 deletions backend/ai/budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ def _round_up(avg: float) -> int:


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.
"""One line per category: the mean over the 3 complete months before today's (only those with any spend, which
are the ones basis.months lists), 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
Expand All @@ -65,15 +65,15 @@ def draft_budget(txns: list[dict], today: date) -> list[dict]:
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:
spent = sorted({mo for (_, mo), v in totals.items() if v}) # months with any spend (a new user may have only 2)
if len(spent) < 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
avg = sum(totals.get((key, mo), 0.0) for mo in months) / len(spent)
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)}})
"basis": {"months": [f"{yy}-{mm:02d}" for yy, mm in spent], "average": round(avg, 2)}})
lines.sort(key=lambda l: l["limit"], reverse=True)
return lines[:MAX_LINES]
6 changes: 4 additions & 2 deletions backend/ai/home_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json
from collections import defaultdict
from datetime import date, datetime, timedelta
from urllib.parse import quote

from ai.budget import _display_names, _key
from ai.recurring import _norm
Expand Down Expand Up @@ -90,7 +91,8 @@ def _spikes(txns: list[dict], today: date) -> list[dict]:
pct = round((n - p) / p * 100)
out.append((pct, {"kind": "spike", "tag": "SPENDING", "subject": names[k], "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"}))
"amounts": [round(n), round(p)], "worth": None,
"href": f"/spending?category={quote(names[k])}"}))
return [s for _, s in sorted(out, key=lambda x: -x[0])]


Expand All @@ -113,7 +115,7 @@ def find_signals(txns: list[dict], today: date, review_count: int, recurring: li
review = [{"kind": "review", "tag": "RECEIPTS", "subject": None,
"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"}]
"amounts": [], "worth": None, "href": "/receipts"}]
return _duplicates(txns, today) + review + _spikes(txns, today) + _price_rises(recurring, today)


Expand Down
17 changes: 10 additions & 7 deletions backend/api/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ def _save_invoice(proposal_id: str, user_id: str, deps: AgentDeps) -> dict | Non
if not session.execute(update(Proposal).where(
Proposal.id == proposal_id, Proposal.user_id == user_id, Proposal.status == "pending")
.values(status="saving")).rowcount:
raise HTTPException(409, f"Proposal is already {p.status}")
raise HTTPException(409, "This proposal was already decided.")
payload = json.loads(p.payload or "{}")
try:
item = submit_invoice(deps.engine, user_id, payload, warnings_seen=True)
Expand Down Expand Up @@ -148,25 +148,28 @@ def _decide(proposal_id: str, user_id: str, decision: str, deps: AgentDeps) -> d
if p is None:
raise HTTPException(404, "Proposal not found")
# claim it first (UPDATE ... WHERE status = 'pending'): of two concurrent decisions only one can apply;
# anything raised below rolls the claim back with the rest of the transaction
# anything raised below rolls the claim back with the rest of the transaction. The 409 doesn't name p.status:
# it was read before the claim, so the loser of a race would still see "pending"
now = datetime.utcnow()
if not session.execute(update(Proposal).where(
Proposal.id == proposal_id, Proposal.user_id == user_id, Proposal.status == "pending")
.values(status=decision, decided_at=now).execution_options(synchronize_session=False)).rowcount:
raise HTTPException(409, f"Proposal is already {p.status}")
raise HTTPException(409, "This proposal was already decided.")
p.status, p.decided_at = decision, now
applied = None
if decision == "approved" and p.type == "set_budget":
from api.budget import upsert_budget
from api.budget import this_month, upsert_budget, valid_month

# 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"])
payload = json.loads(p.payload or "{}")
limit = float(payload["limit"])
except (KeyError, TypeError, ValueError):
limit = math.nan
payload, 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)
# from the month the draft was for, but never earlier than this one: past months stay as they were
upsert_budget(session, user_id, p.target.strip(), limit, max(valid_month(payload.get("month")) or "", this_month()))
applied = {"category": p.target.strip(), "limit": limit}
if decision == "approved" and p.type == "update_category":
category = json.loads(p.payload or "{}").get("category")
Expand Down
72 changes: 72 additions & 0 deletions backend/api/bills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""UI revamp Phase 5c: recurring charges for the Bills page and Home's "Coming up".

Detection is ai/recurring.find_recurring over the user's transactions (pure, no LLM). The user's choices live in
recurring_states: "not_a_bill" moves a vendor to `hidden`, "cancelled" keeps it listed as cancelled unless it was
charged again after the cancel ("charged_again"). Posting "active" forgets the choice.
"""
from __future__ import annotations

from datetime import date, datetime
from typing import Literal

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 ai.recurring import _norm, find_recurring
from api.deps import current_user, rate_limit
from api.profile import get_engine

router = APIRouter(prefix="/api/bills", tags=["bills"])


class StateChange(BaseModel):
vendor: str = Field(min_length=1, max_length=200)
state: Literal["cancelled", "not_a_bill", "active"]


def _recurring(engine: Engine, user_id: str, today: date) -> dict:
from models import RecurringState

with engine.connect() as conn:
rows = [dict(r) for r in conn.execute(text(
"SELECT vendor_name, date, total_amount, category FROM transactions WHERE user_id = :u"), {"u": user_id}).mappings()]
with Session(engine) as session:
states = {s.vendor_key: s for s in session.scalars(select(RecurringState).where(RecurringState.user_id == user_id))}
items, hidden = [], []
for item in find_recurring(rows, today):
key = _norm(item["vendor"])
saved = states.get(key)
if saved and saved.state == "not_a_bill":
hidden.append({"vendor": item["vendor"], "vendor_key": key})
continue
state = "active"
if saved and saved.state == "cancelled":
state = "charged_again" if item["last_charge_date"] > saved.changed_at.date().isoformat() else "cancelled"
items.append({**item, "state": state})
return {"success": True, "items": items, "hidden": hidden}


@router.get("/recurring", dependencies=[Depends(rate_limit())])
def recurring(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
return _recurring(engine, claims["sub"], date.today())


@router.post("/recurring/state", dependencies=[Depends(rate_limit())])
def set_state(body: StateChange, claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
from models import RecurringState

user_id, key = claims["sub"], _norm(body.vendor)
with Session(engine) as session, session.begin():
row = session.scalars(select(RecurringState).where(RecurringState.user_id == user_id,
RecurringState.vendor_key == key)).first()
if body.state == "active":
if row:
session.delete(row)
elif row:
row.state, row.changed_at = body.state, datetime.utcnow()
else:
session.add(RecurringState(user_id=user_id, vendor_key=key, state=body.state))
return _recurring(engine, user_id, date.today())
Loading
Loading