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>
197 lines
7.3 KiB
Python
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,
|
|
)
|