JB Compta v1.1 - multi-activité
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
*.sqlite3
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
staticfiles/
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
@@ -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
|
||||||
@@ -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
@@ -0,0 +1,14 @@
|
|||||||
|
# Secrets & env
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
|
||||||
|
# Postgres dumps
|
||||||
|
*.dump
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
+19
@@ -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"]
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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."))
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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"))
|
||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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/"
|
||||||
@@ -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")),
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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}"
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Django>=5.0,<5.3
|
||||||
|
psycopg2-binary>=2.9
|
||||||
|
gunicorn>=22.0
|
||||||
|
python-dotenv>=1.0
|
||||||
|
whitenoise>=6.6
|
||||||
Reference in New Issue
Block a user