From 1716cebfbd91d8c8545100c10f692f559d87019a Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 29 Jun 2026 23:56:40 +0200 Subject: [PATCH] =?UTF-8?q?JB=20Compta=20v1.1=20-=20multi-activit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 9 + .env.example | 23 ++ .gitattributes | 8 + .gitignore | 14 ++ Dockerfile | 19 ++ PLAN.md | 76 +++++++ README.md | 74 +++++++ build.sh | 8 + compta/__init__.py | 0 compta/admin.py | 65 ++++++ compta/apps.py | 7 + compta/management/__init__.py | 0 compta/management/commands/__init__.py | 0 compta/management/commands/seed_demo.py | 93 ++++++++ compta/migrations/0001_initial.py | 130 ++++++++++++ ...ense_activite_alter_entreprise_activite.py | 23 ++ compta/migrations/__init__.py | 0 compta/models.py | 199 ++++++++++++++++++ compta/services.py | 154 ++++++++++++++ compta/templates/compta/dashboard.html | 169 +++++++++++++++ compta/urls.py | 9 + compta/views.py | 89 ++++++++ config/__init__.py | 0 config/asgi.py | 5 + config/settings.py | 110 ++++++++++ config/urls.py | 13 ++ config/wsgi.py | 5 + docker-compose.yml | 34 +++ docs/deploiement.md | 83 ++++++++ docs/modele-donnees.md | 39 ++++ docs/regles-fiscales-2026.md | 65 ++++++ entrypoint.sh | 20 ++ manage.py | 20 ++ requirements.txt | 5 + 34 files changed, 1568 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 build.sh create mode 100644 compta/__init__.py create mode 100644 compta/admin.py create mode 100644 compta/apps.py create mode 100644 compta/management/__init__.py create mode 100644 compta/management/commands/__init__.py create mode 100644 compta/management/commands/seed_demo.py create mode 100644 compta/migrations/0001_initial.py create mode 100644 compta/migrations/0002_depense_activite_alter_entreprise_activite.py create mode 100644 compta/migrations/__init__.py create mode 100644 compta/models.py create mode 100644 compta/services.py create mode 100644 compta/templates/compta/dashboard.html create mode 100644 compta/urls.py create mode 100644 compta/views.py create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/settings.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 docker-compose.yml create mode 100644 docs/deploiement.md create mode 100644 docs/modele-donnees.md create mode 100644 docs/regles-fiscales-2026.md create mode 100644 entrypoint.sh create mode 100644 manage.py create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4777dfe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +.env +*.sqlite3 +__pycache__/ +*.pyc +staticfiles/ +docs/ +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..015cf76 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Copie ce fichier en .env et renseigne tes valeurs. +# En production (Portainer), saisis plutôt ces variables dans l'interface. + +# 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 + +# PostgreSQL EXISTANT (ne pas héberger de base ici) +DB_NAME=compta +DB_USER=jb +DB_PASSWORD=ton_mot_de_passe +DB_HOST=192.168.1.40 +DB_PORT=5432 + +# Compte admin créé au 1er démarrage +DJANGO_SUPERUSER_USERNAME=admin +DJANGO_SUPERUSER_PASSWORD=change-moi +DJANGO_SUPERUSER_EMAIL=admin@example.fr + +# Données d'exemple au 1er démarrage (1 = oui, 0 = non) +SEED_DEMO=0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e5dd026 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Forcer les fins de ligne Unix (LF) — indispensable pour les scripts shell +# exécutés dans le conteneur Linux, même si on pousse depuis Windows. +* text=auto eol=lf +*.sh text eol=lf +*.py text eol=lf +Dockerfile text eol=lf +entrypoint.sh text eol=lf +build.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..564de1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Secrets & env +.env +*.env.local + +# Postgres dumps +*.dump +*.bak + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bdff38d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# psycopg2-binary embarque sa propre libpq : pas besoin de build-essential ni libpq-dev. +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Fichiers statiques de l'admin (ne nécessite pas la base de données) +RUN DJANGO_SECRET_KEY=build python manage.py collectstatic --noinput + +EXPOSE 8000 +RUN chmod +x /app/entrypoint.sh +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..ab1adff --- /dev/null +++ b/PLAN.md @@ -0,0 +1,76 @@ +# Plan du projet — Compta micro-entreprise (Appsmith) + +## Contexte + +- Statut actuel : **micro-entreprise**, option **versement forfaitaire libératoire**. +- **Assujetti à la TVA** : déclaration et paiement **mensuels** (donc hors franchise en base). +- Trois flux payés chaque mois : **cotisations URSSAF**, **versement libératoire (impôt)**, **TVA**. +- Outil existant : **Indy** (devis + factures + suivi bancaire) — conservé. +- Limites d'Indy gratuit : pas de gestion comptable, **pas d'export CSV** des données. + +## Objectif + +Construire dans **Appsmith + PostgreSQL** une couche de gestion financière qui complète Indy : +saisie des ventes/dépenses, calculs TVA/URSSAF/impôt, statistiques et exports CSV. +Système **paramétrable** car le statut pourra évoluer. + +## Périmètre + +| Reste sur Indy | Géré dans Appsmith | +|----------------|--------------------| +| Devis | Saisie des ventes encaissées | +| Factures (format Indy apprécié) | Saisie des dépenses | +| Suivi bancaire | TVA collectée / déductible / à reverser | +| | Cotisations URSSAF | +| | Versement libératoire (impôt) | +| | Statistiques + suivi des seuils | +| | Exports CSV | + +## Décisions actées + +- **Indy conservé** pour devis et factures (complément, pas remplacement). +- **Saisie manuelle** via formulaires (Indy n'exporte pas). +- **Interface = Python / Django** (admin auto pour la saisie, logique fiscale en Python, + multi-utilisateurs gratuit) — au lieu d'Appsmith. +- **Déploiement = Docker + Portainer**, base PostgreSQL externe (conteneur sans état). +- **TVA réelle**, suivi mensuel ; base de calcul URSSAF/impôt = **CA hors taxes (HT)**. +- **Modèle paramétrable** : taux et seuils dans le modèle `Bareme` par activité/année ; + aucun taux codé en dur. + +## Modules + +1. **Ventes** — formulaire (date émission, client, montant HT, taux TVA, date encaissement, statut). CA compté à l'encaissement. +2. **Dépenses** — formulaire (date, catégorie, montant TTC, TVA déductible). +3. **TVA** — vue mensuelle : collectée − déductible = à reverser ; export. +4. **URSSAF** — CA encaissé × taux social (selon activité). +5. **Impôt (versement libératoire)** — CA × taux (1 % / 1,7 % / 2,2 %). +6. **Tableau de bord** — CA mensuel/annuel, % des plafonds, alertes seuils, top clients, trésorerie. + +## Architecture technique + +``` +PostgreSQL externe (données) ── App Django/Gunicorn (admin + dashboard + exports) ── Image Docker déployée via Portainer +``` + +Cœur durable = le modèle de données + la logique fiscale en Python ; l'interface est remplaçable. + +## Évolutivité du statut + +Tout est rattaché à une table `entreprise` portant ses propres paramètres (activité, régime TVA, option fiscale). +Un changement de statut (EI au réel, société) réutilise les tables ventes/dépenses/clients et n'ajoute que de nouvelles règles de calcul. Multi-entreprises prévu pour gérer d'autres personnes plus tard. + +## Phasage + +- **v1 — Cœur compta** : Ventes + Dépenses + calculs TVA/URSSAF/impôt + exports CSV. +- **v2 — Pilotage** : tableau de bord, statistiques, alertes de seuils. +- **v3 — Extension** : multi-entreprises et adaptation à un éventuel nouveau statut. + +## Points à confirmer plus tard + +- Régime de TVA précis (réel normal mensuel ou réel simplifié). +- Activité fiscale exacte (BIC vente / BIC service / BNC) → fixe les bons taux. +- Direction probable d'évolution du statut. + +## Prochaine étape technique + +Adapter le jeu de données d'exemple à la TVA réelle, puis construire les formulaires Ventes et Dépenses dans Appsmith (v1). diff --git a/README.md b/README.md new file mode 100644 index 0000000..1be9749 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# JB — Compta Micro-Entreprise + +Application **Django** de gestion comptable pour micro-entreprise(s) française(s), +au-dessus de **PostgreSQL**. Complète Indy (qui garde devis + factures + suivi bancaire) +en gérant ce que sa version gratuite ne fait pas : saisie des ventes/dépenses, +calculs **TVA / URSSAF / impôt**, statistiques et **exports CSV**. + +Conçue pour évoluer : aucun taux n'est codé en dur (table `Bareme` par activité/année), +multi-entreprises, et logique fiscale en Python que tu contrôles. + +## Ce que ça fait + +- Saisie des **ventes** (factures) et **dépenses** via l'admin Django (formulaires auto). +- **CA HT encaissé**, suivi des plafonds micro et du seuil de franchise TVA. +- **Cotisations URSSAF**, **versement libératoire** et **TVA à reverser** calculés sur la base HT. +- **Tableau de bord** avec graphique mensuel + montants à provisionner. +- **Exports CSV** (ventes, TVA, URSSAF) pour tes déclarations. + +## Déploiement avec Portainer (recommandé) + +La base PostgreSQL est **externe** (la tienne) ; le conteneur ne contient que l'app (sans état). + +1. Dans Portainer → **Stacks** → **Add stack** → colle le contenu de `docker-compose.yml` + (ou pointe sur ce dépôt Git). +2. Renseigne les variables d'environnement (voir `.env.example`) : `DB_HOST`, `DB_NAME`, + `DB_USER`, `DB_PASSWORD`, `DJANGO_SECRET_KEY`, `DJANGO_ALLOWED_HOSTS`, + `DJANGO_CSRF_TRUSTED_ORIGINS`, et le compte admin `DJANGO_SUPERUSER_*`. +3. Mets `SEED_DEMO=1` au premier lancement pour charger les barèmes 2026 + données d'exemple + (repasse-le à `0` ensuite). +4. Déploie. Au démarrage le conteneur applique les migrations, crée l'admin, puis sert l'app. +5. Accède à `http://IP_DU_SERVEUR:8001` → `/admin/` pour saisir, `/dashboard/` pour les stats. + +> Prérequis côté PostgreSQL : crée la base (ex. `compta`) et un utilisateur ayant les droits dessus. +> Les **tables sont créées automatiquement** par les migrations Django (rien à exécuter à la main). + +## Lancer en local (développement) + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # renseigne DB_* (ou laisse vide pour SQLite de test) +python manage.py migrate +python manage.py seed_demo # barèmes 2026 + données d'exemple (optionnel) +python manage.py createsuperuser +python manage.py runserver +# http://127.0.0.1:8000/ +``` + +## Structure + +``` +JB - compta/ +├── manage.py +├── requirements.txt +├── Dockerfile / entrypoint.sh / docker-compose.yml # packaging Portainer +├── .env.example +├── config/ # projet Django (settings, urls, wsgi) +├── compta/ # application métier +│ ├── models.py # Entreprise, Client, Facture, Ligne, Depense, Bareme +│ ├── admin.py # formulaires de saisie +│ ├── services.py # calculs TVA / URSSAF / versement libératoire (base HT) +│ ├── views.py # dashboard + exports CSV +│ └── templates/compta/dashboard.html +└── docs/ + ├── regles-fiscales-2026.md # seuils & taux sourcés + ├── modele-donnees.md # description du schéma + └── deploiement.md # détails Portainer / mise à jour +``` + +## Avertissement + +Les taux et seuils sont ceux connus pour 2026 (voir `docs/regles-fiscales-2026.md`, +sourcé). Cet outil aide au suivi mais ne remplace pas un expert-comptable. +Vérifie toujours les chiffres officiels sur urssaf.fr et impots.gouv.fr. diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..0289bda --- /dev/null +++ b/build.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Construit l'image Docker de JB Compta sur l'hôte. +# Usage : copie ce dossier sur ton serveur Docker, puis lance : sh build.sh +set -e +cd "$(dirname "$0")" +echo "→ Construction de l'image jb-compta:latest ..." +docker build -t jb-compta:latest . +echo "✓ Image construite. Dans Portainer, utilise : image: jb-compta:latest" diff --git a/compta/__init__.py b/compta/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/compta/admin.py b/compta/admin.py new file mode 100644 index 0000000..a91e638 --- /dev/null +++ b/compta/admin.py @@ -0,0 +1,65 @@ +from django.contrib import admin +from django.utils import timezone + +from .models import ( + Bareme, Entreprise, Client, Facture, LigneFacture, Depense, +) + + +@admin.register(Bareme) +class BaremeAdmin(admin.ModelAdmin): + list_display = ("annee", "activite", "taux_urssaf", "abattement_fiscal", + "taux_vl", "seuil_ca", "seuil_tva_base") + list_filter = ("annee", "activite") + + +@admin.register(Entreprise) +class EntrepriseAdmin(admin.ModelAdmin): + list_display = ("nom", "activite", "franchise_tva", + "versement_liberatoire", "email") + list_filter = ("activite", "franchise_tva", "versement_liberatoire") + search_fields = ("nom", "siret") + + +@admin.register(Client) +class ClientAdmin(admin.ModelAdmin): + list_display = ("nom", "entreprise", "email", "siret") + list_filter = ("entreprise",) + search_fields = ("nom", "email", "siret") + + +class LigneFactureInline(admin.TabularInline): + model = LigneFacture + extra = 1 + + +@admin.action(description="Marquer comme encaissée (aujourd'hui)") +def marquer_encaissee(modeladmin, request, queryset): + queryset.update(statut="encaissee", date_encaissement=timezone.now().date()) + + +@admin.register(Facture) +class FactureAdmin(admin.ModelAdmin): + list_display = ("numero", "entreprise", "client", "date_emission", + "date_encaissement", "statut", "montant_ht", + "montant_tva", "montant_ttc") + list_filter = ("entreprise", "statut", "activite", "date_emission") + search_fields = ("numero", "client__nom") + date_hierarchy = "date_emission" + inlines = [LigneFactureInline] + actions = [marquer_encaissee] + readonly_fields = ("montant_ht", "montant_tva", "montant_ttc") + + def save_related(self, request, form, formsets, change): + super().save_related(request, form, formsets, change) + # Recalcule les totaux à partir des lignes après sauvegarde + form.instance.recompute_totaux() + + +@admin.register(Depense) +class DepenseAdmin(admin.ModelAdmin): + list_display = ("date", "entreprise", "libelle", "activite", "categorie", + "montant_ttc", "montant_tva") + list_filter = ("entreprise", "activite", "categorie", "date") + search_fields = ("libelle", "categorie") + date_hierarchy = "date" diff --git a/compta/apps.py b/compta/apps.py new file mode 100644 index 0000000..3ffd66d --- /dev/null +++ b/compta/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ComptaConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "compta" + verbose_name = "Compta micro-entreprise" diff --git a/compta/management/__init__.py b/compta/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/compta/management/commands/__init__.py b/compta/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/compta/management/commands/seed_demo.py b/compta/management/commands/seed_demo.py new file mode 100644 index 0000000..85ae3ca --- /dev/null +++ b/compta/management/commands/seed_demo.py @@ -0,0 +1,93 @@ +""" +Charge les barèmes 2026 et un jeu de données d'exemple (multi-activité). +Usage : python manage.py seed_demo +""" +from decimal import Decimal +from datetime import date + +from django.core.management.base import BaseCommand + +from compta.models import ( + Bareme, Entreprise, Client, Facture, LigneFacture, Depense, Activite, + StatutFacture, +) + +# Barèmes officiels 2026 (voir docs/regles-fiscales-2026.md) +BAREMES_2026 = [ + # activite, taux_urssaf, abattement, taux_vl, seuil_ca, tva_base, tva_majore + (Activite.VENTE, Decimal("12.30"), Decimal("71"), Decimal("1.0"), + Decimal("203100"), Decimal("85000"), Decimal("93500")), + (Activite.SERVICE_BIC, Decimal("21.20"), Decimal("50"), Decimal("1.7"), + Decimal("83600"), Decimal("37500"), Decimal("41250")), + (Activite.SERVICE_BNC, Decimal("25.60"), Decimal("34"), Decimal("2.2"), + Decimal("83600"), Decimal("37500"), Decimal("41250")), +] + + +class Command(BaseCommand): + help = "Charge les barèmes 2026 et des données d'exemple." + + def handle(self, *args, **options): + for act, urssaf, ab, vl, sca, tb, tm in BAREMES_2026: + Bareme.objects.update_or_create( + activite=act, annee=2026, + defaults=dict(taux_urssaf=urssaf, abattement_fiscal=ab, + taux_vl=vl, seuil_ca=sca, + seuil_tva_base=tb, seuil_tva_majore=tm), + ) + self.stdout.write(self.style.SUCCESS("Barèmes 2026 chargés.")) + + # Entreprise multi-activité : impression 3D (Vente BIC) + aide info (Presta BIC) + ent, _ = Entreprise.objects.get_or_create( + nom="JB Conseil", + defaults=dict(activite=Activite.SERVICE_BIC, franchise_tva=False, + taux_tva_defaut=Decimal("20.00"), + versement_liberatoire=True, email="jb@example.fr"), + ) + + clients = {} + for nom, mail in [("Acme SARL", "contact@acme.fr"), + ("Studio Lumiere", "hello@studiolumiere.fr"), + ("Mairie de Ville", "compta@ville.fr")]: + c, _ = Client.objects.get_or_create(entreprise=ent, nom=nom, + defaults=dict(email=mail)) + clients[nom] = c + + V, S = Activite.VENTE, Activite.SERVICE_BIC + factures = [ + # numero, client, activite, emission, encaissement, statut, lignes + ("2026-001", "Acme SARL", S, date(2026, 1, 10), date(2026, 1, 25), + StatutFacture.ENCAISSEE, [("Dépannage informatique", 2, 600)]), + ("2026-002", "Studio Lumiere", V, date(2026, 1, 18), date(2026, 2, 5), + StatutFacture.ENCAISSEE, [("Impression 3D - lot pièces", 1, 800)]), + ("2026-003", "Mairie de Ville", S, date(2026, 2, 2), date(2026, 2, 20), + StatutFacture.ENCAISSEE, [("Maintenance parc", 1, 2500)]), + ("2026-004", "Acme SARL", V, date(2026, 3, 1), date(2026, 3, 15), + StatutFacture.ENCAISSEE, [("Impression 3D - prototype", 1, 1500)]), + ("2026-005", "Studio Lumiere", S, date(2026, 3, 20), None, + StatutFacture.ENVOYEE, [("Audit poste de travail", 1, 950)]), + ] + for numero, client_nom, activite, emis, enc, statut, lignes in factures: + f, created = Facture.objects.get_or_create( + entreprise=ent, numero=numero, + defaults=dict(client=clients[client_nom], activite=activite, + date_emission=emis, date_encaissement=enc, + statut=statut), + ) + if created: + for des, qte, pu in lignes: + LigneFacture.objects.create( + facture=f, designation=des, quantite=Decimal(qte), + prix_unitaire_ht=Decimal(pu), taux_tva=Decimal("20")) + f.recompute_totaux() + + Depense.objects.get_or_create( + entreprise=ent, libelle="Bobines PLA/PETG", date=date(2026, 1, 15), + defaults=dict(categorie="Matiere", activite=Activite.VENTE, + montant_ttc=Decimal("96.00"), montant_tva=Decimal("16.00"))) + Depense.objects.get_or_create( + entreprise=ent, libelle="Abonnement outils", date=date(2026, 2, 10), + defaults=dict(categorie="Logiciel", activite=Activite.SERVICE_BIC, + montant_ttc=Decimal("34.80"), montant_tva=Decimal("5.80"))) + + self.stdout.write(self.style.SUCCESS("Données d'exemple chargées.")) diff --git a/compta/migrations/0001_initial.py b/compta/migrations/0001_initial.py new file mode 100644 index 0000000..e682bba --- /dev/null +++ b/compta/migrations/0001_initial.py @@ -0,0 +1,130 @@ +# Generated by Django 5.2.15 on 2026-06-29 20:53 + +import django.db.models.deletion +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Entreprise', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nom', models.CharField(max_length=200)), + ('siret', models.CharField(blank=True, max_length=20)), + ('activite', models.CharField(choices=[('vente', 'Vente de marchandises (BIC)'), ('service_bic', 'Prestation de services (BIC)'), ('service_bnc', 'Prestation de services / libéral (BNC)')], default='service_bnc', max_length=20)), + ('franchise_tva', models.BooleanField(default=False, help_text='Décochez si vous facturez et déclarez la TVA.', verbose_name='En franchise de TVA')), + ('taux_tva_defaut', models.DecimalField(decimal_places=2, default=Decimal('20.00'), max_digits=4, verbose_name='Taux de TVA par défaut (%)')), + ('versement_liberatoire', models.BooleanField(default=False, verbose_name='Option versement libératoire')), + ('email', models.EmailField(blank=True, max_length=254)), + ('adresse', models.TextField(blank=True)), + ('cree_le', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Entreprise', + 'verbose_name_plural': 'Entreprises', + 'ordering': ['nom'], + }, + ), + migrations.CreateModel( + name='Bareme', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('activite', models.CharField(choices=[('vente', 'Vente de marchandises (BIC)'), ('service_bic', 'Prestation de services (BIC)'), ('service_bnc', 'Prestation de services / libéral (BNC)')], max_length=20)), + ('annee', models.PositiveIntegerField()), + ('taux_urssaf', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='Taux cotisations URSSAF (%)')), + ('abattement_fiscal', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='Abattement forfaitaire (%)')), + ('taux_vl', models.DecimalField(decimal_places=2, max_digits=4, verbose_name='Taux versement libératoire (%)')), + ('seuil_ca', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Plafond CA micro (€)')), + ('seuil_tva_base', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Seuil franchise TVA (€)')), + ('seuil_tva_majore', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Seuil franchise TVA majoré (€)')), + ], + options={ + 'verbose_name': 'Barème', + 'verbose_name_plural': 'Barèmes', + 'ordering': ['-annee', 'activite'], + 'unique_together': {('activite', 'annee')}, + }, + ), + migrations.CreateModel( + name='Depense', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('categorie', models.CharField(blank=True, max_length=100)), + ('libelle', models.CharField(max_length=255)), + ('montant_ttc', models.DecimalField(decimal_places=2, max_digits=12)), + ('montant_tva', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, verbose_name='TVA déductible (€)')), + ('cree_le', models.DateTimeField(auto_now_add=True)), + ('entreprise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='depenses', to='compta.entreprise')), + ], + options={ + 'verbose_name': 'Dépense', + 'verbose_name_plural': 'Dépenses', + 'ordering': ['-date'], + }, + ), + migrations.CreateModel( + name='Client', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nom', models.CharField(max_length=200)), + ('email', models.EmailField(blank=True, max_length=254)), + ('siret', models.CharField(blank=True, max_length=20)), + ('adresse', models.TextField(blank=True)), + ('cree_le', models.DateTimeField(auto_now_add=True)), + ('entreprise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='clients', to='compta.entreprise')), + ], + options={ + 'verbose_name': 'Client', + 'verbose_name_plural': 'Clients', + 'ordering': ['nom'], + }, + ), + migrations.CreateModel( + name='Facture', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('numero', models.CharField(max_length=50)), + ('activite', models.CharField(choices=[('vente', 'Vente de marchandises (BIC)'), ('service_bic', 'Prestation de services (BIC)'), ('service_bnc', 'Prestation de services / libéral (BNC)')], max_length=20)), + ('date_emission', models.DateField()), + ('date_encaissement', models.DateField(blank=True, help_text="À renseigner une fois la facture payée. Le CA micro est compté à l'encaissement.", null=True)), + ('statut', models.CharField(choices=[('brouillon', 'Brouillon'), ('envoyee', 'Envoyée'), ('encaissee', 'Encaissée'), ('annulee', 'Annulée')], default='brouillon', max_length=12)), + ('montant_ht', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12)), + ('montant_tva', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12)), + ('montant_ttc', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12)), + ('notes', models.TextField(blank=True)), + ('cree_le', models.DateTimeField(auto_now_add=True)), + ('client', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='factures', to='compta.client')), + ('entreprise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='factures', to='compta.entreprise')), + ], + options={ + 'verbose_name': 'Facture', + 'verbose_name_plural': 'Factures', + 'ordering': ['-date_emission', 'numero'], + 'unique_together': {('entreprise', 'numero')}, + }, + ), + migrations.CreateModel( + name='LigneFacture', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('designation', models.CharField(max_length=255)), + ('quantite', models.DecimalField(decimal_places=3, default=Decimal('1'), max_digits=12)), + ('prix_unitaire_ht', models.DecimalField(decimal_places=2, max_digits=12)), + ('taux_tva', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=4)), + ('facture', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lignes', to='compta.facture')), + ], + options={ + 'verbose_name': 'Ligne de facture', + 'verbose_name_plural': 'Lignes de facture', + }, + ), + ] diff --git a/compta/migrations/0002_depense_activite_alter_entreprise_activite.py b/compta/migrations/0002_depense_activite_alter_entreprise_activite.py new file mode 100644 index 0000000..7364de6 --- /dev/null +++ b/compta/migrations/0002_depense_activite_alter_entreprise_activite.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-06-29 21:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('compta', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='depense', + name='activite', + field=models.CharField(blank=True, choices=[('vente', 'Vente de marchandises (BIC)'), ('service_bic', 'Prestation de services (BIC)'), ('service_bnc', 'Prestation de services / libéral (BNC)')], help_text='Activité concernée (facultatif, pour la ventilation).', max_length=20), + ), + migrations.AlterField( + model_name='entreprise', + name='activite', + field=models.CharField(choices=[('vente', 'Vente de marchandises (BIC)'), ('service_bic', 'Prestation de services (BIC)'), ('service_bnc', 'Prestation de services / libéral (BNC)')], default='service_bnc', help_text='Activité par défaut des nouvelles factures. Une entreprise peut avoir plusieurs activités : chaque facture/dépense porte la sienne.', max_length=20, verbose_name='Activité principale'), + ), + ] diff --git a/compta/migrations/__init__.py b/compta/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/compta/models.py b/compta/models.py new file mode 100644 index 0000000..c05e291 --- /dev/null +++ b/compta/models.py @@ -0,0 +1,199 @@ +""" +Modèles de données — JB Compta. + +Conçus pour rester valables si le statut évolue : les taux et seuils +ne sont jamais codés en dur, ils vivent dans le modèle `Bareme` +(par activité et par année). +""" +from decimal import Decimal + +from django.db import models +from django.db.models import Sum + + +class Activite(models.TextChoices): + VENTE = "vente", "Vente de marchandises (BIC)" + SERVICE_BIC = "service_bic", "Prestation de services (BIC)" + SERVICE_BNC = "service_bnc", "Prestation de services / libéral (BNC)" + + +class StatutFacture(models.TextChoices): + BROUILLON = "brouillon", "Brouillon" + ENVOYEE = "envoyee", "Envoyée" + ENCAISSEE = "encaissee", "Encaissée" + ANNULEE = "annulee", "Annulée" + + +class Bareme(models.Model): + """Taux et seuils officiels, par activité et par année (paramétrable).""" + activite = models.CharField(max_length=20, choices=Activite.choices) + annee = models.PositiveIntegerField() + taux_urssaf = models.DecimalField( + "Taux cotisations URSSAF (%)", max_digits=5, decimal_places=2) + abattement_fiscal = models.DecimalField( + "Abattement forfaitaire (%)", max_digits=5, decimal_places=2) + taux_vl = models.DecimalField( + "Taux versement libératoire (%)", max_digits=4, decimal_places=2) + seuil_ca = models.DecimalField( + "Plafond CA micro (€)", max_digits=12, decimal_places=2) + seuil_tva_base = models.DecimalField( + "Seuil franchise TVA (€)", max_digits=12, decimal_places=2) + seuil_tva_majore = models.DecimalField( + "Seuil franchise TVA majoré (€)", max_digits=12, decimal_places=2) + + class Meta: + verbose_name = "Barème" + verbose_name_plural = "Barèmes" + unique_together = ("activite", "annee") + ordering = ["-annee", "activite"] + + def __str__(self): + return f"{self.get_activite_display()} — {self.annee}" + + +class Entreprise(models.Model): + nom = models.CharField(max_length=200) + siret = models.CharField(max_length=20, blank=True) + activite = models.CharField( + "Activité principale", max_length=20, choices=Activite.choices, + default=Activite.SERVICE_BNC, + help_text="Activité par défaut des nouvelles factures. " + "Une entreprise peut avoir plusieurs activités : " + "chaque facture/dépense porte la sienne.") + franchise_tva = models.BooleanField( + "En franchise de TVA", default=False, + help_text="Décochez si vous facturez et déclarez la TVA.") + taux_tva_defaut = models.DecimalField( + "Taux de TVA par défaut (%)", max_digits=4, decimal_places=2, + default=Decimal("20.00")) + versement_liberatoire = models.BooleanField( + "Option versement libératoire", default=False) + email = models.EmailField(blank=True) + adresse = models.TextField(blank=True) + cree_le = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Entreprise" + verbose_name_plural = "Entreprises" + ordering = ["nom"] + + def __str__(self): + return self.nom + + def bareme(self, annee): + return Bareme.objects.filter(activite=self.activite, annee=annee).first() + + +class Client(models.Model): + entreprise = models.ForeignKey(Entreprise, on_delete=models.CASCADE, + related_name="clients") + nom = models.CharField(max_length=200) + email = models.EmailField(blank=True) + siret = models.CharField(max_length=20, blank=True) + adresse = models.TextField(blank=True) + cree_le = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Client" + verbose_name_plural = "Clients" + ordering = ["nom"] + + def __str__(self): + return self.nom + + +class Facture(models.Model): + entreprise = models.ForeignKey(Entreprise, on_delete=models.CASCADE, + related_name="factures") + client = models.ForeignKey(Client, on_delete=models.PROTECT, + related_name="factures") + numero = models.CharField(max_length=50) + activite = models.CharField(max_length=20, choices=Activite.choices) + date_emission = models.DateField() + date_encaissement = models.DateField( + null=True, blank=True, + help_text="À renseigner une fois la facture payée. " + "Le CA micro est compté à l'encaissement.") + statut = models.CharField(max_length=12, choices=StatutFacture.choices, + default=StatutFacture.BROUILLON) + montant_ht = models.DecimalField(max_digits=12, decimal_places=2, + default=Decimal("0")) + 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) + cree_le = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Facture" + verbose_name_plural = "Factures" + unique_together = ("entreprise", "numero") + ordering = ["-date_emission", "numero"] + + def __str__(self): + return f"{self.numero} — {self.client}" + + def recompute_totaux(self, save=True): + """Recalcule HT/TVA/TTC à partir des lignes.""" + ht = Decimal("0") + tva = Decimal("0") + for ligne in self.lignes.all(): + l_ht = ligne.montant_ht + ht += l_ht + tva += (l_ht * ligne.taux_tva / Decimal("100")) + self.montant_ht = ht.quantize(Decimal("0.01")) + self.montant_tva = tva.quantize(Decimal("0.01")) + self.montant_ttc = (self.montant_ht + self.montant_tva) + if save: + super().save(update_fields=["montant_ht", "montant_tva", "montant_ttc"]) + + +class LigneFacture(models.Model): + facture = models.ForeignKey(Facture, on_delete=models.CASCADE, + related_name="lignes") + designation = models.CharField(max_length=255) + quantite = models.DecimalField(max_digits=12, decimal_places=3, + default=Decimal("1")) + prix_unitaire_ht = models.DecimalField(max_digits=12, decimal_places=2) + taux_tva = models.DecimalField(max_digits=4, decimal_places=2, + default=Decimal("0")) + + class Meta: + verbose_name = "Ligne de facture" + verbose_name_plural = "Lignes de facture" + + def __str__(self): + return self.designation + + @property + def montant_ht(self): + return (self.quantite * self.prix_unitaire_ht).quantize(Decimal("0.01")) + + +class Depense(models.Model): + entreprise = models.ForeignKey(Entreprise, on_delete=models.CASCADE, + related_name="depenses") + date = models.DateField() + activite = models.CharField( + max_length=20, choices=Activite.choices, blank=True, + help_text="Activité concernée (facultatif, pour la ventilation).") + categorie = models.CharField(max_length=100, blank=True) + libelle = models.CharField(max_length=255) + montant_ttc = models.DecimalField(max_digits=12, decimal_places=2) + montant_tva = models.DecimalField( + "TVA déductible (€)", max_digits=12, decimal_places=2, + default=Decimal("0")) + cree_le = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Dépense" + verbose_name_plural = "Dépenses" + ordering = ["-date"] + + def __str__(self): + return f"{self.date} — {self.libelle}" + + @property + def montant_ht(self): + return (self.montant_ttc - self.montant_tva).quantize(Decimal("0.01")) diff --git a/compta/services.py b/compta/services.py new file mode 100644 index 0000000..4b728e3 --- /dev/null +++ b/compta/services.py @@ -0,0 +1,154 @@ +""" +Logique fiscale — JB Compta. + +Bases de calcul : CA HORS TAXES (HT) encaissé. +- Base des cotisations URSSAF et du versement libératoire (assujetti TVA). +- En franchise de TVA, HT = TTC : le calcul reste correct. + +Multi-activité : une entreprise peut exercer plusieurs activités (ex. Vente BIC ++ Prestation BIC). Les taux URSSAF, abattements, taux de versement libératoire et +seuils diffèrent par activité, donc tout est ventilé par activité puis totalisé. + +TVA à reverser = TVA collectée (ventes encaissées) − TVA déductible (dépenses). +""" +from decimal import Decimal +from django.db.models import Sum +from django.db.models.functions import TruncMonth + +from .models import Facture, Depense, Bareme, StatutFacture, Activite + +CENT = Decimal("0.01") + + +def _q(value): + return (value or Decimal("0")).quantize(CENT) + + +def factures_encaissees(entreprise, annee): + return Facture.objects.filter( + entreprise=entreprise, + statut=StatutFacture.ENCAISSEE, + date_encaissement__year=annee, + ) + + +def ca_ht_par_activite(entreprise, annee): + """Dict {activite: CA HT encaissé}.""" + rows = ( + factures_encaissees(entreprise, annee) + .values("activite") + .annotate(total=Sum("montant_ht")) + ) + return {r["activite"]: _q(r["total"]) for r in rows} + + +def ca_ht(entreprise, annee): + agg = factures_encaissees(entreprise, annee).aggregate(s=Sum("montant_ht")) + return _q(agg["s"]) + + +def tva_collectee(entreprise, annee): + agg = factures_encaissees(entreprise, annee).aggregate(s=Sum("montant_tva")) + return _q(agg["s"]) + + +def tva_deductible(entreprise, annee): + agg = Depense.objects.filter( + entreprise=entreprise, date__year=annee + ).aggregate(s=Sum("montant_tva")) + return _q(agg["s"]) + + +def ca_ht_mensuel(entreprise, annee): + """Liste de 12 valeurs (index 0 = janvier) du CA HT encaissé (toutes activités).""" + rows = ( + factures_encaissees(entreprise, annee) + .annotate(mois=TruncMonth("date_encaissement")) + .values("mois") + .annotate(total=Sum("montant_ht")) + ) + mensuel = [Decimal("0")] * 12 + for r in rows: + if r["mois"]: + mensuel[r["mois"].month - 1] = _q(r["total"]) + return mensuel + + +def detail_activite(activite, ca, bareme): + """Calculs fiscaux pour une activité donnée.""" + label = dict(Activite.choices).get(activite, activite) + d = {"activite": activite, "label": label, "ca_ht": ca, "bareme": bareme} + if bareme: + d.update({ + "taux_urssaf": bareme.taux_urssaf, + "cotisations": _q(ca * bareme.taux_urssaf / Decimal("100")), + "taux_vl": bareme.taux_vl, + "versement_liberatoire": _q(ca * bareme.taux_vl / Decimal("100")), + "abattement_fiscal": bareme.abattement_fiscal, + "revenu_imposable": _q( + ca * (Decimal("100") - bareme.abattement_fiscal) / Decimal("100")), + "seuil_ca": bareme.seuil_ca, + "pct_seuil_ca": (_q(ca / bareme.seuil_ca * Decimal("100")) + if bareme.seuil_ca else Decimal("0")), + "seuil_tva_base": bareme.seuil_tva_base, + "depasse_seuil_tva": ca > bareme.seuil_tva_base, + "depasse_seuil_ca": ca > bareme.seuil_ca, + }) + else: + d.update({ + "taux_urssaf": None, "cotisations": None, + "taux_vl": None, "versement_liberatoire": None, + "abattement_fiscal": None, "revenu_imposable": None, + "seuil_ca": None, "pct_seuil_ca": None, + "seuil_tva_base": None, "depasse_seuil_tva": False, + "depasse_seuil_ca": False, + }) + return d + + +def synthese(entreprise, annee): + """Tableau de bord chiffré, ventilé par activité puis totalisé.""" + ca_par_act = ca_ht_par_activite(entreprise, annee) + + # On inclut toujours l'activité principale, même sans CA encore. + activites = set(ca_par_act) | {entreprise.activite} + baremes = {b.activite: b for b in Bareme.objects.filter(annee=annee)} + + lignes = [] + for act in sorted(activites): + ca = ca_par_act.get(act, Decimal("0")) + lignes.append(detail_activite(act, ca, baremes.get(act))) + + # Totaux (on additionne ce qui est calculable) + def somme(cle): + vals = [l[cle] for l in lignes if l.get(cle) is not None] + return _q(sum(vals, Decimal("0"))) if vals else None + + tva_col = tva_collectee(entreprise, annee) + tva_ded = tva_deductible(entreprise, annee) + + return { + "entreprise": entreprise, + "annee": annee, + "lignes_activite": lignes, + "bareme_manquant": any(l["bareme"] is None and l["ca_ht"] for l in lignes), + "ca_ht": ca_ht(entreprise, annee), + "cotisations_urssaf": somme("cotisations"), + "versement_liberatoire": ( + somme("versement_liberatoire") if entreprise.versement_liberatoire else None), + "revenu_imposable": somme("revenu_imposable"), + "tva_collectee": tva_col, + "tva_deductible": tva_ded, + "tva_a_reverser": _q(tva_col - tva_ded), + "ca_mensuel": ca_ht_mensuel(entreprise, annee), + } + + +def annees_disponibles(entreprise): + qs = ( + Facture.objects.filter(entreprise=entreprise, + statut=StatutFacture.ENCAISSEE, + date_encaissement__isnull=False) + .dates("date_encaissement", "year") + ) + return sorted({d.year for d in qs}, reverse=True) diff --git a/compta/templates/compta/dashboard.html b/compta/templates/compta/dashboard.html new file mode 100644 index 0000000..408f900 --- /dev/null +++ b/compta/templates/compta/dashboard.html @@ -0,0 +1,169 @@ + + + + + +JB Compta — Tableau de bord + + + + +{% if not entreprise %} +

