From be7a64a6ccc563d15b50762b43eeb8c1aa5fcb26 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 10 Jun 2026 10:30:53 -0400 Subject: [PATCH] =?UTF-8?q?Lancement=20v1.0=20=E2=80=94=20Gestionnaire=20d?= =?UTF-8?q?'immobilisations=20fiscales=20(Chezlepro=20Inc.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 33 + README.md | 73 +++ app/__init__.py | 0 app/accounting.py | 197 ++++++ app/cca_pool.py | 163 +++++ app/cca_registry.py | 171 ++++++ app/main.py | 828 +++++++++++++++++++++++++ app/seed.py | 139 +++++ app/store.py | 577 +++++++++++++++++ data/.gitkeep | 0 lancer.bat | 28 + lancer.sh | 32 + requirements.txt | 3 + scripts/import_config.py | 241 ++++++++ static/aide.html | 830 +++++++++++++++++++++++++ static/index.html | 1256 ++++++++++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 7 + tests/test_api.py | 636 +++++++++++++++++++ 19 files changed, 5214 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/accounting.py create mode 100644 app/cca_pool.py create mode 100644 app/cca_registry.py create mode 100644 app/main.py create mode 100644 app/seed.py create mode 100644 app/store.py create mode 100644 data/.gitkeep create mode 100644 lancer.bat create mode 100755 lancer.sh create mode 100644 requirements.txt create mode 100644 scripts/import_config.py create mode 100644 static/aide.html create mode 100644 static/index.html create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..000893a --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +.venv/ +*.egg-info/ + +# Données / bases SQLite +*.db +data/backups/ +backups/ + +# Répertoire de tests pytest +.pytest_cache/ + +# Fichiers de travail (anciennes versions conservées localement) +*.old +*_v3.* +*_v4.* +cca_pool.py +cca_registry.py +index_v3.html +index_v4.html +main_v3.py +main_v4.py +store_v3.py +store_v4.py +immos_test.db + +# Éditeurs +.DS_Store +*.swp +*.swo diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ba0b07 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Gestionnaire d'immobilisations fiscales + +Outil de gestion d'actifs pour les PME québécoises / canadiennes. +Produit les intrants pour les déclarations fiscales (Annexe 8 / Schedule 8, CO-17) +et maintient un inventaire d'équipements défendable devant un auditeur. + +> **Avis** : Les calculs DPA/FNACC sont structurellement corrects mais indicatifs. +> Toute déclaration fiscale doit être validée par un CPA autorisé. + +--- + +## Lancement + +Requiert Python 3.10+ ([python.org](https://www.python.org/downloads/)). + +**Mac / Linux** — dans un terminal : +```bash +./lancer.sh +``` + +**Windows** — double-clic sur `lancer.bat` + +L'environnement Python est créé automatiquement à la première utilisation. +Les données sont conservées dans le dossier `data/` du projet. + +--- + +## Ce que l'outil produit + +| Vue | Pour qui | Contenu | +|---|---|---| +| **Propriétaire** | Dirigeant | VNC linéaire, coût de remplacement, statut, barre d'amortissement | +| **CPA** | Comptable | FNACC fiscale (décroissante), base capitalisable, catégorie DPA | +| **Auditeur** | Inspecteur | Numéro de série, fournisseur, facture, piste d'audit horodatée | + +### Fonctions fiscales +- Registre pool par catégorie DPA (Cat. 50, 46, 8, 12, 10…) +- Annexe 8 / Schedule 8 multi-catégories avec export CSV +- FNACC indicative par pièce (taux dégressif + règle demi-année) +- Amortissement linéaire indicatif par pièce (usage comptable) + +### Fonctions d'inventaire +- Modèle à deux niveaux : immobilisation fiscale → pièces composantes +- Porteur d'étiquette : une pièce porte le tag, les autres l'héritent +- Piste d'audit complète sur chaque actif (toutes modifications horodatées) +- Coût de remplacement pour assurances et budgets + +--- + +## Structure + +``` +app/ + main.py Routes FastAPI + store.py Persistance SQLite + calculs + accounting.py Comptabilité de détachement (journal) + cca_registry.py Registre pool DPA + cca_pool.py Calculs pool CCA +static/ + index.html Interface React +data/ + immos.db Base de données (créée au premier lancement) +``` + +--- + +## Développement + +```bash +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/uvicorn app.main:app --reload --port 8001 +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/accounting.py b/app/accounting.py new file mode 100644 index 0000000..72d5725 --- /dev/null +++ b/app/accounting.py @@ -0,0 +1,197 @@ +""" +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, + ) diff --git a/app/cca_pool.py b/app/cca_pool.py new file mode 100644 index 0000000..a119313 --- /dev/null +++ b/app/cca_pool.py @@ -0,0 +1,163 @@ +""" +Module fiscal — Registre de catégorie (pool global), ex. Catégorie 50. + +L'actif fiscal est un FLUX, pas un objet. La DPA se calcule sur le solde +global de la catégorie (FNACC), pas pièce par pièce. + +Mécanique annuelle (déterministe) : + + FNACC_ouverture + + ajouts_annee + - dispositions_annee (produit de disposition, souvent 0 $) + = solde_avant_ajustement + - ajustement (coeff * ajouts_nets) ; demi-année => coeff 0.5 + = base_calcul + * taux (0.55) = DPA_annee (bornée >= 0) + FNACC_cloture = solde_avant_ajustement - DPA_annee + +Perte finale : si l'inventaire PHYSIQUE de la catégorie est vide (plus aucun +actif) mais que la FNACC reste positive, ce solde devient une perte finale +déductible à 100 % (DPA = solde, FNACC clôture = 0). + +Récupération d'amortissement : si la FNACC devient négative (dispositions > +solde), ce négatif est un revenu (récupération) ; la FNACC clôture est +ramenée à 0 et le montant est signalé. + +Le coefficient d'ajustement de l'année d'acquisition est PARAMÉTRABLE +(demi-année standard = 0.5 ; incitatif accéléré éventuel saisi par l'usager +sur avis de son CPA). Aucun taux d'accélération n'est câblé en dur. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal, ROUND_HALF_UP + +TWOPLACES = Decimal("0.01") + + +def _q(x: Decimal) -> Decimal: + return x.quantize(TWOPLACES, rounding=ROUND_HALF_UP) + + +@dataclass +class YearResult: + year: int + opening_ucc: Decimal # FNACC d'ouverture + additions: Decimal # ajouts de l'année + dispositions: Decimal # produit des dispositions de l'année + adjustment_coeff: Decimal # coeff sur ajouts nets (0.5 = demi-année) + adjustment: Decimal # montant retiré de la base de calcul + cca_rate: Decimal # 0.55 + base_for_cca: Decimal # base après ajustement + cca_claimed: Decimal # DPA de l'année + closing_ucc: Decimal # FNACC de clôture + recapture: Decimal # récupération (revenu) si > 0 + terminal_loss: Decimal # perte finale (déductible) si > 0 + note: str = "" + + def to_dict(self) -> dict: + return { + "year": self.year, + "opening_ucc": str(self.opening_ucc), + "additions": str(self.additions), + "dispositions": str(self.dispositions), + "adjustment_coeff": str(self.adjustment_coeff), + "adjustment": str(self.adjustment), + "cca_rate": str(self.cca_rate), + "base_for_cca": str(self.base_for_cca), + "cca_claimed": str(self.cca_claimed), + "closing_ucc": str(self.closing_ucc), + "recapture": str(self.recapture), + "terminal_loss": str(self.terminal_loss), + "note": self.note, + } + + +@dataclass +class YearInput: + year: int + additions: Decimal = Decimal("0") + dispositions: Decimal = Decimal("0") + adjustment_coeff: Decimal = Decimal("0.5") # demi-année par défaut + pool_physically_empty: bool = False # plus aucun actif physique + claim_full_cca: bool = True # demander la DPA max cette année + + +def compute_pool_year(opening_ucc: Decimal, yi: YearInput, + cca_rate: Decimal = Decimal("0.55")) -> YearResult: + """Calcule une année du pool de façon déterministe.""" + opening = _q(opening_ucc) + additions = _q(yi.additions) + dispositions = _q(yi.dispositions) + + solde_avant = opening + additions - dispositions + + recapture = Decimal("0") + terminal_loss = Decimal("0") + note = "" + + # Récupération : solde négatif et il reste (ou non) des actifs. + if solde_avant < 0: + recapture = -solde_avant + result = YearResult( + year=yi.year, opening_ucc=opening, additions=additions, + dispositions=dispositions, adjustment_coeff=yi.adjustment_coeff, + adjustment=Decimal("0.00"), cca_rate=cca_rate, + base_for_cca=Decimal("0.00"), cca_claimed=Decimal("0.00"), + closing_ucc=Decimal("0.00"), recapture=_q(recapture), + terminal_loss=Decimal("0.00"), + note="Solde négatif : récupération d'amortissement (revenu imposable). FNACC ramenée à 0.", + ) + return result + + # Perte finale : plus d'actif physique mais solde positif. + if yi.pool_physically_empty and solde_avant > 0: + terminal_loss = solde_avant + return YearResult( + year=yi.year, opening_ucc=opening, additions=additions, + dispositions=dispositions, adjustment_coeff=yi.adjustment_coeff, + adjustment=Decimal("0.00"), cca_rate=cca_rate, + base_for_cca=Decimal("0.00"), cca_claimed=Decimal("0.00"), + closing_ucc=Decimal("0.00"), recapture=Decimal("0.00"), + terminal_loss=_q(terminal_loss), + note="Catégorie vidée physiquement avec solde positif : perte finale déductible à 100 %.", + ) + + # Ajustement de l'année d'acquisition sur les ajouts NETS. + net_additions = additions - dispositions + if net_additions < 0: + net_additions = Decimal("0") # pas d'ajustement négatif + adjustment = _q(yi.adjustment_coeff * net_additions) + + base = solde_avant - adjustment + if base < 0: + base = Decimal("0") + + cca = _q(base * cca_rate) if yi.claim_full_cca else Decimal("0.00") + # La DPA ne peut excéder le solde disponible. + if cca > solde_avant: + cca = solde_avant + + closing = _q(solde_avant - cca) + + return YearResult( + year=yi.year, opening_ucc=opening, additions=additions, + dispositions=dispositions, adjustment_coeff=yi.adjustment_coeff, + adjustment=adjustment, cca_rate=cca_rate, base_for_cca=_q(base), + cca_claimed=cca, closing_ucc=closing, recapture=Decimal("0.00"), + terminal_loss=Decimal("0.00"), + note=note or "Calcul standard : DPA dégressive sur le solde global.", + ) + + +def compute_pool_timeline(years: list[YearInput], + opening_ucc: Decimal = Decimal("0"), + cca_rate: Decimal = Decimal("0.55")) -> list[YearResult]: + """Enchaîne plusieurs années : la clôture d'une année ouvre la suivante.""" + results: list[YearResult] = [] + ucc = opening_ucc + for yi in sorted(years, key=lambda y: y.year): + r = compute_pool_year(ucc, yi, cca_rate) + results.append(r) + ucc = r.closing_ucc + return results diff --git a/app/cca_registry.py b/app/cca_registry.py new file mode 100644 index 0000000..349c3dd --- /dev/null +++ b/app/cca_registry.py @@ -0,0 +1,171 @@ +""" +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], + } diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..1f39c58 --- /dev/null +++ b/app/main.py @@ -0,0 +1,828 @@ +""" +Serveur FastAPI v3 — persistance SQLite, édition complète, export fiscal. + +Routes : + GET /api/assets -> tous les actifs + GET /api/assets/{id} -> un actif + POST /api/assets -> créer (parent | piece) + PATCH /api/assets/{id} -> éditer (changes: {chemin.pointé: valeur}) + POST /api/assets/{id}/attach -> rattacher une pièce à un parent + POST /api/assets/{id}/detach -> détacher (free | stock | scrap) + DELETE /api/assets/{id} -> supprimer (pièces enfants -> libres) + GET /api/fiscal-export -> regroupement par catégorie (JSON) + GET /api/fiscal-export.csv -> même chose en CSV téléchargeable + POST /api/seed-demo -> insère des données d'exemple + POST /api/reset -> vide la base +""" +from __future__ import annotations + +import csv +import io +import re +import uuid +from datetime import date, datetime, timezone +from pathlib import Path + +from fastapi import FastAPI, File, HTTPException, UploadFile +import html as _html +from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from .accounting import process_asset_detachment +from .store import store, registry, PARENT_TYPE, PIECE_TYPE, CCA_CATEGORIES, CCA_BY_ID + +BASE_DIR = Path(__file__).resolve().parent.parent +STATIC_DIR = BASE_DIR / "static" +BACKUP_DIR = store._db_path.parent / "backups" +INVOICES_DIR = store._db_path.parent / "invoices" + +_ALLOWED_EXTS = {".pdf", ".jpg", ".jpeg", ".png"} +_INVOICE_RE = re.compile(r"[0-9a-f]{32}\.(pdf|jpg|jpeg|png)") +_MAX_UPLOAD = 20 * 1024 * 1024 # 20 Mo + +app = FastAPI(title="Gestionnaire d'immobilisations") + + +class CreateBody(BaseModel): + type: str + name: str + cost_cad: float = 0 + currency: str = "CAD" + serial_number: str | None = None + inventory_tag: str | None = None + manufacturer: str | None = None + model: str | None = None + form_factor: str | None = None + assembler: str | None = None + operational_status: str | None = None + cca_class_id: int = 50 + cca_class_desc: str | None = None + dpa_rate: float = 0.55 + exchange_rate: float = 1.0 + non_recoverable_taxes_and_duties: float = 0 + acquisition_date: str | None = None + in_service_date: str | None = None + supplier: str | None = None + invoice_number: str | None = None + invoice_pdf_file_id: str | None = None + replacement_cost_cad: float = 0 + fnacc_override_cad: float | None = None + + +class UpdateBody(BaseModel): + changes: dict # { "view_admin.serial_number": "...", "name": "...", ... } + + +class AttachBody(BaseModel): + parent_id: int + + +class DetachBody(BaseModel): + disposition: str + detachment_date: str | None = None + + +@app.get("/api/cca-categories") +def cca_categories() -> list[dict]: + return CCA_CATEGORIES + + +@app.get("/api/assets") +def list_assets() -> list[dict]: + return store.all() + + +@app.get("/api/assets/{asset_id}") +def get_asset(asset_id: int) -> dict: + a = store.get(asset_id) + if a is None: + raise HTTPException(404, "Actif introuvable.") + return a + + +@app.post("/api/assets") +def create_asset(body: CreateBody) -> dict: + try: + store.create_asset(body.model_dump()) + except ValueError as e: + raise HTTPException(400, str(e)) + return {"assets": store.all()} + + +@app.patch("/api/assets/{asset_id}") +def update_asset(asset_id: int, body: UpdateBody) -> dict: + try: + store.update_asset(asset_id, body.changes) + except (KeyError, ValueError) as e: + raise HTTPException(400, str(e)) + return {"assets": store.all()} + + +@app.post("/api/assets/{asset_id}/attach") +def attach_asset(asset_id: int, body: AttachBody) -> dict: + try: + store.attach(asset_id, body.parent_id) + except (KeyError, ValueError) as e: + raise HTTPException(400, str(e)) + return {"assets": store.all()} + + +@app.post("/api/assets/{asset_id}/detach") +def detach_asset(asset_id: int, body: DetachBody) -> dict: + asset = store.get(asset_id) + if asset is None: + raise HTTPException(404, "Pièce introuvable.") + if body.disposition not in ("free", "stock", "scrap"): + raise HTTPException(400, "Disposition invalide (free|stock|scrap).") + + accounting = None + if body.disposition in ("stock", "scrap"): + d = date.fromisoformat(body.detachment_date) if body.detachment_date else date.today() + try: + accounting = process_asset_detachment(asset, d, body.disposition).to_dict() # type: ignore[arg-type] + except (ValueError, ArithmeticError) as e: + raise HTTPException(400, str(e)) + + store.detach(asset_id, body.disposition) + return {"accounting": accounting, "assets": store.all()} + + +@app.post("/api/assets/{asset_id}/set-tag-bearer") +def set_tag_bearer(asset_id: int) -> dict: + try: + return {"assets": store.set_tag_bearer(asset_id)} + except (KeyError, ValueError) as e: + raise HTTPException(400, str(e)) + + +@app.delete("/api/assets/{asset_id}") +def delete_asset(asset_id: int) -> dict: + if store.get(asset_id) is None: + raise HTTPException(404, "Actif introuvable.") + store.delete_asset(asset_id) + return {"assets": store.all()} + + +@app.get("/api/fiscal-export") +def fiscal_export() -> dict: + return store.fiscal_export() + + +@app.get("/api/fiscal-export.csv") +def fiscal_export_csv() -> StreamingResponse: + data = store.fiscal_export() + buf = io.StringIO() + w = csv.writer(buf) + w.writerow(["Categorie DPA", "Taux DPA", "Immobilisation", "Etiquette", + "Base amortissable CAD", "Mise en service", "Demi-annee", "Nb pieces"]) + for cls in data["classes"]: + for it in cls["items"]: + w.writerow([ + f"Cat. {cls['cca_class_id']} - {cls['description']}", + f"{cls['dpa_rate']*100:.0f}%", + it["name"], it["inventory_tag"], + f"{it['base_cad']:.2f}", it["in_service_date"], + "Oui" if it["half_year_rule"] else "Non", it["pieces_count"], + ]) + w.writerow([f"TOTAL Cat. {cls['cca_class_id']}", "", "", "", + f"{cls['total_base_cad']:.2f}", "", "", ""]) + w.writerow([]) + buf.seek(0) + return StreamingResponse( + iter([buf.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=export_fiscal.csv"}, + ) + + +@app.post("/api/invoices") +async def upload_invoice(file: UploadFile = File(...)) -> dict: + ext = Path(file.filename or "").suffix.lower() + if ext not in _ALLOWED_EXTS: + raise HTTPException(400, f"Format non supporté. Acceptés : PDF, JPG, PNG.") + content = await file.read() + if len(content) > _MAX_UPLOAD: + raise HTTPException(400, "Fichier trop volumineux (max 20 Mo).") + INVOICES_DIR.mkdir(parents=True, exist_ok=True) + file_id = uuid.uuid4().hex + ext + (INVOICES_DIR / file_id).write_bytes(content) + return {"file_id": file_id} + + +@app.get("/api/invoices/{file_id}") +def get_invoice(file_id: str) -> FileResponse: + if not _INVOICE_RE.fullmatch(file_id): + raise HTTPException(400, "Identifiant invalide.") + path = INVOICES_DIR / file_id + if not path.exists(): + raise HTTPException(404, "Fichier introuvable.") + mt = ("application/pdf" if file_id.endswith(".pdf") + else "image/png" if file_id.endswith(".png") + else "image/jpeg") + return FileResponse(str(path), media_type=mt) + + +@app.delete("/api/invoices/{file_id}") +def delete_invoice(file_id: str) -> dict: + if not _INVOICE_RE.fullmatch(file_id): + raise HTTPException(400, "Identifiant invalide.") + path = INVOICES_DIR / file_id + if path.exists(): + path.unlink() + return {"ok": True} + + +@app.get("/api/backups") +def list_backups() -> list[dict]: + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + result = [] + for f in sorted(BACKUP_DIR.glob("immos_*.db"), reverse=True): + stat = f.stat() + result.append({ + "filename": f.name, + "size_kb": round(stat.st_size / 1024, 1), + "created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) + .astimezone().isoformat(timespec="seconds"), + }) + return result + + +@app.post("/api/backup") +def create_backup() -> dict: + ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + dest = BACKUP_DIR / f"immos_{ts}.db" + store.backup(dest) + return {"filename": dest.name, "backups": list_backups()} + + +class RestoreBody(BaseModel): + filename: str + + +@app.post("/api/restore") +def restore_backup(body: RestoreBody) -> dict: + if not re.fullmatch(r"immos_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.db", body.filename): + raise HTTPException(400, "Nom de fichier invalide.") + src = BACKUP_DIR / body.filename + if not src.exists(): + raise HTTPException(404, "Sauvegarde introuvable.") + store.restore_from(src) + return {"assets": store.all()} + + +@app.post("/api/reset") +def reset() -> dict: + store.reset() + return {"assets": store.all()} + + +@app.post("/api/seed-demo") +def seed_demo() -> dict: + """Jeu de données complet — 6 immobilisations, 5 catégories DPA, pièces + actives/stock/rebut, porteurs d'étiquette, registres multi-exercices + couvrant DPA standard, perte finale (Cat. 12) et récupération (Cat. 10).""" + today_str = date.today().isoformat() + + # ── 1. Cat. 50 — Serveur Dell PowerEdge R760 (actif, acquis 2024) ───────── + r760 = store.create_asset({ + "type": PARENT_TYPE, "name": "Serveur Dell PowerEdge R760", + "manufacturer": "Dell", "model": "PowerEdge R760", "form_factor": "2U", + "cca_class_id": 50, "operational_status": "Actif", + "in_service_date": "2024-02-20", "assembler": "Dell Canada Inc.", + }) + r760_pieces = [ + ("Châssis + carte mère R760", "Dell", "R760 Base", 23_436.05, "SRV-CHS-001", "DC-QC-SRV-001"), + ("RAM DDR5 64 Go – Slot 1", "Samsung", "M321R8GA0BB0", 420.00, "MEM-001-S1", ""), + ("RAM DDR5 64 Go – Slot 2", "Samsung", "M321R8GA0BB0", 420.00, "MEM-001-S2", ""), + ("SSD NVMe 3,84 To", "Micron", "7450 PRO", 980.00, "SSD-001-NV1", ""), + ("GPU NVIDIA A30 PCIe", "NVIDIA", "A30", 4_500.00, "GPU-001-A30", ""), + ] + r760_ids = [] + for nm, mf, md, cost, sn, tag in r760_pieces: + pc = store.create_asset({ + "type": PIECE_TYPE, "name": nm, "manufacturer": mf, "model": md, + "cost_cad": cost, "serial_number": sn, "inventory_tag": tag, + "acquisition_date": "2024-01-15", + "supplier": "Dell Canada Inc.", "invoice_number": "DELL-CA-2024-R760", + }) + store.attach(pc["id"], r760["id"]) + r760_ids.append(pc["id"]) + store.set_tag_bearer(r760_ids[0]) # Châssis = porteur + + # ── 2. Cat. 50 — Serveur HP ProLiant DL380 Gen10 (actif, acquis 2023) ───── + dl380 = store.create_asset({ + "type": PARENT_TYPE, "name": "Serveur HP ProLiant DL380 Gen10", + "manufacturer": "HP", "model": "ProLiant DL380 Gen10", "form_factor": "2U", + "cca_class_id": 50, "operational_status": "Actif", + "in_service_date": "2023-04-01", "assembler": "HP Inc.", + }) + dl380_pieces = [ + ("Châssis HP DL380 Gen10", "HP", "DL380 Gen10", 18_200.00, "SRV-HP-001", "DC-QC-SRV-002"), + ("RAM DDR4 128 Go", "Samsung", "M393A8G40AB2", 890.00, "MEM-HP-001", ""), + ("SSD SATA 7,68 To", "Micron", "5400 MAX", 650.00, "SSD-HP-SATA-1", ""), + ] + dl380_ids = [] + for nm, mf, md, cost, sn, tag in dl380_pieces: + pc = store.create_asset({ + "type": PIECE_TYPE, "name": nm, "manufacturer": mf, "model": md, + "cost_cad": cost, "serial_number": sn, "inventory_tag": tag, + "acquisition_date": "2023-03-10", + "supplier": "HP Inc.", "invoice_number": "HP-CA-2023-DL380", + }) + store.attach(pc["id"], dl380["id"]) + dl380_ids.append(pc["id"]) + store.set_tag_bearer(dl380_ids[0]) # Châssis HP = porteur + + # ── 3. Cat. 8 — Onduleur APC Smart-UPS RT 10 kVA (actif, acquis 2024) ───── + ups = store.create_asset({ + "type": PARENT_TYPE, "name": "Onduleur APC Smart-UPS RT 10 kVA", + "manufacturer": "APC", "model": "SURT10000XLI", "form_factor": "Rack 6U", + "cca_class_id": 8, "operational_status": "Actif", + "in_service_date": "2024-07-01", "assembler": "APC by Schneider Electric", + }) + ups_pieces = [ + ("Boîtier UPS SURT10000XLI", "APC", "SURT10000XLI", 8_450.00, "UPS-001-BOITIER", "DC-QC-UPS-001"), + ("Pack batteries SURT192XLBP", "APC", "SURT192XLBP", 1_200.00, "UPS-001-BAT", ""), + ("Carte réseau UPS AP9631", "APC", "AP9631", 320.00, "UPS-001-NMC", ""), + ] + ups_ids = [] + for nm, mf, md, cost, sn, tag in ups_pieces: + pc = store.create_asset({ + "type": PIECE_TYPE, "name": nm, "manufacturer": mf, "model": md, + "cost_cad": cost, "serial_number": sn, "inventory_tag": tag, + "cca_class_id": 8, # pièces héritent la catégorie du parent pour le pool + "acquisition_date": "2024-06-15", + "supplier": "APC by Schneider Electric", "invoice_number": "APC-QC-2024-UPS", + }) + store.attach(pc["id"], ups["id"]) + ups_ids.append(pc["id"]) + store.set_tag_bearer(ups_ids[0]) # Boîtier = porteur + + # ── 4. Cat. 46 — Switch Cisco Catalyst 9300-24T (actif, acquis 2025) ─────── + sw = store.create_asset({ + "type": PARENT_TYPE, "name": "Switch Cisco Catalyst 9300-24T", + "manufacturer": "Cisco", "model": "C9300-24T", "form_factor": "Rack 1U", + "cca_class_id": 46, "operational_status": "Actif", + "in_service_date": "2025-02-01", "assembler": "Cisco Systems Canada", + }) + sw_pieces = [ + ("Switch Cisco C9300-24T", "Cisco", "C9300-24T", 12_500.00, "SW-001-CISCO", "DC-QC-SW-001"), + ("Module SFP+ 10G #1", "Cisco", "SFP-10G-SR", 350.00, "SFP-001-1", ""), + ("Module SFP+ 10G #2", "Cisco", "SFP-10G-SR", 350.00, "SFP-001-2", ""), + ("Module SFP+ 10G #3", "Cisco", "SFP-10G-SR", 350.00, "SFP-001-3", ""), + ] + sw_ids = [] + for nm, mf, md, cost, sn, tag in sw_pieces: + pc = store.create_asset({ + "type": PIECE_TYPE, "name": nm, "manufacturer": mf, "model": md, + "cost_cad": cost, "serial_number": sn, "inventory_tag": tag, + "cca_class_id": 46, # pièces héritent la catégorie du parent pour le pool + "acquisition_date": "2025-01-10", + "supplier": "Cisco Systems Canada", "invoice_number": "CISCO-CA-2025-SW001", + }) + store.attach(pc["id"], sw["id"]) + sw_ids.append(pc["id"]) + store.set_tag_bearer(sw_ids[0]) # Switch = porteur + + # ── 5. Cat. 12 — Licence Veeam Backup Enterprise (non renouvelée 2025) ───── + # Démontre la perte finale : 100 % DPA, pool physiquement vide en 2025. + veeam = store.create_asset({ + "type": PARENT_TYPE, "name": "Licence Veeam Backup Enterprise", + "manufacturer": "Veeam", "model": "VBR Enterprise Plus", "form_factor": "Logiciel", + "cca_class_id": 12, "operational_status": "Rebut", + "in_service_date": "2024-03-01", "assembler": "Veeam Software Group", + }) + lic_veeam = store.create_asset({ + "type": PIECE_TYPE, "name": "Licence Veeam Enterprise Plus (3 ans)", + "manufacturer": "Veeam", "model": "VBR-ENT-PLUS-3Y", + "cost_cad": 3_000.00, "serial_number": "LIC-VBR-2024-001", + "cca_class_id": 12, # logiciel → Cat. 12, pool distinct de l'équipement + "acquisition_date": "2024-02-15", + "supplier": "Veeam Software Group", "invoice_number": "VBR-QC-2024-001", + }) + store.attach(lic_veeam["id"], veeam["id"]) + store.set_tag_bearer(lic_veeam["id"]) + store.detach(lic_veeam["id"], "scrap") # Licence non renouvelée → rebut + + # ── 6. Cat. 10 — Serveur IBM x3650 M4 (fin de vie, vendu 2024) ─────────── + # Démontre la récupération : produit de cession > FNACC résiduelle. + # Actif vendu avant l'adoption de l'application ; suivi via opening_ucc_override. + store.create_asset({ + "type": PARENT_TYPE, "name": "Serveur IBM x3650 M4 (fin de vie)", + "manufacturer": "IBM", "model": "System x3650 M4", "form_factor": "2U", + "cca_class_id": 10, "operational_status": "Rebut", + "in_service_date": "2019-06-01", "acquisition_date": "2019-05-15", + "assembler": "IBM Canada", + }) + + # ── Pièces libres (stock de rechange) ───────────────────────────────────── + store.create_asset({ + "type": PIECE_TYPE, "name": "RAM DDR5 32 Go – pièce de rechange", + "manufacturer": "Samsung", "model": "M321R4GA0BB0", + "cost_cad": 210.00, "serial_number": "MEM-SPARE-001", + "acquisition_date": today_str, "supplier": "Dell Canada Inc.", + }) + store.create_asset({ + "type": PIECE_TYPE, "name": "SSD NVMe 1,92 To – pièce de rechange", + "manufacturer": "Micron", "model": "7450 PRO 1.92T", + "cost_cad": 540.00, "serial_number": "SSD-SPARE-001", + "acquisition_date": today_str, "supplier": "Dell Canada Inc.", + }) + + # ── Pièce mise au rebut avec piste d'audit complète ─────────────────────── + old_nic = store.create_asset({ + "type": PIECE_TYPE, "name": "Vieille carte réseau Broadcom BCM5720", + "manufacturer": "Broadcom", "model": "BCM5720", "form_factor": "PCIe", + "cost_cad": 280.00, "serial_number": "NIC-LEGACY-001", + "acquisition_date": "2022-01-10", + "supplier": "Dell Canada Inc.", "invoice_number": "DELL-CA-2022-LEGACY", + }) + store.attach(old_nic["id"], dl380["id"]) # Rattachée au DL380 + store.detach(old_nic["id"], "scrap") # Puis mise au rebut + + # ── Registre fiscal (pool DPA) ──────────────────────────────────────────── + # Cat. 50 — 3 exercices 2023-2025 (DPA standard) + registry.upsert_year(2023, 50, dispositions=0, adjustment_coeff=0.5) + registry.upsert_year(2024, 50, dispositions=0, adjustment_coeff=0.5) + registry.upsert_year(2025, 50, dispositions=0, adjustment_coeff=1.0) + + # Cat. 8 — 2 exercices 2024-2025 + registry.upsert_year(2024, 8, dispositions=0, adjustment_coeff=0.5) + registry.upsert_year(2025, 8, dispositions=0, adjustment_coeff=1.0) + + # Cat. 46 — 1 exercice 2025 + registry.upsert_year(2025, 46, dispositions=0, adjustment_coeff=0.5) + + # Cat. 12 — Veeam : 2024 (DPA 100 %) + 2025 perte finale + registry.upsert_year(2024, 12, dispositions=0, adjustment_coeff=0.5) + registry.upsert_year(2025, 12, dispositions=0, adjustment_coeff=1.0, + pool_physically_empty=True) + + # Cat. 10 — IBM x3650 M4 : historique 2022-2024, récupération en 2024 + # FNACC au 01-01-2022 = 4 500 $ (dérivé avant utilisation de l'application). + # Vendu 3 000 $ en 2024 → solde négatif → récupération d'amortissement. + registry.upsert_year(2022, 10, additions_override=0.0, dispositions=0, + adjustment_coeff=0.5, opening_ucc_override=4_500.0) + registry.upsert_year(2023, 10, dispositions=0, adjustment_coeff=1.0) + registry.upsert_year(2024, 10, dispositions=3_000.0, adjustment_coeff=1.0, + pool_physically_empty=True) + + return {"assets": store.all()} + + + +class PoolYearBody(BaseModel): + year: int + cca_class_id: int = 50 + additions_override: float | None = None + dispositions: float = 0 + adjustment_coeff: float = 0.5 + pool_physically_empty: bool = False + opening_ucc_override: float | None = None + + +@app.get("/api/cca-registry") +def cca_registry(cca_class_id: int = 50) -> dict: + return registry.compute(cca_class_id) + + +@app.post("/api/cca-registry/year") +def cca_upsert_year(body: PoolYearBody) -> dict: + registry.upsert_year( + body.year, body.cca_class_id, + additions_override=body.additions_override, + dispositions=body.dispositions, + adjustment_coeff=body.adjustment_coeff, + pool_physically_empty=body.pool_physically_empty, + opening_ucc_override=body.opening_ucc_override, + ) + return registry.compute(body.cca_class_id) + + +@app.delete("/api/cca-registry/year/{year}") +def cca_delete_year(year: int, cca_class_id: int = 50) -> dict: + registry.delete_year(year, cca_class_id) + return registry.compute(cca_class_id) + + +@app.get("/api/cca-registry.csv") +def cca_registry_csv(cca_class_id: int = 50) -> StreamingResponse: + data = registry.compute(cca_class_id) + buf = io.StringIO() + w = csv.writer(buf) + w.writerow(["Annee", "FNACC ouverture", "Ajouts", "Dispositions", + "Coeff ajust.", "Ajustement", "Base DPA", "Taux", + "DPA", "FNACC cloture", "Recuperation", "Perte finale"]) + for y in data["years"]: + w.writerow([y["year"], y["opening_ucc"], y["additions"], y["dispositions"], + y["adjustment_coeff"], y["adjustment"], y["base_for_cca"], + y["cca_rate"], y["cca_claimed"], y["closing_ucc"], + y["recapture"], y["terminal_loss"]]) + buf.seek(0) + return StreamingResponse(iter([buf.getvalue()]), media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=registre_cat{cca_class_id}.csv"}) + + +class AddDispositionBody(BaseModel): + year: int + cca_class_id: int = 50 + amount: float = 0 + + +@app.post("/api/cca-registry/year/add-disposition") +def cca_add_disposition(body: AddDispositionBody) -> dict: + """Cumule un produit de disposition sur une année du pool (sans écraser les autres champs).""" + data = registry.compute(body.cca_class_id) + row = next((y for y in data["years"] if y["year"] == body.year), None) + current_disp = float(row["dispositions"]) if row else 0.0 + current_coeff = float(row["adjustment_coeff"]) if row else 0.5 + registry.upsert_year( + body.year, body.cca_class_id, + dispositions=current_disp + body.amount, + adjustment_coeff=current_coeff, + ) + return registry.compute(body.cca_class_id) + + +@app.get("/api/annexe8") +def annexe8(year: int = None) -> dict: + """Données Annexe 8 / Schedule 8 pour toutes les catégories actives, un exercice.""" + from decimal import Decimal + from datetime import datetime, timezone + if year is None: + year = date.today().year - 1 + class_ids = sorted({a["view_cpa"]["cca_class"]["id"] for a in store.all()}) + classes = [] + for cid in class_ids: + cat = CCA_BY_ID.get(cid, {"description": "", "dpa_rate": 0.55}) + rate = Decimal(str(cat["dpa_rate"])) + pool = registry.compute(cid, cca_rate=rate) + year_data = next((y for y in pool["years"] if y["year"] == year), None) + classes.append({ + "cca_class_id": cid, + "description": cat["description"], + "dpa_rate": float(rate), + "physical_count": pool["physical_count"], + "year_data": year_data, + }) + return { + "year": year, + "classes": classes, + "generated_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), + } + + +@app.get("/api/annexe8.csv") +def annexe8_csv(year: int = None) -> StreamingResponse: + data = annexe8(year) + yr = data["year"] + buf = io.StringIO() + w = csv.writer(buf) + w.writerow([f"ANNEXE 8 — DÉDUCTION POUR AMORTISSEMENT (DPA) — Exercice {yr}"]) + w.writerow(["Chezlepro Inc."]) + w.writerow([]) + w.writerow(["Catégorie", "Description", "Taux", "FNACC ouv.", "Ajouts", "Dispositions", + "Coeff.", "Ajust. $", "Base DPA", "DPA réclamée", "FNACC clôt.", + "Récupération", "Perte finale", "Note"]) + total_cca = 0.0 + for cls in data["classes"]: + y = cls["year_data"] + if y: + w.writerow([ + f"Cat. {cls['cca_class_id']}", cls["description"], + f"{cls['dpa_rate']*100:.0f}%", + y["opening_ucc"], y["additions"], y["dispositions"], + y["adjustment_coeff"], y["adjustment"], y["base_for_cca"], + y["cca_claimed"], y["closing_ucc"], + y["recapture"], y["terminal_loss"], y["note"], + ]) + total_cca += float(y["cca_claimed"]) + else: + w.writerow([f"Cat. {cls['cca_class_id']}", cls["description"], + f"{cls['dpa_rate']*100:.0f}%"] + ["—"] * 10 + [f"Aucune donnée pour {yr}"]) + w.writerow([]) + w.writerow(["", "", "", "", "", "", "", "", "DPA TOTALE", f"{total_cca:.2f}"]) + w.writerow([]) + w.writerow(["Calculs indicatifs — à valider par un CPA avant déclaration fiscale."]) + buf.seek(0) + return StreamingResponse(iter([buf.getvalue()]), media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=annexe8_{yr}.csv"}) + + +def _build_rapport_data(year: int) -> dict: + ann = annexe8(year) + total_cca = sum(float(c["year_data"]["cca_claimed"]) for c in ann["classes"] if c["year_data"]) + total_recapture = sum(float(c["year_data"]["recapture"]) for c in ann["classes"] if c["year_data"]) + total_terminal = sum(float(c["year_data"]["terminal_loss"]) for c in ann["classes"] if c["year_data"]) + book_deprec = 0.0 + for a in store.all(): + if a["type"] != PIECE_TYPE: + continue + isd = a["view_cpa"].get("in_service_date") or "" + if not isd or isd[:4] > str(year): + continue + cost = float(a["view_cpa"].get("capitalizable_base_cad", 0)) + life = float(a["view_admin"].get("useful_life_years") or 4) + if life > 0: + book_deprec += cost / life + return { + "year": year, + "annexe8": ann, + "schedule1": { + "book_deprec_cad": round(book_deprec, 2), + "cca_claimed_cad": round(total_cca, 2), + "recapture_cad": round(total_recapture, 2), + "terminal_loss_cad": round(total_terminal, 2), + }, + "fiscal_export": store.fiscal_export(), + "generated_at": ann["generated_at"], + } + + +def _fmt(n: float) -> str: + return f"{n:,.2f} $".replace(",", " ") + + +def _render_rapport_html(d: dict) -> str: + yr = d["year"] + s1 = d["schedule1"] + gen = d["generated_at"] + e = _html.escape + + def pool_rows() -> str: + rows = [] + for cls in d["annexe8"]["classes"]: + y = cls["year_data"] + if not y: + rows.append(f'Cat.{cls["cca_class_id"]}Aucune donnée pour {yr}') + continue + rows.append( + f'' + f'Cat. {cls["cca_class_id"]}' + f'{e(cls["description"])}' + f'{cls["dpa_rate"]*100:.0f} %' + f'{_fmt(float(y["opening_ucc"]))}' + f'{_fmt(float(y["additions"]))}' + f'{_fmt(float(y["dispositions"]))}' + f'{_fmt(float(y["base_for_cca"]))}' + f'{_fmt(float(y["cca_claimed"]))}' + f'{_fmt(float(y["closing_ucc"]))}' + f'{_fmt(float(y["recapture"])) if float(y["recapture"]) else "—"}' + f'{_fmt(float(y["terminal_loss"])) if float(y["terminal_loss"]) else "—"}' + f'' + ) + return "\n".join(rows) + + def asset_rows() -> str: + rows = [] + for cls in d["fiscal_export"]["classes"]: + rows.append( + f'Cat. {cls["cca_class_id"]} — {e(cls["description"])} ({cls["dpa_rate"]*100:.0f} %)' + ) + for it in cls["items"]: + rows.append( + f'' + f'{e(it["name"])}' + f'{e(it["inventory_tag"] or "—")}' + f'{_fmt(float(it["base_cad"]))}' + f'{e(it["in_service_date"] or "—")}' + f'{"Oui" if it["half_year_rule"] else "Non"}' + f'' + ) + rows.append( + f'Sous-total Cat. {cls["cca_class_id"]}' + f'{_fmt(float(cls["total_base_cad"]))}' + ) + return "\n".join(rows) + + net = s1["book_deprec_cad"] + s1["recapture_cad"] - s1["cca_claimed_cad"] - s1["terminal_loss_cad"] + net_label = "Augmentation du revenu imposable" if net > 0 else "Réduction du revenu imposable" + + return f""" + + + +Rapport fiscal {yr} — Chezlepro Inc. + + + + +

