Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
788 lines
26 KiB
Python
788 lines
26 KiB
Python
"""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
|