From 4da7ec64ce5042444fd78ff0d438f8554038c85e Mon Sep 17 00:00:00 2001 From: Evin Bento Date: Tue, 23 Jun 2026 16:43:53 -0400 Subject: [PATCH 01/13] feat(db): add safe_to_spend_cache table --- backend/migrations/016_safe_to_spend_cache.sql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 backend/migrations/016_safe_to_spend_cache.sql diff --git a/backend/migrations/016_safe_to_spend_cache.sql b/backend/migrations/016_safe_to_spend_cache.sql new file mode 100644 index 0000000..da7412d --- /dev/null +++ b/backend/migrations/016_safe_to_spend_cache.sql @@ -0,0 +1,15 @@ +-- backend/migrations/016_safe_to_spend_cache.sql + +CREATE TABLE IF NOT EXISTS safe_to_spend_cache ( + user_id UUID PRIMARY KEY REFERENCES users ON DELETE CASCADE, + safe_amount DECIMAL NOT NULL, + breakdown JSONB NOT NULL DEFAULT '{}', + computed_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE safe_to_spend_cache ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "safe_to_spend_cache_user_policy" + ON safe_to_spend_cache + FOR ALL + USING (user_id = auth.uid()); From 3ab982462c1d5f2aed7ea8efb755d633946fa4fb Mon Sep 17 00:00:00 2001 From: Evin Bento Date: Tue, 23 Jun 2026 16:44:27 -0400 Subject: [PATCH 02/13] =?UTF-8?q?feat(engine):=20SafeToSpendEngine=20?= =?UTF-8?q?=E2=80=94=20pure=20math,=20no=20AI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/engines/__init__.py | 0 backend/engines/safe_to_spend.py | 40 ++++++++++++++++++ backend/tests/test_safe_to_spend_engine.py | 49 ++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 backend/engines/__init__.py create mode 100644 backend/engines/safe_to_spend.py create mode 100644 backend/tests/test_safe_to_spend_engine.py diff --git a/backend/engines/__init__.py b/backend/engines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/engines/safe_to_spend.py b/backend/engines/safe_to_spend.py new file mode 100644 index 0000000..5f7a121 --- /dev/null +++ b/backend/engines/safe_to_spend.py @@ -0,0 +1,40 @@ +from datetime import date, timedelta + +_SCHEDULE_DAYS = { + "weekly": 7, + "biweekly": 14, + "semimonthly": 15, + "monthly": 30, +} + + +def days_until_next_payday(pay_schedule: str) -> int: + return _SCHEDULE_DAYS.get(pay_schedule, 30) + + +def compute_safe_to_spend( + balance: float, + bills: list[dict], + pay_schedule: str, + buffer_reserve: float, +) -> dict: + window = days_until_next_payday(pay_schedule) + cutoff = date.today() + timedelta(days=window) + + bills_due = sum( + b["avg_amount"] + for b in bills + if b.get("next_due_date") and date.fromisoformat(b["next_due_date"]) <= cutoff + ) + + safe = max(0.0, balance - bills_due - buffer_reserve) + + return { + "safe_amount": round(safe, 2), + "breakdown": { + "balance": balance, + "bills_due": round(bills_due, 2), + "buffer_reserve": buffer_reserve, + "window_days": window, + }, + } diff --git a/backend/tests/test_safe_to_spend_engine.py b/backend/tests/test_safe_to_spend_engine.py new file mode 100644 index 0000000..18c2210 --- /dev/null +++ b/backend/tests/test_safe_to_spend_engine.py @@ -0,0 +1,49 @@ +from datetime import date, timedelta + +from engines.safe_to_spend import compute_safe_to_spend, days_until_next_payday + + +def test_days_until_next_payday_biweekly(): + days = days_until_next_payday("biweekly") + assert 0 <= days <= 14 + + +def test_safe_amount_subtracts_bills_due_outside_window(): + bills = [{"avg_amount": 100.0, "next_due_date": "2099-01-01"}] + result = compute_safe_to_spend( + balance=1000.0, + bills=bills, + pay_schedule="monthly", + buffer_reserve=50.0, + ) + assert result["safe_amount"] == 1000.0 - 50.0 + + +def test_safe_amount_subtracts_bills_due_within_window(): + due_soon = (date.today() + timedelta(days=3)).isoformat() + bills = [{"avg_amount": 200.0, "next_due_date": due_soon}] + result = compute_safe_to_spend( + balance=1000.0, + bills=bills, + pay_schedule="monthly", + buffer_reserve=50.0, + ) + assert result["safe_amount"] == 1000.0 - 200.0 - 50.0 + + +def test_safe_amount_never_negative(): + bills = [{"avg_amount": 9999.0, "next_due_date": "2025-01-02"}] + result = compute_safe_to_spend( + balance=100.0, + bills=bills, + pay_schedule="weekly", + buffer_reserve=50.0, + ) + assert result["safe_amount"] == 0.0 + + +def test_breakdown_keys_present(): + result = compute_safe_to_spend(1000.0, [], "biweekly", 100.0) + assert "balance" in result["breakdown"] + assert "bills_due" in result["breakdown"] + assert "buffer_reserve" in result["breakdown"] From 760a30c183e1666932ab02727f35e5accef9dc6e Mon Sep 17 00:00:00 2001 From: Evin Bento Date: Tue, 23 Jun 2026 16:45:37 -0400 Subject: [PATCH 03/13] feat(tasks): nightly Safe to Spend recompute Celery task --- backend/celery_app.py | 11 +++++ backend/tasks/recompute_safe_to_spend.py | 44 +++++++++++++++++ backend/tests/test_recompute_safe_to_spend.py | 48 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 backend/tasks/recompute_safe_to_spend.py create mode 100644 backend/tests/test_recompute_safe_to_spend.py diff --git a/backend/celery_app.py b/backend/celery_app.py index 8b3efbe..97809fd 100644 --- a/backend/celery_app.py +++ b/backend/celery_app.py @@ -16,6 +16,7 @@ "tasks.detect_subscriptions", "tasks.categorize_transactions", "tasks.run_intelligence_pipeline", + "tasks.recompute_safe_to_spend", ], ) @@ -26,3 +27,13 @@ timezone="UTC", enable_utc=True, ) + +from celery.schedules import crontab # noqa: E402 + +celery.conf.beat_schedule = { + "nightly-safe-to-spend-recompute": { + "task": "tasks.recompute_safe_to_spend.recompute_safe_to_spend_for_user", + "schedule": crontab(hour=2, minute=0), + "args": [], + }, +} diff --git a/backend/tasks/recompute_safe_to_spend.py b/backend/tasks/recompute_safe_to_spend.py new file mode 100644 index 0000000..fd17e0f --- /dev/null +++ b/backend/tasks/recompute_safe_to_spend.py @@ -0,0 +1,44 @@ +from celery_app import celery +from db.client import get_supabase +from engines.safe_to_spend import compute_safe_to_spend + +_DEFAULT_BUFFER = 100.0 + + +@celery.task +def recompute_safe_to_spend_for_user(user_id: str) -> None: + supabase = get_supabase() + + accounts = ( + supabase.table("accounts") + .select("id, balance") + .eq("user_id", user_id) + .execute() + ).data or [] + balance = sum(a.get("balance") or 0 for a in accounts) + + bills = ( + supabase.table("bills") + .select("avg_amount, next_due_date") + .eq("user_id", user_id) + .execute() + ).data or [] + + onboarding = ( + supabase.table("onboarding_responses") + .select("pay_schedule") + .eq("user_id", user_id) + .execute() + ).data or [] + pay_schedule = (onboarding[0].get("pay_schedule") or "monthly") if onboarding else "monthly" + + result = compute_safe_to_spend(balance, bills, pay_schedule, _DEFAULT_BUFFER) + + supabase.table("safe_to_spend_cache").upsert( + { + "user_id": user_id, + "safe_amount": result["safe_amount"], + "breakdown": result["breakdown"], + }, + on_conflict="user_id", + ).execute() diff --git a/backend/tests/test_recompute_safe_to_spend.py b/backend/tests/test_recompute_safe_to_spend.py new file mode 100644 index 0000000..b841fbc --- /dev/null +++ b/backend/tests/test_recompute_safe_to_spend.py @@ -0,0 +1,48 @@ +from unittest.mock import MagicMock, patch + + +def test_recompute_stores_result(): + mock_supabase = MagicMock() + accounts_mock = MagicMock() + accounts_mock.data = [{"id": "acc-1", "balance": 1000.0}] + bills_mock = MagicMock() + bills_mock.data = [] + onboarding_mock = MagicMock() + onboarding_mock.data = [{"pay_schedule": "biweekly"}] + + cache_table_mock = MagicMock() + upserted_payloads = [] + + def capture_upsert(payload, **kwargs): + upserted_payloads.append(payload) + return MagicMock() + + cache_table_mock.upsert.side_effect = capture_upsert + + def table_side_effect(name): + if name == "accounts": + m = MagicMock() + m.select.return_value.eq.return_value.execute.return_value = accounts_mock + return m + elif name == "bills": + m = MagicMock() + m.select.return_value.eq.return_value.execute.return_value = bills_mock + return m + elif name == "onboarding_responses": + m = MagicMock() + m.select.return_value.eq.return_value.execute.return_value = onboarding_mock + return m + elif name == "safe_to_spend_cache": + return cache_table_mock + return MagicMock() + + mock_supabase.table.side_effect = table_side_effect + + with patch("tasks.recompute_safe_to_spend.get_supabase", return_value=mock_supabase): + from tasks.recompute_safe_to_spend import recompute_safe_to_spend_for_user + recompute_safe_to_spend_for_user("user-1") + + assert len(upserted_payloads) == 1 + payload = upserted_payloads[0] + assert "safe_amount" in payload + assert payload["user_id"] == "user-1" From aaf4fcff21bc0624f8639c8181396d34b29a2727 Mon Sep 17 00:00:00 2001 From: Evin Bento Date: Tue, 23 Jun 2026 16:46:46 -0400 Subject: [PATCH 04/13] feat(api): GET /insights/safe-to-spend --- backend/routers/insights.py | 32 +++++++++++ backend/tests/test_safe_to_spend_endpoint.py | 57 ++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 backend/tests/test_safe_to_spend_endpoint.py diff --git a/backend/routers/insights.py b/backend/routers/insights.py index d5809d8..f413d04 100644 --- a/backend/routers/insights.py +++ b/backend/routers/insights.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, Query from db.client import get_supabase +from engines.safe_to_spend import compute_safe_to_spend from middleware.auth import get_current_user router = APIRouter(prefix="/insights", tags=["insights"]) @@ -29,3 +30,34 @@ async def get_insights( if (r.get("structured_output_json") or {}).get("signal_type") == signal_type ] return data + + +@router.get("/safe-to-spend") +async def get_safe_to_spend(user_id: str = Depends(get_current_user)): + supabase = get_supabase() + cached = ( + supabase.table("safe_to_spend_cache") + .select("safe_amount, breakdown, computed_at") + .eq("user_id", user_id) + .execute() + ).data or [] + + if cached: + return cached[0] + + accounts = ( + supabase.table("accounts").select("balance").eq("user_id", user_id).execute() + ).data or [] + balance = sum(a.get("balance") or 0 for a in accounts) + + bills = ( + supabase.table("bills").select("avg_amount, next_due_date").eq("user_id", user_id).execute() + ).data or [] + + onboarding = ( + supabase.table("onboarding_responses").select("pay_schedule").eq("user_id", user_id).execute() + ).data or [] + pay_schedule = (onboarding[0].get("pay_schedule") or "monthly") if onboarding else "monthly" + + result = compute_safe_to_spend(balance, bills, pay_schedule, 100.0) + return {**result, "computed_at": None} diff --git a/backend/tests/test_safe_to_spend_endpoint.py b/backend/tests/test_safe_to_spend_endpoint.py new file mode 100644 index 0000000..0dd93ea --- /dev/null +++ b/backend/tests/test_safe_to_spend_endpoint.py @@ -0,0 +1,57 @@ +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from main import app +from middleware.auth import get_current_user + +app.dependency_overrides[get_current_user] = lambda: "user-1" +client = TestClient(app) + + +def test_get_safe_to_spend_returns_cached_value(): + mock_supabase = MagicMock() + + def table_side(name): + m = MagicMock() + if name == "safe_to_spend_cache": + m.select.return_value.eq.return_value.execute.return_value.data = [ + { + "safe_amount": 450.0, + "breakdown": {"balance": 600.0, "bills_due": 100.0, "buffer_reserve": 50.0, "window_days": 14}, + "computed_at": "2026-06-01T02:00:00Z", + } + ] + return m + + mock_supabase.table.side_effect = table_side + + with patch("routers.insights.get_supabase", return_value=mock_supabase): + resp = client.get("/insights/safe-to-spend") + + assert resp.status_code == 200 + assert resp.json()["safe_amount"] == 450.0 + + +def test_get_safe_to_spend_live_compute_when_no_cache(): + mock_supabase = MagicMock() + + def table_side(name): + m = MagicMock() + if name == "safe_to_spend_cache": + m.select.return_value.eq.return_value.execute.return_value.data = [] + elif name == "accounts": + m.select.return_value.eq.return_value.execute.return_value.data = [{"balance": 800.0}] + elif name == "bills": + m.select.return_value.eq.return_value.execute.return_value.data = [] + elif name == "onboarding_responses": + m.select.return_value.eq.return_value.execute.return_value.data = [{"pay_schedule": "biweekly"}] + return m + + mock_supabase.table.side_effect = table_side + + with patch("routers.insights.get_supabase", return_value=mock_supabase): + resp = client.get("/insights/safe-to-spend") + + assert resp.status_code == 200 + assert "safe_amount" in resp.json() From 225b34c9ea398edfa730d4129f9dbf611ced2672 Mon Sep 17 00:00:00 2001 From: Evin Bento Date: Tue, 23 Jun 2026 16:47:16 -0400 Subject: [PATCH 05/13] feat(argus): register safe-to-spend as Argus tool --- backend/agents/tools.py | 18 ++++++++++++++++++ backend/tests/test_safe_to_spend_tool.py | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 backend/tests/test_safe_to_spend_tool.py diff --git a/backend/agents/tools.py b/backend/agents/tools.py index 72a3416..6e0add3 100644 --- a/backend/agents/tools.py +++ b/backend/agents/tools.py @@ -87,3 +87,21 @@ def _get_spending_by_category_tool(user_id: str) -> dict: .execute() ) return {"transactions": result.data or []} + + +@register_tool( + "get_safe_to_spend", + "Returns how much money is safe to spend today without risking upcoming bills", + keywords=["safe to spend", "safe amount", "how much can i spend", "available money"], +) +def _get_safe_to_spend_tool(user_id: str) -> dict: + supabase = get_supabase() + cached = ( + supabase.table("safe_to_spend_cache") + .select("safe_amount, breakdown, computed_at") + .eq("user_id", user_id) + .execute() + ).data or [] + if cached: + return cached[0] + return {"safe_amount": None, "breakdown": {}, "computed_at": None} diff --git a/backend/tests/test_safe_to_spend_tool.py b/backend/tests/test_safe_to_spend_tool.py new file mode 100644 index 0000000..59cbbbd --- /dev/null +++ b/backend/tests/test_safe_to_spend_tool.py @@ -0,0 +1,18 @@ +from unittest.mock import MagicMock, patch + + +def test_safe_to_spend_tool_registered(): + from agents.tools import get_registered_tools + tools = get_registered_tools() + assert "get_safe_to_spend" in tools + + +def test_safe_to_spend_tool_returns_amount(): + mock_supabase = MagicMock() + mock_supabase.table.return_value.select.return_value.eq.return_value.execute.return_value.data = [ + {"safe_amount": 300.0, "breakdown": {}} + ] + with patch("agents.tools.get_supabase", return_value=mock_supabase): + from agents.tools import call_tool + result = call_tool("get_safe_to_spend", "user-1") + assert result["safe_amount"] == 300.0 From 581b4f326e0327382627822674cb1f7d4b209811 Mon Sep 17 00:00:00 2001 From: Evin Bento Date: Tue, 23 Jun 2026 16:48:12 -0400 Subject: [PATCH 06/13] =?UTF-8?q?feat(engine):=20PayTimingEngine=20?= =?UTF-8?q?=E2=80=94=20utilization=20optimizer=20+=20bill=20stacking=20det?= =?UTF-8?q?ector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/engines/pay_timing.py | 62 +++++++++++++++++++++++ backend/tests/test_pay_timing_engine.py | 67 +++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 backend/engines/pay_timing.py create mode 100644 backend/tests/test_pay_timing_engine.py diff --git a/backend/engines/pay_timing.py b/backend/engines/pay_timing.py new file mode 100644 index 0000000..3fbbac8 --- /dev/null +++ b/backend/engines/pay_timing.py @@ -0,0 +1,62 @@ +from collections import Counter +from datetime import date, timedelta + +_TARGET_UTILIZATION = 0.08 +_STACK_WINDOW_DAYS = 3 + + +def infer_closing_date(transactions: list[dict]) -> int: + days = [int(t["timestamp"][8:10]) for t in transactions if t.get("timestamp")] + if not days: + return 1 + return Counter(days).most_common(1)[0][0] + + +def bills_in_window(bills: list[dict], window_days: int) -> list[dict]: + cutoff = date.today() + timedelta(days=window_days) + return [ + b for b in bills + if b.get("next_due_date") and date.fromisoformat(b["next_due_date"]) <= cutoff + ] + + +def compute_pay_timing( + accounts: list[dict], + bills: list[dict], + balance: float, +) -> dict: + card_recommendations = [] + for acct in accounts: + if acct.get("account_type") != "credit": + continue + current_balance = acct.get("balance") or 0.0 + limit = acct.get("credit_limit") or 0.0 + if limit == 0: + continue + target_balance = round(limit * _TARGET_UTILIZATION, 2) + pay_amount = max(0.0, round(current_balance - target_balance, 2)) + + card_recommendations.append({ + "account_id": acct["id"], + "pay_amount": pay_amount, + "target_utilization": _TARGET_UTILIZATION, + "closing_day": acct.get("closing_date"), + }) + + stacked_windows = [] + today = date.today() + window_bills = bills_in_window(bills, _STACK_WINDOW_DAYS) + total_due = sum(b.get("avg_amount") or 0 for b in window_bills) + if window_bills and total_due > balance: + sorted_bills = sorted(window_bills, key=lambda b: b.get("avg_amount") or 0, reverse=True) + stacked_windows.append({ + "window_start": today.isoformat(), + "window_end": (today + timedelta(days=_STACK_WINDOW_DAYS)).isoformat(), + "total_due": round(total_due, 2), + "priority_order": sorted_bills, + }) + + return { + "card_recommendations": card_recommendations, + "stacked_windows": stacked_windows, + } diff --git a/backend/tests/test_pay_timing_engine.py b/backend/tests/test_pay_timing_engine.py new file mode 100644 index 0000000..286aca9 --- /dev/null +++ b/backend/tests/test_pay_timing_engine.py @@ -0,0 +1,67 @@ +from datetime import date, timedelta + +from engines.pay_timing import bills_in_window, compute_pay_timing, infer_closing_date + + +def test_infer_closing_date_from_transactions(): + txns = [ + {"timestamp": "2026-05-05T00:00:00Z"}, + {"timestamp": "2026-04-05T00:00:00Z"}, + {"timestamp": "2026-03-05T00:00:00Z"}, + ] + day = infer_closing_date(txns) + assert day == 5 + + +def test_infer_closing_date_fallback(): + assert infer_closing_date([]) == 1 + + +def test_bills_in_window_filters_correctly(): + today = date.today() + bills = [ + {"next_due_date": (today + timedelta(days=1)).isoformat(), "avg_amount": 50.0, "merchant": "A"}, + {"next_due_date": (today + timedelta(days=10)).isoformat(), "avg_amount": 200.0, "merchant": "B"}, + {"next_due_date": (today + timedelta(days=40)).isoformat(), "avg_amount": 100.0, "merchant": "C"}, + ] + result = bills_in_window(bills, window_days=7) + assert len(result) == 1 + assert result[0]["merchant"] == "A" + + +def test_compute_pay_timing_card_recommendation(): + accounts = [ + {"id": "card-1", "account_type": "credit", "balance": 800.0, "credit_limit": 1000.0, "closing_date": None} + ] + result = compute_pay_timing(accounts=accounts, bills=[], balance=2000.0) + recs = result["card_recommendations"] + assert len(recs) == 1 + assert recs[0]["pay_amount"] == round(800.0 - 1000.0 * 0.08, 2) + + +def test_compute_pay_timing_skips_non_credit(): + accounts = [ + {"id": "chk-1", "account_type": "checking", "balance": 2000.0, "credit_limit": None, "closing_date": None} + ] + result = compute_pay_timing(accounts=accounts, bills=[], balance=2000.0) + assert result["card_recommendations"] == [] + + +def test_compute_pay_timing_stacked_window_detection(): + today = date.today() + bills = [ + {"next_due_date": (today + timedelta(days=1)).isoformat(), "avg_amount": 300.0, "merchant": "Rent"}, + {"next_due_date": (today + timedelta(days=2)).isoformat(), "avg_amount": 200.0, "merchant": "Car"}, + {"next_due_date": (today + timedelta(days=3)).isoformat(), "avg_amount": 150.0, "merchant": "Electric"}, + ] + result = compute_pay_timing(accounts=[], bills=bills, balance=400.0) + assert len(result["stacked_windows"]) > 0 + + +def test_no_stack_when_balance_covers_bills(): + today = date.today() + bills = [ + {"next_due_date": (today + timedelta(days=1)).isoformat(), "avg_amount": 100.0, "merchant": "Netflix"}, + ] + result = compute_pay_timing(accounts=[], bills=bills, balance=5000.0) + assert result["stacked_windows"] == [] From 19ed62d466a2abd2cbfb9a640f9e818ce0e8db65 Mon Sep 17 00:00:00 2001 From: Evin Bento Date: Tue, 23 Jun 2026 16:49:13 -0400 Subject: [PATCH 07/13] feat(api): GET /insights/pay-timing + register as Argus tool --- backend/agents/tools.py | 24 +++++++++++ backend/main.py | 3 +- backend/routers/pay_timing.py | 32 ++++++++++++++ backend/tests/test_pay_timing_endpoint.py | 51 +++++++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 backend/routers/pay_timing.py create mode 100644 backend/tests/test_pay_timing_endpoint.py diff --git a/backend/agents/tools.py b/backend/agents/tools.py index 6e0add3..230a7e8 100644 --- a/backend/agents/tools.py +++ b/backend/agents/tools.py @@ -105,3 +105,27 @@ def _get_safe_to_spend_tool(user_id: str) -> dict: if cached: return cached[0] return {"safe_amount": None, "breakdown": {}, "computed_at": None} + + +@register_tool( + "get_pay_timing", + "Returns when and how much to pay on each credit card to stay under 8% utilization, plus dangerous bill stacking windows", + keywords=["pay timing", "when to pay", "credit utilization", "bill stacking", "pay my card"], +) +def _get_pay_timing_tool(user_id: str) -> dict: + from engines.pay_timing import compute_pay_timing + supabase = get_supabase() + accounts = ( + supabase.table("accounts") + .select("id, account_type, balance, credit_limit, closing_date") + .eq("user_id", user_id) + .execute() + ).data or [] + bills = ( + supabase.table("bills") + .select("avg_amount, next_due_date, merchant") + .eq("user_id", user_id) + .execute() + ).data or [] + balance = sum(a.get("balance") or 0 for a in accounts if a.get("account_type") != "credit") + return compute_pay_timing(accounts=accounts, bills=bills, balance=balance) diff --git a/backend/main.py b/backend/main.py index fa5eb69..e5494f0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from routers import argus, auth, bills, insights, onboarding, plaid, subscriptions, transactions +from routers import argus, auth, bills, insights, onboarding, pay_timing, plaid, subscriptions, transactions load_dotenv() @@ -36,6 +36,7 @@ app.include_router(insights.router) app.include_router(onboarding.router) app.include_router(argus.router) +app.include_router(pay_timing.router) @app.get("/health", tags=["system"]) diff --git a/backend/routers/pay_timing.py b/backend/routers/pay_timing.py new file mode 100644 index 0000000..db1ce78 --- /dev/null +++ b/backend/routers/pay_timing.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, Depends + +from db.client import get_supabase +from engines.pay_timing import compute_pay_timing +from middleware.auth import get_current_user + +router = APIRouter(prefix="/insights", tags=["insights"]) + + +@router.get("/pay-timing") +async def get_pay_timing(user_id: str = Depends(get_current_user)): + supabase = get_supabase() + + accounts = ( + supabase.table("accounts") + .select("id, account_type, balance, credit_limit, closing_date") + .eq("user_id", user_id) + .execute() + ).data or [] + + bills = ( + supabase.table("bills") + .select("avg_amount, next_due_date, merchant") + .eq("user_id", user_id) + .execute() + ).data or [] + + balance = sum( + a.get("balance") or 0 for a in accounts if a.get("account_type") != "credit" + ) + + return compute_pay_timing(accounts=accounts, bills=bills, balance=balance) diff --git a/backend/tests/test_pay_timing_endpoint.py b/backend/tests/test_pay_timing_endpoint.py new file mode 100644 index 0000000..55cf47d --- /dev/null +++ b/backend/tests/test_pay_timing_endpoint.py @@ -0,0 +1,51 @@ +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from main import app +from middleware.auth import get_current_user + +app.dependency_overrides[get_current_user] = lambda: "user-1" +client = TestClient(app) + + +def test_pay_timing_returns_recommendations(): + mock_supabase = MagicMock() + + def table_side(name): + m = MagicMock() + if name == "accounts": + m.select.return_value.eq.return_value.execute.return_value.data = [ + {"id": "card-1", "account_type": "credit", "balance": 500.0, "credit_limit": 1000.0, "closing_date": None} + ] + elif name == "bills": + m.select.return_value.eq.return_value.execute.return_value.data = [] + return m + + mock_supabase.table.side_effect = table_side + + with patch("routers.pay_timing.get_supabase", return_value=mock_supabase): + resp = client.get("/insights/pay-timing") + + assert resp.status_code == 200 + data = resp.json() + assert "card_recommendations" in data + assert "stacked_windows" in data + assert data["card_recommendations"][0]["pay_amount"] == round(500.0 - 1000.0 * 0.08, 2) + + +def test_pay_timing_empty_when_no_accounts(): + mock_supabase = MagicMock() + + def table_side(name): + m = MagicMock() + m.select.return_value.eq.return_value.execute.return_value.data = [] + return m + + mock_supabase.table.side_effect = table_side + + with patch("routers.pay_timing.get_supabase", return_value=mock_supabase): + resp = client.get("/insights/pay-timing") + + assert resp.status_code == 200 + assert resp.json()["card_recommendations"] == [] From 2301bd5a19d55adc7cf22492f5cfaa69eae6df1d Mon Sep 17 00:00:00 2001 From: Evin Bento Date: Tue, 23 Jun 2026 16:50:02 -0400 Subject: [PATCH 08/13] =?UTF-8?q?feat(service):=20merchant=20logo=20servic?= =?UTF-8?q?e=20=E2=80=94=20Clearbit=20fetch=20+=20DB=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/migrations/017_merchant_logos.sql | 7 ++++ backend/services/merchant_logos.py | 36 +++++++++++++++++++ backend/tests/test_merchant_logos.py | 44 +++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 backend/migrations/017_merchant_logos.sql create mode 100644 backend/services/merchant_logos.py create mode 100644 backend/tests/test_merchant_logos.py diff --git a/backend/migrations/017_merchant_logos.sql b/backend/migrations/017_merchant_logos.sql new file mode 100644 index 0000000..c2005db --- /dev/null +++ b/backend/migrations/017_merchant_logos.sql @@ -0,0 +1,7 @@ +-- backend/migrations/017_merchant_logos.sql + +CREATE TABLE IF NOT EXISTS merchant_logos ( + merchant TEXT PRIMARY KEY, + logo_url TEXT, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/backend/services/merchant_logos.py b/backend/services/merchant_logos.py new file mode 100644 index 0000000..4931f7a --- /dev/null +++ b/backend/services/merchant_logos.py @@ -0,0 +1,36 @@ +import re + +import requests + +_CLEARBIT_BASE = "https://logo.clearbit.com" + + +def _slug(merchant: str) -> str: + return re.sub(r"[^a-z0-9]", "", merchant.lower()) + + +def get_logo_url(merchant: str, supabase) -> str | None: + cached = ( + supabase.table("merchant_logos") + .select("logo_url") + .eq("merchant", merchant) + .execute() + ).data or [] + + if cached: + return cached[0]["logo_url"] + + slug = _slug(merchant) + url = f"{_CLEARBIT_BASE}/{slug}.com" + try: + resp = requests.head(url, timeout=3) + logo_url = url if resp.status_code == 200 else None + except requests.RequestException: + logo_url = None + + supabase.table("merchant_logos").upsert( + {"merchant": merchant, "logo_url": logo_url}, + on_conflict="merchant", + ).execute() + + return logo_url diff --git a/backend/tests/test_merchant_logos.py b/backend/tests/test_merchant_logos.py new file mode 100644 index 0000000..e296b3d --- /dev/null +++ b/backend/tests/test_merchant_logos.py @@ -0,0 +1,44 @@ +from unittest.mock import MagicMock, patch + + +def test_returns_cached_logo(): + mock_supabase = MagicMock() + mock_supabase.table.return_value.select.return_value.eq.return_value.execute.return_value.data = [ + {"logo_url": "https://logo.clearbit.com/netflix.com"} + ] + from services.merchant_logos import get_logo_url + result = get_logo_url("Netflix", mock_supabase) + assert result == "https://logo.clearbit.com/netflix.com" + + +def test_fetches_and_caches_on_miss(): + mock_supabase = MagicMock() + mock_supabase.table.return_value.select.return_value.eq.return_value.execute.return_value.data = [] + upserted = [] + + def capture_upsert(payload, **kwargs): + upserted.append(payload) + return MagicMock() + + mock_supabase.table.return_value.upsert.side_effect = capture_upsert + + with patch("services.merchant_logos.requests.head") as mock_head: + mock_head.return_value.status_code = 200 + from services.merchant_logos import get_logo_url + result = get_logo_url("Spotify", mock_supabase) + + assert result is not None + assert "spotify" in result.lower() + assert len(upserted) == 1 + + +def test_returns_none_on_404(): + mock_supabase = MagicMock() + mock_supabase.table.return_value.select.return_value.eq.return_value.execute.return_value.data = [] + + with patch("services.merchant_logos.requests.head") as mock_head: + mock_head.return_value.status_code = 404 + from services.merchant_logos import get_logo_url + result = get_logo_url("UnknownMerchant99", mock_supabase) + + assert result is None From 1c0e28e9872e4fa8699986c15a6c683e7e004d32 Mon Sep 17 00:00:00 2001 From: Evin Bento Date: Tue, 23 Jun 2026 16:51:16 -0400 Subject: [PATCH 09/13] =?UTF-8?q?feat(api):=20GET=20/calendar=20=E2=80=94?= =?UTF-8?q?=20unified=20bills=20+=20subscriptions=20feed=20with=20urgency?= =?UTF-8?q?=20+=20logos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 3 +- backend/routers/calendar.py | 72 ++++++++++++++++++++++++ backend/tests/test_calendar_endpoint.py | 74 +++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 backend/routers/calendar.py create mode 100644 backend/tests/test_calendar_endpoint.py diff --git a/backend/main.py b/backend/main.py index e5494f0..01e4da8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from routers import argus, auth, bills, insights, onboarding, pay_timing, plaid, subscriptions, transactions +from routers import argus, auth, bills, calendar, insights, onboarding, pay_timing, plaid, subscriptions, transactions load_dotenv() @@ -37,6 +37,7 @@ app.include_router(onboarding.router) app.include_router(argus.router) app.include_router(pay_timing.router) +app.include_router(calendar.router) @app.get("/health", tags=["system"]) diff --git a/backend/routers/calendar.py b/backend/routers/calendar.py new file mode 100644 index 0000000..b95c547 --- /dev/null +++ b/backend/routers/calendar.py @@ -0,0 +1,72 @@ +from datetime import date, timedelta + +from fastapi import APIRouter, Depends + +from db.client import get_supabase +from middleware.auth import get_current_user +from services.merchant_logos import get_logo_url + +router = APIRouter(prefix="/calendar", tags=["calendar"]) + + +def _urgency(due_date_str: str) -> str: + try: + days = (date.fromisoformat(due_date_str) - date.today()).days + except (ValueError, TypeError): + return "low" + if days <= 3: + return "high" + if days <= 7: + return "medium" + return "low" + + +@router.get("") +async def get_calendar(user_id: str = Depends(get_current_user)): + supabase = get_supabase() + + bills = ( + supabase.table("bills") + .select("id, merchant, avg_amount, next_due_date") + .eq("user_id", user_id) + .order("next_due_date") + .execute() + ).data or [] + + subscriptions = ( + supabase.table("subscriptions") + .select("id, merchant, avg_amount, next_due_date") + .eq("user_id", user_id) + .eq("is_active", True) + .execute() + ).data or [] + + entries = [] + + for b in bills: + entries.append({ + "id": b["id"], + "type": "bill", + "merchant": b["merchant"], + "amount": b.get("avg_amount"), + "due_date": b.get("next_due_date"), + "logo_url": get_logo_url(b["merchant"], supabase), + "urgency": _urgency(b.get("next_due_date") or ""), + "ai_reasoning": None, + }) + + for s in subscriptions: + entries.append({ + "id": s["id"], + "type": "subscription", + "merchant": s["merchant"], + "amount": s.get("avg_amount"), + "due_date": s.get("next_due_date"), + "logo_url": get_logo_url(s["merchant"], supabase), + "urgency": _urgency(s.get("next_due_date") or ""), + "ai_reasoning": None, + }) + + entries.sort(key=lambda e: e.get("due_date") or "9999-12-31") + + return {"entries": entries} diff --git a/backend/tests/test_calendar_endpoint.py b/backend/tests/test_calendar_endpoint.py new file mode 100644 index 0000000..ecab77a --- /dev/null +++ b/backend/tests/test_calendar_endpoint.py @@ -0,0 +1,74 @@ +from datetime import date, timedelta +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from main import app +from middleware.auth import get_current_user + +app.dependency_overrides[get_current_user] = lambda: "user-1" +client = TestClient(app) + + +def make_supabase(bills_data, subs_data): + mock_supabase = MagicMock() + + def table_side(name): + m = MagicMock() + if name == "bills": + m.select.return_value.eq.return_value.order.return_value.execute.return_value.data = bills_data + elif name == "subscriptions": + m.select.return_value.eq.return_value.eq.return_value.execute.return_value.data = subs_data + elif name == "merchant_logos": + m.select.return_value.eq.return_value.execute.return_value.data = [] + m.upsert.return_value.execute.return_value = MagicMock() + return m + + mock_supabase.table.side_effect = table_side + return mock_supabase + + +def test_calendar_returns_sorted_entries(): + today = date.today() + bills = [{"id": "b1", "merchant": "Electric", "avg_amount": 80.0, "next_due_date": (today + timedelta(days=5)).isoformat()}] + subs = [{"id": "s1", "merchant": "Netflix", "avg_amount": 15.0, "next_due_date": (today + timedelta(days=2)).isoformat()}] + + mock_supabase = make_supabase(bills, subs) + with patch("routers.calendar.get_supabase", return_value=mock_supabase), \ + patch("services.merchant_logos.requests.head") as h: + h.return_value.status_code = 404 + resp = client.get("/calendar") + + assert resp.status_code == 200 + entries = resp.json()["entries"] + assert len(entries) == 2 + assert entries[0]["merchant"] == "Netflix" + assert entries[1]["merchant"] == "Electric" + + +def test_calendar_urgency_high_within_3_days(): + today = date.today() + bills = [{"id": "b1", "merchant": "Rent", "avg_amount": 1200.0, "next_due_date": (today + timedelta(days=1)).isoformat()}] + + mock_supabase = make_supabase(bills, []) + with patch("routers.calendar.get_supabase", return_value=mock_supabase), \ + patch("services.merchant_logos.requests.head") as h: + h.return_value.status_code = 404 + resp = client.get("/calendar") + + entries = resp.json()["entries"] + assert entries[0]["urgency"] == "high" + + +def test_calendar_urgency_medium_within_7_days(): + today = date.today() + bills = [{"id": "b1", "merchant": "Car", "avg_amount": 350.0, "next_due_date": (today + timedelta(days=5)).isoformat()}] + + mock_supabase = make_supabase(bills, []) + with patch("routers.calendar.get_supabase", return_value=mock_supabase), \ + patch("services.merchant_logos.requests.head") as h: + h.return_value.status_code = 404 + resp = client.get("/calendar") + + entries = resp.json()["entries"] + assert entries[0]["urgency"] == "medium" From 621e48e7aff7d55ee6180679ded82e90ff1fefc8 Mon Sep 17 00:00:00 2001 From: Evin Bento Date: Tue, 23 Jun 2026 16:52:12 -0400 Subject: [PATCH 10/13] =?UTF-8?q?feat(ui):=20Smart=20Payment=20Calendar=20?= =?UTF-8?q?page=20=E2=80=94=20logos,=20urgency,=20filter=20chips,=20stacki?= =?UTF-8?q?ng=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(app)/calendar/page.tsx | 205 +++++++++++++++++++++++++++ frontend/app/(app)/layout.tsx | 9 ++ 2 files changed, 214 insertions(+) create mode 100644 frontend/app/(app)/calendar/page.tsx diff --git a/frontend/app/(app)/calendar/page.tsx b/frontend/app/(app)/calendar/page.tsx new file mode 100644 index 0000000..6dac5ee --- /dev/null +++ b/frontend/app/(app)/calendar/page.tsx @@ -0,0 +1,205 @@ +"use client"; + +export const dynamic = "force-dynamic"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; + +type CalendarEntry = { + id: string; + type: "bill" | "subscription" | "ai_recommendation"; + merchant: string; + amount: number | null; + due_date: string | null; + logo_url: string | null; + urgency: "low" | "medium" | "high"; + ai_reasoning: string | null; +}; + +type PayTiming = { + card_recommendations: { account_id: string; pay_amount: number; closing_day: number | null }[]; + stacked_windows: { window_start: string; total_due: number; priority_order: { merchant: string; avg_amount: number }[] }[]; +}; + +const URGENCY_COLOR: Record = { + high: "var(--accent-red)", + medium: "var(--copper)", + low: "var(--surface-3)", +}; + +const FILTER_OPTIONS = ["all", "bills", "subscriptions"] as const; +type Filter = (typeof FILTER_OPTIONS)[number]; + +function LogoTile({ merchant, logoUrl }: { merchant: string; logoUrl: string | null }) { + if (logoUrl) { + return ( + {merchant} { (e.target as HTMLImageElement).style.display = "none"; }} + /> + ); + } + return ( +
+ {merchant[0]?.toUpperCase()} +
+ ); +} + +export default function CalendarPage() { + const [entries, setEntries] = useState([]); + const [payTiming, setPayTiming] = useState(null); + const [filter, setFilter] = useState("all"); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([ + api.get<{ entries: CalendarEntry[] }>("/calendar"), + api.get("/insights/pay-timing"), + ]).then(([cal, pt]) => { + setEntries(cal.entries); + setPayTiming(pt); + }).finally(() => setLoading(false)); + }, []); + + const filtered = entries.filter((e) => { + if (filter === "all") return true; + if (filter === "bills") return e.type === "bill"; + if (filter === "subscriptions") return e.type === "subscription"; + return true; + }); + + if (loading) { + return ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ ); + } + + return ( +
+

+ Smart Payment Calendar +

+

+ What's coming up +

+ + {payTiming && payTiming.stacked_windows.length > 0 && ( +
+

+ Bills stacking — balance may not cover all +

+ {payTiming.stacked_windows[0].priority_order.map((b, i) => ( +

+ {i + 1}. {b.merchant} — ${b.avg_amount.toFixed(2)} +

+ ))} +
+ )} + +
+ {FILTER_OPTIONS.map((f) => ( + + ))} +
+ +
+ {filtered.length === 0 && ( +

+ Nothing due +

+ )} + {filtered.map((entry) => ( +
+ +
+

+ {entry.merchant} +

+

+ {entry.due_date + ? new Date(entry.due_date).toLocaleDateString("en-US", { month: "short", day: "numeric" }) + : "—"} +

+
+

+ {entry.amount != null ? `$${entry.amount.toFixed(2)}` : "—"} +

+
+ ))} +
+
+ ); +} diff --git a/frontend/app/(app)/layout.tsx b/frontend/app/(app)/layout.tsx index 0d1afc8..976e8fa 100644 --- a/frontend/app/(app)/layout.tsx +++ b/frontend/app/(app)/layout.tsx @@ -55,6 +55,15 @@ const NAV = [ ), }, + { + href: "/calendar", + title: "Calendar", + icon: ( + + + + ), + }, { href: "/intelligence", title: "Intelligence", From 8fae6138b4188b29c52b65d6f2690dfc712a4d27 Mon Sep 17 00:00:00 2001 From: Evin Bento Date: Tue, 23 Jun 2026 16:52:53 -0400 Subject: [PATCH 11/13] =?UTF-8?q?feat(ui):=20Safe=20to=20Spend=20hero=20on?= =?UTF-8?q?=20dashboard=20=E2=80=94=20tappable=20breakdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/_components/SafeToSpendHero.tsx | 97 +++++++++++++++++++ frontend/app/(app)/dashboard/page.tsx | 3 + 2 files changed, 100 insertions(+) create mode 100644 frontend/app/(app)/dashboard/_components/SafeToSpendHero.tsx diff --git a/frontend/app/(app)/dashboard/_components/SafeToSpendHero.tsx b/frontend/app/(app)/dashboard/_components/SafeToSpendHero.tsx new file mode 100644 index 0000000..c9d6ad6 --- /dev/null +++ b/frontend/app/(app)/dashboard/_components/SafeToSpendHero.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; + +type SafeToSpend = { + safe_amount: number; + breakdown: { + balance: number; + bills_due: number; + buffer_reserve: number; + window_days: number; + }; + computed_at: string | null; +}; + +export function SafeToSpendHero() { + const [data, setData] = useState(null); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api.get("/insights/safe-to-spend") + .then(setData) + .catch(() => null) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+ ); + } + + if (!data) return null; + + return ( +
setOpen((o) => !o)} + style={{ + background: "var(--surface-1)", borderRadius: "var(--r-xl)", + border: "1px solid var(--surface-3)", padding: "20px 24px", + cursor: "pointer", userSelect: "none", + }} + > +