Rapport d'exercice fiscal {yr}

+

Chezlepro Inc.  |  Généré le {gen}

+ +

1. Déduction pour amortissement — Annexe 8 (T2) / Annexe D (CO-17)

+ + + + + + + + + + + {pool_rows()} + +
Cat.DescriptionTauxFNACC ouv.AjoutsDisp.Base DPADPA réclaméeFNACC clôt.RécupérationPerte finale
+ +

2. Ajustements Annexe 1 / Schedule 1 — impact sur le revenu imposable

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AjustementLigne T2 Sch. 1Ligne CO-17 Ann. 1Montant ($)Effet
Amortissement comptable (add-back) — estimé selon durées de vie utiles de l'outil104130{_fmt(s1["book_deprec_cad"])}+ revenu
Récupération d'amortissement107135{_fmt(s1["recapture_cad"]) if s1["recapture_cad"] else "—"}+ revenu
DPA réclamée (total toutes catégories)410150{_fmt(s1["cca_claimed_cad"])}− revenu
Perte finale414155{_fmt(s1["terminal_loss_cad"]) if s1["terminal_loss_cad"] else "—"}− revenu
{net_label}{_fmt(abs(net))}{"+" if net > 0 else "−"}
+

+ * Les numéros de lignes T2/CO-17 sont ceux de l'exercice {yr} — vérifier avec le formulaire officiel de l'année courante. + L'amortissement comptable (ligne 104/130) est estimé selon les durées de vie utiles saisies dans l'outil. + Si vos états financiers utilisent une méthode différente, inscrire le chiffre réel de vos livres. +

