Files
lifetrack/apps/api/app/tests/modules/test_finance_parsers.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
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>
2026-08-14 10:48:57 +02:00

253 lines
8.9 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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))