"""HTTP contract of the finance module (datamodel-finance.md §9). Covers the endpoints an agent/UI can call: seeding, CRUD, import (preview + run + rollback), rules, budgets, transfers and the chart-ready stats. """ from io import BytesIO from pathlib import Path from typing import Any from fastapi.testclient import TestClient FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "finance" API = "/api/finance" def upload(name: str) -> tuple[str, BytesIO, str]: return (name, BytesIO((FIXTURES / name).read_bytes()), "text/csv") def create_account( client: TestClient, headers: dict[str, str], name: str = "BoursoBank" ) -> dict[str, Any]: response = client.post( f"{API}/accounts", json={"name": name, "kind": "checking", "initial_balance": 100}, headers=headers, ) assert response.status_code == 201, response.text return response.json() def profile_id(client: TestClient, headers: dict[str, str], name: str) -> str: profiles = client.get(f"{API}/source-profiles", headers=headers).json() return next(p["id"] for p in profiles if p["name"] == name) def import_boursobank( client: TestClient, headers: dict[str, str], account_id: str ) -> dict[str, Any]: response = client.post( f"{API}/imports", files={"file": upload("boursobank.csv")}, data={ "account_id": account_id, "source_profile_id": profile_id( client, headers, "BoursoBank / Boursorama (CSV)" ), }, headers=headers, ) assert response.status_code == 201, response.text return response.json() # --------------------------------------------------------------------------- # Auth + seeding # --------------------------------------------------------------------------- def test_endpoints_require_authentication(client: TestClient) -> None: response = client.get(f"{API}/accounts") assert response.status_code == 401 assert response.json()["error"]["code"] == "unauthorized" def test_categories_are_seeded_on_first_access( client: TestClient, auth_headers: dict[str, str] ) -> None: response = client.get(f"{API}/categories", headers=auth_headers) assert response.status_code == 200 tree = response.json() names = {node["name"] for node in tree} assert {"Alimentation", "Logement", "Transports", "Salaire"} <= names alimentation = next(n for n in tree if n["name"] == "Alimentation") assert {c["name"] for c in alimentation["children"]} >= {"Courses", "Livraison"} transfer = next(n for n in tree if n["kind"] == "transfer") assert transfer["name"] == "Virements internes" assert transfer["is_system"] is True def test_builtin_source_profiles_are_seeded_and_read_only( client: TestClient, auth_headers: dict[str, str] ) -> None: profiles = client.get(f"{API}/source-profiles", headers=auth_headers).json() builtin = [p for p in profiles if p["is_builtin"]] assert len(builtin) >= 12 assert {"csv", "ofx", "paypal_csv"} == {p["kind"] for p in builtin} target = next(p for p in builtin if p["name"] == "Fortuneo (CSV)") forbidden = client.patch( f"{API}/source-profiles/{target['id']}", json={"name": "Bidouille"}, headers=auth_headers, ) assert forbidden.status_code == 403 clone = client.post( f"{API}/source-profiles/{target['id']}/clone", headers=auth_headers ) assert clone.status_code == 201 assert clone.json()["is_builtin"] is False assert clone.json()["name"] == "Fortuneo (CSV) (copie)" patched = client.patch( f"{API}/source-profiles/{clone.json()['id']}", json={"name": "Fortuneo perso"}, headers=auth_headers, ) assert patched.status_code == 200 def test_deleting_a_category_cleans_transactions_budgets_and_rules( client: TestClient, auth_headers: dict[str, str] ) -> None: account = create_account(client, auth_headers) import_boursobank(client, auth_headers, account["id"]) tree = client.get(f"{API}/categories", headers=auth_headers).json() alimentation = next(n for n in tree if n["name"] == "Alimentation") courses = next(c for c in alimentation["children"] if c["name"] == "Courses") rule = client.post( f"{API}/rules", json={ "name": "Courses Carrefour", "matchers": {"label_contains": ["CARREFOUR"]}, "actions": {"set_category_id": courses["id"]}, }, headers=auth_headers, ).json() client.post( f"{API}/rules/apply", json={"scope": "uncategorized"}, headers=auth_headers ) client.post( f"{API}/budgets", json={ "category_id": alimentation["id"], "monthly_amount": 400, "start_month": "2026-01-01", }, headers=auth_headers, ) system = next(n for n in tree if n["is_system"]) refused = client.delete(f"{API}/categories/{system['id']}", headers=auth_headers) assert refused.status_code == 422 deleted = client.delete( f"{API}/categories/{alimentation['id']}", headers=auth_headers ) assert deleted.status_code == 204 tree_after = client.get(f"{API}/categories", headers=auth_headers).json() names = {node["name"] for node in tree_after} assert "Alimentation" not in names tx = client.get( f"{API}/transactions", params={"q": "carrefour"}, headers=auth_headers ).json()["items"][0] assert tx["category_id"] is None assert tx["category_source"] is None assert client.get(f"{API}/budgets", headers=auth_headers).json() == [] rules = client.get(f"{API}/rules", headers=auth_headers).json() orphan = next(r for r in rules if r["id"] == rule["id"]) assert orphan["actions"].get("set_category_id") is None assert orphan["enabled"] is False def test_category_depth_is_limited_to_two_levels( client: TestClient, auth_headers: dict[str, str] ) -> None: tree = client.get(f"{API}/categories", headers=auth_headers).json() child = next(n for n in tree if n["name"] == "Alimentation")["children"][0] response = client.post( f"{API}/categories", json={"name": "Trop profond", "parent_id": child["id"]}, headers=auth_headers, ) assert response.status_code == 422 assert "deux niveaux" in response.json()["error"]["message"] # --------------------------------------------------------------------------- # Accounts # --------------------------------------------------------------------------- def test_accounts_crud(client: TestClient, auth_headers: dict[str, str]) -> None: account = create_account(client, auth_headers) assert account["balance"] == 100.0 assert account["transaction_count"] == 0 duplicate = client.post( f"{API}/accounts", json={"name": "BoursoBank"}, headers=auth_headers ) assert duplicate.status_code == 409 patched = client.patch( f"{API}/accounts/{account['id']}", json={"institution": "BoursoBank", "is_archived": True}, headers=auth_headers, ) assert patched.status_code == 200 assert patched.json()["is_archived"] is True assert client.get(f"{API}/accounts", headers=auth_headers).json() == [] listed = client.get( f"{API}/accounts", params={"include_archived": True}, headers=auth_headers ).json() assert len(listed) == 1 assert ( client.delete( f"{API}/accounts/{account['id']}", headers=auth_headers ).status_code == 204 ) def test_account_with_transactions_cannot_be_deleted( client: TestClient, auth_headers: dict[str, str] ) -> None: account = create_account(client, auth_headers) import_boursobank(client, auth_headers, account["id"]) response = client.delete(f"{API}/accounts/{account['id']}", headers=auth_headers) assert response.status_code == 409 assert "Archivez" in response.json()["error"]["message"] # --------------------------------------------------------------------------- # Imports # --------------------------------------------------------------------------- def test_import_preview_writes_nothing( client: TestClient, auth_headers: dict[str, str] ) -> None: account = create_account(client, auth_headers) response = client.post( f"{API}/imports/preview", files={"file": upload("boursobank.csv")}, data={ "account_id": account["id"], "source_profile_id": profile_id( client, auth_headers, "BoursoBank / Boursorama (CSV)" ), }, headers=auth_headers, ) assert response.status_code == 200 body = response.json() assert body["rows_total"] == 8 assert body["rows_error"] == 1 assert body["would_skip_duplicates"] == 0 assert body["date_min"] == "2026-06-01" assert len(body["rows_preview"]) == 7 assert body["rows_preview"][0]["amount"] == -42.9 listed = client.get(f"{API}/transactions", headers=auth_headers).json() assert listed["total"] == 0 def test_import_then_rollback(client: TestClient, auth_headers: dict[str, str]) -> None: account = create_account(client, auth_headers) run = import_boursobank(client, auth_headers, account["id"]) assert run["status"] == "completed" assert run["stats"]["rows_imported"] == 7 runs = client.get(f"{API}/imports", headers=auth_headers).json() assert runs["total"] == 1 detail = client.get(f"{API}/imports/{run['id']}", headers=auth_headers) assert detail.status_code == 200 # A duplicate re-upload is reported, not inserted twice. again = import_boursobank(client, auth_headers, account["id"]) assert again["stats"]["rows_imported"] == 0 assert again["stats"]["duplicate_file_of"] == run["import_run_id"] deleted = client.delete(f"{API}/imports/{run['id']}", headers=auth_headers) assert deleted.status_code == 204 remaining = client.get(f"{API}/transactions", headers=auth_headers).json() assert remaining["total"] == 0 def test_rollback_refuses_manually_edited_rows_without_force( client: TestClient, auth_headers: dict[str, str] ) -> None: account = create_account(client, auth_headers) run = import_boursobank(client, auth_headers, account["id"]) tx = client.get(f"{API}/transactions", headers=auth_headers).json()["items"][0] categories = client.get(f"{API}/categories", headers=auth_headers).json() courses = next( c for node in categories for c in node["children"] if c["name"] == "Courses" ) client.patch( f"{API}/transactions/{tx['id']}", json={"category_id": courses["id"]}, headers=auth_headers, ) blocked = client.delete(f"{API}/imports/{run['id']}", headers=auth_headers) assert blocked.status_code == 409 forced = client.delete( f"{API}/imports/{run['id']}", params={"force": True}, headers=auth_headers ) assert forced.status_code == 204 def test_generic_imports_endpoint_serves_the_finance_importers( client: TestClient, auth_headers: dict[str, str] ) -> None: """The registered importers (C3) also work through POST /api/imports.""" sources = client.get("/api/imports/sources", headers=auth_headers).json() finance_ids = {s["id"] for s in sources if s["domain"] == "finance"} assert {"bank_generic_csv", "bank_ofx", "paypal_csv"} <= finance_ids first = client.post( "/api/imports", files={"file": upload("sample.ofx")}, data={"source": "bank_ofx"}, headers=auth_headers, ) assert first.status_code == 201, first.text assert first.json()["rows_inserted"] == 4 assert first.json()["status"] == "completed" # A default account was created for that format… accounts = client.get(f"{API}/accounts", headers=auth_headers).json() assert [a["name"] for a in accounts] == ["Compte importé (OFX)"] assert accounts[0]["transaction_count"] == 4 # …and re-importing the same file stays idempotent. again = client.post( "/api/imports", files={"file": upload("sample.ofx")}, data={"source": "bank_ofx"}, headers=auth_headers, ) assert again.json()["rows_inserted"] == 0 assert again.json()["rows_duplicates"] == 4 # Central rollback removes the finance rows (FK ON DELETE CASCADE). assert ( client.delete( f"/api/imports/{first.json()['id']}", headers=auth_headers ).status_code == 204 ) assert client.get(f"{API}/transactions", headers=auth_headers).json()["total"] == 0 # --------------------------------------------------------------------------- # Transactions # --------------------------------------------------------------------------- def test_transactions_filters_and_manual_flag( client: TestClient, auth_headers: dict[str, str] ) -> None: account = create_account(client, auth_headers) import_boursobank(client, auth_headers, account["id"]) page = client.get( f"{API}/transactions", params={"page_size": 3, "sort": "booked_date"}, headers=auth_headers, ).json() assert page["total"] == 7 assert len(page["items"]) == 3 assert page["items"][0]["booked_date"] == "2026-06-01" assert page["items"][0]["account_name"] == "BoursoBank" credits = client.get( f"{API}/transactions", params={"direction": "credit"}, headers=auth_headers ).json() assert credits["total"] == 1 assert credits["items"][0]["amount"] == 2450.0 searched = client.get( f"{API}/transactions", params={"q": "netflix"}, headers=auth_headers ).json() assert searched["total"] == 1 window = client.get( f"{API}/transactions", params={"date_from": "2026-06-05", "date_to": "2026-06-10"}, headers=auth_headers, ).json() assert window["total"] == 3 uncategorized = client.get( f"{API}/transactions", params={"category_id": "none"}, headers=auth_headers ).json() assert uncategorized["total"] == 7 bad_sort = client.get( f"{API}/transactions", params={"sort": "hacker"}, headers=auth_headers ) assert bad_sort.status_code == 422 def test_patch_and_bulk_categorize_set_user_source( client: TestClient, auth_headers: dict[str, str] ) -> None: account = create_account(client, auth_headers) import_boursobank(client, auth_headers, account["id"]) items = client.get(f"{API}/transactions", headers=auth_headers).json()["items"] categories = client.get(f"{API}/categories", headers=auth_headers).json() courses = next( c for node in categories for c in node["children"] if c["name"] == "Courses" ) patched = client.patch( f"{API}/transactions/{items[0]['id']}", json={"category_id": courses["id"], "notes": "Vérifié"}, headers=auth_headers, ).json() assert patched["category_source"] == "user" assert patched["category_name"] == "Courses" assert patched["notes"] == "Vérifié" # Date/amount of an imported row stay read-only. refused = client.patch( f"{API}/transactions/{items[0]['id']}", json={"amount": -1}, headers=auth_headers, ) assert refused.status_code == 422 bulk = client.post( f"{API}/transactions/bulk-categorize", json={ "transaction_ids": [item["id"] for item in items[:3]], "category_id": courses["id"], }, headers=auth_headers, ).json() assert bulk["updated"] == 3 # Deleting an imported row is refused (roll the import back instead). assert ( client.delete( f"{API}/transactions/{items[0]['id']}", headers=auth_headers ).status_code == 422 ) def test_manual_transaction_lifecycle( client: TestClient, auth_headers: dict[str, str] ) -> None: account = create_account(client, auth_headers) created = client.post( f"{API}/transactions", json={ "account_id": account["id"], "booked_date": "2026-08-02", "amount": -12.5, "label_clean": "Café du coin", }, headers=auth_headers, ) assert created.status_code == 201 body = created.json() assert body["amount"] == -12.5 assert body["import_run_id"] is None twin = client.post( f"{API}/transactions", json={ "account_id": account["id"], "booked_date": "2026-08-02", "amount": -12.5, "label_clean": "Café du coin", }, headers=auth_headers, ) assert twin.status_code == 201 # occurrence 1, not a conflict assert ( client.delete( f"{API}/transactions/{body['id']}", headers=auth_headers ).status_code == 204 ) # --------------------------------------------------------------------------- # Rules # --------------------------------------------------------------------------- def test_rules_crud_preview_and_apply( client: TestClient, auth_headers: dict[str, str] ) -> None: account = create_account(client, auth_headers) import_boursobank(client, auth_headers, account["id"]) categories = client.get(f"{API}/categories", headers=auth_headers).json() courses = next( c for node in categories for c in node["children"] if c["name"] == "Courses" ) invalid = client.post( f"{API}/rules", json={ "name": "Regex cassée", "matchers": {"label_regex": "["}, "actions": {"set_category_id": courses["id"]}, }, headers=auth_headers, ) assert invalid.status_code == 422 empty = client.post( f"{API}/rules", json={"name": "Vide", "matchers": {}, "actions": {}}, headers=auth_headers, ) assert empty.status_code == 422 preview = client.post( f"{API}/rules/preview", json={"matchers": {"label_contains": ["CARREFOUR"]}}, headers=auth_headers, ).json() assert preview["total_matched"] == 1 created = client.post( f"{API}/rules", json={ "name": "Courses Carrefour", "priority": 10, "matchers": {"label_contains": ["CARREFOUR"], "direction": "debit"}, "actions": { "set_category_id": courses["id"], "set_counterparty": "Carrefour", }, }, headers=auth_headers, ) assert created.status_code == 201 rule = created.json() second = client.post( f"{API}/rules", json={ "name": "Loyer", "matchers": {"label_contains": ["LOYER"]}, "actions": {"set_label_clean": "Loyer"}, }, headers=auth_headers, ).json() reordered = client.post( f"{API}/rules/reorder", json={"ordered_ids": [second["id"], rule["id"]]}, headers=auth_headers, ).json() assert [r["priority"] for r in reordered] == [0, 10] dry = client.post( f"{API}/rules/apply", json={"scope": "uncategorized", "dry_run": True}, headers=auth_headers, ).json() assert dry["matched"] == 2 assert dry["dry_run"] is True applied = client.post( f"{API}/rules/apply", json={"scope": "uncategorized"}, headers=auth_headers ).json() assert applied["updated"] == 2 assert {entry["name"] for entry in applied["by_rule"]} == { "Courses Carrefour", "Loyer", } tx = client.get( f"{API}/transactions", params={"q": "carrefour"}, headers=auth_headers ).json()["items"][0] assert tx["category_name"] == "Courses" assert tx["category_source"] == "rule" assert tx["counterparty"] == "Carrefour" forced = client.post( f"{API}/rules/apply", json={"scope": "all"}, headers=auth_headers ) assert forced.status_code == 422 # force=true required assert ( client.delete(f"{API}/rules/{rule['id']}", headers=auth_headers).status_code == 204 ) # --------------------------------------------------------------------------- # Budgets # --------------------------------------------------------------------------- def test_budgets_overlap_and_effective_from( client: TestClient, auth_headers: dict[str, str] ) -> None: categories = client.get(f"{API}/categories", headers=auth_headers).json() alimentation = next(c for c in categories if c["name"] == "Alimentation") salaire = next(c for c in categories if c["name"] == "Salaire") created = client.post( f"{API}/budgets", json={ "category_id": alimentation["id"], "monthly_amount": 450, "start_month": "2026-01-01", }, headers=auth_headers, ) assert created.status_code == 201 overlap = client.post( f"{API}/budgets", json={ "category_id": alimentation["id"], "monthly_amount": 500, "start_month": "2026-06-01", }, headers=auth_headers, ) assert overlap.status_code == 409 income_budget = client.post( f"{API}/budgets", json={ "category_id": salaire["id"], "monthly_amount": 100, "start_month": "2026-01-01", }, headers=auth_headers, ) assert income_budget.status_code == 422 split = client.patch( f"{API}/budgets/{created.json()['id']}", json={"monthly_amount": 520, "effective_from": "2026-08-01"}, headers=auth_headers, ).json() assert len(split) == 2 assert split[0]["end_month"] == "2026-07-01" assert split[1]["start_month"] == "2026-08-01" assert split[1]["monthly_amount"] == 520.0 listed = client.get( f"{API}/budgets", params={"month": "2026-08"}, headers=auth_headers ).json() assert len(listed) == 1 assert listed[0]["monthly_amount"] == 520.0 assert listed[0]["category_name"] == "Alimentation" assert ( client.delete( f"{API}/budgets/{split[1]['id']}", headers=auth_headers ).status_code == 204 ) # --------------------------------------------------------------------------- # Transfers + stats # --------------------------------------------------------------------------- def test_transfer_endpoints(client: TestClient, auth_headers: dict[str, str]) -> None: bank = create_account(client, auth_headers, "BoursoBank") paypal = create_account(client, auth_headers, "PayPal") import_boursobank(client, auth_headers, bank["id"]) response = client.post( f"{API}/imports", files={"file": upload("paypal_fr.csv")}, data={ "account_id": paypal["id"], "source_profile_id": profile_id( client, auth_headers, "PayPal — rapport d'activité (CSV)" ), }, headers=auth_headers, ) assert response.status_code == 201 assert response.json()["stats"]["transfers_detected"] == 1 transfers = client.get( f"{API}/transactions", params={"is_transfer": True}, headers=auth_headers ).json() assert transfers["total"] == 2 group_id = transfers["items"][0]["transfer_group_id"] assert transfers["items"][0]["category_name"] == "Virements internes" unlinked = client.delete(f"{API}/transfers/{group_id}", headers=auth_headers) assert unlinked.status_code == 204 assert ( client.get( f"{API}/transactions", params={"is_transfer": True}, headers=auth_headers ).json()["total"] == 0 ) detected = client.post( f"{API}/transfers/detect", json={}, headers=auth_headers ).json() assert detected["pairs_created"] == 1 legs = client.get( f"{API}/transactions", params={"is_transfer": True}, headers=auth_headers ).json()["items"] client.delete( f"{API}/transfers/{legs[0]['transfer_group_id']}", headers=auth_headers ) linked = client.post( f"{API}/transfers/link", json={ "transaction_id_a": legs[0]["id"], "transaction_id_b": legs[1]["id"], }, headers=auth_headers, ) assert linked.status_code == 200 assert linked.json()["transfer_group_id"] def test_stats_endpoints_are_chart_ready( client: TestClient, auth_headers: dict[str, str] ) -> None: account = create_account(client, auth_headers) import_boursobank(client, auth_headers, account["id"]) monthly = client.get( f"{API}/stats/monthly-by-category", params={"months": 6}, headers=auth_headers, ).json() assert len(monthly["months"]) == 6 assert all(len(series["data"]) == 6 for series in monthly["series"]) assert monthly["series"][0]["color"].startswith("#") cashflow = client.get( f"{API}/stats/cashflow", params={"months": 4}, headers=auth_headers ).json() assert set(cashflow) == { "months", "income", "expenses", "net", "cumulative_net", } assert len(cashflow["cumulative_net"]) == 4 merchants = client.get( f"{API}/stats/top-merchants", params={"months": 24}, headers=auth_headers ).json() assert merchants["period"]["from"] <= merchants["period"]["to"] assert merchants["items"] recurring = client.get(f"{API}/stats/recurring", headers=auth_headers).json() assert recurring["items"] == [] assert recurring["monthly_total_estimate"] == 0.0 budgets = client.get( f"{API}/stats/budget-progress", params={"month": "2026-06"}, headers=auth_headers, ).json() assert budgets["month"] == "2026-06" sankey = client.get( f"{API}/stats/sankey", params={"month": "2026-06"}, headers=auth_headers ).json() names = [node["name"] for node in sankey["nodes"]] assert "Revenus" in names assert len(names) == len(set(names)) assert all(link["value"] > 0 for link in sankey["links"]) dashboard = client.get(f"{API}/dashboard", headers=auth_headers).json() assert dashboard["accounts_count"] == 1 assert dashboard["uncategorized_count"] == 7 # 100 € initial balance + the net of the 7 imported rows. assert dashboard["total_balance"] == 1688.61 assert dashboard["month_expenses"] == 0.0 # the file covers June 2026 bad_month = client.get( f"{API}/stats/budget-progress", params={"month": "2026-13"}, headers=auth_headers, ) assert bad_month.status_code == 422