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>
253 lines
8.9 KiB
Python
253 lines
8.9 KiB
Python
"""Parser/normalisation tests against synthetic files of the real bank layouts.
|
||
|
||
Fixtures live in app/tests/fixtures/finance and reproduce the header lines,
|
||
encodings and quirks documented in docs/research/finance-sources.md §2-4.
|
||
"""
|
||
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from app.modules.finance.normalize import (
|
||
compute_dedup_hash,
|
||
light_clean,
|
||
merchant_key,
|
||
normalize_label_for_hash,
|
||
parse_amount,
|
||
)
|
||
from app.modules.finance.parsers import decode_bytes
|
||
from app.modules.finance.pipeline import parse_file
|
||
from app.modules.finance.presets import BUILTIN_PROFILES
|
||
|
||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "finance"
|
||
ACCOUNT_UUID = __import__("uuid").UUID("00000000-0000-0000-0000-0000000000aa")
|
||
|
||
|
||
def preset(slug: str) -> tuple[str, dict]:
|
||
for item in BUILTIN_PROFILES:
|
||
if item["slug"] == slug:
|
||
return item["kind"], item["config"]
|
||
raise AssertionError(f"preset {slug} missing")
|
||
|
||
|
||
def parse_fixture(slug: str, filename: str):
|
||
kind, config = preset(slug)
|
||
return parse_file(kind, config, (FIXTURES / filename).read_bytes(), "EUR")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Amounts / labels
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("raw", "expected"),
|
||
[
|
||
("-6,4", "-6.4"),
|
||
("+3500,00", "3500.00"),
|
||
("-123 456,78", "-123456.78"),
|
||
("1 234,56", "1234.56"),
|
||
("1.234,56", "1234.56"),
|
||
("226.68", "226.68"),
|
||
("12,50 €", "12.50"),
|
||
("−12,50", "-12.50"),
|
||
("1 234,56", "1234.56"),
|
||
],
|
||
)
|
||
def test_parse_amount_french_variants(raw: str, expected: str) -> None:
|
||
assert parse_amount(raw) == Decimal(expected)
|
||
|
||
|
||
def test_parse_amount_dot_decimal_profile() -> None:
|
||
assert parse_amount("1,234.56", decimal_separator=".") == Decimal("1234.56")
|
||
|
||
|
||
def test_parse_amount_rejects_garbage() -> None:
|
||
with pytest.raises(ValueError, match="montant"):
|
||
parse_amount("abc")
|
||
|
||
|
||
def test_normalize_label_is_conservative() -> None:
|
||
assert normalize_label_for_hash(" Café Crème\n2 ") == "CAFE CREME 2"
|
||
|
||
|
||
def test_light_clean_strips_technical_prefixes() -> None:
|
||
assert light_clean("CARTE 04/06 BOULANGERIE") == "BOULANGERIE"
|
||
assert light_clean("PRLV SEPA NETFLIX.COM") == "NETFLIX.COM"
|
||
assert light_clean("VIR INST SALAIRE MAI") == "SALAIRE MAI"
|
||
|
||
|
||
def test_merchant_key_drops_dates_and_numbers() -> None:
|
||
key = merchant_key("CARTE 04/06 CARREFOUR CITY 123456", None)
|
||
assert key == "CARREFOUR CITY"
|
||
assert merchant_key("n'importe quoi", "Netflix") == "NETFLIX"
|
||
|
||
|
||
def test_dedup_hash_depends_on_occurrence() -> None:
|
||
args = (ACCOUNT_UUID, date(2026, 6, 5), Decimal("-2.50"), "CAFE")
|
||
assert compute_dedup_hash(*args, 0) != compute_dedup_hash(*args, 1)
|
||
assert compute_dedup_hash(*args, 0) == compute_dedup_hash(*args, 0)
|
||
|
||
|
||
def test_dedup_hash_ignores_accents_and_case() -> None:
|
||
args = (ACCOUNT_UUID, date(2026, 6, 5), Decimal("-2.50"))
|
||
assert compute_dedup_hash(*args, "Café Crème", 0) == compute_dedup_hash(
|
||
*args, "CAFE CREME", 0
|
||
)
|
||
|
||
|
||
def test_decode_bytes_bom_and_cp1252() -> None:
|
||
sample = (
|
||
"Date;Libellé;Débit euros;Crédit euros;\n"
|
||
"02/06/2026;PRLV SEPA EDF ÉNERGIE;89,00;;\n"
|
||
"03/06/2026;VIREMENT DE Mme DÉSIRÉE;;1 500,00;\n"
|
||
)
|
||
# BOM wins over the configured encoding.
|
||
assert decode_bytes(b"\xef\xbb\xbfdate;label", "cp1252") == "date;label"
|
||
# "auto" never fails on a legacy French export…
|
||
assert decode_bytes(sample.encode("cp1252"), "auto") == sample
|
||
# …nor on UTF-8, and honours an explicit encoding.
|
||
assert decode_bytes(sample.encode(), "auto") == sample
|
||
assert decode_bytes(sample.encode("cp1252"), "cp1252") == sample
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Bank presets
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_boursobank_csv() -> None:
|
||
result = parse_fixture("boursobank", "boursobank.csv")
|
||
assert len(result.rows) == 7
|
||
assert result.rows_total == 8
|
||
assert result.rows_error == 1
|
||
assert result.errors[0]["message"].startswith("Date invalide")
|
||
first = result.rows[0]
|
||
assert first.booked_date == date(2026, 6, 1)
|
||
assert first.value_date == date(2026, 6, 1)
|
||
assert first.amount == Decimal("-42.90")
|
||
assert first.label_raw == "CARTE 31/05 CARREFOUR CITY PARIS"
|
||
# Two identical purchases the same day survive as two rows.
|
||
twins = [r for r in result.rows if r.amount == Decimal("-2.50")]
|
||
assert len(twins) == 2
|
||
|
||
|
||
def test_credit_agricole_split_columns_multiline_and_footer() -> None:
|
||
result = parse_fixture("credit-agricole", "credit_agricole.csv")
|
||
assert len(result.rows) == 4 # footer row stopped the parsing
|
||
assert result.rows[0].amount == Decimal("-58.20")
|
||
# multi-line quoted label collapsed into a single line
|
||
assert result.rows[0].label_raw == "PAIEMENT CB 0106 INTERMARCHE FACT 010626"
|
||
assert result.rows[1].amount == Decimal("1500.00") # thousands space
|
||
assert result.rows[1].value_date == date(2026, 6, 4)
|
||
|
||
|
||
def test_societe_generale_preamble_and_padding() -> None:
|
||
result = parse_fixture("societe-generale", "societe_generale.csv")
|
||
assert len(result.rows) == 4
|
||
assert result.rows[0].label_raw == "CARTE X7527 15/06 METRO"
|
||
assert result.rows[0].currency == "EUR"
|
||
assert result.rows[3].amount == Decimal("-1234.56")
|
||
|
||
|
||
def test_banque_postale_skip_until_header() -> None:
|
||
result = parse_fixture("banque-postale", "banque_postale.csv")
|
||
assert [r.amount for r in result.rows] == [
|
||
Decimal("-32.15"),
|
||
Decimal("185.00"),
|
||
]
|
||
|
||
|
||
def test_paypal_filters_and_net_amount() -> None:
|
||
result = parse_fixture("paypal", "paypal_fr.csv")
|
||
assert len(result.rows) == 3
|
||
# Authorization + pending + USD + currency conversion are filtered out.
|
||
assert result.rows_filtered == 4
|
||
steam = result.rows[0]
|
||
assert steam.amount == Decimal("-12.99")
|
||
assert steam.external_id == "5AB12345CD678901E"
|
||
assert steam.counterparty == "Steam Games"
|
||
vinted = result.rows[1]
|
||
assert vinted.amount == Decimal("17.50") # Net (gross 18,00 - fee 0,50)
|
||
|
||
|
||
def test_ofx_sgml_fitid_collision_gets_occurrence_suffix() -> None:
|
||
result = parse_fixture("ofx", "sample.ofx")
|
||
assert len(result.rows) == 4
|
||
ids = [row.external_id for row in result.rows]
|
||
assert ids[0] == "948 040626 -1275"
|
||
assert ids[1] == "948 040626 -1275#1"
|
||
assert len(set(ids)) == 4
|
||
assert result.rows[2].label_raw == "PRLV SEPA ORANGE — FACTURE JUIN"
|
||
assert result.rows[3].amount == Decimal("1500.00")
|
||
|
||
|
||
def test_bnp_positional_mapping_without_column_header() -> None:
|
||
result = parse_fixture("bnp-paribas", "bnp_paribas.csv")
|
||
# the balance line is skipped, the 3 operations are mapped by position
|
||
assert len(result.rows) == 3
|
||
assert result.rows[0].label_raw == "AMORTISSEMENT PRET 1234"
|
||
assert result.rows[0].amount == Decimal("-70.93")
|
||
assert result.rows[2].amount == Decimal("2450.00") # thousands space
|
||
|
||
|
||
def test_caisse_epargne_signed_debit_and_plus_prefixed_credit() -> None:
|
||
result = parse_fixture("caisse-epargne", "caisse_epargne.csv")
|
||
assert [r.amount for r in result.rows] == [
|
||
Decimal("-45.50"),
|
||
Decimal("3500.00"),
|
||
]
|
||
assert result.rows[0].counterparty == "SUPERMARCHE" # "Libelle simplifie"
|
||
assert result.rows[1].value_date == date(2026, 11, 9)
|
||
|
||
|
||
def test_fortuneo_split_columns_and_short_decimals() -> None:
|
||
result = parse_fixture("fortuneo", "fortuneo.csv")
|
||
assert result.rows[0].amount == Decimal("-6.40")
|
||
assert result.rows[1].amount == Decimal("2500.00")
|
||
|
||
|
||
def test_revolut_net_of_fee_and_completed_only() -> None:
|
||
result = parse_fixture("revolut", "revolut.csv")
|
||
assert len(result.rows) == 2
|
||
assert result.rows_filtered == 1 # the PENDING row is ignored
|
||
assert result.rows[0].amount == Decimal("-15.50") # amount 15,00 + fee 0,50
|
||
assert result.rows[0].currency == "EUR"
|
||
|
||
|
||
def test_n26_label_join_and_counterparty() -> None:
|
||
result = parse_fixture("n26", "n26.csv")
|
||
assert result.rows[0].label_raw == "Netflix — Abonnement fevrier"
|
||
assert result.rows[0].counterparty == "Netflix"
|
||
assert result.rows[0].amount == Decimal("-13.49")
|
||
|
||
|
||
def test_generic_preset_autodetects_delimiter_and_columns() -> None:
|
||
result = parse_fixture("generic", "generic.csv")
|
||
assert [r.amount for r in result.rows] == [
|
||
Decimal("-25.40"),
|
||
Decimal("42.10"),
|
||
]
|
||
|
||
|
||
def test_every_builtin_preset_is_declared_once() -> None:
|
||
slugs = [item["slug"] for item in BUILTIN_PROFILES]
|
||
assert len(slugs) == len(set(slugs))
|
||
expected = {
|
||
"generic",
|
||
"boursobank",
|
||
"credit-agricole",
|
||
"bnp-paribas",
|
||
"societe-generale",
|
||
"banque-postale",
|
||
"caisse-epargne",
|
||
"fortuneo",
|
||
"revolut",
|
||
"n26",
|
||
"ofx",
|
||
"paypal",
|
||
}
|
||
assert expected.issubset(set(slugs))
|