commit 1716cebfbd91d8c8545100c10f692f559d87019a Author: admin Date: Mon Jun 29 23:56:40 2026 +0200 JB Compta v1.1 - multi-activité 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