diff --git a/compta/indy.py b/compta/indy.py
new file mode 100644
index 0000000..8f7da5c
--- /dev/null
+++ b/compta/indy.py
@@ -0,0 +1,118 @@
+"""
+Synchronisation Indy : récupère les transactions via l'API privée d'Indy et les
+normalise au format attendu par l'import (réutilisé par la page /import/).
+
+La « catégorie » Indy est un numéro de compte du plan comptable (PCG) ; on le
+mappe vers nos catégories. Montants en centimes, signés. `tva_intracom` => UE.
+
+⚠️ API non officielle d'Indy : pour les données de l'utilisateur. Le token JWT
+expire (~1 h) et doit être renouvelé.
+"""
+from decimal import Decimal
+
+import httpx
+
+BASE_URL = "https://app.indy.fr"
+TRANSACTIONS_PATH = "/api/transactions/transactions-list"
+
+COMPTE_CATEGORIE = [
+ ("701", "Vente de produits finis"), ("707", "Vente de produits finis"),
+ ("706", "Prestation de services"),
+ ("605", "Matériel et outillage"), ("606", "Matériel et outillage"),
+ ("607", "Matériel et outillage"),
+ ("6256", "Frais de repas hors domicile"), ("6253", "Frais de repas hors domicile"),
+ ("626", "Télécom, fournitures, documentation"),
+ ("6181", "Télécom, fournitures, documentation"),
+ ("6183", "Télécom, fournitures, documentation"),
+ ("6136", "Abonnement logiciel"), ("6226", "Abonnement logiciel"),
+ ("646", "Cotisation sociale Urssaf"),
+ ("44551", "TVA payée"), ("44558", "TVA payée"),
+ ("44583", "Remboursement de TVA"),
+ ("108", "Prélèvement personnel"),
+]
+
+COMPTE_ACTIVITE = [("701", "vente"), ("707", "vente"), ("706", "service_bic")]
+
+
+def _cat(num):
+ num = str(num or "")
+ best = None
+ for pref, cat in COMPTE_CATEGORIE:
+ if num.startswith(pref) and (best is None or len(pref) > len(best[0])):
+ best = (pref, cat)
+ return best[1] if best else None
+
+
+def _act(num):
+ num = str(num or "")
+ for pref, act in COMPTE_ACTIVITE:
+ if num.startswith(pref):
+ return act
+ return ""
+
+
+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")]
+ 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,
+ }
+
+
+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",
+ }
+ params = {"search": search or "", "page": page}
+ if date_debut:
+ params["dateFrom"] = date_debut
+ if date_fin:
+ params["dateTo"] = date_fin
+ try:
+ with httpx.Client(base_url=base_url.rstrip("/"), headers=headers,
+ timeout=30, 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 expiré ?)"
+ data = r.json()
+ return 200, data.get("transactions", []), None
+ except Exception as ex: # noqa: BLE001
+ 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]
+ return lignes, None
diff --git a/compta/migrations/0009_depense_indy_id_entreprise_indy_token_and_more.py b/compta/migrations/0009_depense_indy_id_entreprise_indy_token_and_more.py
new file mode 100644
index 0000000..f9be8ac
--- /dev/null
+++ b/compta/migrations/0009_depense_indy_id_entreprise_indy_token_and_more.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.2.15 on 2026-06-30 12:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('compta', '0008_alter_depense_montant_tva_alter_depense_taux_tva_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='depense',
+ name='indy_id',
+ field=models.CharField(blank=True, db_index=True, max_length=40),
+ ),
+ migrations.AddField(
+ model_name='entreprise',
+ name='indy_token',
+ field=models.TextField(blank=True, help_text='Token JWT capté dans le navigateur, pour la synchronisation. Expire ~1 h.', verbose_name='Token Indy (API)'),
+ ),
+ migrations.AddField(
+ model_name='facture',
+ name='indy_id',
+ field=models.CharField(blank=True, db_index=True, max_length=40),
+ ),
+ migrations.AddField(
+ model_name='paiement',
+ name='indy_id',
+ field=models.CharField(blank=True, db_index=True, max_length=40),
+ ),
+ ]
diff --git a/compta/models.py b/compta/models.py
index 4d3bbe6..f93bae0 100644
--- a/compta/models.py
+++ b/compta/models.py
@@ -88,6 +88,9 @@ class Entreprise(models.Model):
num_tva_intracom = models.CharField("N° TVA intracommunautaire", max_length=20, blank=True)
vue_tva = models.CharField(
"Mode d'affichage de la déclaration TVA", max_length=10, choices=VUE_TVA, default="simplifie")
+ indy_token = models.TextField(
+ "Token Indy (API)", blank=True,
+ help_text="Token JWT capté dans le navigateur, pour la synchronisation. Expire ~1 h.")
utilisateurs = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, related_name="entreprises",
verbose_name="Utilisateurs autorisés",
@@ -151,6 +154,7 @@ class Facture(models.Model):
montant_tva = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0"))
montant_ttc = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0"))
notes = models.TextField(blank=True)
+ indy_id = models.CharField(max_length=40, blank=True, db_index=True)
cree_le = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -207,6 +211,7 @@ class Paiement(models.Model):
categorie = models.ForeignKey(Categorie, on_delete=models.SET_NULL, null=True, blank=True,
related_name="paiements",
help_text="Surtout pour les mouvements neutres.")
+ indy_id = models.CharField(max_length=40, blank=True, db_index=True)
cree_le = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -255,6 +260,7 @@ class Depense(models.Model):
"Taux de TVA (%)", max_digits=4, decimal_places=2, default=Decimal("20"),
choices=TAUX_TVA_CHOICES,
help_text="Achat national : la TVA déductible est calculée. UE/import : taux auto-liquidé.")
+ indy_id = models.CharField(max_length=40, blank=True, db_index=True)
cree_le = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -266,7 +272,6 @@ class Depense(models.Model):
return f"{self.date} — {self.libelle}"
def save(self, *args, **kwargs):
- # TVA déductible calculée : incluse dans le TTC (national), nulle en autoliq.
t = self.taux_tva or Decimal("0")
if self.autoliquidee:
self.montant_tva = Decimal("0.00")
diff --git a/compta/templates/compta/import_csv.html b/compta/templates/compta/import_csv.html
index cc505fa..33b7ea3 100644
--- a/compta/templates/compta/import_csv.html
+++ b/compta/templates/compta/import_csv.html
@@ -10,16 +10,18 @@
* { box-sizing:border-box; }
body { margin:0; font-family:system-ui,Segoe UI,Roboto,sans-serif;
background:var(--bg); color:var(--txt); padding:24px; }
- h1 { margin:0 0 4px; font-size:22px; } h2{font-size:15px;color:var(--muted);}
+ h1 { margin:0 0 4px; font-size:22px; } h2{font-size:15px;color:var(--muted);margin:0 0 8px;}
a { color:var(--accent); }
.sub { color:var(--muted); margin-bottom:14px; }
.card { background:var(--card); border:1px solid #334155; border-radius:12px; padding:16px; margin-bottom:16px; }
+ .card.indy { border-color:#0e7490; }
label { display:block; font-size:12px; color:var(--muted); margin:8px 0 4px; }
input, select, textarea { background:#0f172a; color:var(--txt); border:1px solid #334155;
border-radius:8px; padding:8px 10px; font-size:14px; width:100%; }
- textarea { min-height:110px; font-family:ui-monospace,monospace; }
+ textarea { min-height:90px; font-family:ui-monospace,monospace; }
.grid3 { display:grid; grid-template-columns:2fr 1fr 1fr; gap:12px; }
- @media(max-width:700px){ .grid3{grid-template-columns:1fr;} }
+ .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
+ @media(max-width:700px){ .grid3,.grid2{grid-template-columns:1fr;} }
button { cursor:pointer; border-radius:8px; border:1px solid #334155; background:var(--card);
color:var(--txt); padding:9px 16px; font-size:14px; }
button.primary { background:var(--accent); color:#04263a; border:none; font-weight:600; }
@@ -53,8 +55,11 @@
{% endif %}
{% if not lignes %}
-
+
+
+