Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 backend/ai/home_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,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
4 changes: 2 additions & 2 deletions backend/api/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ def _decide(proposal_id: str, user_id: str, decision: str, deps: AgentDeps) -> d
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.")
# from the month the draft was for (a draft made last month still starts there), else this month
upsert_budget(session, user_id, p.target.strip(), limit, valid_month(payload.get("month")) or this_month())
# 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
31 changes: 24 additions & 7 deletions backend/api/budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ def _shift(month: str, n: int) -> str:
return f"{y}-{m + 1:02d}"


def near_month(month: str | None) -> str:
"""`month` if it's the server's month or next to it, else 422; None means the server's month."""
from fastapi import HTTPException

now = this_month()
if month is None or month == now:
return now
if month in (_shift(now, -1), _shift(now, 1)):
return month
raise HTTPException(422, "Budgets can only be changed for the current month.")


def limits_in_force(rows, month: str) -> dict[str, tuple[str, float]]:
"""Case-folded category -> (name, limit) for `month`: each category's latest row from that month or before.
A limit of 0 is a removal, so the category has none."""
Expand All @@ -53,6 +65,8 @@ class CategoryLimit(BaseModel):

class BudgetUpdate(BaseModel):
monthly_budget: float | None = Field(default=None, ge=0)
# the browser's month: around midnight on the 1st it can be a month off the server's (UTC) date
month: str | None = Field(default=None, pattern=MONTH_RE)
categories: list[CategoryLimit] = Field(default_factory=list, max_length=50)