+ +

3. Inventaire des immobilisations par catégorie DPA

+ + + + + + {asset_rows()} + +
DésignationÉtiquetteBase amortissableMise en serviceDemi-année
+ +

+ Calculs structurellement corrects mais indicatifs — à valider avant tout dépôt fiscal. + Outil : Gestionnaire d'immobilisations fiscales, Chezlepro Inc. +

+ +""" + + +@app.get("/api/rapport-fiscal") +def rapport_fiscal_json(year: int = None) -> dict: + if year is None: + year = date.today().year - 1 + return _build_rapport_data(year) + + +@app.get("/api/rapport-fiscal.html", response_class=HTMLResponse) +def rapport_fiscal_html(year: int = None) -> HTMLResponse: + if year is None: + year = date.today().year - 1 + return HTMLResponse(content=_render_rapport_html(_build_rapport_data(year))) + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") diff --git a/app/seed.py b/app/seed.py new file mode 100644 index 0000000..50521e8 --- /dev/null +++ b/app/seed.py @@ -0,0 +1,139 @@ +""" +Données de départ — modèle à deux niveaux. + PARENT (type 'parent') : immobilisation fiscale. Base = somme de ses pièces. + PIÈCE (type 'piece') : élément d'inventaire rattaché à un parent. + +La base amortissable des parents est recalculée par le store ; les valeurs +'capitalizable_base_cad' ci-dessous pour les parents sont indicatives +(elles seront écrasées par la somme des pièces). +""" + + +def seed_assets() -> list[dict]: + return [ + # ---------------- PARENT 1 : Serveur Dell ---------------- + { + "id": 1042, + "path": "parent_1042", + "type": "parent", + "name": "Serveur Dell PowerEdge R760 - Noeud 12", + "parent_id": None, + "view_admin": { + "serial_number": "CN-0H7G4T-79611-3K2-A0B1", + "inventory_tag": "DC-QC-SRV-001042", + "manufacturer": "Dell", + "model": "PowerEdge R760", + "form_factor": "2U Rackmount", + "canvas": {"x": 0, "y": 0, "width": 260, "height": 150, "z_index": 0}, + "operational_status": "Actif", + }, + "view_cpa": { + "original_cost": {"amount": 0, "currency": "CAD"}, + "exchange_rate_applied": 1.0, + "exchange_rate_date": "2026-02-11", + "cost_pre_tax_cad": 0, + "non_recoverable_taxes_and_duties": 0.0, + "capitalizable_base_cad": 0, + "cca_class": {"id": 50, "description": "Matériel informatique", "dpa_rate": 0.55}, + "acquisition_date": "2026-02-11", + "in_service_date": "2026-02-20", + "half_year_rule_applies": True, + }, + "view_auditor": { + "supplier": "Dell Canada Inc.", + "invoice_pdf_file_id": "inv_a8f3e1b9-7c4d-4e22-9f10-2b6d5a8e0c33", + "invoice_number": "DELL-CA-2026-0044871", + "audit_trail": [ + {"action": "CREATED", "description": "Immobilisation créée", "timestamp": "2026-02-11T14:32:08-05:00"}, + ], + }, + }, + # pièces du parent 1042 + _piece(5001, 1042, "RAM DDR5 64GB - Slot 1", "Samsung", "M321R8GA0BB0-CQK", "DIMM", "MEM-DDR5-64-5001", "DC-QC-CMP-005001", 420.00), + _piece(5002, 1042, "RAM DDR5 64GB - Slot 2", "Samsung", "M321R8GA0BB0-CQK", "DIMM", "MEM-DDR5-64-5002", "DC-QC-CMP-005002", 420.00), + _piece(5003, 1042, "SSD NVMe 3.84TB", "Micron", "7450 PRO", "U.3", "NVME-3840-5003", "DC-QC-CMP-005003", 980.00), + _piece(5004, 1042, "Châssis + carte mère R760", "Dell", "PowerEdge R760 Base", "2U", "CHS-R760-5004", "DC-QC-CMP-005004", 23436.05), + + # ---------------- PARENT 2 : Serveur HPE ---------------- + { + "id": 1055, + "path": "parent_1055", + "type": "parent", + "name": "Serveur HPE ProLiant DL380 Gen11", + "parent_id": None, + "view_admin": { + "serial_number": "SGH-2026-DL380-0055", + "inventory_tag": "DC-QC-SRV-001055", + "manufacturer": "HPE", + "model": "ProLiant DL380 Gen11", + "form_factor": "2U Rackmount", + "canvas": {"x": 0, "y": 0, "width": 260, "height": 150, "z_index": 0}, + "operational_status": "Actif", + }, + "view_cpa": { + "original_cost": {"amount": 0, "currency": "CAD"}, + "exchange_rate_applied": 1.0, + "exchange_rate_date": "2026-03-05", + "cost_pre_tax_cad": 0, + "non_recoverable_taxes_and_duties": 0.0, + "capitalizable_base_cad": 0, + "cca_class": {"id": 50, "description": "Matériel informatique", "dpa_rate": 0.55}, + "acquisition_date": "2026-03-05", + "in_service_date": "2026-03-10", + "half_year_rule_applies": True, + }, + "view_auditor": { + "supplier": "HPE Canada", + "invoice_pdf_file_id": "inv_hpe_dl380_0055", + "invoice_number": "HPE-CA-2026-0123", + "audit_trail": [ + {"action": "CREATED", "description": "Immobilisation créée", "timestamp": "2026-03-05T11:00:00-05:00"}, + ], + }, + }, + _piece(5005, 1055, "Châssis + carte mère DL380", "HPE", "DL380 Gen11 Base", "2U", "CHS-DL380-5005", "DC-QC-CMP-005005", 14800.00), + _piece(5006, 1055, "RAM DDR5 32GB - Slot 1", "Kingston", "KSM48E40BS8KI", "DIMM", "MEM-DDR5-32-5006", "DC-QC-CMP-005006", 700.00), + + # ---------------- PIÈCE LIBRE (non rattachée) ---------------- + _piece(5007, None, "SSD NVMe 1.92TB (en stock)", "Samsung", "PM9A3", "U.2", "NVME-1920-5007", "DC-QC-CMP-005007", 540.00, status="Stock"), + ] + + +def _piece(pid, parent_id, name, manuf, model, form, serial, tag, cost, status="Actif"): + path = f"parent_{parent_id}.piece_{pid}" if parent_id else f"libre_{pid}" + return { + "id": pid, + "path": path, + "type": "piece", + "name": name, + "parent_id": parent_id, + "view_admin": { + "serial_number": serial, + "inventory_tag": tag, + "manufacturer": manuf, + "model": model, + "form_factor": form, + "canvas": {"x": 0, "y": 0, "width": 200, "height": 60, "z_index": 0}, + "operational_status": status, + }, + "view_cpa": { + "original_cost": {"amount": cost, "currency": "CAD"}, + "exchange_rate_applied": 1.0, + "exchange_rate_date": "2026-02-11", + "cost_pre_tax_cad": cost, + "non_recoverable_taxes_and_duties": 0.0, + "capitalizable_base_cad": cost, + "cca_class": {"id": 50, "description": "Matériel informatique", "dpa_rate": 0.55}, + "acquisition_date": "2026-02-11", + "in_service_date": "2026-02-20", + "half_year_rule_applies": True, + }, + "view_auditor": { + "supplier": manuf, + "invoice_pdf_file_id": f"inv_piece_{pid}", + "invoice_number": f"INV-2026-{pid}", + "audit_trail": [ + {"action": "CREATED", "description": "Pièce créée", "timestamp": "2026-02-11T14:35:00-05:00"} + ], + }, + } diff --git a/app/store.py b/app/store.py new file mode 100644 index 0000000..516fe45 --- /dev/null +++ b/app/store.py @@ -0,0 +1,577 @@ +""" +Couche de persistance SQLite — modèle à deux niveaux. + +Stockage : un fichier immos.db. Chaque actif est une ligne ; la structure +JSON 3-vues (view_admin / view_cpa / view_auditor) est sérialisée en colonne +JSON. Les colonnes plates (id, type, name, parent_id) servent aux requêtes. + +- PARENT : immobilisation fiscale. Base amortissable CALCULÉE = somme des + coûts des pièces rattachées (jamais éditée directement). +- PIÈCE : élément d'inventaire. Coût éditable ; il alimente la base du parent. + +Édition : tout champ éditable sauf la base consolidée du parent. Chaque +modification d'un champ d'un PARENT est journalisée dans sa piste d'audit +(ancien -> nouveau + date) pour défendabilité fiscale. +""" +from __future__ import annotations + +import copy +import json +import os +import sqlite3 +from datetime import datetime, timezone +from decimal import Decimal +from pathlib import Path +from threading import Lock + +_db_env = os.environ.get("IMMOS_DB_PATH") +DB_PATH = Path(_db_env) if _db_env else Path(__file__).resolve().parent.parent / "immos.db" + +PARENT_TYPE = "parent" +PIECE_TYPE = "piece" + +# Catalogue des catégories DPA pertinentes (id -> description, taux). +# Source : ARC/Revenu Québec. À valider avec un CPA selon l'année. +CCA_CATEGORIES = [ + {"id": 50, "description": "Matériel informatique et logiciels de systèmes", "dpa_rate": 0.55, "default_useful_life_years": 4}, + {"id": 8, "description": "Mobilier, équipement et matériel divers", "dpa_rate": 0.20, "default_useful_life_years": 10}, + {"id": 10, "description": "Matériel informatique (avant mars 2007), véhicules","dpa_rate": 0.30, "default_useful_life_years": 5}, + {"id": 12, "description": "Logiciels d'application, petits outils", "dpa_rate": 1.00, "default_useful_life_years": 2}, + {"id": 46, "description": "Matériel d'infrastructure de réseau de données", "dpa_rate": 0.30, "default_useful_life_years": 7}, +] +CCA_BY_ID = {c["id"]: c for c in CCA_CATEGORIES} + + +def _now_iso() -> str: + return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + + +def _compute_linear_deprec( + cost: float, isd: str, useful_life_years: float +) -> tuple[float, float, float]: + """ + Amortissement linéaire indicatif (comptable, NON fiscal). + Retourne (accum_deprec_cad, nbv_cad, remaining_life_years). + """ + if isd and len(isd) >= 10: + try: + y_, m_, d_ = int(isd[:4]), int(isd[5:7]), int(isd[8:10]) + isd_dt = datetime(y_, m_, d_, tzinfo=timezone.utc) + today_dt = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + years_svc = max(0.0, (today_dt - isd_dt).days / 365.25) + except (ValueError, IndexError): + years_svc = 0.0 + annual = cost / useful_life_years if useful_life_years > 0 else cost + accum = round(min(annual * years_svc, cost), 2) + nbv = round(max(cost - accum, 0.0), 2) + remaining = round(max(useful_life_years - years_svc, 0.0), 2) + else: + accum, nbv, remaining = 0.0, round(cost, 2), useful_life_years + return accum, nbv, remaining + + +def _compute_piece_fnacc(cost: float, acquisition_year: int, cca_rate: float) -> tuple[float, int]: + """ + FNACC théorique d'une pièce au 31 décembre du dernier exercice fiscal complété. + Méthode : amortissement dégressif, règle de la demi-année, DPA maximale chaque année. + Retourne (fnacc_cad, reference_year). + + Indicatif uniquement : la FNACC fiscale légalement opposable appartient au + solde de catégorie (pool), pas à la pièce individuelle. + """ + reference_year = datetime.now(timezone.utc).year - 1 + if acquisition_year > reference_year: + # Pièce acquise dans l'exercice courant — aucune DPA réclamée encore. + return (round(cost, 2), reference_year) + fnacc = cost * (1.0 - cca_rate * 0.5) # année d'acquisition (demi-année) + for _ in range(reference_year - acquisition_year): # exercices subséquents + fnacc *= (1.0 - cca_rate) + return (round(max(fnacc, 0.0), 2), reference_year) + + +class AssetStore: + def __init__(self, db_path: Path = DB_PATH) -> None: + self._lock = Lock() + self._db_path = db_path + self._conn = sqlite3.connect(str(db_path), check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._init_schema() + + # ---- Schéma ---- + def _init_schema(self) -> None: + self._conn.execute(""" + CREATE TABLE IF NOT EXISTS assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + name TEXT NOT NULL, + parent_id INTEGER, + data TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (parent_id) REFERENCES assets(id) ON DELETE SET NULL + ) + """) + self._conn.commit() + + # ---- Helpers ---- + def _row_to_asset(self, row: sqlite3.Row) -> dict: + asset = json.loads(row["data"]) + asset["id"] = row["id"] + asset["type"] = row["type"] + asset["name"] = row["name"] + asset["parent_id"] = row["parent_id"] + return asset + + def _raw_get(self, asset_id: int) -> dict | None: + cur = self._conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)) + row = cur.fetchone() + return self._row_to_asset(row) if row else None + + def _raw_children(self, parent_id: int) -> list[dict]: + cur = self._conn.execute("SELECT * FROM assets WHERE parent_id = ?", (parent_id,)) + return [self._row_to_asset(r) for r in cur.fetchall()] + + def _sum_pieces_cost(self, parent_id: int) -> Decimal: + total = Decimal("0") + for child in self._raw_children(parent_id): + total += Decimal(str(child["view_cpa"]["capitalizable_base_cad"])) + return total + + def _with_computed_base(self, asset: dict) -> dict: + snap = copy.deepcopy(asset) + + if snap["type"] == PARENT_TYPE: + children = self._raw_children(snap["id"]) + cost_total = Decimal("0") + fnacc_total = Decimal("0") + replacement_total = Decimal("0") + nbv_linear_total = Decimal("0") + for child in children: + enriched = self._with_computed_base(child) + cost_total += Decimal(str(enriched["view_cpa"]["capitalizable_base_cad"])) + fnacc_total += Decimal(str(enriched["view_cpa"].get("fnacc_cad", 0))) + replacement_total += Decimal(str(enriched["view_admin"].get("replacement_cost_cad", 0))) + nbv_linear_total += Decimal(str(enriched["view_cpa"].get( + "nbv_linear_cad", enriched["view_cpa"]["capitalizable_base_cad"]))) + snap["view_cpa"]["capitalizable_base_cad"] = float(cost_total) + snap["view_cpa"]["cost_pre_tax_cad"] = float(cost_total) + snap["view_cpa"]["net_book_value_cad"] = float(fnacc_total) + snap["view_admin"]["pieces_count"] = len(children) + snap["view_admin"]["total_replacement_cost_cad"] = float(replacement_total) + snap["view_cpa"]["nbv_linear_total_cad"] = float(nbv_linear_total) + # L'étiquette du parent est celle du porteur désigné. + bearer = next((c for c in children if c["view_admin"].get("is_tag_bearer")), None) + if bearer: + snap["view_admin"]["inventory_tag"] = bearer["view_admin"].get("inventory_tag", "") + + elif snap["type"] == PIECE_TYPE: + cost = float(snap["view_cpa"].get("capitalizable_base_cad", 0)) + isd = snap["view_cpa"].get("in_service_date") or "" + rate = float(snap["view_cpa"]["cca_class"].get("dpa_rate", 0.55)) + override = snap["view_cpa"].get("fnacc_override_cad") + ref_year = datetime.now(timezone.utc).year - 1 + if isd and isd[:4].isdigit(): + # En service : amortissement dégressif depuis l'année de mise en service. + fnacc_auto, ref_year = _compute_piece_fnacc(cost, int(isd[:4]), rate) + else: + # Stock (jamais rattachée) : FNACC = coût intégral, zéro amortissement. + fnacc_auto = round(cost, 2) + snap["view_cpa"]["fnacc_auto_cad"] = fnacc_auto + snap["view_cpa"]["fnacc_reference_year"] = ref_year + snap["view_cpa"]["fnacc_is_override"] = override is not None + snap["view_cpa"]["fnacc_cad"] = float(override) if override is not None else fnacc_auto + + # Amortissement linéaire indicatif (comptable, non fiscal). + useful_life_years = float(snap["view_admin"].get("useful_life_years") or + CCA_BY_ID.get(snap["view_cpa"]["cca_class"]["id"], {}).get("default_useful_life_years", 4)) + snap["view_admin"]["useful_life_years"] = useful_life_years + accum_l, nbv_l, remaining_l = _compute_linear_deprec(cost, isd, useful_life_years) + snap["view_cpa"]["accum_deprec_linear_cad"] = accum_l + snap["view_cpa"]["nbv_linear_cad"] = nbv_l + snap["view_cpa"]["remaining_useful_life_years"] = remaining_l + + return snap + + def _persist(self, asset: dict) -> None: + """Écrit un actif existant (UPDATE) à partir de sa structure complète.""" + data = copy.deepcopy(asset) + for k in ("id", "type", "name", "parent_id"): + data.pop(k, None) + self._conn.execute( + "UPDATE assets SET type=?, name=?, parent_id=?, data=?, updated_at=? WHERE id=?", + (asset["type"], asset["name"], asset["parent_id"], json.dumps(data, ensure_ascii=False), _now_iso(), asset["id"]), + ) + self._conn.commit() + + # ---- Lectures ---- + def _inject_bearer_tags(self, assets: list[dict]) -> None: + """Propage l'étiquette du porteur à ses siblings et marque tag_inherited.""" + bearer_tag: dict[int, str] = {} + for a in assets: + if a["type"] == PIECE_TYPE and a["parent_id"] is not None and a["view_admin"].get("is_tag_bearer"): + bearer_tag[a["parent_id"]] = a["view_admin"].get("inventory_tag", "") + for a in assets: + if a["type"] == PIECE_TYPE and a["parent_id"] is not None: + is_bearer = bool(a["view_admin"].get("is_tag_bearer")) + a["view_admin"]["tag_inherited"] = not is_bearer + if not is_bearer: + a["view_admin"]["inventory_tag"] = bearer_tag.get(a["parent_id"], "") + + def all(self) -> list[dict]: + cur = self._conn.execute("SELECT * FROM assets ORDER BY id") + result = [self._with_computed_base(self._row_to_asset(r)) for r in cur.fetchall()] + self._inject_bearer_tags(result) + return result + + def get(self, asset_id: int) -> dict | None: + a = self._raw_get(asset_id) + if a is None: + return None + result = self._with_computed_base(a) + if result["type"] == PIECE_TYPE and result["parent_id"] is not None: + siblings = self._raw_children(result["parent_id"]) + bearer = next((s for s in siblings if s["view_admin"].get("is_tag_bearer")), None) + is_bearer = bool(result["view_admin"].get("is_tag_bearer")) + result["view_admin"]["tag_inherited"] = not is_bearer + if not is_bearer: + result["view_admin"]["inventory_tag"] = bearer["view_admin"].get("inventory_tag", "") if bearer else "" + return result + + # ---- Création ---- + def create_asset(self, payload: dict) -> dict: + with self._lock: + atype = payload.get("type") + if atype not in (PARENT_TYPE, PIECE_TYPE): + raise ValueError("Type invalide (parent|piece).") + + name = payload.get("name") or "Immo" + cost = float(payload.get("cost_cad") or 0) + currency = payload.get("currency") or "CAD" + now = _now_iso() + + data = { + "path": "", + "view_admin": { + "serial_number": payload.get("serial_number") or "", + "inventory_tag": payload.get("inventory_tag") or "", + "manufacturer": payload.get("manufacturer") or "", + "model": payload.get("model") or "", + "form_factor": payload.get("form_factor") or "", + # Propre au PARENT : assembleur de l'immobilisation. + "assembler": payload.get("assembler") or ("Chezlepro Inc." if atype == PARENT_TYPE else ""), + "canvas": {"x": 0, "y": 0, "width": 240, "height": 140, "z_index": 0}, + "operational_status": payload.get("operational_status") or "Stock", + # Coût estimé pour remplacer à neuf (assurances, budget). Propre aux PIÈCES. + "replacement_cost_cad": float(payload.get("replacement_cost_cad") or 0), + # True sur exactement une pièce par immobilisation : source de l'étiquette partagée. + "is_tag_bearer": False, + }, + "view_cpa": { + "original_cost": {"amount": cost, "currency": currency}, + "exchange_rate_applied": float(payload.get("exchange_rate") or 1.0), + "exchange_rate_date": payload.get("acquisition_date") or now[:10], + "cost_pre_tax_cad": cost, + "non_recoverable_taxes_and_duties": float(payload.get("non_recoverable_taxes_and_duties") or 0), + "capitalizable_base_cad": cost, + # FNACC saisie manuellement (override). Null = utiliser le calcul auto. + "fnacc_override_cad": payload.get("fnacc_override_cad"), + "cca_class": { + "id": int(payload.get("cca_class_id") or 50), + "description": CCA_BY_ID.get(int(payload.get("cca_class_id") or 50), {}).get("description", "Matériel informatique"), + "dpa_rate": CCA_BY_ID.get(int(payload.get("cca_class_id") or 50), {}).get("dpa_rate", 0.55), + }, + "acquisition_date": payload.get("acquisition_date") or now[:10], + # Parent : date du jour par défaut. Pièce libre : vide — ne s'amortit + # pas tant qu'elle n'est pas rattachée (in_service_date posée par attach). + "in_service_date": payload.get("in_service_date") or (now[:10] if atype == PARENT_TYPE else ""), + "half_year_rule_applies": bool(payload.get("half_year_rule_applies", True)), + }, + "view_auditor": { + "supplier": payload.get("supplier") or "", + "invoice_pdf_file_id": payload.get("invoice_pdf_file_id") or "", + "invoice_number": payload.get("invoice_number") or "", + "audit_trail": [{ + "action": "CREATED", + "description": f"{'Immobilisation' if atype==PARENT_TYPE else 'Pièce'} créée", + "timestamp": now, + "previous_value": None, + "new_value": {"type": atype, "parent_id": None}, + }], + }, + } + cur = self._conn.execute( + "INSERT INTO assets (type, name, parent_id, data, created_at, updated_at) VALUES (?,?,?,?,?,?)", + (atype, name, None, json.dumps(data, ensure_ascii=False), now, now), + ) + self._conn.commit() + new_id = cur.lastrowid + self._set_path(new_id) + return self.get(new_id) + + def _set_path(self, asset_id: int) -> None: + a = self._raw_get(asset_id) + if not a: + return + if a["parent_id"]: + a["path"] = f"parent_{a['parent_id']}.piece_{asset_id}" + else: + a["path"] = (f"parent_{asset_id}" if a["type"] == PARENT_TYPE else f"libre_{asset_id}") + self._persist(a) + + # ---- Édition ---- + # Champs éditables par chemin "view.cle". La base consolidée du parent est exclue. + def update_asset(self, asset_id: int, changes: dict) -> dict: + with self._lock: + asset = self._raw_get(asset_id) + if asset is None: + raise KeyError(f"Actif {asset_id} introuvable.") + + is_parent = asset["type"] == PARENT_TYPE + audit_changes = [] + + for key, new_value in changes.items(): + # Interdit : éditer la base consolidée d'un parent (calculée). + if is_parent and key in ("view_cpa.capitalizable_base_cad", "view_cpa.cost_pre_tax_cad"): + raise ValueError("La base consolidée d'un parent est calculée et non éditable.") + + old_value = self._get_path(asset, key) + if old_value == new_value: + continue + self._set_path_value(asset, key, new_value) + audit_changes.append((key, old_value, new_value)) + + # Nom de premier niveau si modifié via view ? On gère 'name' à part. + if "name" in changes and changes["name"] != asset["name"]: + audit_changes.append(("name", asset["name"], changes["name"])) + asset["name"] = changes["name"] + + # Si la catégorie DPA change, le taux et la description suivent (catalogue). + if "view_cpa.cca_class.id" in changes: + cid = int(changes["view_cpa.cca_class.id"]) + cat = CCA_BY_ID.get(cid) + if cat: + asset["view_cpa"]["cca_class"]["dpa_rate"] = cat["dpa_rate"] + asset["view_cpa"]["cca_class"]["description"] = cat["description"] + + # Journalisation : toujours pour un parent (données fiscales). + if is_parent and audit_changes: + for (field, old, new) in audit_changes: + asset["view_auditor"]["audit_trail"].append({ + "action": "EDITED", + "description": f"Champ « {field} » modifié", + "timestamp": _now_iso(), + "previous_value": {field: old}, + "new_value": {field: new}, + }) + + self._persist(asset) + return self.get(asset_id) + + def _get_path(self, obj: dict, dotted: str): + cur = obj + for part in dotted.split("."): + if not isinstance(cur, dict) or part not in cur: + return None + cur = cur[part] + return cur + + def _set_path_value(self, obj: dict, dotted: str, value) -> None: + parts = dotted.split(".") + cur = obj + for part in parts[:-1]: + cur = cur.setdefault(part, {}) + cur[parts[-1]] = value + + # ---- Attache / Détache ---- + def attach(self, piece_id: int, parent_id: int) -> dict: + with self._lock: + piece = self._raw_get(piece_id) + parent = self._raw_get(parent_id) + if piece is None: + raise KeyError(f"Pièce {piece_id} introuvable.") + if parent is None: + raise KeyError(f"Parent {parent_id} introuvable.") + if parent["type"] != PARENT_TYPE: + raise ValueError("La cible n'est pas une immobilisation parent.") + if piece["type"] != PIECE_TYPE: + raise ValueError("Seule une pièce peut être rattachée.") + + old_parent_id = piece["parent_id"] + piece["parent_id"] = parent_id + piece["path"] = f"parent_{parent_id}.piece_{piece_id}" + piece["view_admin"]["operational_status"] = "Actif" + now = _now_iso() + # Pose la date de mise en service au premier rattachement seulement. + # Re-rattachement (pièce déjà en service) : date conservée, DPA continue. + if not piece["view_cpa"].get("in_service_date"): + today = now[:10] + piece["view_cpa"]["in_service_date"] = today + piece["view_auditor"]["audit_trail"].append({ + "action": "IN_SERVICE", + "description": f"Mise en service posée automatiquement au rattachement au parent {parent_id} ({today})", + "timestamp": now, + "previous_value": {"in_service_date": None}, + "new_value": {"in_service_date": today}, + }) + piece["view_auditor"]["audit_trail"].append({ + "action": "ATTACHED", + "description": f"Rattachée au parent {parent_id}", + "timestamp": now, + "previous_value": {"parent_id": old_parent_id}, + "new_value": {"parent_id": parent_id}, + }) + self._persist(piece) + return self.get(parent_id) + + def detach(self, piece_id: int, disposition: str) -> dict: + with self._lock: + piece = self._raw_get(piece_id) + if piece is None: + raise KeyError(f"Pièce {piece_id} introuvable.") + + old_parent_id = piece["parent_id"] + piece["parent_id"] = None + piece["path"] = f"libre_{piece_id}" + status = "Rebut" if disposition == "scrap" else "Stock" + piece["view_admin"]["operational_status"] = status + piece["view_admin"]["inventory_tag"] = "" + piece["view_admin"]["is_tag_bearer"] = False + piece["view_auditor"]["audit_trail"].append({ + "action": "DETACHED", + "description": f"Détachée du parent {old_parent_id} — disposition {disposition}", + "timestamp": _now_iso(), + "previous_value": {"parent_id": old_parent_id}, + "new_value": {"parent_id": None, "operational_status": status}, + }) + self._persist(piece) + return self.get(piece_id) + + # ---- Porteur d'étiquette ---- + def set_tag_bearer(self, piece_id: int) -> list[dict]: + """Désigne une pièce comme porteur d'étiquette pour son immobilisation parente. + + - Tous les siblings perdent is_tag_bearer. + - L'étiquette affichée sur tous les siblings et sur le parent est déduite + du porteur à la lecture (aucun stockage sur les non-porteurs). + - Migration rétroactive : si la pièce n'a pas d'étiquette et le parent en a + une, celle-ci est copiée sur la pièce porteur. + """ + with self._lock: + piece = self._raw_get(piece_id) + if piece is None: + raise KeyError(f"Pièce {piece_id} introuvable.") + if piece["parent_id"] is None: + raise ValueError("La pièce doit être rattachée à une immobilisation pour être porteur d'étiquette.") + parent_id = piece["parent_id"] + parent = self._raw_get(parent_id) + if parent is None: + raise KeyError(f"Parent {parent_id} introuvable.") + now = _now_iso() + + # Migration rétroactive : hérite l'étiquette du parent si la pièce n'en a pas. + if not piece["view_admin"].get("inventory_tag") and parent["view_admin"].get("inventory_tag"): + piece["view_admin"]["inventory_tag"] = parent["view_admin"]["inventory_tag"] + + # Retirer le statut porteur des autres pièces. + for sibling in self._raw_children(parent_id): + if sibling["id"] == piece_id: + continue + if sibling["view_admin"].get("is_tag_bearer"): + sibling["view_admin"]["is_tag_bearer"] = False + sibling["view_auditor"]["audit_trail"].append({ + "action": "TAG_BEARER_REMOVED", + "description": f"Statut porteur retiré (nouveau porteur : pièce {piece_id})", + "timestamp": now, + "previous_value": {"is_tag_bearer": True}, + "new_value": {"is_tag_bearer": False}, + }) + self._persist(sibling) + + # Désigner cette pièce comme porteur. + tag = piece["view_admin"].get("inventory_tag", "") + piece["view_admin"]["is_tag_bearer"] = True + piece["view_auditor"]["audit_trail"].append({ + "action": "TAG_BEARER_SET", + "description": f"Désignée porteur d'étiquette pour l'immo. {parent_id} — étiquette : {tag!r}", + "timestamp": now, + "previous_value": {"is_tag_bearer": False}, + "new_value": {"is_tag_bearer": True}, + }) + self._persist(piece) + + # Journaliser sur le parent. + parent["view_auditor"]["audit_trail"].append({ + "action": "TAG_BEARER_CHANGED", + "description": f"Porteur d'étiquette → pièce {piece_id} ({piece['name']}) — étiquette : {tag!r}", + "timestamp": now, + "previous_value": None, + "new_value": {"tag_bearer_piece_id": piece_id, "inventory_tag": tag}, + }) + self._persist(parent) + return self.all() + + # ---- Suppression ---- + def delete_asset(self, asset_id: int) -> None: + with self._lock: + # Détache d'abord les pièces enfants (deviennent libres). + for child in self._raw_children(asset_id): + child["parent_id"] = None + child["path"] = f"libre_{child['id']}" + child["view_admin"]["operational_status"] = "Stock" + self._persist(child) + self._conn.execute("DELETE FROM assets WHERE id = ?", (asset_id,)) + self._conn.commit() + + # ---- Export fiscal ---- + def fiscal_export(self) -> dict: + """Regroupe les PARENTS par catégorie DPA avec base consolidée.""" + parents = [a for a in self.all() if a["type"] == PARENT_TYPE] + by_class: dict[int, dict] = {} + for p in parents: + cca = p["view_cpa"]["cca_class"] + cid = cca["id"] + entry = by_class.setdefault(cid, { + "cca_class_id": cid, + "description": cca["description"], + "dpa_rate": cca["dpa_rate"], + "items": [], + "total_base_cad": 0.0, + }) + base = float(p["view_cpa"]["capitalizable_base_cad"]) + entry["items"].append({ + "id": p["id"], + "name": p["name"], + "inventory_tag": p["view_admin"]["inventory_tag"], + "base_cad": base, + "in_service_date": p["view_cpa"]["in_service_date"], + "half_year_rule": p["view_cpa"]["half_year_rule_applies"], + "pieces_count": p["view_admin"].get("pieces_count", 0), + }) + entry["total_base_cad"] = round(entry["total_base_cad"] + base, 2) + return {"classes": list(by_class.values()), "generated_at": _now_iso()} + + def reset(self) -> None: + with self._lock: + self._conn.execute("DELETE FROM assets") + self._conn.execute("DELETE FROM cca_pool_years") + self._conn.commit() + + def backup(self, dest_path: Path) -> None: + dest_path.parent.mkdir(parents=True, exist_ok=True) + dest_conn = sqlite3.connect(str(dest_path)) + with self._lock: + self._conn.backup(dest_conn) + dest_conn.close() + + def restore_from(self, src_path: Path) -> None: + src_conn = sqlite3.connect(str(src_path)) + with self._lock: + src_conn.backup(self._conn) + src_conn.close() + + +store = AssetStore() + +# Registre fiscal (pool de catégorie) branché sur le même store/connexion. +from .cca_registry import CcaRegistry +registry = CcaRegistry(store._conn, store, store._lock) diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lancer.bat b/lancer.bat new file mode 100644 index 0000000..4ea5b24 --- /dev/null +++ b/lancer.bat @@ -0,0 +1,28 @@ +@echo off +:: Gestionnaire d'immobilisations — lancement rapide (Windows) +cd /d "%~dp0" + +if not exist ".venv" ( + echo Premiere utilisation : creation de l'environnement Python... + python -m venv .venv + if errorlevel 1 ( + echo ERREUR : Python 3 n'est pas installe. + echo Telecharge-le sur https://www.python.org/downloads/ + pause + exit /b 1 + ) +) + +.venv\Scripts\pip install -q -r requirements.txt + +if not exist "data" mkdir data +set IMMOS_DB_PATH=%~dp0data\immos.db + +echo. +echo Gestionnaire d'immobilisations - Chezlepro Inc. +echo Ouvre http://localhost:8001 dans ton navigateur. +echo Ctrl+C pour arreter. +echo. + +start http://localhost:8001 +.venv\Scripts\uvicorn app.main:app --host 127.0.0.1 --port 8001 diff --git a/lancer.sh b/lancer.sh new file mode 100755 index 0000000..6454c1d --- /dev/null +++ b/lancer.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Gestionnaire d'immobilisations — lancement rapide (Mac / Linux) +set -e +cd "$(dirname "$0")" + +if [ ! -d ".venv" ]; then + echo "Première utilisation : création de l'environnement Python..." + python3 -m venv .venv +fi + +.venv/bin/pip install -q -r requirements.txt + +mkdir -p data +export IMMOS_DB_PATH="$(pwd)/data/immos.db" + +PORT=8001 +URL="http://localhost:$PORT" + +echo "" +echo " Gestionnaire d'immobilisations — Chezlepro Inc." +echo " Ouvre $URL dans ton navigateur." +echo " Ctrl+C pour arrêter." +echo "" + +# Ouvre le navigateur automatiquement (Mac et Linux) +if command -v open &>/dev/null; then + sleep 1 && open "$URL" & +elif command -v xdg-open &>/dev/null; then + sleep 1 && xdg-open "$URL" & +fi + +.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port $PORT diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ec09873 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +python-multipart==0.0.9 diff --git a/scripts/import_config.py b/scripts/import_config.py new file mode 100644 index 0000000..a1d8318 --- /dev/null +++ b/scripts/import_config.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +Script d'import de configuration d'immobilisations fiscales. + +Usage (depuis la racine du projet) : + python3 scripts/import_config.py + +Par défaut le script écrit dans immos_test.db. +Après validation, changer DB_FILE en "immos.db". +""" +import sys +from pathlib import Path + +# Garantit que la racine du projet est dans sys.path, peu importe d'où le +# script est appelé. +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app.store import AssetStore # noqa: E402 + +# ── BASE DE TEST ───────────────────────────────────────────────────────────── +# Changer en ROOT / "immos.db" uniquement après validation avec la base test. +DB_FILE = ROOT / "immos_test.db" +store = AssetStore(db_path=DB_FILE) + +# ── CONFIGURATION DES IMMOBILISATIONS ──────────────────────────────────────── +# Structure : +# "asset" : champs du PARENT (immobilisation fiscale) +# "pieces" : liste des pièces à créer et rattacher +# +# Champs reconnus par create_asset : +# Parent : name, cca_class_id, in_service_date, assembler, inventory_tag +# Pièce : name, serial_number, inventory_tag, manufacturer, model, +# form_factor, cost_cad, acquisition_date, supplier, invoice_number, +# replacement_cost_cad, fnacc_override_cad (None = calcul auto) +# +# NOTE : ne saisissez pas de valeur directement sur le parent — sa base fiscale +# est calculée automatiquement comme la somme des coûts des pièces. +# ───────────────────────────────────────────────────────────────────────────── +IMMOBILISATIONS = [ + { + "asset": { + "name": "Serveur de calcul DEMO-SRV-01", + "cca_class_id": 50, # Cat. 50 : matériel informatique, 55 % + "in_service_date": "2025-01-15", + "assembler": "Acme Integrators Inc.", + "inventory_tag": "TAG-SRV-001", + }, + "pieces": [ + { + "name": "CPU Intel Xeon Gold 6338", + "serial_number": "SN-CPU-001", + "manufacturer": "Intel", + "model": "Xeon Gold 6338", + "form_factor": "CPU", + "cost_cad": 4200.00, + "acquisition_date": "2025-01-10", + "supplier": "MicroAge Canada", + "invoice_number": "INV-2025-0042", + "replacement_cost_cad": 4500.00, # prix marché actuel pour ce CPU + }, + { + "name": "RAM DDR4 ECC 256 Go (8x32 Go)", + "serial_number": "SN-RAM-001", + "manufacturer": "Samsung", + "model": "M393A4K40DB3", + "form_factor": "DIMM", + "cost_cad": 2800.00, + "acquisition_date": "2025-01-10", + "supplier": "MicroAge Canada", + "invoice_number": "INV-2025-0042", + "replacement_cost_cad": 2600.00, # prix actuel légèrement inférieur + }, + { + "name": "SSD NVMe 4 To Samsung 980 Pro", + "serial_number": "SN-SSD-001", + "manufacturer": "Samsung", + "model": "980 Pro 4TB", + "form_factor": "M.2", + "cost_cad": 1100.00, + "acquisition_date": "2025-01-10", + "supplier": "MicroAge Canada", + "invoice_number": "INV-2025-0042", + "replacement_cost_cad": 950.00, # prix en baisse depuis l'achat + }, + ], + }, + { + "asset": { + "name": "Commutateur reseau DEMO-SW-01", + "cca_class_id": 46, # Cat. 46 : infra reseau de donnees, 30 % + "in_service_date": "2025-02-01", + "assembler": "Cisco Reseller Quebec", + "inventory_tag": "TAG-SW-001", + }, + "pieces": [ + { + "name": "Cisco Catalyst 9300-48P", + "serial_number": "SN-SW-001", + "manufacturer": "Cisco", + "model": "Catalyst 9300-48P", + "form_factor": "1U", + "cost_cad": 8500.00, + "acquisition_date": "2025-01-25", + "supplier": "Cisco Quebec", + "invoice_number": "INV-2025-0055", + "replacement_cost_cad": 9200.00, + }, + { + "name": "Alim. redondante Cisco PWR-C1-1100WAC", + "serial_number": "SN-PSU-001", + "manufacturer": "Cisco", + "model": "PWR-C1-1100WAC", + "form_factor": "PSU", + "cost_cad": 750.00, + "acquisition_date": "2025-01-25", + "supplier": "Cisco Quebec", + "invoice_number": "INV-2025-0055", + "replacement_cost_cad": 820.00, + }, + ], + }, +] +# ───────────────────────────────────────────────────────────────────────────── + + +def _check_duplicates(config: list[dict]) -> list[str]: + """Retourne les avertissements de doublons potentiels dans la base cible.""" + warnings = [] + existing = store.all() + existing_names = {a["name"] for a in existing if a["type"] == "parent"} + existing_serials = { + a["view_admin"]["serial_number"] + for a in existing + if a["type"] == "piece" and a["view_admin"].get("serial_number") + } + + for entry in config: + aname = entry["asset"]["name"] + if aname in existing_names: + warnings.append(f" PARENT deja present : << {aname} >>") + for piece in entry.get("pieces", []): + sn = piece.get("serial_number", "") + if sn and sn in existing_serials: + warnings.append( + f" PIECE deja presente (S/N {sn}) : {piece['name']}" + ) + + return warnings + + +def _import_all(config: list[dict]) -> None: + for entry in config: + # ── Creer le parent ────────────────────────────────────────────────── + a_payload = dict(entry["asset"]) + a_payload["type"] = "parent" + parent = store.create_asset(a_payload) + parent_id = parent["id"] + + # ── Creer et rattacher chaque piece ────────────────────────────────── + pieces_created = [] + for p in entry.get("pieces", []): + p_payload = dict(p) + p_payload["type"] = "piece" + # La piece herite de la categorie DPA du parent. + p_payload["cca_class_id"] = entry["asset"].get("cca_class_id", 50) + piece = store.create_asset(p_payload) + store.attach(piece["id"], parent_id) + pieces_created.append(piece) + + # Relit le parent et les pieces pour avoir toutes les valeurs calculees. + parent_updated = store.get(parent_id) + cpa = parent_updated["view_cpa"] + cat = cpa["cca_class"] + adm = parent_updated["view_admin"] + + print(f"\n [OK] {parent_updated['name']} (id={parent_id})") + print(f" Categorie DPA : {cat['id']} -- {cat['description']} " + f"({cat['dpa_rate'] * 100:.0f} %)") + print(f" Mise en service : {cpa['in_service_date']}") + print(f" Assembleur : {adm['assembler']}") + print(f" Pieces rattachees : {len(pieces_created)}") + print(f" {'Piece':<42} {'Cout hist.':>11} {'FNACC':>10} {'Rempl.':>10} Override") + print(f" {'-'*42} {'-'*11} {'-'*10} {'-'*10} {'-'*8}") + for pc in pieces_created: + # Relit la piece pour avoir fnacc_cad calcule + pc_full = store.get(pc["id"]) + cost = pc_full["view_cpa"]["capitalizable_base_cad"] + fnacc = pc_full["view_cpa"].get("fnacc_cad", 0) + ref_y = pc_full["view_cpa"].get("fnacc_reference_year", "?") + is_ovr = pc_full["view_cpa"].get("fnacc_is_override", False) + repl = pc_full["view_admin"].get("replacement_cost_cad", 0) + sn = pc_full["view_admin"]["serial_number"] or "—" + nm = f"{pc_full['name']} [{sn}]" + ovr_flag = f"oui ({ref_y})" if is_ovr else f"auto ({ref_y})" + print(f" {nm:<40} {cost:>11,.2f} {fnacc:>10,.2f} {repl:>10,.2f} {ovr_flag}") + print(f" {'':42} {'-'*11} {'-'*10} {'-'*10}") + print(f" {'TOTAUX':42} {cpa['capitalizable_base_cad']:>11,.2f} " + f"{cpa['net_book_value_cad']:>10,.2f} " + f"{adm['total_replacement_cost_cad']:>10,.2f}") + from datetime import date as _date + ref_label = _date.today().year - 1 + print(f" (base fiscale historique / FNACC theorique au 31-12-{ref_label} / cout remplacement)") + + +def main() -> None: + print() + print("=" * 62) + print(f" Import d'immobilisations -- BASE : {DB_FILE.name}") + print("=" * 62) + + # ── Verification des doublons ──────────────────────────────────────────── + warnings = _check_duplicates(IMMOBILISATIONS) + if warnings: + print("\n[AVERTISSEMENT -- DOUBLONS POTENTIELS]") + for w in warnings: + print(w) + resp = input("\nContinuer quand meme ? (oui/non) : ").strip().lower() + if resp != "oui": + print("Import annule.") + sys.exit(0) + + # ── Import ─────────────────────────────────────────────────────────────── + print("\n[IMPORT EN COURS]") + _import_all(IMMOBILISATIONS) + + # ── Recapitulatif global ───────────────────────────────────────────────── + all_parents = [a for a in store.all() if a["type"] == "parent"] + total_base = sum(a["view_cpa"]["capitalizable_base_cad"] for a in all_parents) + print() + print("-" * 62) + print(f" Recapitulatif global ({DB_FILE.name})") + print(f" Immobilisations : {len(all_parents)}") + print(f" Base fiscale totale : {total_base:,.2f} $ CAD") + print("=" * 62) + print() + + +if __name__ == "__main__": + main() diff --git a/static/aide.html b/static/aide.html new file mode 100644 index 0000000..3396335 --- /dev/null +++ b/static/aide.html @@ -0,0 +1,830 @@ + + + + + +Guide d'utilisation — Gestionnaire d'immobilisations fiscales + + + + + + + + +
+ +

