monregistraire/app/cca_registry.py
Daniel Allaire be7a64a6cc Lancement v1.0 — Gestionnaire d'immobilisations fiscales (Chezlepro Inc.)
Application web locale (FastAPI + SQLite + React SPA) pour la gestion des
immobilisations fiscales d'une PME québécoise (T2 / CO-17).

─── Modèle de données ───────────────────────────────────────────────────────
• Deux niveaux : PARENT (immobilisation fiscale) + PIÈCE (inventaire physique)
• Trois vues par actif : Propriétaire, CPA, Auditeur
• Porteur d'étiquette d'assemblage (unique par immobilisation)
• Piste d'audit journalisée sur chaque modification

─── Mécanique fiscale ───────────────────────────────────────────────────────
• Pool DPA par catégorie (Cat. 8, 10, 12, 46, 50) — amortissement dégressif
• Règle de la demi-année paramétrable (coeff. 0..1 par exercice)
• Perte finale et récupération d'amortissement calculées automatiquement
• Annexe 8 / Schedule 8 multi-catégories (JSON + CSV téléchargeable)
• Rapport fiscal HTML imprimable — lignes Schedule 1 (T2 : 104/107/410/414 ;
  CO-17 : 130/135/150/155)
• Export immobilisations par catégorie (JSON + CSV)

─── Fonctionnalités ─────────────────────────────────────────────────────────
• CRUD complet : parents et pièces, rattachement/détachement (libre/stock/rebut)
• Écriture comptable générée au détachement
• Registre de pool DPA multi-exercices avec override manuel
• Sauvegarde/restauration (SQLite native backup, horodatée)
• Factures jointes aux pièces (PDF/JPG/PNG, max 20 Mo, UUID + regex)
• UI mobile-first : bottom nav, FAB, bottom sheet, safe-area iPhone
• Guide utilisateur complet in-app (aide.html, 11 sections)
• Données de démonstration riches (6 immos, 5 catégories, perte finale +
  récupération incluses)

─── Qualité ─────────────────────────────────────────────────────────────────
• 50 tests pytest — 100 % verts (calculs DPA, export, backup, factures, seed)
• Lancement en 1 commande : ./lancer.sh (Mac/Linux) ou lancer.bat (Windows)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 10:30:53 -04:00

171 lines
7.8 KiB
Python