Expand Down Expand Up @@ -83,10 +97,13 @@ def _state(engine: Engine, user_id: str, month: str) -> dict:
by_key = {k.casefold(): v for k, v in spend.items()}
with Session(engine) as session:
user = session.get(User, user_id)
limits = limits_in_force(_rows(session, user_id), month)
cats = [{"category": name, "limit": limit, "spent": round(by_key.get(key, 0), 2)}
for key, (name, limit) in limits_in_force(_rows(session, user_id), month).items()]
for key, (name, limit) in limits.items()]
cats.sort(key=lambda c: c["spent"] / c["limit"], reverse=True)
return {"success": True, "month": month, "monthly_budget": user.monthly_budget if user else None,
# the overall budget isn't versioned, so a past month only has it once category limits were in force
overall = user.monthly_budget if user and (limits or month >= this_month()) else None
return {"success": True, "month": month, "monthly_budget": overall,
"spent": round(sum(spend.values()), 2), "categories": cats}


Expand Down Expand Up @@ -140,13 +157,13 @@ def get_budget(month: str | None = Query(default=None, pattern=MONTH_RE), claims


@router.get("/history", dependencies=[Depends(rate_limit())])
def get_history(months: int = Query(default=6, ge=1, le=12), claims: dict = Depends(current_user),
engine: Engine = Depends(get_engine)):
def get_history(months: int = Query(default=6, ge=1, le=12), to: str | None = Query(default=None, pattern=MONTH_RE),
claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
"""Budget vs spent for the last `months` months, oldest first, ending with this one. A past month with no
category limit in force has no budget (null): spend only."""
from models import User

user_id, now = claims["sub"], this_month()
user_id, now = claims["sub"], near_month(to)
with Session(engine) as session:
user = session.get(User, user_id)
overall = user.monthly_budget if user else None
Expand All @@ -158,7 +175,7 @@ def get_history(months: int = Query(default=6, ge=1, le=12), claims: dict = Depe
out = []
for month in (_shift(now, -n) for n in range(months - 1, -1, -1)):
limits = limits_in_force(rows, month)
budget = (overall or round(sum(v for _, v in limits.values()), 2) or None) if limits or month == now else None
budget = (overall or round(sum(v for _, v in limits.values()), 2) or None) if limits or month >= now else None
spent = sum(month_spend_by_category(txns, int(month[:4]), int(month[5:])).values())
out.append({"month": month, "budget": budget, "spent": round(spent, 2)})
return {"success": True, "months": out}
Expand All @@ -167,7 +184,7 @@ def get_history(months: int = Query(default=6, ge=1, le=12), claims: dict = Depe
@router.put("", dependencies=[Depends(rate_limit())])
def put_budget(body: BudgetUpdate, claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
"""Changes apply from the current month on; past months keep the limits they had."""
user_id, month = claims["sub"], this_month()
user_id, month = claims["sub"], near_month(body.month)
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
Expand Down
13 changes: 12 additions & 1 deletion backend/api/review.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
from __future__ import annotations

import json
import math
import uuid
from datetime import date, datetime
from functools import lru_cache

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from sqlalchemy import create_engine, func, select
from sqlalchemy.engine import Engine
from sqlalchemy.exc import IntegrityError
Expand Down Expand Up @@ -43,6 +44,16 @@ class Decision(BaseModel):
edits: dict = {}
note: str | None = None

@field_validator("edits")
@classmethod
def _total_is_an_amount(cls, edits: dict) -> dict:
# an edited total becomes the transaction's amount: it has to be a real, positive number
if "total_amount" in edits:
v = edits["total_amount"]
if isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) or v <= 0:
raise ValueError("total_amount must be a positive number")
return edits


def _history(session: Session, user_id: str) -> list[dict]:
"""The user's review decisions, newest first, for SPEC-FEEDBACK loop B."""
Expand Down
30 changes: 27 additions & 3 deletions backend/api/statements.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,14 @@ def preview(file: UploadFile = File(...), claims: dict = Depends(current_user)):

@router.post("/import", dependencies=[Depends(rate_limit("10 per minute"))])
def import_statement(file: UploadFile = File(...), mapping: str = Form(...), currency: str | None = Form(None),
header_line: int | None = Form(None), claims: dict = Depends(current_user),
engine: Engine = Depends(get_engine)):
header_line: int | None = Form(None), label: str | None = Form(None),
claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
from config import Config
from models import Transaction, User
from models import StatementImport, Transaction, User

label = (label or "").strip() or None
if label and len(label) > 60:
raise HTTPException(400, "The account name is too long (60 characters at most).")

try:
parsed, header = _load(file)
Expand Down Expand Up @@ -127,6 +131,26 @@ def import_statement(file: UploadFile = File(...), mapping: str = Form(...), cur
category=vendor.category(name), currency=code, source="statement",
import_key=key, invoice_number=None))
imported += 1
days = sorted(ln.date for ln in ok)
session.add(StatementImport(user_id=user_id, file_name=file.filename, label=label,
period_from=days[0] if days else None, period_to=days[-1] if days else None,
lines=len(lines), imported=imported, matched=matched, duplicates=duplicates,
credits=credits, unreadable=len(unreadable)))
session.commit()
return {"success": True, "imported": imported, "duplicates": duplicates, "matched": matched,
"credits": credits, "unreadable": unreadable}


@router.get("/imports", dependencies=[Depends(rate_limit())])
def imports(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
"""The user's statement imports, newest first (Receipts & statements page)."""
from models import StatementImport

with Session(engine) as session:
rows = session.scalars(select(StatementImport).where(StatementImport.user_id == claims["sub"])
.order_by(StatementImport.created_at.desc())).all()
return {"success": True, "imports": [
{"id": r.id, "file_name": r.file_name, "label": r.label, "period_from": r.period_from,
"period_to": r.period_to, "lines": r.lines, "imported": r.imported, "matched": r.matched,
"duplicates": r.duplicates, "credits": r.credits, "unreadable": r.unreadable,
"created_at": r.created_at.isoformat()} for r in rows]}
21 changes: 21 additions & 0 deletions backend/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,3 +408,24 @@ class UserKey(db.Model):
last_error = db.Column(Text)

__table_args__ = (db.UniqueConstraint("user_id", "provider", name="u_user_key"),)


class StatementImport(db.Model):
"""One bank-statement import (UI revamp Phase 5e), for the Receipts page's history. Counts are as the import
returned them ("matched" is at import time; a re-import is a new row). The CSV itself isn't kept."""

__tablename__ = "statement_imports"

id = db.Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = db.Column(String(36), nullable=False, index=True)
file_name = db.Column(db.String)
label = db.Column(db.String(60))
period_from = db.Column(db.String(10))
period_to = db.Column(db.String(10))
lines = db.Column(db.Integer, nullable=False, default=0)
imported = db.Column(db.Integer, nullable=False, default=0)
matched = db.Column(db.Integer, nullable=False, default=0)
duplicates = db.Column(db.Integer, nullable=False, default=0)
credits = db.Column(db.Integer, nullable=False, default=0)
unreadable = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
4 changes: 4 additions & 0 deletions backend/routes/database_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def get_transactions(user_id=None):
vendor = request.args.get('vendor')
min_amount = request.args.get('min_amount', type=float)
max_amount = request.args.get('max_amount', type=float)
exclude_source = request.args.get('exclude_source') # e.g. "statement": Receipts lists files, not bank lines

# Get sorting parameters
sort_by = request.args.get('sort_by', 'created_at')
Expand All @@ -60,6 +61,9 @@ def get_transactions(user_id=None):
if vendor:
query = query.filter(Transaction.vendor_name.like(f'%{vendor}%'))

if exclude_source:
query = query.filter(or_(Transaction.source.is_(None), Transaction.source != exclude_source))

if min_amount is not None:
query = query.filter(Transaction.total_amount >= min_amount)

Expand Down
42 changes: 42 additions & 0 deletions backend/tests/test_api_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,3 +260,45 @@ def test_approving_a_draft_writes_it_from_the_drafts_month(setup):
c.post(f"/api/agent/proposals/{food['id']}/approve", headers=bearer(A))
with Session(engine) as s:
assert [(b.category, b.effective_from) for b in s.scalars(select(Budget))] == [("Food", ym(0))]


def test_a_past_month_without_limits_has_no_overall_budget(setup):
"""The overall budget isn't versioned: a month before any category limit shows spend only, like history."""
c, engine = setup
c.put("/api/budget", headers=bearer(A), json={"monthly_budget": 50000})
assert c.get(f"/api/budget?month={ym(3)}", headers=bearer(A)).json()["monthly_budget"] is None
put_row(engine, A, "Food", 3000, ym(2))
assert c.get(f"/api/budget?month={ym(2)}", headers=bearer(A)).json()["monthly_budget"] == 50000
assert c.get("/api/budget", headers=bearer(A)).json()["monthly_budget"] == 50000


def test_approving_last_months_draft_applies_from_this_month(setup):
import json

from models import Budget, Proposal

c, engine = setup
history(engine, A)
food = next(p for p in c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"] if p["target"] == "Food")
with Session(engine) as s, s.begin(): # a draft left pending since last month
p = s.get(Proposal, food["id"])
p.payload = json.dumps({**json.loads(p.payload), "month": ym(1)})
c.post(f"/api/agent/proposals/{food['id']}/approve", headers=bearer(A))
with Session(engine) as s:
assert [b.effective_from for b in s.scalars(select(Budget))] == [ym(0)]


def test_writes_can_name_the_clients_month_within_one_of_the_servers(setup):
"""Around midnight on the 1st the browser and server can be a month apart: the client's month wins if adjacent."""
from models import Budget

c, engine = setup
nxt = date.today().replace(day=1) + __import__("datetime").timedelta(days=32)
nxt = nxt.strftime("%Y-%m")
body = c.put("/api/budget", headers=bearer(A), json={"month": nxt, "categories": [{"category": "Food", "limit": 900}]}).json()
assert body["month"] == nxt
with Session(engine) as s:
assert [b.effective_from for b in s.scalars(select(Budget))] == [nxt]
assert c.put("/api/budget", headers=bearer(A), json={"month": ym(2), "categories": []}).status_code == 422
assert c.get(f"/api/budget/history?months=2&to={nxt}", headers=bearer(A)).json()["months"][-1]["month"] == nxt
assert c.get(f"/api/budget/history?months=2&to={ym(3)}", headers=bearer(A)).status_code == 422
7 changes: 7 additions & 0 deletions backend/tests/test_api_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,10 @@ def test_list_count_only(client):
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}


@pytest.mark.parametrize("total", [0, -5, None, "lots", True])
def test_an_edit_cannot_approve_a_bad_total(client, total):
item = client.post("/api/review", headers=bearer(ALICE), json={"invoice": invoice(total_amount=999.0)}).json()["item"]
r = client.post(f"/api/review/{item['id']}/approve", headers=bearer(ALICE), json={"edits": {"total_amount": total}})
assert r.status_code == 422 and _txn_count(client.engine, ALICE) == 1
28 changes: 28 additions & 0 deletions backend/tests/test_api_statements.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,31 @@ def test_bad_currency_is_400(client):
c, _ = client
m = json.dumps({"date": 0, "description": 1, "amount": 2})
assert post(c, "import", mapping=m, currency="RUPEES").status_code == 400


def history(c, who=USER):
r = c.get("/api/statements/imports", headers=bearer(who))
assert r.status_code == 200, r.text
return r.json()["imports"]


def test_each_import_is_kept_in_history(client):
"""UI revamp Phase 5e: one history row per import, with the period and the counts it returned."""
c, _ = client
m = json.dumps(post(c, "preview").json()["mapping"])
post(c, "import", mapping=m, label=" HDFC Savings ··4417 ")
post(c, "import", mapping=m)
second, first = history(c)
assert first["label"] == "HDFC Savings ··4417" and first["file_name"] == "s.csv"
assert (first["period_from"], first["period_to"]) == ("2026-10-03", "2026-10-06")
assert (first["lines"], first["imported"], first["duplicates"], first["matched"], first["credits"], first["unreadable"]) \
== (3, 2, 0, 0, 1, 0)
assert second["label"] is None and (second["imported"], second["duplicates"]) == (0, 2)
assert first["created_at"] and history(c, OTHER) == []


def test_history_needs_auth_and_label_is_capped(client):
c, _ = client
assert c.get("/api/statements/imports").status_code == 401
m = json.dumps(post(c, "preview").json()["mapping"])
assert post(c, "import", mapping=m, label="x" * 61).status_code == 400
2 changes: 1 addition & 1 deletion backend/tests/test_home_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def test_order_and_review_item():
assert kinds(items) == ["duplicate", "review", "spike", "price_rise"]
assert items[1] == {"kind": "review", "tag": "RECEIPTS", "subject": None, "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"}
"amounts": [], "worth": None, "href": "/receipts"}
assert find_signals([], TODAY, 1, [])[0]["headline"] == "1 receipt needs a glance"


Expand Down
15 changes: 15 additions & 0 deletions backend/tests/test_invoice_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,3 +621,18 @@ def test_the_transaction_list_says_where_each_row_came_from(client, ocr):
listed = client.get("/transactions", headers=AUTH).get_json()
[tx] = [t for t in listed["data"] if t["id"] == body["transaction_id"]]
assert tx["source"] == "statement"


def test_the_list_can_leave_out_statement_lines(client, ocr):
# Receipts & statements lists files, not bank lines: one big import must not push the receipts off the page
from models import Transaction, db

body = _upload(client, _png_bytes()).get_json()
with client.application.app_context():
for i in range(3):
db.session.add(Transaction(user_id=db.session.get(Transaction, body["transaction_id"]).user_id,
vendor_name=f"Line {i}", date="2026-10-01", total_amount=1, source="statement",
import_key=f"k{i}"))
db.session.commit()
listed = client.get("/transactions?exclude_source=statement&page_size=2", headers=AUTH).get_json()
assert [t["id"] for t in listed["data"]] == [body["transaction_id"]]
Loading
Loading