Guide d'utilisation

+
Gestionnaire d'immobilisations fiscales — Chezlepro Inc.  ·  Édition québécoise / canadienne
+ + +

1. Introduction

+ +

À quoi sert cet outil

+

+Cet outil gère le cycle de vie complet de tes immobilisations corporelles dans un contexte de déclaration fiscale canadienne et québécoise. +Il produit directement les données dont tu as besoin pour remplir tes formulaires fiscaux annuels, sans passer par un CPA. +

+

Concrètement, il te permet de :

+
    +
  • Tenir un inventaire structuré de tes équipements (qui, quoi, où, combien)
  • +
  • Calculer automatiquement la déduction pour amortissement (DPA) par catégorie de classe CCA
  • +
  • Générer les données pour l'Annexe 8 (T2 fédéral) et l'Annexe D (CO-17 québécois)
  • +
  • Obtenir un rapport d'exercice imprimable avec tous les ajustements Schedule 1
  • +
  • Maintenir une piste d'audit complète défendable devant Revenu Québec ou l'ARC
  • +
+ +

Pourquoi l'utiliser sans CPA

+

+Pour une petite corporation québécoise comme Chezlepro Inc., la partie la plus technique du T2/CO-17 est le calcul de la DPA. +C'est précisément cette partie-là que l'outil automatise entièrement. +Le reste — revenu, dépenses, taux d'imposition — est relativement simple une fois la DPA réglée. +