+ Safe to spend today +

+

+ ${data.safe_amount.toLocaleString("en-US", { minimumFractionDigits: 2 })} +

+ + {open && ( +
+ {( + [ + ["Current balance", data.breakdown.balance, false], + ["Bills due this window", data.breakdown.bills_due, true], + ["Buffer reserve", data.breakdown.buffer_reserve, true], + ] as [string, number, boolean][] + ).map(([label, val, negative]) => ( +
+ + {label} + + + {negative ? "-" : ""}${val.toFixed(2)} + +
+ ))} +

+ {data.breakdown.window_days}-day window · tap to close +

+
+ )} +
+ ); +} diff --git a/frontend/app/(app)/dashboard/page.tsx b/frontend/app/(app)/dashboard/page.tsx index 80e9ee9..b8ac9e2 100644 --- a/frontend/app/(app)/dashboard/page.tsx +++ b/frontend/app/(app)/dashboard/page.tsx @@ -9,6 +9,7 @@ import { api } from "@/lib/api"; import { createClient } from "@/lib/supabase/client"; import { AskArgusBar } from "./_components/AskArgusBar"; +import { SafeToSpendHero } from "./_components/SafeToSpendHero"; import { BalanceCard, type Account } from "./_components/BalanceCard"; import { SpendingLimitCard, type Transaction } from "./_components/SpendingLimitCard"; import { MyCards } from "./_components/MyCards"; @@ -192,6 +193,8 @@ export default function DashboardPage() {
+ + {accounts.length === 0 ? ( ) : ( From 814b8591e57e5482d00783c9e297eaf9aff4514e Mon Sep 17 00:00:00 2001 From: Evin Bento Date: Tue, 23 Jun 2026 17:21:39 -0400 Subject: [PATCH 12/13] fix(lint): wrap long lines to satisfy ruff E501 --- backend/agents/analyst_agent.py | 85 ++++--- backend/agents/chat.py | 37 +-- backend/agents/enrichment_agent.py | 36 +-- backend/agents/tools.py | 8 +- backend/engines/pay_timing.py | 31 ++- backend/main.py | 13 +- backend/routers/argus.py | 12 +- backend/routers/calendar.py | 46 ++-- backend/routers/insights.py | 8 +- backend/routers/pay_timing.py | 4 +- backend/routers/plaid.py | 7 +- backend/routers/transactions.py | 1 + backend/services/merchant_logos.py | 5 +- backend/tasks/categorize_transactions.py | 5 +- backend/tasks/detect_bills.py | 25 +- backend/tasks/detect_subscriptions.py | 25 +- backend/tasks/recompute_safe_to_spend.py | 10 +- backend/tasks/resolve_predictions.py | 9 +- backend/tasks/run_intelligence_pipeline.py | 33 ++- backend/tasks/sync_transactions.py | 12 +- backend/tests/test_agents.py | 237 ++++++++++++------ backend/tests/test_argus_chat.py | 11 +- backend/tests/test_argus_router.py | 84 +++++-- backend/tests/test_auth.py | 4 +- backend/tests/test_calendar_endpoint.py | 62 ++++- backend/tests/test_enrich_detected_records.py | 66 +++-- backend/tests/test_merchant_logos.py | 17 +- backend/tests/test_pay_timing_endpoint.py | 8 +- backend/tests/test_pay_timing_engine.py | 58 ++++- backend/tests/test_recompute_safe_to_spend.py | 1 + .../tests/test_run_intelligence_pipeline.py | 19 +- backend/tests/test_safe_to_spend_endpoint.py | 11 +- backend/tests/test_safe_to_spend_tool.py | 9 +- backend/tests/test_supervisor.py | 6 +- backend/tests/test_synthesize_insights.py | 50 ++-- 35 files changed, 676 insertions(+), 379 deletions(-) diff --git a/backend/agents/analyst_agent.py b/backend/agents/analyst_agent.py index 3451fa7..700c693 100644 --- a/backend/agents/analyst_agent.py +++ b/backend/agents/analyst_agent.py @@ -75,9 +75,7 @@ def _build_analyst_brief( "title": i.get("summary"), "created_at": i.get("created_at"), "similarity": ( - round(i.get("similarity", 0), 3) - if i.get("similarity") is not None - else None + round(i.get("similarity", 0), 3) if i.get("similarity") is not None else None ), } for i in past_insights @@ -90,28 +88,53 @@ def _build_analyst_brief( f"RELEVANT PAST DECISIONS" f" (semantic memory — ranked by relevance to current situation):" f"\n{recent_memory}\n\n" - f"CURRENT ACCOUNTS:\n{json.dumps([ - {'type': a.get('account_type'), 'balance': a.get('balance'), - 'credit_limit': a.get('credit_limit')} - for a in accounts - ], indent=2)}\n\n" - f"UPCOMING BILLS:\n{json.dumps([ - {'merchant': b.get('merchant'), 'amount': b.get('avg_amount'), - 'due': b.get('next_due_date'), 'enrichment': b.get('ai_enrichment')} - for b in bills - ], indent=2)}\n\n" - f"ACTIVE SUBSCRIPTIONS:\n{json.dumps([ - {'merchant': s.get('merchant'), 'amount': s.get('avg_amount'), - 'price_change_pct': s.get('price_change_pct'), - 'enrichment': s.get('ai_enrichment')} - for s in subscriptions - ], indent=2)}\n\n" + f"CURRENT ACCOUNTS:\n{ + json.dumps( + [ + { + 'type': a.get('account_type'), + 'balance': a.get('balance'), + 'credit_limit': a.get('credit_limit'), + } + for a in accounts + ], + indent=2, + ) + }\n\n" + f"UPCOMING BILLS:\n{ + json.dumps( + [ + { + 'merchant': b.get('merchant'), + 'amount': b.get('avg_amount'), + 'due': b.get('next_due_date'), + 'enrichment': b.get('ai_enrichment'), + } + for b in bills + ], + indent=2, + ) + }\n\n" + f"ACTIVE SUBSCRIPTIONS:\n{ + json.dumps( + [ + { + 'merchant': s.get('merchant'), + 'amount': s.get('avg_amount'), + 'price_change_pct': s.get('price_change_pct'), + 'enrichment': s.get('ai_enrichment'), + } + for s in subscriptions + ], + indent=2, + ) + }\n\n" f"SPENDING SUMMARY (last 90 days by category):\n{json.dumps(tx_summary, indent=2)}\n\n" f"Generate 3-5 analyst decisions AND an updated user profile.\n\n" f"Return this exact JSON shape:\n" - f'{{\n' + f"{{\n" f' "decisions": [\n' - f' {{\n' + f" {{\n" f' "signal_type": "",\n' f' "severity": "",\n' f' "title": "",\n' @@ -120,8 +143,8 @@ def _build_analyst_brief( f' "simulation": "",\n' f' "confidence": ,\n' f' "sources": [""]\n' - f' }}\n' - f' ],\n' + f" }}\n" + f" ],\n" f' "updated_profile": {{\n' f' "income_pattern": {{}},\n' f' "spending_baselines": {{}},\n' @@ -129,8 +152,8 @@ def _build_analyst_brief( f' "known_risks": [],\n' f' "analyst_notes": "",\n' f' "resolved_patterns": []\n' - f' }}\n' - f'}}' + f" }}\n" + f"}}" ) @@ -166,11 +189,13 @@ def analyst_node(state: IntelligenceState) -> dict: message = client.messages.create( model="claude-sonnet-4-6", max_tokens=8096, - system=[{ - "type": "text", - "text": _ANALYST_SYSTEM_PROMPT, - "cache_control": {"type": "ephemeral"}, - }], + system=[ + { + "type": "text", + "text": _ANALYST_SYSTEM_PROMPT, + "cache_control": {"type": "ephemeral"}, + } + ], messages=[{"role": "user", "content": brief}], ) response_text = message.content[0].text diff --git a/backend/agents/chat.py b/backend/agents/chat.py index aa83a76..8ec6ba6 100644 --- a/backend/agents/chat.py +++ b/backend/agents/chat.py @@ -93,31 +93,32 @@ def _extract_prediction_block(response_text: str) -> dict | None: def _log_prediction(user_id: str, prediction: dict) -> None: supabase = get_supabase() - supabase.table("ai_predictions").insert({ - "id": str(uuid.uuid4()), - "user_id": user_id, - "prediction_type": prediction["prediction_type"], - "prediction_payload": prediction["payload"], - "predicted_at": datetime.now(UTC).isoformat(), - "resolves_at": prediction["resolves_at"], - }).execute() + supabase.table("ai_predictions").insert( + { + "id": str(uuid.uuid4()), + "user_id": user_id, + "prediction_type": prediction["prediction_type"], + "prediction_payload": prediction["payload"], + "predicted_at": datetime.now(UTC).isoformat(), + "resolves_at": prediction["resolves_at"], + } + ).execute() def _retrieve_chat_context(user_id: str, query: str) -> dict: - supervisor_result = supervisor_graph.invoke({ - "user_id": user_id, - "query": query, - "selected_tools": [], - "tool_results": {}, - }) + supervisor_result = supervisor_graph.invoke( + { + "user_id": user_id, + "query": query, + "selected_tools": [], + "tool_results": {}, + } + ) supabase = get_supabase() profile_result = ( - supabase.table("user_financial_profiles") - .select("profile") - .eq("user_id", user_id) - .execute() + supabase.table("user_financial_profiles").select("profile").eq("user_id", user_id).execute() ) profile = profile_result.data[0]["profile"] if profile_result.data else {} diff --git a/backend/agents/enrichment_agent.py b/backend/agents/enrichment_agent.py index 03f0651..2aed4be 100644 --- a/backend/agents/enrichment_agent.py +++ b/backend/agents/enrichment_agent.py @@ -47,20 +47,20 @@ def _build_enrichment_prompt(bills: list[dict], subscriptions: list[dict]) -> st f"Bills:\n{bills_section}\n\n" f"Subscriptions:\n{subs_section}\n\n" f"Return a JSON object with this exact shape:\n" - f'{{\n' + f"{{\n" f' "bills": [\n' - f' {{\n' + f" {{\n" f' "id": "",\n' f' "enrichment": {{\n' f' "ai_confidence": ,\n' f' "merchant_context": "",\n' f' "classification_note": "",\n' f' "is_subscription_candidate": \n' - f' }}\n' - f' }}\n' - f' ],\n' + f" }}\n" + f" }}\n" + f" ],\n" f' "subscriptions": [\n' - f' {{\n' + f" {{\n" f' "id": "",\n' f' "enrichment": {{\n' f' "service_category": "",\n' @@ -69,10 +69,10 @@ def _build_enrichment_prompt(bills: list[dict], subscriptions: list[dict]) -> st f' "price_trend_interpretation": "",\n' f' "cancel_recommendation": ,\n' f' "cancel_reasoning": ""\n' - f' }}\n' - f' }}\n' - f' ]\n' - f'}}' + f" }}\n" + f" }}\n" + f" ]\n" + f"}}" ) @@ -107,11 +107,13 @@ def enrichment_node(state: IntelligenceState) -> dict: message = client.messages.create( model="claude-sonnet-4-6", max_tokens=4096, - system=[{ - "type": "text", - "text": _SYSTEM_PROMPT, - "cache_control": {"type": "ephemeral"}, - }], + system=[ + { + "type": "text", + "text": _SYSTEM_PROMPT, + "cache_control": {"type": "ephemeral"}, + } + ], messages=[{"role": "user", "content": prompt}], ) response_text = message.content[0].text @@ -152,9 +154,7 @@ def enrichment_node(state: IntelligenceState) -> dict: if "id" in e and "enrichment" in e } - enriched_bills = [ - {**b, "ai_enrichment": enrichment_by_bill_id.get(b["id"])} for b in bills - ] + enriched_bills = [{**b, "ai_enrichment": enrichment_by_bill_id.get(b["id"])} for b in bills] enriched_subs = [ {**s, "ai_enrichment": enrichment_by_sub_id.get(s["id"])} for s in subscriptions ] diff --git a/backend/agents/tools.py b/backend/agents/tools.py index 230a7e8..011ec02 100644 --- a/backend/agents/tools.py +++ b/backend/agents/tools.py @@ -71,9 +71,7 @@ def _get_subscriptions_tool(user_id: str) -> dict: ) def _get_spending_by_category_tool(user_id: str) -> dict: supabase = get_supabase() - accounts = ( - supabase.table("accounts").select("id").eq("user_id", user_id).execute() - ).data or [] + accounts = (supabase.table("accounts").select("id").eq("user_id", user_id).execute()).data or [] account_ids = [a["id"] for a in accounts] if not account_ids: return {"transactions": []} @@ -109,11 +107,13 @@ def _get_safe_to_spend_tool(user_id: str) -> dict: @register_tool( "get_pay_timing", - "Returns when and how much to pay on each credit card to stay under 8% utilization, plus dangerous bill stacking windows", + "Returns when and how much to pay on each credit card to stay under 8% utilization," + " plus dangerous bill stacking windows", keywords=["pay timing", "when to pay", "credit utilization", "bill stacking", "pay my card"], ) def _get_pay_timing_tool(user_id: str) -> dict: from engines.pay_timing import compute_pay_timing + supabase = get_supabase() accounts = ( supabase.table("accounts") diff --git a/backend/engines/pay_timing.py b/backend/engines/pay_timing.py index 3fbbac8..de0cdd4 100644 --- a/backend/engines/pay_timing.py +++ b/backend/engines/pay_timing.py @@ -15,7 +15,8 @@ def infer_closing_date(transactions: list[dict]) -> int: def bills_in_window(bills: list[dict], window_days: int) -> list[dict]: cutoff = date.today() + timedelta(days=window_days) return [ - b for b in bills + b + for b in bills if b.get("next_due_date") and date.fromisoformat(b["next_due_date"]) <= cutoff ] @@ -36,12 +37,14 @@ def compute_pay_timing( target_balance = round(limit * _TARGET_UTILIZATION, 2) pay_amount = max(0.0, round(current_balance - target_balance, 2)) - card_recommendations.append({ - "account_id": acct["id"], - "pay_amount": pay_amount, - "target_utilization": _TARGET_UTILIZATION, - "closing_day": acct.get("closing_date"), - }) + card_recommendations.append( + { + "account_id": acct["id"], + "pay_amount": pay_amount, + "target_utilization": _TARGET_UTILIZATION, + "closing_day": acct.get("closing_date"), + } + ) stacked_windows = [] today = date.today() @@ -49,12 +52,14 @@ def compute_pay_timing( total_due = sum(b.get("avg_amount") or 0 for b in window_bills) if window_bills and total_due > balance: sorted_bills = sorted(window_bills, key=lambda b: b.get("avg_amount") or 0, reverse=True) - stacked_windows.append({ - "window_start": today.isoformat(), - "window_end": (today + timedelta(days=_STACK_WINDOW_DAYS)).isoformat(), - "total_due": round(total_due, 2), - "priority_order": sorted_bills, - }) + stacked_windows.append( + { + "window_start": today.isoformat(), + "window_end": (today + timedelta(days=_STACK_WINDOW_DAYS)).isoformat(), + "total_due": round(total_due, 2), + "priority_order": sorted_bills, + } + ) return { "card_recommendations": card_recommendations, diff --git a/backend/main.py b/backend/main.py index 01e4da8..ccbcd26 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,7 +4,18 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from routers import argus, auth, bills, calendar, insights, onboarding, pay_timing, plaid, subscriptions, transactions +from routers import ( + argus, + auth, + bills, + calendar, + insights, + onboarding, + pay_timing, + plaid, + subscriptions, + transactions, +) load_dotenv() diff --git a/backend/routers/argus.py b/backend/routers/argus.py index 47474d7..28f23d0 100644 --- a/backend/routers/argus.py +++ b/backend/routers/argus.py @@ -39,11 +39,13 @@ async def _stream_chat_response( with client.messages.stream( model="claude-sonnet-4-6", max_tokens=2048, - system=[{ - "type": "text", - "text": _CHAT_SYSTEM_PROMPT, - "cache_control": {"type": "ephemeral"}, - }], + system=[ + { + "type": "text", + "text": _CHAT_SYSTEM_PROMPT, + "cache_control": {"type": "ephemeral"}, + } + ], messages=[{"role": "user", "content": brief}], ) as stream: for chunk in stream.text_stream: diff --git a/backend/routers/calendar.py b/backend/routers/calendar.py index b95c547..cfc2d91 100644 --- a/backend/routers/calendar.py +++ b/backend/routers/calendar.py @@ -1,4 +1,4 @@ -from datetime import date, timedelta +from datetime import date from fastapi import APIRouter, Depends @@ -44,28 +44,32 @@ async def get_calendar(user_id: str = Depends(get_current_user)): entries = [] for b in bills: - entries.append({ - "id": b["id"], - "type": "bill", - "merchant": b["merchant"], - "amount": b.get("avg_amount"), - "due_date": b.get("next_due_date"), - "logo_url": get_logo_url(b["merchant"], supabase), - "urgency": _urgency(b.get("next_due_date") or ""), - "ai_reasoning": None, - }) + entries.append( + { + "id": b["id"], + "type": "bill", + "merchant": b["merchant"], + "amount": b.get("avg_amount"), + "due_date": b.get("next_due_date"), + "logo_url": get_logo_url(b["merchant"], supabase), + "urgency": _urgency(b.get("next_due_date") or ""), + "ai_reasoning": None, + } + ) for s in subscriptions: - entries.append({ - "id": s["id"], - "type": "subscription", - "merchant": s["merchant"], - "amount": s.get("avg_amount"), - "due_date": s.get("next_due_date"), - "logo_url": get_logo_url(s["merchant"], supabase), - "urgency": _urgency(s.get("next_due_date") or ""), - "ai_reasoning": None, - }) + entries.append( + { + "id": s["id"], + "type": "subscription", + "merchant": s["merchant"], + "amount": s.get("avg_amount"), + "due_date": s.get("next_due_date"), + "logo_url": get_logo_url(s["merchant"], supabase), + "urgency": _urgency(s.get("next_due_date") or ""), + "ai_reasoning": None, + } + ) entries.sort(key=lambda e: e.get("due_date") or "9999-12-31") diff --git a/backend/routers/insights.py b/backend/routers/insights.py index f413d04..c896741 100644 --- a/backend/routers/insights.py +++ b/backend/routers/insights.py @@ -26,7 +26,8 @@ async def get_insights( data = result.data or [] if signal_type: data = [ - r for r in data + r + for r in data if (r.get("structured_output_json") or {}).get("signal_type") == signal_type ] return data @@ -55,7 +56,10 @@ async def get_safe_to_spend(user_id: str = Depends(get_current_user)): ).data or [] onboarding = ( - supabase.table("onboarding_responses").select("pay_schedule").eq("user_id", user_id).execute() + supabase.table("onboarding_responses") + .select("pay_schedule") + .eq("user_id", user_id) + .execute() ).data or [] pay_schedule = (onboarding[0].get("pay_schedule") or "monthly") if onboarding else "monthly" diff --git a/backend/routers/pay_timing.py b/backend/routers/pay_timing.py index db1ce78..6dd87c7 100644 --- a/backend/routers/pay_timing.py +++ b/backend/routers/pay_timing.py @@ -25,8 +25,6 @@ async def get_pay_timing(user_id: str = Depends(get_current_user)): .execute() ).data or [] - balance = sum( - a.get("balance") or 0 for a in accounts if a.get("account_type") != "credit" - ) + balance = sum(a.get("balance") or 0 for a in accounts if a.get("account_type") != "credit") return compute_pay_timing(accounts=accounts, bills=bills, balance=balance) diff --git a/backend/routers/plaid.py b/backend/routers/plaid.py index 0ff8d76..7891e8c 100644 --- a/backend/routers/plaid.py +++ b/backend/routers/plaid.py @@ -129,11 +129,7 @@ async def exchange_public_token( async def get_accounts(user_id: str = Depends(get_current_user)): supabase = get_supabase() result = ( - supabase.table("accounts") - .select("*") - .eq("user_id", user_id) - .order("institution") - .execute() + supabase.table("accounts").select("*").eq("user_id", user_id).order("institution").execute() ) return {"accounts": result.data} @@ -141,5 +137,6 @@ async def get_accounts(user_id: str = Depends(get_current_user)): @router.post("/sync") async def trigger_sync(user_id: str = Depends(get_current_user)): from tasks.sync_transactions import sync_transactions_for_user + task = sync_transactions_for_user.delay(user_id) return {"status": "syncing", "task_id": task.id} diff --git a/backend/routers/transactions.py b/backend/routers/transactions.py index 6e7219b..169406c 100644 --- a/backend/routers/transactions.py +++ b/backend/routers/transactions.py @@ -40,5 +40,6 @@ async def get_transactions( @router.post("/recategorize") async def recategorize_transactions(user_id: str = Depends(get_current_user)): from tasks.categorize_transactions import recategorize_transactions_for_user + task = recategorize_transactions_for_user.delay(user_id) return {"status": "categorizing", "task_id": task.id} diff --git a/backend/services/merchant_logos.py b/backend/services/merchant_logos.py index 4931f7a..3371eec 100644 --- a/backend/services/merchant_logos.py +++ b/backend/services/merchant_logos.py @@ -11,10 +11,7 @@ def _slug(merchant: str) -> str: def get_logo_url(merchant: str, supabase) -> str | None: cached = ( - supabase.table("merchant_logos") - .select("logo_url") - .eq("merchant", merchant) - .execute() + supabase.table("merchant_logos").select("logo_url").eq("merchant", merchant).execute() ).data or [] if cached: diff --git a/backend/tasks/categorize_transactions.py b/backend/tasks/categorize_transactions.py index 431e658..d4c77b0 100644 --- a/backend/tasks/categorize_transactions.py +++ b/backend/tasks/categorize_transactions.py @@ -29,8 +29,7 @@ def _filter_uncategorized(transactions: list[dict]) -> list[dict]: def _build_categorization_prompt(transactions: list[dict]) -> str: txn_lines = "\n".join( - f'- id: {t["id"]}, merchant: {t["merchant"]}, amount: {t["amount"]}' - for t in transactions + f"- id: {t['id']}, merchant: {t['merchant']}, amount: {t['amount']}" for t in transactions ) categories = ( "FOOD_AND_DRINK, SHOPPING, TRANSPORTATION, ENTERTAINMENT, UTILITIES, " @@ -44,7 +43,7 @@ def _build_categorization_prompt(transactions: list[dict]) -> str: f"Classify each transaction into one of these categories:\n{categories}\n\n" f"Also provide a short subcategory (e.g. {subcategory_examples}).\n\n" f"Transactions:\n{txn_lines}\n\n" - f'Return ONLY a valid JSON array with no explanation. ' + f"Return ONLY a valid JSON array with no explanation. " f'Each item must have: "id", "category", "subcategory".\n' f'Example: [{{"id": "abc", "category": "FOOD_AND_DRINK", ' f'"subcategory": "restaurants"}}]' diff --git a/backend/tasks/detect_bills.py b/backend/tasks/detect_bills.py index 465380c..be497b0 100644 --- a/backend/tasks/detect_bills.py +++ b/backend/tasks/detect_bills.py @@ -55,15 +55,17 @@ def _detect_bills_for_transactions(transactions: list[dict], user_id: str) -> li next_due = last_seen + timedelta(days=int(median_interval)) avg_amount = round(statistics.mean(amounts), 2) - bills.append({ - "id": str(uuid.uuid4()), - "user_id": user_id, - "merchant": merchant, - "recurrence_pattern": pattern, - "avg_amount": avg_amount, - "next_due_date": next_due.isoformat(), - "last_seen": last_seen.isoformat(), - }) + bills.append( + { + "id": str(uuid.uuid4()), + "user_id": user_id, + "merchant": merchant, + "recurrence_pattern": pattern, + "avg_amount": avg_amount, + "next_due_date": next_due.isoformat(), + "last_seen": last_seen.isoformat(), + } + ) return bills @@ -89,11 +91,10 @@ def detect_recurring_bills_for_user(user_id: str) -> dict: bills = _detect_bills_for_transactions(result.data, user_id) for bill in bills: - supabase.table("bills").upsert( - bill, on_conflict="user_id,merchant" - ).execute() + supabase.table("bills").upsert(bill, on_conflict="user_id,merchant").execute() from tasks.detect_subscriptions import detect_subscriptions_for_user + detect_subscriptions_for_user.delay(user_id) return {"user_id": user_id, "bills_detected": len(bills)} diff --git a/backend/tasks/detect_subscriptions.py b/backend/tasks/detect_subscriptions.py index 3546054..ef7b1c7 100644 --- a/backend/tasks/detect_subscriptions.py +++ b/backend/tasks/detect_subscriptions.py @@ -49,15 +49,17 @@ def _build_subscriptions( merchant = bill["merchant"] recent, old = _split_amounts_by_period(transactions, merchant, today) price_change_pct = _compute_price_change_pct(recent, old) - subscriptions.append({ - "id": str(uuid.uuid4()), - "user_id": user_id, - "merchant": merchant, - "avg_amount": bill["avg_amount"], - "billing_cycle": "monthly", - "price_change_pct": price_change_pct, - "is_active": True, - }) + subscriptions.append( + { + "id": str(uuid.uuid4()), + "user_id": user_id, + "merchant": merchant, + "avg_amount": bill["avg_amount"], + "billing_cycle": "monthly", + "price_change_pct": price_change_pct, + "is_active": True, + } + ) return subscriptions @@ -89,11 +91,10 @@ def detect_subscriptions_for_user(user_id: str) -> dict: subs = _build_subscriptions(bills_result.data, txn_result.data, user_id) for sub in subs: - supabase.table("subscriptions").upsert( - sub, on_conflict="user_id,merchant" - ).execute() + supabase.table("subscriptions").upsert(sub, on_conflict="user_id,merchant").execute() from tasks.run_intelligence_pipeline import run_intelligence_pipeline_for_user + run_intelligence_pipeline_for_user.delay(user_id) return {"user_id": user_id, "subscriptions_detected": len(subs)} diff --git a/backend/tasks/recompute_safe_to_spend.py b/backend/tasks/recompute_safe_to_spend.py index fd17e0f..5187b0e 100644 --- a/backend/tasks/recompute_safe_to_spend.py +++ b/backend/tasks/recompute_safe_to_spend.py @@ -10,18 +10,12 @@ def recompute_safe_to_spend_for_user(user_id: str) -> None: supabase = get_supabase() accounts = ( - supabase.table("accounts") - .select("id, balance") - .eq("user_id", user_id) - .execute() + supabase.table("accounts").select("id, balance").eq("user_id", user_id).execute() ).data or [] balance = sum(a.get("balance") or 0 for a in accounts) bills = ( - supabase.table("bills") - .select("avg_amount, next_due_date") - .eq("user_id", user_id) - .execute() + supabase.table("bills").select("avg_amount, next_due_date").eq("user_id", user_id).execute() ).data or [] onboarding = ( diff --git a/backend/tasks/resolve_predictions.py b/backend/tasks/resolve_predictions.py index aa18c9c..cf7d862 100644 --- a/backend/tasks/resolve_predictions.py +++ b/backend/tasks/resolve_predictions.py @@ -36,17 +36,12 @@ def resolve_due_predictions() -> dict: if account_id: account = ( - supabase.table("accounts") - .select("balance") - .eq("id", account_id) - .execute() + supabase.table("accounts").select("balance").eq("id", account_id).execute() ).data if account: actual_balance = account[0]["balance"] - was_accurate = _evaluate_prediction( - prediction["prediction_type"], payload, actual_balance - ) + was_accurate = _evaluate_prediction(prediction["prediction_type"], payload, actual_balance) if was_accurate is None: continue diff --git a/backend/tasks/run_intelligence_pipeline.py b/backend/tasks/run_intelligence_pipeline.py index 4eacfa9..7e9a2c7 100644 --- a/backend/tasks/run_intelligence_pipeline.py +++ b/backend/tasks/run_intelligence_pipeline.py @@ -15,16 +15,11 @@ def _build_rag_query_text( subscriptions: list[dict], ) -> str: account_summary = ", ".join( - f"{a.get('account_type', 'account')} balance {a.get('balance', 0)}" - for a in accounts[:3] + f"{a.get('account_type', 'account')} balance {a.get('balance', 0)}" for a in accounts[:3] ) bill_merchants = ", ".join(b["merchant"] for b in bills[:5] if b.get("merchant")) sub_merchants = ", ".join(s["merchant"] for s in subscriptions[:5] if s.get("merchant")) - return ( - f"Accounts: {account_summary}. " - f"Bills: {bill_merchants}. " - f"Subscriptions: {sub_merchants}." - ) + return f"Accounts: {account_summary}. Bills: {bill_merchants}. Subscriptions: {sub_merchants}." def _retrieve_relevant_insights( @@ -86,19 +81,21 @@ def run_intelligence_pipeline_for_user(user_id: str) -> dict: ).data or [] transactions = ( - supabase.table("transactions") - .select("category, amount, timestamp") - .in_("account_id", account_ids) - .order("timestamp", desc=True) - .limit(500) - .execute() - ).data or [] if account_ids else [] + ( + supabase.table("transactions") + .select("category, amount, timestamp") + .in_("account_id", account_ids) + .order("timestamp", desc=True) + .limit(500) + .execute() + ).data + or [] + if account_ids + else [] + ) profile_result = ( - supabase.table("user_financial_profiles") - .select("profile") - .eq("user_id", user_id) - .execute() + supabase.table("user_financial_profiles").select("profile").eq("user_id", user_id).execute() ) profile = profile_result.data[0]["profile"] if profile_result.data else {} diff --git a/backend/tasks/sync_transactions.py b/backend/tasks/sync_transactions.py index 9e08295..b5f927b 100644 --- a/backend/tasks/sync_transactions.py +++ b/backend/tasks/sync_transactions.py @@ -14,11 +14,7 @@ def _normalize_transaction(plaid_txn: dict, account_id: str) -> dict: "id": str(uuid.uuid4()), "account_id": account_id, "plaid_transaction_id": plaid_txn.get("transaction_id", ""), - "merchant": ( - plaid_txn.get("merchant_name") - or plaid_txn.get("name") - or "Unknown" - ), + "merchant": (plaid_txn.get("merchant_name") or plaid_txn.get("name") or "Unknown"), "amount": float(plaid_txn.get("amount", 0)), "category": _extract_category(plaid_txn), "subcategory": _extract_subcategory(plaid_txn), @@ -92,6 +88,7 @@ def sync_transactions_for_user(user_id: str) -> dict: ).execute() from tasks.generate_embeddings import generate_embedding_for_transaction + generate_embedding_for_transaction.delay(normalized["id"]) total_added += 1 @@ -100,11 +97,10 @@ def sync_transactions_for_user(user_id: str) -> dict: "plaid_transaction_id", removed_txn.get("transaction_id", "") ).execute() - supabase.table("plaid_items").update({"cursor": cursor}).eq( - "id", item["id"] - ).execute() + supabase.table("plaid_items").update({"cursor": cursor}).eq("id", item["id"]).execute() from tasks.detect_bills import detect_recurring_bills_for_user + detect_recurring_bills_for_user.delay(user_id) return {"user_id": user_id, "transactions_synced": total_added} diff --git a/backend/tests/test_agents.py b/backend/tests/test_agents.py index 39cb0b1..b789579 100644 --- a/backend/tests/test_agents.py +++ b/backend/tests/test_agents.py @@ -26,25 +26,60 @@ def _base_state(**overrides) -> IntelligenceState: # ── enrichment_node ────────────────────────────────────────────────────────── + def test_enrichment_node_empty_input(): result = enrichment_node(_base_state()) assert result["enrichment_result"] == {"bills": [], "subscriptions": []} def test_enrichment_node_merges_enrichment(): - bills = [{"id": "b1", "merchant": "Netflix", "avg_amount": 15.99, - "recurrence_pattern": "monthly", "next_due_date": "2026-06-01"}] - subs = [{"id": "s1", "merchant": "Spotify", "avg_amount": 9.99, - "price_change_pct": 0.0, "billing_cycle": "monthly"}] - - fake_response = json.dumps({ - "bills": [{"id": "b1", "enrichment": {"ai_confidence": 0.9, "merchant_context": "streaming", - "classification_note": "monthly", "is_subscription_candidate": True}}], - "subscriptions": [{"id": "s1", "enrichment": {"service_category": "streaming", - "duplicate_flag": False, "duplicate_note": None, - "price_trend_interpretation": "stable", - "cancel_recommendation": False, "cancel_reasoning": None}}], - }) + bills = [ + { + "id": "b1", + "merchant": "Netflix", + "avg_amount": 15.99, + "recurrence_pattern": "monthly", + "next_due_date": "2026-06-01", + } + ] + subs = [ + { + "id": "s1", + "merchant": "Spotify", + "avg_amount": 9.99, + "price_change_pct": 0.0, + "billing_cycle": "monthly", + } + ] + + fake_response = json.dumps( + { + "bills": [ + { + "id": "b1", + "enrichment": { + "ai_confidence": 0.9, + "merchant_context": "streaming", + "classification_note": "monthly", + "is_subscription_candidate": True, + }, + } + ], + "subscriptions": [ + { + "id": "s1", + "enrichment": { + "service_category": "streaming", + "duplicate_flag": False, + "duplicate_note": None, + "price_trend_interpretation": "stable", + "cancel_recommendation": False, + "cancel_reasoning": None, + }, + } + ], + } + ) mock_msg = MagicMock() mock_msg.content = [MagicMock(text=fake_response)] @@ -54,8 +89,10 @@ def test_enrichment_node_merges_enrichment(): mock_supabase = MagicMock() mock_supabase.table.return_value.update.return_value.eq.return_value.execute.return_value = None - with patch("agents.enrichment_agent.anthropic.Anthropic", return_value=mock_client), \ - patch("agents.enrichment_agent.get_supabase", return_value=mock_supabase): + with ( + patch("agents.enrichment_agent.anthropic.Anthropic", return_value=mock_client), + patch("agents.enrichment_agent.get_supabase", return_value=mock_supabase), + ): result = enrichment_node(_base_state(bills=bills, subscriptions=subs)) assert result["bills"][0]["ai_enrichment"]["ai_confidence"] == 0.9 @@ -64,8 +101,15 @@ def test_enrichment_node_merges_enrichment(): def test_enrichment_node_handles_claude_error(): - bills = [{"id": "b1", "merchant": "Netflix", "avg_amount": 15.99, - "recurrence_pattern": "monthly", "next_due_date": "2026-06-01"}] + bills = [ + { + "id": "b1", + "merchant": "Netflix", + "avg_amount": 15.99, + "recurrence_pattern": "monthly", + "next_due_date": "2026-06-01", + } + ] mock_client = MagicMock() mock_client.messages.create.side_effect = Exception("API error") @@ -78,27 +122,32 @@ def test_enrichment_node_handles_claude_error(): # ── analyst_node ───────────────────────────────────────────────────────────── + def test_analyst_node_returns_decisions(): - fake_response = json.dumps({ - "decisions": [{ - "signal_type": "behavioral", - "severity": "warning", - "title": "Dining up 40%", - "reasoning": "Three week trend", - "recommendation": "Set a limit", - "simulation": "Will reach $400 by August", - "confidence": 0.85, - "sources": ["transactions:FOOD_AND_DRINK"], - }], - "updated_profile": { - "income_pattern": {}, - "spending_baselines": {}, - "behavioral_patterns": [], - "known_risks": [], - "analyst_notes": "First session", - "resolved_patterns": [], - }, - }) + fake_response = json.dumps( + { + "decisions": [ + { + "signal_type": "behavioral", + "severity": "warning", + "title": "Dining up 40%", + "reasoning": "Three week trend", + "recommendation": "Set a limit", + "simulation": "Will reach $400 by August", + "confidence": 0.85, + "sources": ["transactions:FOOD_AND_DRINK"], + } + ], + "updated_profile": { + "income_pattern": {}, + "spending_baselines": {}, + "behavioral_patterns": [], + "known_risks": [], + "analyst_notes": "First session", + "resolved_patterns": [], + }, + } + ) mock_msg = MagicMock() mock_msg.content = [MagicMock(text=fake_response)] @@ -106,14 +155,18 @@ def test_analyst_node_returns_decisions(): mock_client.messages.create.return_value = mock_msg with patch("agents.analyst_agent.anthropic.Anthropic", return_value=mock_client): - result = analyst_node(_base_state( - accounts=[{"account_type": "checking", "balance": 1000.0, "credit_limit": None}], - tx_summary={ - "FOOD_AND_DRINK": { - "monthly_baseline": 200.0, "last_30_total": 280.0, "change_pct": 40.0 - } - }, - )) + result = analyst_node( + _base_state( + accounts=[{"account_type": "checking", "balance": 1000.0, "credit_limit": None}], + tx_summary={ + "FOOD_AND_DRINK": { + "monthly_baseline": 200.0, + "last_30_total": 280.0, + "change_pct": 40.0, + } + }, + ) + ) assert len(result["decisions"]) == 1 assert result["decisions"][0]["title"] == "Dining up 40%" @@ -151,10 +204,20 @@ def test_analyst_node_handles_claude_error(): # ── memory_node ────────────────────────────────────────────────────────────── + def test_memory_node_inserts_with_embedding(): - decisions = [{"title": "Dining up 40%", "signal_type": "behavioral", "severity": "warning", - "reasoning": "r", "recommendation": "rec", "simulation": "sim", - "confidence": 0.85, "sources": []}] + decisions = [ + { + "title": "Dining up 40%", + "signal_type": "behavioral", + "severity": "warning", + "reasoning": "r", + "recommendation": "rec", + "simulation": "sim", + "confidence": 0.85, + "sources": [], + } + ] mock_supabase = MagicMock() mock_embed = MagicMock() @@ -162,8 +225,10 @@ def test_memory_node_inserts_with_embedding(): mock_openai = MagicMock() mock_openai.embeddings.create.return_value = mock_embed - with patch("agents.memory_agent.get_supabase", return_value=mock_supabase), \ - patch("agents.memory_agent.OpenAI", return_value=mock_openai): + with ( + patch("agents.memory_agent.get_supabase", return_value=mock_supabase), + patch("agents.memory_agent.OpenAI", return_value=mock_openai), + ): memory_node(_base_state(decisions=decisions)) insert_call = mock_supabase.table.return_value.insert.call_args @@ -179,24 +244,37 @@ def test_memory_node_skips_profile_upsert_when_empty(): mock_openai = MagicMock() mock_openai.embeddings.create.return_value = MagicMock(data=[MagicMock(embedding=[0.1] * 1536)]) - with patch("agents.memory_agent.get_supabase", return_value=mock_supabase), \ - patch("agents.memory_agent.OpenAI", return_value=mock_openai): + with ( + patch("agents.memory_agent.get_supabase", return_value=mock_supabase), + patch("agents.memory_agent.OpenAI", return_value=mock_openai), + ): memory_node(_base_state(decisions=[], updated_profile={})) mock_supabase.table.return_value.upsert.assert_not_called() def test_memory_node_handles_embed_failure(): - decisions = [{"title": "Test", "signal_type": "risk", "severity": "info", - "reasoning": "r", "recommendation": "rec", "simulation": "sim", - "confidence": 0.5, "sources": []}] + decisions = [ + { + "title": "Test", + "signal_type": "risk", + "severity": "info", + "reasoning": "r", + "recommendation": "rec", + "simulation": "sim", + "confidence": 0.5, + "sources": [], + } + ] mock_supabase = MagicMock() mock_openai = MagicMock() mock_openai.embeddings.create.side_effect = Exception("OpenAI down") - with patch("agents.memory_agent.get_supabase", return_value=mock_supabase), \ - patch("agents.memory_agent.OpenAI", return_value=mock_openai): + with ( + patch("agents.memory_agent.get_supabase", return_value=mock_supabase), + patch("agents.memory_agent.OpenAI", return_value=mock_openai), + ): memory_node(_base_state(decisions=decisions)) insert_call = mock_supabase.table.return_value.insert.call_args @@ -207,15 +285,32 @@ def test_memory_node_handles_embed_failure(): # ── graph smoke test ───────────────────────────────────────────────────────── + def test_graph_runs_all_three_nodes(): - fake_analyst = json.dumps({ - "decisions": [{"title": "Test decision", "signal_type": "behavioral", - "severity": "info", "reasoning": "r", "recommendation": "rec", - "simulation": "sim", "confidence": 0.7, "sources": []}], - "updated_profile": {"income_pattern": {}, "spending_baselines": {}, - "behavioral_patterns": [], "known_risks": [], - "analyst_notes": "test", "resolved_patterns": []}, - }) + fake_analyst = json.dumps( + { + "decisions": [ + { + "title": "Test decision", + "signal_type": "behavioral", + "severity": "info", + "reasoning": "r", + "recommendation": "rec", + "simulation": "sim", + "confidence": 0.7, + "sources": [], + } + ], + "updated_profile": { + "income_pattern": {}, + "spending_baselines": {}, + "behavioral_patterns": [], + "known_risks": [], + "analyst_notes": "test", + "resolved_patterns": [], + }, + } + ) mock_msg_analyst = MagicMock() mock_msg_analyst.content = [MagicMock(text=fake_analyst)] @@ -234,11 +329,13 @@ def test_graph_runs_all_three_nodes(): from agents.graph import intelligence_graph - with patch("agents.enrichment_agent.anthropic.Anthropic", return_value=mock_enrich_client), \ - patch("agents.analyst_agent.anthropic.Anthropic", return_value=mock_analyst_client), \ - patch("agents.enrichment_agent.get_supabase", return_value=mock_supabase), \ - patch("agents.memory_agent.get_supabase", return_value=mock_supabase), \ - patch("agents.memory_agent.OpenAI", return_value=mock_openai): + with ( + patch("agents.enrichment_agent.anthropic.Anthropic", return_value=mock_enrich_client), + patch("agents.analyst_agent.anthropic.Anthropic", return_value=mock_analyst_client), + patch("agents.enrichment_agent.get_supabase", return_value=mock_supabase), + patch("agents.memory_agent.get_supabase", return_value=mock_supabase), + patch("agents.memory_agent.OpenAI", return_value=mock_openai), + ): final_state = intelligence_graph.invoke(_base_state()) assert len(final_state["decisions"]) == 1 diff --git a/backend/tests/test_argus_chat.py b/backend/tests/test_argus_chat.py index 34dd8aa..f9c7400 100644 --- a/backend/tests/test_argus_chat.py +++ b/backend/tests/test_argus_chat.py @@ -89,17 +89,18 @@ def test_retrieve_chat_context_combines_sources(): profile_chain.execute.return_value.data = [{"profile": {"income": 5000}}] predictions_chain = ( - mock_supabase.table.return_value.select.return_value.eq.return_value - .order.return_value.limit.return_value + mock_supabase.table.return_value.select.return_value.eq.return_value.order.return_value.limit.return_value ) predictions_chain.execute.return_value.data = [ {"prediction_type": "balance_below_threshold", "was_accurate": True}, {"prediction_type": "balance_below_threshold", "was_accurate": None}, ] - with patch("agents.chat.supervisor_graph") as mock_graph, \ - patch("agents.chat.get_supabase", return_value=mock_supabase), \ - patch("agents.chat._retrieve_relevant_insights", return_value=[{"summary": "x"}]): + with ( + patch("agents.chat.supervisor_graph") as mock_graph, + patch("agents.chat.get_supabase", return_value=mock_supabase), + patch("agents.chat._retrieve_relevant_insights", return_value=[{"summary": "x"}]), + ): mock_graph.invoke.return_value = mock_supervisor_result result = _retrieve_chat_context("user-123", "what bills do I have") diff --git a/backend/tests/test_argus_router.py b/backend/tests/test_argus_router.py index 161e021..d4b87c2 100644 --- a/backend/tests/test_argus_router.py +++ b/backend/tests/test_argus_router.py @@ -23,11 +23,20 @@ def test_chat_streams_sse_response(): ["You'll ", "overdraft on the 27th."] ) - with patch("routers.argus._retrieve_chat_context", return_value={ - "tool_results": {}, "profile": {}, "past_predictions": [], "distilled_insights": [], - }), patch("routers.argus._build_chat_brief", return_value="brief"), \ - patch("routers.argus.anthropic.Anthropic", return_value=mock_anthropic_client), \ - patch("routers.argus._extract_prediction_block", return_value=None): + with ( + patch( + "routers.argus._retrieve_chat_context", + return_value={ + "tool_results": {}, + "profile": {}, + "past_predictions": [], + "distilled_insights": [], + }, + ), + patch("routers.argus._build_chat_brief", return_value="brief"), + patch("routers.argus.anthropic.Anthropic", return_value=mock_anthropic_client), + patch("routers.argus._extract_prediction_block", return_value=None), + ): resp = client.post("/argus/chat", json={"query": "when will I overdraft"}) assert resp.status_code == 200 @@ -41,12 +50,21 @@ def test_chat_returns_cards_in_done_payload(): mock_anthropic_client.messages.stream.side_effect = lambda **_: _fake_stream(["text"]) cards = [{"type": "verdict", "data": {"label": "Safe to spend", "value": "$214"}}] - with patch("routers.argus._retrieve_chat_context", return_value={ - "tool_results": {}, "profile": {}, "past_predictions": [], "distilled_insights": [], - }), patch("routers.argus._build_chat_brief", return_value="brief"), \ - patch("routers.argus.anthropic.Anthropic", return_value=mock_anthropic_client), \ - patch("routers.argus._extract_prediction_block", return_value=None), \ - patch("routers.argus._extract_card_blocks", return_value=cards): + with ( + patch( + "routers.argus._retrieve_chat_context", + return_value={ + "tool_results": {}, + "profile": {}, + "past_predictions": [], + "distilled_insights": [], + }, + ), + patch("routers.argus._build_chat_brief", return_value="brief"), + patch("routers.argus.anthropic.Anthropic", return_value=mock_anthropic_client), + patch("routers.argus._extract_prediction_block", return_value=None), + patch("routers.argus._extract_card_blocks", return_value=cards), + ): resp = client.post("/argus/chat", json={"query": "can I afford this"}) assert resp.status_code == 200 @@ -62,12 +80,21 @@ def test_chat_logs_prediction_when_present(): "resolves_at": "2026-06-27T00:00:00Z", } - with patch("routers.argus._retrieve_chat_context", return_value={ - "tool_results": {}, "profile": {}, "past_predictions": [], "distilled_insights": [], - }), patch("routers.argus._build_chat_brief", return_value="brief"), \ - patch("routers.argus.anthropic.Anthropic", return_value=mock_anthropic_client), \ - patch("routers.argus._extract_prediction_block", return_value=prediction), \ - patch("routers.argus._log_prediction") as mock_log: + with ( + patch( + "routers.argus._retrieve_chat_context", + return_value={ + "tool_results": {}, + "profile": {}, + "past_predictions": [], + "distilled_insights": [], + }, + ), + patch("routers.argus._build_chat_brief", return_value="brief"), + patch("routers.argus.anthropic.Anthropic", return_value=mock_anthropic_client), + patch("routers.argus._extract_prediction_block", return_value=prediction), + patch("routers.argus._log_prediction") as mock_log, + ): resp = client.post("/argus/chat", json={"query": "when will I overdraft"}) assert resp.status_code == 200 @@ -78,14 +105,21 @@ def test_chat_passes_page_to_brief(): mock_anthropic_client = MagicMock() mock_anthropic_client.messages.stream.side_effect = lambda **_: _fake_stream(["text"]) - with patch("routers.argus._retrieve_chat_context", return_value={ - "tool_results": {}, "profile": {}, "past_predictions": [], "distilled_insights": [], - }), patch("routers.argus._build_chat_brief", return_value="brief") as mock_brief, \ - patch("routers.argus.anthropic.Anthropic", return_value=mock_anthropic_client), \ - patch("routers.argus._extract_prediction_block", return_value=None): - resp = client.post( - "/argus/chat", json={"query": "what bills do I have", "page": "/bills"} - ) + with ( + patch( + "routers.argus._retrieve_chat_context", + return_value={ + "tool_results": {}, + "profile": {}, + "past_predictions": [], + "distilled_insights": [], + }, + ), + patch("routers.argus._build_chat_brief", return_value="brief") as mock_brief, + patch("routers.argus.anthropic.Anthropic", return_value=mock_anthropic_client), + patch("routers.argus._extract_prediction_block", return_value=None), + ): + resp = client.post("/argus/chat", json={"query": "what bills do I have", "page": "/bills"}) assert resp.status_code == 200 _, kwargs = mock_brief.call_args diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 9dcc086..9467bfb 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -34,7 +34,5 @@ def test_sync_user_unauthenticated(): def test_sync_user_invalid_token(): - response = client.post( - "/api/users/sync", headers={"Authorization": "Bearer not-a-real-token"} - ) + response = client.post("/api/users/sync", headers={"Authorization": "Bearer not-a-real-token"}) assert response.status_code == 401 diff --git a/backend/tests/test_calendar_endpoint.py b/backend/tests/test_calendar_endpoint.py index ecab77a..467401d 100644 --- a/backend/tests/test_calendar_endpoint.py +++ b/backend/tests/test_calendar_endpoint.py @@ -16,9 +16,13 @@ def make_supabase(bills_data, subs_data): def table_side(name): m = MagicMock() if name == "bills": - m.select.return_value.eq.return_value.order.return_value.execute.return_value.data = bills_data + m.select.return_value.eq.return_value.order.return_value.execute.return_value.data = ( + bills_data + ) elif name == "subscriptions": - m.select.return_value.eq.return_value.eq.return_value.execute.return_value.data = subs_data + m.select.return_value.eq.return_value.eq.return_value.execute.return_value.data = ( + subs_data + ) elif name == "merchant_logos": m.select.return_value.eq.return_value.execute.return_value.data = [] m.upsert.return_value.execute.return_value = MagicMock() @@ -30,12 +34,28 @@ def table_side(name): def test_calendar_returns_sorted_entries(): today = date.today() - bills = [{"id": "b1", "merchant": "Electric", "avg_amount": 80.0, "next_due_date": (today + timedelta(days=5)).isoformat()}] - subs = [{"id": "s1", "merchant": "Netflix", "avg_amount": 15.0, "next_due_date": (today + timedelta(days=2)).isoformat()}] + bills = [ + { + "id": "b1", + "merchant": "Electric", + "avg_amount": 80.0, + "next_due_date": (today + timedelta(days=5)).isoformat(), + } + ] + subs = [ + { + "id": "s1", + "merchant": "Netflix", + "avg_amount": 15.0, + "next_due_date": (today + timedelta(days=2)).isoformat(), + } + ] mock_supabase = make_supabase(bills, subs) - with patch("routers.calendar.get_supabase", return_value=mock_supabase), \ - patch("services.merchant_logos.requests.head") as h: + with ( + patch("routers.calendar.get_supabase", return_value=mock_supabase), + patch("services.merchant_logos.requests.head") as h, + ): h.return_value.status_code = 404 resp = client.get("/calendar") @@ -48,11 +68,20 @@ def test_calendar_returns_sorted_entries(): def test_calendar_urgency_high_within_3_days(): today = date.today() - bills = [{"id": "b1", "merchant": "Rent", "avg_amount": 1200.0, "next_due_date": (today + timedelta(days=1)).isoformat()}] + bills = [ + { + "id": "b1", + "merchant": "Rent", + "avg_amount": 1200.0, + "next_due_date": (today + timedelta(days=1)).isoformat(), + } + ] mock_supabase = make_supabase(bills, []) - with patch("routers.calendar.get_supabase", return_value=mock_supabase), \ - patch("services.merchant_logos.requests.head") as h: + with ( + patch("routers.calendar.get_supabase", return_value=mock_supabase), + patch("services.merchant_logos.requests.head") as h, + ): h.return_value.status_code = 404 resp = client.get("/calendar") @@ -62,11 +91,20 @@ def test_calendar_urgency_high_within_3_days(): def test_calendar_urgency_medium_within_7_days(): today = date.today() - bills = [{"id": "b1", "merchant": "Car", "avg_amount": 350.0, "next_due_date": (today + timedelta(days=5)).isoformat()}] + bills = [ + { + "id": "b1", + "merchant": "Car", + "avg_amount": 350.0, + "next_due_date": (today + timedelta(days=5)).isoformat(), + } + ] mock_supabase = make_supabase(bills, []) - with patch("routers.calendar.get_supabase", return_value=mock_supabase), \ - patch("services.merchant_logos.requests.head") as h: + with ( + patch("routers.calendar.get_supabase", return_value=mock_supabase), + patch("services.merchant_logos.requests.head") as h, + ): h.return_value.status_code = 404 resp = client.get("/calendar") diff --git a/backend/tests/test_enrich_detected_records.py b/backend/tests/test_enrich_detected_records.py index 37e587b..59ae0ff 100644 --- a/backend/tests/test_enrich_detected_records.py +++ b/backend/tests/test_enrich_detected_records.py @@ -7,16 +7,30 @@ def test_build_prompt_includes_bill_merchant(): - bills = [{"id": "b1", "merchant": "Netflix", "avg_amount": 15.99, - "recurrence_pattern": "monthly", "next_due_date": "2026-06-01"}] + bills = [ + { + "id": "b1", + "merchant": "Netflix", + "avg_amount": 15.99, + "recurrence_pattern": "monthly", + "next_due_date": "2026-06-01", + } + ] prompt = _build_enrichment_prompt(bills, []) assert "Netflix" in prompt assert "b1" in prompt def test_build_prompt_includes_subscription_merchant(): - subs = [{"id": "s1", "merchant": "Spotify", "avg_amount": 9.99, - "price_change_pct": 5.0, "billing_cycle": "monthly"}] + subs = [ + { + "id": "s1", + "merchant": "Spotify", + "avg_amount": 9.99, + "price_change_pct": 5.0, + "billing_cycle": "monthly", + } + ] prompt = _build_enrichment_prompt([], subs) assert "Spotify" in prompt assert "s1" in prompt @@ -29,22 +43,34 @@ def test_build_prompt_handles_empty_lists(): def test_parse_valid_response(): - response = json.dumps({ - "bills": [{"id": "b1", "enrichment": { - "ai_confidence": 0.9, - "merchant_context": "video streaming", - "classification_note": "monthly charges stable within $0.01", - "is_subscription_candidate": True, - }}], - "subscriptions": [{"id": "s1", "enrichment": { - "service_category": "streaming", - "duplicate_flag": False, - "duplicate_note": None, - "price_trend_interpretation": "stable", - "cancel_recommendation": False, - "cancel_reasoning": None, - }}], - }) + response = json.dumps( + { + "bills": [ + { + "id": "b1", + "enrichment": { + "ai_confidence": 0.9, + "merchant_context": "video streaming", + "classification_note": "monthly charges stable within $0.01", + "is_subscription_candidate": True, + }, + } + ], + "subscriptions": [ + { + "id": "s1", + "enrichment": { + "service_category": "streaming", + "duplicate_flag": False, + "duplicate_note": None, + "price_trend_interpretation": "stable", + "cancel_recommendation": False, + "cancel_reasoning": None, + }, + } + ], + } + ) result = _parse_enrichment_response(response) assert len(result["bills"]) == 1 assert result["bills"][0]["id"] == "b1" diff --git a/backend/tests/test_merchant_logos.py b/backend/tests/test_merchant_logos.py index e296b3d..c21a8c5 100644 --- a/backend/tests/test_merchant_logos.py +++ b/backend/tests/test_merchant_logos.py @@ -1,19 +1,24 @@ from unittest.mock import MagicMock, patch +def _mock_chain(mock_supabase): + return ( + mock_supabase.table.return_value.select.return_value.eq.return_value.execute.return_value + ) + + def test_returns_cached_logo(): mock_supabase = MagicMock() - mock_supabase.table.return_value.select.return_value.eq.return_value.execute.return_value.data = [ - {"logo_url": "https://logo.clearbit.com/netflix.com"} - ] + _mock_chain(mock_supabase).data = [{"logo_url": "https://logo.clearbit.com/netflix.com"}] from services.merchant_logos import get_logo_url + result = get_logo_url("Netflix", mock_supabase) assert result == "https://logo.clearbit.com/netflix.com" def test_fetches_and_caches_on_miss(): mock_supabase = MagicMock() - mock_supabase.table.return_value.select.return_value.eq.return_value.execute.return_value.data = [] + _mock_chain(mock_supabase).data = [] upserted = [] def capture_upsert(payload, **kwargs): @@ -25,6 +30,7 @@ def capture_upsert(payload, **kwargs): with patch("services.merchant_logos.requests.head") as mock_head: mock_head.return_value.status_code = 200 from services.merchant_logos import get_logo_url + result = get_logo_url("Spotify", mock_supabase) assert result is not None @@ -34,11 +40,12 @@ def capture_upsert(payload, **kwargs): def test_returns_none_on_404(): mock_supabase = MagicMock() - mock_supabase.table.return_value.select.return_value.eq.return_value.execute.return_value.data = [] + _mock_chain(mock_supabase).data = [] with patch("services.merchant_logos.requests.head") as mock_head: mock_head.return_value.status_code = 404 from services.merchant_logos import get_logo_url + result = get_logo_url("UnknownMerchant99", mock_supabase) assert result is None diff --git a/backend/tests/test_pay_timing_endpoint.py b/backend/tests/test_pay_timing_endpoint.py index 55cf47d..ed4b80f 100644 --- a/backend/tests/test_pay_timing_endpoint.py +++ b/backend/tests/test_pay_timing_endpoint.py @@ -16,7 +16,13 @@ def table_side(name): m = MagicMock() if name == "accounts": m.select.return_value.eq.return_value.execute.return_value.data = [ - {"id": "card-1", "account_type": "credit", "balance": 500.0, "credit_limit": 1000.0, "closing_date": None} + { + "id": "card-1", + "account_type": "credit", + "balance": 500.0, + "credit_limit": 1000.0, + "closing_date": None, + } ] elif name == "bills": m.select.return_value.eq.return_value.execute.return_value.data = [] diff --git a/backend/tests/test_pay_timing_engine.py b/backend/tests/test_pay_timing_engine.py index 286aca9..3be96eb 100644 --- a/backend/tests/test_pay_timing_engine.py +++ b/backend/tests/test_pay_timing_engine.py @@ -20,9 +20,21 @@ def test_infer_closing_date_fallback(): def test_bills_in_window_filters_correctly(): today = date.today() bills = [ - {"next_due_date": (today + timedelta(days=1)).isoformat(), "avg_amount": 50.0, "merchant": "A"}, - {"next_due_date": (today + timedelta(days=10)).isoformat(), "avg_amount": 200.0, "merchant": "B"}, - {"next_due_date": (today + timedelta(days=40)).isoformat(), "avg_amount": 100.0, "merchant": "C"}, + { + "next_due_date": (today + timedelta(days=1)).isoformat(), + "avg_amount": 50.0, + "merchant": "A", + }, + { + "next_due_date": (today + timedelta(days=10)).isoformat(), + "avg_amount": 200.0, + "merchant": "B", + }, + { + "next_due_date": (today + timedelta(days=40)).isoformat(), + "avg_amount": 100.0, + "merchant": "C", + }, ] result = bills_in_window(bills, window_days=7) assert len(result) == 1 @@ -31,7 +43,13 @@ def test_bills_in_window_filters_correctly(): def test_compute_pay_timing_card_recommendation(): accounts = [ - {"id": "card-1", "account_type": "credit", "balance": 800.0, "credit_limit": 1000.0, "closing_date": None} + { + "id": "card-1", + "account_type": "credit", + "balance": 800.0, + "credit_limit": 1000.0, + "closing_date": None, + } ] result = compute_pay_timing(accounts=accounts, bills=[], balance=2000.0) recs = result["card_recommendations"] @@ -41,7 +59,13 @@ def test_compute_pay_timing_card_recommendation(): def test_compute_pay_timing_skips_non_credit(): accounts = [ - {"id": "chk-1", "account_type": "checking", "balance": 2000.0, "credit_limit": None, "closing_date": None} + { + "id": "chk-1", + "account_type": "checking", + "balance": 2000.0, + "credit_limit": None, + "closing_date": None, + } ] result = compute_pay_timing(accounts=accounts, bills=[], balance=2000.0) assert result["card_recommendations"] == [] @@ -50,9 +74,21 @@ def test_compute_pay_timing_skips_non_credit(): def test_compute_pay_timing_stacked_window_detection(): today = date.today() bills = [ - {"next_due_date": (today + timedelta(days=1)).isoformat(), "avg_amount": 300.0, "merchant": "Rent"}, - {"next_due_date": (today + timedelta(days=2)).isoformat(), "avg_amount": 200.0, "merchant": "Car"}, - {"next_due_date": (today + timedelta(days=3)).isoformat(), "avg_amount": 150.0, "merchant": "Electric"}, + { + "next_due_date": (today + timedelta(days=1)).isoformat(), + "avg_amount": 300.0, + "merchant": "Rent", + }, + { + "next_due_date": (today + timedelta(days=2)).isoformat(), + "avg_amount": 200.0, + "merchant": "Car", + }, + { + "next_due_date": (today + timedelta(days=3)).isoformat(), + "avg_amount": 150.0, + "merchant": "Electric", + }, ] result = compute_pay_timing(accounts=[], bills=bills, balance=400.0) assert len(result["stacked_windows"]) > 0 @@ -61,7 +97,11 @@ def test_compute_pay_timing_stacked_window_detection(): def test_no_stack_when_balance_covers_bills(): today = date.today() bills = [ - {"next_due_date": (today + timedelta(days=1)).isoformat(), "avg_amount": 100.0, "merchant": "Netflix"}, + { + "next_due_date": (today + timedelta(days=1)).isoformat(), + "avg_amount": 100.0, + "merchant": "Netflix", + }, ] result = compute_pay_timing(accounts=[], bills=bills, balance=5000.0) assert result["stacked_windows"] == [] diff --git a/backend/tests/test_recompute_safe_to_spend.py b/backend/tests/test_recompute_safe_to_spend.py index b841fbc..3e9589b 100644 --- a/backend/tests/test_recompute_safe_to_spend.py +++ b/backend/tests/test_recompute_safe_to_spend.py @@ -40,6 +40,7 @@ def table_side_effect(name): with patch("tasks.recompute_safe_to_spend.get_supabase", return_value=mock_supabase): from tasks.recompute_safe_to_spend import recompute_safe_to_spend_for_user + recompute_safe_to_spend_for_user("user-1") assert len(upserted_payloads) == 1 diff --git a/backend/tests/test_run_intelligence_pipeline.py b/backend/tests/test_run_intelligence_pipeline.py index e776098..7972f84 100644 --- a/backend/tests/test_run_intelligence_pipeline.py +++ b/backend/tests/test_run_intelligence_pipeline.py @@ -23,16 +23,16 @@ def test_build_rag_query_text_handles_empty(): def test_retrieve_relevant_insights_returns_results(): mock_openai = MagicMock() - mock_openai.embeddings.create.return_value = MagicMock( - data=[MagicMock(embedding=[0.1] * 1536)] - ) + mock_openai.embeddings.create.return_value = MagicMock(data=[MagicMock(embedding=[0.1] * 1536)]) mock_supabase = MagicMock() mock_supabase.rpc.return_value.execute.return_value.data = [ {"id": "ins1", "summary": "Past insight", "similarity": 0.85} ] - with patch("tasks.run_intelligence_pipeline.OpenAI", return_value=mock_openai), \ - patch("tasks.run_intelligence_pipeline.get_supabase", return_value=mock_supabase): + with ( + patch("tasks.run_intelligence_pipeline.OpenAI", return_value=mock_openai), + patch("tasks.run_intelligence_pipeline.get_supabase", return_value=mock_supabase), + ): result = _retrieve_relevant_insights("user-1", "test query") assert len(result) == 1 @@ -73,10 +73,13 @@ def test_pipeline_task_returns_decisions_written(): mock_openai = MagicMock() mock_openai.embeddings.create.side_effect = Exception("skip RAG") - with patch("tasks.run_intelligence_pipeline.get_supabase", return_value=mock_supabase), \ - patch("tasks.run_intelligence_pipeline.intelligence_graph", mock_graph), \ - patch("tasks.run_intelligence_pipeline.OpenAI", return_value=mock_openai): + with ( + patch("tasks.run_intelligence_pipeline.get_supabase", return_value=mock_supabase), + patch("tasks.run_intelligence_pipeline.intelligence_graph", mock_graph), + patch("tasks.run_intelligence_pipeline.OpenAI", return_value=mock_openai), + ): from tasks.run_intelligence_pipeline import run_intelligence_pipeline_for_user + result = run_intelligence_pipeline_for_user("user-1") assert result["decisions_written"] == 2 diff --git a/backend/tests/test_safe_to_spend_endpoint.py b/backend/tests/test_safe_to_spend_endpoint.py index 0dd93ea..c3f782d 100644 --- a/backend/tests/test_safe_to_spend_endpoint.py +++ b/backend/tests/test_safe_to_spend_endpoint.py @@ -18,7 +18,12 @@ def table_side(name): m.select.return_value.eq.return_value.execute.return_value.data = [ { "safe_amount": 450.0, - "breakdown": {"balance": 600.0, "bills_due": 100.0, "buffer_reserve": 50.0, "window_days": 14}, + "breakdown": { + "balance": 600.0, + "bills_due": 100.0, + "buffer_reserve": 50.0, + "window_days": 14, + }, "computed_at": "2026-06-01T02:00:00Z", } ] @@ -45,7 +50,9 @@ def table_side(name): elif name == "bills": m.select.return_value.eq.return_value.execute.return_value.data = [] elif name == "onboarding_responses": - m.select.return_value.eq.return_value.execute.return_value.data = [{"pay_schedule": "biweekly"}] + m.select.return_value.eq.return_value.execute.return_value.data = [ + {"pay_schedule": "biweekly"} + ] return m mock_supabase.table.side_effect = table_side diff --git a/backend/tests/test_safe_to_spend_tool.py b/backend/tests/test_safe_to_spend_tool.py index 59cbbbd..fe006a7 100644 --- a/backend/tests/test_safe_to_spend_tool.py +++ b/backend/tests/test_safe_to_spend_tool.py @@ -3,16 +3,19 @@ def test_safe_to_spend_tool_registered(): from agents.tools import get_registered_tools + tools = get_registered_tools() assert "get_safe_to_spend" in tools def test_safe_to_spend_tool_returns_amount(): mock_supabase = MagicMock() - mock_supabase.table.return_value.select.return_value.eq.return_value.execute.return_value.data = [ - {"safe_amount": 300.0, "breakdown": {}} - ] + chain = ( + mock_supabase.table.return_value.select.return_value.eq.return_value.execute.return_value + ) + chain.data = [{"safe_amount": 300.0, "breakdown": {}}] with patch("agents.tools.get_supabase", return_value=mock_supabase): from agents.tools import call_tool + result = call_tool("get_safe_to_spend", "user-1") assert result["safe_amount"] == 300.0 diff --git a/backend/tests/test_supervisor.py b/backend/tests/test_supervisor.py index 728f998..2b2c677 100644 --- a/backend/tests/test_supervisor.py +++ b/backend/tests/test_supervisor.py @@ -42,8 +42,10 @@ def test_select_tools_falls_back_to_all_when_no_match(): def test_route_and_execute_node_calls_selected_tools(): state = {"user_id": "user-123", "query": "what bills do I have"} - with patch("agents.supervisor.get_registered_tools", return_value=_FAKE_TOOLS), \ - patch("agents.supervisor.call_tool", return_value={"bills": []}) as mock_call: + with ( + patch("agents.supervisor.get_registered_tools", return_value=_FAKE_TOOLS), + patch("agents.supervisor.call_tool", return_value={"bills": []}) as mock_call, + ): result = route_and_execute_node(state) mock_call.assert_called_once_with("get_bills", "user-123") diff --git a/backend/tests/test_synthesize_insights.py b/backend/tests/test_synthesize_insights.py index 24de8f1..7619f32 100644 --- a/backend/tests/test_synthesize_insights.py +++ b/backend/tests/test_synthesize_insights.py @@ -50,8 +50,10 @@ def test_build_analyst_brief_includes_all_sections(): accounts = [{"account_type": "checking", "balance": 1000.0, "credit_limit": None}] bills = [ { - "merchant": "Rent", "avg_amount": 1200.0, - "next_due_date": "2026-06-01", "ai_enrichment": None, + "merchant": "Rent", + "avg_amount": 1200.0, + "next_due_date": "2026-06-01", + "ai_enrichment": None, } ] subs = [ @@ -69,26 +71,30 @@ def test_build_analyst_brief_includes_all_sections(): def test_parse_synthesis_response_valid(): - response = json.dumps({ - "decisions": [{ - "signal_type": "behavioral", - "severity": "warning", - "title": "Dining up 40%", - "reasoning": "Three week trend", - "recommendation": "Set a limit", - "simulation": "Will reach $400 by August", - "confidence": 0.85, - "sources": ["transactions:FOOD_AND_DRINK"], - }], - "updated_profile": { - "income_pattern": {}, - "spending_baselines": {}, - "behavioral_patterns": [], - "known_risks": [], - "analyst_notes": "First session", - "resolved_patterns": [], - }, - }) + response = json.dumps( + { + "decisions": [ + { + "signal_type": "behavioral", + "severity": "warning", + "title": "Dining up 40%", + "reasoning": "Three week trend", + "recommendation": "Set a limit", + "simulation": "Will reach $400 by August", + "confidence": 0.85, + "sources": ["transactions:FOOD_AND_DRINK"], + } + ], + "updated_profile": { + "income_pattern": {}, + "spending_baselines": {}, + "behavioral_patterns": [], + "known_risks": [], + "analyst_notes": "First session", + "resolved_patterns": [], + }, + } + ) result = _parse_synthesis_response(response) assert len(result["decisions"]) == 1 assert result["decisions"][0]["title"] == "Dining up 40%" From 861d1f14cb67ff275a25cd7747649b45976e1346 Mon Sep 17 00:00:00 2001 From: Evin Bento Date: Tue, 23 Jun 2026 17:25:30 -0400 Subject: [PATCH 13/13] fix(tests): align dependency override user-id + fix test isolation --- backend/tests/test_calendar_endpoint.py | 26 ++++++++------------ backend/tests/test_pay_timing_endpoint.py | 2 +- backend/tests/test_safe_to_spend_endpoint.py | 6 +++-- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/backend/tests/test_calendar_endpoint.py b/backend/tests/test_calendar_endpoint.py index 467401d..0928894 100644 --- a/backend/tests/test_calendar_endpoint.py +++ b/backend/tests/test_calendar_endpoint.py @@ -6,7 +6,7 @@ from main import app from middleware.auth import get_current_user -app.dependency_overrides[get_current_user] = lambda: "user-1" +app.dependency_overrides[get_current_user] = lambda: "test-user-id" client = TestClient(app) @@ -50,12 +50,10 @@ def test_calendar_returns_sorted_entries(): "next_due_date": (today + timedelta(days=2)).isoformat(), } ] - mock_supabase = make_supabase(bills, subs) - with ( - patch("routers.calendar.get_supabase", return_value=mock_supabase), - patch("services.merchant_logos.requests.head") as h, - ): + with patch("routers.calendar.get_supabase", return_value=mock_supabase), patch( + "services.merchant_logos.requests.head" + ) as h: h.return_value.status_code = 404 resp = client.get("/calendar") @@ -76,12 +74,10 @@ def test_calendar_urgency_high_within_3_days(): "next_due_date": (today + timedelta(days=1)).isoformat(), } ] - mock_supabase = make_supabase(bills, []) - with ( - patch("routers.calendar.get_supabase", return_value=mock_supabase), - patch("services.merchant_logos.requests.head") as h, - ): + with patch("routers.calendar.get_supabase", return_value=mock_supabase), patch( + "services.merchant_logos.requests.head" + ) as h: h.return_value.status_code = 404 resp = client.get("/calendar") @@ -99,12 +95,10 @@ def test_calendar_urgency_medium_within_7_days(): "next_due_date": (today + timedelta(days=5)).isoformat(), } ] - mock_supabase = make_supabase(bills, []) - with ( - patch("routers.calendar.get_supabase", return_value=mock_supabase), - patch("services.merchant_logos.requests.head") as h, - ): + with patch("routers.calendar.get_supabase", return_value=mock_supabase), patch( + "services.merchant_logos.requests.head" + ) as h: h.return_value.status_code = 404 resp = client.get("/calendar") diff --git a/backend/tests/test_pay_timing_endpoint.py b/backend/tests/test_pay_timing_endpoint.py index ed4b80f..1f64687 100644 --- a/backend/tests/test_pay_timing_endpoint.py +++ b/backend/tests/test_pay_timing_endpoint.py @@ -5,7 +5,7 @@ from main import app from middleware.auth import get_current_user -app.dependency_overrides[get_current_user] = lambda: "user-1" +app.dependency_overrides[get_current_user] = lambda: "test-user-id" client = TestClient(app) diff --git a/backend/tests/test_safe_to_spend_endpoint.py b/backend/tests/test_safe_to_spend_endpoint.py index c3f782d..007a644 100644 --- a/backend/tests/test_safe_to_spend_endpoint.py +++ b/backend/tests/test_safe_to_spend_endpoint.py @@ -5,7 +5,7 @@ from main import app from middleware.auth import get_current_user -app.dependency_overrides[get_current_user] = lambda: "user-1" +app.dependency_overrides[get_current_user] = lambda: "test-user-id" client = TestClient(app) @@ -46,7 +46,9 @@ def table_side(name): if name == "safe_to_spend_cache": m.select.return_value.eq.return_value.execute.return_value.data = [] elif name == "accounts": - m.select.return_value.eq.return_value.execute.return_value.data = [{"balance": 800.0}] + m.select.return_value.eq.return_value.execute.return_value.data = [ + {"balance": 800.0} + ] elif name == "bills": m.select.return_value.eq.return_value.execute.return_value.data = [] elif name == "onboarding_responses":