Skip to main content

koprogo_api/application/use_cases/
fund_use_cases.rs

1//! Use cases for the Fund feature (issue #635 — fonds affectés &
2//! thésaurisation, ADR-0054).
3//!
4//! Trois opérations couvrent la checklist d'acceptation :
5//!
6//! 1. [`FundUseCases::create_working_capital_or_reserve`] /
7//!    [`FundUseCases::create_earmarked_fund`] — créer un fonds. Un fonds
8//!    affecté exige la majorité obtenue en AG (`MajorityType`), vérifiée par
9//!    `Fund::new_earmarked` (Art. 3.88, 2/3).
10//! 2. [`FundUseCases::contribute`] — alimenter un fonds (épargne).
11//! 3. [`FundUseCases::reassign`] — réaffecter un fonds affecté vers une
12//!    autre fin, sur décision d'AG adoptée, avec audit trail persistant
13//!    (`fund_reassignments`).
14//!
15//! Aussi : [`FundUseCases::record_expense`] (garde-fou dépense / objet,
16//! avec échappatoire d'AG) et des lectures (`get`, `list_for_acp`,
17//! `list_reassignments`).
18
19use crate::application::error::AppError;
20use crate::application::ports::FundRepository;
21use crate::domain::copropriete::resolution::{MajorityType, ResolutionStatus};
22use crate::domain::entities::{Fund, FundKind, FundReassignment};
23use rust_decimal::Decimal;
24use std::sync::Arc;
25use uuid::Uuid;
26
27pub struct FundUseCases {
28    repo: Arc<dyn FundRepository>,
29}
30
31impl FundUseCases {
32    pub fn new(repo: Arc<dyn FundRepository>) -> Self {
33        Self { repo }
34    }
35
36    /// Crée un fonds de roulement ou de réserve (pas de vote qualifié
37    /// requis à ce niveau — la réserve légale reste gouvernée par
38    /// `Acp::assert_reserve_fund_compliant`, ADR-0012).
39    pub async fn create_working_capital_or_reserve(
40        &self,
41        acp_id: Uuid,
42        kind: FundKind,
43        name: String,
44    ) -> Result<Fund, AppError> {
45        let fund = Fund::new(acp_id, kind, name, None, None)?;
46        self.repo.create(&fund).await
47    }
48
49    /// Crée un fonds affecté (thésaurisation), en exigeant la majorité des
50    /// gros travaux (2/3, Art. 3.88) — appliquée par `Fund::new_earmarked`.
51    pub async fn create_earmarked_fund(
52        &self,
53        acp_id: Uuid,
54        name: String,
55        purpose: String,
56        target_amount: Decimal,
57        majority_used: MajorityType,
58    ) -> Result<Fund, AppError> {
59        let fund = Fund::new_earmarked(acp_id, name, purpose, target_amount, majority_used)?;
60        self.repo.create(&fund).await
61    }
62
63    /// Alimente un fonds existant.
64    pub async fn contribute(&self, fund_id: Uuid, amount: Decimal) -> Result<Fund, AppError> {
65        let mut fund = self
66            .repo
67            .find_by_id(fund_id)
68            .await?
69            .ok_or_else(|| AppError::NotFound(format!("fund {fund_id}")))?;
70        fund.contribute(amount)?;
71        self.repo.update(&fund).await
72    }
73
74    /// Vérifie qu'une dépense imputée à `fund_id` correspond à son objet —
75    /// sauf si une décision d'AG adoptée l'autorise explicitement
76    /// (`ag_override_status`).
77    pub async fn record_expense(
78        &self,
79        fund_id: Uuid,
80        expense_work_ref: &str,
81        ag_override_status: Option<ResolutionStatus>,
82    ) -> Result<(), AppError> {
83        let fund = self
84            .repo
85            .find_by_id(fund_id)
86            .await?
87            .ok_or_else(|| AppError::NotFound(format!("fund {fund_id}")))?;
88        fund.assert_expense_matches_purpose(expense_work_ref, ag_override_status)?;
89        Ok(())
90    }
91
92    /// Réaffecte tout ou partie du solde d'un fonds affecté vers une autre
93    /// fin, sur décision d'AG adoptée. Persiste le fonds mis à jour ET
94    /// l'audit trail (`fund_reassignments`).
95    pub async fn reassign(
96        &self,
97        fund_id: Uuid,
98        new_purpose: String,
99        amount: Option<Decimal>,
100        resolution_id: Uuid,
101        resolution_status: ResolutionStatus,
102    ) -> Result<FundReassignment, AppError> {
103        let mut fund = self
104            .repo
105            .find_by_id(fund_id)
106            .await?
107            .ok_or_else(|| AppError::NotFound(format!("fund {fund_id}")))?;
108        let record = fund.reassign(new_purpose, amount, resolution_id, resolution_status)?;
109        self.repo.update(&fund).await?;
110        self.repo.record_reassignment(&record).await?;
111        Ok(record)
112    }
113
114    pub async fn get(&self, fund_id: Uuid) -> Result<Fund, AppError> {
115        self.repo
116            .find_by_id(fund_id)
117            .await?
118            .ok_or_else(|| AppError::NotFound(format!("fund {fund_id}")))
119    }
120
121    pub async fn list_for_acp(&self, acp_id: Uuid) -> Result<Vec<Fund>, AppError> {
122        self.repo.find_by_acp_id(acp_id).await
123    }
124
125    pub async fn list_reassignments(
126        &self,
127        fund_id: Uuid,
128    ) -> Result<Vec<FundReassignment>, AppError> {
129        self.repo.list_reassignments(fund_id).await
130    }
131}
132
133// ============================================================================
134// Tests — taxonomie 4 catégories (CRITICAL.md #3)
135// ============================================================================
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use async_trait::async_trait;
141    use rust_decimal_macros::dec;
142    use std::sync::Mutex;
143
144    #[derive(Default)]
145    struct InMemoryRepo {
146        funds: Mutex<Vec<Fund>>,
147        reassignments: Mutex<Vec<FundReassignment>>,
148    }
149
150    #[async_trait]
151    impl FundRepository for InMemoryRepo {
152        async fn create(&self, fund: &Fund) -> Result<Fund, AppError> {
153            self.funds.lock().unwrap().push(fund.clone());
154            Ok(fund.clone())
155        }
156
157        async fn find_by_id(&self, id: Uuid) -> Result<Option<Fund>, AppError> {
158            Ok(self
159                .funds
160                .lock()
161                .unwrap()
162                .iter()
163                .find(|f| f.id == id)
164                .cloned())
165        }
166
167        async fn find_by_acp_id(&self, acp_id: Uuid) -> Result<Vec<Fund>, AppError> {
168            Ok(self
169                .funds
170                .lock()
171                .unwrap()
172                .iter()
173                .filter(|f| f.acp_id == acp_id)
174                .cloned()
175                .collect())
176        }
177
178        async fn update(&self, fund: &Fund) -> Result<Fund, AppError> {
179            let mut funds = self.funds.lock().unwrap();
180            if let Some(existing) = funds.iter_mut().find(|f| f.id == fund.id) {
181                *existing = fund.clone();
182            }
183            Ok(fund.clone())
184        }
185
186        async fn record_reassignment(
187            &self,
188            reassignment: &FundReassignment,
189        ) -> Result<(), AppError> {
190            self.reassignments
191                .lock()
192                .unwrap()
193                .push(reassignment.clone());
194            Ok(())
195        }
196
197        async fn list_reassignments(
198            &self,
199            fund_id: Uuid,
200        ) -> Result<Vec<FundReassignment>, AppError> {
201            Ok(self
202                .reassignments
203                .lock()
204                .unwrap()
205                .iter()
206                .filter(|r| r.fund_id == fund_id)
207                .cloned()
208                .collect())
209        }
210    }
211
212    fn factory() -> (Arc<InMemoryRepo>, FundUseCases) {
213        let repo: Arc<InMemoryRepo> = Arc::new(InMemoryRepo::default());
214        let uc = FundUseCases::new(repo.clone() as Arc<dyn FundRepository>);
215        (repo, uc)
216    }
217
218    // ---- @happy -------------------------------------------------------------
219
220    #[tokio::test]
221    async fn happy_earmarked_fund_created_and_fed_towards_its_target() {
222        let (_repo, uc) = factory();
223        let acp_id = Uuid::new_v4();
224
225        let fund = uc
226            .create_earmarked_fund(
227                acp_id,
228                "Toiture".to_string(),
229                "Réfection toiture".to_string(),
230                dec!(50000),
231                MajorityType::TwoThirds,
232            )
233            .await
234            .expect("vote suffisant");
235
236        let fed = uc.contribute(fund.id, dec!(12500)).await.unwrap();
237        assert_eq!(fed.balance, dec!(12500));
238        assert_eq!(fed.progress(), Some(dec!(0.25)));
239    }
240
241    #[tokio::test]
242    async fn happy_reassignment_persists_fund_and_audit_trail() {
243        let (_repo, uc) = factory();
244        let acp_id = Uuid::new_v4();
245        let fund = uc
246            .create_earmarked_fund(
247                acp_id,
248                "Ascenseur".to_string(),
249                "Remplacement ascenseur".to_string(),
250                dec!(20000),
251                MajorityType::TwoThirds,
252            )
253            .await
254            .unwrap();
255        uc.contribute(fund.id, dec!(20000)).await.unwrap();
256        let resolution_id = Uuid::new_v4();
257
258        let record = uc
259            .reassign(
260                fund.id,
261                "Ravalement façade".to_string(),
262                None,
263                resolution_id,
264                ResolutionStatus::Adopted,
265            )
266            .await
267            .expect("réaffectation autorisée par l'AG");
268
269        assert_eq!(record.resolution_id, resolution_id);
270
271        let updated = uc.get(fund.id).await.unwrap();
272        assert_eq!(updated.purpose, Some("Ravalement façade".to_string()));
273
274        let history = uc.list_reassignments(fund.id).await.unwrap();
275        assert_eq!(history.len(), 1);
276        assert_eq!(history[0].previous_purpose, "Remplacement ascenseur");
277    }
278
279    // ---- @edge ----------------------------------------------------------------
280
281    #[tokio::test]
282    async fn edge_working_capital_and_reserve_keep_separate_balances() {
283        let (_repo, uc) = factory();
284        let acp_id = Uuid::new_v4();
285
286        let roulement = uc
287            .create_working_capital_or_reserve(
288                acp_id,
289                FundKind::WorkingCapital,
290                "Roulement".to_string(),
291            )
292            .await
293            .unwrap();
294        let reserve = uc
295            .create_working_capital_or_reserve(
296                acp_id,
297                FundKind::Reserve,
298                "Réserve légale".to_string(),
299            )
300            .await
301            .unwrap();
302
303        uc.contribute(roulement.id, dec!(3000)).await.unwrap();
304        uc.contribute(reserve.id, dec!(7000)).await.unwrap();
305
306        let funds = uc.list_for_acp(acp_id).await.unwrap();
307        assert_eq!(funds.len(), 2);
308        let roulement_after = funds.iter().find(|f| f.id == roulement.id).unwrap();
309        let reserve_after = funds.iter().find(|f| f.id == reserve.id).unwrap();
310        assert_eq!(roulement_after.balance, dec!(3000));
311        assert_eq!(reserve_after.balance, dec!(7000));
312    }
313
314    #[tokio::test]
315    async fn edge_get_unknown_fund_returns_not_found() {
316        let (_repo, uc) = factory();
317        let err = uc.get(Uuid::new_v4()).await.unwrap_err();
318        assert!(matches!(err, AppError::NotFound(_)));
319    }
320
321    // ---- @security --------------------------------------------------------
322
323    #[tokio::test]
324    async fn security_creating_earmarked_fund_with_simple_majority_is_rejected() {
325        let (_repo, uc) = factory();
326        let err = uc
327            .create_earmarked_fund(
328                Uuid::new_v4(),
329                "Toiture".to_string(),
330                "Réfection toiture".to_string(),
331                dec!(50000),
332                MajorityType::Absolute,
333            )
334            .await
335            .unwrap_err();
336        assert!(matches!(err, AppError::Validation(_)));
337    }
338
339    #[tokio::test]
340    async fn security_expense_outside_purpose_is_rejected_unless_ag_overrides() {
341        let (_repo, uc) = factory();
342        let fund = uc
343            .create_earmarked_fund(
344                Uuid::new_v4(),
345                "Toiture".to_string(),
346                "Réfection toiture".to_string(),
347                dec!(50000),
348                MajorityType::TwoThirds,
349            )
350            .await
351            .unwrap();
352
353        let refused = uc
354            .record_expense(fund.id, "Ravalement façade", None)
355            .await
356            .unwrap_err();
357        assert!(matches!(refused, AppError::Validation(_)));
358
359        // Une décision d'AG adoptée lève la restriction.
360        uc.record_expense(
361            fund.id,
362            "Ravalement façade",
363            Some(ResolutionStatus::Adopted),
364        )
365        .await
366        .expect("l'AG autorise la dépense hors objet");
367    }
368
369    // ---- @negative ----------------------------------------------------------
370
371    #[tokio::test]
372    async fn negative_reassign_unknown_fund_returns_not_found() {
373        let (_repo, uc) = factory();
374        let err = uc
375            .reassign(
376                Uuid::new_v4(),
377                "Autre objet".to_string(),
378                None,
379                Uuid::new_v4(),
380                ResolutionStatus::Adopted,
381            )
382            .await
383            .unwrap_err();
384        assert!(matches!(err, AppError::NotFound(_)));
385    }
386
387    #[tokio::test]
388    async fn negative_reassign_without_adopted_resolution_is_rejected() {
389        let (_repo, uc) = factory();
390        let fund = uc
391            .create_earmarked_fund(
392                Uuid::new_v4(),
393                "Toiture".to_string(),
394                "Réfection toiture".to_string(),
395                dec!(50000),
396                MajorityType::TwoThirds,
397            )
398            .await
399            .unwrap();
400        uc.contribute(fund.id, dec!(1000)).await.unwrap();
401
402        let err = uc
403            .reassign(
404                fund.id,
405                "Autre objet".to_string(),
406                None,
407                Uuid::new_v4(),
408                ResolutionStatus::Pending,
409            )
410            .await
411            .unwrap_err();
412        assert!(matches!(err, AppError::Validation(_)));
413        // Aucun audit trail ne doit être créé pour une réaffectation refusée.
414        assert!(uc.list_reassignments(fund.id).await.unwrap().is_empty());
415    }
416
417    #[tokio::test]
418    async fn negative_contribute_to_unknown_fund_returns_not_found() {
419        let (_repo, uc) = factory();
420        let err = uc.contribute(Uuid::new_v4(), dec!(10)).await.unwrap_err();
421        assert!(matches!(err, AppError::NotFound(_)));
422    }
423}