monregistraire/app/accounting.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

197 lines
7.3 KiB
Python

"""
Algorithme comptable déterministe (Québec/Canada) — Étape 4 validée.
process_asset_detachment : impact fiscal du détachement d'un composant
enfant de son parent. Aucune décision heuristique : entrées identiques
=> sortie identique.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal, ROUND_HALF_UP
from typing import Literal
DetachmentReason = Literal["stock", "scrap"]
TWOPLACES = Decimal("0.01")
def _q(x: Decimal) -> Decimal:
"""Arrondi monétaire déterministe à 2 décimales (demi vers le haut)."""
return x.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
@dataclass(frozen=True)
class JournalLine:
account: str
label: str
debit: Decimal = Decimal("0.00")
credit: Decimal = Decimal("0.00")
def to_dict(self) -> dict:
return {
"account": self.account,
"label": self.label,
"debit": str(self.debit),
"credit": str(self.credit),
}
@dataclass(frozen=True)
class DetachmentResult:
child_id: int
reason: DetachmentReason
detachment_date: str
capitalizable_base_cad: Decimal
days_in_service: int
useful_life_days: int
accumulated_depreciation_cad: Decimal
net_book_value_cad: Decimal
fiscal_cca_taken_indicative_cad: Decimal
fiscal_undepreciated_balance_cad: Decimal
fiscal_note: str
journal_entries: list[JournalLine]
parent_capitalizable_base_reduction_cad: Decimal
def to_dict(self) -> dict:
return {
"child_id": self.child_id,
"reason": self.reason,
"detachment_date": self.detachment_date,
"capitalizable_base_cad": str(self.capitalizable_base_cad),
"days_in_service": self.days_in_service,
"useful_life_days": self.useful_life_days,
"accumulated_depreciation_cad": str(self.accumulated_depreciation_cad),
"net_book_value_cad": str(self.net_book_value_cad),
"fiscal_cca_taken_indicative_cad": str(self.fiscal_cca_taken_indicative_cad),
"fiscal_undepreciated_balance_cad": str(self.fiscal_undepreciated_balance_cad),
"fiscal_note": self.fiscal_note,
"journal_entries": [l.to_dict() for l in self.journal_entries],
"parent_capitalizable_base_reduction_cad": str(
self.parent_capitalizable_base_reduction_cad
),
}
# Durée de vie comptable utilisée pour le prorata linéaire (paramétrable).
# Catégorie 50 = matériel informatique : 4 ans par défaut.
DEFAULT_USEFUL_LIFE_DAYS = 365 * 4
# Comptes (plan comptable interne déterministe)
ACCT_ASSET_COST = "1700-Immobilisations-Materiel-Informatique"
ACCT_ACCUM_DEPREC = "1709-Amortissement-Cumule-Materiel"
ACCT_PARTS_INVENTORY = "1340-Inventaire-Pieces-Detachees"
ACCT_LOSS_ON_DISPOSAL = "7200-Perte-sur-Disposition-Actif"
def process_asset_detachment(
child: dict,
detachment_date: date,
reason: DetachmentReason,
useful_life_days: int = DEFAULT_USEFUL_LIFE_DAYS,
) -> DetachmentResult:
"""Impact comptable et fiscal (Québec/Canada) du détachement d'un enfant."""
cpa = child["view_cpa"]
child_id = int(child["id"])
base = Decimal(str(cpa["capitalizable_base_cad"]))
dpa_rate = Decimal(str(cpa["cca_class"]["dpa_rate"])) # 0.55
if base < 0:
raise ValueError("Base amortissable négative.")
in_service_str = cpa.get("in_service_date") or ""
if not in_service_str:
# Pièce jamais mise en service : zéro amortissement, VNC = coût intégral.
days_in_service = 0
accum_deprec = Decimal("0.00")
nbv = base
fiscal_cca = Decimal("0.00")
fiscal_ucc = base
fiscal_note = (
"Pièce retirée sans avoir été mise en service (aucune date de mise en "
"service enregistrée). Aucun amortissement comptable ni fiscal appliqué : "
"VNC = coût d'acquisition intégral."
)
else:
in_service = date.fromisoformat(in_service_str)
if detachment_date < in_service:
raise ValueError("La date de détachement précède la mise en service.")
# --- 1. Amortissement comptable au prorata temporis exact (en jours) ---
days_in_service = (detachment_date - in_service).days
capped_days = min(days_in_service, useful_life_days)
accum_deprec = _q(base * Decimal(capped_days) / Decimal(useful_life_days))
nbv = _q(base - accum_deprec)
# --- 2. Suivi fiscal indicatif (déclin 55% + règle demi-année) ---
fiscal_year_1_base = base / Decimal(2)
full_years = capped_days // 365
ucc = base
cca_total = Decimal("0")
if full_years >= 1:
y1 = _q(fiscal_year_1_base * dpa_rate)
cca_total += y1
ucc -= y1
for _ in range(1, full_years):
yn = _q(ucc * dpa_rate)
cca_total += yn
ucc -= yn
else:
y1 = _q(fiscal_year_1_base * dpa_rate)
cca_total += y1
ucc -= y1
fiscal_cca = _q(cca_total)
fiscal_ucc = _q(ucc)
fiscal_note = (
"DPA fiscale calculée à titre indicatif au niveau de la pièce. "
"La disposition réelle (produit vs FNACC de la Catégorie 50) se traite "
"au niveau du solde de catégorie : récupération d'amortissement ou perte "
"finale uniquement si la catégorie est vidée. Aucune perte en capital "
"sur biens amortissables."
)
# --- 3. Écriture de journal selon le motif ---
journal: list[JournalLine] = []
if reason == "stock":
journal.append(JournalLine(ACCT_PARTS_INVENTORY, "Transfert pièce détachée (VNC)", debit=nbv))
journal.append(JournalLine(ACCT_ACCUM_DEPREC, "Reprise amortissement cumulé", debit=accum_deprec))
journal.append(JournalLine(ACCT_ASSET_COST, "Sortie coût d'origine immobilisation", credit=base))
elif reason == "scrap":
journal.append(JournalLine(ACCT_ACCUM_DEPREC, "Reprise amortissement cumulé", debit=accum_deprec))
journal.append(JournalLine(ACCT_LOSS_ON_DISPOSAL, "Perte sèche sur disposition (VNC)", debit=nbv))
journal.append(JournalLine(ACCT_ASSET_COST, "Sortie coût d'origine immobilisation", credit=base))
else:
raise ValueError(f"Motif de détachement invalide: {reason}")
total_debit = sum((l.debit for l in journal), Decimal("0"))
total_credit = sum((l.credit for l in journal), Decimal("0"))
if _q(total_debit) != _q(total_credit):
raise ArithmeticError(f"Écriture déséquilibrée: D={total_debit} C={total_credit}")
# --- 4. Impact sur le parent ---
parent_reduction = base
return DetachmentResult(
child_id=child_id,
reason=reason,
detachment_date=detachment_date.isoformat(),
capitalizable_base_cad=base,
days_in_service=days_in_service,
useful_life_days=useful_life_days,
accumulated_depreciation_cad=accum_deprec,
net_book_value_cad=nbv,
fiscal_cca_taken_indicative_cad=fiscal_cca,
fiscal_undepreciated_balance_cad=fiscal_ucc,
fiscal_note=fiscal_note,
journal_entries=journal,
parent_capitalizable_base_reduction_cad=parent_reduction,
)