From b08666f6f39e61d0eb7da5aa92e08b4613e772e7 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:25:41 +0530 Subject: [PATCH 1/5] docs: detail the Phase 5e plan (Receipts & statements) --- .../plans/2026-10-22-ui-revamp-phase5.md | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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. From d32db1854e364fe242167a1d420f3d943738a935 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:28:59 +0530 Subject: [PATCH 2/5] feat: keep a history of statement imports (GET /api/statements/imports) --- backend/api/statements.py | 30 +++++++++++++++++++++++++--- backend/models/__init__.py | 21 +++++++++++++++++++ backend/tests/test_api_statements.py | 28 ++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) 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/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 From cf5d2af7e9d356767a675248d348aaa3301bab2a Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:40:59 +0530 Subject: [PATCH 3/5] feat: Receipts & statements page (review cards, one library, statement imports, inbox) replaces the old tabs --- backend/ai/home_signals.py | 2 +- backend/tests/test_home_signals.py | 2 +- frontend/e2e/fixtures.ts | 37 +++- frontend/e2e/home.spec.ts | 2 +- frontend/e2e/receipts.spec.ts | 174 ++++++++++++++++ frontend/e2e/shell.spec.ts | 8 +- frontend/next.config.js | 4 +- .../app/(app)/documents/documentsContent.tsx | 194 ------------------ .../(app)/home/_components/activity-card.tsx | 2 +- .../(app)/receipts/_components/library.tsx | 137 +++++++++++++ .../receipts/_components/review-cards.tsx | 163 +++++++++++++++ .../(app)/receipts/_components/side-cards.tsx | 127 ++++++++++++ .../import/import-content.tsx} | 30 ++- .../src/app/(app)/receipts/import/page.tsx | 8 + frontend/src/app/(app)/receipts/page.tsx | 5 +- .../app/(app)/receipts/receipts-content.tsx | 81 ++++++++ .../src/app/(app)/receipts/receipts-tabs.tsx | 42 ---- .../app/(app)/receipts/use-receipts-data.ts | 52 +++++ .../src/app/(app)/review/reviewContent.tsx | 76 ------- frontend/src/components/uploadDialog.tsx | 69 ++++++- frontend/src/lib/api/client.ts | 23 ++- 21 files changed, 886 insertions(+), 352 deletions(-) create mode 100644 frontend/e2e/receipts.spec.ts delete mode 100644 frontend/src/app/(app)/documents/documentsContent.tsx create mode 100644 frontend/src/app/(app)/receipts/_components/library.tsx create mode 100644 frontend/src/app/(app)/receipts/_components/review-cards.tsx create mode 100644 frontend/src/app/(app)/receipts/_components/side-cards.tsx rename frontend/src/app/(app)/{import/importContent.tsx => receipts/import/import-content.tsx} (84%) create mode 100644 frontend/src/app/(app)/receipts/import/page.tsx create mode 100644 frontend/src/app/(app)/receipts/receipts-content.tsx delete mode 100644 frontend/src/app/(app)/receipts/receipts-tabs.tsx create mode 100644 frontend/src/app/(app)/receipts/use-receipts-data.ts delete mode 100644 frontend/src/app/(app)/review/reviewContent.tsx 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/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/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 7d0da98..611308a 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,37 @@ 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 === "/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") { @@ -131,7 +156,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 } }); 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..8d40b1a --- /dev/null +++ b/frontend/e2e/receipts.spec.ts @@ -0,0 +1,174 @@ +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); +}); 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/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)/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)/receipts/_components/library.tsx b/frontend/src/app/(app)/receipts/_components/library.tsx new file mode 100644 index 0000000..7fb156b --- /dev/null +++ b/frontend/src/app/(app)/receipts/_components/library.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useState } from "react"; +import { FileSpreadsheet, FileText, Receipt, Search, Trash2 } from "lucide-react"; +import { PageCard } from "@/components/page/page-card"; +import type { LumenDocument, ReviewItem, StatementImport } from "@/lib/api/client"; +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; + 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, + })), + ...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, + })), + ...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, 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.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..4535a55 --- /dev/null +++ b/frontend/src/app/(app)/receipts/_components/review-cards.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState } from "react"; +import { PageCard } from "@/components/page/page-card"; +import { VendorChip } from "@/components/page/vendor-chip"; +import { 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)}.`; +const errorText = (e: unknown) => (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail; + +// 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() !== 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) + )} +
+
+ ))} +
+ {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..3f9bfb6 --- /dev/null +++ b/frontend/src/app/(app)/receipts/use-receipts-data.ts @@ -0,0 +1,52 @@ +"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; 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 }), + statementApi.imports(), + emailConfigApi.getStatus() as Promise, + ]); + const rows = (value(txns, { data: [] }) as { data?: Txn[] }).data ?? []; + setData({ + review: value(review, []) ?? [], + documents: value(documents, []) ?? [], + receipts: rows.filter((t) => t.source !== "statement"), + 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/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..eae48bf 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -729,7 +729,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 +832,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, From 748dc97d8a8deb3451467fd07ebbe2496f86d89f Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:46:40 +0530 Subject: [PATCH 4/5] docs: STORY 36, codebase guide and spec backlog for Phase 5e --- docs/CODEBASE-GUIDE.md | 22 ++++++++++++++----- docs/direction/STORY.md | 18 +++++++++++++++ .../specs/2026-10-20-ui-revamp-design.md | 2 ++ 3 files changed, 37 insertions(+), 5 deletions(-) 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..2cada52 100644 --- a/docs/direction/STORY.md +++ b/docs/direction/STORY.md @@ -461,3 +461,21 @@ 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. + 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..52b4433 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,7 @@ 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)* - [ ] **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)* From b3f1fa52ce092fa9a6241d775b08af4547a8cf7a Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:16:39 +0530 Subject: [PATCH 5/5] fix: Phase 5 review findings (receipts filter, past-month and stale-draft budgets, month skew, late bills, open original, review errors and totals, CSV formulas) --- backend/api/agent.py | 4 +- backend/api/budget.py | 31 ++++++++++---- backend/api/review.py | 13 +++++- backend/routes/database_query.py | 4 ++ backend/tests/test_api_budget.py | 42 +++++++++++++++++++ backend/tests/test_api_review.py | 7 ++++ backend/tests/test_invoice_upload.py | 15 +++++++ docs/direction/STORY.md | 7 ++++ .../specs/2026-10-20-ui-revamp-design.md | 9 ++++ frontend/e2e/bills-math.spec.ts | 22 +++++++++- frontend/e2e/budget.spec.ts | 12 +++++- frontend/e2e/fixtures.ts | 6 ++- frontend/e2e/receipts.spec.ts | 37 ++++++++++++++++ frontend/e2e/spending-math.spec.ts | 11 ++++- .../src/app/(app)/budget/budget-content.tsx | 4 +- .../src/app/(app)/budget/use-budget-data.ts | 11 +++-- frontend/src/app/(app)/home/use-home-data.ts | 15 +------ .../(app)/receipts/_components/library.tsx | 23 ++++++++-- .../receipts/_components/review-cards.tsx | 28 +++++++++++-- .../app/(app)/receipts/use-receipts-data.ts | 7 ++-- .../spending/_components/transactions.tsx | 7 +--- frontend/src/lib/api/client.ts | 10 +++-- frontend/src/lib/bills-math.ts | 25 ++++++++--- frontend/src/lib/spending-math.ts | 13 ++++++ 24 files changed, 305 insertions(+), 58 deletions(-) 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/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_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/direction/STORY.md b/docs/direction/STORY.md index 2cada52..1b98fd6 100644 --- a/docs/direction/STORY.md +++ b/docs/direction/STORY.md @@ -478,4 +478,11 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in 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/specs/2026-10-20-ui-revamp-design.md b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md index 52b4433..9491a80 100644 --- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -247,5 +247,14 @@ Small items found while building, parked here so they aren't lost. Tick them off - [ ] **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 611308a..ab9f57e 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -104,6 +104,7 @@ export async function signIn( 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 } }); @@ -136,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); @@ -186,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/receipts.spec.ts b/frontend/e2e/receipts.spec.ts index 8d40b1a..007dac1 100644 --- a/frontend/e2e/receipts.spec.ts +++ b/frontend/e2e/receipts.spec.ts @@ -172,3 +172,40 @@ test("receipts fits a phone", 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("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/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/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)/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 index 7fb156b..176a759 100644 --- a/frontend/src/app/(app)/receipts/_components/library.tsx +++ b/frontend/src/app/(app)/receipts/_components/library.tsx @@ -3,7 +3,8 @@ import { useState } from "react"; import { FileSpreadsheet, FileText, Receipt, Search, Trash2 } from "lucide-react"; import { PageCard } from "@/components/page/page-card"; -import type { LumenDocument, ReviewItem, StatementImport } from "@/lib/api/client"; +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"; @@ -18,6 +19,8 @@ export type LibraryRow = { 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 }[] = [ @@ -41,15 +44,16 @@ export function toRows(d: { review: ReviewItem[]; receipts: Txn[]; documents: Lu 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, + 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, onDelete: () => remove(doc.id), + 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", @@ -104,7 +108,18 @@ export function Library({ rows }: { rows: LibraryRow[] }) { - {r.title} + {r.fileKey ? ( + + ) : ( + {r.title} + )} {r.sub} · {r.from} · {dayMonth(r.date)} diff --git a/frontend/src/app/(app)/receipts/_components/review-cards.tsx b/frontend/src/app/(app)/receipts/_components/review-cards.tsx index 4535a55..76e63cc 100644 --- a/frontend/src/app/(app)/receipts/_components/review-cards.tsx +++ b/frontend/src/app/(app)/receipts/_components/review-cards.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { PageCard } from "@/components/page/page-card"; import { VendorChip } from "@/components/page/vendor-chip"; -import { reviewApi, type ReviewItem } from "@/lib/api/client"; +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"; @@ -32,7 +32,13 @@ const dayMonth = (s: string | null) => (s ? new Date(s).toLocaleString("en-GB", // 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)}.`; -const errorText = (e: unknown) => (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail; +// 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" @@ -82,7 +88,7 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str setBusy(true); const changed = Object.fromEntries( Object.entries(edits) - .filter(([k, v]) => v.trim() !== String(inv[k] ?? "")) + .filter(([k, v]) => v.trim() !== "" && v.trim() !== String(inv[k] ?? "")) .map(([k, v]) => [k, k === "total_amount" ? Number(v) : v.trim()]), ); try { @@ -127,6 +133,15 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
))} + {typeof inv.file_key === "string" && ( + + )} {first && (

{REASON[first.rule] ?? "Lumen wasn't sure."} {plain(first.rule, first.detail)} @@ -135,7 +150,12 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str

{editing ? ( <> -