Skip to main content

koprogo_api/domain/comptabilite/
fund.rs

1//! Domain Entity: Fund (fonds de l'ACP — roulement / réserve / affecté)
2//!
3//! Prolonge le modèle binaire roulement/réserve (ADR-0012, Story H13a,
4//! colonnes `acps.reserve_fund_balance` / `working_capital_balance`) par une
5//! troisième nature : le fonds **affecté** (thésaurisation pluriannuelle
6//! vers un chantier nommé). Voir ADR-0054, issue #635.
7//!
8//! Les trois natures restent distinctes et sur des comptes (soldes)
9//! séparés — chaque `Fund` porte le sien :
10//! - `WorkingCapital` (roulement) — dépenses périodiques.
11//! - `Reserve` (réserve légale, Art. 3.86 §3 CC) — reste gouverné par
12//!   `Acp::assert_reserve_fund_compliant` (ADR-0012) ; ce module ne
13//!   réimplémente PAS cette obligation, il évite seulement de la confondre
14//!   avec un fonds affecté facultatif.
15//! - `Earmarked` (fonds affecté) — épargne vers un objet précis
16//!   (`purpose`), votée à la majorité des 2/3 (Art. 3.88, gros travaux).
17//!
18//! **L'affectation n'est pas un verrou immuable** (décision PO @gilmry,
19//! reprise ADR-0054) : une AG peut réaffecter un fonds affecté vers une
20//! autre fin (`Fund::reassign`), à condition qu'une résolution ADOPTÉE le
21//! décide. L'audit trail (`FundReassignment`) trace l'affectation d'origine.
22
23use chrono::{DateTime, Utc};
24use rust_decimal::Decimal;
25use serde::{Deserialize, Serialize};
26use uuid::Uuid;
27
28use crate::domain::copropriete::resolution::{MajorityType, ResolutionStatus};
29
30/// Nature d'un fonds — Art. 3.86 §3 CC (roulement/réserve) + thésaurisation
31/// facultative (fonds affecté, pratique courante non prévue par la loi).
32#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
33#[serde(rename_all = "snake_case")]
34pub enum FundKind {
35    /// Fonds de roulement — dépenses périodiques. Compte distinct.
36    WorkingCapital,
37    /// Fonds de réserve légal (Art. 3.86 §3, ADR-0012) — reste gouverné par
38    /// `Acp::assert_reserve_fund_compliant`. Compte distinct.
39    Reserve,
40    /// Fonds affecté (thésaurisation) — épargne vers un chantier nommé
41    /// (`purpose`), voté aux 2/3 (Art. 3.88).
42    Earmarked,
43}
44
45/// Erreur typée du domaine `Fund` — jamais de `Result<_, String>`
46/// (CRITICAL.md #4).
47#[derive(Debug, Clone, PartialEq)]
48pub enum FundError {
49    /// Nom de fonds vide.
50    EmptyName,
51    /// Un fonds affecté doit porter un objet précis.
52    EarmarkedRequiresPurpose,
53    /// Un fonds affecté doit porter un objectif d'épargne strictement positif.
54    EarmarkedRequiresPositiveTarget,
55    /// Roulement/réserve ne portent pas d'objet (réservé aux fonds affectés).
56    NonEarmarkedCannotHavePurpose,
57    /// Roulement/réserve ne portent pas d'objectif d'épargne.
58    NonEarmarkedCannotHaveTarget,
59    /// Versement au fonds non strictement positif.
60    NonPositiveContribution,
61    /// Dépense imputée à un fonds affecté pour un autre objet que le sien.
62    ExpensePurposeMismatch {
63        fund_purpose: String,
64        expense_work_ref: String,
65    },
66    /// La majorité obtenue pour créer un fonds affecté est sous le seuil des
67    /// gros travaux (Art. 3.88, 2/3).
68    InsufficientMajorityForCreation {
69        required: MajorityType,
70        used: MajorityType,
71    },
72    /// Seul un fonds affecté peut être réaffecté.
73    OnlyEarmarkedFundsCanBeReassigned,
74    /// La réaffectation exige une décision d'AG adoptée.
75    ReassignmentRequiresAdoptedResolution,
76    /// Montant réaffecté non strictement positif.
77    NonPositiveReassignmentAmount,
78    /// Montant réaffecté supérieur au solde disponible.
79    ReassignmentAmountExceedsBalance,
80}
81
82impl std::fmt::Display for FundError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            Self::EmptyName => write!(f, "Le nom du fonds ne peut pas être vide"),
86            Self::EarmarkedRequiresPurpose => write!(
87                f,
88                "Un fonds affecté doit porter un objet précis (travail ciblé)"
89            ),
90            Self::EarmarkedRequiresPositiveTarget => write!(
91                f,
92                "Un fonds affecté doit porter un objectif d'épargne strictement positif"
93            ),
94            Self::NonEarmarkedCannotHavePurpose => write!(
95                f,
96                "Seul un fonds affecté peut porter un objet précis \
97                 (roulement/réserve n'en portent pas)"
98            ),
99            Self::NonEarmarkedCannotHaveTarget => write!(
100                f,
101                "Seul un fonds affecté peut porter un objectif d'épargne \
102                 (roulement/réserve n'en portent pas)"
103            ),
104            Self::NonPositiveContribution => {
105                write!(f, "Un versement au fonds doit être strictement positif")
106            }
107            Self::ExpensePurposeMismatch {
108                fund_purpose,
109                expense_work_ref,
110            } => write!(
111                f,
112                "Dépense refusée : le fonds est affecté à « {fund_purpose} », \
113                 pas à « {expense_work_ref} »"
114            ),
115            Self::InsufficientMajorityForCreation { required, used } => write!(
116                f,
117                "La création d'un fonds affecté exige la majorité {required:?} \
118                 (Art. 3.88, gros travaux) ; majorité obtenue : {used:?}"
119            ),
120            Self::OnlyEarmarkedFundsCanBeReassigned => write!(
121                f,
122                "Seul un fonds affecté peut être réaffecté (le fonds de réserve \
123                 légal reste gouverné par sa propre règle de renonciation 4/5)"
124            ),
125            Self::ReassignmentRequiresAdoptedResolution => write!(
126                f,
127                "La réaffectation d'un fonds exige une décision d'AG adoptée"
128            ),
129            Self::NonPositiveReassignmentAmount => {
130                write!(f, "Le montant réaffecté doit être strictement positif")
131            }
132            Self::ReassignmentAmountExceedsBalance => {
133                write!(f, "Le montant réaffecté dépasse le solde du fonds")
134            }
135        }
136    }
137}
138
139impl std::error::Error for FundError {}
140
141/// Fonds de l'ACP — roulement, réserve légale, ou affecté (thésaurisation).
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
143pub struct Fund {
144    pub id: Uuid,
145    pub acp_id: Uuid,
146    pub kind: FundKind,
147    pub name: String,
148    /// Objet précis du chantier — uniquement pour `FundKind::Earmarked`.
149    pub purpose: Option<String>,
150    /// Objectif d'épargne — uniquement pour `FundKind::Earmarked`.
151    pub target_amount: Option<Decimal>,
152    pub balance: Decimal,
153    pub created_at: DateTime<Utc>,
154    pub updated_at: DateTime<Utc>,
155}
156
157/// Audit trail d'une réaffectation de fonds — traçabilité comptable et
158/// légale de l'affectation d'origine vers la nouvelle fin (DoD #635).
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
160pub struct FundReassignment {
161    pub id: Uuid,
162    pub fund_id: Uuid,
163    pub previous_purpose: String,
164    pub new_purpose: String,
165    pub amount: Decimal,
166    /// La décision d'AG qui autorise la réaffectation (Art. 3.88).
167    pub resolution_id: Uuid,
168    pub reassigned_at: DateTime<Utc>,
169}
170
171impl Fund {
172    /// Crée un fonds de roulement ou de réserve (pas d'objet, pas
173    /// d'objectif). Pour un fonds affecté, voir [`Fund::new_earmarked`].
174    pub fn new(
175        acp_id: Uuid,
176        kind: FundKind,
177        name: String,
178        purpose: Option<String>,
179        target_amount: Option<Decimal>,
180    ) -> Result<Self, FundError> {
181        let name = name.trim().to_string();
182        if name.is_empty() {
183            return Err(FundError::EmptyName);
184        }
185
186        match kind {
187            FundKind::Earmarked => {
188                let purpose_trimmed = purpose.as_deref().unwrap_or("").trim().to_string();
189                if purpose_trimmed.is_empty() {
190                    return Err(FundError::EarmarkedRequiresPurpose);
191                }
192                match target_amount {
193                    Some(t) if t > Decimal::ZERO => {}
194                    _ => return Err(FundError::EarmarkedRequiresPositiveTarget),
195                }
196            }
197            FundKind::WorkingCapital | FundKind::Reserve => {
198                if purpose.is_some() {
199                    return Err(FundError::NonEarmarkedCannotHavePurpose);
200                }
201                if target_amount.is_some() {
202                    return Err(FundError::NonEarmarkedCannotHaveTarget);
203                }
204            }
205        }
206
207        let now = Utc::now();
208        Ok(Self {
209            id: Uuid::new_v4(),
210            acp_id,
211            kind,
212            name,
213            purpose,
214            target_amount,
215            balance: Decimal::ZERO,
216            created_at: now,
217            updated_at: now,
218        })
219    }
220
221    /// Crée un fonds affecté, en exigeant la majorité des gros travaux (2/3,
222    /// Art. 3.88 §1, 1°, b).
223    ///
224    /// @security (story #635) : créer un fonds fléché à la majorité simple
225    /// contournerait la majorité qui protège les copropriétaires du
226    /// chantier lui-même.
227    pub fn new_earmarked(
228        acp_id: Uuid,
229        name: String,
230        purpose: String,
231        target_amount: Decimal,
232        majority_used: MajorityType,
233    ) -> Result<Self, FundError> {
234        if !majority_used.satisfait(&MajorityType::TwoThirds) {
235            return Err(FundError::InsufficientMajorityForCreation {
236                required: MajorityType::TwoThirds,
237                used: majority_used,
238            });
239        }
240        Self::new(
241            acp_id,
242            FundKind::Earmarked,
243            name,
244            Some(purpose),
245            Some(target_amount),
246        )
247    }
248
249    /// Alimente le fonds. Le solde ne se confond avec aucun autre fonds :
250    /// chaque `Fund` porte le sien.
251    pub fn contribute(&mut self, amount: Decimal) -> Result<(), FundError> {
252        if amount <= Decimal::ZERO {
253            return Err(FundError::NonPositiveContribution);
254        }
255        self.balance += amount;
256        self.updated_at = Utc::now();
257        Ok(())
258    }
259
260    /// Progression de l'épargne vers l'objectif (`balance / target_amount`).
261    /// `None` pour un fonds sans objectif (roulement/réserve) ou à objectif
262    /// nul.
263    pub fn progress(&self) -> Option<Decimal> {
264        self.target_amount
265            .filter(|t| *t > Decimal::ZERO)
266            .map(|t| self.balance / t)
267    }
268
269    /// Le reliquat : ce que le fonds a accumulé au-delà de son objectif, une
270    /// fois celui-ci atteint (chantier terminé sous budget).
271    ///
272    /// `None` tant que l'objectif n'est pas dépassé, ou pour un fonds sans
273    /// objectif (roulement/réserve).
274    pub fn reliquat(&self) -> Option<Decimal> {
275        let target = self.target_amount?;
276        (self.balance > target).then_some(self.balance - target)
277    }
278
279    /// Autorise (ou refuse) une dépense imputée à ce fonds pour l'objet
280    /// `expense_work_ref`.
281    ///
282    /// Seul un fonds affecté restreint ainsi ses dépenses — roulement et
283    /// réserve n'ont pas d'objet unique à comparer. `ag_override` lève la
284    /// restriction quand il vaut `Some(ResolutionStatus::Adopted)` : « ne
285    /// jamais bloquer une dépense au seul motif que le fonds était fléché
286    /// ailleurs, dès lors qu'une décision d'AG l'autorise » (PO @gilmry).
287    pub fn assert_expense_matches_purpose(
288        &self,
289        expense_work_ref: &str,
290        ag_override: Option<ResolutionStatus>,
291    ) -> Result<(), FundError> {
292        if ag_override == Some(ResolutionStatus::Adopted) {
293            return Ok(());
294        }
295        if self.kind != FundKind::Earmarked {
296            return Ok(());
297        }
298        match &self.purpose {
299            Some(purpose) if purpose == expense_work_ref => Ok(()),
300            Some(purpose) => Err(FundError::ExpensePurposeMismatch {
301                fund_purpose: purpose.clone(),
302                expense_work_ref: expense_work_ref.to_string(),
303            }),
304            // Invariant : un fonds Earmarked porte toujours un purpose
305            // (garanti par `Fund::new`).
306            None => Ok(()),
307        }
308    }
309
310    /// Réaffecte tout ou partie du solde de ce fonds affecté vers une autre
311    /// fin, sur décision d'AG adoptée. Retourne l'audit trail
312    /// (`FundReassignment`) tracant l'affectation d'origine.
313    ///
314    /// `amount = None` réaffecte la TOTALITÉ du solde disponible : le fonds
315    /// sert alors intégralement la nouvelle fin (`purpose` change).
316    /// `amount = Some(x)` avec `x` strictement inférieur au solde ne
317    /// réaffecte qu'une partie : le reliquat continue de servir l'objet
318    /// d'origine. Si `x` égale le solde entier, le résultat est le même que
319    /// la totalité.
320    pub fn reassign(
321        &mut self,
322        new_purpose: String,
323        amount: Option<Decimal>,
324        resolution_id: Uuid,
325        resolution_status: ResolutionStatus,
326    ) -> Result<FundReassignment, FundError> {
327        if self.kind != FundKind::Earmarked {
328            return Err(FundError::OnlyEarmarkedFundsCanBeReassigned);
329        }
330        if resolution_status != ResolutionStatus::Adopted {
331            return Err(FundError::ReassignmentRequiresAdoptedResolution);
332        }
333        let moved = amount.unwrap_or(self.balance);
334        if moved <= Decimal::ZERO {
335            return Err(FundError::NonPositiveReassignmentAmount);
336        }
337        if moved > self.balance {
338            return Err(FundError::ReassignmentAmountExceedsBalance);
339        }
340
341        let previous_purpose = self.purpose.clone().unwrap_or_default();
342        self.balance -= moved;
343
344        // Plus rien du solde d'origine : le fonds sert désormais
345        // intégralement la nouvelle fin. Un reliquat partiel garde l'objet
346        // d'origine pour ce qui reste.
347        if self.balance == Decimal::ZERO {
348            self.purpose = Some(new_purpose.clone());
349        }
350        self.updated_at = Utc::now();
351
352        Ok(FundReassignment {
353            id: Uuid::new_v4(),
354            fund_id: self.id,
355            previous_purpose,
356            new_purpose,
357            amount: moved,
358            resolution_id,
359            reassigned_at: Utc::now(),
360        })
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use rust_decimal_macros::dec;
368
369    fn earmarked(name: &str, purpose: &str, target: Decimal) -> Fund {
370        Fund::new_earmarked(
371            Uuid::new_v4(),
372            name.to_string(),
373            purpose.to_string(),
374            target,
375            MajorityType::TwoThirds,
376        )
377        .expect("fonds affecté valide")
378    }
379
380    // ── @happy ──────────────────────────────────────────────────────────
381
382    /// Gherkin de la story #635 : un fonds affecté voté en AG progresse vers
383    /// son objectif et reste distinct des deux autres fonds.
384    #[test]
385    fn happy_fonds_affecte_progresse_et_reste_distinct() {
386        let acp_id = Uuid::new_v4();
387        let mut toiture = Fund::new_earmarked(
388            acp_id,
389            "Réfection toiture".to_string(),
390            "Réfection toiture".to_string(),
391            dec!(50000),
392            MajorityType::TwoThirds,
393        )
394        .expect("vote valide");
395        let mut roulement = Fund::new(
396            acp_id,
397            FundKind::WorkingCapital,
398            "Roulement".to_string(),
399            None,
400            None,
401        )
402        .unwrap();
403        let mut reserve = Fund::new(
404            acp_id,
405            FundKind::Reserve,
406            "Réserve légale".to_string(),
407            None,
408            None,
409        )
410        .unwrap();
411
412        toiture.contribute(dec!(12500)).unwrap();
413        roulement.contribute(dec!(3000)).unwrap();
414        reserve.contribute(dec!(7000)).unwrap();
415
416        assert_eq!(toiture.balance, dec!(12500));
417        assert_eq!(toiture.progress(), Some(dec!(0.25)));
418        // Les soldes ne se confondent avec aucun autre fonds.
419        assert_eq!(roulement.balance, dec!(3000));
420        assert_eq!(reserve.balance, dec!(7000));
421        assert_ne!(toiture.balance, roulement.balance);
422        assert_ne!(toiture.balance, reserve.balance);
423    }
424
425    /// AC checklist #635 — reliquat une fois l'objectif dépassé.
426    #[test]
427    fn happy_reliquat_apres_objectif_atteint() {
428        let mut fonds = earmarked("Façade", "Ravalement façade", dec!(10000));
429        fonds.contribute(dec!(11500)).unwrap();
430        assert_eq!(fonds.reliquat(), Some(dec!(1500)));
431    }
432
433    /// AC checklist #635 — réaffectation par décision d'AG adoptée, avec
434    /// audit trail.
435    #[test]
436    fn happy_reassignation_par_decision_ag_adoptee() {
437        let mut fonds = earmarked("Ascenseur", "Remplacement ascenseur", dec!(20000));
438        fonds.contribute(dec!(20000)).unwrap();
439        let resolution_id = Uuid::new_v4();
440
441        let audit = fonds
442            .reassign(
443                "Ravalement façade".to_string(),
444                None,
445                resolution_id,
446                ResolutionStatus::Adopted,
447            )
448            .expect("réaffectation autorisée par l'AG");
449
450        assert_eq!(audit.fund_id, fonds.id);
451        assert_eq!(audit.previous_purpose, "Remplacement ascenseur");
452        assert_eq!(audit.new_purpose, "Ravalement façade");
453        assert_eq!(audit.amount, dec!(20000));
454        assert_eq!(audit.resolution_id, resolution_id);
455        assert_eq!(fonds.purpose, Some("Ravalement façade".to_string()));
456        assert_eq!(fonds.balance, Decimal::ZERO);
457    }
458
459    // ── @negative ───────────────────────────────────────────────────────
460
461    /// Gherkin @negative de la story #635 : une dépense imputée à un fonds
462    /// affecté pour un autre objet que le sien est refusée.
463    #[test]
464    fn negative_depense_hors_objet_est_refusee() {
465        let fonds = earmarked("Toiture", "Réfection toiture", dec!(50000));
466        let erreur = fonds
467            .assert_expense_matches_purpose("Ravalement façade", None)
468            .expect_err("doit refuser");
469        assert_eq!(
470            erreur,
471            FundError::ExpensePurposeMismatch {
472                fund_purpose: "Réfection toiture".to_string(),
473                expense_work_ref: "Ravalement façade".to_string(),
474            }
475        );
476    }
477
478    #[test]
479    fn negative_reassignation_sans_resolution_adoptee_refusee() {
480        let mut fonds = earmarked("Toiture", "Réfection toiture", dec!(50000));
481        fonds.contribute(dec!(1000)).unwrap();
482
483        for statut_non_adopte in [ResolutionStatus::Pending, ResolutionStatus::Rejected] {
484            let erreur = fonds
485                .clone()
486                .reassign(
487                    "Autre objet".to_string(),
488                    None,
489                    Uuid::new_v4(),
490                    statut_non_adopte,
491                )
492                .expect_err("doit refuser sans décision adoptée");
493            assert_eq!(erreur, FundError::ReassignmentRequiresAdoptedResolution);
494        }
495    }
496
497    #[test]
498    fn negative_contribution_non_positive_refusee() {
499        let mut fonds = Fund::new(
500            Uuid::new_v4(),
501            FundKind::WorkingCapital,
502            "Roulement".to_string(),
503            None,
504            None,
505        )
506        .unwrap();
507        assert_eq!(
508            fonds.contribute(Decimal::ZERO).unwrap_err(),
509            FundError::NonPositiveContribution
510        );
511        assert_eq!(
512            fonds.contribute(dec!(-1)).unwrap_err(),
513            FundError::NonPositiveContribution
514        );
515    }
516
517    // ── @edge ───────────────────────────────────────────────────────────
518
519    /// Gherkin @edge de la story #635 : le fonds de réserve légal reste
520    /// distinct, et l'obligation des 5 % (portée par ADR-0012, hors de ce
521    /// module) n'est pas diluée dans le modèle facultatif — un `Fund::Reserve`
522    /// ne peut pas être créé avec un objet/objectif de thésaurisation.
523    #[test]
524    fn edge_fonds_de_reserve_legal_reste_distinct_et_sans_objet() {
525        let reserve = Fund::new(
526            Uuid::new_v4(),
527            FundKind::Reserve,
528            "Réserve légale".to_string(),
529            None,
530            None,
531        )
532        .expect("fonds de réserve valide, sans vote qualifié requis ici");
533        assert_eq!(reserve.kind, FundKind::Reserve);
534        assert_eq!(reserve.purpose, None);
535        assert_eq!(reserve.target_amount, None);
536
537        let refuse_avec_objet = Fund::new(
538            Uuid::new_v4(),
539            FundKind::Reserve,
540            "Réserve légale".to_string(),
541            Some("Chantier".to_string()),
542            None,
543        )
544        .expect_err("la réserve légale ne porte pas d'objet de thésaurisation");
545        assert_eq!(refuse_avec_objet, FundError::NonEarmarkedCannotHavePurpose);
546    }
547
548    /// Une réaffectation partielle laisse un reliquat sous l'ancien objet.
549    #[test]
550    fn edge_reassignation_partielle_laisse_un_reliquat_sous_lancien_objet() {
551        let mut fonds = earmarked("Toiture", "Réfection toiture", dec!(50000));
552        fonds.contribute(dec!(30000)).unwrap();
553
554        let audit = fonds
555            .reassign(
556                "Ravalement façade".to_string(),
557                Some(dec!(10000)),
558                Uuid::new_v4(),
559                ResolutionStatus::Adopted,
560            )
561            .expect("réaffectation partielle autorisée");
562
563        assert_eq!(audit.amount, dec!(10000));
564        assert_eq!(fonds.balance, dec!(20000));
565        // Le reliquat sert toujours l'objet d'origine.
566        assert_eq!(fonds.purpose, Some("Réfection toiture".to_string()));
567    }
568
569    #[test]
570    fn edge_reliquat_absent_avant_objectif_atteint() {
571        let mut fonds = earmarked("Toiture", "Réfection toiture", dec!(50000));
572        fonds.contribute(dec!(10000)).unwrap();
573        assert_eq!(fonds.reliquat(), None);
574    }
575
576    // ── @security ───────────────────────────────────────────────────────
577
578    /// Gherkin @security de la story #635 : la majorité exigée pour créer un
579    /// fonds affecté est celle des gros travaux (2/3, Art. 3.88).
580    #[test]
581    fn security_creation_fonds_affecte_exige_majorite_des_deux_tiers() {
582        let refus = Fund::new_earmarked(
583            Uuid::new_v4(),
584            "Toiture".to_string(),
585            "Réfection toiture".to_string(),
586            dec!(50000),
587            MajorityType::Absolute,
588        )
589        .expect_err("la majorité simple ne suffit pas");
590        assert_eq!(
591            refus,
592            FundError::InsufficientMajorityForCreation {
593                required: MajorityType::TwoThirds,
594                used: MajorityType::Absolute,
595            }
596        );
597
598        for majorite_suffisante in [
599            MajorityType::TwoThirds,
600            MajorityType::FourFifths,
601            MajorityType::Unanimity,
602        ] {
603            assert!(Fund::new_earmarked(
604                Uuid::new_v4(),
605                "Toiture".to_string(),
606                "Réfection toiture".to_string(),
607                dec!(50000),
608                majorite_suffisante,
609            )
610            .is_ok());
611        }
612    }
613
614    /// Une décision d'AG adoptée autorise une dépense hors objet — le
615    /// fléchage par défaut ne doit jamais bloquer une décision d'AG.
616    #[test]
617    fn security_ag_override_autorise_une_depense_hors_objet() {
618        let fonds = earmarked("Toiture", "Réfection toiture", dec!(50000));
619        let resultat = fonds
620            .assert_expense_matches_purpose("Ravalement façade", Some(ResolutionStatus::Adopted));
621        assert!(resultat.is_ok());
622
623        // Une résolution simplement en attente ne lève pas la restriction.
624        let refuse = fonds
625            .assert_expense_matches_purpose("Ravalement façade", Some(ResolutionStatus::Pending))
626            .expect_err("doit refuser sans décision adoptée");
627        assert!(matches!(refuse, FundError::ExpensePurposeMismatch { .. }));
628    }
629
630    /// Seul un fonds affecté peut être réaffecté : la réserve légale garde
631    /// sa propre procédure de renonciation (4/5, ADR-0012), qu'une
632    /// réaffectation générique contournerait sinon.
633    #[test]
634    fn security_seul_un_fonds_affecte_peut_etre_reassigne() {
635        let mut roulement = Fund::new(
636            Uuid::new_v4(),
637            FundKind::WorkingCapital,
638            "Roulement".to_string(),
639            None,
640            None,
641        )
642        .unwrap();
643        let erreur = roulement
644            .reassign(
645                "Autre objet".to_string(),
646                None,
647                Uuid::new_v4(),
648                ResolutionStatus::Adopted,
649            )
650            .expect_err("le roulement n'est pas réaffectable");
651        assert_eq!(erreur, FundError::OnlyEarmarkedFundsCanBeReassigned);
652    }
653}