From 2ba1c39bfd170a95a493264662d1beb712212f91 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 15 Jul 2026 10:26:58 +0200 Subject: [PATCH] Gestion des depenses ventilees (multi-taux TVA) a l'import Indy --- compta/indy.py | 132 ++++++++++++++---------- compta/templates/compta/gestion.html | 3 +- compta/templates/compta/import_csv.html | 1 + compta/views_gestion.py | 9 +- compta/views_import.py | 76 +++++++++++--- 5 files changed, 153 insertions(+), 68 deletions(-) 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 @@ {{ r.date|date:"d/m/Y" }} {{ r.type_label }} {{ r.libelle }} - {{ r.categorie }}{% if r.neutre %}neutre{% endif %} + {{ r.categorie }}{% if r.neutre %}neutre{% endif %}{% if r.ventile %}ventilé{% endif %} {{ r.montant|floatformat:2|unlocalize }} € {% if r.tva %}{{ r.tva|floatformat:2|unlocalize }}{% else %}—{% endif %} diff --git a/compta/templates/compta/import_csv.html b/compta/templates/compta/import_csv.html index 139bcb7..5e8ad34 100644 --- a/compta/templates/compta/import_csv.html +++ b/compta/templates/compta/import_csv.html @@ -124,6 +124,7 @@ {{ l.type }} + {% endfor %} diff --git a/compta/views_gestion.py b/compta/views_gestion.py index 8854ff2..6583ad9 100644 --- a/compta/views_gestion.py +++ b/compta/views_gestion.py @@ -14,6 +14,7 @@ from datetime import datetime from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.db.models import Count from django.shortcuts import redirect, render from django.urls import reverse @@ -106,8 +107,14 @@ def _rows(entreprise): "regime": d.regime_tva, "taux": d.taux_tva, "montant": -d.montant_ttc, "tva": d.tva_deductible_value, "neutre": d.neutre, "signe": Decimal("-1"), "editable_montant": True, + "ventile": bool(d.paiement_id), }) - for p in Paiement.objects.filter(entreprise=entreprise).select_related("categorie"): + # Opérations bancaires SANS dépenses rattachées : les « conteneurs » d'une + # ventilation sont représentés par leurs dépenses (évite le double comptage). + paiements = (Paiement.objects.filter(entreprise=entreprise) + .select_related("categorie") + .annotate(nb_dep=Count("depenses")).filter(nb_dep=0)) + for p in paiements: rows.append({ "type": "operation", "type_label": "Opération", "id": p.pk, "date": p.date, "libelle": p.libelle, diff --git a/compta/views_import.py b/compta/views_import.py index 258690d..3b42c59 100644 --- a/compta/views_import.py +++ b/compta/views_import.py @@ -78,16 +78,18 @@ def _match_cat(text, cats): def _row(dt, libelle, montant, taux=Decimal("0"), regime=RegimeTva.NATIONALE, - categorie=None, activite="", indy_id="", n=0): + categorie=None, activite="", indy_id="", n=0, groupe="", n_parts=1): neutre = bool(categorie and categorie.exclure_calculs) if montant > 0: typ = "Mouvement neutre" if neutre else "Vente" else: typ = "Dépense neutre" if neutre else "Dépense" + if n_parts > 1: + typ += " (ventilée)" return {"n": n, "date": dt.isoformat() if dt else "", "libelle": libelle, "categorie_id": categorie.pk if categorie else "", "taux": taux, "regime": regime, "montant": montant, "type": typ, - "activite": activite, "indy_id": indy_id} + "activite": activite, "indy_id": indy_id, "groupe": groupe} # ---- CSV ---------------------------------------------------------------- @@ -248,7 +250,7 @@ def _parse_indy(entreprise, token, date_debut, date_fin, cats_par_nom): cat = cats_par_nom.get((ln["categorie_nom"] or "").strip().lower()) lignes.append(_row(dt, ln["libelle"], ln["montant"], ln["taux"], _regime(ln["regime"]), cat, ln["activite"] or "", - ln["indy_id"], n)) + ln["indy_id"], n, ln.get("groupe", ""), ln.get("n_parts", 1))) return lignes, [] @@ -262,6 +264,17 @@ def _deja_importe(entreprise, indy_id): or Paiement.objects.filter(entreprise=entreprise, indy_id=indy_id).exists()) +def _deja_importe_groupe(entreprise, groupe): + """Vrai si la transaction Indy (ou une de ses parts ventilées) est déjà là.""" + if not groupe: + return False + from django.db.models import Q + q = Q(indy_id=groupe) | Q(indy_id__startswith=f"{groupe}#") + return (Facture.objects.filter(entreprise=entreprise).filter(q).exists() + or Depense.objects.filter(entreprise=entreprise).filter(q).exists() + or Paiement.objects.filter(entreprise=entreprise).filter(q).exists()) + + # ---- Création ----------------------------------------------------------- def _creer(entreprise, dt, libelle, montant, taux, regime, categorie, @@ -295,6 +308,29 @@ def _creer(entreprise, dt, libelle, montant, taux, regime, categorie, return "depense" +def _creer_groupe(entreprise, rows): + """rows = lignes d'une même transaction. Si ≥2 dépenses -> une opération + bancaire (le total) qui porte les dépenses ventilées. Sinon création simple.""" + depenses = [r for r in rows if r["montant"] < 0] + if len(rows) >= 2 and len(depenses) == len(rows): + total = sum(-r["montant"] for r in rows) + p = Paiement.objects.create( + entreprise=entreprise, date=rows[0]["dt"], libelle=rows[0]["libelle"], + montant_total=total, sens="debit", indy_id=rows[0].get("groupe", "")) + for r in rows: + Depense.objects.create( + entreprise=entreprise, paiement=p, date=r["dt"], libelle=r["libelle"], + categorie=r["categorie"], activite=r["activite"], regime_tva=r["regime"], + taux_tva=r["taux"], montant_ttc=-r["montant"], indy_id=r["indy_id"]) + return {"dépense (ventilée)": len(rows)} + compteur = {} + for r in rows: + k = _creer(entreprise, r["dt"], r["libelle"], r["montant"], r["taux"], + r["regime"], r["categorie"], r["activite"], r["indy_id"]) + compteur[k] = compteur.get(k, 0) + 1 + return compteur + + @login_required def import_csv(request): entreprises = Entreprise.accessibles(request.user) @@ -323,24 +359,40 @@ def import_csv(request): montants = request.POST.getlist("l_montant") activites = request.POST.getlist("l_activite") indy_ids = request.POST.getlist("l_indy_id") + groupes = request.POST.getlist("l_groupe") cats_by_id = {str(c.pk): c for c in Categorie.objects.all()} compteur = {} ignores = 0 + # 1) Construire les lignes valides, en préservant l'ordre des groupes. + groupes_ordre = [] + par_groupe = {} for i in range(len(libelles)): dt = _date(dates[i]) if i < len(dates) else None montant = _dec(montants[i] if i < len(montants) else "0") - iid = indy_ids[i] if i < len(indy_ids) else "" if dt is None or montant == 0: continue - if iid and _deja_importe(entreprise, iid): - ignores += 1 + iid = indy_ids[i] if i < len(indy_ids) else "" + grp = (groupes[i] if i < len(groupes) else "") or "" + cle = grp or f"_solo_{i}" # lignes sans groupe = isolées + if cle not in par_groupe: + par_groupe[cle] = [] + groupes_ordre.append((cle, grp)) + par_groupe[cle].append({ + "dt": dt, "montant": montant, + "libelle": (libelles[i] or "").strip() or "(sans libellé)", + "taux": _dec(taux[i] if i < len(taux) else "0"), + "regime": _regime(regimes[i] if i < len(regimes) else ""), + "categorie": cats_by_id.get(cat_ids[i] if i < len(cat_ids) else ""), + "activite": _activite(activites[i] if i < len(activites) else ""), + "indy_id": iid, "groupe": grp}) + # 2) Créer groupe par groupe (anti-doublon par transaction). + for cle, grp in groupes_ordre: + rows = par_groupe[cle] + if grp and _deja_importe_groupe(entreprise, grp): + ignores += len(rows) continue - k = _creer(entreprise, dt, (libelles[i] or "").strip() or "(sans libellé)", - montant, _dec(taux[i] if i < len(taux) else "0"), - _regime(regimes[i] if i < len(regimes) else ""), - cats_by_id.get(cat_ids[i] if i < len(cat_ids) else ""), - _activite(activites[i] if i < len(activites) else ""), iid) - compteur[k] = compteur.get(k, 0) + 1 + for k, v in _creer_groupe(entreprise, rows).items(): + compteur[k] = compteur.get(k, 0) + v if ignores: compteur["déjà importées (ignorées)"] = ignores ctx["resultat"] = compteur