+
+ Rappel légal : Les calculs de cet outil sont structurellement corrects selon les règles de l'ARC/Revenu Québec, + mais ils demeurent indicatifs. Tu es responsable de la conformité de ta déclaration fiscale. + En cas de doute sur une situation particulière (vente d'actifs, financement, crédit d'impôt), consulte un fiscaliste. +
+ + +

2. Concepts fondamentaux

+ +

Le modèle à deux niveaux

+

+L'outil utilise un modèle à deux niveaux qui reflète la réalité d'un parc informatique : +

+ + + + + + +
NiveauAppeléCorrespond àExemple
ParentImmobilisation fiscaleL'unité que l'ARC/RQ voit et amortit comme un toutServeur Dell PowerEdge R760
PiècePièce d'inventaireChaque composant physique, avec son propre coût et son numéro de sérieCarte mère, RAM, disque NVMe, GPU
+

+La base amortissable fiscale du parent est automatiquement calculée comme la somme des coûts de toutes ses pièces rattachées. +Tu n'entres jamais le coût total du parent directement — il émerge des pièces qui le composent. +

+
+ Pourquoi ce modèle ? Un serveur est physiquement constitué de composants distincts, achetés séparément, à des dates différentes. + Chaque composant a son propre bon de commande, son propre numéro de série, et sa propre durée de vie. + Ce modèle te permet de remplacer une pièce défectueuse sans perturber la structure fiscale du parent. +
+ +

