diff --git a/backend/ai/home_signals.py b/backend/ai/home_signals.py index 32f0857..b5d8f03 100644 --- a/backend/ai/home_signals.py +++ b/backend/ai/home_signals.py @@ -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) diff --git a/backend/api/agent.py b/backend/api/agent.py index 6671c78..184fd93 100644 --- a/backend/api/agent.py +++ b/backend/api/agent.py @@ -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") diff --git a/backend/api/budget.py b/backend/api/budget.py index 5d40c93..8fea860 100644 --- a/backend/api/budget.py +++ b/backend/api/budget.py @@ -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.""" @@ -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) @@ -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} @@ -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 @@ -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} @@ -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 diff --git a/backend/api/review.py b/backend/api/review.py index 123b842..ada9943 100644 --- a/backend/api/review.py +++ b/backend/api/review.py @@ -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 @@ -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.""" diff --git a/backend/api/statements.py b/backend/api/statements.py index 4bd9445..bec9bd9 100644 --- a/backend/api/statements.py +++ b/backend/api/statements.py @@ -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) @@ -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]} diff --git a/backend/models/__init__.py b/backend/models/__init__.py index a9ced99..8a938c6 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -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) diff --git a/backend/routes/database_query.py b/backend/routes/database_query.py index 1c9170a..768a889 100644 --- a/backend/routes/database_query.py +++ b/backend/routes/database_query.py @@ -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') @@ -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) diff --git a/backend/tests/test_api_budget.py b/backend/tests/test_api_budget.py index d96482d..35460df 100644 --- a/backend/tests/test_api_budget.py +++ b/backend/tests/test_api_budget.py @@ -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 diff --git a/backend/tests/test_api_review.py b/backend/tests/test_api_review.py index 338952b..b7c43af 100644 --- a/backend/tests/test_api_review.py +++ b/backend/tests/test_api_review.py @@ -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 diff --git a/backend/tests/test_api_statements.py b/backend/tests/test_api_statements.py index 92b8603..80b35e6 100644 --- a/backend/tests/test_api_statements.py +++ b/backend/tests/test_api_statements.py @@ -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 diff --git a/backend/tests/test_home_signals.py b/backend/tests/test_home_signals.py index 0124935..3e0c246 100644 --- a/backend/tests/test_home_signals.py +++ b/backend/tests/test_home_signals.py @@ -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" diff --git a/backend/tests/test_invoice_upload.py b/backend/tests/test_invoice_upload.py index 3690199..4c252d6 100644 --- a/backend/tests/test_invoice_upload.py +++ b/backend/tests/test_invoice_upload.py @@ -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"]] diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md index 3e826a2..636823d 100644 --- a/docs/CODEBASE-GUIDE.md +++ b/docs/CODEBASE-GUIDE.md @@ -253,10 +253,10 @@ embeds the demo documents once per server and uploads the files in the backgroun the landing or sign-in pages, which don't need the API; they still ping it so it's warm by the time the app opens). ## 7b2. App frame and navigation (frontend) -- The signed-in pages live in the route group `app/(app)/`: `/home`, `/spending`, `/bills`, `/receipts` - (`?tab=documents|import|review`), `/ask`, `/settings`. The old URLs (`/dashboard`, `/analytics`, `/ai-analytics`, +- The signed-in pages live in the route group `app/(app)/`: `/home`, `/spending`, `/bills`, `/budget`, `/receipts` + (and `/receipts/import`), `/ask`, `/settings`. The old URLs (`/dashboard`, `/analytics`, `/ai-analytics`, `/chatbot`, `/documents`, `/import`, `/review`, `/upload`) redirect to them in `next.config.js` (temporary 307s, query - strings carried). Until UI revamp Phase 5, Spending, Bills and Receipts reuse the old pages' `*Content.tsx`. + strings carried). - **Sidebar** (`components/app-sidebar.tsx`): Home · Track (Spending, Bills & subscriptions) · Plan (Budget) · Receipts & statements (badge = flagged review items) · Ask Lumen (Ctrl/⌘+K). It collapses to a 56px icon rail with Ctrl/⌘+B, remembered in the `sidebar_state` cookie. The layout reads that cookie on the sidebar's first render, which is always @@ -316,6 +316,15 @@ embeds the demo documents once per server and uploads the files in the backgroun Add category, and the unassigned footer. The draft panel shows pending `set_budget` lines (Skip / Approve named for the category, "Approve remaining N"). History sits above By category so the data-height cards come last. Maths in `lib/budget-math.ts` (tests: `e2e/budget-math.spec.ts`). +- **Receipts & statements** (`app/(app)/receipts/`, Phase 5e): "what have I given Lumen, and what needs my OK?" It + replaced the Documents / Import / Review tabs. Everything loads together and appears in one step. Needs your review + (only when the queue isn't empty): a card per flagged item with the doubtful field highlighted from its validation + flag (`total_mismatch`/`no_currency` → Total, `duplicate` → Bill no., `bad_date` → Date, `unknown_vendor` → Vendor), + the reason in plain words, Approve / Edit (sends only changed fields) / Reject; a duplicate offers Keep both. The + library merges receipts (non-statement transactions, latest 100), review items, RAG documents (deletable) and + statement imports, with chips, search and Show more. Right column: Statement imports (counts bar) and From your + inbox (`/api/v1/email-config/status`: Check now, Pause/Resume, or Connect with Google). The upload dialog + (`components/uploadDialog.tsx`) chooses Receipts & invoices (`/extract`) or Documents (PDF, `/api/documents`). - **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 (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, @@ -514,8 +523,11 @@ embeds the demo documents once per server and uploads the files in the backgroun into a vendor name and a category. - `api/statements.py`: `POST /api/statements/preview` (columns, guessed mapping, 5 sample lines, counts; a file with no header is a 400 with `code: "no_header"` and the first raw rows) and `POST /api/statements/import` (file + - mapping JSON + currency + header_line). Only debits become transactions (`source="statement"`). + mapping JSON + currency + header_line + optional account `label`). Only debits become transactions + (`source="statement"`). Each import also writes a `statement_imports` row (file, label, period, counts as returned; + the CSV isn't kept), listed by `GET /api/statements/imports` (UI revamp Phase 5e). - Dedupe: each line has an `import_key` (sha1 of user, date, amount, description), so re-importing is safe. Invoice matching: a debit with the same amount within 3 days and a shared word in the vendor name is "already recorded", not added. Credits are counted and skipped; unreadable lines are returned by line number. -- `/import` (frontend, minimal): choose a CSV, adjust the mapping and currency, preview, import, see the result line. +- `/receipts/import` (frontend): choose a CSV, adjust the mapping, currency and account name, preview, import, see the + result line. diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md index 7f2e78b..1b98fd6 100644 --- a/docs/direction/STORY.md +++ b/docs/direction/STORY.md @@ -461,3 +461,28 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in and 4 maths tests. A phone screenshot showed buttons and bars clipped past the screen edge, which the old "no sideways scroll" check missed; the test now checks every header button, bar and table cell ends on screen. +## 36. One place for everything you've given Lumen (UI revamp, Phase 5e) +- **S:** Receipts & statements was three old pages as tabs (Documents, Import, Review). The review queue showed raw + rule text and a "correct total" box; statement imports left no trace once the result line was gone; and connecting + Gmail had no entry point in the app at all since the old dashboard was removed. +- **T:** One page that answers "what have I given Lumen, and what needs my OK?", with the review as cards that point at + the doubtful field, and a history of statement imports. +- **A:** A `statement_imports` table written by every import (file, optional account name, period, counts as returned) + and `GET /api/statements/imports` (TDD). The page: review cards that map each validation flag to the field it doubts + and a plain reason, Edit that sends only changed fields, Keep both for duplicates; one library of receipts, review + items, documents and imports with chips and search; Statement imports and From your inbox cards (Check now, Pause, + Connect with Google). The upload dialog now files PDFs as documents too. The import flow moved to + `/receipts/import`; the old routes redirect, and the three old pages were deleted. +- **R:** Backend 995 passed (2 new: history per import with period and counts, re-import as its own row, per-user, + label limit). Frontend: 15 Receipts e2e checks (brief, highlights and reasons, edit-and-approve body, reject and the + sidebar badge, empty queue, library mix, chips, search, delete, imports counts, inbox both ways, document upload, + import link, redirects, CLS under 0.01, nothing past the edge at 375 px). A screenshot pass fixed raw ISO dates on the + cards. +- **Phase 5 review:** one fresh reviewer read all 43 commits of Phase 5. No Critical issues; 8 Important ones, plus a + CSV-formula risk upgraded to Important, all fixed test-first: receipts lost behind a big statement import (now + filtered server-side), a past month showing today's overall budget, a stale draft writing into last month, the + first night of a month (browser and UTC server a month apart; every budget call now names the browser's month, + writes only within one month of the server's), late bills vanishing from "what's due" (now "Not seen yet"), no way + to open the original receipt, the wrong error text, an edit that could approve a ₹0 total (now refused by the API + too), and spreadsheet formulas in the CSV export. Backend 1004 passed, e2e 207. Nine minors went to the backlog. + diff --git a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md index 733dd20..4fdb82b 100644 --- a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md +++ b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md @@ -291,5 +291,23 @@ Branch: `ui-revamp/phase5d-budget`, stacked on `ui-revamp/phase5c-bills` (#99). - STORY 35, guide (Budget), spec §12 ticks (draft accessible names, `basis.months`). - Full checks green, screenshot vs `budget.html` at 1440 and 375, PR stacked on #99. -## Phase 5e — Receipts & statements (outline) -Backend (TDD): `StatementImport` table + write on import; `GET /api/statements/imports`. Frontend: header actions; Needs-your-review cards from the review queue with highlights from validation flags (replaces the Review tab); library list with filter chips (Statements = import rows) and search; Statement imports card; From your inbox card; Activity moves here from Home if it no longer fits. Old `/documents`, `/import`, `/review` routes redirect into `/receipts`. STORY 36, then the whole-branch review for Phase 5. +## Phase 5e — Receipts & statements (detailed) + +Branch: `ui-revamp/phase5e-receipts`, stacked on `ui-revamp/phase5d-budget` (#100). Mockup `receipts.html`; spec §4.5 and the builder-check decisions on import history and the Statements filter. + +### Task E1: statement import history (backend, TDD) +- Model `StatementImport` (`statement_imports`: id, user_id, file_name, label nullable, period_from/period_to "YYYY-MM-DD" from the readable rows, lines, imported, matched, duplicates, credits, unreadable, created_at); created by `create_all`. +- `POST /api/statements/import` takes an optional `label` form field (≤60 chars) and writes one row per import with the counts it returns (`matched` "at import"). `GET /api/statements/imports` → `{success, imports: [...]}` newest first, the user's own only. +- Tests (`tests/test_statements.py` style): an import writes one history row with period and counts; a re-import is a second row with duplicates counted; per-user; label trimmed and optional. + +### Task E2: the Receipts & statements page +- `statementsApi.imports()`, `emailConfigApi.getStatus()` typed. Header "Receipts & statements" with Upload (dialog: Receipts & invoices vs Documents; documents upload via `documentsApi.upload(file, type)`; a line pointing to Import statement for CSVs), Import statement (→ `/receipts/import`, the existing flow plus an optional account label), Connect email (scrolls to the inbox card). Brief: "N receipts need your OK before they count." / "Everything you've given Lumen is filed." +- **Needs your review** (only when the queue is non-empty): one card per flagged item: vendor, how it came in, amount; fields Vendor / Date / Total (+ Bill no. for duplicates) with the flagged ones highlighted (`total_mismatch` → Total, `duplicate` → Bill no., `bad_date` → Date, `unknown_vendor` → Vendor, `no_currency` → Total); the reason in plain words from the first flag; Approve / Edit (inline vendor/date/total inputs, then Save & approve) / Reject; a duplicate shows "Keep both" instead of Approve. Deciding removes the card and updates the sidebar badge (`REVIEW_DECIDED`). Compare is left out (no "other record" in the payload; hidden, not faked). +- **Library**: one list of receipts (non-statement transactions, latest 100), flagged review items (status Needs review), documents (with delete) and statement imports; chips All / Receipts & invoices / Statements / Documents (a chip only when it has rows), search over title/vendor/file name, 10 rows then "Show N more". Columns: file (icon, title, kind · file/sender), came from (Upload / Gmail / Import), date, amount, status. +- Right column: **Statement imports** (label or file name, period, imported date, lines, a new / matched / duplicates / credits bar with counts) and **From your inbox** (connected: address, last check, receipts added, Check now, Pause/Resume; not connected: Connect Gmail). +- Routes: `/import` → `/receipts/import`; `/review`, `/documents` → `/receipts`; the tabs component, `review/reviewContent.tsx` and `documents/documentsContent.tsx` go (their APIs stay). +- Tests (`receipts.spec.ts`): review card highlights and reason per flag; approve with edits posts them; duplicate shows Keep both; reject removes the card and the badge drops; library chips and search; statements in the library and the imports card; inbox connected vs not; upload dialog's document path; old routes redirect; CLS < 0.01; phone fit (nothing past the edge). + +### Task E3: docs, checks, PR, whole-branch review +- STORY 36, guide (Receipts), spec §12. +- Full checks green, screenshots at 1440 and 375, PR stacked on #100. Then the whole-branch review of Phase 5 (5a–5e) on the most capable model; Critical/Important fixed test-first, Minors to §12. diff --git a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md index d3974a3..9491a80 100644 --- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -245,5 +245,16 @@ Small items found while building, parked here so they aren't lost. Tick them off - [ ] **Later.** Budget page: "Safe per day" is left ÷ days left; subtract bills still due this month (the Bills data) once both are on the page. *(5d)* - [ ] **Later.** The overall monthly budget (`User.monthly_budget`) isn't versioned like category limits, so history uses today's total for past budgeted months. Version it if people change it often. *(5d)* - [ ] **Later.** Draft lines could show a small month-by-month glyph once `basis` carries per-month amounts. *(5d)* +- [ ] **Later.** Receipts: tell receipts from invoices in the data so the library can have both chips; "Compare" on a duplicate needs the review item to carry the record it duplicates. *(5e)* +- [ ] **Later.** Receipts library lists the latest 100 receipts; page through `/transactions` if people need older ones there. *(5e)* +- [ ] **Phase 6.** Phase 5 review minors: refunds (negative spend) draw a full bar instead of an empty one (budget hero/categories, Home budget card, Spending by category) — clamp at 0. *(5 review)* +- [ ] **Phase 6.** Bills calendar projects a monthly bill into months before its first charge, and hides cancelled bills in months they really were charged. Project from `last_charge_date` on; show real charges for past months. *(5 review)* +- [ ] **Phase 6.** Naive UTC timestamps (`created_at`, `last_successful_poll`) are parsed as local time: "Checked 6 h ago" right after a poll in IST, late uploads dated a day off. Send them with `Z`. *(5 review)* +- [ ] **Phase 6.** Spending brief can name a payment bigger than the category's change ("one ₹9,999 payment is most of a ₹2,000 jump"); check the size first. *(5 review)* +- [ ] **Phase 6.** Receipts brief says "Everything … is filed" when the review list failed to load; say nothing instead. *(5 review)* +- [ ] **Phase 6.** Small layout shifts: "Connect email" joins the header after load (wraps on phones); Budget hero/History taller than their skeletons on phones; a pending draft narrows By category one frame after load on lg. *(5 review)* +- [ ] **Phase 6.** Home's spike link `?category=` is matched case-sensitively on Spending ("food" rows missed under "Food"). *(5 review)* +- [ ] **Phase 6.** Import page currency starts as USD until the profile loads; an INR user who doesn't notice imports in USD. *(5 review; predates 5e)* +- [ ] **Phase 6.** If the budget-versions migration is skipped on Postgres (only a warning), edits to an existing category in a new month hit the old unique constraint and 500. Make that failure loud. *(5 review; migration smoke-tested on Postgres 16 locally)* - [ ] **Later.** Spending: the category edit (pencil) is hidden below `sm`, so phones can't recategorise; and "Lumen noticed" wraps beside its pill at 375px. *(5b)* - [ ] **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/bills-math.spec.ts b/frontend/e2e/bills-math.spec.ts index 58bf5d0..78b48e2 100644 --- a/frontend/e2e/bills-math.spec.ts +++ b/frontend/e2e/bills-math.spec.ts @@ -1,6 +1,6 @@ // Node-only unit tests (no browser) for the Bills page's calculations (UI revamp Phase 5c). import { test, expect } from "@playwright/test"; -import { dueDays, monthlyEquivalent, tiles, upcoming } from "../src/lib/bills-math"; +import { dueDays, monthlyEquivalent, tiles, toBills, upcoming } from "../src/lib/bills-math"; import type { RecurringItem } from "../src/lib/api/client"; const item = (vendor: string, cadence: RecurringItem["cadence"], next: string, amount: number, extra: Partial = {}): RecurringItem => ({ @@ -44,3 +44,23 @@ test("tiles: due soon, recurring per month, and subscriptions that look stopped" expect(t.perMonth).toBe(25125); // rent + prime/12; stopped and cancelled ones don't count expect(t.stopped).toEqual({ count: 1, perMonth: 119 }); }); + +test("a bill that's late but not stopped stays on the list as not seen yet", () => { + const late = item("Power", "monthly", "2026-10-18", 1800); + expect(upcoming([late, item("Rent", "monthly", "2026-10-24", 25000)], today).map((g) => [g.label, g.rows.map((r) => r.vendor)])).toEqual([ + ["Not seen yet", ["Power"]], + ["This week", ["Rent"]], + ]); + expect(tiles([late], today).dueSoon).toEqual({ total: 1800, count: 1 }); +}); + +test("toBills: Home keeps late and charged-again bills, drops cancelled and stopped ones", () => { + const bills = toBills([ + item("Power", "monthly", "2026-10-18", 1800), + item("Netflix", "monthly", "2026-10-30", 649, { state: "charged_again" }), + item("Gym", "monthly", "2026-10-28", 1500, { state: "cancelled" }), + item("Spotify", "monthly", "2026-08-02", 119, { possibly_stopped: true }), + item("Far", "monthly", "2026-12-30", 5), + ], today); + expect(bills.map((b) => b.vendor)).toEqual(["Power", "Netflix"]); +}); diff --git a/frontend/e2e/budget.spec.ts b/frontend/e2e/budget.spec.ts index 2afd00b..97a12a6 100644 --- a/frontend/e2e/budget.spec.ts +++ b/frontend/e2e/budget.spec.ts @@ -71,7 +71,7 @@ test("editing a category's budget in place: Enter saves, the row keeps its heigh await input.fill("9000"); await input.press("Enter"); await expect(row(page, "Food")).toContainText("₹9,000"); - expect(puts).toEqual([{ categories: [{ category: "Food", limit: 9000 }] }]); + expect(puts).toEqual([{ month: ym(0), categories: [{ category: "Food", limit: 9000 }] }]); }); test("Esc cancels an edit without saving", async ({ page }) => { @@ -203,3 +203,13 @@ test("budget fits a phone without sideways scroll", async ({ page }) => { .evaluateAll((els) => els.map((e) => [e.getAttribute("aria-label") ?? e.textContent, e.getBoundingClientRect().right] as const)); for (const [what, right] of ends) expect(right, String(what)).toBeLessThanOrEqual(375); }); + +test("every budget request names the browser's month (the server's date can differ around midnight on the 1st)", async ({ page }) => { + const urls: string[] = []; + page.on("request", (r) => r.url().includes("/api/budget") && r.method() === "GET" && urls.push(r.url())); + await openBudget(page); + expect(urls.some((u) => u.includes(`/api/budget?month=${ym(0)}`))).toBe(true); + expect(urls.some((u) => u.includes(`/api/budget/history?months=6&to=${ym(0)}`))).toBe(true); + expect(urls.filter((u) => /\/api\/budget$/.test(u))).toEqual([]); +}); + diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 7d0da98..ab9f57e 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -6,7 +6,7 @@ import type { Page } from "@playwright/test"; // - the `lumen_session` cookie gets past middleware. // - every backend call (NEXT_PUBLIC_BACKEND_URL in playwright.config.ts) is stubbed. export type HomeFixture = { - txns?: { id: string; vendor_name: string; date: string; total_amount: number; category: string | null; created_at: string | null }[]; + txns?: { id: string; vendor_name: string; date: string; total_amount: number; category: string | null; created_at: string | null; source?: string | null; invoice_number?: string | null }[]; reminders?: { id: string; vendor: string; amount: number; date: string; type: string; category?: string }[]; /** GET /api/bills/recurring items as the API sends them; default: built from `reminders` (monthly, active) */ recurring?: Record[]; @@ -28,6 +28,14 @@ export type HomeFixture = { currency?: string; /** proposal ids whose approve returns 409 (a newer draft replaced them) */ replaced?: string[]; + /** the flagged review queue as GET /api/review sends it; default: `reviewCount` plain items */ + review?: Record[]; + /** GET /api/documents; default [] (DELETE removes, POST adds) */ + documents?: Record[]; + /** GET /api/statements/imports; default [] */ + imports?: Record[]; + /** GET /api/v1/email-config/status; default not configured */ + email?: Record; }; export type ChatFixture = { @@ -68,20 +76,38 @@ export async function signIn( ); }); // a tiny stateful review queue: approving/rejecting removes the item, so the sidebar badge can be checked - let queue = Array.from({ length: opts.reviewCount ?? 0 }, (_, i) => ({ + const generated = Array.from({ length: opts.reviewCount ?? 0 }, (_, i) => ({ id: `r${i}`, status: "flagged", confidence: "low", - invoice: { vendor_name: `Vendor ${i}` }, - flags: [], + invoice: { vendor_name: `Vendor ${i}` } as Record, + flags: [] as unknown[], transaction_id: null, note: null, - created_at: null, + created_at: null as string | null, })); + let queue = (home.review as typeof generated | undefined) ?? generated; + const documents = [...(home.documents ?? [])]; await page.route("http://127.0.0.1:9/**", async (route) => { const url = new URL(route.request().url()); const path = url.pathname; if (home.delayMs) await new Promise((r) => setTimeout(r, home.delayMs)); + const method = route.request().method(); + if (path === "/api/documents" && method === "GET") return route.fulfill({ json: { success: true, documents } }); + if (path === "/api/documents" && method === "POST") { + const doc = { id: `d${documents.length + 1}`, title: "New document", doc_type: "other", filename: "new.pdf", chunk_count: 3, created_at: new Date().toISOString() }; + documents.unshift(doc); + return route.fulfill({ json: { success: true, document: doc } }); + } + const doc = path.match(/^\/api\/documents\/([^/]+)$/); + if (doc && method === "DELETE") { + documents.splice(documents.findIndex((d) => d.id === doc[1]), 1); + return route.fulfill({ json: { success: true } }); + } + if (path.startsWith("/api/files/")) return route.fulfill({ body: "original", contentType: "image/png" }); + if (path === "/api/statements/imports") return route.fulfill({ json: { success: true, imports: home.imports ?? [] } }); + if (path === "/api/v1/email-config/status") return route.fulfill({ json: home.email ?? { configured: false } }); + if (path.startsWith("/api/v1/email-config/")) return route.fulfill({ json: { success: true } }); // recurring charges (api/bills.py): the fixture's items, or simple monthly ones built from `reminders`; state // changes are kept, like the real table if (path === "/api/bills/recurring" || path === "/api/bills/recurring/state") { @@ -111,7 +137,8 @@ export async function signIn( if (path === "/transactions") { const from = url.searchParams.get("date_from") ?? "0000"; const to = url.searchParams.get("date_to") ?? "9999"; - const all = (home.txns ?? []).filter((t) => t.date >= from && t.date <= to).sort((a, b) => b.date.localeCompare(a.date)); + const skip = url.searchParams.get("exclude_source"); + const all = (home.txns ?? []).filter((t) => t.date >= from && t.date <= to && (!skip || t.source !== skip)).sort((a, b) => b.date.localeCompare(a.date)); // like the real API: at most 100 rows per page const size = Math.min(Number(url.searchParams.get("page_size") ?? 10), 100); const page = Number(url.searchParams.get("page") ?? 1); @@ -131,7 +158,7 @@ export async function signIn( if (path === "/api/home/summary") { const review = queue.length ? [{ kind: "review", tag: "RECEIPTS", headline: `${queue.length} ${queue.length === 1 ? "receipt needs" : "receipts need"} a glance`, - detail: "Lumen wasn't sure about these. Nothing is saved until you approve.", amounts: [], worth: null, href: "/receipts?tab=review" }] + detail: "Lumen wasn't sure about these. Nothing is saved until you approve.", amounts: [], worth: null, href: "/receipts" }] : []; const s = home.summary ?? { found: review }; return route.fulfill({ json: { success: true, more: 0, worth_total: 0, activity: [], ...s } }); @@ -161,6 +188,8 @@ export async function signIn( return route.fulfill({ status: 409, json: { detail: "Proposal is already rejected" } }); if (proposal) return route.fulfill({ json: { success: true, proposal: { id: proposal[1], status: `${proposal[2]}d` }, applied: null } }); const decided = path.match(/^\/api\/review\/([^/]+)\/(approve|reject)$/); + if (decided && home.replaced?.includes(decided[1])) + return route.fulfill({ status: 409, json: { success: false, error: "This invoice is already recorded", code: "conflict" } }); if (decided) { const item = queue.find((q) => q.id === decided[1])!; queue = queue.filter((q) => q.id !== decided[1]); diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index b53d04b..0a10899 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -137,7 +137,7 @@ test("lumen found links to review", async ({ page }) => { await openHome(page, typical, 2); const found = page.getByTestId("lumen-found"); await expect(found.getByText("2 receipts need a glance")).toBeVisible(); - await expect(found.getByRole("link", { name: /review/i })).toHaveAttribute("href", "/receipts?tab=review"); + await expect(found.getByRole("link", { name: /review/i })).toHaveAttribute("href", "/receipts"); }); test("lumen found is honest when there is nothing", async ({ page }) => { diff --git a/frontend/e2e/receipts.spec.ts b/frontend/e2e/receipts.spec.ts new file mode 100644 index 0000000..007dac1 --- /dev/null +++ b/frontend/e2e/receipts.spec.ts @@ -0,0 +1,211 @@ +import { test, expect, type Page } from "@playwright/test"; +import { signIn, type HomeFixture } from "./fixtures"; + +const flagged = (id: string, invoice: Record, rules: string[]) => ({ + id, status: "flagged", confidence: "low", invoice, transaction_id: null, note: null, created_at: "2026-10-12T10:00:00", + flags: rules.map((rule) => ({ rule, severity: "fail", detail: `detail for ${rule}` })), +}); +const sample: HomeFixture = { + review: [ + flagged("r1", { vendor_name: "Croma", date: "2026-10-12", total_amount: 9999, invoice_number: "CR-2210" }, ["total_mismatch"]), + flagged("r2", { vendor_name: "FreshMart", date: "2026-10-18", total_amount: 1284, invoice_number: "FM-55120", source: "email" }, ["duplicate"]), + ], + txns: [ + { id: "t1", vendor_name: "Jio Fiber", date: "2026-10-09", total_amount: 999, category: "Bills", created_at: null, source: "email", invoice_number: "JIO-1" }, + { id: "t2", vendor_name: "Uber", date: "2026-10-08", total_amount: 212, category: "Transport", created_at: null, source: null, invoice_number: "U-9" }, + { id: "t3", vendor_name: "Swiggy", date: "2026-10-07", total_amount: 450, category: "Food", created_at: null, source: "statement" }, + ], + documents: [{ id: "d1", title: "Rental agreement", doc_type: "contract", filename: "rent.pdf", chunk_count: 14, created_at: "2026-10-01T09:00:00" }], + imports: [ + { id: "i1", file_name: "hdfc-sep.csv", label: "HDFC Savings ··4417", period_from: "2026-09-01", period_to: "2026-09-30", lines: 142, + imported: 97, matched: 21, duplicates: 3, credits: 21, unreadable: 0, created_at: "2026-10-03T08:00:00" }, + ], + email: { configured: true, email_address: "me@gmail.com", provider: "gmail", polling_enabled: true, transactions_added: 11, + last_successful_poll: new Date(Date.now() - 12 * 60_000).toISOString() }, +}; +async function openReceipts(page: Page, home: HomeFixture = sample) { + await signIn(page, { home }); + await page.goto("/receipts"); + await expect(page.getByTestId("library")).toContainText("Rental agreement", { timeout: 15_000 }); +} +const card = (page: Page, vendor: string) => page.getByTestId("review-card").filter({ hasText: vendor }); + +test("receipts: heading and a brief from the queue", async ({ page }) => { + await openReceipts(page); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Receipts & statements"); + await expect(page.getByTestId("brief")).toContainText("2 receipts need your OK before they count."); +}); + +test("review cards highlight the doubtful field and say why", async ({ page }) => { + await openReceipts(page); + await expect(page.getByTestId("review-card")).toHaveCount(2); + await expect(card(page, "Croma").locator("[data-doubtful]")).toHaveText(["Total"]); + await expect(card(page, "Croma")).toContainText("Totals don't add up."); + await expect(card(page, "FreshMart").locator("[data-doubtful]")).toHaveText(["Bill no."]); + await expect(card(page, "FreshMart")).toContainText("Looks like a duplicate."); + await expect(card(page, "FreshMart")).toContainText("from email"); + await expect(card(page, "FreshMart").getByRole("button", { name: "Keep both" })).toBeVisible(); + await expect(card(page, "FreshMart").getByRole("button", { name: "Approve", exact: true })).toHaveCount(0); +}); + +test("edit then approve sends only the changed fields", async ({ page }) => { + const bodies: unknown[] = []; + page.on("request", (r) => r.url().endsWith("/api/review/r1/approve") && bodies.push(r.postDataJSON())); + await openReceipts(page); + await card(page, "Croma").getByRole("button", { name: "Edit" }).click(); + await card(page, "Croma").getByRole("textbox", { name: "Total" }).fill("9990"); + await card(page, "Croma").getByRole("button", { name: "Save & approve" }).click(); + await expect(card(page, "Croma")).toHaveCount(0); + expect(bodies).toEqual([{ edits: { total_amount: 9990 } }]); +}); + +test("reject removes the card and the sidebar badge drops", async ({ page }) => { + await openReceipts(page); + await expect(page.locator('[data-sidebar="menu-badge"]')).toHaveText("2"); + await card(page, "FreshMart").getByRole("button", { name: "Reject" }).click(); + await expect(card(page, "FreshMart")).toHaveCount(0); + await expect(page.locator('[data-sidebar="menu-badge"]')).toHaveText("1"); +}); + +test("no queue, no review section", async ({ page }) => { + await openReceipts(page, { ...sample, review: [] }); + await expect(page.getByTestId("review")).toHaveCount(0); + await expect(page.getByTestId("brief")).toContainText("Everything you've given Lumen is filed."); +}); + +test("library: receipts, review items, documents and statements in one list", async ({ page }) => { + await openReceipts(page); + const rows = page.getByTestId("library-row"); + await expect(rows).toHaveCount(6); // 2 receipts (not the statement line), 2 in review, 1 document, 1 import + await expect(rows.filter({ hasText: "Jio Fiber" })).toContainText("Gmail"); + await expect(rows.filter({ hasText: "Croma" })).toContainText("Needs review"); + await page.getByRole("button", { name: "Statements", exact: true }).click(); + await expect(rows).toHaveCount(1); + await expect(rows).toContainText("HDFC Savings ··4417"); + await expect(rows).toContainText("Import"); + await page.getByRole("button", { name: "All", exact: true }).click(); + await page.getByRole("searchbox", { name: "Search" }).fill("uber"); + await expect(rows).toHaveCount(1); +}); + +test("deleting a document from the library", async ({ page }) => { + await openReceipts(page); + await page.getByRole("button", { name: "Delete Rental agreement" }).click(); + await expect(page.getByTestId("library-row").filter({ hasText: "Rental agreement" })).toHaveCount(0); +}); + +test("statement imports card shows counts", async ({ page }) => { + await openReceipts(page); + const imports = page.getByTestId("imports"); + await expect(imports).toContainText("HDFC Savings ··4417"); + await expect(imports).toContainText("142 lines"); + for (const text of ["97 new", "21 matched", "3 duplicates", "21 credits skipped"]) await expect(imports).toContainText(text); +}); + +test("inbox: connected shows the address and checks now", async ({ page }) => { + const polled = page.waitForRequest((r) => r.url().endsWith("/api/v1/email-config/poll-now") && r.method() === "POST"); + await openReceipts(page); + const inbox = page.getByTestId("inbox"); + await expect(inbox).toContainText("me@gmail.com"); + await expect(inbox).toContainText("11 receipts added"); + await inbox.getByRole("button", { name: "Check now" }).click(); + await polled; + await expect(inbox.getByRole("button", { name: "Pause" })).toBeVisible(); +}); + +test("inbox: not connected offers Gmail", async ({ page }) => { + await openReceipts(page, { ...sample, email: { configured: false } }); + await expect(page.getByTestId("inbox").getByRole("button", { name: /Connect with Google/ })).toBeVisible(); +}); + +test("upload dialog can file a document", async ({ page }) => { + const posted = page.waitForRequest((r) => r.url().endsWith("/api/documents") && r.method() === "POST"); + await openReceipts(page); + await page.getByRole("button", { name: "Upload" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("radio", { name: "Documents" }).click(); + await dialog.locator('input[type="file"]').setInputFiles({ name: "policy.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4") }); + await dialog.getByRole("button", { name: "Upload", exact: true }).click(); + const body = (await posted).postDataBuffer()!.toString(); + expect(body).toContain("policy.pdf"); + expect(body).toContain('name="doc_type"'); + await expect(dialog).toContainText("Statements are CSV files"); +}); + +test("import statement goes to the import flow", async ({ page }) => { + await openReceipts(page); + await page.getByRole("link", { name: "Import statement" }).first().click(); + await expect(page).toHaveURL(/\/receipts\/import$/); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Import a statement"); +}); + +test("old routes land on the new page", async ({ page }) => { + await signIn(page, { home: sample }); + await page.goto("/review"); + await expect(page).toHaveURL(/\/receipts$/); + await page.goto("/import"); + await expect(page).toHaveURL(/\/receipts\/import$/); +}); + +test("receipts has no layout shift while loading", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.addInitScript(() => { + (window as unknown as { cls: number }).cls = 0; + new PerformanceObserver((list) => { + for (const e of list.getEntries() as unknown as { value: number; hadRecentInput: boolean }[]) + if (!e.hadRecentInput) (window as unknown as { cls: number }).cls += e.value; + }).observe({ type: "layout-shift", buffered: true }); + }); + await signIn(page, { home: { ...sample, delayMs: 1500 } }); + await page.goto("/receipts"); + await expect(page.getByTestId("library")).toContainText("Rental agreement", { timeout: 15_000 }); + await page.waitForTimeout(500); + expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01); +}); + +test("receipts fits a phone", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openReceipts(page); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + const ends = await page + .locator('header a, header button, [data-testid="review-card"], [data-testid="library-row"] > *') + .evaluateAll((els) => els.map((e) => [e.getAttribute("aria-label") ?? e.textContent, e.getBoundingClientRect().right] as const)); + for (const [what, right] of ends) expect(right, String(what)).toBeLessThanOrEqual(375); +}); + +test("a big statement import doesn't push the receipts out of the library", async ({ page }) => { + const lines = Array.from({ length: 120 }, (_, i) => ({ id: `s${i}`, vendor_name: `Line ${i}`, date: "2026-10-20", total_amount: 10, category: null, created_at: null, source: "statement" })); + await openReceipts(page, { ...sample, txns: [...lines, ...sample.txns!] }); + await expect(page.getByTestId("library-row").filter({ hasText: "Jio Fiber" })).toHaveCount(1); +}); + +test("a review card and a document open their original", async ({ page }) => { + const opened: string[] = []; + page.on("request", (r) => r.url().includes("/api/files/") && opened.push(r.url().split("/api/files/")[1])); + const review = [flagged("r1", { vendor_name: "Croma", total_amount: 9999, file_key: "inv/croma.png" }, ["total_mismatch"])]; + await openReceipts(page, { ...sample, review, documents: [{ ...sample.documents![0], file_key: "doc/rent.pdf" }] }); + await card(page, "Croma").getByRole("button", { name: "Open original" }).click(); + await page.getByRole("button", { name: "Open Rental agreement" }).click(); + await expect.poll(() => opened).toEqual(["inv/croma.png", "doc/rent.pdf"]); +}); + +test("a decision the server refuses says why", async ({ page }) => { + await openReceipts(page, { ...sample, replaced: ["r1"] }); + await card(page, "Croma").getByRole("button", { name: "Approve", exact: true }).click(); + await expect(page.getByText("This invoice is already recorded")).toBeVisible(); + await expect(card(page, "Croma")).toHaveCount(1); +}); + +test("an edit can't approve an empty or non-numeric total", async ({ page }) => { + await openReceipts(page); + await card(page, "Croma").getByRole("button", { name: "Edit" }).click(); + const total = card(page, "Croma").getByRole("textbox", { name: "Total" }); + const save = card(page, "Croma").getByRole("button", { name: "Save & approve" }); + await total.fill(""); + await expect(save).toBeDisabled(); + await total.fill("1,234"); + await expect(save).toBeDisabled(); + await total.fill("1234.5"); + await expect(save).toBeEnabled(); +}); + diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts index 203bba8..2e81faf 100644 --- a/frontend/e2e/shell.spec.ts +++ b/frontend/e2e/shell.spec.ts @@ -17,8 +17,8 @@ const moved: [string, string][] = [ ["/ai-analytics", "/spending"], ["/chatbot", "/ask"], ["/documents", "/receipts"], - ["/import", "/receipts?tab=import"], - ["/review", "/receipts?tab=review"], + ["/import", "/receipts/import"], + ["/review", "/receipts"], ["/upload", "/home?upload=1"], ]; @@ -44,7 +44,7 @@ test("new pages render for a signed-in user", async ({ page }) => { ["/spending", "Spending"], ["/bills", "Bills & subscriptions"], ["/receipts", "Receipts & statements"], - ["/receipts?tab=review", "Receipts & statements"], + ["/receipts/import", "Import a statement"], ["/ask", "Ask Lumen"], ]; for (const [p, title] of pages) { @@ -236,7 +236,7 @@ test("body is never its own scroll container (it would lose its scrollbar when a test("review badge updates after a decision on the same page", async ({ page }) => { await signIn(page, { reviewCount: 2 }); - await page.goto("/receipts?tab=review"); + await page.goto("/receipts"); const badge = sidebar(page).locator('[data-sidebar="menu-badge"]'); await expect(badge).toHaveText("2"); await page.getByRole("button", { name: "Approve" }).first().click(); diff --git a/frontend/e2e/spending-math.spec.ts b/frontend/e2e/spending-math.spec.ts index cf6ff68..74ffca4 100644 --- a/frontend/e2e/spending-math.spec.ts +++ b/frontend/e2e/spending-math.spec.ts @@ -1,6 +1,6 @@ // Node-only unit tests (no browser) for the Spending page's calculations (UI revamp Phase 5b). import { test, expect } from "@playwright/test"; -import { byCategory, monthlyTotals, needsCategory, spendPeriods, topMerchants } from "../src/lib/spending-math"; +import { byCategory, monthlyTotals, needsCategory, spendPeriods, toCsv, topMerchants } from "../src/lib/spending-math"; const tx = (id: string, vendor_name: string | null, date: string, total_amount: number, category: string | null = "Food") => ({ id, @@ -58,3 +58,12 @@ test("monthlyTotals: oldest first, current month included, empty months are zero test("needsCategory counts rows with no real category", () => { expect(needsCategory([tx("1", "a", "2026-10-02", 1, null), tx("2", "a", "2026-10-02", 1, ""), tx("3", "a", "2026-10-02", 1, "Other"), tx("4", "a", "2026-10-02", 1, "Uncategorised"), tx("5", "a", "2026-10-02", 1, "Food")])).toBe(4); }); + +test("toCsv: text that a spreadsheet would run as a formula is kept as text; amounts stay numbers", () => { + const row = (vendor_name: string, total_amount: number) => ({ id: 1, vendor_name, date: "2026-10-01", total_amount, category: null, created_at: null }); + const csv = toCsv([row("=HYPERLINK(\"http://x\")", 10), row("+91 Store", -400), row("@sum", 5), row("Plain, Co", 1)]).split("\n"); + expect(csv[1]).toBe(`2026-10-01,"'=HYPERLINK(""http://x"")",,Receipt,10`); + expect(csv[2]).toBe("2026-10-01,'+91 Store,,Receipt,-400"); + expect(csv[3]).toBe("2026-10-01,'@sum,,Receipt,5"); + expect(csv[4]).toBe('2026-10-01,"Plain, Co",,Receipt,1'); +}); diff --git a/frontend/next.config.js b/frontend/next.config.js index 200d02c..c5db174 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -32,8 +32,8 @@ const nextConfig = { ["/ai-analytics", "/spending"], ["/chatbot", "/ask"], ["/documents", "/receipts"], - ["/import", "/receipts?tab=import"], - ["/review", "/receipts?tab=review"], + ["/import", "/receipts/import"], + ["/review", "/receipts"], ["/upload", "/home?upload=1"], ].map(([source, destination]) => ({ source, destination, permanent: false })); return [{ source: "/signin", destination: "/login", permanent: true }, ...moved]; diff --git a/frontend/src/app/(app)/budget/budget-content.tsx b/frontend/src/app/(app)/budget/budget-content.tsx index 3b3d719..343cd62 100644 --- a/frontend/src/app/(app)/budget/budget-content.tsx +++ b/frontend/src/app/(app)/budget/budget-content.tsx @@ -63,9 +63,9 @@ export default function BudgetContent() { } } - async function save(body: Parameters[0]) { + async function save(body: Omit[0], "month">) { try { - data.setBudget(await budgetApi.put(body)); + data.setBudget(await budgetApi.put({ month: ym(today), ...body })); data.reload(); } catch { toast.error("Couldn't save the budget."); diff --git a/frontend/src/app/(app)/budget/use-budget-data.ts b/frontend/src/app/(app)/budget/use-budget-data.ts index 4731214..6b19957 100644 --- a/frontend/src/app/(app)/budget/use-budget-data.ts +++ b/frontend/src/app/(app)/budget/use-budget-data.ts @@ -32,7 +32,7 @@ export function useBudgetData(month: Date, today: Date): BudgetData { useEffect(() => { let live = true; const end = new Date(month.getFullYear(), month.getMonth() + 1, 0); - Promise.allSettled([budgetApi.get(key === ym(today) ? undefined : key), fetchTxns(month, end < today ? end : today)]).then(([b, t]) => { + Promise.allSettled([budgetApi.get(key), fetchTxns(month, end < today ? end : today)]).then(([b, t]) => { if (live) setShown({ budget: value(b, null), txns: value(t, []) }); }); return () => { @@ -44,21 +44,24 @@ export function useBudgetData(month: Date, today: Date): BudgetData { useEffect(() => { let live = true; - Promise.allSettled([budgetApi.history(6), agentApi.proposals()]).then(([h, p]) => { + Promise.allSettled([budgetApi.history(6, ym(today)), agentApi.proposals()]).then(([h, p]) => { if (live) setOnce({ history: value(h, []), pending: value(p, []).filter((x) => x.type === "set_budget") }); }); return () => { live = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [day]); const setBudget = useCallback((budget: BudgetState) => setShown((s) => (s ? { ...s, budget } : s)), []); const reload = useCallback(() => { - Promise.allSettled([budgetApi.get(), budgetApi.history(6)]).then(([b, h]) => { + Promise.allSettled([budgetApi.get(ym(today)), budgetApi.history(6, ym(today))]).then(([b, h]) => { if (b.status === "fulfilled") setShown((s) => (s ? { ...s, budget: b.value } : s)); if (h.status === "fulfilled") setOnce((o) => (o ? { ...o, history: h.value } : o)); }); - }, []); + // today is fixed for the page's life; its day is what matters + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [day]); return { status: shown && once ? "ready" : "loading", diff --git a/frontend/src/app/(app)/documents/documentsContent.tsx b/frontend/src/app/(app)/documents/documentsContent.tsx deleted file mode 100644 index 5118239..0000000 --- a/frontend/src/app/(app)/documents/documentsContent.tsx +++ /dev/null @@ -1,194 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { FileText, Loader2, Trash2, Upload } from "lucide-react"; -import { DashboardShell } from "@/components/dashboard-shell"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { - documentsApi, - filesApi, - type DocumentAnswer, - type LumenDocument, -} from "@/lib/api/client"; -import { toast } from "@/lib/toast"; - -const DOC_TYPES = [ - { value: "purchase_order", label: "Purchase order" }, - { value: "contract", label: "Contract" }, - { value: "policy", label: "Policy" }, - { value: "invoice", label: "Invoice" }, - { value: "other", label: "Other" }, -]; - -function errorMessage(error: unknown, fallback: string): string { - const data = (error as { response?: { data?: { error?: string } } })?.response?.data; - return data?.error ?? fallback; -} - -export default function DocumentsContent() { - const [documents, setDocuments] = useState([]); - const [loading, setLoading] = useState(true); - const [uploading, setUploading] = useState(false); - const [docType, setDocType] = useState("contract"); - const [question, setQuestion] = useState(""); - const [asking, setAsking] = useState(false); - const [answer, setAnswer] = useState(null); - const fileInput = useRef(null); - - const refresh = useCallback(async () => { - try { - setDocuments(await documentsApi.list()); - } catch (error) { - toast.error(errorMessage(error, "Couldn't load your documents.")); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void refresh(); - }, [refresh]); - - async function onUpload(file: File | undefined) { - if (!file) return; - setUploading(true); - try { - const doc = await documentsApi.upload(file, docType); - toast.success(`Indexed "${doc.title}" (${doc.chunk_count} sections)`); - await refresh(); - } catch (error) { - toast.error(errorMessage(error, "Upload failed.")); - } finally { - setUploading(false); - if (fileInput.current) fileInput.current.value = ""; - } - } - - async function onDelete(doc: LumenDocument) { - try { - await documentsApi.remove(doc.id); - setDocuments((docs) => docs.filter((d) => d.id !== doc.id)); - } catch (error) { - toast.error(errorMessage(error, "Couldn't delete the document.")); - } - } - - async function onAsk(event: React.FormEvent) { - event.preventDefault(); - if (!question.trim()) return; - setAsking(true); - setAnswer(null); - try { - setAnswer(await documentsApi.ask(question.trim())); - } catch (error) { - const code = (error as { response?: { data?: { code?: string } } })?.response?.data?.code; - const hint = code === "needs_key" || code === "key_rejected" ? " Add or fix your key in Settings." : ""; - toast.error(errorMessage(error, "Couldn't answer right now.") + hint); - } finally { - setAsking(false); - } - } - - return ( - - -
- setQuestion(e.target.value)} - placeholder="e.g. What are the payment terms on PO-U1-202605-06?" - maxLength={500} - /> - -
- {answer && ( -
-

{answer.answer}

- {answer.sources.length > 0 && ( -
-

Sources

- {answer.sources.map((s) => ( -
-

- {s.title} - {s.section ? ` · ${s.section}` : ""} -

-

{s.text}

-
- ))} -
- )} -
- )} -
- - -
-

Your documents

-
- - void onUpload(e.target.files?.[0])} - /> - -
-
- {loading ? ( -

Loading…

- ) : documents.length === 0 ? ( -

No documents yet. Upload a PDF to start asking questions about it.

- ) : ( -
    - {documents.map((doc) => ( -
  • -
    - -
    -

    {doc.title}

    -

    - {DOC_TYPES.find((t) => t.value === doc.doc_type)?.label ?? doc.doc_type} · {doc.chunk_count} sections -

    -
    -
    - {doc.file_key && ( - - )} - -
  • - ))} -
- )} -
-
- ); -} diff --git a/frontend/src/app/(app)/home/_components/activity-card.tsx b/frontend/src/app/(app)/home/_components/activity-card.tsx index 7475e7a..278c785 100644 --- a/frontend/src/app/(app)/home/_components/activity-card.tsx +++ b/frontend/src/app/(app)/home/_components/activity-card.tsx @@ -12,7 +12,7 @@ export function ActivityCard({ items, now, loading }: { items: ActivityItem[]; n {items.length === 0 ? (

Nothing imported yet.{" "} - + Upload a receipt or a statement {" "} to get started. diff --git a/frontend/src/app/(app)/home/use-home-data.ts b/frontend/src/app/(app)/home/use-home-data.ts index 94ee77b..1934f9f 100644 --- a/frontend/src/app/(app)/home/use-home-data.ts +++ b/frontend/src/app/(app)/home/use-home-data.ts @@ -5,18 +5,7 @@ import { aiAnalyticsApi, billsApi, budgetApi, homeApi, transactionApi, type Budg import { parseDay, periods, type Range, type Txn } from "@/lib/home-math"; export type Reminder = { id: string | number; vendor: string; amount: number | null; date: string; type: string; category?: string }; -// Home's bills: active recurring charges due in the next 31 days (api/bills.py). Cancelled ones and ones that look -// stopped aren't bills any more. -export function toBills(items: RecurringItem[], today: Date): Reminder[] { - const until = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 31); - return items - .filter((r) => r.state === "active" && !r.possibly_stopped) - .filter((r) => { - const due = parseDay(r.next_due_date); - return due >= today && due <= until; - }) - .map((r) => ({ id: r.vendor, vendor: r.vendor, amount: r.typical_amount, date: r.next_due_date, type: "upcoming", category: r.category ?? undefined })); -} +import { toBills } from "@/lib/bills-math"; export type Forecast = { dates: string[]; values: number[] } | null; export type HomeData = { status: "loading" | "ready"; @@ -95,7 +84,7 @@ export function useHomeData(range: Range, today: Date): HomeData { Promise.allSettled([ billsApi.recurring().then((r) => toBills(r.items, today)), homeApi.summary(), - budgetApi.get(), + budgetApi.get(`${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`), ]).then(([reminders, summary, budget]) => { if (live) setExtras({ reminders: value(reminders, []), summary: value(summary, null), budget: value(budget, null) }); }); diff --git a/frontend/src/app/(app)/receipts/_components/library.tsx b/frontend/src/app/(app)/receipts/_components/library.tsx new file mode 100644 index 0000000..176a759 --- /dev/null +++ b/frontend/src/app/(app)/receipts/_components/library.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; +import { FileSpreadsheet, FileText, Receipt, Search, Trash2 } from "lucide-react"; +import { PageCard } from "@/components/page/page-card"; +import { filesApi, type LumenDocument, type ReviewItem, type StatementImport } from "@/lib/api/client"; +import { toast } from "@/lib/toast"; +import { useMoney } from "@/lib/format"; +import type { Txn } from "@/lib/home-math"; +import { cn } from "@/lib/utils"; + +type Kind = "receipt" | "document" | "statement"; +export type LibraryRow = { + key: string; + kind: Kind; + title: string; + sub: string; + from: "Upload" | "Gmail" | "Import"; + date: string | null; + amount: number | null; + review: boolean; + /** the original upload, when one was kept */ + fileKey?: string | null; + onDelete?: () => void; +}; +const CHIPS: { label: string; kind: Kind | null }[] = [ + { label: "All", kind: null }, + { label: "Receipts & invoices", kind: "receipt" }, + { label: "Statements", kind: "statement" }, + { label: "Documents", kind: "document" }, +]; +const ICON = { receipt: Receipt, document: FileText, statement: FileSpreadsheet }; +const DOC_TYPE: Record = { purchase_order: "Purchase order", contract: "Contract", policy: "Policy", invoice: "Invoice", other: "Document" }; +const dayMonth = (s: string | null) => (s ? new Date(s.length === 10 ? `${s}T00:00` : s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : "—"); +const range = (a: string | null, b: string | null) => (a && b ? `${dayMonth(a)} – ${dayMonth(b)}` : ""); +const PAGE = 10; + +// Everything the user has given Lumen in one list, newest first (spec 2026-10-22 §4.5): receipts and invoices (with +// the ones still in review), documents, and statement imports. Statement lines themselves are transactions, not files, +// so a statement shows once, as its import. Reading/Failed states aren't stored yet, so every file is Done or in review. +export function toRows(d: { review: ReviewItem[]; receipts: Txn[]; documents: LumenDocument[]; imports: StatementImport[] }, remove: (id: string) => void): LibraryRow[] { + const rows: LibraryRow[] = [ + ...d.review.map((r) => ({ + key: `r-${r.id}`, kind: "receipt" as const, title: String(r.invoice.vendor_name ?? "Unknown vendor"), + sub: `Receipt · ${String(r.invoice.invoice_number ?? "no bill number")}`, from: r.invoice.source === "email" ? ("Gmail" as const) : ("Upload" as const), + date: r.created_at, amount: typeof r.invoice.total_amount === "number" ? r.invoice.total_amount : null, review: true, + fileKey: typeof r.invoice.file_key === "string" ? r.invoice.file_key : null, + })), + ...d.receipts.map((t) => ({ + key: `t-${t.id}`, kind: "receipt" as const, title: t.vendor_name ?? "Unknown vendor", + sub: `Receipt${t.category ? ` · ${t.category}` : ""}`, from: t.source === "email" ? ("Gmail" as const) : ("Upload" as const), + date: t.date, amount: t.total_amount, review: false, fileKey: (t as Txn & { file_key?: string | null }).file_key, + })), + ...d.documents.map((doc) => ({ + key: `d-${doc.id}`, kind: "document" as const, title: doc.title, sub: `${DOC_TYPE[doc.doc_type] ?? "Document"} · ${doc.filename ?? `${doc.chunk_count} sections`}`, + from: "Upload" as const, date: doc.created_at, amount: null, review: false, fileKey: doc.file_key, onDelete: () => remove(doc.id), + })), + ...d.imports.map((i) => ({ + key: `i-${i.id}`, kind: "statement" as const, title: i.label ?? i.file_name ?? "Statement", + sub: `Statement · ${i.lines} lines${i.period_from ? ` · ${range(i.period_from, i.period_to)}` : ""}`, from: "Import" as const, + date: i.created_at, amount: null, review: false, + })), + ]; + return rows.sort((a, b) => (b.date ?? "").localeCompare(a.date ?? "")); +} + +export function Library({ rows }: { rows: LibraryRow[] }) { + const money = useMoney(); + const [kind, setKind] = useState(null); + const [q, setQ] = useState(""); + const [shown, setShown] = useState(PAGE); + const needle = q.trim().toLowerCase(); + const list = rows.filter((r) => (!kind || r.kind === kind) && (!needle || `${r.title} ${r.sub}`.toLowerCase().includes(needle))); + + return ( + {rows.length} {rows.length === 1 ? "file" : "files"}} skeletonHeight={0} data-testid="library"> +

+
+ {CHIPS.filter((c) => !c.kind || rows.some((r) => r.kind === c.kind)).map((c) => ( + + ))} +
+ +
+
+ File + Came from + Date + Amount + Status +
+ {list.length === 0 &&

{rows.length ? "Nothing matches." : "Nothing here yet. Upload a receipt or import a statement."}

} + {list.slice(0, shown).map((r) => { + const Icon = ICON[r.kind]; + return ( +
+ + + + {r.fileKey ? ( + + ) : ( + {r.title} + )} + + {r.sub} + · {r.from} · {dayMonth(r.date)} + + + + {r.from} + {dayMonth(r.date)} + {r.amount === null ? — : money(r.amount)} + + + {r.review ? "Needs review" : "Done"} + + {r.onDelete && ( + + )} + +
+ ); + })} + {list.length > shown && ( + + )} + + ); +} diff --git a/frontend/src/app/(app)/receipts/_components/review-cards.tsx b/frontend/src/app/(app)/receipts/_components/review-cards.tsx new file mode 100644 index 0000000..76e63cc --- /dev/null +++ b/frontend/src/app/(app)/receipts/_components/review-cards.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState } from "react"; +import { PageCard } from "@/components/page/page-card"; +import { VendorChip } from "@/components/page/vendor-chip"; +import { filesApi, reviewApi, type ReviewItem } from "@/lib/api/client"; +import { eventBus, EVENTS } from "@/lib/events"; +import { useMoney } from "@/lib/format"; +import { toast } from "@/lib/toast"; +import { cn } from "@/lib/utils"; + +type Field = { key: "vendor_name" | "date" | "invoice_number" | "total_amount"; label: string }; +const FIELDS: Field[] = [ + { key: "vendor_name", label: "Vendor" }, + { key: "date", label: "Date" }, + { key: "invoice_number", label: "Bill no." }, + { key: "total_amount", label: "Total" }, +]; +// which field each validation flag (backend extract/validate.py) is about, and the reason in plain words +const DOUBT: Record = { total_mismatch: "total_amount", duplicate: "invoice_number", bad_date: "date", unknown_vendor: "vendor_name", no_currency: "total_amount" }; +const REASON: Record = { + total_mismatch: "Totals don't add up.", + duplicate: "Looks like a duplicate.", + bad_date: "Date unclear.", + unknown_vendor: "New to you.", + no_currency: "No currency printed.", + unknown_po: "Purchase order not found.", + po_mismatch: "Doesn't match its purchase order.", + possible_injection: "Unusual text on the bill.", +}; +const dayMonth = (s: string | null) => (s ? new Date(s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : ""); +// the checks' details are terse ("line items 80 + tax 20 != total 110"); the arithmetic one gets a sentence instead +const plain = (rule: string, detail: string) => + rule === "total_mismatch" ? "The items and tax don't add up to the printed total." : `${detail.charAt(0).toUpperCase()}${detail.slice(1)}.`; +// API errors are {error} (api/errors.py); FastAPI's own validation errors are {detail} +const errorText = (e: unknown) => { + const data = (e as { response?: { data?: { error?: string; detail?: unknown } } })?.response?.data; + return data?.error ?? (typeof data?.detail === "string" ? data.detail : undefined); +}; +// a total someone typed: digits with an optional decimal part, more than zero (no "1,234", no blank) +const validTotal = (v: string) => /^\d+(\.\d+)?$/.test(v.trim()) && Number(v) > 0; + +// Needs your review (spec 2026-10-22 §4.5; replaces the old Review tab): one card per flagged receipt with the fields +// the checks doubted highlighted, the reason in plain words, and Approve / Edit / Reject. A duplicate offers "Keep both" +// instead of Approve. Compare is left out: the item doesn't carry the record it duplicates. +export function ReviewCards({ items, onDecided }: { items: ReviewItem[]; onDecided: (id: string) => void }) { + return ( + + {items.length} + Highlighted fields are the ones Lumen wasn't sure about + + } + skeletonHeight={0} + data-testid="review" + > +
+ {items.map((item) => ( + + ))} +
+
+ ); +} + +function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: string) => void }) { + const money = useMoney(); + const inv = item.invoice; + const vendor = String(inv.vendor_name ?? "Unknown vendor"); + const rules = item.flags.map((f) => f.rule); + const doubtful = new Set(rules.map((r) => DOUBT[r]).filter(Boolean)); + const duplicate = rules.includes("duplicate"); + const first = item.flags.find((f) => f.severity !== "note") ?? item.flags[0]; + const [editing, setEditing] = useState(false); + const [edits, setEdits] = useState>({}); + const [busy, setBusy] = useState(false); + const show = (k: Field["key"]) => { + const v = inv[k]; + if (k === "total_amount" && typeof v === "number") return money(v, inv.currency as string | undefined); + // an ISO date reads as "12 Oct 2026"; anything else is shown as printed, since that's what is in doubt + if (k === "date" && typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v)) return new Date(`${v}T00:00`).toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric" }); + return String(v ?? "—"); + }; + + async function decide(decision: "approve" | "reject") { + setBusy(true); + const changed = Object.fromEntries( + Object.entries(edits) + .filter(([k, v]) => v.trim() !== "" && v.trim() !== String(inv[k] ?? "")) + .map(([k, v]) => [k, k === "total_amount" ? Number(v) : v.trim()]), + ); + try { + await reviewApi.decide(item.id, decision, decision === "approve" && Object.keys(changed).length ? { edits: changed } : {}); + onDecided(item.id); + eventBus.emit(EVENTS.REVIEW_DECIDED); + } catch (e) { + toast.error(errorText(e) ?? "Couldn't save that decision."); + setBusy(false); + } + } + + return ( +
+
+ + + {vendor} + + Receipt · {inv.source === "email" ? "from email" : "uploaded"} {dayMonth(item.created_at)} + + + {typeof inv.total_amount === "number" && {money(inv.total_amount, inv.currency as string | undefined)}} +
+
+ {FIELDS.map((f) => ( +
+
{f.label}
+
+ {editing ? ( + setEdits((x) => ({ ...x, [f.key]: e.target.value }))} + className="h-6 w-full rounded border border-border bg-card px-1.5" + /> + ) : ( + show(f.key) + )} +
+
+ ))} +
+ {typeof inv.file_key === "string" && ( + + )} + {first && ( +

+ {REASON[first.rule] ?? "Lumen wasn't sure."} {plain(first.rule, first.detail)} +

+ )} +
+ {editing ? ( + <> + + + + ) : ( + <> + + {!duplicate && ( + + )} + + + )} +
+
+ ); +} diff --git a/frontend/src/app/(app)/receipts/_components/side-cards.tsx b/frontend/src/app/(app)/receipts/_components/side-cards.tsx new file mode 100644 index 0000000..db7feef --- /dev/null +++ b/frontend/src/app/(app)/receipts/_components/side-cards.tsx @@ -0,0 +1,127 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { PageCard } from "@/components/page/page-card"; +import { VendorChip } from "@/components/page/vendor-chip"; +import { GmailOAuthButton } from "@/components/gmailOAuthButton"; +import { emailConfigApi, type StatementImport } from "@/lib/api/client"; +import { toast } from "@/lib/toast"; +import type { EmailStatus } from "../use-receipts-data"; + +const dayMonth = (s: string | null) => (s ? new Date(s.length === 10 ? `${s}T00:00` : s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : ""); +const ago = (s: string) => { + const m = Math.round((Date.now() - new Date(s).getTime()) / 60_000); + return m < 1 ? "just now" : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`; +}; +const PARTS = [ + { key: "imported", label: "new", className: "bg-primary" }, + { key: "matched", label: "matched", className: "bg-primary/45" }, + { key: "duplicates", label: "duplicates", className: "bg-amber-500" }, + { key: "credits", label: "credits skipped", className: "bg-muted-foreground/25" }, +] as const; + +// Statement imports (spec 2026-10-22 §4.5): one entry per import with what became of its lines. Counts are as the +// import returned them; "matched" lines were already recorded from a receipt. +export function ImportsCard({ imports }: { imports: StatementImport[] }) { + return ( + Import statement} + skeletonHeight={0} + data-testid="imports" + > + {imports.length === 0 ? ( +

No statements yet. Import a CSV from your bank or card and its spending lines become transactions.

+ ) : ( +
+ {imports.slice(0, 4).map((i) => { + const total = PARTS.reduce((s, p) => s + i[p.key], 0) || 1; + const name = i.label ?? i.file_name ?? "Statement"; + return ( +
+
+ + + {name} + + {i.period_from ? `${dayMonth(i.period_from)} – ${dayMonth(i.period_to)} · ` : ""}imported {dayMonth(i.created_at)} + + + {i.lines} lines +
+
+ {PARTS.map((p) => i[p.key] > 0 && )} +
+
+ {PARTS.map((p) => i[p.key] > 0 && {i[p.key]} {p.key === "duplicates" && i[p.key] === 1 ? "duplicate" : p.label})} +
+
+ ); + })} +
+ )} +
+ ); +} + +// From your inbox: Gmail (or IMAP) receipt collection. Connected: what it's done and Check now / Pause. Not connected: +// connect Gmail here (it had no home in the app since the old dashboard went). +export function InboxCard({ email, onChanged }: { email: EmailStatus | null; onChanged: () => void }) { + const [busy, setBusy] = useState(false); + async function act(f: () => Promise, done?: string) { + setBusy(true); + try { + await f(); + if (done) toast.success(done); + onChanged(); + } catch { + toast.error("Couldn't reach your inbox right now."); + } finally { + setBusy(false); + } + } + const on = email?.configured; + return ( + {email.polling_enabled ? "Connected" : "Paused"}} + skeletonHeight={0} + data-testid="inbox" + > + {on ? ( + <> +
+ + + {email.email_address} + + {email.last_successful_poll ? `Checked ${ago(email.last_successful_poll)} · ` : ""} + {email.transactions_added ?? 0} receipts added + + +
+ {email.last_error &&

{email.last_error}

} +
+ + +
+ + ) : ( + <> +

Connect Gmail and Lumen files receipts and invoices from your inbox. You still approve anything it isn't sure about.

+ onChanged()} /> + + )} +
+ ); +} diff --git a/frontend/src/app/(app)/import/importContent.tsx b/frontend/src/app/(app)/receipts/import/import-content.tsx similarity index 84% rename from frontend/src/app/(app)/import/importContent.tsx rename to frontend/src/app/(app)/receipts/import/import-content.tsx index 804140c..c51b70b 100644 --- a/frontend/src/app/(app)/import/importContent.tsx +++ b/frontend/src/app/(app)/receipts/import/import-content.tsx @@ -1,10 +1,11 @@ "use client"; -// Bare-bones CSV statement import (SPEC-STATEMENTS). The real screen comes with the UI refactor. +// CSV statement import (SPEC-STATEMENTS), reached from Receipts & statements. Each import is kept in its history +// (UI revamp Phase 5e) under the optional account name. import { Loader2 } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; -import { DashboardShell } from "@/components/dashboard-shell"; +import { PageHeader } from "@/components/page/page-header"; import { useProfile } from "@/components/profile/profile-provider"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -35,6 +36,7 @@ export default function ImportContent() { const [preview, setPreview] = useState(null); const [mapping, setMapping] = useState(null); const [currency, setCurrency] = useState(profile?.currency ?? "USD"); + const [label, setLabel] = useState(""); const [busy, setBusy] = useState<"preview" | "import" | null>(null); const [error, setError] = useState(null); const [raw, setRaw] = useState(null); @@ -72,7 +74,7 @@ export default function ImportContent() { setError(null); setRaw(null); try { - setResult(await statementApi.import(file, mapping, currency, preview.header_line)); + setResult(await statementApi.import(file, mapping, currency, preview.header_line, label)); } catch (e) { fail(e, "Couldn't import that statement."); } finally { @@ -83,10 +85,12 @@ export default function ImportContent() { const money = (n: number | null) => (n == null ? "—" : n.toFixed(2)); return ( - +
+ A CSV from your bank or card. Spending lines become transactions; credits are skipped for now.

} + actions={‹ Receipts & statements} + />
))} +
+ + setLabel(e.target.value)} className={selectClass} /> +
); } diff --git a/frontend/src/app/(app)/receipts/import/page.tsx b/frontend/src/app/(app)/receipts/import/page.tsx new file mode 100644 index 0000000..a4c593e --- /dev/null +++ b/frontend/src/app/(app)/receipts/import/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import ImportContent from "./import-content"; + +export const metadata: Metadata = { title: "Import a statement" }; + +export default function Page() { + return ; +} diff --git a/frontend/src/app/(app)/receipts/page.tsx b/frontend/src/app/(app)/receipts/page.tsx index 66fb472..ad60095 100644 --- a/frontend/src/app/(app)/receipts/page.tsx +++ b/frontend/src/app/(app)/receipts/page.tsx @@ -1,9 +1,8 @@ import type { Metadata } from "next"; -import { ReceiptsTabs } from "./receipts-tabs"; +import ReceiptsContent from "./receipts-content"; export const metadata: Metadata = { title: "Receipts & statements" }; -// Interim (UI revamp Phase 1): the old Documents, Import and Review pages as tabs until Phase 5 redesigns this page. export default function Page() { - return ; + return ; } diff --git a/frontend/src/app/(app)/receipts/receipts-content.tsx b/frontend/src/app/(app)/receipts/receipts-content.tsx new file mode 100644 index 0000000..6a6a0c0 --- /dev/null +++ b/frontend/src/app/(app)/receipts/receipts-content.tsx @@ -0,0 +1,81 @@ +"use client"; + +import Link from "next/link"; +import { FileSpreadsheet, Mail } from "lucide-react"; +import { PageHeader } from "@/components/page/page-header"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { DialogDemo as UploadDialog } from "@/components/uploadDialog"; +import { documentsApi } from "@/lib/api/client"; +import { toast } from "@/lib/toast"; +import { Brief } from "../home/_components/brief"; +import { Library, toRows } from "./_components/library"; +import { ReviewCards } from "./_components/review-cards"; +import { ImportsCard, InboxCard } from "./_components/side-cards"; +import { useReceiptsData } from "./use-receipts-data"; + +// Receipts & statements (UI revamp Phase 5e, spec 2026-10-22 §4.5): what have I given Lumen, and what needs my OK? +// Replaces the Documents / Import / Review tabs. Everything loads together and appears in one step, so the review +// row (only there when something is flagged) never pushes the rest down. +export default function ReceiptsContent() { + const { data, reload, update } = useReceiptsData(); + + async function remove(id: string) { + try { + await documentsApi.remove(id); + update((d) => ({ documents: d.documents.filter((x) => x.id !== id) })); + } catch { + toast.error("Couldn't delete that document."); + } + } + + const n = data?.review.length ?? 0; + const brief = !data ? null : n ? `${n} ${n === 1 ? "receipt needs" : "receipts need"} your OK before ${n === 1 ? "it counts" : "they count"}.` : "Everything you've given Lumen is filed."; + + return ( +
+
+
+ } + actions={ + <> + + + {data && !data.email?.configured && ( + + )} + + } + /> + {!data ? ( + + ) : ( + <> + {n > 0 && ( +
+ update((d) => ({ review: d.review.filter((r) => r.id !== id) }))} /> +
+ )} +
+ void remove(id))} /> +
+ + void reload()} /> +
+
+ + )} +
+
+ ); +} diff --git a/frontend/src/app/(app)/receipts/receipts-tabs.tsx b/frontend/src/app/(app)/receipts/receipts-tabs.tsx deleted file mode 100644 index 694ea98..0000000 --- a/frontend/src/app/(app)/receipts/receipts-tabs.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useSearchParams } from "next/navigation"; -import { cn } from "@/lib/utils"; -import DocumentsContent from "../documents/documentsContent"; -import ImportContent from "../import/importContent"; -import ReviewContent from "../review/reviewContent"; - -const TABS = [ - { id: "documents", label: "Documents", Content: DocumentsContent }, - { id: "import", label: "Import", Content: ImportContent }, - { id: "review", label: "Review", Content: ReviewContent }, -] as const; - -// Reads ?tab= on the client so /receipts stays a static page (no server render per visit). -export function ReceiptsTabs() { - const tab = useSearchParams().get("tab"); - const active = TABS.find((t) => t.id === tab) ?? TABS[0]; - return ( - <> - - - - ); -} diff --git a/frontend/src/app/(app)/receipts/use-receipts-data.ts b/frontend/src/app/(app)/receipts/use-receipts-data.ts new file mode 100644 index 0000000..a66af8f --- /dev/null +++ b/frontend/src/app/(app)/receipts/use-receipts-data.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { documentsApi, emailConfigApi, reviewApi, statementApi, transactionApi, type LumenDocument, type ReviewItem, type StatementImport } from "@/lib/api/client"; +import { eventBus, EVENTS } from "@/lib/events"; +import type { Txn } from "@/lib/home-math"; + +export type EmailStatus = { + configured: boolean; + email_address?: string; + provider?: string; + polling_enabled?: boolean; + transactions_added?: number; + last_successful_poll?: string | null; + last_error?: string | null; +}; + +type Data = { review: ReviewItem[]; documents: LumenDocument[]; receipts: Txn[]; imports: StatementImport[]; email: EmailStatus | null }; +const value = (r: PromiseSettledResult, fallback: T) => (r.status === "fulfilled" ? r.value : fallback); + +// Everything the Receipts & statements page shows, loaded together so the page appears in one step (no layout shift +// from the review row arriving late). A failed call empties only its own part. Uploads elsewhere refresh it. +// ponytail: the library lists the latest 100 receipts (statement lines left out server-side); page through +// /transactions if people need older ones here. +export function useReceiptsData() { + const [data, setData] = useState(null); + + const load = useCallback(async () => { + const [review, documents, txns, imports, email] = await Promise.allSettled([ + reviewApi.list(), + documentsApi.list(), + transactionApi.getTransactions({ page: 1, page_size: 100, exclude_source: "statement" }), + statementApi.imports(), + emailConfigApi.getStatus() as Promise, + ]); + const rows = (value(txns, { data: [] }) as { data?: Txn[] }).data ?? []; + setData({ + review: value(review, []) ?? [], + documents: value(documents, []) ?? [], + receipts: rows, + imports: value(imports, []) ?? [], + email: value(email, null), + }); + }, []); + + useEffect(() => { + void load(); + return eventBus.on(EVENTS.INVOICE_UPDATED, () => void load()); + }, [load]); + + const update = useCallback((change: (d: Data) => Partial) => setData((d) => (d ? { ...d, ...change(d) } : d)), []); + return { data, reload: load, update }; +} diff --git a/frontend/src/app/(app)/review/reviewContent.tsx b/frontend/src/app/(app)/review/reviewContent.tsx deleted file mode 100644 index 6806ff2..0000000 --- a/frontend/src/app/(app)/review/reviewContent.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -// Minimal page to exercise the review queue API. The real review screen comes with the UI refactor. -import { useCallback, useEffect, useState } from "react"; -import { DashboardShell } from "@/components/dashboard-shell"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { filesApi, reviewApi, type ReviewItem } from "@/lib/api/client"; -import { eventBus, EVENTS } from "@/lib/events"; -import { toast } from "@/lib/toast"; - -function errorMessage(error: unknown, fallback: string): string { - return (error as { response?: { data?: { error?: string } } })?.response?.data?.error ?? fallback; -} - -export default function ReviewContent() { - const [items, setItems] = useState([]); - const [totals, setTotals] = useState>({}); - - const load = useCallback(async () => { - try { - setItems(await reviewApi.list()); - } catch (error) { - toast.error(errorMessage(error, "Couldn't load the review queue.")); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - async function decide(item: ReviewItem, decision: "approve" | "reject") { - const total = totals[item.id]; - const edits = decision === "approve" && total ? { total_amount: Number(total) } : {}; - try { - const done = await reviewApi.decide(item.id, decision, { edits }); - toast.success(decision === "approve" ? `Approved${done.flags.length ? " (with warnings)" : ""}` : "Rejected"); - setItems((list) => list.filter((i) => i.id !== item.id)); - eventBus.emit(EVENTS.REVIEW_DECIDED); - } catch (error) { - toast.error(errorMessage(error, "Couldn't save the decision.")); - } - } - - return ( - - - {items.length === 0 ? ( -

Nothing to review. Upload an invoice whose total doesn't match its line items, or the same invoice twice.

- ) : items.map((item) => ( -
-

- {String(item.invoice.vendor_name ?? "Unknown vendor")} · {String(item.invoice.invoice_number ?? "no number")} ·{" "} - {String(item.invoice.date ?? "no date")} · total {String(item.invoice.total_amount ?? "?")} · confidence {item.confidence} -

-
    - {item.flags.map((f) =>
  • {f.severity === "fail" ? "Fail" : "Warning"}: {f.detail}
  • )} -
-
- setTotals((t) => ({ ...t, [item.id]: e.target.value }))} /> - {typeof item.invoice.file_key === "string" && ( - - )} - - -
-
- ))} -
-
- ); -} diff --git a/frontend/src/app/(app)/spending/_components/transactions.tsx b/frontend/src/app/(app)/spending/_components/transactions.tsx index 9bad21e..f37ed76 100644 --- a/frontend/src/app/(app)/spending/_components/transactions.tsx +++ b/frontend/src/app/(app)/spending/_components/transactions.tsx @@ -8,19 +8,14 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { transactionApi } from "@/lib/api/client"; import { useMoney } from "@/lib/format"; import { parseDay, type Txn } from "@/lib/home-math"; +import { sourceOf, toCsv } from "@/lib/spending-math"; import { toast } from "@/lib/toast"; const PAGE = 25; const UNSORTED = new Set(["", "Other", "Uncategorised"]); const dayMonth = (s: string) => parseDay(s).toLocaleString("en-GB", { day: "numeric", month: "short" }); -const sourceOf = (t: Txn) => (t.source === "statement" ? "Statement" : "Receipt"); const inCategory = (t: Txn, c: string) => (c === "Uncategorised" ? !t.category || UNSORTED.has(t.category) : t.category === c); -// CSV of exactly the rows shown (spec 2026-10-22 §4.2): date, merchant, category, source, amount. -const cell = (v: string | number) => (/[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v)); -export const toCsv = (rows: Txn[]) => - ["date,merchant,category,source,amount", ...rows.map((t) => [t.date, t.vendor_name ?? "", t.category ?? "", sourceOf(t), t.total_amount ?? 0].map(cell).join(","))].join("\n"); - // Spending's transactions (spec §4.2): follows the category picked above, plus a search and a source filter. // The category pill edits in place: a menu of known categories, saved with PUT /transactions/. export function Transactions({ diff --git a/frontend/src/components/uploadDialog.tsx b/frontend/src/components/uploadDialog.tsx index 2d4230d..4985d9c 100644 --- a/frontend/src/components/uploadDialog.tsx +++ b/frontend/src/components/uploadDialog.tsx @@ -5,7 +5,9 @@ import { Button } from "@/components/ui/button"; import { Upload, CheckCircle, AlertCircle } from "lucide-react"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { DialogTitle } from "@radix-ui/react-dialog"; -import { ocrApi } from "@/lib/api/client"; +import Link from "next/link"; +import { documentsApi, ocrApi } from "@/lib/api/client"; +import { cn } from "@/lib/utils"; import { eventBus, EVENTS } from "@/lib/events"; import { logger } from "@/lib/logger"; @@ -17,7 +19,17 @@ interface FileWithStatus { extractedData?: any; } +const DOC_TYPES = [ + { value: "contract", label: "Contract" }, + { value: "policy", label: "Policy" }, + { value: "purchase_order", label: "Purchase order" }, + { value: "invoice", label: "Invoice" }, + { value: "other", label: "Other" }, +]; + // Pass `open`/`onOpenChange` to open it from elsewhere too (Home's hero +); otherwise it manages itself. +// Receipts & invoices are read into transactions (/extract); Documents (PDF contracts, policies…) are indexed for +// Ask Lumen's document search (/api/documents). Statements have their own import. export function DialogDemo({ defaultOpen = false, open: openProp, @@ -29,6 +41,8 @@ export function DialogDemo({ }) { const [files, setFiles] = useState([]); const [uploading, setUploading] = useState(false); + const [kind, setKind] = useState<"receipts" | "documents">("receipts"); + const [docType, setDocType] = useState("contract"); const [ownOpen, setOwnOpen] = useState(defaultOpen); const open = openProp ?? ownOpen; const setOpen = (next: boolean) => { @@ -87,7 +101,10 @@ export function DialogDemo({ try { logger.debug("📤 Uploading file:", fileItem.file.name); // Use OCR API to extract data - this saves to database automatically - const result = await ocrApi.extractInvoice(fileItem.file); + const result = + kind === "documents" + ? { success: true, data: await documentsApi.upload(fileItem.file, docType), duplicate: false } + : await ocrApi.extractInvoice(fileItem.file); logger.debug("📥 OCR API Response:", result); @@ -185,7 +202,7 @@ export function DialogDemo({ - Upload documents + Upload
@@ -193,27 +210,63 @@ export function DialogDemo({

- Upload documents + Upload

- Drag & drop files here or click to browse. - Supported: PDF, PNG, JPG + {kind === "receipts" + ? "Lumen reads each one into a transaction. PDF, PNG or JPG." + : "Contracts, policies and other PDFs, searchable from Ask Lumen."}

+
+
+ {([["receipts", "Receipts & invoices"], ["documents", "Documents"]] as const).map(([value, label]) => ( + + ))} +
+ {kind === "documents" && ( + + )} +
+

+ Statements are CSV files:{" "} + setOpen(false)} className="text-foreground underline underline-offset-4"> + import a statement + {" "} + instead. +

+
e.preventDefault()} onDrop={handleDrop} - className="mt-6 rounded-xl border-2 border-dashed border-border p-6 flex flex-col items-center justify-center text-center bg-transparent" + className="mt-4 rounded-xl border-2 border-dashed border-border p-6 flex flex-col items-center justify-center text-center bg-transparent" > diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index c412267..dbd3b92 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -482,8 +482,11 @@ export const transactionApi = { max_amount?: number; sort_by?: string; sort_order?: "asc" | "desc"; + /** e.g. "statement": leave out imported bank lines */ + exclude_source?: string; }) => { const queryParams = new URLSearchParams(); + if (params?.exclude_source) queryParams.append("exclude_source", params.exclude_source); if (params?.page) queryParams.append("page", params.page.toString()); if (params?.page_size) queryParams.append("page_size", params.page_size.toString()); @@ -705,9 +708,10 @@ export interface BudgetMonth { export const budgetApi = { /** `month` "YYYY-MM"; default the current month. Limits are the ones in force that month. */ - get: async (month?: string): Promise => (await apiClient.get("/api/budget", { params: month ? { month } : {} })).data, - history: async (months = 6): Promise => (await apiClient.get("/api/budget/history", { params: { months } })).data.months, - put: async (body: { monthly_budget?: number | null; categories?: { category: string; limit: number | null }[] }): Promise => + // Every call names the browser's month: around midnight on the 1st the server's (UTC) date can be a month off. + get: async (month: string): Promise => (await apiClient.get("/api/budget", { params: { month } })).data, + history: async (months: number, to: string): Promise => (await apiClient.get("/api/budget/history", { params: { months, to } })).data.months, + put: async (body: { month: string; monthly_budget?: number | null; categories?: { category: string; limit: number | null }[] }): Promise => (await apiClient.put("/api/budget", body)).data, /** Files set_budget proposals (nothing is saved until each is approved); [] = not enough history. */ draft: async (): Promise => (await apiClient.post("/api/budget/draft")).data.proposals, @@ -729,7 +733,7 @@ export const agentApi = { export interface ReviewFlag { rule: string; - severity: "warn" | "fail"; + severity: "warn" | "fail" | "note"; // note: a warning not raised because you kept approving it detail: string; } @@ -832,14 +836,31 @@ const upload = (file: File, extra: Record = {}) => { return form; }; +/** One statement import (GET /api/statements/imports); counts as the import returned them. */ +export interface StatementImport { + id: string; + file_name: string | null; + label: string | null; + period_from: string | null; + period_to: string | null; + lines: number; + imported: number; + matched: number; + duplicates: number; + credits: number; + unreadable: number; + created_at: string; +} + export const statementApi = { + imports: async (): Promise => (await apiClient.get("/api/statements/imports")).data.imports, preview: async (file: File): Promise => (await apiClient.post("/api/statements/preview", upload(file), { headers: { "Content-Type": undefined } })).data, - import: async (file: File, mapping: StatementMapping, currency: string, headerLine: number): Promise => + import: async (file: File, mapping: StatementMapping, currency: string, headerLine: number, label = ""): Promise => ( await apiClient.post( "/api/statements/import", - upload(file, { mapping: JSON.stringify(mapping), currency, header_line: String(headerLine) }), + upload(file, { mapping: JSON.stringify(mapping), currency, header_line: String(headerLine), ...(label.trim() ? { label: label.trim() } : {}) }), { headers: { "Content-Type": undefined } }, ) ).data, diff --git a/frontend/src/lib/bills-math.ts b/frontend/src/lib/bills-math.ts index 78bf36f..5f5b667 100644 --- a/frontend/src/lib/bills-math.ts +++ b/frontend/src/lib/bills-math.ts @@ -4,6 +4,7 @@ import type { RecurringItem } from "@/lib/api/client"; import { parseDay } from "@/lib/home-math"; const DAY = 86_400_000; +const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); // still being charged: not cancelled (a "charged again" one is) and not looking stopped const live = (i: RecurringItem) => i.state !== "cancelled" && !i.possibly_stopped; @@ -25,30 +26,44 @@ export function dueDays(i: RecurringItem, year: number, month: number): number[] return out; } -type Due = { vendor: string; date: Date; amount: number; item: RecurringItem }; +type Due = { vendor: string; date: Date; amount: number; item: RecurringItem; late?: boolean }; -// Every expected charge from today to 30 days out (weekly ones more than once), soonest first. +// Every expected charge from today to 30 days out (weekly ones more than once), soonest first. A charge whose expected +// date has passed without being seen is still coming (bills drift, statements arrive in batches), so it's kept, marked +// late, until the detection calls it stopped. function dueSoon(items: RecurringItem[], today: Date): Due[] { const until = addDays(today, 30); const out: Due[] = []; for (const i of items.filter(live)) { + const first = parseDay(i.next_due_date); + if (first < today) out.push({ vendor: i.vendor, date: first, amount: i.typical_amount, item: i, late: true }); const step = i.cadence === "weekly" ? 7 : null; - for (let d = parseDay(i.next_due_date); d <= until; d = step ? addDays(d, step) : addDays(until, 1)) + for (let d = first; d <= until; d = step ? addDays(d, step) : addDays(until, 1)) if (d >= today) out.push({ vendor: i.vendor, date: d, amount: i.typical_amount, item: i }); } return out.sort((a, b) => a.date.getTime() - b.date.getTime()); } -// The next 30 days as This week (to Sunday), Next week and Later, each with its total; empty groups are left out. +// Home's "Coming up": the same charges for the next month, late ones first, in Home's reminder shape. +export function toBills(items: RecurringItem[], today: Date) { + return dueSoon(items, today).map((d) => ({ + id: `${d.vendor}-${d.date.toDateString()}`, vendor: d.vendor, amount: d.amount, date: ymd(d.date), + type: d.late ? "late" : "upcoming", category: d.item.category ?? undefined, + })); +} + +// Not seen yet (late), then the next 30 days as This week (to Sunday), Next week and Later, each with its total; +// empty groups are left out. export function upcoming(items: RecurringItem[], today: Date): { label: string; rows: Due[]; total: number }[] { const sunday = addDays(today, 6 - ((today.getDay() + 6) % 7)); const nextSunday = addDays(sunday, 7); const groups = [ + { label: "Not seen yet", rows: [] as Due[] }, { label: "This week", rows: [] as Due[] }, { label: "Next week", rows: [] as Due[] }, { label: "Later", rows: [] as Due[] }, ]; - for (const d of dueSoon(items, today)) groups[d.date <= sunday ? 0 : d.date <= nextSunday ? 1 : 2].rows.push(d); + for (const d of dueSoon(items, today)) groups[d.late ? 0 : d.date <= sunday ? 1 : d.date <= nextSunday ? 2 : 3].rows.push(d); return groups.filter((g) => g.rows.length).map((g) => ({ ...g, total: g.rows.reduce((s, r) => s + r.amount, 0) })); } diff --git a/frontend/src/lib/spending-math.ts b/frontend/src/lib/spending-math.ts index 52cdf7b..be95ef2 100644 --- a/frontend/src/lib/spending-math.ts +++ b/frontend/src/lib/spending-math.ts @@ -74,3 +74,16 @@ export function monthlyTotals(txns: Txn[], today: Date, months: number): { month export const needsCategory = (txns: Txn[]) => txns.filter((t) => !t.category || t.category === "Other" || t.category === UNCATEGORISED).length; + +export const sourceOf = (t: Txn) => (t.source === "statement" ? "Statement" : "Receipt"); + +// One CSV cell. Vendor names come from bank lines and emailed invoices, so text a spreadsheet would run as a formula +// (=, +, -, @, tab or CR first) gets a leading ' to stay text; amounts are numbers and stay as they are. +const cell = (v: string | number) => { + const s = typeof v === "string" && /^[=+\-@\t\r]/.test(v) ? `'${v}` : String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +}; + +// CSV of exactly the rows shown (spec 2026-10-22 §4.2): date, merchant, category, source, amount. +export const toCsv = (rows: Txn[]) => + ["date,merchant,category,source,amount", ...rows.map((t) => [t.date, t.vendor_name ?? "", t.category ?? "", sourceOf(t), t.total_amount ?? 0].map(cell).join(","))].join("\n");