Gestion des depenses ventilees (multi-taux TVA) a l'import Indy
Deploy via Portainer / deploy (push) Successful in 0s
Deploy via Portainer / deploy (push) Successful in 0s
This commit is contained in:
+78
-54
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user