La DPA et les pools

+

+La Déduction pour Amortissement (DPA) est la façon dont l'ARC te permet de déduire fiscalement la perte de valeur de tes équipements. +Contrairement à l'amortissement comptable (linéaire), la DPA utilise un système de pools par catégorie. +

+

+Un pool fonctionne ainsi : +

+
    +
  1. Tous les actifs d'une même catégorie (ex. : Cat. 50 — matériel informatique) sont regroupés dans un seul solde
  2. +
  3. Chaque année, on applique le taux de la catégorie sur ce solde pour calculer la DPA maximale
  4. +
  5. Le solde restant s'appelle la FNACC (Fraction Non Amortie du Coût en Capital)
  6. +
  7. Quand on dispose d'un actif, le produit de la vente (souvent $0 pour du rebut) vient réduire le pool
  8. +
+
+ Tu n'es pas obligé de réclamer le maximum de DPA chaque année. Tu peux en réclamer moins (zéro si tu veux), + et reporter la déduction aux années suivantes où ton revenu imposable sera plus élevé. + Le Registre de pool DPA te permet de contrôler ce montant. +
+ +

La FNACC

+

+La Fraction Non Amortie du Coût en Capital (FNACC) est le solde du pool à la fin de chaque exercice. +C'est la valeur fiscale résiduelle de tes actifs — ce qu'il te reste à amortir. +

+

+L'outil calcule une FNACC indicative par pièce (dans la vue CPA) pour t'aider à visualiser la situation. +La FNACC légalement opposable, elle, appartient au solde de la catégorie entière (le pool) — elle ne se décompose pas par actif individuel selon la loi fiscale. +

+ +

Amortissement linéaire vs DPA dégressif

+ + + + + + + + + +
Amortissement linéaireDPA dégressif (fiscal)
UsageComptable / IndicatifFiscal / Obligatoire pour le T2/CO-17
MéthodeCoût ÷ durée de vie utile, chaque annéeTaux × solde décroissant
Fin de vieValeur = $0 après la durée de vieValeur tend vers $0 sans jamais l'atteindre exactement
Dans l'outilVue Propriétaire — barre de progressionVue CPA — FNACC
Pourquoi s'en servirBudgets, assurances, remplacement planifiéDéclaration fiscale T2/CO-17
+
+ Pour la planification de tes remplacements d'équipements, l'amortissement linéaire est plus utile. + Pour ta déclaration fiscale, c'est toujours la DPA dégressif qui s'applique. +
+ +

La règle de la demi-année

+

+L'ARC impose que dans l'année d'acquisition d'un actif, tu ne peux réclamer que la moitié de la DPA normale. +C'est la « règle de la demi-année » ou « règle de la passation en charges de 50 % ». +

+

+Exemple : Tu achètes du matériel Cat. 50 (55%) pour 10 000 $.
+Année 1 : DPA = 10 000 $ × 55% × 50% = 2 750 $ (demi-année)
+Année 2 : DPA = 7 250 $ × 55% = 3 987,50 $ (année pleine)
+Etc. +

+

L'outil applique automatiquement cette règle dans tous les calculs.

+ + +

3. Démarrage rapide

+ +

Données démo

+

+Le bouton « Données démo » dans la barre du haut insère un exemple réaliste : +un serveur Dell PowerEdge R760 avec trois pièces composantes, plus une pièce libre en stock. +Utilise-le pour explorer l'interface sans risque sur tes vraies données. +

+
+ Le bouton « Réinitialiser » vide entièrement la base de données. + Fais une sauvegarde avant toute opération sur tes vraies données. +
+ +

Workflow de base

+
+
1Créer l'immobilisation fiscale (parent)
+
2Créer chaque pièce composante
+
3Rattacher les pièces au parent
+
4Saisir l'historique dans le Registre pool
+
5Générer l'Annexe 8 et le Rapport fiscal
+
+ + +

4. Immobilisations fiscales

+ +

Créer une immobilisation

+

+Clique sur « + Immobilisation » dans la barre du haut. Remplis les champs : +

+ + + + + + + + + +
ChampObligatoireNotes
Nom / désignationOuiNom interne descriptif (ex. : « Serveur hyperviseur principal »)
Catégorie DPAOuiVoir le tableau des catégories ci-dessous
Date de mise en serviceRecommandéDate à laquelle l'actif a commencé à être utilisé (pas nécessairement la date d'achat)
AssembleurNonNom de la personne ou entreprise qui a assemblé le système
StatutNonActif / En maintenance / Hors service
+
+ Le coût et l'étiquette d'inventaire d'un parent ne se saisissent pas directement. + Le coût est calculé automatiquement à partir des pièces; l'étiquette est portée par une des pièces (voir section 5.3). +
+ +

Catégories DPA disponibles

+ + + + + + + + + +
Cat.TauxDescriptionDurée indicative
5055%Matériel informatique et logiciels de systèmes (après mars 2007)4 ans
4630%Matériel d'infrastructure de réseau de données7 ans
12100%Logiciels d'application, petits outils (< 500 $)2 ans
1030%Matériel informatique acquis avant mars 2007, véhicules5 ans
820%Mobilier, équipement et matériel divers10 ans
+
+ Pour un datacenter : la grande majorité des serveurs, ordinateurs et équipements actifs récents vont en Cat. 50 (55%). + Les commutateurs réseau, routeurs et câblage structuré vont en Cat. 46 (30%). + Les UPS, racks et mobilier vont en Cat. 8 (20%). +
+ +

Base amortissable consolidée

+

+La base amortissable d'une immobilisation fiscale est la somme des coûts capitalisables de toutes ses pièces rattachées. +Elle est recalculée automatiquement à chaque modification. +

+

+Le coût capitalisable d'une pièce inclut : +

+
    +
  • Le prix d'achat (converti en CAD au taux de change saisi)
  • +
  • Les taxes non récupérables et droits de douane
  • +
+

+Les taxes récupérables (TPS/TVQ pour une entreprise inscrite) ne font pas partie du coût capitalisable — elles sont réclamées séparément dans tes déclarations de taxes. +

+ + +

5. Pièces d'inventaire

+ +

Créer une pièce

+

+Clique sur « + Pièce ». Les champs principaux : +

+ + + + + + + + + + + + + + + +
ChampNotes
Nom / désignationDescriptif et unique (ex. : « RAM DDR5 32 GB — Slot A1 »)
Numéro de sérieCritique pour la traçabilité auditeur. Saisi à l'achat.
Coût (CAD)Coût d'acquisition hors taxes récupérables
Taxes et droits non récupérablesDroits de douane, taxes d'importation, frais de courtage
Taux de changeSi achat en USD : entrer le taux à la date d'achat (ex. : 1.37)
Date d'acquisitionDate de la facture
Date de mise en serviceDate d'installation effective (peut différer de l'acquisition)
Durée de vie utile (ans)Utilisée pour l'amortissement linéaire indicatif. Défaut selon la catégorie DPA.
FournisseurNom légal du fournisseur
N° de factureRéférence pour retrouver le document justificatif
Coût de remplacementValeur actuelle de remplacement à neuf — utile pour les assurances
+ +

Rattacher une pièce à une immobilisation

+

+Une pièce peut être créée indépendamment (pièce libre / stock), puis rattachée à un parent. +Il y a deux façons de le faire : +

+
    +
  1. Sélectionner la pièce libre → onglet Propriétaire → bouton « Rattacher à une immo » → choisir le parent dans la liste
  2. +
  3. Naviguer dans une immobilisation → les pièces libres apparaissent dans le panneau de rattachement
  4. +
+
+ Dès qu'une pièce est rattachée, son coût s'ajoute automatiquement à la base amortissable du parent. + La date de mise en service du parent est mise à jour si elle était antérieure. +
+ +

Étiquette d'inventaire et porteur

+

+Chaque assemblage physique (parent + ses pièces) porte une seule étiquette d'inventaire. +Parmi toutes les pièces rattachées, une seule est désignée comme porteur d'étiquette ⭐. +Toutes les autres pièces héritent de cette étiquette — elles ne stockent pas leur propre numéro. +

+

Comment désigner le porteur :

+
    +
  1. Sélectionner une pièce rattachée
  2. +
  3. Onglet Propriétaire → bouton « ⭐ Désigner comme porteur d'étiquette »
  4. +
+
+ Bonne pratique : désigne le chassis ou la pièce principale comme porteur. + C'est généralement la pièce sur laquelle l'étiquette physique est collée. + Si la pièce porteuse est remplacée ou retirée, le numéro d'étiquette peut être migré vers une autre pièce. +
+

+Si une pièce est détachée, elle perd son étiquette et son statut de porteur. +Cela reflète la réalité physique : une pièce sortie d'un assemblage n'en porte plus l'étiquette. +

+ +

Détacher une pièce

+

+Sélectionner une pièce rattachée → onglet Propriétaire → section Actions. +Trois options de détachement : +

+ + + + + + + + + + + + + + + + + + + +
OptionSignificationImpact fiscal
LibrePièce retirée mais encore utilisable — retourne dans le stock de pièces libresLa base du parent diminue. La pièce reste dans le pool (aucune disposition).
StockPièce retirée pour reconditionnement ou reventeGénère une écriture de disposition dans le pool. Produit de disposition = montant saisi.
RebutPièce mise au rebut définitivement (fin de vie, bris irréparable)Génère une disposition à $0 dans le pool (Chezlepro ne revend pas ses pièces usagées).
+
+ Règle fiscale : une pièce mise au rebut (disposition à $0) réduit le solde du pool de $0. + Elle ne crée pas de perte déductible immédiate — la DPA sur ce montant a déjà été réclamée au fil des années. + La perte finale n'apparaît que si le pool entier d'une catégorie tombe à zéro alors que des actifs existent encore physiquement. +
+ +

Cycle de vie complet d'une pièce

+

+Voici le parcours typique d'une pièce d'inventaire dans le système : +

+
    +
  1. Achat → Création de la pièce avec coût, facture, numéro de série
  2. +
  3. Installation → Rattachement à une immobilisation fiscale
  4. +
  5. Service actif → Dépréciation annuelle via le pool DPA
  6. +
  7. Panne / remplacement → Détachement en « rebut » ($0) + création d'une nouvelle pièce de remplacement
  8. +
  9. La nouvelle pièce est rattachée → son coût augmente la base du parent → le pool de la catégorie augmente en conséquence
  10. +
+
+ Après 10-15 ans de vie, une immobilisation peut avoir une FNACC indicative très faible + (les pièces originales ont presque toutes été remplacées), mais son coût total cumulé reflète tous les investissements réels. + C'est la nature normale du système dégressif. +
+ + +

6. Les trois vues

+

+Sélectionne n'importe quel actif (parent ou pièce) dans la grille principale. +Le panneau latéral droit affiche trois onglets selon le rôle : +

+ +

Vue Propriétaire

+

+Destinée au dirigeant au quotidien. Elle répond à la question : « Est-ce que cet équipement est encore en forme ? » +

+
    +
  • Statut opérationnel — Actif / En maintenance / Hors service
  • +
  • Barre de progression — Amortissement linéaire indicatif (visuel)
  • +
  • VNC linéaire — Valeur nette comptable selon la méthode linéaire
  • +
  • Coût de remplacement — Valeur à neuf pour les assurances et la budgétisation
  • +
  • Étiquette d'inventaire et porteur
  • +
  • Actions — Rattacher, détacher, désigner porteur d'étiquette
  • +
+ +

Vue CPA

+

+Destinée à la préparation de la déclaration fiscale. Elle répond à : « Quelle est la valeur fiscale de cet actif ? » +

+
    +
  • FNACC indicative — Fraction non amortie selon la méthode DPA dégressif
  • +
  • Base capitalisable — Coût d'acquisition incluant taxes non récupérables
  • +
  • Catégorie DPA et taux applicable
  • +
  • Date de mise en service et application de la règle de la demi-année
  • +
  • Amortissement linéaire détaillé — Cumulé, VNC, durée de vie résiduelle
  • +
+
+ Pour un parent, la vue CPA affiche également la VNC linéaire totale consolidée (somme de toutes les pièces). + C'est le chiffre à utiliser dans ton bilan si tu optes pour la méthode linéaire dans tes états financiers. +
+ +

Vue Auditeur

+

+Destinée à une vérification externe (Revenu Québec, ARC, auditeur indépendant). Elle répond à : +« D'où vient cet actif et qu'est-il advenu de lui ? » +

+
    +
  • Numéro de série, fabricant, modèle
  • +
  • Fournisseur, numéro de facture, référence PDF
  • +
  • Piste d'audit complète — chaque modification est enregistrée avec la date, l'ancienne valeur et la nouvelle valeur
  • +
+
+ Valeur auprès de Revenu Québec : La piste d'audit te permet de démontrer à tout moment + la provenance, le coût, et l'historique de chaque actif. C'est ta protection en cas de vérification fiscale. +
+ + +

7. Registre de pool DPA

+ +

Comprendre le pool

+

+Clique sur « Registre pool » dans la barre du haut. +Tu peux sélectionner la catégorie DPA avec le menu déroulant. +

+

+Le registre calcule automatiquement, à partir de tes actifs, les valeurs suivantes pour chaque catégorie et chaque exercice : +

+ + + + + + + + + + + + + + +
ColonneSignification
FNACC ouvertureSolde du pool au 1er janvier (= clôture de l'année précédente)
AjoutsCoût des actifs acquis dans l'année (calculé à partir de tes pièces)
DispositionsProduit de cession des actifs vendus ou mis au rebut dans l'année
Coeff. ajustementFacteur de la règle de demi-année (0.5 par défaut)
Ajustement= (Ajouts − Dispositions) × Coeff. — application de la règle de demi-année
Base DPAFNACC ouv. + Ajouts − Dispositions − Ajustement
DPA réclaméeBase DPA × Taux (modifiable — tu peux réclamer moins)
FNACC clôtureBase DPA − DPA réclamée
RécupérationRevenus supplémentaires si le pool passe en négatif
Perte finaleDéduction supplémentaire si le pool se vide alors qu'il reste des actifs
+ +

Gérer une année dans le registre

+

+Pour chaque exercice fiscal, tu peux : +

+
    +
  • Modifier la DPA réclamée — en saisissant une valeur différente du maximum calculé
  • +
  • Forcer la FNACC d'ouverture — utile pour la saisie initiale si tu migres d'un autre système
  • +
  • Forcer les ajouts — si le calcul automatique ne correspond pas à ce que tu as déclaré
  • +
  • Indiquer si le pool est physiquement vide — déclenchera la perte finale si applicable
  • +
+
+ Première utilisation : si tu n'as jamais utilisé ce registre, saisis la FNACC d'ouverture de l'exercice le plus ancien + que tu veux suivre (trouvable dans ta dernière déclaration T2, Schedule 8, colonne « FNACC d'ouverture »). + L'outil calculera les exercices suivants automatiquement. +
+ +

Récupération d'amortissement

+

+La récupération se produit quand le produit de disposition d'un actif dépasse le solde FNACC du pool. +Le pool devient négatif, et ce solde négatif est ajouté à ton revenu imposable (c'est un revenu, pas une déduction). +

+

+Exemple : FNACC Cat. 50 = 3 000 $. Tu vends un serveur pour 5 000 $.
+3 000 $ − 5 000 $ = −2 000 $ → Récupération de 2 000 $ à inclure dans ton revenu (T2 Schedule 1, ligne 107). +

+
+ Chezlepro ne revendant pas ses équipements usagés, la récupération est extrêmement rare. + Elle ne se produirait que si un actif prenait de la valeur (ex. : équipement devenu rare) et était vendu. +
+ +

Perte finale

+

+La perte finale se produit quand tous les actifs d'une catégorie sont éliminés (vendus, rebuts) +mais qu'il reste encore un solde FNACC positif dans le pool. +Ce solde résiduel devient une déduction fiscale supplémentaire pour l'année. +

+

+Pour la déclencher dans le registre : cocher « Pool physiquement vide » pour l'année concernée. +La perte finale = FNACC clôture calculée (elle sera reportée à la ligne 414 du Schedule 1). +

+ +

Les « fantômes fiscaux »

+

+Avec le système dégressif, une pièce rebut à $0 ne fait que cesser d'exister dans l'inventaire — son coût original reste dans le pool et continue de s'amortir lentement. +Après 20 ans, une immobilisation peut avoir des pièces avec une FNACC de quelques cents — des « fantômes ». +

+

+C'est normal et légal. Deux stratégies pour s'en débarrasser proprement : +

