diff --git a/compta/admin.py b/compta/admin.py index b0d724a..24cb94c 100644 --- a/compta/admin.py +++ b/compta/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin from django.utils import timezone from .models import ( - Bareme, Entreprise, Client, Facture, LigneFacture, Depense, + Bareme, Entreprise, Client, Facture, LigneFacture, Depense, Paiement, ) @@ -55,10 +55,39 @@ class FactureAdmin(admin.ModelAdmin): form.instance.recompute_totaux() +class DepenseInline(admin.TabularInline): + model = Depense + extra = 0 + fields = ("date", "libelle", "activite", "regime_tva", "taux_tva", + "montant_ttc", "montant_tva") + + +@admin.register(Paiement) +class PaiementAdmin(admin.ModelAdmin): + list_display = ("date", "entreprise", "libelle", "sens", "montant_total", + "reparti", "reste", "equilibre") + list_filter = ("entreprise", "sens", "date") + search_fields = ("libelle",) + date_hierarchy = "date" + inlines = [DepenseInline] + + @admin.display(description="Réparti") + def reparti(self, obj): + return obj.reparti + + @admin.display(description="Reste") + def reste(self, obj): + return obj.reste + + @admin.display(boolean=True, description="Équilibré") + def equilibre(self, obj): + return obj.equilibre + + @admin.register(Depense) class DepenseAdmin(admin.ModelAdmin): list_display = ("date", "entreprise", "libelle", "activite", "regime_tva", - "montant_ttc", "montant_tva", "tva_autoliq") + "montant_ttc", "montant_tva", "tva_autoliq", "paiement") list_filter = ("entreprise", "regime_tva", "activite", "categorie", "date") search_fields = ("libelle", "categorie") date_hierarchy = "date" diff --git a/compta/migrations/0004_paiement_depense_paiement.py b/compta/migrations/0004_paiement_depense_paiement.py new file mode 100644 index 0000000..73ebe32 --- /dev/null +++ b/compta/migrations/0004_paiement_depense_paiement.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.15 on 2026-06-29 22:16 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('compta', '0003_depense_regime_tva_depense_taux_tva_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Paiement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('libelle', models.CharField(max_length=255, verbose_name='Libellé bancaire')), + ('montant_total', models.DecimalField(decimal_places=2, max_digits=12)), + ('sens', models.CharField(choices=[('debit', 'Débit (achat)'), ('credit', 'Crédit (encaissement)')], default='debit', max_length=6)), + ('cree_le', models.DateTimeField(auto_now_add=True)), + ('entreprise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='paiements', to='compta.entreprise')), + ], + options={ + 'verbose_name': 'Paiement', + 'verbose_name_plural': 'Paiements', + 'ordering': ['-date'], + }, + ), + migrations.AddField( + model_name='depense', + name='paiement', + field=models.ForeignKey(blank=True, help_text='Opération bancaire dont cette dépense fait partie (achat groupé éclaté).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='depenses', to='compta.paiement'), + ), + ] diff --git a/compta/models.py b/compta/models.py index 109f9c8..0ce78df 100644 --- a/compta/models.py +++ b/compta/models.py @@ -180,9 +180,47 @@ class LigneFacture(models.Model): return (self.quantite * self.prix_unitaire_ht).quantize(Decimal("0.01")) +class Paiement(models.Model): + """Opération bancaire réelle, à éclater en une ou plusieurs dépenses + (ex. un débit Amazon regroupant plusieurs vendeurs/factures).""" + SENS = [("debit", "Débit (achat)"), ("credit", "Crédit (encaissement)")] + entreprise = models.ForeignKey(Entreprise, on_delete=models.CASCADE, + related_name="paiements") + date = models.DateField() + libelle = models.CharField("Libellé bancaire", max_length=255) + montant_total = models.DecimalField(max_digits=12, decimal_places=2) + sens = models.CharField(max_length=6, choices=SENS, default="debit") + cree_le = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Paiement" + verbose_name_plural = "Paiements" + ordering = ["-date"] + + def __str__(self): + return f"{self.date} — {self.libelle} ({self.montant_total} €)" + + @property + def reparti(self): + total = self.depenses.aggregate(s=Sum("montant_ttc"))["s"] or Decimal("0") + return total.quantize(Decimal("0.01")) + + @property + def reste(self): + return (self.montant_total - self.reparti).quantize(Decimal("0.01")) + + @property + def equilibre(self): + return abs(self.reste) < Decimal("0.01") + + class Depense(models.Model): entreprise = models.ForeignKey(Entreprise, on_delete=models.CASCADE, related_name="depenses") + paiement = models.ForeignKey(Paiement, on_delete=models.SET_NULL, + null=True, blank=True, related_name="depenses", + help_text="Opération bancaire dont cette dépense " + "fait partie (achat groupé éclaté).") date = models.DateField() activite = models.CharField( max_length=20, choices=Activite.choices, blank=True, diff --git a/compta/templates/compta/dashboard.html b/compta/templates/compta/dashboard.html index fa0ef85..6694271 100644 --- a/compta/templates/compta/dashboard.html +++ b/compta/templates/compta/dashboard.html @@ -62,7 +62,8 @@ {% endfor %} - + + {% if bareme_manquant %} diff --git a/compta/templates/compta/saisie_paiement.html b/compta/templates/compta/saisie_paiement.html new file mode 100644 index 0000000..881778e --- /dev/null +++ b/compta/templates/compta/saisie_paiement.html @@ -0,0 +1,152 @@ + + + + + +JB Compta — Saisie d'un paiement + + + +

Saisie d'un paiement

+
Éclate une opération bancaire (ex. un débit Amazon groupé) en + plusieurs dépenses. · ← Tableau de bord · + Voir les dépenses
+ + {% if messages %} + {% for m in messages %} +
{{ m }}
+ {% endfor %} + {% endif %} + +
+ {% csrf_token %} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + + + + + + + + + + + + + +
Libellé / vendeurCatégorieActivitéRégime TVATaux %Montant €TVA déduct. €
+ +
+ +
+ +
+ Réparti : 0.00 + Reste : 0.00 +
+
+ +
+ +
+
+ + + + + + diff --git a/compta/urls.py b/compta/urls.py index 8dfad0e..7353247 100644 --- a/compta/urls.py +++ b/compta/urls.py @@ -1,8 +1,10 @@ from django.urls import path from . import views +from . import views_saisie urlpatterns = [ path("dashboard/", views.dashboard, name="dashboard"), + path("saisie/", views_saisie.saisie_paiement, name="saisie_paiement"), path("export/ventes/", views.export_ventes, name="export_ventes"), path("export/tva/", views.export_tva, name="export_tva"), path("export/urssaf/", views.export_urssaf, name="export_urssaf"), diff --git a/compta/views_saisie.py b/compta/views_saisie.py new file mode 100644 index 0000000..45136ab --- /dev/null +++ b/compta/views_saisie.py @@ -0,0 +1,99 @@ +""" +Page de saisie / import : éclater un paiement bancaire en plusieurs dépenses. +""" +from decimal import Decimal, InvalidOperation +from datetime import date + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect + +from .models import Entreprise, Paiement, Depense, Activite, RegimeTva + + +def _dec(val, defaut="0"): + try: + return Decimal((val or defaut).replace(",", ".").strip() or defaut) + except (InvalidOperation, AttributeError): + return Decimal(defaut) + + +@login_required +def saisie_paiement(request): + entreprises = Entreprise.objects.all() + + if request.method == "POST": + entreprise = entreprises.filter(pk=request.POST.get("entreprise")).first() + d = request.POST.get("date") or date.today().isoformat() + libelle = (request.POST.get("libelle") or "").strip() + montant_total = _dec(request.POST.get("montant_total")) + + libelles = request.POST.getlist("l_libelle") + categories = request.POST.getlist("l_categorie") + activites = request.POST.getlist("l_activite") + regimes = request.POST.getlist("l_regime") + taux = request.POST.getlist("l_taux") + montants = request.POST.getlist("l_montant") + tvas = request.POST.getlist("l_tva") + + lignes = [] + for i in range(len(libelles)): + m = _dec(montants[i] if i < len(montants) else "0") + lib = (libelles[i] or "").strip() + if m == 0 and not lib: + continue # ligne vide ignorée + lignes.append({ + "libelle": lib or "(sans libellé)", + "categorie": categories[i] if i < len(categories) else "", + "activite": activites[i] if i < len(activites) else "", + "regime": regimes[i] if i < len(regimes) else RegimeTva.NATIONALE, + "taux": _dec(taux[i] if i < len(taux) else "0"), + "montant": m, + "tva": _dec(tvas[i] if i < len(tvas) else "0"), + }) + + somme = sum((l["montant"] for l in lignes), Decimal("0")) + erreurs = [] + if not entreprise: + erreurs.append("Sélectionne une entreprise.") + if not libelle: + erreurs.append("Renseigne le libellé du paiement.") + if not lignes: + erreurs.append("Ajoute au moins une ligne de dépense.") + if lignes and abs(somme - montant_total) >= Decimal("0.01"): + erreurs.append( + f"La somme des lignes ({somme} €) ne correspond pas au " + f"montant total ({montant_total} €). Écart : {somme - montant_total} €.") + + if erreurs: + for e in erreurs: + messages.error(request, e) + return render(request, "compta/saisie_paiement.html", + _context(entreprises, request.POST)) + + paiement = Paiement.objects.create( + entreprise=entreprise, date=d, libelle=libelle, + montant_total=montant_total, sens="debit") + for l in lignes: + Depense.objects.create( + entreprise=entreprise, paiement=paiement, date=d, + libelle=l["libelle"], categorie=l["categorie"], + activite=l["activite"] or "", regime_tva=l["regime"], + taux_tva=l["taux"], montant_ttc=l["montant"], montant_tva=l["tva"]) + + messages.success( + request, + f"Paiement « {libelle} » enregistré et éclaté en {len(lignes)} dépense(s).") + return redirect("saisie_paiement") + + return render(request, "compta/saisie_paiement.html", _context(entreprises)) + + +def _context(entreprises, post=None): + return { + "entreprises": entreprises, + "activites": Activite.choices, + "regimes": RegimeTva.choices, + "today": date.today().isoformat(), + "post": post, + }