Skip to content
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())
4 changes: 2 additions & 2 deletions backend/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from fastapi.middleware.cors import CORSMiddleware

import app as flask_module
from api import agent, budget, demo, documents, errors, files, home, keys, profile, review, statements
from api import agent, bills, budget, demo, documents, errors, files, home, keys, profile, review, statements
from api.deps import llm_deadline
from config import Config

Expand Down Expand Up @@ -90,5 +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,
budget.router, bills.router, home.router,
keys.router)
15 changes: 15 additions & 0 deletions backend/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,21 @@ class Budget(db.Model):

__table_args__ = (db.UniqueConstraint("user_id", "category", name="u_budget_user_category"),)

class RecurringState(db.Model):
"""What the user said about a recurring charge on the Bills page (UI revamp Phase 5c): "cancelled" keeps it listed
(and flags a charge after the cancel), "not_a_bill" hides it. Keyed by ai.recurring._norm(vendor), the same key
the detection groups charges by. No row = active."""

__tablename__ = "recurring_states"

id = db.Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = db.Column(String(36), nullable=False, index=True)
vendor_key = db.Column(db.String, nullable=False)
state = db.Column(db.String(20), nullable=False) # cancelled | not_a_bill
changed_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)

__table_args__ = (db.UniqueConstraint("user_id", "vendor_key", name="u_recurring_state_user_vendor"),)


class AuditEvent(db.Model):
"""Append-only log of proposals and decisions (who, what, when)."""
Expand Down
107 changes: 107 additions & 0 deletions backend/tests/test_api_bills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""UI revamp Phase 5c: recurring charges for the Bills page, with the user's saved choices (cancelled / not a bill)."""
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, "email": f"{sub}@x.com"}, "k" * 32, algorithm="HS256")}


def ago(days):
return (date.today() - timedelta(days=days)).isoformat()


@pytest.fixture
def setup(tmp_path, monkeypatch):
import models # noqa: F401
import utils.auth
from api import bills
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 / 'bills.db'}")
flask_db.metadata.create_all(engine)
app = create_app(bills.router)
app.dependency_overrides[bills.get_engine] = lambda: engine
return TestClient(app), engine


def charge(engine, user, vendor, when, amount=649.0, category="Entertainment"):
from models import Transaction

with Session(engine) as s, s.begin():
s.add(Transaction(user_id=user, vendor_name=vendor, invoice_number=f"{user}-{vendor}-{when}", date=when,
total_amount=amount, category=category))


def monthly(engine, user, vendor, amount=649.0, category="Entertainment", latest=10):
for n in (latest + 60, latest + 30, latest):
charge(engine, user, vendor, ago(n), amount, category)


def items(c, user=A):
r = c.get("/api/bills/recurring", headers=bearer(user))
assert r.status_code == 200, r.text
return r.json()


def test_recurring_lists_active_charges_per_user(setup):
c, engine = setup
monthly(engine, A, "Netflix")
monthly(engine, B, "Spotify", 119)
[item] = items(c)["items"]
assert item["vendor"] == "Netflix" and item["cadence"] == "monthly" and item["state"] == "active"
assert items(c)["hidden"] == []
assert [i["vendor"] for i in items(c, B)["items"]] == ["Spotify"]


def test_cancel_then_charged_again(setup):
c, engine = setup
monthly(engine, A, "Netflix")
r = c.post("/api/bills/recurring/state", json={"vendor": "Netflix", "state": "cancelled"}, headers=bearer(A))
assert r.status_code == 200 and r.json()["items"][0]["state"] == "cancelled"
# cancelled yesterday, charged again today: the user should know the cancel didn't stick
from models import RecurringState

charge(engine, A, "Netflix", date.today().isoformat())
with Session(engine) as s, s.begin():
row = s.query(RecurringState).one()
row.changed_at = row.changed_at - timedelta(days=1)
assert items(c)["items"][0]["state"] == "charged_again"


def test_not_a_bill_hides_and_active_restores(setup):
c, engine = setup
monthly(engine, A, "Netflix")
r = c.post("/api/bills/recurring/state", json={"vendor": "NETFLIX", "state": "not_a_bill"}, headers=bearer(A))
body = r.json()
assert body["items"] == [] and [h["vendor"] for h in body["hidden"]] == ["Netflix"]
r = c.post("/api/bills/recurring/state", json={"vendor": "Netflix", "state": "active"}, headers=bearer(A))
assert [i["state"] for i in r.json()["items"]] == ["active"] and r.json()["hidden"] == []


def test_states_are_per_user_and_match_vendor_spelling(setup):
c, engine = setup
monthly(engine, A, "Netflix.com")
monthly(engine, B, "Netflix.com")
c.post("/api/bills/recurring/state", json={"vendor": "netflix com", "state": "cancelled"}, headers=bearer(A))
assert items(c)["items"][0]["state"] == "cancelled"
assert items(c, B)["items"][0]["state"] == "active"