+
    +
  1. Attendre la perte finale — Quand tous les actifs de la catégorie sont retirés, déclarer le pool vide. La FNACC résiduelle devient une perte déductible.
  2. +
  3. Transfert vers une immobilisation de clôture — Transférer toutes les vieilles pièces fantômes vers une immobilisation fictive dédiée, puis disposer de cette immobilisation proprement pour déclencher la perte finale.
  4. +
+ + +

8. Déclarations fiscales

+ +

L'Annexe 8 / Schedule 8

+

+Clique sur « Annexe 8 » dans la barre du haut. +Sélectionne l'exercice fiscal à produire. +

+

+Ce tableau correspond directement aux données demandées dans : +

+
    +
  • Fédéral : T2 Schedule 8 — Capital Cost Allowance
  • +
  • Québec : CO-17 Annexe D — Déduction pour amortissement
  • +
+

+Chaque ligne de ce tableau correspond à une catégorie DPA active dans ton entreprise. +Le bouton « Télécharger CSV » exporte ce tableau dans un format que tu peux ouvrir dans Excel ou Numbers. +

+
+ L'Annexe 8 te dit combien de DPA tu peux réclamer cette année. + C'est toi qui décides si tu réclames le maximum ou moins. + Modifie la DPA réclamée dans le Registre pool si tu veux ajuster. +
+ +

Rapport d'exercice imprimable

+

+Clique sur « Rapport fiscal »« Ouvrir rapport imprimable ». +Une nouvelle page s'ouvre avec un document complet contenant : +

+
    +
  1. Le tableau Annexe 8 complet (tous les pools)
  2. +
  3. Les ajustements Schedule 1 avec leurs numéros de lignes T2/CO-17
  4. +
  5. L'inventaire complet des actifs par catégorie DPA
  6. +
+

+Fais Ctrl+P (ou Cmd+P sur Mac) et choisis « Enregistrer en PDF » pour archiver ce document dans ton dossier fiscal annuel. +

+ +

Remplir son T2 et CO-17

+

+Voici le workflow complet de déclaration fiscale pour les immobilisations : +

+
    +
  1. + Dans l'outil → Registre pool : vérifier que les ajouts et dispositions de l'exercice sont exacts. + Ajuster la DPA réclamée si tu veux optimiser. +
  2. +
  3. + Rapport fiscal → noter les quatre montants clés (amortissement comptable, récupération, DPA réclamée, perte finale). +
  4. +
  5. + Dans ton logiciel T2 (GenuTax, TaxCycle, etc.) → Schedule 8 : saisir les données de chaque catégorie DPA depuis l'Annexe 8 de l'outil (ou importer le CSV). +
  6. +
  7. + Toujours dans ton logiciel → Schedule 1 : les ajustements liés à la DPA sont normalement remplis automatiquement depuis le Schedule 8. + Vérifier avec les montants du rapport. +
  8. +
  9. + Pour le CO-17 (Québec) : même démarche avec l'Annexe D (données identiques à l'Annexe 8 fédérale pour les catégories standard). +
  10. +
+
+ Conserve le rapport d'exercice PDF dans ton dossier fiscal annuel. + Revenu Québec peut demander la justification de ta DPA jusqu'à 6 ans après la date de production. +
+ +

Ajustements Schedule 1 — tableau de référence rapide

+

+La vue « Rapport fiscal » (modale interactive) te montre exactement ces quatre ajustements : +

+ + + + + + + + +
AjustementT2 Schedule 1CO-17 Annexe 1Effet
Amortissement comptable (add-back)Ligne 104Ligne 130+ revenu imposable
Récupération d'amortissementLigne 107Ligne 135+ revenu imposable
DPA réclaméeLigne 410Ligne 150− revenu imposable
Perte finaleLigne 414Ligne 155− revenu imposable
+

+ * Vérifier les numéros de lignes avec les formulaires officiels de l'année courante. + Ces numéros peuvent changer d'une année à l'autre lors de révisions des formulaires. +

+ + +

9. Sauvegardes

+

+Clique sur « Sauvegardes » dans la barre du haut. +

+

Créer une sauvegarde

+
    +
  1. Cliquer sur « Créer une sauvegarde maintenant »
  2. +
  3. Un fichier horodaté est créé dans le dossier data/backups/ (ex. : immos_2026-06-09_14-30-00.db)
  4. +
+

Restaurer une sauvegarde

+
    +
  1. Choisir la sauvegarde dans la liste (la plus récente est en haut)
  2. +
  3. Cliquer sur « Restaurer »
  4. +
  5. Confirmer — toutes les données actuelles seront remplacées par celles de la sauvegarde
  6. +
  7. L'interface se recharge automatiquement
  8. +
+
+ Bonne pratique : crée une sauvegarde avant chaque clôture d'exercice fiscal et avant toute opération majeure (réinitialisation, importation de données en lot). +
+
+ Les sauvegardes sont des copies complètes de la base SQLite. Elles peuvent être copiées manuellement vers un autre disque ou un service de nuage pour un archivage externe. +
+ + +

10. Stratégies et situations particulières

+ +

Transférer une pièce entre deux immobilisations

+

+L'outil supporte le transfert d'une pièce d'un parent vers un autre : +

+
    +
  1. Sélectionner la pièce → Détacher → choisir « Libre »
  2. +
  3. La pièce devient une pièce libre (son coût quitte le premier parent)
  4. +
  5. Sélectionner la pièce libre → Rattacher → choisir le second parent
  6. +
+
+ Impact fiscal : ce transfert n'est pas une disposition — aucune écriture dans le pool n'est générée. + Le coût de la pièce migre simplement d'une base amortissable à l'autre. + Les deux parents appartenant normalement à la même catégorie DPA, le solde du pool de la catégorie reste inchangé. +
+

+Cette technique est également utile pour résorber des fantômes fiscaux : +regroupe les vieilles pièces à FNACC résiduelle vers une immobilisation de clôture, puis déclenche la perte finale sur cette catégorie. +

+ +

Achats en devise étrangère (USD)

+

+Lors de la création d'une pièce achetée en USD : +

+
    +
  1. Entrer le coût en USD dans le champ « Coût »
  2. +
  3. Changer la devise sur « USD »
  4. +
  5. Entrer le taux de change CAD/USD en vigueur à la date d'acquisition
  6. +
+

+L'outil convertit automatiquement en CAD. Le taux de change historique (à la date d'achat) est la règle de l'ARC — utilise le taux de la Banque du Canada pour cette date. +

+ +

Remplacement partiel d'un composant

+

+Quand un composant tombe en panne et est remplacé sous garantie ou hors garantie : +

+
    +
  1. Sélectionner la pièce défectueuse → Détacher → Rebut ($0 — pas de produit de vente)
  2. +
  3. Créer une nouvelle pièce pour le composant de remplacement (avec son propre coût et numéro de série)
  4. +
  5. Rattacher la nouvelle pièce à l'immobilisation parent
  6. +
+

+Si le remplacement est sous garantie (coût = $0 pour toi) : +entrer un coût de $0.01 symbolique pour maintenir la traçabilité de l'actif physique, en notant la garantie dans les champs fournisseur/facture. +

+ + +

11. Questions fréquentes

+ +

La FNACC par pièce dans la vue CPA et le solde du pool DPA — pourquoi sont-ils différents ?

+

+La FNACC par pièce est indicative — calculée comme si chaque pièce vivait dans son propre mini-pool depuis sa date d'acquisition. +Le solde du pool DPA, lui, est le solde légal réel qui tient compte de l'historique exact des réclamations, des dispositions, et des années où tu as réclamé moins que le maximum. +Pour la déclaration fiscale, seul le solde du pool fait foi. La FNACC par pièce sert à la visualisation et à l'audit interne. +

+ +

Peut-on réclamer $0 de DPA une année ?

+

+Oui. La DPA est une déduction optionnelle — tu peux réclamer entre $0 et le maximum calculé. +Réclamer moins que le maximum peut être stratégique si ton revenu imposable est faible cette année +(l'ARC n'accepte pas les soldes négatifs de taxe d'un exercice à l'autre pour les petites entreprises). +Modifie la DPA réclamée dans le Registre pool pour l'exercice concerné. +

+ +

Quelle est la différence entre la date d'acquisition et la date de mise en service ?

+

+La date d'acquisition est la date de la facture — quand tu as payé ou t'es engagé à payer. +La date de mise en service est quand l'actif a commencé à être utilisé dans ton entreprise. +La règle de la demi-année s'applique à l'exercice fiscal dans lequel la mise en service a lieu. +Si tu achètes en décembre mais l'actif n'est installé qu'en janvier, la mise en service est en janvier (exercice suivant). +

+ +

Faut-il un numéro de série pour chaque pièce ?

+

+Ce n'est pas obligatoire légalement, mais fortement recommandé pour deux raisons : +(1) il te permet d'identifier physiquement chaque actif et de le retrouver en cas de vérification ; +(2) il permet de distinguer deux pièces identiques (ex. : deux barrettes RAM du même modèle). +

+ +

Comment gérer un actif acheté avant la mise en place de l'outil ?

+

+Crée l'actif normalement avec les vraies dates d'acquisition et de mise en service historiques. +Dans le Registre pool, saisis la FNACC d'ouverture réelle (tirée de ton dernier T2 Schedule 8) pour l'exercice où tu commences à utiliser l'outil. +L'outil calculera correctement les exercices suivants à partir de ce solde de départ. +

+ +

Données démo et données réelles — peut-on mélanger ?

+

+Non. Le bouton « Données démo » insère des données dans ta base réelle. +Si tu veux tester sans affecter tes données, fais d'abord une sauvegarde, +explore, puis restaure la sauvegarde. +

+ +

L'outil fonctionne-t-il pour plusieurs corporations ?

+

+L'outil est conçu pour une seule entité juridique. +Pour gérer plusieurs corporations, il faudrait une instance distincte par corporation +(dossier séparé, base de données séparée). +

+ +
+

+ Gestionnaire d'immobilisations fiscales — Chezlepro Inc.
+ Calculs indicatifs selon les règles ARC/Revenu Québec. À valider avant tout dépôt fiscal. +

