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>
643 lines
23 KiB
Python
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
|