Aucune entreprise

+

Crée une entreprise et des factures dans + l'administration, puis reviens ici.

+{% else %} +

{{ entreprise.nom }}

+
{{ entreprise.get_activite_display }} (principale) · + {% if entreprise.franchise_tva %}Franchise de TVA{% else %}Assujetti TVA{% endif %} + {% if entreprise.versement_liberatoire %}· Versement libératoire{% endif %} + · Exercice {{ annee }}
+ +
+ + + +
+ + {% if bareme_manquant %} +
+ Une activité avec du CA n'a pas de barème {{ annee }}. Ajoute-le dans + Barèmes. +
+ {% endif %} + + +
+
+
CA HT encaissé (total)
+
{{ ca_ht }} €
+
+
+
Cotisations URSSAF (total)
+
{{ cotisations_urssaf|default:"—" }} €
+
+
+
Impôt — versement libératoire
+
{{ versement_liberatoire|default:"—" }} €
+
+
+
TVA à reverser
+
{{ tva_a_reverser }} €
+
{{ tva_collectee }} collectée − {{ tva_deductible }} déductible
+
+
+ + +

Ventilation par activité

+
+ + + + + + + + + {% for l in lignes_activite %} + + + + + + + + + {% endfor %} + + + + + + + + + +
ActivitéCA HT% plafondURSSAFImpôt (VL)Revenu imposable
{{ l.label }}{{ l.ca_ht }} € + {% if l.pct_seuil_ca is not None %}{{ l.pct_seuil_ca }} %{% else %}—{% endif %} + {% if l.cotisations is not None %}{{ l.cotisations }} €{% else %}—{% endif %}{% if l.versement_liberatoire is not None %}{{ l.versement_liberatoire }} €{% else %}—{% endif %}{% if l.revenu_imposable is not None %}{{ l.revenu_imposable }} €{% else %}—{% endif %}
Total{{ ca_ht }} €{{ cotisations_urssaf|default:"—" }} €{{ versement_liberatoire|default:"—" }} €{{ revenu_imposable|default:"—" }} €
+
+ +
+
+
CA HT mensuel (toutes activités)
+ +
+
+
À provisionner (année)
+ + + + + +
PosteMontant
URSSAF{{ cotisations_urssaf|default:"—" }} €
Impôt (VL){{ versement_liberatoire|default:"—" }} €
TVA{{ tva_a_reverser }} €
+
+
Exports CSV
+ Ventes + TVA + URSSAF +
+
+
+ + + +{% endif %} + + diff --git a/compta/urls.py b/compta/urls.py new file mode 100644 index 0000000..8dfad0e --- /dev/null +++ b/compta/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path("dashboard/", views.dashboard, name="dashboard"), + 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.py b/compta/views.py new file mode 100644 index 0000000..38dfb0a --- /dev/null +++ b/compta/views.py @@ -0,0 +1,89 @@ +import csv +from datetime import date + +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse +from django.shortcuts import render, get_object_or_404 + +from .models import Entreprise +from . import services + + +def _entreprise_courante(request): + eid = request.GET.get("entreprise") + qs = Entreprise.objects.all() + if eid: + return get_object_or_404(Entreprise, pk=eid) + return qs.first() + + +@login_required +def dashboard(request): + entreprise = _entreprise_courante(request) + if entreprise is None: + return render(request, "compta/dashboard.html", {"entreprise": None}) + + annee = int(request.GET.get("annee", date.today().year)) + ctx = services.synthese(entreprise, annee) + ctx.update({ + "entreprises": Entreprise.objects.all(), + "annees": services.annees_disponibles(entreprise) or [annee], + "mois_labels": ["Jan", "Fév", "Mar", "Avr", "Mai", "Juin", + "Juil", "Août", "Sep", "Oct", "Nov", "Déc"], + "ca_mensuel_json": [float(v) for v in ctx["ca_mensuel"]], + }) + return render(request, "compta/dashboard.html", ctx) + + +def _csv_response(filename): + resp = HttpResponse(content_type="text/csv; charset=utf-8") + resp["Content-Disposition"] = f'attachment; filename="{filename}"' + resp.write("") # BOM pour Excel + return resp + + +@login_required +def export_ventes(request): + entreprise = _entreprise_courante(request) + annee = int(request.GET.get("annee", date.today().year)) + resp = _csv_response(f"ventes_{annee}.csv") + w = csv.writer(resp, delimiter=";") + w.writerow(["Numero", "Client", "Activite", "Date emission", + "Date encaissement", "Statut", "HT", "TVA", "TTC"]) + for f in services.factures_encaissees(entreprise, annee).select_related("client"): + w.writerow([f.numero, f.client.nom, f.get_activite_display(), + f.date_emission, f.date_encaissement, f.get_statut_display(), + f.montant_ht, f.montant_tva, f.montant_ttc]) + return resp + + +@login_required +def export_tva(request): + entreprise = _entreprise_courante(request) + annee = int(request.GET.get("annee", date.today().year)) + s = services.synthese(entreprise, annee) + resp = _csv_response(f"tva_{annee}.csv") + w = csv.writer(resp, delimiter=";") + w.writerow(["Annee", "TVA collectee", "TVA deductible", "TVA a reverser"]) + w.writerow([annee, s["tva_collectee"], s["tva_deductible"], + s["tva_a_reverser"]]) + return resp + + +@login_required +def export_urssaf(request): + entreprise = _entreprise_courante(request) + annee = int(request.GET.get("annee", date.today().year)) + s = services.synthese(entreprise, annee) + resp = _csv_response(f"urssaf_{annee}.csv") + w = csv.writer(resp, delimiter=";") + w.writerow(["Annee", "Activite", "CA HT", "Taux URSSAF", "Cotisations", + "Taux VL", "Versement liberatoire", "Abattement", + "Revenu imposable"]) + for l in s["lignes_activite"]: + w.writerow([annee, l["label"], l["ca_ht"], l["taux_urssaf"], + l["cotisations"], l["taux_vl"], l["versement_liberatoire"], + l["abattement_fiscal"], l["revenu_imposable"]]) + w.writerow([annee, "TOTAL", s["ca_ht"], "", s["cotisations_urssaf"], + "", s["versement_liberatoire"], "", s["revenu_imposable"]]) + return resp diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..b523d2e --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,5 @@ +import os +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +application = get_asgi_application() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..ec661a3 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,110 @@ +""" +Configuration Django — JB Compta. +Tous les paramètres sensibles viennent de variables d'environnement +(fichier .env en développement, variables Portainer en production). +""" +from pathlib import Path +import os + +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent.parent +load_dotenv(BASE_DIR / ".env") + + +def env_bool(name: str, default: bool = False) -> bool: + return os.getenv(name, str(default)).lower() in ("1", "true", "yes", "on") + + +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-insecure-change-me") +DEBUG = env_bool("DJANGO_DEBUG", False) +ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "*").split(",") +CSRF_TRUSTED_ORIGINS = [ + o for o in os.getenv("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if o +] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "compta", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +# Base de données : PostgreSQL existant (sinon SQLite en repli pour les tests) +if os.getenv("DB_NAME"): + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("DB_NAME"), + "USER": os.getenv("DB_USER", "jb"), + "PASSWORD": os.getenv("DB_PASSWORD", ""), + "HOST": os.getenv("DB_HOST", "localhost"), + "PORT": os.getenv("DB_PORT", "5432"), + "CONN_MAX_AGE": 60, + } + } +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } + } + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "fr-fr" +TIME_ZONE = "Europe/Paris" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage"}, +} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Redirections de connexion vers l'admin +LOGIN_URL = "/admin/login/" diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..f0ad67d --- /dev/null +++ b/config/urls.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from django.urls import path, include +from django.views.generic import RedirectView + +admin.site.site_header = "JB Compta — Administration" +admin.site.site_title = "JB Compta" +admin.site.index_title = "Gestion de la micro-entreprise" + +urlpatterns = [ + path("", RedirectView.as_view(url="/dashboard/", permanent=False)), + path("admin/", admin.site.urls), + path("", include("compta.urls")), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..86de4c9 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,5 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +application = get_wsgi_application() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..15f68bb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +# Stack à déployer dans Portainer. +# La base PostgreSQL est EXTERNE (ta base existante) — rien à héberger ici. +# Renseigne les variables dans Portainer (onglet "Environment variables") +# ou via un fichier .env à côté de ce compose. + +services: + web: + # OPTION A (dépôt Git) : laisse "build: ." — Portainer clone le dépôt, + # le contexte contient tout le projet. + # OPTION B (image pré-construite sur l'hôte) : commente "build: ." et + # décommente la ligne "image:" ci-dessous. + build: . + # image: jb-compta:latest + container_name: jb-compta + restart: unless-stopped + ports: + - "8001:8000" # http://IP_DU_SERVEUR:8001 + environment: + DJANGO_SECRET_KEY: "${DJANGO_SECRET_KEY}" + DJANGO_DEBUG: "0" + DJANGO_ALLOWED_HOSTS: "${DJANGO_ALLOWED_HOSTS}" + DJANGO_CSRF_TRUSTED_ORIGINS: "${DJANGO_CSRF_TRUSTED_ORIGINS}" + # --- Connexion à ton PostgreSQL existant --- + DB_NAME: "${DB_NAME}" + DB_USER: "${DB_USER}" + DB_PASSWORD: "${DB_PASSWORD}" + DB_HOST: "${DB_HOST}" # IP/host de ton serveur PostgreSQL + DB_PORT: "${DB_PORT:-5432}" + # --- Compte admin créé au 1er démarrage (optionnel) --- + DJANGO_SUPERUSER_USERNAME: "${DJANGO_SUPERUSER_USERNAME:-admin}" + DJANGO_SUPERUSER_PASSWORD: "${DJANGO_SUPERUSER_PASSWORD:-}" + DJANGO_SUPERUSER_EMAIL: "${DJANGO_SUPERUSER_EMAIL:-admin@example.fr}" + # --- Charger les données d'exemple au 1er démarrage (1 = oui) --- + SEED_DEMO: "${SEED_DEMO:-0}" diff --git a/docs/deploiement.md b/docs/deploiement.md new file mode 100644 index 0000000..68a1492 --- /dev/null +++ b/docs/deploiement.md @@ -0,0 +1,83 @@ +# Déploiement (Portainer) & maintenance + +## Principe + +- Le conteneur ne contient que **l'application** (Gunicorn). Il est **sans état**. +- La **base PostgreSQL est externe** (la tienne). Aucune donnée n'est stockée dans le conteneur. +- Au démarrage, l'`entrypoint` applique les migrations, crée le compte admin (si variables + fournies), charge éventuellement les données d'exemple, puis lance Gunicorn. + +## Préparer PostgreSQL + +Sur ton serveur PostgreSQL existant : + +```sql +CREATE DATABASE compta; +CREATE USER jb WITH PASSWORD 'ton_mot_de_passe'; +GRANT ALL PRIVILEGES ON DATABASE compta TO jb; +-- PostgreSQL 15+ : donner aussi les droits sur le schéma public +\c compta +GRANT ALL ON SCHEMA public TO jb; +``` + +Vérifie que le serveur accepte les connexions depuis l'hôte Docker +(`listen_addresses` dans `postgresql.conf` et une ligne dans `pg_hba.conf`). + +## Important : le contexte de build + +`COPY . .` dans le Dockerfile copie le **contexte de build** (les fichiers fournis à Docker). +L'**éditeur web** de Portainer ne fournit **que le docker-compose** comme contexte : tes dossiers +`config/` et `compta/` sont absents, et le build échoue avec `No module named 'config'`. +Il faut donc fournir le projet complet par l'une des deux méthodes ci-dessous. + +### Option A — dépôt Git (recommandé) + +1. Pousse le dossier du projet dans un dépôt Git (GitHub privé, ou Gitea auto-hébergé). +2. Portainer → **Stacks → Add stack → Repository**. +3. Renseigne l'URL du dépôt, la branche, et `docker-compose.yml` comme *Compose path*. +4. **Environment variables** : ajoute les valeurs (cf. `.env.example`). +5. **Deploy the stack**. Portainer clone le dépôt → contexte complet → `build: .` fonctionne. + +Mise à jour : `git push`, puis dans Portainer **Pull and redeploy**. + +### Option B — image pré-construite sur l'hôte (sans Git) + +1. Copie le projet sur l'hôte Docker (WinSCP/scp), ex. `/opt/jb-compta`. +2. En SSH : `cd /opt/jb-compta && docker build -t jb-compta:latest .` +3. Dans `docker-compose.yml`, commente `build: .` et décommente `image: jb-compta:latest`. +4. Portainer → **Stacks → Add stack → Web editor**, colle le compose, renseigne les variables, déploie. + +Mise à jour : re-copier les fichiers, `docker build` à nouveau, puis redéployer la stack. + +### Premier démarrage + +Mets `SEED_DEMO=1` pour charger les barèmes 2026 et un jeu d'exemple, +puis repasse à `0` et redéploie. + +## Accès + +- Admin / saisie : `http://IP:8001/admin/` +- Tableau de bord : `http://IP:8001/dashboard/` + +Change le port `8001` dans le compose si besoin. + +## Mettre à jour l'application + +1. Mets à jour le code (Git pull ou nouvelle image). +2. Dans Portainer : **Stacks → jb-compta → Update / Re-pull and redeploy**. +3. Les migrations éventuelles s'appliquent automatiquement au redémarrage. + +## Sauvegarde + +Sauvegarde **uniquement la base PostgreSQL** (le conteneur n'a rien à sauvegarder) : + +```bash +pg_dump -h IP_DB -U jb compta > sauvegarde_compta_$(date +%F).sql +``` + +## Sécurité + +- Génère un vrai `DJANGO_SECRET_KEY` (longue chaîne aléatoire). +- `DJANGO_DEBUG=0` en production. +- Renseigne `DJANGO_ALLOWED_HOSTS` et `DJANGO_CSRF_TRUSTED_ORIGINS` avec ton IP/domaine. +- Place l'app derrière un reverse proxy HTTPS (Nginx Proxy Manager, Traefik…) si tu l'exposes. diff --git a/docs/modele-donnees.md b/docs/modele-donnees.md new file mode 100644 index 0000000..b924d77 --- /dev/null +++ b/docs/modele-donnees.md @@ -0,0 +1,39 @@ +# Modèle de données + +Modèles Django (`compta/models.py`). Les tables sont créées par les migrations. + +## Tables + +- **Entreprise** — chaque micro-entreprise gérée (la tienne et celles d'autres personnes). + Porte l'activité, le régime de TVA (`franchise_tva`), le taux de TVA par défaut et + l'option versement libératoire. +- **Client** — clients, rattachés à une entreprise. +- **Facture** — factures. Le CA micro étant compté à l'encaissement, le champ clé est + `date_encaissement` et le statut `encaissee`. Les montants HT/TVA/TTC sont recalculés + depuis les lignes (`recompute_totaux`) puis figés pour garder la valeur historique. +- **LigneFacture** — détail (désignation, quantité, prix unitaire HT, taux TVA). +- **Depense** — suivi de trésorerie ; `montant_tva` sert de TVA déductible. +- **Bareme** — taux et seuils par activité et par année (aucun taux codé en dur). + +## Calculs (`compta/services.py`) + +Toutes les bases s'appuient sur le **CA hors taxes (HT) encaissé** : + +| Indicateur | Formule | +|-----------|---------| +| Cotisations URSSAF | CA HT × `taux_urssaf` | +| Versement libératoire | CA HT × `taux_vl` | +| Revenu imposable | CA HT × (1 − `abattement_fiscal`) | +| TVA à reverser | TVA collectée (ventes encaissées) − TVA déductible (dépenses) | +| % plafond | CA HT ÷ `seuil_ca` | + +> Pourquoi le HT ? Quand l'entreprise est assujettie à la TVA, la base déclarée à +> l'URSSAF et au fisc est le chiffre d'affaires **hors taxes**. En franchise, HT = TTC, +> donc la formule reste correcte dans les deux cas. + +## Évolutivité + +- **Multi-entreprises** : tout est filtré par `entreprise`. +- **Changement de statut** : on garde les tables ventes/dépenses/clients ; on ajoute + seulement de nouvelles règles de calcul (ex. charges réelles déductibles au réel). +- **Nouvelle année** : ajoute des `Bareme` avec `annee = 2027` ; rien d'autre à changer. diff --git a/docs/regles-fiscales-2026.md b/docs/regles-fiscales-2026.md new file mode 100644 index 0000000..bf5f682 --- /dev/null +++ b/docs/regles-fiscales-2026.md @@ -0,0 +1,65 @@ +# Règles fiscales micro-entreprise — 2026 + +Valeurs utilisées par l'application (table `bareme`). À revérifier chaque année sur urssaf.fr et impots.gouv.fr. + +## Plafonds de chiffre d'affaires (régime micro) + +Seuils revalorisés pour 2026 : + +| Activité | Plafond CA | +|----------|-----------| +| Vente de marchandises / hébergement (BIC) | 203 100 € | +| Prestations de services (BIC et BNC) | 83 600 € | + +Au-delà, sortie du régime micro. + +## Cotisations sociales URSSAF (taux 2026) + +| Activité | Taux | +|----------|------| +| Vente de marchandises (BIC) | 12,3 % | +| Prestations de services BIC | 21,2 % | +| Professions libérales BNC | 25,6 % | +| Professions libérales CIPAV | 23,2 % | + +Au 1er janvier 2026, le taux des prestations BNC augmente de 1 point. Calcul : `cotisations = CA encaissé × taux`. (Le barème de l'app retient 25,6 % pour `service_bnc` ; ajustez à 23,2 % si l'activité relève de la CIPAV.) + +## TVA — franchise en base (seuils 2026) + +| Activité | Seuil de base | Seuil majoré | +|----------|--------------|--------------| +| Vente / hébergement | 85 000 € | 93 500 € | +| Prestations de services | 37 500 € | 41 250 € | + +En franchise en base : pas de TVA facturée ni collectée, pas de déclaration ; mention obligatoire sur les factures : **« TVA non applicable, article 293 B du CGI »**. En contrepartie, la TVA sur les achats n'est pas déductible. + +Important : les seuils de franchise TVA sont distincts des plafonds du régime micro. On peut dépasser le seuil TVA tout en restant en micro-entreprise. Au-delà du seuil majoré, on devient redevable de la TVA dès le jour du dépassement. + +## Impôt sur le revenu + +**Abattement forfaitaire** (régime de droit commun) appliqué au CA pour obtenir le revenu imposable : + +| Activité | Abattement | +|----------|-----------| +| Vente de marchandises | 71 % | +| Prestations de services BIC | 50 % | +| Prestations / BNC | 34 % | + +**Versement libératoire** (option) — taux appliqué directement au CA brut, sans abattement : + +| Activité | Taux | +|----------|------| +| Vente (BIC) | 1 % | +| Prestations commerciales/artisanales (BIC) | 1,7 % | +| Autres prestations et BNC | 2,2 % | + +Éligibilité au versement libératoire en 2026 : revenu fiscal de référence 2024 inférieur à 29 315 € par part. + +## Sources + +- [Seuils TVA micro-entreprise 2026 — petite-entreprise.net](https://www.petite-entreprise.net/aucune/seuils-tva-micro-entreprise-2026.html) +- [2026 : modification des seuils de chiffre d'affaires — autoentrepreneur.urssaf.fr](https://www.autoentrepreneur.urssaf.fr/portail/accueil/sinformer-sur-le-statut/toutes-les-actualites/2026--modification-des-seuils-de.html) +- [Évolution des taux de cotisations sociales — urssaf.fr](https://www.urssaf.fr/accueil/actualites/taux-cotisations-autoentrepeneur.html) +- [Micro-entreprises, montant des cotisations sociales — economie.gouv.fr](https://www.economie.gouv.fr/entreprises/gerer-sa-micro-entreprise/micro-entreprises-quel-est-le-montant-de-vos-cotisations-sociales) +- [Le versement libératoire — impots.gouv.fr](https://www.impots.gouv.fr/professionnel/le-versement-liberatoire) +- [Régime fiscal de la micro-entreprise — service-public.gouv.fr](https://entreprendre.service-public.gouv.fr/vosdroits/F23267) diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..5dac58d --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +echo "→ Migrations de la base..." +python manage.py migrate --noinput + +# Création automatique d'un compte admin si les variables sont fournies +if [ -n "$DJANGO_SUPERUSER_USERNAME" ] && [ -n "$DJANGO_SUPERUSER_PASSWORD" ]; then + echo "→ Vérification du compte admin..." + python manage.py createsuperuser --noinput 2>/dev/null || true +fi + +# Chargement optionnel des données d'exemple (barèmes + démo) +if [ "$SEED_DEMO" = "1" ]; then + echo "→ Chargement des données d'exemple..." + python manage.py seed_demo || true +fi + +echo "→ Démarrage de Gunicorn sur :8000" +exec gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..0eee720 --- /dev/null +++ b/manage.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +"""Utilitaire en ligne de commande Django.""" +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Django introuvable. Active ton environnement virtuel et " + "installe les dépendances (pip install -r requirements.txt)." + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1d468f1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Django>=5.0,<5.3 +psycopg2-binary>=2.9 +gunicorn>=22.0 +python-dotenv>=1.0 +whitenoise>=6.6