diff --git a/.env.example b/.env.example index 015cf76..cf86b35 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/compta/__pycache__/__init__.cpython-310.pyc b/compta/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..682e4f3 Binary files /dev/null and b/compta/__pycache__/__init__.cpython-310.pyc differ diff --git a/compta/__pycache__/admin.cpython-310.pyc b/compta/__pycache__/admin.cpython-310.pyc new file mode 100644 index 0000000..be0ecd7 Binary files /dev/null and b/compta/__pycache__/admin.cpython-310.pyc differ diff --git a/compta/__pycache__/apps.cpython-310.pyc b/compta/__pycache__/apps.cpython-310.pyc new file mode 100644 index 0000000..ba6425b Binary files /dev/null and b/compta/__pycache__/apps.cpython-310.pyc differ diff --git a/compta/__pycache__/models.cpython-310.pyc b/compta/__pycache__/models.cpython-310.pyc new file mode 100644 index 0000000..c592ad5 Binary files /dev/null and b/compta/__pycache__/models.cpython-310.pyc differ diff --git a/compta/admin.py b/compta/admin.py index d62025c..d8b21f4 100644 --- a/compta/admin.py +++ b/compta/admin.py @@ -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) diff --git a/compta/categories_indy.py b/compta/categories_indy.py new file mode 100644 index 0000000..0c18457 --- /dev/null +++ b/compta/categories_indy.py @@ -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", +} diff --git a/compta/indy.py b/compta/indy.py index 44498f7..c78a291 100644 --- a/compta/indy.py +++ b/compta/indy.py @@ -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}" diff --git a/compta/management/commands/seed_reference.py b/compta/management/commands/seed_reference.py index c5f6d1f..46747b6 100644 --- a/compta/management/commands/seed_reference.py +++ b/compta/management/commands/seed_reference.py @@ -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) diff --git a/compta/migrations/0010_categorie_compte_pcg.py b/compta/migrations/0010_categorie_compte_pcg.py new file mode 100644 index 0000000..1b06cd9 --- /dev/null +++ b/compta/migrations/0010_categorie_compte_pcg.py @@ -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)'), + ), + ] diff --git a/compta/models.py b/compta/models.py index f93bae0..3ba769e 100644 --- a/compta/models.py +++ b/compta/models.py @@ -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) diff --git a/compta/templates/compta/base.html b/compta/templates/compta/base.html index 6e4451a..66e1552 100644 --- a/compta/templates/compta/base.html +++ b/compta/templates/compta/base.html @@ -53,6 +53,7 @@ Mensuel Déclaration URSSAF Déclaration TVA + Entrées / sorties + Saisir Importer {% if user.is_staff %}Admin{% endif %} diff --git a/compta/templates/compta/gestion.html b/compta/templates/compta/gestion.html new file mode 100644 index 0000000..61f836a --- /dev/null +++ b/compta/templates/compta/gestion.html @@ -0,0 +1,323 @@ +{% load l10n %} + + + + +JB Compta — Entrées / sorties + + + + +{% if not entreprise %} +

Aucune entreprise

+

Crée une entreprise dans l'administration.

+{% else %} + + +

Entrées / sorties

+
{{ entreprise.nom }} — vue unifiée de toutes les opérations importées
+ + {% for m in messages %}
{{ m }}
{% endfor %} + +
+
Opérations
{{ nb }}
+
Entrées
{{ entrees|floatformat:2|unlocalize }} €
+
Sorties
{{ sorties|floatformat:2|unlocalize }} €
+
Solde
{{ total|floatformat:2|unlocalize }} €
+
+ +
+ + + + + + + + + + + Réinitialiser +
+ + {% if rows %} + + + + + + + {% for r in rows %} + + + + + + + + + + {% endfor %} + +
DateTypeLibelléCatégorieMontantTVA
{{ r.date|date:"d/m/Y" }}{{ r.type_label }}{{ r.libelle }}{{ r.categorie }}{% if r.neutre %}neutre{% endif %}{{ r.montant|floatformat:2|unlocalize }} €{% if r.tva %}{{ r.tva|floatformat:2|unlocalize }}{% else %}—{% endif %} + +
+ {% else %} +
Aucune opération ne correspond à ces filtres.
+ {% endif %} +{% endif %} + + +
+ +
+ + + + diff --git a/compta/urls.py b/compta/urls.py index 152c844..5ca3508 100644 --- a/compta/urls.py +++ b/compta/urls.py @@ -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"), diff --git a/compta/views_gestion.py b/compta/views_gestion.py new file mode 100644 index 0000000..0b5d053 --- /dev/null +++ b/compta/views_gestion.py @@ -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) diff --git a/config/__pycache__/__init__.cpython-310.pyc b/config/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..7b12e9c Binary files /dev/null and b/config/__pycache__/__init__.cpython-310.pyc differ diff --git a/config/__pycache__/settings.cpython-310.pyc b/config/__pycache__/settings.cpython-310.pyc new file mode 100644 index 0000000..fea28db Binary files /dev/null and b/config/__pycache__/settings.cpython-310.pyc differ diff --git a/config/settings.py b/config/settings.py index 1efdacd..48a5673 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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", diff --git a/entrypoint.sh b/entrypoint.sh index 6ad933e..5fed5c7 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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="*"