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