+ +
+ + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..42e3df6 --- /dev/null +++ b/static/index.html @@ -0,0 +1,1256 @@ + + + + + + Gestionnaire d'immobilisations + + + + + + +
+ + + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a641ca2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +"""Configure la base SQLite de test avant tout import de l'application.""" +import os +import tempfile + +_fd, _path = tempfile.mkstemp(suffix=".db") +os.close(_fd) +os.environ["IMMOS_DB_PATH"] = _path diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..0935d61 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,636 @@ +"""Tests de régression — API du gestionnaire d'immobilisations. + +Couvre : CRUD actifs, rattachement/détachement, porteur d'étiquette, +mécanique DPA (demi-année, perte finale, récupération), export CSV, +rapport fiscal, sauvegardes, factures jointes, seed-demo. + +Exécuter : .venv/bin/pytest tests/ -v +""" +import io +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def reset_db(): + """Base et registre vides avant chaque test.""" + client.post("/api/reset") + yield + client.post("/api/reset") + + +# ─── Helpers ──────────────────────────────────────────────────────────────── + +def _newest(assets: list) -> dict: + """Retourne l'actif avec le plus grand id (le dernier créé).""" + return max(assets, key=lambda a: a["id"]) + +def _make_parent(name="Parent", cca=50) -> dict: + r = client.post("/api/assets", json={"type": "parent", "name": name, "cca_class_id": cca}) + return _newest(r.json()["assets"]) + +def _make_piece(name="Pièce", cost=500.0, acq=None, cca=50) -> dict: + body = {"type": "piece", "name": name, "cost_cad": cost, "cca_class_id": cca} + if acq: + body["acquisition_date"] = acq + r = client.post("/api/assets", json=body) + return _newest(r.json()["assets"]) + + +# ═══════════════════════════════════════════════════════════ +# CRUD — actifs +# ═══════════════════════════════════════════════════════════ + +def test_create_parent(): + r = client.post("/api/assets", json={"type": "parent", "name": "Srv Test", "cca_class_id": 50}) + assert r.status_code == 200 + d = _newest(r.json()["assets"]) + assert d["type"] == "parent" + assert d["name"] == "Srv Test" + assert d["view_cpa"]["cca_class"]["id"] == 50 + + +def test_create_piece(): + r = client.post("/api/assets", json={"type": "piece", "name": "RAM Test", "cost_cad": 420.0}) + assert r.status_code == 200 + d = _newest(r.json()["assets"]) + assert d["type"] == "piece" + assert float(d["view_cpa"]["capitalizable_base_cad"]) == pytest.approx(420.0) + + +def test_list_assets(): + client.post("/api/assets", json={"type": "parent", "name": "P1"}) + client.post("/api/assets", json={"type": "piece", "name": "PC1", "cost_cad": 100.0}) + r = client.get("/api/assets") + assert r.status_code == 200 + assert len(r.json()) == 2 + + +def test_get_asset(): + aid = _make_piece()["id"] + r = client.get(f"/api/assets/{aid}") + assert r.status_code == 200 + assert r.json()["id"] == aid + + +def test_get_asset_not_found(): + r = client.get("/api/assets/99999") + assert r.status_code == 404 + + +def test_patch_name(): + aid = _make_piece(name="Ancien")["id"] + r = client.patch(f"/api/assets/{aid}", json={"changes": {"name": "Nouveau"}}) + assert r.status_code == 200 + updated = next(a for a in r.json()["assets"] if a["id"] == aid) + assert updated["name"] == "Nouveau" + + +def test_patch_cost(): + aid = _make_piece(cost=100.0)["id"] + r = client.patch(f"/api/assets/{aid}", json={"changes": {"view_cpa.capitalizable_base_cad": 999.0}}) + assert r.status_code == 200 + updated = next(a for a in r.json()["assets"] if a["id"] == aid) + assert float(updated["view_cpa"]["capitalizable_base_cad"]) == pytest.approx(999.0) + + +def test_delete_piece(): + aid = _make_piece()["id"] + r = client.delete(f"/api/assets/{aid}") + assert r.status_code == 200 + assert not any(a["id"] == aid for a in r.json()["assets"]) + + +def test_delete_parent_frees_pieces(): + """Supprimer un parent libère ses pièces (elles deviennent libres).""" + p = _make_parent() + pc = _make_piece(cost=100.0) + client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) + + r = client.delete(f"/api/assets/{p['id']}") + assert r.status_code == 200 + assets = r.json()["assets"] + child = next(a for a in assets if a["id"] == pc["id"]) + assert child["parent_id"] is None + + +# ═══════════════════════════════════════════════════════════ +# Rattachement / détachement +# ═══════════════════════════════════════════════════════════ + +def test_attach_updates_parent_base(): + """La base amortissable du parent = somme exacte des coûts de ses pièces.""" + p = _make_parent() + pc1 = _make_piece(cost=1000.0) + pc2 = _make_piece(cost=500.0) + client.post(f"/api/assets/{pc1['id']}/attach", json={"parent_id": p["id"]}) + client.post(f"/api/assets/{pc2['id']}/attach", json={"parent_id": p["id"]}) + + r = client.get(f"/api/assets/{p['id']}") + assert float(r.json()["view_cpa"]["capitalizable_base_cad"]) == pytest.approx(1500.0) + + +def test_detach_free(): + p = _make_parent() + pc = _make_piece() + client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) + + r = client.post(f"/api/assets/{pc['id']}/detach", json={"disposition": "free"}) + assert r.status_code == 200 + detached = next(a for a in r.json()["assets"] if a["id"] == pc["id"]) + assert detached["parent_id"] is None + + +def test_detach_scrap(): + p = _make_parent() + pc = _make_piece() + client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) + + r = client.post(f"/api/assets/{pc['id']}/detach", json={"disposition": "scrap"}) + assert r.status_code == 200 + scrapped = next(a for a in r.json()["assets"] if a["id"] == pc["id"]) + assert scrapped["view_admin"]["operational_status"] == "Rebut" + assert scrapped["parent_id"] is None + + +def test_detach_reduces_parent_base(): + p = _make_parent() + pc1 = _make_piece(cost=1000.0) + pc2 = _make_piece(cost=400.0) + client.post(f"/api/assets/{pc1['id']}/attach", json={"parent_id": p["id"]}) + client.post(f"/api/assets/{pc2['id']}/attach", json={"parent_id": p["id"]}) + + client.post(f"/api/assets/{pc2['id']}/detach", json={"disposition": "free"}) + + r = client.get(f"/api/assets/{p['id']}") + assert float(r.json()["view_cpa"]["capitalizable_base_cad"]) == pytest.approx(1000.0) + + +def test_set_tag_bearer(): + p = _make_parent() + pc = _newest(client.post("/api/assets", json={ + "type": "piece", "name": "Porteur", "cost_cad": 500.0, "inventory_tag": "TAG-001" + }).json()["assets"]) + client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) + + r = client.post(f"/api/assets/{pc['id']}/set-tag-bearer") + assert r.status_code == 200 + bearer = next(a for a in r.json()["assets"] if a["id"] == pc["id"]) + assert bearer["view_admin"]["is_tag_bearer"] is True + + +def test_tag_bearer_unique_per_parent(): + """Désigner un nouveau porteur retire le statut à l'ancien.""" + p = _make_parent() + pc1 = _newest(client.post("/api/assets", json={ + "type": "piece", "name": "A", "cost_cad": 100.0, "inventory_tag": "T1" + }).json()["assets"]) + pc2 = _newest(client.post("/api/assets", json={ + "type": "piece", "name": "B", "cost_cad": 200.0, "inventory_tag": "T2" + }).json()["assets"]) + client.post(f"/api/assets/{pc1['id']}/attach", json={"parent_id": p["id"]}) + client.post(f"/api/assets/{pc2['id']}/attach", json={"parent_id": p["id"]}) + client.post(f"/api/assets/{pc1['id']}/set-tag-bearer") + + r = client.post(f"/api/assets/{pc2['id']}/set-tag-bearer") + assets = r.json()["assets"] + assert next(a for a in assets if a["id"] == pc1["id"])["view_admin"]["is_tag_bearer"] is False + assert next(a for a in assets if a["id"] == pc2["id"])["view_admin"]["is_tag_bearer"] is True + + +# ═══════════════════════════════════════════════════════════ +# Catégories DPA +# ═══════════════════════════════════════════════════════════ + +def test_cca_categories(): + r = client.get("/api/cca-categories") + assert r.status_code == 200 + ids = [c["id"] for c in r.json()] + for expected in (50, 8, 10, 12, 46): + assert expected in ids + + +def test_cca_categories_have_rate(): + cats = client.get("/api/cca-categories").json() + for c in cats: + assert 0 < c["dpa_rate"] <= 1.0 + + +# ═══════════════════════════════════════════════════════════ +# Registre pool DPA — mécanique fiscale +# ═══════════════════════════════════════════════════════════ + +def test_pool_demi_annee_standard(): + """Première année, ajouts = 10 000 $, coeff 0,5 → DPA = 2 750 $ (Cat. 50).""" + r = client.post("/api/cca-registry/year", json={ + "year": 2024, "cca_class_id": 50, + "additions_override": 10_000.0, "dispositions": 0, + "adjustment_coeff": 0.5, "opening_ucc_override": 0, + }) + assert r.status_code == 200 + y = next(yr for yr in r.json()["years"] if yr["year"] == 2024) + assert float(y["additions"]) == pytest.approx(10_000.0) + assert float(y["adjustment"]) == pytest.approx(5_000.0) + assert float(y["base_for_cca"]) == pytest.approx(5_000.0) + assert float(y["cca_claimed"]) == pytest.approx(2_750.0) # 5 000 × 0.55 + assert float(y["closing_ucc"]) == pytest.approx(7_250.0) + + +def test_pool_annee_pleine(): + """Deuxième année sans acquisitions → DPA pleine sur le solde résiduel.""" + client.post("/api/cca-registry/year", json={ + "year": 2023, "cca_class_id": 50, + "additions_override": 10_000.0, "adjustment_coeff": 0.5, + "opening_ucc_override": 0, + }) + r = client.post("/api/cca-registry/year", json={ + "year": 2024, "cca_class_id": 50, + "additions_override": 0, "adjustment_coeff": 1.0, + }) + y = next(yr for yr in r.json()["years"] if yr["year"] == 2024) + # Ouverture 2024 = 7 250 $ (clôture 2023) → DPA = 7 250 × 0.55 = 3 987.50 + assert float(y["opening_ucc"]) == pytest.approx(7_250.0) + assert float(y["cca_claimed"]) == pytest.approx(3_987.50) + + +def test_pool_recapture(): + """Produit de cession > FNACC résiduelle → récupération d'amortissement.""" + client.post("/api/cca-registry/year", json={ + "year": 2022, "cca_class_id": 10, + "additions_override": 0.0, "dispositions": 0, + "adjustment_coeff": 0.5, "opening_ucc_override": 3_000.0, + }) + client.post("/api/cca-registry/year", json={ + "year": 2023, "cca_class_id": 10, + "additions_override": 0, "dispositions": 0, "adjustment_coeff": 1.0, + }) + # Vente à 2 500 $, FNACC < 2 500 → récupération + r = client.post("/api/cca-registry/year", json={ + "year": 2024, "cca_class_id": 10, + "additions_override": 0, "dispositions": 2_500.0, + "adjustment_coeff": 1.0, "pool_physically_empty": True, + }) + y = next(yr for yr in r.json()["years"] if yr["year"] == 2024) + assert float(y["recapture"]) > 0, "Récupération attendue" + assert float(y["terminal_loss"]) == 0 + assert float(y["closing_ucc"]) == 0 + + +def test_pool_perte_finale(): + """Catégorie physiquement vide avec FNACC positive → perte finale.""" + client.post("/api/cca-registry/year", json={ + "year": 2024, "cca_class_id": 12, + "additions_override": 3_000.0, "dispositions": 0, + "adjustment_coeff": 0.5, "opening_ucc_override": 0, + }) + r = client.post("/api/cca-registry/year", json={ + "year": 2025, "cca_class_id": 12, + "additions_override": 0, "dispositions": 0, + "adjustment_coeff": 1.0, "pool_physically_empty": True, + }) + y = next(yr for yr in r.json()["years"] if yr["year"] == 2025) + assert float(y["terminal_loss"]) > 0, "Perte finale attendue" + assert float(y["cca_claimed"]) == 0 + assert float(y["closing_ucc"]) == 0 + + +def test_pool_progression_continue(): + """La FNACC de clôture d'une année est l'ouverture de la suivante.""" + for yr, coeff in [(2022, 0.5), (2023, 1.0), (2024, 1.0)]: + client.post("/api/cca-registry/year", json={ + "year": yr, "cca_class_id": 50, + "additions_override": 0, "dispositions": 0, + "adjustment_coeff": coeff, + **({"opening_ucc_override": 20_000.0} if yr == 2022 else {}), + }) + r = client.get("/api/cca-registry?cca_class_id=50") + years = {y["year"]: y for y in r.json()["years"]} + assert float(years[2022]["closing_ucc"]) == pytest.approx(float(years[2023]["opening_ucc"])) + assert float(years[2023]["closing_ucc"]) == pytest.approx(float(years[2024]["opening_ucc"])) + + +def test_delete_registry_year(): + client.post("/api/cca-registry/year", json={"year": 2024, "cca_class_id": 50}) + r = client.delete("/api/cca-registry/year/2024?cca_class_id=50") + assert r.status_code == 200 + assert not any(y["year"] == 2024 for y in r.json()["years"]) + + +# ═══════════════════════════════════════════════════════════ +# Annexe 8 / Schedule 8 +# ═══════════════════════════════════════════════════════════ + +def test_annexe8_empty(): + r = client.get("/api/annexe8?year=2024") + assert r.status_code == 200 + assert r.json()["classes"] == [] + + +def test_annexe8_calcul_dpa_cat8(): + """Annexe 8 utilise le bon taux de la catégorie (Cat. 8 = 20 %, pas 55 %).""" + p = _make_parent(cca=8) + pc = _make_piece(cost=10_000.0, acq="2024-01-01", cca=8) + client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) + client.post("/api/cca-registry/year", json={ + "year": 2024, "cca_class_id": 8, "dispositions": 0, "adjustment_coeff": 0.5, + }) + + r = client.get("/api/annexe8?year=2024") + cls8 = next(c for c in r.json()["classes"] if c["cca_class_id"] == 8) + y = cls8["year_data"] + # adj = 0.5 × 10 000 = 5 000 ; base = 5 000 ; DPA = 5 000 × 0.20 = 1 000 + assert float(y["cca_claimed"]) == pytest.approx(1_000.0) + assert float(y["closing_ucc"]) == pytest.approx(9_000.0) + + +def test_annexe8_cat50_calcul(): + p = _make_parent(cca=50) + pc = _make_piece(cost=20_000.0, acq="2023-03-01", cca=50) + client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) + client.post("/api/cca-registry/year", json={ + "year": 2023, "cca_class_id": 50, "dispositions": 0, "adjustment_coeff": 0.5, + }) + + r = client.get("/api/annexe8?year=2023") + cls50 = next(c for c in r.json()["classes"] if c["cca_class_id"] == 50) + y = cls50["year_data"] + assert float(y["additions"]) == pytest.approx(20_000.0) + assert float(y["cca_claimed"]) == pytest.approx(5_500.0) # 10 000 × 0.55 + + +# ═══════════════════════════════════════════════════════════ +# Export fiscal +# ═══════════════════════════════════════════════════════════ + +def test_fiscal_export_base_correcte(): + p = _make_parent(cca=50) + for cost in (5_000.0, 1_500.0, 300.0): + pc = _make_piece(cost=cost) + client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) + + r = client.get("/api/fiscal-export") + assert r.status_code == 200 + cls = next(c for c in r.json()["classes"] if c["cca_class_id"] == 50) + assert float(cls["total_base_cad"]) == pytest.approx(6_800.0) + + +def test_fiscal_export_plusieurs_categories(): + for cca in (50, 8, 46): + p = _make_parent(cca=cca) + pc = _make_piece(cost=1_000.0, cca=cca) + client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) + + r = client.get("/api/fiscal-export") + ids = [c["cca_class_id"] for c in r.json()["classes"]] + assert 50 in ids and 8 in ids and 46 in ids + + +def test_fiscal_export_csv(): + r = client.get("/api/fiscal-export.csv") + assert r.status_code == 200 + assert "text/csv" in r.headers["content-type"] + + +def test_annexe8_csv(): + r = client.get("/api/annexe8.csv?year=2024") + assert r.status_code == 200 + assert "text/csv" in r.headers["content-type"] + + +def test_cca_registry_csv(): + r = client.get("/api/cca-registry.csv?cca_class_id=50") + assert r.status_code == 200 + assert "text/csv" in r.headers["content-type"] + + +# ═══════════════════════════════════════════════════════════ +# Rapport fiscal (Schedule 1 / Annexe 1) +# ═══════════════════════════════════════════════════════════ + +def test_rapport_fiscal_structure(): + r = client.get("/api/rapport-fiscal?year=2024") + assert r.status_code == 200 + d = r.json() + assert d["year"] == 2024 + s1 = d["schedule1"] + for key in ("book_deprec_cad", "cca_claimed_cad", "recapture_cad", "terminal_loss_cad"): + assert key in s1 + + +def test_rapport_fiscal_html(): + r = client.get("/api/rapport-fiscal.html?year=2024") + assert r.status_code == 200 + assert "text/html" in r.headers["content-type"] + assert "Chezlepro" in r.text + + +def test_rapport_schedule1_cca_coherent(): + """DPA du Schedule 1 = DPA totale de l'Annexe 8.""" + p = _make_parent(cca=50) + pc = _make_piece(cost=10_000.0, acq="2024-01-01", cca=50) + client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) + client.post("/api/cca-registry/year", json={ + "year": 2024, "cca_class_id": 50, "dispositions": 0, "adjustment_coeff": 0.5, + }) + + rapport = client.get("/api/rapport-fiscal?year=2024").json() + annexe8 = client.get("/api/annexe8?year=2024").json() + + total_dpa_a8 = sum( + float(c["year_data"]["cca_claimed"]) + for c in annexe8["classes"] if c["year_data"] + ) + assert rapport["schedule1"]["cca_claimed_cad"] == pytest.approx(total_dpa_a8) + + +# ═══════════════════════════════════════════════════════════ +# Sauvegardes et restauration +# ═══════════════════════════════════════════════════════════ + +def test_create_and_list_backup(): + _make_piece(name="Avant backup", cost=999.0) + r = client.post("/api/backup") + assert r.status_code == 200 + filename = r.json()["filename"] + assert filename.startswith("immos_") and filename.endswith(".db") + + r2 = client.get("/api/backups") + assert any(b["filename"] == filename for b in r2.json()) + + +def test_restore_backup(): + _make_piece(name="Avant backup", cost=999.0) + filename = client.post("/api/backup").json()["filename"] + + client.post("/api/reset") + assert client.get("/api/assets").json() == [] + + r = client.post("/api/restore", json={"filename": filename}) + assert r.status_code == 200 + restored = client.get("/api/assets").json() + assert any(a["name"] == "Avant backup" for a in restored) + + +def test_restore_invalide(): + r = client.post("/api/restore", json={"filename": "../../etc/passwd"}) + assert r.status_code == 400 + + r2 = client.post("/api/restore", json={"filename": "immos_1234-99-99_99-99-99.db"}) + assert r2.status_code == 404 + + +# ═══════════════════════════════════════════════════════════ +# Reset — vide actifs ET registre +# ═══════════════════════════════════════════════════════════ + +def test_reset_vide_actifs(): + _make_piece() + client.post("/api/reset") + assert client.get("/api/assets").json() == [] + + +def test_reset_vide_registre(): + """Après reset, le registre ne conserve aucune année.""" + client.post("/api/cca-registry/year", json={ + "year": 2024, "cca_class_id": 50, "additions_override": 1_000.0 + }) + client.post("/api/reset") + r = client.get("/api/cca-registry?cca_class_id=50") + assert r.json()["years"] == [] + + +# ═══════════════════════════════════════════════════════════ +# Factures jointes +# ═══════════════════════════════════════════════════════════ + +def test_invoice_upload_et_suppression(): + contenu = b"%PDF-1.4 fichier test" + files = {"file": ("facture.pdf", io.BytesIO(contenu), "application/pdf")} + r = client.post("/api/invoices", files=files) + assert r.status_code == 200 + file_id = r.json()["file_id"] + assert file_id.endswith(".pdf") + assert len(file_id) == 36 # 32 hex + ".pdf" + + r2 = client.get(f"/api/invoices/{file_id}") + assert r2.status_code == 200 + assert r2.content == contenu + + client.delete(f"/api/invoices/{file_id}") + assert client.get(f"/api/invoices/{file_id}").status_code == 404 + + +def test_invoice_upload_image(): + contenu = b"\xff\xd8\xff\xe0" # en-tête JPEG minimal + files = {"file": ("photo.jpg", io.BytesIO(contenu), "image/jpeg")} + r = client.post("/api/invoices", files=files) + assert r.status_code == 200 + assert r.json()["file_id"].endswith(".jpg") + + +def test_invoice_extension_invalide(): + files = {"file": ("script.exe", io.BytesIO(b"MZ"), "application/octet-stream")} + r = client.post("/api/invoices", files=files) + assert r.status_code == 400 + + +def test_invoice_path_traversal(): + # file_id avec des caractères invalides (non-hex) → rejeté par la validation ou le routage + r = client.get("/api/invoices/INVALID_FILE_ID_WITH_UPPER_CASE.pdf") + assert r.status_code in (400, 404, 422) + + +def test_invoice_inexistant(): + r = client.get("/api/invoices/" + "a" * 32 + ".pdf") + assert r.status_code == 404 + + +# ═══════════════════════════════════════════════════════════ +# Données de démonstration — seed-demo +# ═══════════════════════════════════════════════════════════ + +def test_seed_demo_actifs(): + r = client.post("/api/seed-demo") + assert r.status_code == 200 + assets = r.json()["assets"] + parents = [a for a in assets if a["type"] == "parent"] + pieces = [a for a in assets if a["type"] == "piece"] + + assert len(parents) >= 6, "Au moins 6 immobilisations attendues" + assert len(pieces) >= 10, "Au moins 10 pièces attendues" + + # Plusieurs catégories DPA + cat_ids = {a["view_cpa"]["cca_class"]["id"] for a in parents} + assert len(cat_ids) >= 4, f"Au moins 4 catégories attendues, obtenu : {cat_ids}" + + # Pièces au rebut + rebuts = [a for a in pieces if a["view_admin"]["operational_status"] == "Rebut"] + assert len(rebuts) >= 2, "Au moins 2 pièces au rebut (Veeam + Broadcom)" + + # Porteurs d'étiquette + bearers = [a for a in pieces if a["view_admin"].get("is_tag_bearer")] + assert len(bearers) >= 4, "Un porteur par immobilisation active" + + # Pièces libres (stock) + libres = [a for a in pieces if a["parent_id"] is None + and a["view_admin"]["operational_status"] != "Rebut"] + assert len(libres) >= 2, "Des pièces de rechange en stock" + + +def test_seed_demo_registre_cat50(): + client.post("/api/seed-demo") + r = client.get("/api/cca-registry?cca_class_id=50") + assert r.status_code == 200 + assert len(r.json()["years"]) >= 3, "3 exercices pour Cat. 50 (2023-2025)" + + +def test_seed_demo_perte_finale_cat12(): + """Cat. 12 — perte finale en 2025 : licence Veeam non renouvelée.""" + client.post("/api/seed-demo") + r = client.get("/api/annexe8?year=2025") + cls12 = next((c for c in r.json()["classes"] if c["cca_class_id"] == 12), None) + assert cls12 is not None, "Cat. 12 doit apparaître dans l'Annexe 8" + y = cls12["year_data"] + assert y is not None + assert float(y["terminal_loss"]) > 0, "Perte finale attendue en 2025 (Cat. 12)" + + +def test_seed_demo_recuperation_cat10(): + """Cat. 10 — récupération en 2024 : IBM vendu pour plus que la FNACC.""" + client.post("/api/seed-demo") + r = client.get("/api/annexe8?year=2024") + cls10 = next((c for c in r.json()["classes"] if c["cca_class_id"] == 10), None) + assert cls10 is not None, "Cat. 10 doit apparaître dans l'Annexe 8" + y = cls10["year_data"] + assert y is not None + assert float(y["recapture"]) > 0, "Récupération attendue en 2024 (Cat. 10)" + + +def test_seed_demo_bases_coherentes(): + """La base de chaque parent = somme des coûts de ses pièces rattachées.""" + client.post("/api/seed-demo") + assets = client.get("/api/assets").json() + pieces_by_parent: dict[int, list[float]] = {} + for a in assets: + if a["type"] == "piece" and a["parent_id"] is not None: + pieces_by_parent.setdefault(a["parent_id"], []).append( + float(a["view_cpa"]["capitalizable_base_cad"]) + ) + for a in assets: + if a["type"] != "parent" or a["id"] not in pieces_by_parent: + continue + expected = sum(pieces_by_parent[a["id"]]) + actual = float(a["view_cpa"]["capitalizable_base_cad"]) + assert actual == pytest.approx(expected, rel=1e-4), ( + f"Base incohérente pour « {a['name']} » : {actual} ≠ {expected}" + ) + + +def test_seed_double_idempotent(): + """Deux appels seed-demo successifs ne provoquent pas d'erreur.""" + assert client.post("/api/seed-demo").status_code == 200 + assert client.post("/api/seed-demo").status_code == 200