Compare commits

...

2 Commits

Author SHA1 Message Date
admin 757c0850c3 Config Traefik (proxy TLS) + page entrees/sorties + referentiel Indy + .gitignore 2026-07-11 20:32:02 +02:00
admin 0a4ba2001b Changes to be committed:
modified:   .env.example
	new file:   compta/__pycache__/__init__.cpython-310.pyc
	new file:   compta/__pycache__/admin.cpython-310.pyc
	new file:   compta/__pycache__/apps.cpython-310.pyc
	new file:   compta/__pycache__/models.cpython-310.pyc
	modified:   compta/admin.py
	new file:   compta/categories_indy.py
	modified:   compta/indy.py
	modified:   compta/management/commands/seed_reference.py
	new file:   compta/migrations/0010_categorie_compte_pcg.py
	modified:   compta/models.py
	modified:   compta/templates/compta/base.html
	new file:   compta/templates/compta/gestion.html
	modified:   compta/urls.py
	new file:   compta/views_gestion.py
	new file:   config/__pycache__/__init__.cpython-310.pyc
	new file:   config/__pycache__/settings.cpython-310.pyc
	modified:   config/settings.py
	modified:   entrypoint.sh
2026-07-11 20:27:51 +02:00
14 changed files with 751 additions and 63 deletions
+10 -2
View File
@@ -4,8 +4,16 @@
# Sécurité Django
DJANGO_SECRET_KEY=change-moi-avec-une-longue-chaine-aleatoire
DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,192.168.1.50
DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.1.50:8001
# Domaine Traefik + accès direct éventuel (IP de l'hôte Docker / localhost)
DJANGO_ALLOWED_HOSTS=compta.lespatas.ovh,localhost,127.0.0.1
# Origine HTTPS servie par Traefik — le schéma https:// est OBLIGATOIRE
DJANGO_CSRF_TRUSTED_ORIGINS=https://compta.lespatas.ovh
# Derrière Traefik (reverse-proxy distant qui termine le TLS) : mets 1.
# Django reconnaît alors le HTTPS via X-Forwarded-Proto et sécurise les cookies.
DJANGO_BEHIND_PROXY=1
# Laisse 0 : c'est Traefik qui redirige HTTP->HTTPS.
DJANGO_SSL_REDIRECT=0
# PostgreSQL EXISTANT (ne pas héberger de base ici)
DB_NAME=compta
+8
View File
@@ -2,6 +2,14 @@
.env
*.env.local
# Python
__pycache__/
*.pyc
# Django
db.sqlite3
staticfiles/
# Postgres dumps
*.dump
*.bak
+3 -3
View File
@@ -30,10 +30,10 @@ class ScopedAdmin(admin.ModelAdmin):
@admin.register(Categorie)
class CategorieAdmin(admin.ModelAdmin):
list_display = ("nom", "usage", "exclure_calculs", "actif", "ordre")
list_display = ("nom", "usage", "exclure_calculs", "compte_pcg", "actif", "ordre")
list_filter = ("usage", "exclure_calculs", "actif")
list_editable = ("usage", "exclure_calculs", "actif", "ordre")
search_fields = ("nom",)
list_editable = ("usage", "exclure_calculs", "compte_pcg", "actif", "ordre")
search_fields = ("nom", "compte_pcg")
@admin.register(Bareme)
+84
View File
@@ -0,0 +1,84 @@
"""
Catalogue des catégories Indy (référentiel complet), source de vérité unique
utilisée par le seed (création des Catégorie) et par la sync Indy (mapping
numéro de compte PCG -> catégorie).
Chaque entrée : (nom, usage, exclure_calculs, compte_pcg)
- usage : "vente" | "depense" | "neutre"
- exclure_calculs=True : mouvement neutre (hors calcul du bénéfice / non déductible /
trésorerie / impôts) — exclu des calculs fiscaux.
"""
# nom, usage, exclure_calculs, compte_pcg
CATALOGUE = [
# --- Revenus (ventes) ---
("Vente de produits finis", "vente", False, "701000"),
("Prestation de services", "vente", False, "706001"),
("Vente de services annexes", "vente", False, "706002"),
("Vente de marchandise", "vente", False, "707000"),
("Autre gain divers", "vente", False, "710000"),
# --- Dépenses ---
("Matériel et outillage", "depense", False, "606000"),
("Matière première", "depense", False, ""), # (nôtre, pas de compte Indy)
("Achat", "depense", False, "605000"),
("Immobilisation", "depense", False, "210000"),
("Frais divers", "depense", False, "600000"),
("Télécom, fournitures, documentation", "depense", False, "626000"),
("Frais d'acte et de contentieux", "depense", False, "622700"),
("Honoraires payés", "depense", False, "622000"),
("Formation", "depense", False, "618000"),
("Réception et congrès", "depense", False, "625600"),
("Restaurant et repas d'affaires", "depense", False, "625700"),
("Frais de repas hors domicile", "depense", False, "625300"),
("Frais de déplacement", "depense", False, "625100"),
("Véhicule et carburant", "depense", False, "625200"),
("Location de matériel", "depense", False, "613500"),
("Loyers et charges locatives", "depense", False, "613200"),
("Entretien et réparation", "depense", False, "615000"),
("Abonnement logiciel", "depense", False, "613600"),
("Eau, gaz, électricité", "depense", False, "606100"),
("Assurance professionnelle", "depense", False, "616000"),
("Frais bancaires", "depense", False, "661000"),
("Cotisations professionnelles", "depense", False, "628100"),
("Personnel intérimaire", "depense", False, "621000"),
("[Salariés] Salaire net", "depense", False, "641000"),
("[Salariés] Charge sociale", "depense", False, "645000"),
# --- Neutres (hors calcul du bénéfice / non déductible / trésorerie / impôts) ---
("Apport personnel", "neutre", True, "108900"),
("Prélèvement personnel", "neutre", True, "108100"),
("Dépense personnelle", "neutre", True, "108200"),
("[Salariés] Prélèvement à la source", "neutre", True, "641100"),
("Amende et pénalité", "neutre", True, "108500"),
("Emprunt", "neutre", True, "164000"),
("Caution reçue", "neutre", True, "275000"),
("Caution versée", "neutre", True, ""), # même compte 275000
("Compte commun ou SCM", "neutre", True, "467000"),
("Débours pour vos clients", "neutre", True, "709800"),
("Virement interne", "neutre", True, "580000"),
("Carte à débit différé", "neutre", True, "512500"),
("Remboursement de TVA", "neutre", True, "445830"),
("Remboursement reçu", "neutre", True, ""), # (nôtre, générique)
("TVA payée", "neutre", True, "445510"),
("TVA déductible", "neutre", True, "445610"),
("TVA sur cession d'immobilisation", "neutre", True, "445775"),
("Vente d'une immobilisation", "neutre", True, "462000"),
("Cotisation sociale Urssaf", "neutre", True, "646000"),
("Cotisation retraite", "neutre", True, "646200"),
("Cotisation facultative", "neutre", True, "646100"),
("CFE", "neutre", True, "635120"),
("Autre impôt", "neutre", True, "635100"),
]
# Compte PCG -> nom de catégorie (pour la sync Indy). Premier gagnant si doublon.
COMPTE_VERS_NOM = {}
for _nom, _u, _e, _c in CATALOGUE:
if _c and _c not in COMPTE_VERS_NOM:
COMPTE_VERS_NOM[_c] = _nom
# Compte de vente -> activité (multi-activité)
COMPTE_ACTIVITE = {
"701000": "vente", "707000": "vente",
"706001": "service_bic", "706002": "service_bic",
}
+10 -34
View File
@@ -1,9 +1,10 @@
"""
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/).
normalise au format d'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.
La « catégorie » Indy = un numéro de compte du plan comptable (PCG). On mappe ce
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é.
@@ -12,43 +13,18 @@ from decimal import Decimal
import httpx
from .categories_indy import COMPTE_VERS_NOM, COMPTE_ACTIVITE
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
return COMPTE_VERS_NOM.get(str(num or ""))
def _act(num):
num = str(num or "")
for pref, act in COMPTE_ACTIVITE:
if num.startswith(pref):
return act
return ""
return COMPTE_ACTIVITE.get(str(num or ""), "")
def normaliser(tx):
@@ -109,12 +85,12 @@ def fetch_transactions(token, date_debut=None, date_fin=None, search="", page=1,
return r.status_code, [], (
"Reponse non-JSON (probablement Cloudflare). Le conteneur n'arrive "
"pas a joindre Indy directement. Recupere le CSV depuis ton poste "
"(MCP/CLI) et colle-le ci-dessous.")
"(favori/MCP) et colle-le ci-dessous.")
data = r.json()
return 200, data.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 CSV "
"pas d'acces Internet, ou Cloudflare bloque. Utilise le favori "
"depuis ton poste.")
except Exception as ex: # noqa: BLE001
return None, [], f"{type(ex).__name__}: {ex}"
+9 -23
View File
@@ -1,5 +1,5 @@
"""
Données de RÉFÉRENCE (barèmes + catégories + groupe Gestionnaire) — sans démo.
Données de RÉFÉRENCE (barèmes + catégories complètes Indy + groupe Gestionnaire).
Idempotent : sûr à relancer en production. Appelé par l'entrypoint au démarrage.
Usage : python manage.py seed_reference
@@ -10,6 +10,7 @@ from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission
from compta.models import Bareme, Categorie, Activite
from compta.categories_indy import CATALOGUE
BAREMES_2026 = [
(Activite.VENTE, Decimal("12.30"), Decimal("71"), Decimal("1.0"),
@@ -20,26 +21,10 @@ BAREMES_2026 = [
Decimal("83600"), Decimal("37500"), Decimal("41250")),
]
CATEGORIES = [
("Vente de produits finis", "vente", False, 1),
("Prestation de services", "vente", False, 2),
("Matériel et outillage", "depense", False, 1),
("Matière première", "depense", False, 2),
("Télécom, fournitures, documentation", "depense", False, 3),
("Frais de repas hors domicile", "depense", False, 4),
("Abonnement logiciel", "depense", False, 5),
("TVA payée", "neutre", True, 1),
("Cotisation sociale Urssaf", "neutre", True, 2),
("Remboursement de TVA", "neutre", True, 3),
("Prélèvement personnel", "neutre", True, 4),
("Virement interne", "neutre", True, 5),
]
# Permissions du groupe "Gestionnaire entreprise"
GROUPE_NOM = "Gestionnaire entreprise"
PERMS_COMPLETES = ["client", "facture", "lignefacture", "depense", "paiement"] # add/change/delete/view
PERMS_CHANGE = ["entreprise"] # view + change (pas add/delete, ni gestion des accès)
PERMS_VUE = ["categorie", "bareme"] # view seulement
PERMS_COMPLETES = ["client", "facture", "lignefacture", "depense", "paiement"]
PERMS_CHANGE = ["entreprise"]
PERMS_VUE = ["categorie", "bareme"]
def charger_groupes():
@@ -67,9 +52,10 @@ def charger_reference(stdout=None, style=None):
defaults=dict(taux_urssaf=urssaf, abattement_fiscal=ab, taux_vl=vl,
seuil_ca=sca, seuil_tva_base=tb, seuil_tva_majore=tm))
cat = {}
for nom, usage, excl, ordre in CATEGORIES:
for i, (nom, usage, excl, compte) in enumerate(CATALOGUE):
c, _ = Categorie.objects.update_or_create(
nom=nom, defaults=dict(usage=usage, exclure_calculs=excl, ordre=ordre))
nom=nom, defaults=dict(usage=usage, exclure_calculs=excl,
compte_pcg=compte, ordre=i))
cat[nom] = c
g = charger_groupes()
if stdout and style:
@@ -80,7 +66,7 @@ def charger_reference(stdout=None, style=None):
class Command(BaseCommand):
help = "Charge barèmes, catégories et le groupe Gestionnaire (sans démo)."
help = "Charge barèmes, catégories (référentiel Indy) et le groupe Gestionnaire."
def handle(self, *args, **options):
charger_reference(self.stdout, self.style)
@@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-07-11 17:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('compta', '0009_depense_indy_id_entreprise_indy_token_and_more'),
]
operations = [
migrations.AddField(
model_name='categorie',
name='compte_pcg',
field=models.CharField(blank=True, db_index=True, help_text='Numéro de compte du plan comptable, pour le mapping Indy.', max_length=10, verbose_name='Compte PCG (Indy)'),
),
]
+3
View File
@@ -40,6 +40,9 @@ class Categorie(models.Model):
"Neutre (exclue des calculs)", default=False,
help_text="Cochez pour les mouvements non fiscaux : TVA payée, "
"cotisations URSSAF, remboursement de TVA, virements…")
compte_pcg = models.CharField(
"Compte PCG (Indy)", max_length=10, blank=True, db_index=True,
help_text="Numéro de compte du plan comptable, pour le mapping Indy.")
actif = models.BooleanField(default=True)
ordre = models.IntegerField(default=0)
+1
View File
@@ -53,6 +53,7 @@
<a class="{% if vue == 'mensuel' %}on{% endif %}" href="/dashboard/mensuel/?entreprise={{ entreprise.pk }}&annee={{ annee }}">Mensuel</a>
<a class="{% if vue == 'urssaf' %}on{% endif %}" href="/declaration/urssaf/?entreprise={{ entreprise.pk }}&annee={{ annee }}">Déclaration URSSAF</a>
<a class="{% if vue == 'tva' %}on{% endif %}" href="/declaration/tva/?entreprise={{ entreprise.pk }}&annee={{ annee }}">Déclaration TVA</a>
<a class="{% if vue == 'gestion' %}on{% endif %}" href="/gestion/?entreprise={{ entreprise.pk }}">Entrées / sorties</a>
<a class="saisie" href="/saisie/">+ Saisir</a>
<a href="/import/">Importer</a>
{% if user.is_staff %}<a href="/admin/">Admin</a>{% endif %}
+323
View File
@@ -0,0 +1,323 @@
{% load l10n %}<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>JB Compta — Entrées / sorties</title>
<style>
:root { --bg:#0f172a; --card:#1e293b; --txt:#e2e8f0; --muted:#94a3b8;
--accent:#38bdf8; --ok:#34d399; --warn:#fbbf24; --bad:#f87171; --line:#334155; }
* { 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; }
a { color:var(--accent); text-decoration:none; }
.sub { color:var(--muted); margin-bottom:16px; }
.nav { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:14px; }
.nav a, .nav button { padding:7px 13px; border-radius:8px; border:1px solid var(--line);
background:var(--card); font-size:14px; color:var(--txt); cursor:pointer; }
.nav a.on { background:var(--accent); color:#04263a; border-color:var(--accent); font-weight:600; }
select, button, input { background:var(--card); color:var(--txt); border:1px solid var(--line);
border-radius:8px; padding:8px 10px; font-size:14px; }
button { cursor:pointer; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; margin:8px 0 18px; }
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:14px; }
.card .label { color:var(--muted); font-size:12px; }
.card .value { font-size:20px; font-weight:600; margin-top:4px; }
.ok{color:var(--ok)} .warn{color:var(--warn)} .bad{color:var(--bad)}
form.filters { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:16px; align-items:end; }
form.filters label { display:flex; flex-direction:column; gap:3px; font-size:12px; color:var(--muted); }
table { width:100%; border-collapse:collapse; font-size:14px; }
td,th { padding:8px 10px; border-bottom:1px solid var(--line); text-align:left; }
th { color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.03em; }
td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; }
tr:hover td { background:#243247; }
.tag { font-size:11px; padding:2px 7px; border-radius:99px; border:1px solid var(--line); }
.tag.recette { color:var(--ok); border-color:#2f6b52; }
.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; }
.btn-mini { padding:4px 9px; font-size:12px; }
.pos{color:var(--ok)} .neg{color:var(--bad)}
.msg { padding:10px 14px; border-radius:8px; margin-bottom:12px; background:#14532d33; border:1px solid #2f6b52; }
.msg.error { background:#7f1d1d33; border-color:#7a2f2f; }
/* modal */
.overlay { position:fixed; inset:0; background:#020617cc; display:none; align-items:center;
justify-content:center; padding:16px; z-index:50; }
.overlay.on { display:flex; }
.modal { background:var(--card); border:1px solid var(--line); border-radius:14px;
width:min(560px,100%); max-height:90vh; overflow:auto; padding:22px; }
.modal h3 { margin:0 0 14px; }
.modal .field { display:flex; flex-direction:column; gap:4px; margin-bottom:12px; }
.modal .field label { font-size:13px; color:var(--muted); }
.modal .field input, .modal .field select { width:100%; }
.modal .actions { display:flex; gap:10px; justify-content:space-between; margin-top:18px; }
.explic { background:#0b1220; border:1px solid var(--line); border-radius:10px;
padding:11px 13px; font-size:13px; color:var(--muted); margin-top:2px; }
.explic b { color:var(--txt); }
.hidegroup { display:none; }
.empty { color:var(--muted); padding:24px; text-align:center; }
</style>
</head>
<body>
{% if not entreprise %}
<h1>Aucune entreprise</h1>
<p class="sub">Crée une entreprise dans <a href="/admin/">l'administration</a>.</p>
{% else %}
<div class="nav">
<a href="/dashboard/?entreprise={{ entreprise.pk }}">Global</a>
<a href="/dashboard/mensuel/?entreprise={{ entreprise.pk }}">Mensuel</a>
<a href="/declaration/urssaf/?entreprise={{ entreprise.pk }}">Déclaration URSSAF</a>
<a href="/declaration/tva/?entreprise={{ entreprise.pk }}">Déclaration TVA</a>
<a class="on" href="/gestion/?entreprise={{ entreprise.pk }}">Entrées / sorties</a>
<a href="/saisie/">+ Saisir</a>
<a href="/import/">Importer</a>
{% if user.is_staff %}<a href="/admin/">Admin</a>{% endif %}
<form method="post" action="/logout/" style="margin:0 0 0 auto;">{% csrf_token %}<button type="submit">Déconnexion</button></form>
</div>
<h1>Entrées / sorties</h1>
<div class="sub">{{ entreprise.nom }} — vue unifiée de toutes les opérations importées</div>
{% for m in messages %}<div class="msg {{ m.tags }}">{{ m }}</div>{% endfor %}
<div class="grid">
<div class="card"><div class="label">Opérations</div><div class="value">{{ nb }}</div></div>
<div class="card"><div class="label">Entrées</div><div class="value pos">{{ entrees|floatformat:2|unlocalize }} €</div></div>
<div class="card"><div class="label">Sorties</div><div class="value neg">{{ sorties|floatformat:2|unlocalize }} €</div></div>
<div class="card"><div class="label">Solde</div><div class="value {% if total >= 0 %}pos{% else %}neg{% endif %}">{{ total|floatformat:2|unlocalize }} €</div></div>
</div>
<form class="filters" method="get">
<input type="hidden" name="entreprise" value="{{ entreprise.pk }}">
<label>Recherche<input type="text" name="q" value="{{ filtres.q }}" placeholder="libellé, catégorie…"></label>
<label>Type<select name="type">
<option value="">Tous</option>
<option value="recette" {% if filtres.type == 'recette' %}selected{% endif %}>Recettes</option>
<option value="depense" {% if filtres.type == 'depense' %}selected{% endif %}>Dépenses</option>
<option value="operation" {% if filtres.type == 'operation' %}selected{% endif %}>Opérations</option>
</select></label>
<label>Catégorie<select name="cat">
<option value="">Toutes</option>
{% for c in categories %}<option value="{{ c.pk }}" {% if filtres.cat == c.pk|stringformat:'s' %}selected{% endif %}>{{ c.nom }}</option>{% endfor %}
</select></label>
<label>Régime<select name="regime">
<option value="">Tous</option>
{% for v,l in regimes %}<option value="{{ v }}" {% if filtres.regime == v %}selected{% endif %}>{{ l }}</option>{% endfor %}
</select></label>
<label>Du<input type="date" name="du" value="{{ filtres.du }}"></label>
<label>Au<input type="date" name="au" value="{{ filtres.au }}"></label>
<label>Trier<select name="tri">
<option value="date" {% if filtres.tri == 'date' %}selected{% endif %}>Date</option>
<option value="montant" {% if filtres.tri == 'montant' %}selected{% endif %}>Montant</option>
<option value="libelle" {% if filtres.tri == 'libelle' %}selected{% endif %}>Libellé</option>
</select></label>
<label>Sens<select name="sens">
<option value="desc" {% if filtres.sens == 'desc' %}selected{% endif %}>↓ décroissant</option>
<option value="asc" {% if filtres.sens == 'asc' %}selected{% endif %}>↑ croissant</option>
</select></label>
<button type="submit">Filtrer</button>
<a class="btn-mini" href="/gestion/?entreprise={{ entreprise.pk }}" style="padding:8px 12px;border:1px solid var(--line);border-radius:8px;">Réinitialiser</a>
</form>
{% if rows %}
<table>
<thead><tr>
<th>Date</th><th>Type</th><th>Libellé</th><th>Catégorie</th>
<th class="num">Montant</th><th class="num">TVA</th><th></th>
</tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.date|date:"d/m/Y" }}</td>
<td><span class="tag {{ r.type }}">{{ r.type_label }}</span></td>
<td>{{ r.libelle }}</td>
<td>{{ r.categorie }}{% if r.neutre %}<span class="neutre-badge">neutre</span>{% endif %}</td>
<td class="num {% if r.montant >= 0 %}pos{% else %}neg{% endif %}">{{ r.montant|floatformat:2|unlocalize }} €</td>
<td class="num">{% if r.tva %}{{ r.tva|floatformat:2|unlocalize }}{% else %}—{% endif %}</td>
<td class="num">
<button type="button" class="btn-mini"
data-edit
data-type="{{ r.type }}" data-id="{{ r.id }}"
data-libelle="{{ r.libelle }}"
data-date="{{ r.date|date:'Y-m-d' }}"
data-montant="{{ r.montant|floatformat:2|unlocalize }}"
data-cat="{{ r.categorie_id }}"
data-activite="{{ r.activite }}"
data-regime="{{ r.regime }}"
data-taux="{{ r.taux|unlocalize }}"
data-statut="{{ r.statut }}"
data-sens="{{ r.sens }}"
data-encaissement="{{ r.date_encaissement|date:'Y-m-d' }}"
data-emission="{{ r.date_emission|date:'Y-m-d' }}"
data-editmontant="{{ r.editable_montant|yesno:'1,0' }}"
>Modifier</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">Aucune opération ne correspond à ces filtres.</div>
{% endif %}
{% endif %}
<!-- Modal d'édition -->
<div class="overlay" id="ov">
<div class="modal">
<h3 id="mtitre">Modifier l'opération</h3>
<form method="post" action="/gestion/" id="mform">
{% csrf_token %}
<input type="hidden" name="action" value="edit">
<input type="hidden" name="entreprise" value="{{ entreprise.pk }}">
<input type="hidden" name="type" id="f_type">
<input type="hidden" name="id" id="f_id">
<input type="hidden" name="retour_qs" id="f_retour">
<div class="field"><label>Date</label><input type="date" name="date" id="f_date"></div>
<div class="field"><label>Libellé</label><input type="text" name="libelle" id="f_libelle"></div>
<div class="field" id="g_cat">
<label>Catégorie</label>
<select name="categorie" id="f_cat">
<option value="">(à catégoriser)</option>
{% for c in categories %}<option value="{{ c.pk }}">{{ c.nom }}{% if c.exclure_calculs %} · neutre{% endif %}</option>{% endfor %}
</select>
<div class="explic" id="f_explic">Choisissez une catégorie pour voir son explication.</div>
</div>
<div class="field hidegroup" id="g_activite">
<label>Activité (facultatif)</label>
<select name="activite" id="f_activite">
<option value=""></option>
{% for v,l in activites %}<option value="{{ v }}">{{ l }}</option>{% endfor %}
</select>
</div>
<div class="field hidegroup" id="g_regime">
<label>Régime de TVA</label>
<select name="regime" id="f_regime">
{% for v,l in regimes %}<option value="{{ v }}">{{ l }}</option>{% endfor %}
</select>
</div>
<div class="field hidegroup" id="g_taux">
<label>Taux de TVA</label>
<select name="taux" id="f_taux">
{% for v,l in taux_choices %}<option value="{{ v|unlocalize }}">{{ l }}</option>{% endfor %}
</select>
</div>
<div class="field hidegroup" id="g_sens">
<label>Sens</label>
<select name="sens" id="f_sens">
<option value="debit">Débit (sortie)</option>
<option value="credit">Crédit (entrée)</option>
</select>
</div>
<div class="field hidegroup" id="g_statut">
<label>Statut</label>
<select name="statut" id="f_statut">
{% for v,l in statuts %}<option value="{{ v }}">{{ l }}</option>{% endfor %}
</select>
</div>
<div class="field hidegroup" id="g_encaissement">
<label>Date d'encaissement (compte le CA)</label>
<input type="date" name="date_encaissement" id="f_encaissement">
</div>
<div class="field"><label id="lbl_montant">Montant (€)</label>
<input type="number" step="0.01" name="montant" id="f_montant"></div>
<div class="explic hidegroup" id="note_montant">Le montant est géré par les lignes de facture — modifiez-le dans l'admin.</div>
<div class="actions">
<button type="button" onclick="supprimer()" style="border-color:#7a2f2f;color:var(--bad);">Supprimer</button>
<div>
<button type="button" onclick="fermer()" style="margin-right:6px;">Annuler</button>
<button type="submit" style="background:var(--accent);color:#04263a;border-color:var(--accent);font-weight:600;">Enregistrer</button>
</div>
</div>
</form>
<form method="post" action="/gestion/" id="delform" style="display:none;">
{% csrf_token %}
<input type="hidden" name="action" value="delete">
<input type="hidden" name="entreprise" value="{{ entreprise.pk }}">
<input type="hidden" name="type" id="d_type">
<input type="hidden" name="id" id="d_id">
<input type="hidden" name="retour_qs" id="d_retour">
</form>
</div>
</div>
<script>
const CATS = {{ cats_json|safe }};
const $ = id => document.getElementById(id);
function retourQs(){
const p = new URLSearchParams(window.location.search);
p.delete('entreprise');
return p.toString();
}
function show(id, on){ $(id).classList.toggle('hidegroup', !on); }
function majExplic(){
const id = $('f_cat').value;
const c = CATS[id];
const box = $('f_explic');
if(!c){ box.innerHTML = 'Non catégorisé : le calcul fiscal peut être incomplet.'; return; }
const badge = c.neutre ? ' <b>(neutre)</b>' : '';
const compte = c.compte ? ` · compte ${c.compte}` : '';
box.innerHTML = '<b>'+c.nom+'</b>'+badge+compte+'<br>'+c.explication;
}
function ouvrir(btn){
const d = btn.dataset;
const t = d.type;
$('f_type').value = t; $('f_id').value = d.id;
$('d_type').value = t; $('d_id').value = d.id;
const qs = retourQs();
$('f_retour').value = qs; $('d_retour').value = qs;
$('f_date').value = d.date || '';
$('f_libelle').value = d.libelle || '';
$('f_montant').value = Math.abs(parseFloat(d.montant||'0')).toFixed(2);
$('f_cat').value = d.cat || '';
$('f_activite').value = d.activite || '';
$('f_regime').value = d.regime || 'nationale';
$('f_taux').value = (parseFloat(d.taux||'0')||0).toString();
$('f_sens').value = d.sens || 'debit';
$('f_statut').value = d.statut || '';
$('f_encaissement').value = d.encaissement || '';
// Champs par type
const dep = t === 'depense', ope = t === 'operation', rec = t === 'recette';
show('g_cat', dep || ope);
show('g_activite', dep);
show('g_regime', dep);
show('g_taux', dep || rec);
show('g_sens', ope);
show('g_statut', rec);
show('g_encaissement', rec);
const editMontant = d.editmontant === '1';
$('f_montant').readOnly = !editMontant;
show('note_montant', !editMontant);
$('lbl_montant').textContent = dep ? 'Montant payé — TTC (€)' : 'Montant (€)';
$('mtitre').textContent = 'Modifier — ' + (rec?'recette':dep?'dépense':'opération bancaire');
majExplic();
$('ov').classList.add('on');
}
function fermer(){ $('ov').classList.remove('on'); }
function supprimer(){
if(confirm('Supprimer définitivement cette opération ?')) $('delform').submit();
}
document.addEventListener('click', e => {
const b = e.target.closest('[data-edit]');
if(b) ouvrir(b);
});
$('f_cat').addEventListener('change', majExplic);
$('ov').addEventListener('click', e => { if(e.target === $('ov')) fermer(); });
</script>
</body>
</html>
+2
View File
@@ -3,9 +3,11 @@ from . import views
from . import views_saisie
from . import views_import
from . import views_bookmarklet
from . import views_gestion
urlpatterns = [
path("dashboard/", views.dashboard, name="dashboard"),
path("gestion/", views_gestion.gestion, name="gestion"),
path("dashboard/mensuel/", views.vue_mensuelle, name="vue_mensuelle"),
path("declaration/urssaf/", views.declaration_urssaf, name="declaration_urssaf"),
path("declaration/tva/", views.declaration_tva, name="declaration_tva"),
+267
View File
@@ -0,0 +1,267 @@
"""
Page de gestion des entrées / sorties : vue unifiée de TOUTES les opérations
importées (recettes = factures, dépenses, opérations bancaires neutres).
- Filtrer (type, catégorie, régime, texte, dates), ordonner (date, montant, libellé).
- Modifier via une popup (avec explications sur les catégories) : la TVA est
recalculée automatiquement.
- Supprimer.
Tout est cloisonné par entreprise (Entreprise.accessibles).
"""
from decimal import Decimal, InvalidOperation
from datetime import datetime
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render
from django.urls import reverse
from .models import (Entreprise, Facture, LigneFacture, Depense, Paiement,
Categorie, Activite, RegimeTva, StatutFacture, TAUX_TVA_CHOICES)
CENT = Decimal("0.01")
# Explications spécifiques (affichées dans la popup). Sinon, texte générique par usage.
EXPLIC = {
"vente": "Recette imposable : entre dans le chiffre d'affaires (CA) et dans "
"l'assiette des cotisations URSSAF. La TVA collectée est due.",
"depense": "Charge professionnelle : ne réduit PAS le CA micro, mais la TVA "
"payée est récupérable (TVA déductible) si vous êtes assujetti.",
"neutre": "Mouvement neutre : n'entre ni dans le CA, ni dans les cotisations, "
"ni dans la TVA. Pur mouvement de trésorerie (apport, prélèvement, "
"impôt, virement interne, remboursement…).",
}
EXPLIC_SPE = {
"Apport personnel": "Argent que vous injectez depuis vos fonds perso. Ce n'est "
"pas une recette : aucun impact fiscal.",
"Prélèvement personnel": "Argent que vous sortez pour vous. En micro, la "
"rémunération n'est pas une charge : mouvement neutre.",
"Remboursement reçu": "Remboursement d'un tiers (trop-perçu, avoir…). Ce n'est "
"pas un revenu d'activité : neutre.",
"Cotisation sociale Urssaf": "Vos cotisations URSSAF. Déjà prises en compte via "
"le taux URSSAF : ne pas les recompter en charge.",
"TVA payée": "Reversement de TVA à l'État. Neutre : c'est de la trésorerie, "
"pas une charge.",
"Remboursement de TVA": "Crédit de TVA remboursé par l'État. Neutre.",
"Virement interne": "Transfert entre vos propres comptes. Aucun impact.",
"CFE": "Cotisation Foncière des Entreprises. Impôt : suivi mais neutre côté TVA/CA.",
}
def _explication(cat):
if cat is None:
return ("Non catégorisé : pensez à choisir une catégorie pour un calcul "
"fiscal correct.")
return EXPLIC_SPE.get(cat.nom, EXPLIC.get(cat.usage, EXPLIC["depense"]))
def _entreprise(request, pk=None):
acc = Entreprise.accessibles(request.user)
pk = pk or request.GET.get("entreprise") or request.POST.get("entreprise")
if pk:
return acc.filter(pk=pk).first()
return acc.first()
def _dec(v, d="0"):
try:
return Decimal(str(v).replace(",", ".").strip() or d)
except (InvalidOperation, AttributeError):
return Decimal(d)
def _date(s):
for fmt in ("%Y-%m-%d", "%d/%m/%Y"):
try:
return datetime.strptime((s or "").strip(), fmt).date()
except ValueError:
continue
return None
def _rows(entreprise):
"""Liste unifiée des opérations de l'entreprise (recettes, dépenses, neutres)."""
rows = []
for f in (Facture.objects.filter(entreprise=entreprise)
.prefetch_related("lignes").select_related("client")):
une_ligne = f.lignes.count() == 1
rows.append({
"type": "recette", "type_label": "Recette", "id": f.pk,
"date": f.date_encaissement or f.date_emission,
"libelle": str(f.client), "categorie": f"Facture {f.numero}",
"categorie_id": "", "activite": f.activite,
"regime": "nationale", "taux": f.lignes.first().taux_tva if une_ligne else "",
"montant": f.montant_ttc, "tva": f.montant_tva, "neutre": False,
"signe": Decimal("1"), "statut": f.statut,
"date_encaissement": f.date_encaissement, "date_emission": f.date_emission,
"editable_montant": une_ligne,
})
for d in Depense.objects.filter(entreprise=entreprise).select_related("categorie"):
rows.append({
"type": "depense", "type_label": "Dépense", "id": d.pk,
"date": d.date, "libelle": d.libelle,
"categorie": d.categorie.nom if d.categorie else "(à catégoriser)",
"categorie_id": d.categorie_id or "", "activite": d.activite,
"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,
})
for p in Paiement.objects.filter(entreprise=entreprise).select_related("categorie"):
rows.append({
"type": "operation", "type_label": "Opération", "id": p.pk,
"date": p.date, "libelle": p.libelle,
"categorie": p.categorie.nom if p.categorie else "(non catégorisée)",
"categorie_id": p.categorie_id or "", "activite": "",
"regime": "", "taux": "",
"montant": p.montant_total if p.sens == "credit" else -p.montant_total,
"tva": Decimal("0"),
"neutre": bool(p.categorie and p.categorie.exclure_calculs),
"signe": Decimal("1") if p.sens == "credit" else Decimal("-1"),
"sens": p.sens, "editable_montant": True,
})
return rows
def _filtrer_trier(rows, request):
q = (request.GET.get("q") or "").strip().lower()
ftype = request.GET.get("type") or ""
fcat = request.GET.get("cat") or ""
fregime = request.GET.get("regime") or ""
dfrom = _date(request.GET.get("du"))
dto = _date(request.GET.get("au"))
tri = request.GET.get("tri") or "date"
sens = request.GET.get("sens") or "desc"
def garde(r):
if ftype and r["type"] != ftype:
return False
if fcat and str(r["categorie_id"]) != fcat:
return False
if fregime and r["regime"] != fregime:
return False
if q and q not in (r["libelle"] + " " + r["categorie"]).lower():
return False
if dfrom and r["date"] and r["date"] < dfrom:
return False
if dto and r["date"] and r["date"] > dto:
return False
return True
rows = [r for r in rows if garde(r)]
cles = {"date": lambda r: (r["date"] or datetime(1900, 1, 1).date()),
"montant": lambda r: r["montant"],
"libelle": lambda r: r["libelle"].lower()}
rows.sort(key=cles.get(tri, cles["date"]), reverse=(sens == "desc"))
return rows, {"q": q, "type": ftype, "cat": fcat, "regime": fregime,
"du": request.GET.get("du") or "", "au": request.GET.get("au") or "",
"tri": tri, "sens": sens}
@login_required
def gestion(request):
if request.method == "POST":
return _traiter_post(request)
entreprise = _entreprise(request)
if entreprise is None:
return render(request, "compta/gestion.html", {"entreprise": None})
rows, filtres = _filtrer_trier(_rows(entreprise), request)
total = sum((r["montant"] for r in rows), Decimal("0"))
entrees = sum((r["montant"] for r in rows if r["montant"] > 0), Decimal("0"))
sorties = sum((r["montant"] for r in rows if r["montant"] < 0), Decimal("0"))
cats = list(Categorie.objects.filter(actif=True))
cats_json = {str(c.pk): {"nom": c.nom, "usage": c.usage,
"neutre": c.exclure_calculs, "compte": c.compte_pcg,
"explication": _explication(c)} for c in cats}
ctx = {
"entreprise": entreprise,
"entreprises": Entreprise.accessibles(request.user),
"rows": rows, "filtres": filtres,
"total": total, "entrees": entrees, "sorties": sorties, "nb": len(rows),
"categories": cats, "cats_json": cats_json,
"activites": Activite.choices, "regimes": RegimeTva.choices,
"statuts": StatutFacture.choices, "taux_choices": TAUX_TVA_CHOICES,
"vue": "gestion",
}
return render(request, "compta/gestion.html", ctx)
def _retour(request, entreprise):
base = reverse("gestion")
qs = request.POST.get("retour_qs", "")
url = f"{base}?entreprise={entreprise.pk}"
if qs:
url += "&" + qs
return redirect(url)
def _traiter_post(request):
entreprise = _entreprise(request)
if entreprise is None:
return redirect(reverse("gestion"))
action = request.POST.get("action")
typ = request.POST.get("type")
oid = request.POST.get("id")
modeles = {"recette": Facture, "depense": Depense, "operation": Paiement}
Modele = modeles.get(typ)
if not Modele:
return _retour(request, entreprise)
obj = Modele.objects.filter(entreprise=entreprise, pk=oid).first()
if not obj:
messages.error(request, "Opération introuvable.")
return _retour(request, entreprise)
if action == "delete":
obj.delete()
messages.success(request, "Opération supprimée.")
return _retour(request, entreprise)
# action == "edit"
dt = _date(request.POST.get("date"))
libelle = (request.POST.get("libelle") or "").strip()
montant = _dec(request.POST.get("montant"))
cat_id = request.POST.get("categorie") or ""
cat = Categorie.objects.filter(pk=cat_id).first() if cat_id else None
if typ == "depense":
if dt:
obj.date = dt
obj.libelle = libelle or obj.libelle
obj.categorie = cat
obj.activite = request.POST.get("activite") or ""
obj.regime_tva = request.POST.get("regime") or RegimeTva.NATIONALE
obj.taux_tva = _dec(request.POST.get("taux"), "0")
obj.montant_ttc = abs(montant)
obj.save() # recalcule la TVA
elif typ == "operation":
if dt:
obj.date = dt
obj.libelle = libelle or obj.libelle
obj.categorie = cat
obj.sens = request.POST.get("sens") or obj.sens
obj.montant_total = abs(montant)
obj.save()
elif typ == "recette":
de = _date(request.POST.get("date_emission")) or _date(request.POST.get("date"))
if de:
obj.date_emission = de
obj.date_encaissement = _date(request.POST.get("date_encaissement"))
obj.statut = request.POST.get("statut") or obj.statut
ligne = obj.lignes.first()
if ligne and obj.lignes.count() == 1:
taux = _dec(request.POST.get("taux"), str(ligne.taux_tva))
ttc = abs(montant) if montant else obj.montant_ttc
ligne.prix_unitaire_ht = (ttc / (Decimal("1") + taux / Decimal("100"))).quantize(CENT)
ligne.taux_tva = taux
ligne.quantite = Decimal("1")
ligne.save()
obj.save()
obj.recompute_totaux()
messages.success(request, "Opération modifiée.")
return _retour(request, entreprise)
+11
View File
@@ -23,6 +23,17 @@ CSRF_TRUSTED_ORIGINS = [
o for o in os.getenv("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if o
]
# Derrière un reverse-proxy (Traefik, Nginx…) qui termine le TLS.
# Active-le avec DJANGO_BEHIND_PROXY=1 pour que Django reconnaisse le HTTPS
# transmis par le proxy (en-tête X-Forwarded-Proto) et sécurise les cookies.
if env_bool("DJANGO_BEHIND_PROXY", False):
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Redirige HTTP -> HTTPS seulement si demandé (laisser 0 si Traefik le fait déjà).
SECURE_SSL_REDIRECT = env_bool("DJANGO_SSL_REDIRECT", False)
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
+2 -1
View File
@@ -20,4 +20,5 @@ if [ "$SEED_DEMO" = "1" ]; then
fi
echo "→ Démarrage de Gunicorn sur :8000"
exec gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 60
exec gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 60 \
--forwarded-allow-ips="*" --proxy-allow-from="*"