JB Compta v1.1 - multi-activité

This commit is contained in:
2026-06-29 23:56:40 +02:00
commit 1716cebfbd
34 changed files with 1568 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.gitignore
.env
*.sqlite3
__pycache__/
*.pyc
staticfiles/
docs/
*.md
+23
View File
@@ -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
+8
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
# Secrets & env
.env
*.env.local
# Postgres dumps
*.dump
*.bak
# OS
.DS_Store
Thumbs.db
# Logs
*.log
+19
View File
@@ -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"]
+76
View File
@@ -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).
+74
View File
@@ -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.
+8
View File
@@ -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"
View File
+65
View File
@@ -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"
+7
View File
@@ -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"
View File
+93
View File
@@ -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."))
+130
View File
@@ -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',
},
),
]
@@ -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'),
),
]
View File
+199
View File
@@ -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"))
+154
View File
@@ -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)
+169
View File
@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>JB Compta — Tableau de bord</title>
<style>
:root { --bg:#0f172a; --card:#1e293b; --txt:#e2e8f0; --muted:#94a3b8;
--accent:#38bdf8; --ok:#34d399; --warn:#fbbf24; --bad:#f87171; }
* { box-sizing:border-box; }
body { margin:0; font-family:system-ui,Segoe UI,Roboto,sans-serif;
background:var(--bg); color:var(--txt); padding:24px; }
h1 { margin:0 0 4px; font-size:22px; }
h2 { font-size:15px; color:var(--muted); margin:24px 0 10px; font-weight:600; }
a { color:var(--accent); }
.sub { color:var(--muted); margin-bottom:20px; }
form.filters { display:flex; gap:12px; flex-wrap:wrap; margin-bottom:24px; }
select, button { background:var(--card); color:var(--txt); border:1px solid #334155;
border-radius:8px; padding:8px 12px; font-size:14px; }
button { cursor:pointer; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr));
gap:16px; margin-bottom:8px; }
.card { background:var(--card); border:1px solid #334155; border-radius:12px;
padding:16px; }
.card .label { color:var(--muted); font-size:13px; }
.card .value { font-size:26px; font-weight:600; margin-top:6px; }
.card .hint { color:var(--muted); font-size:12px; margin-top:4px; }
.ok{color:var(--ok)} .warn{color:var(--warn)} .bad{color:var(--bad)}
.bar { height:8px; background:#334155; border-radius:99px; overflow:hidden; margin-top:8px;}
.bar > span { display:block; height:100%; background:var(--accent); }
.row { display:grid; grid-template-columns:2fr 1fr; gap:16px; margin-top:8px; }
@media(max-width:800px){ .row{grid-template-columns:1fr;} }
.exports a { display:inline-block; margin-right:12px; }
canvas { max-width:100%; }
table { width:100%; border-collapse:collapse; font-size:14px; }
td,th { padding:8px; border-bottom:1px solid #334155; text-align:right; }
th:first-child, td:first-child { text-align:left; }
tr.total td { font-weight:600; border-top:2px solid #475569; }
</style>
</head>
<body>
{% if not entreprise %}
<h1>Aucune entreprise</h1>
<p class="sub">Crée une entreprise et des factures dans
<a href="/admin/">l'administration</a>, puis reviens ici.</p>
{% else %}
<h1>{{ entreprise.nom }}</h1>
<div class="sub">{{ 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 }}</div>
<form class="filters" method="get">
<select name="entreprise" onchange="this.form.submit()">
{% for e in entreprises %}
<option value="{{ e.pk }}" {% if e.pk == entreprise.pk %}selected{% endif %}>{{ e.nom }}</option>
{% endfor %}
</select>
<select name="annee" onchange="this.form.submit()">
{% for a in annees %}
<option value="{{ a }}" {% if a == annee %}selected{% endif %}>{{ a }}</option>
{% endfor %}
</select>
<a href="/admin/"><button type="button">Saisie / Admin</button></a>
</form>
{% if bareme_manquant %}
<div class="card bad" style="margin-bottom:16px;">
Une activité avec du CA n'a pas de barème {{ annee }}. Ajoute-le dans
<a href="/admin/compta/bareme/">Barèmes</a>.
</div>
{% endif %}
<!-- Totaux entreprise -->
<div class="grid">
<div class="card">
<div class="label">CA HT encaissé (total)</div>
<div class="value">{{ ca_ht }} €</div>
</div>
<div class="card">
<div class="label">Cotisations URSSAF (total)</div>
<div class="value">{{ cotisations_urssaf|default:"—" }} €</div>
</div>
<div class="card">
<div class="label">Impôt — versement libératoire</div>
<div class="value">{{ versement_liberatoire|default:"—" }} €</div>
</div>
<div class="card">
<div class="label">TVA à reverser</div>
<div class="value">{{ tva_a_reverser }} €</div>
<div class="hint">{{ tva_collectee }} collectée {{ tva_deductible }} déductible</div>
</div>
</div>
<!-- Ventilation par activité -->
<h2>Ventilation par activité</h2>
<div class="card">
<table>
<thead>
<tr>
<th>Activité</th><th>CA HT</th><th>% plafond</th>
<th>URSSAF</th><th>Impôt (VL)</th><th>Revenu imposable</th>
</tr>
</thead>
<tbody>
{% for l in lignes_activite %}
<tr>
<td>{{ l.label }}</td>
<td>{{ l.ca_ht }} €</td>
<td class="{% if l.depasse_seuil_ca %}bad{% endif %}">
{% if l.pct_seuil_ca is not None %}{{ l.pct_seuil_ca }} %{% else %}—{% endif %}
</td>
<td>{% if l.cotisations is not None %}{{ l.cotisations }} €{% else %}—{% endif %}</td>
<td>{% if l.versement_liberatoire is not None %}{{ l.versement_liberatoire }} €{% else %}—{% endif %}</td>
<td>{% if l.revenu_imposable is not None %}{{ l.revenu_imposable }} €{% else %}—{% endif %}</td>
</tr>
{% endfor %}
<tr class="total">
<td>Total</td>
<td>{{ ca_ht }} €</td>
<td></td>
<td>{{ cotisations_urssaf|default:"—" }} €</td>
<td>{{ versement_liberatoire|default:"—" }} €</td>
<td>{{ revenu_imposable|default:"—" }} €</td>
</tr>
</tbody>
</table>
</div>
<div class="row">
<div class="card">
<div class="label" style="margin-bottom:8px;">CA HT mensuel (toutes activités)</div>
<canvas id="caChart" height="120"></canvas>
</div>
<div class="card">
<div class="label" style="margin-bottom:8px;">À provisionner (année)</div>
<table>
<tr><th>Poste</th><th>Montant</th></tr>
<tr><td>URSSAF</td><td>{{ cotisations_urssaf|default:"—" }} €</td></tr>
<tr><td>Impôt (VL)</td><td>{{ versement_liberatoire|default:"—" }} €</td></tr>
<tr><td>TVA</td><td>{{ tva_a_reverser }} €</td></tr>
</table>
<div class="exports" style="margin-top:16px;">
<div class="label" style="margin-bottom:6px;">Exports CSV</div>
<a href="{% url 'export_ventes' %}?entreprise={{ entreprise.pk }}&annee={{ annee }}">Ventes</a>
<a href="{% url 'export_tva' %}?entreprise={{ entreprise.pk }}&annee={{ annee }}">TVA</a>
<a href="{% url 'export_urssaf' %}?entreprise={{ entreprise.pk }}&annee={{ annee }}">URSSAF</a>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
const ctx = document.getElementById('caChart');
new Chart(ctx, {
type: 'bar',
data: {
labels: {{ mois_labels|safe }},
datasets: [{ label: 'CA HT (€)', data: {{ ca_mensuel_json|safe }},
backgroundColor: '#38bdf8' }]
},
options: { plugins:{legend:{display:false}},
scales:{ x:{ticks:{color:'#94a3b8'}}, y:{ticks:{color:'#94a3b8'}} } }
});
</script>
{% endif %}
</body>
</html>
+9
View File
@@ -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"),
]
+89
View File
@@ -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
View File
+5
View File
@@ -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()
+110
View File
@@ -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/"
+13
View File
@@ -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")),
]
+5
View File
@@ -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()
+34
View File
@@ -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}"
+83
View File
@@ -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.
+39
View File
@@ -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.
+65
View File
@@ -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)
+20
View File
@@ -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
+20
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
Django>=5.0,<5.3
psycopg2-binary>=2.9
gunicorn>=22.0
python-dotenv>=1.0
whitenoise>=6.6