diff --git a/compta/indy.py b/compta/indy.py index c78a291..62b6d8e 100644 --- a/compta/indy.py +++ b/compta/indy.py @@ -6,8 +6,15 @@ La « catégorie » Indy = un numéro de compte du plan comptable (PCG). On mapp numéro vers nos catégories via le catalogue (categories_indy). Montants en centimes, signés. `tva_intracom` => UE (autoliquidation). -API non officielle d'Indy : pour les données de l'utilisateur. Le token JWT -expire (~1 h) et doit être renouvelé. +Ventilation : une transaction peut être éclatée en plusieurs subdivisions de +charge (ex. un repas à 10 % + 20 %). Chaque subdivision de charge porte son HT +(`amount_in_cents`), son taux (`tva_rate`) et sa TVA (`tva_amount_in_cents`). +Indy ajoute aussi des subdivisions « miroir » de TVA (compte 445*, `is_tva:true`) +et la contrepartie bancaire (compte 512*) : on les ignore. On émet donc UNE ligne +par subdivision de charge, avec un identifiant de groupe commun (l'id de la +transaction) pour reconstituer l'opération d'origine. + +API non officielle d'Indy : le token JWT expire (~1 h) et doit être renouvelé. """ from decimal import Decimal @@ -17,6 +24,7 @@ from .categories_indy import COMPTE_VERS_NOM, COMPTE_ACTIVITE BASE_URL = "https://app.indy.fr" TRANSACTIONS_PATH = "/api/transactions/transactions-list" +CENT = Decimal("0.01") def _cat(num): @@ -27,80 +35,96 @@ def _act(num): return COMPTE_ACTIVITE.get(str(num or ""), "") -def normaliser(tx): - """Transaction Indy -> ligne normalisée, ou None si inexploitable.""" - try: - montant = (Decimal(tx["totalAmountInCents"]) / 100).quantize(Decimal("0.01")) - except Exception: - return None - subs = [s for s in tx.get("subdivisions", []) - if not str((s.get("accounting_account") or {}).get("number", "")).startswith("512")] +def _charge_subdivisions(tx): + """Subdivisions de charge/produit réelles : hors banque (512*) et hors + miroir de TVA (445* avec is_tva).""" + out = [] + for s in tx.get("subdivisions", []): + num = str((s.get("accounting_account") or {}).get("number", "")) + if num.startswith("512"): + continue + if s.get("is_tva"): + continue + out.append((num, s)) + return out + + +def lignes_transaction(tx): + """Transaction Indy -> liste de lignes normalisées (une par subdivision de + charge). Liste vide si inexploitable.""" + txid = tx.get("_id", "") + date = tx.get("date", "") + libelle = tx.get("description") or tx.get("raw_description") or "(sans libellé)" + subs = _charge_subdivisions(tx) if not subs: - return None - main = max(subs, key=lambda s: abs(s.get("amount_in_cents", 0))) - num = str((main.get("accounting_account") or {}).get("number", "")) - intra = main.get("tva_intracom") - if intra and intra.get("type") == "eu": - regime, taux = "intracom", Decimal(intra.get("rate", 0)) / 100 - else: - regime, taux = "nationale", Decimal(main.get("tva_rate", 0)) / 100 - return { - "indy_id": tx.get("_id", ""), - "date": tx.get("date", ""), - "libelle": tx.get("description") or tx.get("raw_description") or "(sans libellé)", - "categorie_nom": _cat(num), - "activite": _act(num), - "taux": taux, - "regime": regime, - "montant": montant, - "compte": num, - } + return [] + # Mode de saisie de la TVA : en "tva_ttc" le montant inclut déjà la TVA. + mode = tx.get("tva_selected") or (tx.get("vat") or {}).get("tvaSelected") or "tva_ht" + inclut_tva = mode == "tva_ttc" + n = len(subs) + lignes = [] + for i, (num, s) in enumerate(subs): + amt = Decimal(s.get("amount_in_cents", 0)) + tva = Decimal(s.get("tva_amount_in_cents", 0)) + ttc = (amt if inclut_tva else amt + tva) / 100 + ttc = ttc.quantize(CENT) + intra = s.get("tva_intracom") + if intra and intra.get("type") == "eu": + regime, taux = "intracom", Decimal(intra.get("rate", 0)) / 100 + else: + regime, taux = "nationale", Decimal(s.get("tva_rate", 0)) / 100 + lignes.append({ + "indy_id": f"{txid}#{i}" if n > 1 else txid, + "groupe": txid, + "n_parts": n, + "date": date, + "libelle": libelle, + "categorie_nom": _cat(num), + "activite": _act(num), + "taux": taux, + "regime": regime, + "montant": ttc, + "compte": num, + }) + return lignes + + +def normaliser(tx): + """Compat : première ligne de charge de la transaction (ou None).""" + lignes = lignes_transaction(tx) + return lignes[0] if lignes else None def fetch_transactions(token, date_debut=None, date_fin=None, search="", page=1, base_url=BASE_URL): - """Appelle l'API Indy. Retourne (status, liste_transactions_brutes, erreur).""" - headers = { - "Accept": "application/json, text/plain, */*", - "Authorization": f"Bearer {token}", - "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/149.0 Safari/537.36"), - "x-client-app-type": "web", - } + headers = {"Accept": "application/json", "Authorization": f"Bearer {token}", + "x-client-app-type": "web"} params = {"search": search or "", "page": page} if date_debut: params["dateFrom"] = date_debut if date_fin: params["dateTo"] = date_fin - timeout = httpx.Timeout(12.0, connect=8.0) try: with httpx.Client(base_url=base_url.rstrip("/"), headers=headers, - timeout=timeout, follow_redirects=True) as c: + timeout=httpx.Timeout(12.0, connect=8.0), follow_redirects=True) as c: r = c.get(TRANSACTIONS_PATH, params=params) if r.status_code != 200: - return r.status_code, [], ( - f"HTTP {r.status_code} — token expire, ou requete bloquee par " - f"Cloudflare depuis le serveur.") + return r.status_code, [], f"HTTP {r.status_code}" if "json" not in r.headers.get("content-type", "").lower(): - return r.status_code, [], ( - "Reponse non-JSON (probablement Cloudflare). Le conteneur n'arrive " - "pas a joindre Indy directement. Recupere le CSV depuis ton poste " - "(favori/MCP) et colle-le ci-dessous.") - data = r.json() - return 200, data.get("transactions", []), None + return r.status_code, [], "Reponse non-JSON (Cloudflare)." + return 200, r.json().get("transactions", []), None except httpx.TimeoutException: - return None, [], ("Delai depasse en joignant Indy : le conteneur n'a peut-etre " - "pas d'acces Internet, ou Cloudflare bloque. Utilise le favori " - "depuis ton poste.") - except Exception as ex: # noqa: BLE001 + return None, [], "Delai depasse." + except Exception as ex: return None, [], f"{type(ex).__name__}: {ex}" def lignes_normalisees(token, date_debut=None, date_fin=None, search="", page=1, base_url=BASE_URL): - """Retourne (lignes_normalisées, erreur).""" status, txs, err = fetch_transactions(token, date_debut, date_fin, search, page, base_url) if err: return [], err - lignes = [n for n in (normaliser(t) for t in txs) if n] + lignes = [] + for t in txs: + lignes.extend(lignes_transaction(t)) return lignes, None diff --git a/compta/templates/compta/gestion.html b/compta/templates/compta/gestion.html index 96b754e..6f50948 100644 --- a/compta/templates/compta/gestion.html +++ b/compta/templates/compta/gestion.html @@ -9,6 +9,7 @@ .tag.depense { color:var(--warn); border-color:#7a5f1f; } .tag.operation { color:var(--accent); border-color:#25566e; } .neutre-badge { font-size:10px; color:var(--muted); border:1px dashed var(--line); border-radius:6px; padding:1px 5px; margin-left:6px; } + .ventile-badge { font-size:10px; color:var(--accent); border:1px solid #25566e; border-radius:6px; padding:1px 5px; margin-left:6px; } .btn-mini { padding:5px 11px; font-size:12.5px; border-radius:8px; } #gtable td, #gtable th { border-bottom:1px solid var(--line); } #gtable tbody tr:hover td { background:#243247; } @@ -105,7 +106,7 @@