def test_state_input_is_validated(setup):
c, _ = setup
assert c.post("/api/bills/recurring/state", json={"vendor": "X", "state": "deleted"}, headers=bearer(A)).status_code == 422
assert c.post("/api/bills/recurring/state", json={"vendor": "", "state": "cancelled"}, headers=bearer(A)).status_code == 422
assert c.get("/api/bills/recurring").status_code == 401
15 changes: 12 additions & 3 deletions docs/CODEBASE-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,16 +298,25 @@ embeds the demo documents once per server and uploads the files in the backgroun
pure in `lib/spending-math.ts` (tests: `e2e/spending-math.spec.ts`); data from `GET /transactions` (which now
includes `source`) and `/api/home/summary` (spikes link to `/spending?category=<name>`). The old Analytics and
AI Insights pages, their components and the API calls only they used are gone.
- **Bills & subscriptions** (`app/(app)/bills/`, Phase 5c): "what's due, what recurs, what can I cut?" Everything
comes from `GET /api/bills/recurring` (`api/bills.py`): `ai/recurring.find_recurring` over the user's transactions
plus their saved choices in `recurring_states` (keyed by the detection's normalised vendor). `not_a_bill` moves a
vendor to `hidden`; `cancelled` keeps it listed as cancelled, or `charged_again` if a charge lands after the
cancel day; posting `active` forgets the choice (`POST /api/bills/recurring/state`). The page: three tiles (due in
30 days, recurring per month with weekly/yearly spread, subscriptions that look stopped), a six-row month calendar
with each bill on its projected days (‹ month › switcher), Upcoming grouped This week / Next week / Later, and the
table with tabs, price-rise / looks-stopped / charged-again pills, Mark as cancelled, Not a bill, and How to cancel
(`/ask?draft=How do I cancel X?`). Maths in `lib/bills-math.ts` (tests: `e2e/bills-math.spec.ts`).
- **Home** (`app/(app)/home/`, Home v2 from Phase 5a, modelled on Origin's app home): greeting, quiet Upload and
Export, and a serif brief over a sky band. Then a full-width hero: Spent in <Month> (total, change vs the previous
period by the same day, "heading for" from the forecast, smooth now-vs-before lines, bill markers on the axis,
1W/1M/3M/YTD, `+` opens upload). Below it, the main column has "<Month>, day by day" (amount per day, today
outlined, upcoming bill names on future days, always as tall as a six-week month) beside Recent transactions, then
Lumen found as a list whose links act ("Ask Lumen" opens `/ask?draft=` with the question typed but not sent). The
side column has Budget in <Month> (spent of total, % bar, top four categories by spend), Coming up (next four
bills), the draft-budget promo and Activity. `use-home-data.ts` fetches today's endpoints in parallel (transactions, reminders, forecast, review
queue). `/api/analytics/reminders` sends `title`/`predicted_date`/`metadata`; `toReminders` turns that into bills
once (vendor patterns only, rows without a date skipped). A failed call empties only its card. Skeletons show only on the first load, so changing the chart range
bills), the draft-budget promo and Activity. `use-home-data.ts` fetches today's endpoints in parallel (transactions, recurring
charges, forecast, review queue). Bills come from `GET /api/bills/recurring`: `toBills` keeps active ones that
don't look stopped and are due in the next 31 days. 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`).
**Phase 3 backend:** `GET /api/home/summary` (`api/home.py` over the pure `ai/home_signals.py`, no LLM) supplies
Lumen found and Activity:
Expand Down
17 changes: 17 additions & 0 deletions docs/direction/STORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,20 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in
tests; the e2e fixture now copies its data per test after a PUT leaked between tests. A screenshot against the
approved mockup caught the wrapping period chips on phones, which a test then pinned.

## 34. Bills & subscriptions you can act on (UI revamp, Phase 5c)
- **S:** Bills had only the payment calendar and reminders cards, which read fields the backend never sent, and Home's
"Coming up" only had rows after a heavy analysis had run. Lumen already detected recurring charges well, but only the
agent and Home's signals used it.
- **T:** One page answering "what's due, what recurs, what can I cut?" from that detection, where telling Lumen
"cancelled" or "that's not a bill" is remembered, and noticing when a cancel didn't stick.
- **A:** A small `/api/bills` (TDD): recurring charges plus a `recurring_states` table keyed by the detection's own
normalised vendor, so "NETFLIX.com" and "Netflix com" are one choice; a charge after the cancel day flips the row to
"charged again". Home's bills switched to it. Pure maths (`lib/bills-math.ts`: monthly equivalents, projected due days
per cadence, a 30-day agenda, the tiles) under unit tests, then the page: tiles, a six-row month calendar that never
changes height, Upcoming, and a table whose actions save in place, with Undo for cancelled and hidden vendors and
"How to cancel" opening Ask with the question typed. Removed the interim list and the last caller of the old reminders.
- **R:** Backend 986 passed (5 new: per-user states, cancel → charged again, hide/restore, vendor spelling,
validation). Frontend: 10 Bills e2e checks (tiles, the calendar across months without moving, groups, tabs, cancel and
undo, hide and undo, the Ask link, CLS under 0.01, tabs on one line at 375 px) and 4 maths tests. A screenshot against
the mockup caught the wrapping tabs on phones, now pinned by a test.

Loading
Loading