Skip to main content

koprogo_api/application/use_cases/
call_for_funds_use_cases.rs

1use crate::application::ports::{
2    AcpRepository, BuildingRepository, CallForFundsRepository, OwnerContributionRepository,
3    UnitOwnerRepository,
4};
5use crate::domain::entities::{CallForFunds, ContributionType, OwnerContribution};
6use chrono::{DateTime, Utc};
7use std::sync::Arc;
8use uuid::Uuid;
9
10pub struct CallForFundsUseCases {
11    call_for_funds_repository: Arc<dyn CallForFundsRepository>,
12    owner_contribution_repository: Arc<dyn OwnerContributionRepository>,
13    unit_owner_repository: Arc<dyn UnitOwnerRepository>,
14    /// Track H Story H7 — validate-before-compute ACP-level (FR-CL1). Optional.
15    /// Quand présents (wiring `main.rs`), le pre-check `Acp::assert_conformant?`
16    /// s'exécute avant `create_call_for_funds` / `send_call_for_funds`.
17    building_repository: Option<Arc<dyn BuildingRepository>>,
18    acp_repository: Option<Arc<dyn AcpRepository>>,
19}
20
21impl CallForFundsUseCases {
22    pub fn new(
23        call_for_funds_repository: Arc<dyn CallForFundsRepository>,
24        owner_contribution_repository: Arc<dyn OwnerContributionRepository>,
25        unit_owner_repository: Arc<dyn UnitOwnerRepository>,
26    ) -> Self {
27        Self {
28            call_for_funds_repository,
29            owner_contribution_repository,
30            unit_owner_repository,
31            building_repository: None,
32            acp_repository: None,
33        }
34    }
35
36    /// Track H Story H7 — wiring complet (validate-before-compute ACP-level).
37    pub fn with_full_wiring(
38        call_for_funds_repository: Arc<dyn CallForFundsRepository>,
39        owner_contribution_repository: Arc<dyn OwnerContributionRepository>,
40        unit_owner_repository: Arc<dyn UnitOwnerRepository>,
41        building_repository: Arc<dyn BuildingRepository>,
42        acp_repository: Arc<dyn AcpRepository>,
43    ) -> Self {
44        Self {
45            call_for_funds_repository,
46            owner_contribution_repository,
47            unit_owner_repository,
48            building_repository: Some(building_repository),
49            acp_repository: Some(acp_repository),
50        }
51    }
52
53    /// Résout l'ACP de l'immeuble, et vérifie sa conformité au passage.
54    ///
55    /// Deux responsabilités volontairement réunies : on ne peut pas vérifier
56    /// la conformité d'une ACP sans l'avoir identifiée, et on ne veut pas
57    /// appeler des fonds au nom d'une ACP dont l'acte de base ne boucle pas
58    /// (Story H7, Art. 3.85 § 1er).
59    ///
60    /// **Le dépôt d'immeubles est requis.** Sans lui, on ne sait pas au nom de
61    /// qui l'argent est appelé — et un appel de fonds dont on ignore le
62    /// créancier n'a pas à exister. On échoue plutôt que de retomber sur
63    /// l'identifiant du syndic, qui est précisément la confusion que
64    /// l'ADR-0045 supprime.
65    ///
66    /// La vérification de conformité, elle, reste facultative : elle dépend du
67    /// dépôt d'ACP, câblé séparément.
68    async fn resoudre_lacp_conforme(&self, building_id: Uuid) -> Result<Uuid, String> {
69        let Some(building_repo) = &self.building_repository else {
70            return Err(
71                "Impossible d'appeler des fonds : l'ACP créancière n'est pas résoluble \
72                 (dépôt d'immeubles non câblé)"
73                    .to_string(),
74            );
75        };
76        let building = building_repo
77            .find_by_id(building_id)
78            .await?
79            .ok_or_else(|| "Building not found".to_string())?;
80
81        self.verifier_conformite(building.acp_id).await?;
82        Ok(building.acp_id)
83    }
84
85    /// Vérifie que l'acte de base d'une ACP boucle, quand le dépôt est câblé.
86    ///
87    /// Facultatif à dessein : la conformité est un garde-fou de calcul
88    /// (Story H7), pas une condition d'identité. Une ACP non conforme existe,
89    /// elle n'est simplement pas en état qu'on réparte des charges dessus.
90    async fn verifier_conformite(&self, acp_id: Uuid) -> Result<(), String> {
91        let Some(acp_repo) = &self.acp_repository else {
92            return Ok(());
93        };
94        let (acp, metrics) = acp_repo
95            .find_by_id_with_metrics(acp_id)
96            .await
97            .map_err(|e| e.to_string())?
98            .ok_or_else(|| "ACP not found".to_string())?;
99        acp.assert_conformant(&metrics)?; // bridge String livré par H5
100        Ok(())
101    }
102
103    /// Create a new call for funds
104    #[allow(clippy::too_many_arguments)]
105    pub async fn create_call_for_funds(
106        &self,
107        organization_id: Uuid,
108        building_id: Uuid,
109        title: String,
110        description: String,
111        total_amount: rust_decimal::Decimal,
112        contribution_type: ContributionType,
113        call_date: DateTime<Utc>,
114        due_date: DateTime<Utc>,
115        account_code: Option<String>,
116        created_by: Option<Uuid>,
117        reserve_fund_share: rust_decimal::Decimal,
118    ) -> Result<CallForFunds, String> {
119        // Track H Story H2 — validate-before-compute gate (Art. 3.85 CC),
120        // et résolution de l'ACP créancière (ADR-0045).
121        let acp_id = self.resoudre_lacp_conforme(building_id).await?;
122
123        // Create the call for funds entity
124        let mut call_for_funds = CallForFunds::new(
125            acp_id,
126            organization_id,
127            building_id,
128            title,
129            description,
130            total_amount,
131            contribution_type.clone(),
132            call_date,
133            due_date,
134            account_code,
135            reserve_fund_share,
136        )?;
137
138        call_for_funds.created_by = created_by;
139
140        // Save to database
141        self.call_for_funds_repository.create(&call_for_funds).await
142    }
143
144    /// Get a call for funds by ID
145    pub async fn get_call_for_funds(&self, id: Uuid) -> Result<Option<CallForFunds>, String> {
146        self.call_for_funds_repository.find_by_id(id).await
147    }
148
149    /// List all calls for funds for a building
150    pub async fn list_by_building(&self, building_id: Uuid) -> Result<Vec<CallForFunds>, String> {
151        self.call_for_funds_repository
152            .find_by_building(building_id)
153            .await
154    }
155
156    /// List all calls for funds for an organization
157    pub async fn list_by_organization(
158        &self,
159        organization_id: Uuid,
160    ) -> Result<Vec<CallForFunds>, String> {
161        self.call_for_funds_repository
162            .find_by_organization(organization_id)
163            .await
164    }
165
166    /// Mark call for funds as sent and generate individual owner contributions
167    /// This is the key operation that automatically creates contributions for all owners
168    pub async fn send_call_for_funds(&self, id: Uuid) -> Result<CallForFunds, String> {
169        // Get the call for funds
170        let mut call_for_funds = self
171            .call_for_funds_repository
172            .find_by_id(id)
173            .await?
174            .ok_or_else(|| "Call for funds not found".to_string())?;
175
176        // Track H Story H2 — validate-before-compute gate (Art. 3.85 CC).
177        // Le send génère les contributions — calcul interdit sur immeuble drift.
178        //
179        // On interroge l'ACP portée par l'appel, pas celle de l'immeuble : la
180        // créance a été constituée au nom d'une ACP donnée, et c'est celle-là
181        // qui doit être conforme au moment où on répartit.
182        self.verifier_conformite(call_for_funds.acp_id).await?;
183
184        // Mark as sent
185        call_for_funds.mark_as_sent();
186
187        // Update in database
188        let updated_call = self
189            .call_for_funds_repository
190            .update(&call_for_funds)
191            .await?;
192
193        // Generate individual contributions for all owners in the building
194        self.generate_owner_contributions(&updated_call).await?;
195
196        Ok(updated_call)
197    }
198
199    /// Generate individual owner contributions based on ownership percentages
200    async fn generate_owner_contributions(
201        &self,
202        call_for_funds: &CallForFunds,
203    ) -> Result<Vec<OwnerContribution>, String> {
204        // Quotes-parts de CHARGE, pas pourcentages de détention.
205        //
206        // `find_active_by_building` renvoyait le `ownership_percentage` brut :
207        // 1.0 pour tout propriétaire unique de son lot, quel que soit le poids
208        // du lot. Multiplié par le montant total, cela appelait le montant
209        // ENTIER à chaque copropriétaire — un appel de 10 000 € sur un
210        // immeuble conforme à 4 lots générait 4 quotes-parts de 10 000 €,
211        // soit 40 000 € appelés, et répondait 200.
212        //
213        // Les tantièmes de l'acte de base étaient purement ignorés.
214        let unit_owners = self
215            .unit_owner_repository
216            .find_active_quota_shares_by_building(call_for_funds.building_id)
217            .await?;
218
219        if unit_owners.is_empty() {
220            return Err("No active owners found for this building".to_string());
221        }
222
223        let mut contributions = Vec::new();
224
225        for (unit_id, owner_id, percentage) in unit_owners {
226            // Calculate individual amount based on ownership percentage
227            let individual_amount = call_for_funds.total_amount * percentage;
228
229            // Create contribution description
230            let description = format!(
231                "{} - Quote-part: {}%",
232                call_for_funds.title,
233                percentage * rust_decimal_macros::dec!(100)
234            );
235
236            // Create owner contribution
237            let mut contribution = OwnerContribution::new(
238                // La quote-part est due à l'ACP créancière de l'appel, pas au
239                // cabinet qui l'a émis (Art. 3.86 § 3, ADR-0045).
240                call_for_funds.acp_id,
241                call_for_funds.organization_id,
242                owner_id,
243                Some(unit_id),
244                description,
245                individual_amount,
246                call_for_funds.contribution_type.clone(),
247                call_for_funds.call_date,
248                call_for_funds.account_code.clone(),
249            )?;
250
251            // Link to the call for funds
252            contribution.call_for_funds_id = Some(call_for_funds.id);
253
254            // Save contribution
255            let saved = self
256                .owner_contribution_repository
257                .create(&contribution)
258                .await?;
259
260            contributions.push(saved);
261        }
262
263        Ok(contributions)
264    }
265
266    /// Cancel a call for funds
267    pub async fn cancel_call_for_funds(&self, id: Uuid) -> Result<CallForFunds, String> {
268        let mut call_for_funds = self
269            .call_for_funds_repository
270            .find_by_id(id)
271            .await?
272            .ok_or_else(|| "Call for funds not found".to_string())?;
273
274        call_for_funds.cancel();
275
276        self.call_for_funds_repository.update(&call_for_funds).await
277    }
278
279    /// Get all overdue calls for funds, scoped to one organization.
280    ///
281    /// `organization_id` est obligatoire (#882) : sans lui, cette méthode
282    /// rendait les arriérés de TOUTE l'instance à qui l'appelait — sans
283    /// organisation, sans ACP, sans immeuble. La signature l'empêche
284    /// désormais d'être appelée sans périmètre.
285    pub async fn get_overdue_calls(
286        &self,
287        organization_id: Uuid,
288    ) -> Result<Vec<CallForFunds>, String> {
289        self.call_for_funds_repository
290            .find_overdue(organization_id)
291            .await
292    }
293
294    /// Delete a call for funds (only if not sent)
295    pub async fn delete_call_for_funds(&self, id: Uuid) -> Result<bool, String> {
296        let call_for_funds = self
297            .call_for_funds_repository
298            .find_by_id(id)
299            .await?
300            .ok_or_else(|| "Call for funds not found".to_string())?;
301
302        // Don't allow deletion if already sent
303        if call_for_funds.status != crate::domain::entities::CallForFundsStatus::Draft {
304            return Err("Cannot delete a call for funds that has been sent".to_string());
305        }
306
307        self.call_for_funds_repository.delete(id).await
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::application::ports::{
315        CallForFundsRepository, OwnerContributionRepository, UnitOwnerRepository,
316    };
317    use crate::domain::entities::{
318        CallForFunds, CallForFundsStatus, ContributionType, OwnerContribution, UnitOwner,
319    };
320    use async_trait::async_trait;
321    use chrono::{Duration, Utc};
322    use rust_decimal_macros::dec;
323    use std::collections::HashMap;
324    use std::sync::{Arc, Mutex};
325    use uuid::Uuid;
326
327    // ── Mock: CallForFundsRepository ──────────────────────────────────
328
329    struct MockCallForFundsRepo {
330        store: Mutex<HashMap<Uuid, CallForFunds>>,
331        overdue: Mutex<Vec<CallForFunds>>,
332    }
333
334    impl MockCallForFundsRepo {
335        fn new() -> Self {
336            Self {
337                store: Mutex::new(HashMap::new()),
338                overdue: Mutex::new(Vec::new()),
339            }
340        }
341
342        fn with_overdue(overdue: Vec<CallForFunds>) -> Self {
343            Self {
344                store: Mutex::new(HashMap::new()),
345                overdue: Mutex::new(overdue),
346            }
347        }
348    }
349
350    #[async_trait]
351    impl CallForFundsRepository for MockCallForFundsRepo {
352        async fn create(&self, cff: &CallForFunds) -> Result<CallForFunds, String> {
353            let mut store = self.store.lock().unwrap();
354            store.insert(cff.id, cff.clone());
355            Ok(cff.clone())
356        }
357
358        async fn find_by_id(&self, id: Uuid) -> Result<Option<CallForFunds>, String> {
359            Ok(self.store.lock().unwrap().get(&id).cloned())
360        }
361
362        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<CallForFunds>, String> {
363            Ok(self
364                .store
365                .lock()
366                .unwrap()
367                .values()
368                .filter(|c| c.building_id == building_id)
369                .cloned()
370                .collect())
371        }
372
373        async fn find_by_organization(
374            &self,
375            organization_id: Uuid,
376        ) -> Result<Vec<CallForFunds>, String> {
377            Ok(self
378                .store
379                .lock()
380                .unwrap()
381                .values()
382                .filter(|c| c.organization_id == organization_id)
383                .cloned()
384                .collect())
385        }
386
387        async fn update(&self, cff: &CallForFunds) -> Result<CallForFunds, String> {
388            let mut store = self.store.lock().unwrap();
389            store.insert(cff.id, cff.clone());
390            Ok(cff.clone())
391        }
392
393        async fn delete(&self, id: Uuid) -> Result<bool, String> {
394            Ok(self.store.lock().unwrap().remove(&id).is_some())
395        }
396
397        async fn find_overdue(&self, organization_id: Uuid) -> Result<Vec<CallForFunds>, String> {
398            Ok(self
399                .overdue
400                .lock()
401                .unwrap()
402                .iter()
403                .filter(|c| c.organization_id == organization_id)
404                .cloned()
405                .collect())
406        }
407    }
408
409    // ── Mock: OwnerContributionRepository ─────────────────────────────
410
411    struct MockOwnerContributionRepo {
412        store: Mutex<Vec<OwnerContribution>>,
413    }
414
415    impl MockOwnerContributionRepo {
416        fn new() -> Self {
417            Self {
418                store: Mutex::new(Vec::new()),
419            }
420        }
421    }
422
423    #[async_trait]
424    impl OwnerContributionRepository for MockOwnerContributionRepo {
425        async fn create(
426            &self,
427            contribution: &OwnerContribution,
428        ) -> Result<OwnerContribution, String> {
429            self.store.lock().unwrap().push(contribution.clone());
430            Ok(contribution.clone())
431        }
432
433        async fn find_by_id(&self, id: Uuid) -> Result<Option<OwnerContribution>, String> {
434            Ok(self
435                .store
436                .lock()
437                .unwrap()
438                .iter()
439                .find(|c| c.id == id)
440                .cloned())
441        }
442
443        async fn find_by_organization(
444            &self,
445            organization_id: Uuid,
446        ) -> Result<Vec<OwnerContribution>, String> {
447            Ok(self
448                .store
449                .lock()
450                .unwrap()
451                .iter()
452                .filter(|c| c.organization_id == organization_id)
453                .cloned()
454                .collect())
455        }
456
457        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<OwnerContribution>, String> {
458            Ok(self
459                .store
460                .lock()
461                .unwrap()
462                .iter()
463                .filter(|c| c.owner_id == owner_id)
464                .cloned()
465                .collect())
466        }
467
468        async fn update(
469            &self,
470            contribution: &OwnerContribution,
471        ) -> Result<OwnerContribution, String> {
472            Ok(contribution.clone())
473        }
474    }
475
476    // ── Mock: UnitOwnerRepository ─────────────────────────────────────
477
478    struct MockUnitOwnerRepo {
479        /// Pourcentages de détention BRUTS (1.0 par propriétaire unique).
480        active_by_building: Mutex<Vec<(Uuid, Uuid, rust_decimal::Decimal)>>,
481        /// Quotes-parts de CHARGE résolues (somme = 1 sur immeuble conforme).
482        ///
483        /// Distinctes de la précédente À DESSEIN : c'est ce qui permet de
484        /// prouver LAQUELLE des deux le cas d'usage consomme. Un mock qui
485        /// renverrait la même chose des deux côtés laisserait passer le défaut
486        /// qui a produit 40 000 € appelés pour 10 000 € dus.
487        quota_shares: Mutex<Vec<(Uuid, Uuid, rust_decimal::Decimal)>>,
488    }
489
490    impl MockUnitOwnerRepo {
491        fn new() -> Self {
492            Self {
493                active_by_building: Mutex::new(Vec::new()),
494                quota_shares: Mutex::new(Vec::new()),
495            }
496        }
497
498        fn with_owners(owners: Vec<(Uuid, Uuid, rust_decimal::Decimal)>) -> Self {
499            Self {
500                active_by_building: Mutex::new(owners.clone()),
501                quota_shares: Mutex::new(owners),
502            }
503        }
504
505        /// Les deux sources divergent : détentions brutes d'un côté,
506        /// quotes-parts de l'autre.
507        fn with_divergent(
508            brut: Vec<(Uuid, Uuid, rust_decimal::Decimal)>,
509            parts: Vec<(Uuid, Uuid, rust_decimal::Decimal)>,
510        ) -> Self {
511            Self {
512                active_by_building: Mutex::new(brut),
513                quota_shares: Mutex::new(parts),
514            }
515        }
516    }
517
518    #[async_trait]
519    impl UnitOwnerRepository for MockUnitOwnerRepo {
520        async fn create(&self, _uo: &UnitOwner) -> Result<UnitOwner, String> {
521            unimplemented!()
522        }
523        async fn find_by_id(&self, _id: Uuid) -> Result<Option<UnitOwner>, String> {
524            unimplemented!()
525        }
526        async fn find_current_owners_by_unit(
527            &self,
528            _unit_id: Uuid,
529        ) -> Result<Vec<UnitOwner>, String> {
530            unimplemented!()
531        }
532        async fn find_current_units_by_owner(
533            &self,
534            _owner_id: Uuid,
535        ) -> Result<Vec<UnitOwner>, String> {
536            unimplemented!()
537        }
538        async fn find_all_owners_by_unit(&self, _unit_id: Uuid) -> Result<Vec<UnitOwner>, String> {
539            unimplemented!()
540        }
541        async fn find_all_units_by_owner(&self, _owner_id: Uuid) -> Result<Vec<UnitOwner>, String> {
542            unimplemented!()
543        }
544        async fn update(&self, _uo: &UnitOwner) -> Result<UnitOwner, String> {
545            unimplemented!()
546        }
547        async fn delete(&self, _id: Uuid) -> Result<(), String> {
548            unimplemented!()
549        }
550        async fn has_active_owners(&self, _unit_id: Uuid) -> Result<bool, String> {
551            unimplemented!()
552        }
553        async fn get_total_ownership_percentage(
554            &self,
555            _unit_id: Uuid,
556        ) -> Result<rust_decimal::Decimal, String> {
557            unimplemented!()
558        }
559        async fn find_active_by_unit_and_owner(
560            &self,
561            _unit_id: Uuid,
562            _owner_id: Uuid,
563        ) -> Result<Option<UnitOwner>, String> {
564            unimplemented!()
565        }
566        async fn find_active_by_building(
567            &self,
568            _building_id: Uuid,
569        ) -> Result<Vec<(Uuid, Uuid, rust_decimal::Decimal)>, String> {
570            Ok(self.active_by_building.lock().unwrap().clone())
571        }
572
573        async fn find_active_quota_shares_by_building(
574            &self,
575            _building_id: Uuid,
576        ) -> Result<Vec<(Uuid, Uuid, rust_decimal::Decimal)>, String> {
577            Ok(self.quota_shares.lock().unwrap().clone())
578        }
579
580        async fn find_voting_holders_by_unit(
581            &self,
582            _unit_id: Uuid,
583        ) -> Result<Vec<crate::domain::entities::LotHolder>, String> {
584            Ok(vec![])
585        }
586
587        async fn is_voting_representative(&self, _unit_owner_id: Uuid) -> Result<bool, String> {
588            Ok(false)
589        }
590
591        async fn set_voting_representative(&self, _unit_owner_id: Uuid) -> Result<(), String> {
592            Ok(())
593        }
594    }
595
596    // ── Dépôt d'immeubles ─────────────────────────────────────────────
597    //
598    // Il n'était pas câblé dans ces tests, parce que la résolution de l'ACP
599    // n'existait pas. Elle est désormais obligatoire à la création : on ne
600    // lance pas un appel de fonds sans savoir qui en est créancier
601    // (ADR-0045).
602
603    struct MockBuildingRepo {
604        acp_id: Uuid,
605    }
606
607    impl MockBuildingRepo {
608        fn rattache_a(acp_id: Uuid) -> Self {
609            Self { acp_id }
610        }
611
612        fn immeuble(&self) -> crate::domain::entities::Building {
613            crate::domain::entities::Building::new(
614                self.acp_id,
615                "Résidence du Parc".to_string(),
616                "12 Rue de la Loi".to_string(),
617                "Brussels".to_string(),
618                "1000".to_string(),
619                "Belgium".to_string(),
620                10,
621                1000,
622                Some(2015),
623            )
624            .expect("immeuble valide")
625        }
626    }
627
628    #[async_trait]
629    impl BuildingRepository for MockBuildingRepo {
630        async fn create(
631            &self,
632            b: &crate::domain::entities::Building,
633        ) -> Result<crate::domain::entities::Building, String> {
634            Ok(b.clone())
635        }
636        async fn find_by_id(
637            &self,
638            _id: Uuid,
639        ) -> Result<Option<crate::domain::entities::Building>, String> {
640            Ok(Some(self.immeuble()))
641        }
642        async fn find_all(&self) -> Result<Vec<crate::domain::entities::Building>, String> {
643            Ok(vec![self.immeuble()])
644        }
645        async fn find_all_paginated(
646            &self,
647            _p: &crate::application::dto::PageRequest,
648            _f: &crate::application::dto::BuildingFilters,
649        ) -> Result<(Vec<crate::domain::entities::Building>, i64), String> {
650            Ok((vec![self.immeuble()], 1))
651        }
652        async fn update(
653            &self,
654            b: &crate::domain::entities::Building,
655        ) -> Result<crate::domain::entities::Building, String> {
656            Ok(b.clone())
657        }
658        async fn delete(&self, _id: Uuid) -> Result<bool, String> {
659            Ok(true)
660        }
661        async fn find_by_slug(
662            &self,
663            _slug: &str,
664        ) -> Result<Option<crate::domain::entities::Building>, String> {
665            Ok(Some(self.immeuble()))
666        }
667        async fn find_by_id_with_metrics(
668            &self,
669            _id: Uuid,
670        ) -> Result<
671            Option<(
672                crate::domain::entities::Building,
673                crate::domain::entities::BuildingMetrics,
674            )>,
675            String,
676        > {
677            Ok(None)
678        }
679    }
680
681    // ── Helpers ───────────────────────────────────────────────────────
682
683    fn make_use_cases(
684        cff_repo: Arc<dyn CallForFundsRepository>,
685        contrib_repo: Arc<dyn OwnerContributionRepository>,
686        uo_repo: Arc<dyn UnitOwnerRepository>,
687    ) -> CallForFundsUseCases {
688        make_use_cases_pour_lacp(cff_repo, contrib_repo, uo_repo, Uuid::new_v4())
689    }
690
691    /// Les mêmes use-cases, mais en nommant l'ACP de l'immeuble.
692    fn make_use_cases_pour_lacp(
693        cff_repo: Arc<dyn CallForFundsRepository>,
694        contrib_repo: Arc<dyn OwnerContributionRepository>,
695        uo_repo: Arc<dyn UnitOwnerRepository>,
696        acp_id: Uuid,
697    ) -> CallForFundsUseCases {
698        let mut uc = CallForFundsUseCases::new(cff_repo, contrib_repo, uo_repo);
699        uc.building_repository = Some(Arc::new(MockBuildingRepo::rattache_a(acp_id)));
700        uc
701    }
702
703    fn sample_dates() -> (chrono::DateTime<Utc>, chrono::DateTime<Utc>) {
704        let call_date = Utc::now();
705        let due_date = call_date + Duration::days(30);
706        (call_date, due_date)
707    }
708
709    // ── 1. Create ─────────────────────────────────────────────────────
710
711    /// Art. 3.86 § 3 et ADR-0045 : l'ACP est créancière des fonds appelés.
712    ///
713    /// L'ACP se déduit de l'immeuble, jamais de l'appelant. Le lien
714    /// immeuble → ACP est fixé par l'acte de base ; un cabinet ne peut donc
715    /// pas appeler des fonds au nom d'une ACP qu'il désigne lui-même.
716    #[tokio::test]
717    async fn test_lappel_de_fonds_a_pour_creanciere_lacp_de_limmeuble() {
718        let acp_creanciere = Uuid::new_v4();
719        let cabinet_emetteur = Uuid::new_v4();
720
721        let uc = make_use_cases_pour_lacp(
722            Arc::new(MockCallForFundsRepo::new()),
723            Arc::new(MockOwnerContributionRepo::new()),
724            Arc::new(MockUnitOwnerRepo::new()),
725            acp_creanciere,
726        );
727        let (call_date, due_date) = sample_dates();
728
729        let appel = uc
730            .create_call_for_funds(
731                cabinet_emetteur,
732                Uuid::new_v4(),
733                "Provision T1 2026".to_string(),
734                "Charges ordinaires".to_string(),
735                dec!(10000),
736                ContributionType::Regular,
737                call_date,
738                due_date,
739                None,
740                None,
741                rust_decimal::Decimal::ZERO, // part fonds de réserve
742            )
743            .await
744            .expect("création valide");
745
746        assert_eq!(
747            appel.acp_id, acp_creanciere,
748            "les fonds sont appelés au nom de l'ACP de l'immeuble"
749        );
750        assert_eq!(
751            appel.organization_id, cabinet_emetteur,
752            "le syndic reste tracé comme émetteur, sans devenir créancier"
753        );
754    }
755
756    /// Sans dépôt d'immeubles, on ne sait pas au nom de qui l'argent est
757    /// appelé. On refuse, plutôt que de retomber sur l'identifiant du syndic
758    /// — c'est exactement la confusion que l'ADR-0045 supprime.
759    #[tokio::test]
760    async fn test_pas_dappel_de_fonds_sans_creanciere_resoluble() {
761        let uc = CallForFundsUseCases::new(
762            Arc::new(MockCallForFundsRepo::new()),
763            Arc::new(MockOwnerContributionRepo::new()),
764            Arc::new(MockUnitOwnerRepo::new()),
765        );
766        let (call_date, due_date) = sample_dates();
767
768        let resultat = uc
769            .create_call_for_funds(
770                Uuid::new_v4(),
771                Uuid::new_v4(),
772                "Provision".to_string(),
773                "Charges".to_string(),
774                dec!(10000),
775                ContributionType::Regular,
776                call_date,
777                due_date,
778                None,
779                None,
780                rust_decimal::Decimal::ZERO, // part fonds de réserve
781            )
782            .await;
783
784        let erreur = resultat.expect_err("doit refuser");
785        assert!(
786            erreur.contains("créancière"),
787            "le refus doit nommer ce qui manque, pas échouer obscurément : {erreur}"
788        );
789    }
790
791    #[tokio::test]
792    async fn test_create_call_for_funds_success() {
793        let cff_repo = Arc::new(MockCallForFundsRepo::new());
794        let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
795        let uo_repo = Arc::new(MockUnitOwnerRepo::new());
796        let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
797
798        let (call_date, due_date) = sample_dates();
799        let org_id = Uuid::new_v4();
800        let building_id = Uuid::new_v4();
801
802        let result = uc
803            .create_call_for_funds(
804                org_id,
805                building_id,
806                "Appel Q1".to_string(),
807                "Charges courantes".to_string(),
808                rust_decimal_macros::dec!(10_000),
809                ContributionType::Regular,
810                call_date,
811                due_date,
812                Some("7000".to_string()),
813                Some(Uuid::new_v4()),
814                rust_decimal::Decimal::ZERO, // part fonds de réserve
815            )
816            .await;
817
818        assert!(result.is_ok());
819        let cff = result.unwrap();
820        assert_eq!(cff.total_amount, rust_decimal_macros::dec!(10_000));
821        assert_eq!(cff.status, CallForFundsStatus::Draft);
822        assert_eq!(cff.organization_id, org_id);
823        assert_eq!(cff.building_id, building_id);
824        // Verify it was persisted in the mock store
825        assert!(cff_repo.store.lock().unwrap().contains_key(&cff.id));
826    }
827
828    // ── 2. Send (generates contributions) ─────────────────────────────
829
830    #[tokio::test]
831    async fn test_send_call_for_funds_generates_contributions() {
832        let cff_repo = Arc::new(MockCallForFundsRepo::new());
833        let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
834
835        let unit1 = Uuid::new_v4();
836        let unit2 = Uuid::new_v4();
837        let owner1 = Uuid::new_v4();
838        let owner2 = Uuid::new_v4();
839        let uo_repo = Arc::new(MockUnitOwnerRepo::with_owners(vec![
840            (unit1, owner1, rust_decimal_macros::dec!(0.60)),
841            (unit2, owner2, rust_decimal_macros::dec!(0.40)),
842        ]));
843
844        let uc = make_use_cases(cff_repo.clone(), contrib_repo.clone(), uo_repo);
845
846        let (call_date, due_date) = sample_dates();
847
848        let cff = uc
849            .create_call_for_funds(
850                Uuid::new_v4(),
851                Uuid::new_v4(),
852                "Appel Q2".to_string(),
853                "Charges extraordinaires".to_string(),
854                rust_decimal_macros::dec!(5_000),
855                ContributionType::Extraordinary,
856                call_date,
857                due_date,
858                None,
859                None,
860                rust_decimal::Decimal::ZERO, // part fonds de réserve
861            )
862            .await
863            .unwrap();
864
865        // Send — should generate individual contributions
866        let result = uc.send_call_for_funds(cff.id).await;
867        assert!(result.is_ok());
868
869        let sent = result.unwrap();
870        assert_eq!(sent.status, CallForFundsStatus::Sent);
871        assert!(sent.sent_date.is_some());
872
873        // Verify two contributions were created with correct amounts
874        let contributions = contrib_repo.store.lock().unwrap();
875        assert_eq!(contributions.len(), 2);
876
877        let mut amounts: Vec<rust_decimal::Decimal> =
878            contributions.iter().map(|c| c.amount).collect();
879        amounts.sort();
880        // 40% of 5000 = 2000, 60% of 5000 = 3000
881        assert_eq!(amounts[0], rust_decimal_macros::dec!(2_000));
882        assert_eq!(amounts[1], rust_decimal_macros::dec!(3_000));
883    }
884
885    /// Non-régression — l'appel de fonds lit les QUOTES-PARTS DE CHARGE, pas
886    /// les pourcentages de détention.
887    ///
888    /// Le défaut, mesuré en production le 2026-09-02 sur un immeuble conforme
889    /// à 4 lots (200/200/300/300 millièmes, un propriétaire unique par lot) :
890    /// un appel de 10 000 € générait QUATRE quotes-parts de 10 000 €, soit
891    /// 40 000 € appelés, chacune étiquetée « Quote-part: 100 % ». Les
892    /// tantièmes de l'acte de base étaient purement ignorés, et la route
893    /// répondait 200.
894    ///
895    /// Cause : `find_active_by_building` renvoie `ownership_percentage` brut,
896    /// qui vaut 1.0 pour tout propriétaire unique de son lot. Multiplié par le
897    /// montant total, il appelle l'intégralité à chacun. La formule légale
898    /// (Art. 3.84) — `(quota / total_tantiemes) × ownership_percentage` —
899    /// existait dans `ChargeDistribution::resolve_owner_quota`, testée, et
900    /// n'avait AUCUN appelant en production.
901    ///
902    /// Le test précédent ne pouvait pas le voir : ses fixtures posent
903    /// directement 0.60/0.40, c'est-à-dire des quotes-parts déjà résolues. Il
904    /// encodait donc le bon contrat pendant que le dépôt le violait. Ici les
905    /// deux sources DIVERGENT, ce qui rend la confusion détectable.
906    #[tokio::test]
907    async fn test_appel_de_fonds_utilise_les_quotes_parts_pas_les_detentions() {
908        let cff_repo = Arc::new(MockCallForFundsRepo::new());
909        let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
910
911        let lots: Vec<Uuid> = (0..4).map(|_| Uuid::new_v4()).collect();
912        let proprios: Vec<Uuid> = (0..4).map(|_| Uuid::new_v4()).collect();
913
914        // Immeuble conforme : 200/200/300/300 millièmes sur 1000.
915        // Détention brute : 100 % de son lot pour chacun → somme = 4.0.
916        let brut: Vec<_> = lots
917            .iter()
918            .zip(&proprios)
919            .map(|(u, o)| (*u, *o, rust_decimal_macros::dec!(1.0)))
920            .collect();
921        // Quotes-parts de charge : 0,2 / 0,2 / 0,3 / 0,3 → somme = 1.0.
922        let parts = vec![
923            (lots[0], proprios[0], rust_decimal_macros::dec!(0.2)),
924            (lots[1], proprios[1], rust_decimal_macros::dec!(0.2)),
925            (lots[2], proprios[2], rust_decimal_macros::dec!(0.3)),
926            (lots[3], proprios[3], rust_decimal_macros::dec!(0.3)),
927        ];
928
929        let uo_repo = Arc::new(MockUnitOwnerRepo::with_divergent(brut, parts));
930        let uc = make_use_cases(cff_repo.clone(), contrib_repo.clone(), uo_repo);
931        let (call_date, due_date) = sample_dates();
932
933        let cff = uc
934            .create_call_for_funds(
935                Uuid::new_v4(),
936                Uuid::new_v4(),
937                "Charges Q3".to_string(),
938                "Non-régression répartition".to_string(),
939                rust_decimal_macros::dec!(10_000),
940                ContributionType::Regular,
941                call_date,
942                due_date,
943                None,
944                None,
945                rust_decimal::Decimal::ZERO, // part fonds de réserve
946            )
947            .await
948            .unwrap();
949
950        uc.send_call_for_funds(cff.id).await.expect("envoi accepté");
951
952        let contributions = contrib_repo.store.lock().unwrap();
953        assert_eq!(contributions.len(), 4, "une quote-part par lot");
954
955        let mut montants: Vec<rust_decimal::Decimal> =
956            contributions.iter().map(|c| c.amount).collect();
957        montants.sort();
958        assert_eq!(
959            montants,
960            vec![
961                rust_decimal_macros::dec!(2_000),
962                rust_decimal_macros::dec!(2_000),
963                rust_decimal_macros::dec!(3_000),
964                rust_decimal_macros::dec!(3_000),
965            ],
966            "chaque copropriétaire doit être appelé au prorata de ses tantièmes"
967        );
968
969        // L'invariant qui compte pour le syndic : on n'appelle jamais plus que
970        // ce qui est dû. Avant correction, cette somme valait 40 000.
971        let total: rust_decimal::Decimal = montants.iter().sum();
972        assert_eq!(
973            total,
974            rust_decimal_macros::dec!(10_000),
975            "la somme appelée doit égaler le montant de l'appel de fonds"
976        );
977    }
978
979    // ── 3. Cancel ─────────────────────────────────────────────────────
980
981    #[tokio::test]
982    async fn test_cancel_call_for_funds() {
983        let cff_repo = Arc::new(MockCallForFundsRepo::new());
984        let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
985        let uo_repo = Arc::new(MockUnitOwnerRepo::new());
986        let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
987
988        let (call_date, due_date) = sample_dates();
989
990        let cff = uc
991            .create_call_for_funds(
992                Uuid::new_v4(),
993                Uuid::new_v4(),
994                "Appel annulable".to_string(),
995                "Description".to_string(),
996                rust_decimal_macros::dec!(1_000),
997                ContributionType::Regular,
998                call_date,
999                due_date,
1000                None,
1001                None,
1002                rust_decimal::Decimal::ZERO, // part fonds de réserve
1003            )
1004            .await
1005            .unwrap();
1006
1007        let result = uc.cancel_call_for_funds(cff.id).await;
1008        assert!(result.is_ok());
1009        assert_eq!(result.unwrap().status, CallForFundsStatus::Cancelled);
1010    }
1011
1012    // ── 4. Delete (draft only, rejects sent) ──────────────────────────
1013
1014    #[tokio::test]
1015    async fn test_delete_call_for_funds_draft_succeeds() {
1016        let cff_repo = Arc::new(MockCallForFundsRepo::new());
1017        let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
1018        let uo_repo = Arc::new(MockUnitOwnerRepo::new());
1019        let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
1020
1021        let (call_date, due_date) = sample_dates();
1022
1023        let cff = uc
1024            .create_call_for_funds(
1025                Uuid::new_v4(),
1026                Uuid::new_v4(),
1027                "Supprimable".to_string(),
1028                "Description".to_string(),
1029                rust_decimal_macros::dec!(500),
1030                ContributionType::Advance,
1031                call_date,
1032                due_date,
1033                None,
1034                None,
1035                rust_decimal::Decimal::ZERO, // part fonds de réserve
1036            )
1037            .await
1038            .unwrap();
1039
1040        let result = uc.delete_call_for_funds(cff.id).await;
1041        assert!(result.is_ok());
1042        assert!(result.unwrap());
1043        assert!(!cff_repo.store.lock().unwrap().contains_key(&cff.id));
1044    }
1045
1046    #[tokio::test]
1047    async fn test_delete_call_for_funds_rejects_non_draft() {
1048        let cff_repo = Arc::new(MockCallForFundsRepo::new());
1049        let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
1050        let uo_repo = Arc::new(MockUnitOwnerRepo::with_owners(vec![(
1051            Uuid::new_v4(),
1052            Uuid::new_v4(),
1053            rust_decimal_macros::dec!(1),
1054        )]));
1055        let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
1056
1057        let (call_date, due_date) = sample_dates();
1058
1059        let cff = uc
1060            .create_call_for_funds(
1061                Uuid::new_v4(),
1062                Uuid::new_v4(),
1063                "Sent call".to_string(),
1064                "Description".to_string(),
1065                rust_decimal_macros::dec!(500),
1066                ContributionType::Regular,
1067                call_date,
1068                due_date,
1069                None,
1070                None,
1071                rust_decimal::Decimal::ZERO, // part fonds de réserve
1072            )
1073            .await
1074            .unwrap();
1075
1076        // Send so it is no longer Draft
1077        uc.send_call_for_funds(cff.id).await.unwrap();
1078
1079        let result = uc.delete_call_for_funds(cff.id).await;
1080        assert!(result.is_err());
1081        assert!(result
1082            .unwrap_err()
1083            .contains("Cannot delete a call for funds that has been sent"));
1084    }
1085
1086    // ── 5. Find overdue (#882) ──────────────────────────────────────────
1087
1088    fn cff_en_retard(organization_id: Uuid, titre: &str) -> CallForFunds {
1089        let call_date = Utc::now() - Duration::days(60);
1090        let due_date = Utc::now() - Duration::days(30);
1091        CallForFunds::new(
1092            Uuid::new_v4(), // acp_id
1093            organization_id,
1094            Uuid::new_v4(), // building_id
1095            titre.to_string(),
1096            "Past due".to_string(),
1097            rust_decimal_macros::dec!(2_000),
1098            ContributionType::Regular,
1099            call_date,
1100            due_date,
1101            None,
1102            rust_decimal::Decimal::ZERO, // part fonds de réserve
1103        )
1104        .unwrap()
1105    }
1106
1107    /// @happy — le syndic lit les arriérés de SA propre organisation.
1108    #[tokio::test]
1109    async fn happy_get_overdue_calls_rend_les_arrieres_de_lorganisation_appelante() {
1110        let organisation_a = Uuid::new_v4();
1111        let overdue_cff = cff_en_retard(organisation_a, "Overdue call");
1112
1113        let cff_repo = Arc::new(MockCallForFundsRepo::with_overdue(
1114            vec![overdue_cff.clone()],
1115        ));
1116        let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
1117        let uo_repo = Arc::new(MockUnitOwnerRepo::new());
1118        let uc = make_use_cases(cff_repo, contrib_repo, uo_repo);
1119
1120        let result = uc.get_overdue_calls(organisation_a).await;
1121        assert!(result.is_ok());
1122        let overdue = result.unwrap();
1123        assert_eq!(overdue.len(), 1);
1124        assert_eq!(overdue[0].title, "Overdue call");
1125    }
1126
1127    /// @security / @negative — les arriérés d'une AUTRE organisation ne
1128    /// paraissent jamais dans la réponse. Avant #882, `get_overdue_calls`
1129    /// ne prenait aucun paramètre : cette même liste contenait les arriérés
1130    /// des DEUX organisations, quel que soit l'appelant.
1131    #[tokio::test]
1132    async fn security_get_overdue_calls_ne_rend_jamais_larrierage_dune_autre_organisation() {
1133        let organisation_a = Uuid::new_v4();
1134        let organisation_b = Uuid::new_v4();
1135        let arriere_a = cff_en_retard(organisation_a, "Arriéré cabinet A");
1136        let arriere_b = cff_en_retard(organisation_b, "Arriéré cabinet B");
1137
1138        let cff_repo = Arc::new(MockCallForFundsRepo::with_overdue(vec![
1139            arriere_a.clone(),
1140            arriere_b.clone(),
1141        ]));
1142        let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
1143        let uo_repo = Arc::new(MockUnitOwnerRepo::new());
1144        let uc = make_use_cases(cff_repo, contrib_repo, uo_repo);
1145
1146        let result = uc.get_overdue_calls(organisation_a).await.unwrap();
1147
1148        assert_eq!(result.len(), 1);
1149        assert!(
1150            result.iter().all(|c| c.organization_id == organisation_a),
1151            "la réponse au cabinet A contient un appel de fonds d'une autre organisation"
1152        );
1153        assert!(
1154            !result.iter().any(|c| c.id == arriere_b.id),
1155            "l'arriéré du cabinet B est visible depuis le cabinet A"
1156        );
1157    }
1158
1159    /// @edge — la méthode ne compile plus sans organisation : il n'existe
1160    /// aucun moyen de l'appeler sans périmètre (la garantie que #882 demande
1161    /// à la SIGNATURE, pas seulement au gestionnaire HTTP).
1162    #[tokio::test]
1163    async fn edge_get_overdue_calls_exige_une_organisation_a_lappel() {
1164        let organisation = Uuid::new_v4();
1165        let cff_repo = Arc::new(MockCallForFundsRepo::with_overdue(Vec::new()));
1166        let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
1167        let uo_repo = Arc::new(MockUnitOwnerRepo::new());
1168        let uc = make_use_cases(cff_repo, contrib_repo, uo_repo);
1169
1170        // La seule forme d'appel possible prend `organization_id` : le
1171        // vérifier ici, c'est vérifier que le refactor de signature a bien eu
1172        // lieu (un appel `uc.get_overdue_calls()` sans argument ne
1173        // compilerait pas).
1174        let result = uc.get_overdue_calls(organisation).await;
1175        assert!(result.is_ok());
1176        assert!(result.unwrap().is_empty());
1177    }
1178
1179    // ── 6. List by building ───────────────────────────────────────────
1180
1181    #[tokio::test]
1182    async fn test_list_by_building() {
1183        let cff_repo = Arc::new(MockCallForFundsRepo::new());
1184        let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
1185        let uo_repo = Arc::new(MockUnitOwnerRepo::new());
1186        let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
1187
1188        let building_id = Uuid::new_v4();
1189        let other_building = Uuid::new_v4();
1190        let org_id = Uuid::new_v4();
1191        let (call_date, due_date) = sample_dates();
1192
1193        // Two calls for our building
1194        uc.create_call_for_funds(
1195            org_id,
1196            building_id,
1197            "Appel 1".to_string(),
1198            "Desc 1".to_string(),
1199            rust_decimal_macros::dec!(1_000),
1200            ContributionType::Regular,
1201            call_date,
1202            due_date,
1203            None,
1204            None,
1205            rust_decimal::Decimal::ZERO, // part fonds de réserve
1206        )
1207        .await
1208        .unwrap();
1209
1210        uc.create_call_for_funds(
1211            org_id,
1212            building_id,
1213            "Appel 2".to_string(),
1214            "Desc 2".to_string(),
1215            rust_decimal_macros::dec!(2_000),
1216            ContributionType::Extraordinary,
1217            call_date,
1218            due_date,
1219            None,
1220            None,
1221            rust_decimal::Decimal::ZERO, // part fonds de réserve
1222        )
1223        .await
1224        .unwrap();
1225
1226        // One call for another building (noise)
1227        uc.create_call_for_funds(
1228            org_id,
1229            other_building,
1230            "Autre appel".to_string(),
1231            "Autre desc".to_string(),
1232            rust_decimal_macros::dec!(500),
1233            ContributionType::Regular,
1234            call_date,
1235            due_date,
1236            None,
1237            None,
1238            rust_decimal::Decimal::ZERO, // part fonds de réserve
1239        )
1240        .await
1241        .unwrap();
1242
1243        let result = uc.list_by_building(building_id).await;
1244        assert!(result.is_ok());
1245        let list = result.unwrap();
1246        assert_eq!(list.len(), 2);
1247        assert!(list.iter().all(|c| c.building_id == building_id));
1248    }
1249}