Skip to main content

koprogo_api/domain/copropriete/
acp.rs

1//! `Acp` — Association des Copropriétaires (Art. 3.84-3.89 Code Civil belge).
2//!
3//! Story 1.1 — première brique de la refacto domaine
4//! `Organization(0..1) → ACP(1..N) → Building(1..N)`
5//! (cf. `docs/maury/refonte-ux-multi-role-acp/architecture.md` §2.1, ADR-0010).
6//!
7//! Une ACP est la **personne juridique** propriétaire collective de l'immeuble en
8//! copropriété. Elle est distincte du cabinet syndic (`Organization`) qui la
9//! gère : un cabinet peut gérer plusieurs ACPs, et une ACP peut être
10//! auto-gérée (aucun cabinet).
11//!
12//! # Invariants
13//!
14//! - `name` non vide après trim, longueur ≥ 2 caractères (PRD FR1, FR3, INV-1).
15//! - `slug` kebab-case dérivé du `name` (unicité scope = repository).
16//! - `address_street`, `address_postal_code`, `address_city` non vides.
17//! - `organization_id` est `Option<Uuid>` (NULL = ACP auto-gérée — ADR-0010).
18//! - `legal_status` par défaut `"copropriete_belge"`.
19//!
20//! # Hexagonal
21//!
22//! Aucune dépendance `sqlx` / `actix_web`. Les erreurs de domaine
23//! retournent `AcpError` (enum dédié), mappé vers `AppError::Validation`
24//! côté application (cf. `application/error.rs`, pattern WP-A* #433).
25
26use super::fenetre_ag_ordinaire::FenetreAgOrdinaire;
27use chrono::{DateTime, Utc};
28use rust_decimal::Decimal;
29use rust_decimal_macros::dec;
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32use uuid::Uuid;
33
34/// Dénominateur par défaut de l'acte de base (millièmes belges classiques).
35/// L'acte authentique peut fixer 10000 (dix-millièmes) ou une autre base —
36/// cf. Art. 3.84 CC + ADR-0010. Jamais hard-codé ailleurs : toute logique de
37/// conformité lit `Acp::total_tantiemes`.
38pub const DEFAULT_TOTAL_TANTIEMES: i32 = 1000;
39
40/// Story H13 — Taux minimal légal du fonds de réserve : **5 % des charges
41/// ordinaires de l'exercice N-1** (Art. 3.86 §3 Code civil, loi du 18/06/2018
42/// en vigueur depuis 2019). Obligatoire ; l'AG peut y renoncer à la majorité
43/// des **4/5** (`reserve_fund_waived`). Cf. ADR-0012.
44pub const RESERVE_FUND_RATE: Decimal = dec!(0.05);
45
46/// Métriques agrégées d'une ACP (calculées par le repository via JOIN sur
47/// tous les buildings de l'ACP — Story H6). Pureté : aucun I/O ici.
48///
49/// La conformité (Art. 3.84 CC, ADR-0010) s'évalue au **niveau ACP** :
50/// `Σ units == Σ buildings.total_units` ET `Σ quota == acps.total_tantiemes`.
51/// Volontairement non stockées (avoid stale state) — recalculées à la lecture.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct AcpMetrics {
54    /// Nombre réel de units de tous les blocs de l'ACP (`COUNT(units)`).
55    pub units_count: i32,
56    /// Somme des `buildings.total_units` (lots déclarés) de tous les blocs.
57    pub declared_units_total: i32,
58    /// Somme exacte des quotités générales (`SUM(units.quota::NUMERIC)`).
59    pub quota_sum: Decimal,
60    /// Nombre de buildings (blocs) rattachés à l'ACP.
61    pub buildings_count: i32,
62}
63
64impl AcpMetrics {
65    /// Métriques vides (ACP sans bloc / sans lot).
66    pub fn empty() -> Self {
67        Self {
68            units_count: 0,
69            declared_units_total: 0,
70            quota_sum: Decimal::ZERO,
71            buildings_count: 0,
72        }
73    }
74}
75
76/// Statut juridique d'une ACP. `Copropriete` correspond à
77/// "copropriete_belge" en DB (encodage stable v0.1.0).
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
79#[serde(rename_all = "snake_case")]
80pub enum AcpLegalStatus {
81    /// Copropriété belge ordinaire (Art. 3.84 CC).
82    #[default]
83    CoproprieteBelge,
84}
85
86impl AcpLegalStatus {
87    /// Encodage DB stable.
88    pub fn as_db_str(&self) -> &'static str {
89        match self {
90            Self::CoproprieteBelge => "copropriete_belge",
91        }
92    }
93
94    /// Décodage depuis la chaîne DB. Toute valeur inconnue est mappée
95    /// volontairement vers `CoproprieteBelge` (mode `lenient`) plutôt que
96    /// panic — un `legal_status` exotique en DB n'est pas une raison de
97    /// faire crasher la lecture (audit + corrigé hors-bande).
98    pub fn from_db_str(s: &str) -> Self {
99        match s {
100            "copropriete_belge" => Self::CoproprieteBelge,
101            _ => Self::CoproprieteBelge,
102        }
103    }
104}
105
106/// Erreurs métier produites par le domaine `Acp`.
107///
108/// Mappées vers `AppError::Validation` (HTTP 400/422) côté application via un
109/// `impl From<AcpError> for AppError` (cf. `application/error.rs`).
110#[derive(Error, Debug, Clone, PartialEq, Eq)]
111pub enum AcpError {
112    #[error("ACP name cannot be empty")]
113    NameEmpty,
114    #[error("ACP name must be at least 2 characters long, got {0}")]
115    NameTooShort(usize),
116    #[error("ACP name must be at most 160 characters long, got {0}")]
117    NameTooLong(usize),
118    #[error("ACP address street cannot be empty")]
119    AddressStreetEmpty,
120    #[error("ACP postal code cannot be empty")]
121    PostalCodeEmpty,
122    #[error("ACP city cannot be empty")]
123    CityEmpty,
124    #[error("ACP total_tantiemes (acte de base) must be greater than 0, got {0}")]
125    TotalTantiemesInvalid(i32),
126    #[error("ACP fund balance cannot be negative, got {0}")]
127    NegativeFundBalance(Decimal),
128}
129
130/// Représente une Association des Copropriétaires (ACP) — racine d'agrégat.
131///
132/// Cf. ADR-0010 (`docs/maury/refonte-ux-multi-role-acp/architecture.md` §4).
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
134pub struct Acp {
135    pub id: Uuid,
136    /// Cabinet syndic gestionnaire. `None` = ACP auto-gérée (ADR-0010).
137    pub organization_id: Option<Uuid>,
138    pub name: String,
139    pub slug: String,
140    pub legal_status: AcpLegalStatus,
141    /// Dénominateur de l'acte de base (quotités). Source de vérité de la
142    /// copropriété (Art. 3.84 CC, ADR-0010). 1000/10000/autre. Défaut
143    /// `DEFAULT_TOTAL_TANTIEMES` ; modifiable via `with_total_tantiemes`.
144    pub total_tantiemes: i32,
145    /// Numéro BCE belge (optionnel — toutes les ACPs ne sont pas immatriculées).
146    pub bce_number: Option<String>,
147    pub address_street: String,
148    pub address_postal_code: String,
149    pub address_city: String,
150    /// Story H13 — solde du **fonds de réserve** (compte distinct au nom de
151    /// l'ACP, Art. 3.86 §3). Doit couvrir ≥ 5 % des charges ordinaires N-1
152    /// sauf renonciation 4/5. Decimal exact (ADR-0007).
153    #[serde(default)]
154    /// La période annuelle de quinze jours pendant laquelle se tient l'AG
155    /// ordinaire, fixée par le règlement d'ordre intérieur.
156    ///
157    /// Art. 3.85 § 3, 3°. `None` tant que le ROI n'a pas été encodé : une ACP
158    /// sans fenêtre n'est pas en infraction, elle est incomplète. C'est au
159    /// SuperAdmin de la renseigner en même temps que le reste des statuts.
160    ///
161    /// Elle sert aussi de point d'ancrage au délai de trois semaines des
162    /// propositions (Art. 3.87 § 3) : sans elle, ce délai n'a pas de départ.
163    pub fenetre_ag_ordinaire: Option<FenetreAgOrdinaire>,
164
165    /// Date de réception provisoire des parties communes de l'immeuble.
166    ///
167    /// Art. 3.86 § 3 al. 4 : elle fait courir le délai de **cinq ans** au
168    /// terme duquel le fonds de réserve devient obligatoire. Ce n'est ni la
169    /// date de constitution de l'ACP, ni celle de la première assemblée — une
170    /// confusion facile qui décalerait l'échéance de plusieurs années.
171    ///
172    /// `None` tant qu'elle n'est pas encodée. Et l'inconnu **ne vaut pas
173    /// dispense** : `StatutFondsReserve::ReceptionInconnue` le dit plutôt que
174    /// de répondre « pas encore exigible » à une ACP qui l'est peut-être
175    /// depuis longtemps.
176    pub reception_provisoire_parties_communes: Option<chrono::NaiveDate>,
177
178    /// Date de la première cession ou attribution d'un lot.
179    ///
180    /// Art. 3.86 § 1er, 1° : c'est elle qui fait **naître l'indivision**, donc
181    /// l'association. Sans elle, un immeuble encore entier aux mains du
182    /// promoteur n'a pas d'ACP, quels que soient ses statuts.
183    pub premiere_cession_de_lot: Option<chrono::NaiveDate>,
184
185    /// Date de transcription des statuts au bureau de la Documentation
186    /// patrimoniale.
187    ///
188    /// Art. 3.86 § 1er, 2°. Son absence ne supprime pas l'association : elle
189    /// la prive seulement du droit d'opposer sa personnalité **aux tiers**,
190    /// lesquels gardent la faculté de la lui opposer (§ 2). La protection joue
191    /// dans un seul sens, contre l'association négligente.
192    pub transcription_statuts: Option<chrono::NaiveDate>,
193
194    pub reserve_fund_balance: Decimal,
195    /// Story H13 — solde du **fonds de roulement** (compte distinct, dépenses
196    /// courantes récurrentes — loi 2019).
197    #[serde(default)]
198    pub working_capital_balance: Decimal,
199    /// Story H13 — l'AG a-t-elle renoncé au fonds de réserve obligatoire
200    /// (vote 4/5, Art. 3.86 §3) ? Si `true`, la conformité réserve est levée.
201    #[serde(default)]
202    pub reserve_fund_waived: bool,
203    pub created_at: DateTime<Utc>,
204    pub updated_at: DateTime<Utc>,
205}
206
207impl Acp {
208    /// Constructeur validé.
209    ///
210    /// Invariants vérifiés :
211    /// 1. `name.trim()` ≥ 2 chars, ≤ 160 chars
212    /// 2. `address_street.trim()` non vide
213    /// 3. `address_postal_code.trim()` non vide
214    /// 4. `address_city.trim()` non vide
215    ///
216    /// Le `slug` est dérivé du `name` (kebab-case ASCII).
217    pub fn new(
218        organization_id: Option<Uuid>,
219        name: String,
220        address_street: String,
221        address_postal_code: String,
222        address_city: String,
223        bce_number: Option<String>,
224    ) -> Result<Self, AcpError> {
225        let name = name.trim().to_string();
226        if name.is_empty() {
227            return Err(AcpError::NameEmpty);
228        }
229        let name_len = name.chars().count();
230        if name_len < 2 {
231            return Err(AcpError::NameTooShort(name_len));
232        }
233        if name_len > 160 {
234            return Err(AcpError::NameTooLong(name_len));
235        }
236
237        let address_street = address_street.trim().to_string();
238        if address_street.is_empty() {
239            return Err(AcpError::AddressStreetEmpty);
240        }
241        let address_postal_code = address_postal_code.trim().to_string();
242        if address_postal_code.is_empty() {
243            return Err(AcpError::PostalCodeEmpty);
244        }
245        let address_city = address_city.trim().to_string();
246        if address_city.is_empty() {
247            return Err(AcpError::CityEmpty);
248        }
249
250        let slug = generate_slug(&name);
251        let now = Utc::now();
252
253        Ok(Self {
254            id: Uuid::new_v4(),
255            organization_id,
256            name,
257            slug,
258            legal_status: AcpLegalStatus::default(),
259            total_tantiemes: DEFAULT_TOTAL_TANTIEMES,
260            bce_number,
261            address_street,
262            address_postal_code,
263            address_city,
264            fenetre_ag_ordinaire: None,
265            reception_provisoire_parties_communes: None,
266            premiere_cession_de_lot: None,
267            transcription_statuts: None,
268            reserve_fund_balance: Decimal::ZERO,
269            working_capital_balance: Decimal::ZERO,
270            reserve_fund_waived: false,
271            created_at: now,
272            updated_at: now,
273        })
274    }
275
276    /// Rattache (ou détache, avec `None`) l'ACP à un cabinet syndic.
277    /// Le sens "promote/demote" est interprété par l'application : ici on se
278    /// limite à mettre à jour le champ + `updated_at`.
279    pub fn set_organization(&mut self, organization_id: Option<Uuid>) {
280        self.organization_id = organization_id;
281        self.updated_at = Utc::now();
282    }
283
284    /// Mise à jour de l'identité de l'ACP (avec re-validation des invariants).
285    pub fn update_info(
286        &mut self,
287        name: String,
288        address_street: String,
289        address_postal_code: String,
290        address_city: String,
291        bce_number: Option<String>,
292    ) -> Result<(), AcpError> {
293        let name = name.trim().to_string();
294        if name.is_empty() {
295            return Err(AcpError::NameEmpty);
296        }
297        let name_len = name.chars().count();
298        if name_len < 2 {
299            return Err(AcpError::NameTooShort(name_len));
300        }
301        if name_len > 160 {
302            return Err(AcpError::NameTooLong(name_len));
303        }
304        let address_street = address_street.trim().to_string();
305        if address_street.is_empty() {
306            return Err(AcpError::AddressStreetEmpty);
307        }
308        let address_postal_code = address_postal_code.trim().to_string();
309        if address_postal_code.is_empty() {
310            return Err(AcpError::PostalCodeEmpty);
311        }
312        let address_city = address_city.trim().to_string();
313        if address_city.is_empty() {
314            return Err(AcpError::CityEmpty);
315        }
316
317        self.slug = generate_slug(&name);
318        self.name = name;
319        self.address_street = address_street;
320        self.address_postal_code = address_postal_code;
321        self.address_city = address_city;
322        self.bce_number = bce_number;
323        self.updated_at = Utc::now();
324        Ok(())
325    }
326
327    /// L'ACP est-elle auto-gérée (sans cabinet syndic) ?
328    pub fn is_self_managed(&self) -> bool {
329        self.organization_id.is_none()
330    }
331
332    /// Builder consommant : fixe le dénominateur de l'acte de base.
333    ///
334    /// Garde `new()` stable (les appelants existants conservent le défaut
335    /// `DEFAULT_TOTAL_TANTIEMES`). Valide `value > 0` (Art. 3.84 CC — un acte
336    /// de base sans tantièmes n'a pas de sens). Cf. ADR-0010.
337    pub fn with_total_tantiemes(mut self, value: i32) -> Result<Self, AcpError> {
338        if value <= 0 {
339            return Err(AcpError::TotalTantiemesInvalid(value));
340        }
341        self.total_tantiemes = value;
342        Ok(self)
343    }
344
345    /// Mise à jour du dénominateur de l'acte de base (re-validation).
346    pub fn set_total_tantiemes(&mut self, value: i32) -> Result<(), AcpError> {
347        if value <= 0 {
348            return Err(AcpError::TotalTantiemesInvalid(value));
349        }
350        self.total_tantiemes = value;
351        self.updated_at = Utc::now();
352        Ok(())
353    }
354
355    // ========================================================================
356    // Story H5 (CL1) — Conformité de la copropriété au niveau ACP (Art. 3.84 CC).
357    //
358    // Règle (ADR-0010, mémoires `admin-publishes-conform-buildings` +
359    // `validate-before-compute`) : l'acte de base est porté par l'ACP. La
360    // conformité s'évalue sur l'agrégat de TOUS les blocs :
361    //   `Σ units_count == Σ buildings.total_units` ET
362    //   `Σ units.quota == acps.total_tantiemes`
363    // Decimal strict (ADR-0007) — aucune tolérance d'arrondi.
364    //
365    // Pureté hexagonale : reçoit `AcpMetrics` (calculé par le repository),
366    // aucun I/O.
367    // ========================================================================
368
369    /// L'ACP est-elle conformante, étant données ses métriques agrégées ?
370    ///
371    /// ── Un seul axe, et c'est le bon ───────────────────────────────────────
372    ///
373    /// La conformité ne porte que sur les **quotités**. C'est l'invariant que
374    /// le registre légal enregistre, dans les termes de l'Art. 3.85 § 1er
375    /// al. 2 : « Les quotités sont fixées par l'acte de base ; leur somme est
376    /// le dénominateur. » Un acte de base donne un total de quotités ; il ne
377    /// donne pas un compte de lots qu'on ne pourrait pas vérifier autrement.
378    ///
379    /// ── Ce qui a été retiré, et pourquoi ───────────────────────────────────
380    ///
381    /// La règle exigeait aussi `units_count == declared_units_total`, c'est-à-
382    /// dire l'égalité entre les lots réellement encodés et un `total_units`
383    /// saisi à la main sur l'immeuble. **Rien ne tenait cette déclaration à
384    /// jour** : créer un lot ne la touchait jamais.
385    ///
386    /// D'où le piège, rencontré en recette dès la première session : un syndic
387    /// déclare vingt lots à la création de l'immeuble, en encode trois, et
388    /// **toute sa comptabilité se ferme** — `assert_conformant` alimente le
389    /// garde-fou « valider avant de calculer ». C'est le chemin nominal d'un
390    /// syndic qui encode son acte de base progressivement, seule façon
391    /// réaliste de le faire.
392    ///
393    /// On demandait deux fois le même fait — le nombre de lots — et on ne le
394    /// réconciliait jamais. La réponse n'est pas d'affaiblir la règle mais de
395    /// **supprimer la source redondante** : le nombre de lots se compte, il ne
396    /// se déclare pas.
397    ///
398    /// L'écart de lots reste **rapporté** par `assert_conformant`, parce qu'il
399    /// renseigne. Il ne bloque plus. Voir #770.
400    pub fn is_conformant(&self, metrics: &AcpMetrics) -> bool {
401        metrics.quota_sum == Decimal::from(self.total_tantiemes)
402    }
403
404    /// Écart de quotités vs l'acte de base : `total_tantiemes - quota_sum`.
405    /// Positif si l'ACP manque de quotités (drift), négatif si surplus.
406    pub fn quota_delta(&self, metrics: &AcpMetrics) -> Decimal {
407        Decimal::from(self.total_tantiemes) - metrics.quota_sum
408    }
409
410    /// Retourne `Err(AcpNotConformantError)` typée si l'ACP n'est pas conforme.
411    /// Consommée par les use-cases (validate-before-compute, Story H7) et le
412    /// frontend (banner/toast 422 narratif).
413    /// Renseigne la période statutaire de l'AG ordinaire (Art. 3.85 § 3, 3°).
414    ///
415    /// Séparé du constructeur à dessein : le ROI est un acte sous signature
416    /// privée, encodé après l'acte de base authentique. Une ACP existe
417    /// juridiquement avant que son ROI soit saisi.
418    pub fn fixer_fenetre_ag_ordinaire(&mut self, fenetre: FenetreAgOrdinaire) {
419        self.fenetre_ag_ordinaire = Some(fenetre);
420        self.updated_at = Utc::now();
421    }
422
423    /// L'assemblée ordinaire tombe-t-elle dans la fenêtre statutaire ?
424    ///
425    /// `None` quand le ROI n'a pas été encodé : on ne peut alors ni confirmer
426    /// ni infirmer, et le dire est plus honnête que de répondre « conforme ».
427    pub fn ag_ordinaire_dans_la_fenetre(&self, date: chrono::NaiveDate) -> Option<bool> {
428        self.fenetre_ag_ordinaire.map(|f| f.contient(date))
429    }
430
431    /// Enregistre la réception provisoire des parties communes.
432    ///
433    /// Elle fait courir le délai de cinq ans du fonds de réserve obligatoire
434    /// (Art. 3.86 § 3 al. 4).
435    pub fn enregistrer_reception_provisoire(&mut self, date: chrono::NaiveDate) {
436        self.reception_provisoire_parties_communes = Some(date);
437        self.updated_at = Utc::now();
438    }
439
440    /// L'état de l'obligation de fonds de réserve, à une date donnée.
441    ///
442    /// `charges_ordinaires_n_moins_1` vient de la comptabilité : le domaine ne
443    /// va pas la chercher, il la reçoit. Les charges **extraordinaires** en
444    /// sont exclues — les inclure gonflerait l'obligation d'une ACP qui vient
445    /// de faire de gros travaux, alors que l'article vise le train de vie
446    /// courant.
447    pub fn statut_fonds_de_reserve(
448        &self,
449        aujourdhui: chrono::NaiveDate,
450        charges_ordinaires_n_moins_1: Decimal,
451    ) -> super::fonds_de_reserve::StatutFondsReserve {
452        super::fonds_de_reserve::statut(
453            self.reception_provisoire_parties_communes,
454            aujourdhui,
455            self.reserve_fund_waived,
456            charges_ordinaires_n_moins_1,
457        )
458    }
459
460    /// Enregistre la première cession ou attribution d'un lot, qui fait
461    /// naître l'indivision (Art. 3.86 § 1er, 1°).
462    pub fn enregistrer_premiere_cession(&mut self, date: chrono::NaiveDate) {
463        self.premiere_cession_de_lot = Some(date);
464        self.updated_at = Utc::now();
465    }
466
467    /// Enregistre la transcription des statuts (Art. 3.86 § 1er, 2°).
468    pub fn enregistrer_transcription_statuts(&mut self, date: chrono::NaiveDate) {
469        self.transcription_statuts = Some(date);
470        self.updated_at = Utc::now();
471    }
472
473    /// L'état de la personnalité juridique, dérivé des deux conditions.
474    ///
475    /// Jamais saisi à la main : il se déduit des faits. Une ACP acquiert la
476    /// personnalité parce qu'un lot a été cédé et que les statuts ont été
477    /// transcrits, pas parce qu'un administrateur l'a coché.
478    pub fn personnalite_juridique(&self) -> super::personnalite_juridique::PersonnaliteJuridique {
479        super::personnalite_juridique::personnalite(
480            self.premiere_cession_de_lot,
481            self.transcription_statuts,
482        )
483    }
484
485    /// L'ACP peut-elle engager — signer un contrat, ouvrir un compte à son
486    /// nom, agir en justice ?
487    ///
488    /// Non tant que ses statuts ne sont pas transcrits : elle ne peut pas
489    /// opposer sa personnalité au cocontractant (Art. 3.86 § 2).
490    pub fn peut_engager(&self) -> bool {
491        self.personnalite_juridique().opposable_par_lacp()
492    }
493
494    pub fn assert_conformant(&self, metrics: &AcpMetrics) -> Result<(), AcpNotConformantError> {
495        if !self.is_conformant(metrics) {
496            return Err(AcpNotConformantError {
497                acp_id: self.id,
498                units_delta: metrics.declared_units_total - metrics.units_count,
499                quota_delta: self.quota_delta(metrics),
500                quota_basis: self.total_tantiemes,
501            });
502        }
503        Ok(())
504    }
505
506    // ========================================================================
507    // Story H13 (CL4) — Fonds de réserve & de roulement (Art. 3.86 §3, loi 2019).
508    //
509    // Le fonds de réserve doit représenter ≥ 5 % des charges ordinaires de
510    // l'exercice N-1 (`RESERVE_FUND_RATE`), sauf renonciation 4/5 de l'AG
511    // (`reserve_fund_waived`). Comptes distincts au nom de l'ACP (modélisés par
512    // `reserve_fund_balance` + `working_capital_balance`). Decimal strict
513    // (ADR-0007) — pas de tolérance d'arrondi. Cf. ADR-0012.
514    // ========================================================================
515
516    /// Fixe le solde du fonds de réserve (validé ≥ 0).
517    pub fn set_reserve_fund_balance(&mut self, balance: Decimal) -> Result<(), AcpError> {
518        if balance < Decimal::ZERO {
519            return Err(AcpError::NegativeFundBalance(balance));
520        }
521        self.reserve_fund_balance = balance;
522        self.updated_at = Utc::now();
523        Ok(())
524    }
525
526    /// Fixe le solde du fonds de roulement (validé ≥ 0).
527    pub fn set_working_capital_balance(&mut self, balance: Decimal) -> Result<(), AcpError> {
528        if balance < Decimal::ZERO {
529            return Err(AcpError::NegativeFundBalance(balance));
530        }
531        self.working_capital_balance = balance;
532        self.updated_at = Utc::now();
533        Ok(())
534    }
535
536    /// Enregistre la décision d'AG de renoncer (ou non) au fonds de réserve
537    /// obligatoire (vote 4/5, Art. 3.86 §3). Le vote lui-même (quorum/majorité)
538    /// relève de la gouvernance (CL3) ; ici on consigne l'issue.
539    pub fn set_reserve_fund_waived(&mut self, waived: bool) {
540        self.reserve_fund_waived = waived;
541        self.updated_at = Utc::now();
542    }
543
544    /// Montant minimal légal du fonds de réserve = 5 % des charges ordinaires
545    /// N-1 (`RESERVE_FUND_RATE`). Exact (Decimal).
546    pub fn required_reserve_fund(&self, ordinary_charges_n1: Decimal) -> Decimal {
547        ordinary_charges_n1 * RESERVE_FUND_RATE
548    }
549
550    /// La réserve est-elle conforme ? Vrai si renoncée (4/5) OU si le solde
551    /// couvre le minimum légal (≥ 5 % charges N-1). Borne inclusive : exactement
552    /// 5 % passe.
553    pub fn is_reserve_fund_compliant(&self, ordinary_charges_n1: Decimal) -> bool {
554        self.reserve_fund_waived
555            || self.reserve_fund_balance >= self.required_reserve_fund(ordinary_charges_n1)
556    }
557
558    /// Retourne `Err(ReserveFundInsufficientError)` typée si la réserve est
559    /// sous le seuil légal et non renoncée. Consommée par les gates de
560    /// conformité (CL4) et le frontend (`<ReserveFundIndicator>`, différé #634).
561    pub fn assert_reserve_fund_compliant(
562        &self,
563        ordinary_charges_n1: Decimal,
564    ) -> Result<(), ReserveFundInsufficientError> {
565        if self.is_reserve_fund_compliant(ordinary_charges_n1) {
566            return Ok(());
567        }
568        Err(ReserveFundInsufficientError {
569            acp_id: self.id,
570            required: self.required_reserve_fund(ordinary_charges_n1),
571            actual: self.reserve_fund_balance,
572            ordinary_charges_n1,
573        })
574    }
575}
576
577/// Story H5 — Erreur typée de non-conformité d'une ACP (Art. 3.84 CC, INV-L3).
578///
579/// Exposée par `Acp::assert_conformant()`. Mappée vers
580/// `AppError::AcpNotConformant` (HTTP 422 + payload `ACP_NOT_CONFORMANT`) par
581/// `From<>` dans `application/error.rs`. Même convention que
582/// `BuildingNotConformantError` (Story H1) : `quota_delta = total_tantiemes -
583/// quota_sum`, `quota_basis` = acte de base (1000/10000/autre).
584#[derive(Debug, Clone, PartialEq, Eq)]
585pub struct AcpNotConformantError {
586    pub acp_id: Uuid,
587    pub units_delta: i32,
588    pub quota_delta: Decimal,
589    pub quota_basis: i32,
590}
591
592impl std::fmt::Display for AcpNotConformantError {
593    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
594        write!(
595            f,
596            "ACP {} not conformant: {} units missing, quota delta {} / {} (acte de base)",
597            self.acp_id, self.units_delta, self.quota_delta, self.quota_basis
598        )
599    }
600}
601
602impl std::error::Error for AcpNotConformantError {}
603
604/// Story H13 — Erreur typée : fonds de réserve sous le seuil légal des 5 %
605/// (Art. 3.86 §3, loi 2019) et non renoncé par l'AG (4/5). Mappée vers
606/// `AppError::ReserveFundInsufficient` (HTTP 422 + `RESERVE_FUND_INSUFFICIENT`)
607/// par `From<>` dans `application/error.rs`. Cf. ADR-0012.
608#[derive(Debug, Clone, PartialEq, Eq)]
609pub struct ReserveFundInsufficientError {
610    pub acp_id: Uuid,
611    /// Minimum légal = 5 % des charges ordinaires N-1.
612    pub required: Decimal,
613    /// Solde actuel du fonds de réserve.
614    pub actual: Decimal,
615    /// Base de calcul : charges ordinaires de l'exercice N-1.
616    pub ordinary_charges_n1: Decimal,
617}
618
619impl std::fmt::Display for ReserveFundInsufficientError {
620    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621        write!(
622            f,
623            "ACP {} reserve fund insufficient: {} < required {} (5% of {} ordinary charges N-1)",
624            self.acp_id, self.actual, self.required, self.ordinary_charges_n1
625        )
626    }
627}
628
629impl std::error::Error for ReserveFundInsufficientError {}
630
631/// Génération du slug kebab-case (déaccentué, alphanum + tirets).
632///
633/// Exemple : `"Résidence Les Tilleuls"` → `"residence-les-tilleuls"`.
634fn generate_slug(name: &str) -> String {
635    name.chars()
636        .map(|c| match c {
637            'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'à' | 'á' | 'â' | 'ã' | 'ä' => 'a',
638            'È' | 'É' | 'Ê' | 'Ë' | 'è' | 'é' | 'ê' | 'ë' => 'e',
639            'Ì' | 'Í' | 'Î' | 'Ï' | 'ì' | 'í' | 'î' | 'ï' => 'i',
640            'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'ò' | 'ó' | 'ô' | 'õ' | 'ö' => 'o',
641            'Ù' | 'Ú' | 'Û' | 'Ü' | 'ù' | 'ú' | 'û' | 'ü' => 'u',
642            'Ç' | 'ç' => 'c',
643            'Ñ' | 'ñ' => 'n',
644            _ if c.is_alphanumeric() => c.to_ascii_lowercase(),
645            _ if c.is_whitespace() || c == '-' => '-',
646            _ => '-',
647        })
648        .collect::<String>()
649        .split('-')
650        .filter(|s| !s.is_empty())
651        .collect::<Vec<_>>()
652        .join("-")
653}
654
655// ============================================================================
656// Tests — taxonomie 4 catégories (CRITICAL.md règle #3, #427).
657// ============================================================================
658
659#[cfg(test)]
660mod tests_art_3_85_fenetre_statutaire {
661    use super::*;
662    use crate::domain::copropriete::fenetre_ag_ordinaire::FenetreAgOrdinaire;
663
664    fn acp() -> Acp {
665        Acp::new(
666            Some(Uuid::new_v4()),
667            "ACP Résidence du Parc".to_string(),
668            "12 Rue de la Loi".to_string(),
669            "1000".to_string(),
670            "Bruxelles".to_string(),
671            None,
672        )
673        .expect("ACP valide")
674    }
675
676    fn le(annee: i32, mois: u32, jour: u32) -> chrono::NaiveDate {
677        chrono::NaiveDate::from_ymd_opt(annee, mois, jour).expect("date valide")
678    }
679
680    /// Art. 3.85 § 3, 3° : le ROI fixe une période annuelle de quinze jours.
681    #[test]
682    fn happy_une_ag_dans_la_fenetre_est_conforme() {
683        let mut acp = acp();
684        acp.fixer_fenetre_ag_ordinaire(FenetreAgOrdinaire::new(6, 1).unwrap());
685
686        assert_eq!(acp.ag_ordinaire_dans_la_fenetre(le(2026, 6, 8)), Some(true));
687    }
688
689    #[test]
690    fn negative_une_ag_hors_fenetre_est_signalee() {
691        let mut acp = acp();
692        acp.fixer_fenetre_ag_ordinaire(FenetreAgOrdinaire::new(6, 1).unwrap());
693
694        assert_eq!(
695            acp.ag_ordinaire_dans_la_fenetre(le(2026, 9, 8)),
696            Some(false)
697        );
698    }
699
700    /// Sans ROI encodé, on ne peut ni confirmer ni infirmer.
701    ///
702    /// Répondre « conforme » serait un mensonge par défaut, et « non conforme »
703    /// accuserait une ACP qui n'a rien fait de mal : elle est incomplète, pas
704    /// en infraction. Le troisième état est le seul honnête.
705    #[test]
706    fn edge_sans_roi_encode_la_question_reste_ouverte() {
707        assert_eq!(acp().ag_ordinaire_dans_la_fenetre(le(2026, 6, 8)), None);
708    }
709
710    /// Le ROI est un acte sous signature privée, encodé après l'acte de base
711    /// authentique : une ACP existe juridiquement avant que sa fenêtre soit
712    /// connue.
713    #[test]
714    fn happy_une_acp_neuve_na_pas_encore_de_fenetre() {
715        assert!(acp().fenetre_ag_ordinaire.is_none());
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    // ----- @happy --------------------------------------------------------------
724
725    #[test]
726    fn happy_new_acp_with_organization_succeeds() {
727        let org_id = Uuid::new_v4();
728        let acp = Acp::new(
729            Some(org_id),
730            "Residence Les Tilleuls".to_string(),
731            "Rue de la Paix 12".to_string(),
732            "1000".to_string(),
733            "Bruxelles".to_string(),
734            None,
735        )
736        .expect("constructor must accept valid inputs");
737
738        assert_eq!(acp.organization_id, Some(org_id));
739        assert_eq!(acp.name, "Residence Les Tilleuls");
740        assert_eq!(acp.slug, "residence-les-tilleuls");
741        assert_eq!(acp.legal_status, AcpLegalStatus::CoproprieteBelge);
742        assert_eq!(acp.address_city, "Bruxelles");
743        assert!(!acp.is_self_managed());
744    }
745
746    #[test]
747    fn happy_new_acp_without_organization_is_self_managed() {
748        let acp = Acp::new(
749            None,
750            "Copro Autogeree".to_string(),
751            "Rue X 1".to_string(),
752            "1000".to_string(),
753            "Bruxelles".to_string(),
754            None,
755        )
756        .unwrap();
757
758        assert!(acp.is_self_managed());
759        assert_eq!(acp.organization_id, None);
760    }
761
762    #[test]
763    fn happy_set_organization_attaches_and_detaches() {
764        let mut acp = Acp::new(
765            None,
766            "Test".to_string(),
767            "Rue X".to_string(),
768            "1000".to_string(),
769            "Bruxelles".to_string(),
770            None,
771        )
772        .unwrap();
773        let original_updated = acp.updated_at;
774
775        let org_id = Uuid::new_v4();
776        acp.set_organization(Some(org_id));
777        assert_eq!(acp.organization_id, Some(org_id));
778        assert!(acp.updated_at >= original_updated);
779
780        acp.set_organization(None);
781        assert_eq!(acp.organization_id, None);
782        assert!(acp.is_self_managed());
783    }
784
785    #[test]
786    fn happy_update_info_regenerates_slug() {
787        let mut acp = Acp::new(
788            None,
789            "Old Name".to_string(),
790            "Rue X".to_string(),
791            "1000".to_string(),
792            "Bruxelles".to_string(),
793            None,
794        )
795        .unwrap();
796        assert_eq!(acp.slug, "old-name");
797
798        acp.update_info(
799            "New Name".to_string(),
800            "Rue X".to_string(),
801            "1000".to_string(),
802            "Bruxelles".to_string(),
803            None,
804        )
805        .unwrap();
806        assert_eq!(acp.name, "New Name");
807        assert_eq!(acp.slug, "new-name");
808    }
809
810    // ----- total_tantiemes (acte de base, ADR-0010) — 4-cat ------------------
811
812    fn sample_acp() -> Acp {
813        Acp::new(
814            None,
815            "Acte Base Test".to_string(),
816            "Rue X 1".to_string(),
817            "1000".to_string(),
818            "Bruxelles".to_string(),
819            None,
820        )
821        .unwrap()
822    }
823
824    #[test]
825    fn happy_total_tantiemes_defaults_to_1000() {
826        assert_eq!(sample_acp().total_tantiemes, DEFAULT_TOTAL_TANTIEMES);
827        assert_eq!(sample_acp().total_tantiemes, 1000);
828    }
829
830    #[test]
831    fn happy_with_total_tantiemes_10000_acte_dix_millemes() {
832        let acp = sample_acp().with_total_tantiemes(10000).unwrap();
833        assert_eq!(acp.total_tantiemes, 10000);
834    }
835
836    #[test]
837    fn edge_with_total_tantiemes_1_accepted() {
838        let acp = sample_acp().with_total_tantiemes(1).unwrap();
839        assert_eq!(acp.total_tantiemes, 1);
840    }
841
842    #[test]
843    fn edge_set_total_tantiemes_updates_timestamp() {
844        let mut acp = sample_acp();
845        let before = acp.updated_at;
846        acp.set_total_tantiemes(10000).unwrap();
847        assert_eq!(acp.total_tantiemes, 10000);
848        assert!(acp.updated_at >= before);
849    }
850
851    #[test]
852    fn security_total_tantiemes_must_be_explicit_to_change() {
853        // Le défaut ne peut PAS être 0 ou négatif silencieusement : seul
854        // `with_total_tantiemes`/`set_total_tantiemes` (validés) le modifient.
855        assert!(sample_acp().total_tantiemes > 0);
856    }
857
858    #[test]
859    fn negative_with_total_tantiemes_zero_rejected() {
860        let err = sample_acp().with_total_tantiemes(0).unwrap_err();
861        assert_eq!(err, AcpError::TotalTantiemesInvalid(0));
862    }
863
864    #[test]
865    fn negative_with_total_tantiemes_negative_rejected() {
866        let err = sample_acp().with_total_tantiemes(-5).unwrap_err();
867        assert_eq!(err, AcpError::TotalTantiemesInvalid(-5));
868    }
869
870    #[test]
871    fn negative_set_total_tantiemes_zero_rejected_and_unchanged() {
872        let mut acp = sample_acp().with_total_tantiemes(10000).unwrap();
873        let err = acp.set_total_tantiemes(0).unwrap_err();
874        assert_eq!(err, AcpError::TotalTantiemesInvalid(0));
875        assert_eq!(acp.total_tantiemes, 10000); // inchangé
876    }
877
878    // ----- assert_conformant (Story H5, ADR-0010) — 4-cat --------------------
879
880    fn metrics(units: i32, declared: i32, quota: Decimal, blocs: i32) -> AcpMetrics {
881        AcpMetrics {
882            units_count: units,
883            declared_units_total: declared,
884            quota_sum: quota,
885            buildings_count: blocs,
886        }
887    }
888
889    #[test]
890    fn happy_acp_conformant_base_1000_mono_bloc() {
891        let acp = sample_acp(); // total_tantiemes = 1000
892        let m = metrics(10, 10, Decimal::from(1000), 1);
893        assert!(acp.is_conformant(&m));
894        assert!(acp.assert_conformant(&m).is_ok());
895    }
896
897    #[test]
898    fn happy_acp_conformant_base_10000_multi_blocs() {
899        let acp = sample_acp().with_total_tantiemes(10000).unwrap();
900        // 3 blocs, 182 lots au total, Σ quotités = 10000.
901        let m = metrics(182, 182, Decimal::from(10000), 3);
902        assert!(acp.assert_conformant(&m).is_ok());
903    }
904
905    #[test]
906    fn edge_acp_quota_drift_one_tenth_base_10000() {
907        let acp = sample_acp().with_total_tantiemes(10000).unwrap();
908        let m = metrics(182, 182, Decimal::from(9999) + Decimal::new(9, 1), 3); // 9999.9
909        let err = acp.assert_conformant(&m).unwrap_err();
910        assert_eq!(err.acp_id, acp.id);
911        assert_eq!(err.quota_delta, Decimal::new(1, 1)); // 0.1
912        assert_eq!(err.quota_basis, 10000);
913        assert_eq!(err.units_delta, 0);
914    }
915
916    /// Un écart de LOTS ne rend plus l'ACP non conforme (#770).
917    ///
918    /// Ce test disait l'inverse jusqu'au 2026-09-06 : neuf lots réels contre
919    /// dix déclarés, quotités justes, et l'ACP était refusée.
920    ///
921    /// C'est ce refus qui fermait la comptabilité d'un syndic encodant son acte
922    /// de base progressivement — le seul chemin réaliste. La déclaration
923    /// `total_units` n'était mise à jour par rien, si bien qu'on comparait un
924    /// compte vivant à un chiffre mort.
925    ///
926    /// La conformité ne porte désormais que sur les quotités, seul axe que
927    /// l'acte de base fixe (Art. 3.85 § 1er al. 2).
928    #[test]
929    fn edge_acp_units_drift_avec_quotites_justes_est_conforme() {
930        let acp = sample_acp(); // 1000
931                                // 9 lots réels mais 10 déclarés ; quotités OK à 1000.
932        let m = metrics(9, 10, Decimal::from(1000), 1);
933        assert!(
934            acp.assert_conformant(&m).is_ok(),
935            "un écart de lots ne doit plus fermer la comptabilité : \
936             c'est le chemin nominal d'un encodage progressif (#770)"
937        );
938    }
939
940    /// L'écart de quotités, lui, reste bloquant — et c'est voulu.
941    ///
942    /// Répartir des charges sur une base fausse produit des appels de fonds
943    /// faux. Refuser de calculer est ici la bonne réponse, et c'est un des
944    /// points forts du produit.
945    #[test]
946    fn negative_ecart_de_quotites_reste_bloquant() {
947        let acp = sample_acp(); // 1000
948                                // Lots justes, quotités courtes de 400.
949        let m = metrics(10, 10, Decimal::from(600), 1);
950        let err = acp.assert_conformant(&m).unwrap_err();
951        assert_eq!(err.quota_delta, Decimal::from(400));
952        assert_eq!(err.quota_basis, 1000);
953    }
954
955    #[test]
956    fn security_acp_metrics_tampering_detected() {
957        // Métriques forgées « conformes-mais-fausses » : le domaine reflète
958        // fidèlement les metrics reçues (la source de vérité = la query SQL,
959        // testée séparément). Ici un quota_sum tronqué est bien détecté.
960        let acp = sample_acp().with_total_tantiemes(10000).unwrap();
961        let m = metrics(182, 182, Decimal::from(5000), 3); // moitié manquante
962        let err = acp.assert_conformant(&m).unwrap_err();
963        assert_eq!(err.quota_delta, Decimal::from(5000));
964        assert_eq!(err.quota_basis, 10000);
965    }
966
967    #[test]
968    fn negative_acp_empty_metrics_is_not_conformant() {
969        let acp = sample_acp(); // 1000
970        let m = AcpMetrics::empty();
971        let err = acp.assert_conformant(&m).unwrap_err();
972        assert_eq!(err.quota_delta, Decimal::from(1000));
973        assert_eq!(err.quota_basis, 1000);
974        assert_eq!(err.units_delta, 0);
975    }
976
977    #[test]
978    fn negative_acp_not_conformant_error_display_is_narrative() {
979        let acp = sample_acp().with_total_tantiemes(10000).unwrap();
980        let err = acp
981            .assert_conformant(&metrics(181, 182, Decimal::from(9975), 3))
982            .unwrap_err();
983        let s = format!("{}", err);
984        assert!(s.contains("not conformant"));
985        assert!(s.contains("10000"));
986    }
987
988    // ----- Fonds de réserve (Story H13, Art. 3.86 §3, loi 2019) — 4-cat ------
989
990    #[test]
991    fn happy_reserve_fund_meets_5pct_threshold() {
992        // Charges ordinaires N-1 = 100 000 € → réserve requise = 5 000 €.
993        let charges = Decimal::from(100_000);
994        let mut acp = sample_acp();
995        acp.set_reserve_fund_balance(Decimal::from(5000)).unwrap();
996        assert_eq!(acp.required_reserve_fund(charges), Decimal::from(5000));
997        assert!(acp.is_reserve_fund_compliant(charges));
998        assert!(acp.assert_reserve_fund_compliant(charges).is_ok());
999        // Un solde supérieur reste conforme.
1000        acp.set_reserve_fund_balance(Decimal::from(8000)).unwrap();
1001        assert!(acp.assert_reserve_fund_compliant(charges).is_ok());
1002    }
1003
1004    #[test]
1005    fn edge_reserve_fund_exactly_5pct_ok_below_ko_waived_ok() {
1006        let charges = Decimal::from(100_000); // requis = 5000
1007        let mut acp = sample_acp();
1008        // Exactement 5 % → OK (borne inclusive).
1009        acp.set_reserve_fund_balance(Decimal::from(5000)).unwrap();
1010        assert!(acp.is_reserve_fund_compliant(charges));
1011        // 4 990 (< 5 %) → KO.
1012        acp.set_reserve_fund_balance(Decimal::from(4990)).unwrap();
1013        assert!(!acp.is_reserve_fund_compliant(charges));
1014        // Renonciation 4/5 → conforme même sous le seuil.
1015        acp.set_reserve_fund_waived(true);
1016        assert!(acp.is_reserve_fund_compliant(charges));
1017        assert!(acp.assert_reserve_fund_compliant(charges).is_ok());
1018    }
1019
1020    #[test]
1021    fn security_reserve_fund_threshold_not_bypassable() {
1022        // Sans renonciation, un solde sous le seuil ne peut être déclaré
1023        // conforme (pas de contournement silencieux).
1024        let charges = Decimal::from(200_000); // requis = 10000
1025        let mut acp = sample_acp();
1026        acp.set_reserve_fund_balance(Decimal::from(9999)).unwrap();
1027        assert!(!acp.reserve_fund_waived);
1028        assert!(!acp.is_reserve_fund_compliant(charges));
1029        let err = acp.assert_reserve_fund_compliant(charges).unwrap_err();
1030        assert_eq!(err.required, Decimal::from(10000));
1031        assert_eq!(err.actual, Decimal::from(9999));
1032    }
1033
1034    #[test]
1035    fn negative_reserve_fund_insufficient_typed_and_negative_balance_rejected() {
1036        let charges = Decimal::from(100_000);
1037        let acp = sample_acp(); // réserve = 0
1038        let err = acp.assert_reserve_fund_compliant(charges).unwrap_err();
1039        assert_eq!(err.acp_id, acp.id);
1040        assert_eq!(err.required, Decimal::from(5000));
1041        assert_eq!(err.actual, Decimal::ZERO);
1042        assert_eq!(err.ordinary_charges_n1, charges);
1043        assert!(format!("{}", err).contains("reserve fund insufficient"));
1044        // Solde négatif rejeté (erreur typée, état inchangé).
1045        let mut acp2 = sample_acp();
1046        let e2 = acp2
1047            .set_reserve_fund_balance(Decimal::from(-1))
1048            .unwrap_err();
1049        assert_eq!(e2, AcpError::NegativeFundBalance(Decimal::from(-1)));
1050        assert_eq!(acp2.reserve_fund_balance, Decimal::ZERO);
1051    }
1052
1053    // ----- @edge ---------------------------------------------------------------
1054
1055    #[test]
1056    fn edge_minimum_name_length_2_accepted() {
1057        let acp = Acp::new(
1058            None,
1059            "Ab".to_string(),
1060            "Rue X 1".to_string(),
1061            "1000".to_string(),
1062            "Bruxelles".to_string(),
1063            None,
1064        );
1065        assert!(acp.is_ok());
1066    }
1067
1068    #[test]
1069    fn edge_name_is_trimmed_before_validation() {
1070        let acp = Acp::new(
1071            None,
1072            "   Trimmed Acp   ".to_string(),
1073            "Rue X 1".to_string(),
1074            "1000".to_string(),
1075            "Bruxelles".to_string(),
1076            None,
1077        )
1078        .unwrap();
1079        assert_eq!(acp.name, "Trimmed Acp");
1080        assert_eq!(acp.slug, "trimmed-acp");
1081    }
1082
1083    #[test]
1084    fn edge_address_fields_are_trimmed() {
1085        let acp = Acp::new(
1086            None,
1087            "Some Name".to_string(),
1088            "  Rue X 1  ".to_string(),
1089            "  1000  ".to_string(),
1090            "  Bruxelles  ".to_string(),
1091            None,
1092        )
1093        .unwrap();
1094        assert_eq!(acp.address_street, "Rue X 1");
1095        assert_eq!(acp.address_postal_code, "1000");
1096        assert_eq!(acp.address_city, "Bruxelles");
1097    }
1098
1099    #[test]
1100    fn edge_legal_status_default_is_copropriete_belge() {
1101        let acp = Acp::new(
1102            None,
1103            "Some Name".to_string(),
1104            "Rue X 1".to_string(),
1105            "1000".to_string(),
1106            "Bruxelles".to_string(),
1107            None,
1108        )
1109        .unwrap();
1110        assert_eq!(acp.legal_status.as_db_str(), "copropriete_belge");
1111    }
1112
1113    #[test]
1114    fn edge_unknown_legal_status_db_string_decodes_to_default() {
1115        assert_eq!(
1116            AcpLegalStatus::from_db_str("totally_unknown_value"),
1117            AcpLegalStatus::CoproprieteBelge
1118        );
1119    }
1120
1121    // ----- @security ----------------------------------------------------------
1122
1123    // L'agrégat lui-même ne porte pas la logique RBAC (qui vit dans les use-cases
1124    // — `acp_use_cases.rs`). Mais on s'assure que les invariants empêchent au
1125    // moins l'invariant structurel : `organization_id` est explicitement
1126    // optionnel et NE peut PAS être inféré silencieusement.
1127
1128    #[test]
1129    fn security_organization_id_is_required_to_be_explicit() {
1130        // Compile-time guarantee : la signature impose `Option<Uuid>`,
1131        // pas de fallback "current org" implicite.
1132        #[allow(clippy::type_complexity)]
1133        let _: fn(
1134            Option<Uuid>,
1135            String,
1136            String,
1137            String,
1138            String,
1139            Option<String>,
1140        ) -> Result<Acp, AcpError> = Acp::new;
1141    }
1142
1143    // ----- @negative ----------------------------------------------------------
1144
1145    #[test]
1146    fn negative_empty_name_is_rejected() {
1147        let err = Acp::new(
1148            None,
1149            "".to_string(),
1150            "Rue X 1".to_string(),
1151            "1000".to_string(),
1152            "Bruxelles".to_string(),
1153            None,
1154        )
1155        .unwrap_err();
1156        assert_eq!(err, AcpError::NameEmpty);
1157    }
1158
1159    #[test]
1160    fn negative_whitespace_only_name_is_rejected_as_empty() {
1161        let err = Acp::new(
1162            None,
1163            "    ".to_string(),
1164            "Rue X 1".to_string(),
1165            "1000".to_string(),
1166            "Bruxelles".to_string(),
1167            None,
1168        )
1169        .unwrap_err();
1170        assert_eq!(err, AcpError::NameEmpty);
1171    }
1172
1173    #[test]
1174    fn negative_single_char_name_is_too_short() {
1175        let err = Acp::new(
1176            None,
1177            "A".to_string(),
1178            "Rue X 1".to_string(),
1179            "1000".to_string(),
1180            "Bruxelles".to_string(),
1181            None,
1182        )
1183        .unwrap_err();
1184        assert_eq!(err, AcpError::NameTooShort(1));
1185    }
1186
1187    #[test]
1188    fn negative_name_too_long_is_rejected() {
1189        let long_name = "A".repeat(161);
1190        let err = Acp::new(
1191            None,
1192            long_name,
1193            "Rue X 1".to_string(),
1194            "1000".to_string(),
1195            "Bruxelles".to_string(),
1196            None,
1197        )
1198        .unwrap_err();
1199        assert_eq!(err, AcpError::NameTooLong(161));
1200    }
1201
1202    #[test]
1203    fn negative_empty_street_is_rejected() {
1204        let err = Acp::new(
1205            None,
1206            "Some Name".to_string(),
1207            "".to_string(),
1208            "1000".to_string(),
1209            "Bruxelles".to_string(),
1210            None,
1211        )
1212        .unwrap_err();
1213        assert_eq!(err, AcpError::AddressStreetEmpty);
1214    }
1215
1216    #[test]
1217    fn negative_empty_postal_code_is_rejected() {
1218        let err = Acp::new(
1219            None,
1220            "Some Name".to_string(),
1221            "Rue X 1".to_string(),
1222            "".to_string(),
1223            "Bruxelles".to_string(),
1224            None,
1225        )
1226        .unwrap_err();
1227        assert_eq!(err, AcpError::PostalCodeEmpty);
1228    }
1229
1230    #[test]
1231    fn negative_empty_city_is_rejected() {
1232        let err = Acp::new(
1233            None,
1234            "Some Name".to_string(),
1235            "Rue X 1".to_string(),
1236            "1000".to_string(),
1237            "".to_string(),
1238            None,
1239        )
1240        .unwrap_err();
1241        assert_eq!(err, AcpError::CityEmpty);
1242    }
1243
1244    #[test]
1245    fn negative_update_info_re_validates_invariants() {
1246        let mut acp = Acp::new(
1247            None,
1248            "Valid".to_string(),
1249            "Rue X 1".to_string(),
1250            "1000".to_string(),
1251            "Bruxelles".to_string(),
1252            None,
1253        )
1254        .unwrap();
1255        let err = acp
1256            .update_info(
1257                "".to_string(),
1258                "Rue X 1".to_string(),
1259                "1000".to_string(),
1260                "Bruxelles".to_string(),
1261                None,
1262            )
1263            .unwrap_err();
1264        assert_eq!(err, AcpError::NameEmpty);
1265        // Name unchanged because update failed.
1266        assert_eq!(acp.name, "Valid");
1267    }
1268}