"""
Registre de catégorie (pool fiscal) — couche au-dessus de l'inventaire.
L'inventaire physique (assets : parents/pièces) et le pool fiscal sont
SÉPARÉS. Le pool agrège des flux financiers par année et par catégorie DPA,
indépendamment du serveur physique auquel une dépense est rattachée.
Table `cca_pool_years` :
- year, cca_class_id : clé (une ligne par année et catégorie)
- additions_override : ajouts saisis manuellement (NULL = dérivé)
- dispositions : produit des dispositions de l'année
- adjustment_coeff : coeff 0..1 sur ajouts nets (0.5 = demi-année)
- pool_physically_empty : 1 si plus aucun actif physique (perte finale)
- opening_ucc_override : FNACC d'ouverture initiale (1re année)
Les AJOUTS d'une année peuvent être dérivés automatiquement de l'inventaire
(somme des coûts des acquisitions dont l'année d'acquisition = year) ou
saisis manuellement (override). Les dispositions et le coefficient sont
toujours saisis par l'usager.
"""
from __future__ import annotations
import sqlite3
from decimal import Decimal
from threading import Lock
from .cca_pool import YearInput, compute_pool_timeline
class CcaRegistry:
def __init__(self, conn: sqlite3.Connection, store, lock: Lock) -> None:
self._conn = conn
self._store = store
self._lock = lock
self._init_schema()
def _init_schema(self) -> None:
self._conn.execute("""
CREATE TABLE IF NOT EXISTS cca_pool_years (
year INTEGER NOT NULL,
cca_class_id INTEGER NOT NULL,
additions_override REAL,
dispositions REAL NOT NULL DEFAULT 0,
adjustment_coeff REAL NOT NULL DEFAULT 0.5,
pool_physically_empty INTEGER NOT NULL DEFAULT 0,
opening_ucc_override REAL,
PRIMARY KEY (year, cca_class_id)
)
""")
self._conn.commit()
# ---- Dérivation des ajouts depuis l'inventaire ----
def _derived_additions(self, year: int, cca_class_id: int) -> Decimal:
"""Somme des coûts des acquisitions de l'année pour cette catégorie.
On compte : le coût des PIÈCES dont acquisition_date tombe dans l'année
(qu'elles soient rattachées ou libres) + les PARENTS sans pièce (coût
propre). Un parent avec pièces n'est pas compté en double : ses pièces
portent déjà le coût."""
total = Decimal("0")
for a in self._store.all():
cca = a["view_cpa"]["cca_class"]["id"]
if cca != cca_class_id:
continue
acq = (a["view_cpa"].get("acquisition_date") or "")[:4]
if acq != str(year):
continue
if a["type"] == "piece":
total += Decimal(str(a["view_cpa"]["capitalizable_base_cad"]))
elif a["type"] == "parent":
# Parent sans pièce : on prend son coût propre (sinon 0, ses
# pièces sont comptées séparément).
if a["view_admin"].get("pieces_count", 0) == 0:
total += Decimal(str(a["view_cpa"]["capitalizable_base_cad"]))
return total
def _physical_count(self, cca_class_id: int) -> int:
"""Nombre d'actifs physiques actifs dans la catégorie (parents + pièces
non au rebut)."""
n = 0
for a in self._store.all():
if a["view_cpa"]["cca_class"]["id"] != cca_class_id:
continue
if a["view_admin"].get("operational_status") == "Rebut":
continue
n += 1
return n
# ---- CRUD années ----
def upsert_year(self, year: int, cca_class_id: int = 50, **kw) -> None:
with self._lock:
existing = self._conn.execute(
"SELECT 1 FROM cca_pool_years WHERE year=? AND cca_class_id=?",
(year, cca_class_id)).fetchone()
fields = {
"additions_override": kw.get("additions_override"),
"dispositions": float(kw.get("dispositions", 0) or 0),
"adjustment_coeff": float(kw.get("adjustment_coeff", 0.5) or 0.5),
"pool_physically_empty": 1 if kw.get("pool_physically_empty") else 0,
"opening_ucc_override": kw.get("opening_ucc_override"),
}
if existing:
self._conn.execute("""
UPDATE cca_pool_years SET additions_override=?, dispositions=?,
adjustment_coeff=?, pool_physically_empty=?, opening_ucc_override=?
WHERE year=? AND cca_class_id=?""",
(fields["additions_override"], fields["dispositions"],
fields["adjustment_coeff"], fields["pool_physically_empty"],
fields["opening_ucc_override"], year, cca_class_id))
else:
self._conn.execute("""
INSERT INTO cca_pool_years (year, cca_class_id, additions_override,
dispositions, adjustment_coeff, pool_physically_empty, opening_ucc_override)
VALUES (?,?,?,?,?,?,?)""",
(year, cca_class_id, fields["additions_override"], fields["dispositions"],
fields["adjustment_coeff"], fields["pool_physically_empty"],
fields["opening_ucc_override"]))
self._conn.commit()
def delete_year(self, year: int, cca_class_id: int = 50) -> None:
with self._lock:
self._conn.execute("DELETE FROM cca_pool_years WHERE year=? AND cca_class_id=?",
(year, cca_class_id))
self._conn.commit()
def _year_rows(self, cca_class_id: int) -> list[sqlite3.Row]:
cur = self._conn.execute(
"SELECT * FROM cca_pool_years WHERE cca_class_id=? ORDER BY year",
(cca_class_id,))
return cur.fetchall()
# ---- Calcul du registre ----
def compute(self, cca_class_id: int = 50, cca_rate: Decimal = Decimal("0.55")) -> dict:
"""Calcule la timeline du pool pour une catégorie.
Les ajouts sont dérivés de l'inventaire sauf si un override est saisi."""
rows = self._year_rows(cca_class_id)
# Si aucune année déclarée, on déduit les années présentes dans l'inventaire.
if not rows:
years_seen = set()
for a in self._store.all():
if a["view_cpa"]["cca_class"]["id"] == cca_class_id:
acq = (a["view_cpa"].get("acquisition_date") or "")[:4]
if acq.isdigit():
years_seen.add(int(acq))
for y in years_seen:
self.upsert_year(y, cca_class_id)
rows = self._year_rows(cca_class_id)
opening = Decimal("0")
year_inputs = []
for r in rows:
add = (Decimal(str(r["additions_override"]))
if r["additions_override"] is not None
else self._derived_additions(r["year"], cca_class_id))
if r["opening_ucc_override"] is not None and not year_inputs:
opening = Decimal(str(r["opening_ucc_override"]))
year_inputs.append(YearInput(
year=r["year"],
additions=add,
dispositions=Decimal(str(r["dispositions"])),
adjustment_coeff=Decimal(str(r["adjustment_coeff"])),
pool_physically_empty=bool(r["pool_physically_empty"]),
))
results = compute_pool_timeline(year_inputs, opening_ucc=opening, cca_rate=cca_rate)
return {
"cca_class_id": cca_class_id,
"cca_rate": str(cca_rate),
"physical_count": self._physical_count(cca_class_id),
"years": [r.to_dict() for r in results],
}