Files
lifetrack/apps/api/app/modules/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

643 lines
23 KiB
Python

"""File parsers for the finance import pipeline (datamodel-finance.md §3-4).
Three parsers (CSV generic driven by a source-profile config, OFX, PayPal CSV)
all produce the same `ParseResult` of `NormalizedRow`s. Row-level problems are
collected (never fatal); a structurally unreadable file raises
`ImporterParseError` with a French message.
"""
import csv
import io
import re
import unicodedata
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal
from app.core.importing.base import ImporterParseError
from app.modules.finance.normalize import parse_amount, quantize2
MAX_ERRORS = 50
_GENERIC_DATE_FORMATS = (
"%d/%m/%Y",
"%Y-%m-%d",
"%d.%m.%Y",
"%Y-%m-%d %H:%M:%S",
"%d/%m/%y",
)
@dataclass
class NormalizedRow:
booked_date: date
value_date: date | None
amount: Decimal # signed, quantized to 2 decimals
currency: str # ISO 4217 upper
label_raw: str # trimmed, newlines collapsed to spaces
counterparty: str | None = None # PayPal / "counterparty" column only
external_id: str | None = None
source_row_index: int = 0 # 1-based row index for error messages
@dataclass
class ParseResult:
rows: list[NormalizedRow] = field(default_factory=list)
errors: list[dict] = field(default_factory=list) # [{"row": int, "message": str}]
rows_filtered: int = 0 # rows intentionally skipped by the parser
# Every data row seen, blank lines and footers excluded. §2.4 requires
# rows_total == imported + skipped_duplicate + skipped_filtered + error, so
# the intentionally filtered rows are part of it.
rows_total: int = 0
errors_truncated: bool = False
@property
def rows_error(self) -> int:
return self.rows_total - self.rows_filtered - len(self.rows)
def add_error(self, row_index: int, message: str) -> None:
if len(self.errors) < MAX_ERRORS:
self.errors.append({"row": row_index, "message": message})
else:
self.errors_truncated = True
# ---------------------------------------------------------------------------
# Decoding
# ---------------------------------------------------------------------------
def decode_bytes(raw: bytes, encoding_cfg: str = "auto") -> str:
"""§4.1 + research §7.3: BOM first, then explicit encoding, then auto
(utf-8 strict -> charset-normalizer -> cp1252, which never fails)."""
if raw.startswith(b"\xef\xbb\xbf"):
return raw.decode("utf-8-sig")
if encoding_cfg and encoding_cfg != "auto":
return raw.decode(encoding_cfg, errors="replace")
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
pass
try:
from charset_normalizer import from_bytes
best = from_bytes(raw).best()
if best is not None and best.encoding not in ("utf_8", "utf-8"):
return str(best)
except Exception: # noqa: BLE001, S110 — auto mode must never fail
pass
return raw.decode("cp1252")
def _norm_header(name: str) -> str:
"""Case/accent-insensitive header comparison key."""
s = unicodedata.normalize("NFKD", name)
s = "".join(c for c in s if not unicodedata.combining(c))
return re.sub(r"\s+", " ", s).strip().strip('"').casefold()
def _clean_cell(value: str) -> str:
return re.sub(r"\s+", " ", value).strip()
# ---------------------------------------------------------------------------
# Generic CSV parser (kind = 'csv')
# ---------------------------------------------------------------------------
def _sniff_delimiter(sample: str) -> str:
try:
return csv.Sniffer().sniff(sample, delimiters=";,\t|").delimiter
except csv.Error:
first = sample.splitlines()[0] if sample.splitlines() else ""
counts = {d: first.count(d) for d in (";", ",", "\t", "|")}
best = max(counts, key=lambda d: counts[d])
return best if counts[best] > 0 else ";"
def _resolve_column(spec: object, header_map: dict[str, int] | None) -> int | None:
"""Column spec: header name (str), 0-based index (int) or list of aliases."""
if spec is None or spec == "":
return None
if isinstance(spec, bool):
return None
if isinstance(spec, int):
return spec
if isinstance(spec, str):
if header_map is None:
return None
return header_map.get(_norm_header(spec))
if isinstance(spec, (list, tuple)):
for alias in spec:
idx = _resolve_column(alias, header_map)
if idx is not None:
return idx
return None
def _parse_date(value: str, config: dict) -> date:
v = value.strip().strip('"')
formats: list[str] = []
if config.get("date_format"):
formats.append(config["date_format"])
formats.extend(config.get("date_formats") or [])
formats.extend(f for f in _GENERIC_DATE_FORMATS if f not in formats)
for fmt in formats:
try:
# Civil date from a bank statement: never timezone-converted (§1).
return datetime.strptime(v, fmt).date() # noqa: DTZ007
except ValueError:
continue
raise ValueError(f"Date invalide : '{value.strip()}'")
def _cell(row: list[str], idx: int | None) -> str:
if idx is None or idx >= len(row):
return ""
return row[idx]
def parse_csv(config: dict, text: str, default_currency: str = "EUR") -> ParseResult:
result = ParseResult()
lines = text.splitlines(keepends=True)
# Preamble handling: fixed number of rows and/or "skip until header" marker.
start = int(config.get("skip_rows_top") or 0)
marker = config.get("skip_until_header_startswith")
if marker:
want = _norm_header(marker)
found = None
for i, line in enumerate(lines):
if _norm_header(line)[: len(want)] == want:
found = i
break
if found is None:
raise ImporterParseError(
"Ligne d'en-tête introuvable — vérifiez le profil de source."
)
start = found
if start >= len(lines) and lines:
raise ImporterParseError("Le fichier ne contient aucune ligne de données.")
body = "".join(lines[start:])
delimiter = config.get("delimiter") or "auto"
if delimiter == "auto":
delimiter = _sniff_delimiter("".join(lines[start : start + 6]))
quote_char = config.get("quote_char") or '"'
reader = csv.reader(io.StringIO(body), delimiter=delimiter, quotechar=quote_char)
try:
raw_rows = list(reader)
except csv.Error as exc:
raise ImporterParseError(
"Fichier CSV illisible — vérifiez le séparateur et les guillemets."
) from exc
skip_bottom = int(config.get("skip_rows_bottom") or 0)
if skip_bottom:
raw_rows = raw_rows[:-skip_bottom] if skip_bottom < len(raw_rows) else []
has_header = config.get("has_header", True)
header_map: dict[str, int] | None = None
data_start = 0
if has_header:
if not raw_rows:
raise ImporterParseError("Le fichier est vide.")
header_map = {
_norm_header(cell): i for i, cell in enumerate(raw_rows[0]) if cell.strip()
}
data_start = 1
columns = config.get("columns") or {}
col_booked = _resolve_column(columns.get("booked_date"), header_map)
col_value = _resolve_column(columns.get("value_date"), header_map)
col_label = _resolve_column(columns.get("label"), header_map)
col_amount = _resolve_column(columns.get("amount"), header_map)
col_debit = _resolve_column(columns.get("debit"), header_map)
col_credit = _resolve_column(columns.get("credit"), header_map)
col_currency = _resolve_column(columns.get("currency"), header_map)
col_external = _resolve_column(columns.get("external_id"), header_map)
col_counterparty = _resolve_column(columns.get("counterparty"), header_map)
col_fee = _resolve_column(columns.get("fee"), header_map)
amount_mode = config.get("amount_mode", "signed")
if amount_mode == "auto":
amount_mode = "signed" if col_amount is not None else "split"
if col_booked is None or col_label is None:
raise ImporterParseError(
"Colonnes obligatoires introuvables (date, libellé) — "
"vérifiez le profil de source."
)
if amount_mode == "signed" and col_amount is None:
raise ImporterParseError(
"Colonne de montant introuvable — vérifiez le profil de source."
)
if amount_mode == "split" and col_debit is None and col_credit is None:
raise ImporterParseError(
"Colonnes Débit/Crédit introuvables — vérifiez le profil de source."
)
decimal_sep = config.get("decimal_separator", ",")
invert = bool(config.get("invert_sign", False))
label_join = config.get("label_join") or []
join_idx = [
idx
for idx in (_resolve_column(c, header_map) for c in label_join)
if idx is not None
]
filters = config.get("skip_row_if") or []
filter_specs = []
for f in filters:
fidx = _resolve_column(f.get("column"), header_map)
if fidx is not None:
filter_specs.append((fidx, f))
stop_on_non_date = bool(config.get("stop_on_non_date_row", False))
for offset, row in enumerate(raw_rows[data_start:]):
row_index = start + data_start + offset + 1 # 1-based, file-relative
if not any(cell.strip() for cell in row):
continue
booked_raw = _cell(row, col_booked)
if stop_on_non_date and booked_raw.strip():
try:
_parse_date(booked_raw, config)
except ValueError:
break # footer reached (Crédit Agricole)
result.rows_total += 1
skip = False
for fidx, f in filter_specs:
cell_val = _clean_cell(_cell(row, fidx)).casefold()
if "equals" in f and cell_val == str(f["equals"]).casefold():
skip = True
if "not_equals" in f and cell_val != str(f["not_equals"]).casefold():
skip = True
if skip:
result.rows_filtered += 1
continue
try:
booked = _parse_date(booked_raw, config)
value_raw = _cell(row, col_value).strip()
value_d = _parse_date(value_raw, config) if value_raw else None
if amount_mode == "signed":
amount = parse_amount(_cell(row, col_amount), decimal_sep)
else:
debit_raw = _cell(row, col_debit).strip()
credit_raw = _cell(row, col_credit).strip()
if bool(debit_raw) == bool(credit_raw):
raise ValueError(
"exactement une des colonnes Débit/Crédit doit être remplie"
)
if credit_raw:
amount = abs(parse_amount(credit_raw, decimal_sep))
else:
amount = -abs(parse_amount(debit_raw, decimal_sep))
if col_fee is not None:
fee_raw = _cell(row, col_fee).strip()
if fee_raw:
amount -= parse_amount(fee_raw, decimal_sep)
if invert:
amount = -amount
label = _clean_cell(_cell(row, col_label))
for jidx in join_idx:
extra = _clean_cell(_cell(row, jidx))
if extra:
label = f"{label}{extra}" if label else extra
if not label:
raise ValueError("libellé vide")
currency = _clean_cell(_cell(row, col_currency)).upper() or (
default_currency
)
external = _clean_cell(_cell(row, col_external)) or None
counterparty = _clean_cell(_cell(row, col_counterparty)) or None
result.rows.append(
NormalizedRow(
booked_date=booked,
value_date=value_d,
amount=quantize2(amount),
currency=currency[:3],
label_raw=label,
counterparty=counterparty,
external_id=external,
source_row_index=row_index,
)
)
except ValueError as exc:
result.add_error(row_index, str(exc).capitalize())
return result
# ---------------------------------------------------------------------------
# OFX parser (kind = 'ofx')
# ---------------------------------------------------------------------------
_OFX_FIELD_RES = {
name: re.compile(rf"<{name}>([^<\r\n]*)", re.IGNORECASE)
for name in ("DTPOSTED", "TRNAMT", "FITID", "NAME", "MEMO")
}
_OFX_CURDEF_RE = re.compile(r"<CURDEF>([^<\r\n]*)", re.IGNORECASE)
_OFX_ACCTID_RE = re.compile(r"<ACCTID>([^<\r\n]*)", re.IGNORECASE)
def _ofx_decode(raw: bytes, fallback_encoding: str) -> str:
head = raw[:512].decode("latin-1", errors="replace")
m = re.search(r"CHARSET:\s*([A-Za-z0-9-]+)", head)
if m:
charset = m.group(1).strip().lower()
if charset in ("1252", "cp1252", "windows-1252"):
return raw.decode("cp1252", errors="replace")
if charset not in ("none",):
try:
return raw.decode(charset, errors="replace")
except LookupError:
pass
m = re.search(r'encoding="([^"]+)"', head, re.IGNORECASE)
if m:
try:
return raw.decode(m.group(1), errors="replace")
except LookupError:
pass
if re.search(r"ENCODING:\s*UTF-8", head, re.IGNORECASE):
return raw.decode("utf-8", errors="replace")
return raw.decode(fallback_encoding or "cp1252", errors="replace")
def _ofx_date(value: str) -> date:
digits = re.sub(r"[^0-9]", "", value)[:8]
if len(digits) < 8:
raise ValueError(f"Date invalide : '{value.strip()}'")
# DTPOSTED: keep the 8 leading digits, no timezone conversion (§3.2).
return datetime.strptime(digits, "%Y%m%d").date() # noqa: DTZ007
def _ofx_label(name: str, memo: str) -> str:
name, memo = _clean_cell(name), _clean_cell(memo)
if name and memo:
return f"{name}{memo}"
return name or memo
def _parse_ofx_with_ofxtools(
raw: bytes, config: dict, default_currency: str
) -> ParseResult | None:
try:
from ofxtools.Parser import OFXTree
tree = OFXTree()
tree.parse(io.BytesIO(raw))
ofx = tree.convert()
statements = list(getattr(ofx, "statements", []) or [])
if not statements:
return None
except Exception: # noqa: BLE001 — malformed French SGML: use the regex fallback
return None
wanted = config.get("account_match")
stmt = statements[0]
if wanted:
for st in statements:
acctid = getattr(getattr(st, "account", None), "acctid", None)
if acctid and str(acctid).strip() == str(wanted).strip():
stmt = st
break
result = ParseResult()
currency = (getattr(stmt, "curdef", None) or default_currency or "EUR").upper()
transactions = list(getattr(stmt, "transactions", None) or [])
for i, trn in enumerate(transactions, start=1):
result.rows_total += 1
try:
dtposted = getattr(trn, "dtposted", None)
if dtposted is None:
raise ValueError("Date absente (DTPOSTED)")
booked = dtposted.date() if hasattr(dtposted, "date") else dtposted
amount = getattr(trn, "trnamt", None)
if amount is None:
raise ValueError("Montant absent (TRNAMT)")
label = _ofx_label(
str(getattr(trn, "name", "") or ""), str(getattr(trn, "memo", "") or "")
)
if not label:
raise ValueError("Libellé vide")
fitid = str(getattr(trn, "fitid", "") or "").strip() or None
result.rows.append(
NormalizedRow(
booked_date=booked,
value_date=None,
amount=quantize2(Decimal(str(amount))),
currency=currency,
label_raw=label,
external_id=fitid,
source_row_index=i,
)
)
except ValueError as exc:
result.add_error(i, str(exc))
return result
def _parse_ofx_with_regex(
text: str, config: dict, default_currency: str
) -> ParseResult:
result = ParseResult()
m = _OFX_CURDEF_RE.search(text)
currency = (m.group(1).strip() if m else default_currency or "EUR").upper()
wanted = config.get("account_match")
scope = text
if wanted:
# Keep the statement block whose ACCTID matches, when identifiable.
parts = re.split(r"(?i)(?=<STMTRS>|<CCSTMTRS>)", text)
for part in parts:
am = _OFX_ACCTID_RE.search(part)
if am and am.group(1).strip() == str(wanted).strip():
scope = part
break
blocks = re.split(r"(?i)<STMTTRN>", scope)[1:]
if not blocks:
raise ImporterParseError("Aucune transaction OFX trouvée dans le fichier.")
for i, block in enumerate(blocks, start=1):
block = re.split(r"(?i)</STMTTRN>", block)[0]
result.rows_total += 1
fields = {}
for name, rx in _OFX_FIELD_RES.items():
fm = rx.search(block)
fields[name] = fm.group(1).strip() if fm else ""
try:
if not fields["DTPOSTED"]:
raise ValueError("Date absente (DTPOSTED)")
booked = _ofx_date(fields["DTPOSTED"])
if not fields["TRNAMT"]:
raise ValueError("Montant absent (TRNAMT)")
amount = parse_amount(fields["TRNAMT"], decimal_separator=".")
label = _ofx_label(fields["NAME"], fields["MEMO"])
if not label:
raise ValueError("Libellé vide")
result.rows.append(
NormalizedRow(
booked_date=booked,
value_date=None,
amount=quantize2(amount),
currency=currency,
label_raw=label,
external_id=fields["FITID"] or None,
source_row_index=i,
)
)
except ValueError as exc:
result.add_error(i, str(exc))
return result
def parse_ofx(config: dict, raw: bytes, default_currency: str = "EUR") -> ParseResult:
result = _parse_ofx_with_ofxtools(raw, config, default_currency)
if result is None:
text = _ofx_decode(raw, config.get("fallback_encoding", "cp1252"))
result = _parse_ofx_with_regex(text, config, default_currency)
_dedupe_fitids(result)
return result
def _dedupe_fitids(result: ParseResult) -> None:
"""LCL-style FITID collisions inside one file: suffix an occurrence counter
so the partial unique index (account_id, external_id) stays truthful."""
seen: dict[str, int] = {}
for row in result.rows:
if not row.external_id:
continue
n = seen.get(row.external_id, 0)
seen[row.external_id] = n + 1
if n:
row.external_id = f"{row.external_id}#{n}"
# ---------------------------------------------------------------------------
# PayPal activity CSV parser (kind = 'paypal_csv')
# ---------------------------------------------------------------------------
_PAYPAL_ALIASES = {
"date": ("date",),
"name": ("nom", "name"),
"type": ("type",),
"status": ("etat", "status", "statut", "state"),
"currency": ("devise", "currency"),
"gross": ("brut", "gross"),
"fee": ("frais", "fee"),
"net": ("net",),
"amount": ("montant", "amount"),
"transaction_id": ("numero de transaction", "transaction id"),
"item_title": ("titre de l'objet", "item title", "objet", "subject"),
"balance_impact": ("impact sur le solde", "balance impact"),
}
_PAYPAL_COMPLETED = {"effectue", "completed"}
_PAYPAL_CONVERSION_TYPES = {
"conversion de devise generale",
"general currency conversion",
}
def parse_paypal_csv(
config: dict, text: str, default_currency: str = "EUR"
) -> ParseResult:
reader = csv.reader(
io.StringIO(text), delimiter=config.get("delimiter", ","), quotechar='"'
)
try:
raw_rows = list(reader)
except csv.Error as exc:
raise ImporterParseError("Fichier CSV PayPal illisible.") from exc
if not raw_rows:
raise ImporterParseError("Le fichier est vide.")
header_map = {_norm_header(c): i for i, c in enumerate(raw_rows[0]) if c.strip()}
cols: dict[str, int | None] = {}
for key, aliases in _PAYPAL_ALIASES.items():
cols[key] = next((header_map[a] for a in aliases if a in header_map), None)
if cols["date"] is None or cols["transaction_id"] is None:
raise ImporterParseError(
"En-têtes PayPal introuvables (Date, Numéro de transaction) — "
"est-ce bien un rapport d'activité PayPal ?"
)
use_net = bool(config.get("use_net_amount", True))
amount_col = (
cols["net"]
if use_net and cols["net"] is not None
else cols["gross"]
if cols["gross"] is not None
else cols["amount"]
)
if amount_col is None:
raise ImporterParseError("Colonne de montant PayPal introuvable (Net/Brut).")
skip_types = {_norm_header(t) for t in (config.get("skip_types") or [])}
if config.get("conversion_as_skip", True):
skip_types |= _PAYPAL_CONVERSION_TYPES
only_completed = bool(config.get("skip_status_not_completed", True))
target_currency = (default_currency or "EUR").upper()
result = ParseResult()
for offset, row in enumerate(raw_rows[1:]):
row_index = offset + 2
if not any(cell.strip() for cell in row):
continue
result.rows_total += 1
typ = _norm_header(_cell(row, cols["type"]))
status = _norm_header(_cell(row, cols["status"]))
impact = _norm_header(_cell(row, cols["balance_impact"]))
currency = _clean_cell(_cell(row, cols["currency"])).upper()
if (
(skip_types and typ in skip_types)
or (
only_completed
and cols["status"] is not None
and status not in _PAYPAL_COMPLETED
)
or (cols["balance_impact"] is not None and impact == "memo")
or (currency and currency != target_currency)
):
result.rows_filtered += 1
continue
try:
booked = _parse_date(_cell(row, cols["date"]), config)
decimal_sep = config.get("decimal_separator", ",")
if decimal_sep == "auto":
decimal_sep = ","
amount = parse_amount(_cell(row, amount_col), decimal_sep)
name = _clean_cell(_cell(row, cols["name"]))
type_label = _clean_cell(_cell(row, cols["type"]))
title = _clean_cell(_cell(row, cols["item_title"]))
label = " — ".join(p for p in (name, type_label, title) if p)
if not label:
raise ValueError("libellé vide")
result.rows.append(
NormalizedRow(
booked_date=booked,
value_date=None,
amount=quantize2(amount),
currency=currency or target_currency,
label_raw=label,
counterparty=name or None,
external_id=_clean_cell(_cell(row, cols["transaction_id"])) or None,
source_row_index=row_index,
)
)
except ValueError as exc:
result.add_error(row_index, str(exc).capitalize())
return result