Skip to main content

koprogo_api/application/use_cases/
charge_distribution_use_cases.rs

1use crate::application::dto::ChargeDistributionResponseDto;
2use crate::application::ports::{
3    AcpRepository, BuildingRepository, ChargeDistributionRepository, ExpenseRepository,
4    UnitOwnerRepository,
5};
6use crate::domain::entities::{ApprovalStatus, ChargeDistribution};
7use rust_decimal::Decimal;
8use std::sync::Arc;
9use uuid::Uuid;
10
11pub struct ChargeDistributionUseCases {
12    distribution_repository: Arc<dyn ChargeDistributionRepository>,
13    expense_repository: Arc<dyn ExpenseRepository>,
14    unit_owner_repository: Arc<dyn UnitOwnerRepository>,
15    /// Track H Story H7 — validate-before-compute ACP-level (FR-CL1). Optional.
16    /// Quand présents (wiring `main.rs`), le pre-check `Acp::assert_conformant?`
17    /// s'exécute avant la répartition des charges (calcul interdit sur ACP non
18    /// conforme — répartition = parties communes, Art. 3.86).
19    building_repository: Option<Arc<dyn BuildingRepository>>,
20    acp_repository: Option<Arc<dyn AcpRepository>>,
21}
22
23impl ChargeDistributionUseCases {
24    pub fn new(
25        distribution_repository: Arc<dyn ChargeDistributionRepository>,
26        expense_repository: Arc<dyn ExpenseRepository>,
27        unit_owner_repository: Arc<dyn UnitOwnerRepository>,
28    ) -> Self {
29        Self {
30            distribution_repository,
31            expense_repository,
32            unit_owner_repository,
33            building_repository: None,
34            acp_repository: None,
35        }
36    }
37
38    /// Track H Story H7 — wiring complet (validate-before-compute ACP-level).
39    pub fn with_full_wiring(
40        distribution_repository: Arc<dyn ChargeDistributionRepository>,
41        expense_repository: Arc<dyn ExpenseRepository>,
42        unit_owner_repository: Arc<dyn UnitOwnerRepository>,
43        building_repository: Arc<dyn BuildingRepository>,
44        acp_repository: Arc<dyn AcpRepository>,
45    ) -> Self {
46        Self {
47            distribution_repository,
48            expense_repository,
49            unit_owner_repository,
50            building_repository: Some(building_repository),
51            acp_repository: Some(acp_repository),
52        }
53    }
54
55    /// Track H Story H7 — pre-check conformité copropriété (ACP). Résout
56    /// `building.acp_id` puis agrège les métriques de tous les blocs.
57    async fn assert_acp_conformant(&self, building_id: Uuid) -> Result<(), String> {
58        let (Some(building_repo), Some(acp_repo)) =
59            (&self.building_repository, &self.acp_repository)
60        else {
61            return Ok(());
62        };
63        let building = building_repo
64            .find_by_id(building_id)
65            .await?
66            .ok_or_else(|| "Building not found".to_string())?;
67        let (acp, metrics) = acp_repo
68            .find_by_id_with_metrics(building.acp_id)
69            .await
70            .map_err(|e| e.to_string())?
71            .ok_or_else(|| "ACP not found".to_string())?;
72        acp.assert_conformant(&metrics)?; // bridge String (H5)
73        Ok(())
74    }
75
76    /// Calculer et sauvegarder la répartition des charges pour une facture approuvée
77    pub async fn calculate_and_save_distribution(
78        &self,
79        expense_id: Uuid,
80    ) -> Result<Vec<ChargeDistributionResponseDto>, String> {
81        // 1. Récupérer la facture
82        let expense = self
83            .expense_repository
84            .find_by_id(expense_id)
85            .await?
86            .ok_or_else(|| "Expense/Invoice not found".to_string())?;
87
88        // 2. Vérifier que la facture est approuvée
89        if expense.approval_status != ApprovalStatus::Approved {
90            return Err(format!(
91                "Cannot calculate distribution for non-approved invoice (status: {:?})",
92                expense.approval_status
93            ));
94        }
95
96        // 2bis. Track H Story H2 — validate-before-compute gate (Art. 3.85 CC).
97        // L'immeuble doit être conforme pour répartir : sinon delta sur quotas
98        // → distribution erronée. Pre-check bloque AVANT le calcul.
99        self.assert_acp_conformant(expense.building_id).await?;
100
101        // 3. Récupérer le montant TTC à répartir
102        let total_amount = expense.amount_incl_vat.unwrap_or(expense.amount);
103
104        // 4. Quotes-parts de CHARGE des détenteurs actifs (Art. 3.84).
105        //
106        // `find_active_by_building` renvoyait le pourcentage de détention brut,
107        // dont la somme vaut le NOMBRE DE LOTS. La répartition échouait donc
108        // sur tout immeuble de plus d'un lot, avec « Total quota percentage
109        // exceeds 100% (got: 400.00000) » sur un immeuble pourtant conforme.
110        let unit_ownerships = self
111            .unit_owner_repository
112            .find_active_quota_shares_by_building(expense.building_id)
113            .await?;
114
115        if unit_ownerships.is_empty() {
116            return Err("No active unit-owner relationships found for this building".to_string());
117        }
118
119        // Relevé AVANT le calcul, qui consomme `unit_ownerships` : ce sont les
120        // deux nombres qui disent pourquoi une répartition ne couvre pas.
121        let lots_detenus: std::collections::HashSet<Uuid> = unit_ownerships
122            .iter()
123            .map(|(unit_id, _, _)| *unit_id)
124            .collect();
125        let somme_des_parts: Decimal = unit_ownerships.iter().map(|(_, _, part)| *part).sum();
126
127        // 5. Calculer les distributions
128        let distributions =
129            ChargeDistribution::calculate_distributions(expense_id, total_amount, unit_ownerships)?;
130
131        // 5bis. La somme répartie doit égaler la charge, au centime près.
132        //
133        // `verify_distribution` existait, testée, et n'avait AUCUN appelant :
134        // troisième garde-fou dormant de ce module, après `resolve_owner_quota`
135        // et `expense_has_journal_entries`.
136        //
137        // Ce qu'il rattrape le plus souvent : des lots SANS DÉTENTEUR ACTIF.
138        // `find_active_quota_shares_by_building` ne renvoie que les lots
139        // détenus ; la quote-part des autres n'est réclamée à personne et la
140        // somme tombe sous 1. Un immeuble de douze lots dont un seul est
141        // rattaché répartit un douzième de la charge. Le message le dit
142        // maintenant, avec la somme des parts et le nombre de lots — il
143        // n'accusait auparavant que la seconde cause, envoyant vérifier des
144        // tantièmes parfaitement corrects.
145        //
146        // Ce qu'il rattrape aussi : la base de tantièmes est stockée
147        // DEUX FOIS — `acps.total_tantiemes`, qui fonde le contrôle de
148        // conformité, et `buildings.total_tantiemes`, qui fonde le calcul des
149        // quotes-parts. Les deux sont saisissables séparément par un
150        // administrateur et rien ne les tient ensemble. Une divergence produit
151        // une répartition qui ne couvre pas la charge, ou qui la dépasse, sans
152        // qu'aucune étape ne s'en aperçoive.
153        //
154        // Répartir 2 420 € en 2 418 € ou en 2 422 € n'est pas un détail
155        // d'affichage : ce sont les montants réclamés aux copropriétaires.
156        if !ChargeDistribution::verify_distribution(&distributions, total_amount) {
157            let reparti = ChargeDistribution::total_distributed(&distributions);
158            return Err(format!(
159                "Distribution does not cover the charge: {reparti} distributed for \
160                 {total_amount} due (delta {delta}). La somme des quotes-parts \
161                 actives vaut {somme_des_parts} pour 1 attendu, répartie sur \
162                 {nb_lots} lot(s) détenu(s).\n\
163                 \n\
164                 Deux causes possibles, dans cet ordre de fréquence :\n\
165                 1. des lots sans détenteur actif — leur quote-part n'est alors \
166                 réclamée à personne, et la somme des parts tombe sous 1 ;\n\
167                 2. une divergence entre `acps.total_tantiemes` et \
168                 `buildings.total_tantiemes`, qui sont saisis séparément et que \
169                 rien ne tient ensemble.",
170                reparti = reparti,
171                total_amount = total_amount,
172                delta = reparti - total_amount,
173                somme_des_parts = somme_des_parts,
174                nb_lots = lots_detenus.len(),
175            ));
176        }
177
178        // 6. Sauvegarder en masse
179        let saved_distributions = self
180            .distribution_repository
181            .create_bulk(&distributions)
182            .await?;
183
184        // 7. Convertir en DTOs
185        Ok(saved_distributions
186            .iter()
187            .map(|d| self.to_response_dto(d))
188            .collect())
189    }
190
191    /// Récupérer la répartition d'une facture
192    pub async fn get_distribution_by_expense(
193        &self,
194        expense_id: Uuid,
195    ) -> Result<Vec<ChargeDistributionResponseDto>, String> {
196        let distributions = self
197            .distribution_repository
198            .find_by_expense(expense_id)
199            .await?;
200        Ok(distributions
201            .iter()
202            .map(|d| self.to_response_dto(d))
203            .collect())
204    }
205
206    /// Récupérer toutes les distributions pour un propriétaire
207    pub async fn get_distributions_by_owner(
208        &self,
209        owner_id: Uuid,
210    ) -> Result<Vec<ChargeDistributionResponseDto>, String> {
211        let distributions = self.distribution_repository.find_by_owner(owner_id).await?;
212        Ok(distributions
213            .iter()
214            .map(|d| self.to_response_dto(d))
215            .collect())
216    }
217
218    /// Récupérer le montant total dû par un propriétaire
219    pub async fn get_total_due_by_owner(&self, owner_id: Uuid) -> Result<Decimal, String> {
220        self.distribution_repository
221            .get_total_due_by_owner(owner_id)
222            .await
223    }
224
225    /// Supprimer les distributions d'une facture (si annulée)
226    pub async fn delete_distribution_by_expense(&self, expense_id: Uuid) -> Result<(), String> {
227        self.distribution_repository
228            .delete_by_expense(expense_id)
229            .await
230    }
231
232    fn to_response_dto(&self, distribution: &ChargeDistribution) -> ChargeDistributionResponseDto {
233        ChargeDistributionResponseDto {
234            id: distribution.id.to_string(),
235            expense_id: distribution.expense_id.to_string(),
236            unit_id: distribution.unit_id.to_string(),
237            owner_id: distribution.owner_id.to_string(),
238            quota_percentage: distribution.quota_percentage,
239            amount_due: distribution.amount_due,
240            distribution_criteria: distribution.distribution_criteria.to_string(),
241            created_at: distribution.created_at.to_rfc3339(),
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::application::dto::{ExpenseFilters, PageRequest};
250    use crate::application::ports::{
251        ChargeDistributionRepository, ExpenseRepository, UnitOwnerRepository,
252    };
253    use crate::domain::entities::{
254        ApprovalStatus, ChargeDistribution, Expense, ExpenseCategory, PaymentStatus, UnitOwner,
255    };
256    use async_trait::async_trait;
257    use chrono::Utc;
258    use rust_decimal_macros::dec;
259    use std::collections::HashMap;
260    use std::sync::Mutex;
261
262    // ========== Mock ChargeDistributionRepository ==========
263
264    struct MockChargeDistributionRepository {
265        distributions: Mutex<HashMap<Uuid, ChargeDistribution>>,
266    }
267
268    impl MockChargeDistributionRepository {
269        fn new() -> Self {
270            Self {
271                distributions: Mutex::new(HashMap::new()),
272            }
273        }
274    }
275
276    #[async_trait]
277    impl ChargeDistributionRepository for MockChargeDistributionRepository {
278        async fn create(
279            &self,
280            distribution: &ChargeDistribution,
281        ) -> Result<ChargeDistribution, String> {
282            let mut distributions = self.distributions.lock().unwrap();
283            distributions.insert(distribution.id, distribution.clone());
284            Ok(distribution.clone())
285        }
286
287        async fn create_bulk(
288            &self,
289            distributions: &[ChargeDistribution],
290        ) -> Result<Vec<ChargeDistribution>, String> {
291            let mut store = self.distributions.lock().unwrap();
292            let mut result = Vec::new();
293            for d in distributions {
294                store.insert(d.id, d.clone());
295                result.push(d.clone());
296            }
297            Ok(result)
298        }
299
300        async fn find_by_id(&self, id: Uuid) -> Result<Option<ChargeDistribution>, String> {
301            let distributions = self.distributions.lock().unwrap();
302            Ok(distributions.get(&id).cloned())
303        }
304
305        async fn find_by_expense(
306            &self,
307            expense_id: Uuid,
308        ) -> Result<Vec<ChargeDistribution>, String> {
309            let distributions = self.distributions.lock().unwrap();
310            Ok(distributions
311                .values()
312                .filter(|d| d.expense_id == expense_id)
313                .cloned()
314                .collect())
315        }
316
317        async fn find_by_unit(&self, unit_id: Uuid) -> Result<Vec<ChargeDistribution>, String> {
318            let distributions = self.distributions.lock().unwrap();
319            Ok(distributions
320                .values()
321                .filter(|d| d.unit_id == unit_id)
322                .cloned()
323                .collect())
324        }
325
326        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<ChargeDistribution>, String> {
327            let distributions = self.distributions.lock().unwrap();
328            Ok(distributions
329                .values()
330                .filter(|d| d.owner_id == owner_id)
331                .cloned()
332                .collect())
333        }
334
335        async fn delete_by_expense(&self, expense_id: Uuid) -> Result<(), String> {
336            let mut distributions = self.distributions.lock().unwrap();
337            distributions.retain(|_, d| d.expense_id != expense_id);
338            Ok(())
339        }
340
341        async fn get_total_due_by_owner(&self, owner_id: Uuid) -> Result<Decimal, String> {
342            let distributions = self.distributions.lock().unwrap();
343            let total = distributions
344                .values()
345                .filter(|d| d.owner_id == owner_id)
346                .map(|d| d.amount_due)
347                .sum();
348            Ok(total)
349        }
350    }
351
352    // ========== Mock ExpenseRepository ==========
353
354    struct MockExpenseRepository {
355        expenses: Mutex<HashMap<Uuid, Expense>>,
356    }
357
358    impl MockExpenseRepository {
359        fn new() -> Self {
360            Self {
361                expenses: Mutex::new(HashMap::new()),
362            }
363        }
364
365        fn with_expense(expense: Expense) -> Self {
366            let mut map = HashMap::new();
367            map.insert(expense.id, expense);
368            Self {
369                expenses: Mutex::new(map),
370            }
371        }
372    }
373
374    #[async_trait]
375    impl ExpenseRepository for MockExpenseRepository {
376        async fn enregistrer_lignes_de_facture(
377            &self,
378            _expense_id: Uuid,
379            _lignes: &[crate::application::ports::expense_repository::LigneDeFacture],
380        ) -> Result<(), String> {
381            // Mock : rien à enregistrer. Le port n'offre pas d'implémentation
382            // par défaut, précisément pour que ce choix soit écrit ici plutôt
383            // que subi partout.
384            Ok(())
385        }
386
387        async fn create(&self, expense: &Expense) -> Result<Expense, String> {
388            let mut expenses = self.expenses.lock().unwrap();
389            expenses.insert(expense.id, expense.clone());
390            Ok(expense.clone())
391        }
392
393        async fn find_by_id(&self, id: Uuid) -> Result<Option<Expense>, String> {
394            let expenses = self.expenses.lock().unwrap();
395            Ok(expenses.get(&id).cloned())
396        }
397
398        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Expense>, String> {
399            let expenses = self.expenses.lock().unwrap();
400            Ok(expenses
401                .values()
402                .filter(|e| e.building_id == building_id)
403                .cloned()
404                .collect())
405        }
406
407        async fn find_all_paginated(
408            &self,
409            _page_request: &PageRequest,
410            _filters: &ExpenseFilters,
411        ) -> Result<(Vec<Expense>, i64), String> {
412            let expenses = self.expenses.lock().unwrap();
413            let all: Vec<Expense> = expenses.values().cloned().collect();
414            let count = all.len() as i64;
415            Ok((all, count))
416        }
417
418        async fn update(&self, expense: &Expense) -> Result<Expense, String> {
419            let mut expenses = self.expenses.lock().unwrap();
420            expenses.insert(expense.id, expense.clone());
421            Ok(expense.clone())
422        }
423
424        async fn delete(&self, id: Uuid) -> Result<bool, String> {
425            let mut expenses = self.expenses.lock().unwrap();
426            Ok(expenses.remove(&id).is_some())
427        }
428    }
429
430    // ========== Mock UnitOwnerRepository ==========
431
432    /// (unit_id, owner_id, percentage) tuples grouped by building_id
433    type BuildingOwnerships = HashMap<Uuid, Vec<(Uuid, Uuid, Decimal)>>;
434
435    struct MockUnitOwnerRepository {
436        /// Stores active building ownerships: building_id -> Vec<(unit_id, owner_id, percentage)>
437        building_ownerships: Mutex<BuildingOwnerships>,
438    }
439
440    impl MockUnitOwnerRepository {
441        fn new() -> Self {
442            Self {
443                building_ownerships: Mutex::new(HashMap::new()),
444            }
445        }
446
447        fn with_building_ownerships(
448            building_id: Uuid,
449            ownerships: Vec<(Uuid, Uuid, Decimal)>,
450        ) -> Self {
451            let mut map = HashMap::new();
452            map.insert(building_id, ownerships);
453            Self {
454                building_ownerships: Mutex::new(map),
455            }
456        }
457    }
458
459    #[async_trait]
460    impl UnitOwnerRepository for MockUnitOwnerRepository {
461        async fn create(&self, _unit_owner: &UnitOwner) -> Result<UnitOwner, String> {
462            unimplemented!("not needed for charge distribution tests")
463        }
464
465        async fn find_by_id(&self, _id: Uuid) -> Result<Option<UnitOwner>, String> {
466            unimplemented!("not needed for charge distribution tests")
467        }
468
469        async fn find_current_owners_by_unit(
470            &self,
471            _unit_id: Uuid,
472        ) -> Result<Vec<UnitOwner>, String> {
473            unimplemented!("not needed for charge distribution tests")
474        }
475
476        async fn find_current_units_by_owner(
477            &self,
478            _owner_id: Uuid,
479        ) -> Result<Vec<UnitOwner>, String> {
480            unimplemented!("not needed for charge distribution tests")
481        }
482
483        async fn find_all_owners_by_unit(&self, _unit_id: Uuid) -> Result<Vec<UnitOwner>, String> {
484            unimplemented!("not needed for charge distribution tests")
485        }
486
487        async fn find_all_units_by_owner(&self, _owner_id: Uuid) -> Result<Vec<UnitOwner>, String> {
488            unimplemented!("not needed for charge distribution tests")
489        }
490
491        async fn update(&self, _unit_owner: &UnitOwner) -> Result<UnitOwner, String> {
492            unimplemented!("not needed for charge distribution tests")
493        }
494
495        async fn delete(&self, _id: Uuid) -> Result<(), String> {
496            unimplemented!("not needed for charge distribution tests")
497        }
498
499        async fn has_active_owners(&self, _unit_id: Uuid) -> Result<bool, String> {
500            unimplemented!("not needed for charge distribution tests")
501        }
502
503        async fn get_total_ownership_percentage(&self, _unit_id: Uuid) -> Result<Decimal, String> {
504            unimplemented!("not needed for charge distribution tests")
505        }
506
507        async fn find_active_by_unit_and_owner(
508            &self,
509            _unit_id: Uuid,
510            _owner_id: Uuid,
511        ) -> Result<Option<UnitOwner>, String> {
512            unimplemented!("not needed for charge distribution tests")
513        }
514
515        async fn find_active_by_building(
516            &self,
517            building_id: Uuid,
518        ) -> Result<Vec<(Uuid, Uuid, Decimal)>, String> {
519            let ownerships = self.building_ownerships.lock().unwrap();
520            Ok(ownerships.get(&building_id).cloned().unwrap_or_default())
521        }
522
523        /// Dans les tests, les fixtures posent directement des quotes-parts
524        /// déjà résolues : la même source sert donc aux deux méthodes.
525        async fn find_active_quota_shares_by_building(
526            &self,
527            building_id: Uuid,
528        ) -> Result<Vec<(Uuid, Uuid, Decimal)>, String> {
529            let ownerships = self.building_ownerships.lock().unwrap();
530            Ok(ownerships.get(&building_id).cloned().unwrap_or_default())
531        }
532
533        async fn find_voting_holders_by_unit(
534            &self,
535            _unit_id: Uuid,
536        ) -> Result<Vec<crate::domain::entities::LotHolder>, String> {
537            Ok(vec![])
538        }
539
540        async fn is_voting_representative(&self, _unit_owner_id: Uuid) -> Result<bool, String> {
541            Ok(false)
542        }
543
544        async fn set_voting_representative(&self, _unit_owner_id: Uuid) -> Result<(), String> {
545            Ok(())
546        }
547    }
548
549    // ========== Helpers ==========
550
551    fn make_approved_expense(building_id: Uuid, amount_incl_vat: Decimal) -> Expense {
552        let now = Utc::now();
553        let vat_factor = dec!(1.21);
554        let amount_excl_vat = amount_incl_vat / vat_factor;
555        Expense {
556            id: Uuid::new_v4(),
557            acp_id: Uuid::new_v4(),
558            organization_id: Uuid::new_v4(),
559            building_id,
560            category: ExpenseCategory::Maintenance,
561            description: "Elevator maintenance".to_string(),
562            amount: amount_incl_vat,
563            amount_excl_vat: Some(amount_excl_vat),
564            vat_rate: Some(dec!(21)),
565            vat_amount: Some(amount_incl_vat - amount_excl_vat),
566            amount_incl_vat: Some(amount_incl_vat),
567            expense_date: now,
568            invoice_date: Some(now),
569            due_date: None,
570            paid_date: None,
571            approval_status: ApprovalStatus::Approved,
572            submitted_at: Some(now),
573            approved_by: Some(Uuid::new_v4()),
574            approved_at: Some(now),
575            rejection_reason: None,
576            payment_status: PaymentStatus::Pending,
577            supplier: Some("Schindler SA".to_string()),
578            invoice_number: Some("INV-001".to_string()),
579            account_code: Some("611002".to_string()),
580            contractor_report_id: None,
581            created_at: now,
582            updated_at: now,
583        }
584    }
585
586    fn make_draft_expense(building_id: Uuid) -> Expense {
587        let now = Utc::now();
588        Expense {
589            id: Uuid::new_v4(),
590            acp_id: Uuid::new_v4(),
591            organization_id: Uuid::new_v4(),
592            building_id,
593            category: ExpenseCategory::Maintenance,
594            description: "Draft expense".to_string(),
595            amount: dec!(1000),
596            amount_excl_vat: None,
597            vat_rate: None,
598            vat_amount: None,
599            amount_incl_vat: None,
600            expense_date: now,
601            invoice_date: None,
602            due_date: None,
603            paid_date: None,
604            approval_status: ApprovalStatus::Draft,
605            submitted_at: None,
606            approved_by: None,
607            approved_at: None,
608            rejection_reason: None,
609            payment_status: PaymentStatus::Pending,
610            supplier: None,
611            invoice_number: None,
612            account_code: None,
613            contractor_report_id: None,
614            created_at: now,
615            updated_at: now,
616        }
617    }
618
619    fn make_use_cases(
620        dist_repo: MockChargeDistributionRepository,
621        expense_repo: MockExpenseRepository,
622        unit_owner_repo: MockUnitOwnerRepository,
623    ) -> ChargeDistributionUseCases {
624        ChargeDistributionUseCases::new(
625            Arc::new(dist_repo),
626            Arc::new(expense_repo),
627            Arc::new(unit_owner_repo),
628        )
629    }
630
631    // ========== Tests ==========
632
633    #[tokio::test]
634    async fn test_calculate_and_save_distribution_success() {
635        let building_id = Uuid::new_v4();
636        let unit1_id = Uuid::new_v4();
637        let unit2_id = Uuid::new_v4();
638        let owner1_id = Uuid::new_v4();
639        let owner2_id = Uuid::new_v4();
640
641        let expense = make_approved_expense(building_id, dec!(1000));
642        let expense_id = expense.id;
643
644        let ownerships = vec![
645            (unit1_id, owner1_id, dec!(0.60)), // 60%
646            (unit2_id, owner2_id, dec!(0.40)), // 40%
647        ];
648
649        let dist_repo = MockChargeDistributionRepository::new();
650        let expense_repo = MockExpenseRepository::with_expense(expense);
651        let unit_owner_repo =
652            MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
653
654        let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
655
656        let result = uc.calculate_and_save_distribution(expense_id).await;
657        assert!(result.is_ok());
658
659        let distributions = result.unwrap();
660        assert_eq!(distributions.len(), 2);
661
662        // Verify amounts: 60% of 1000 = 600, 40% of 1000 = 400
663        let total_amount: Decimal = distributions.iter().map(|d| d.amount_due).sum();
664        assert_eq!(total_amount, dec!(1000));
665
666        // Verify all distributions reference the correct expense
667        assert!(distributions
668            .iter()
669            .all(|d| d.expense_id == expense_id.to_string()));
670    }
671
672    /// Non-régression — une répartition qui ne couvre pas la charge est refusée.
673    ///
674    /// `verify_distribution` existait, testée, sans aucun appelant. Le cas
675    /// qu'elle rattrape n'est pas théorique : la base de tantièmes est stockée
676    /// deux fois (`acps.total_tantiemes` pour le contrôle de conformité,
677    /// `buildings.total_tantiemes` pour le calcul des quotes-parts), les deux
678    /// sont saisissables séparément par un administrateur, et rien ne les tient
679    /// ensemble. Une divergence répartit moins — ou plus — que la charge.
680    ///
681    /// Ici, des quotes-parts de 0,30 et 0,40 : 70 % d'une charge de 1 000 €,
682    /// soit 700 € répartis pour 1 000 € dus. Sans la garde, ces 700 € étaient
683    /// enregistrés et réclamés tels quels aux copropriétaires.
684    #[tokio::test]
685    async fn test_repartition_incomplete_est_refusee() {
686        let building_id = Uuid::new_v4();
687        let expense = make_approved_expense(building_id, dec!(1000));
688        let expense_id = expense.id;
689
690        let ownerships = vec![
691            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.30)),
692            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.40)),
693        ];
694
695        let dist_repo = MockChargeDistributionRepository::new();
696        let uc = make_use_cases(
697            dist_repo,
698            MockExpenseRepository::with_expense(expense),
699            MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships),
700        );
701
702        let err = uc
703            .calculate_and_save_distribution(expense_id)
704            .await
705            .expect_err("une répartition à 70 % doit être refusée");
706        assert!(
707            err.contains("does not cover the charge"),
708            "le message doit nommer l'écart : {err}"
709        );
710        assert!(
711            err.contains("700"),
712            "le message doit chiffrer ce qui a été réparti : {err}"
713        );
714        // Le message doit DIAGNOSTIQUER, pas seulement constater.
715        //
716        // Il n'accusait qu'une seule cause — la divergence entre
717        // `acps.total_tantiemes` et `buildings.total_tantiemes` — alors que la
718        // plus fréquente est ailleurs : des lots sans détenteur actif. Un
719        // immeuble de douze lots dont un seul est rattaché répartit un douzième
720        // de la charge, et le message envoyait vérifier des tantièmes
721        // parfaitement corrects. C'est exactement ce qui se passe dans
722        // `smoke/ChargeDistribution.spec.ts`, et j'ai commencé par regarder les
723        // tantièmes avant de comprendre.
724        assert!(
725            err.contains("0.70") && err.contains("2 lot"),
726            "le message doit donner la somme des parts et le nombre de lots \
727             détenus, qui sont ce qu'on peut vérifier : {err}"
728        );
729        assert!(
730            err.contains("sans détenteur actif"),
731            "le message doit nommer la cause la plus fréquente en premier : \
732             {err}"
733        );
734    }
735
736    /// @edge — la tolérance d'un centime reste admise.
737    ///
738    /// Une division non décimale (un tiers de 1 000 €) laisse une traîne
739    /// d'arrondi. Refuser à l'exactitude absolue rendrait toute copropriété à
740    /// base non décimale impossible à gérer.
741    #[tokio::test]
742    async fn test_arrondi_au_centime_reste_accepte() {
743        let building_id = Uuid::new_v4();
744        let expense = make_approved_expense(building_id, dec!(1000));
745        let expense_id = expense.id;
746
747        // 0,3333 × 3 = 0,9999 → 999,90 € répartis pour 1 000 € dus.
748        // L'écart de 10 centimes DÉPASSE la tolérance : il doit être refusé.
749        let trop_grossier = vec![
750            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.3333)),
751            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.3333)),
752            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.3333)),
753        ];
754        let uc = make_use_cases(
755            MockChargeDistributionRepository::new(),
756            MockExpenseRepository::with_expense(expense.clone()),
757            MockUnitOwnerRepository::with_building_ownerships(building_id, trop_grossier),
758        );
759        assert!(
760            uc.calculate_and_save_distribution(expense_id)
761                .await
762                .is_err(),
763            "10 centimes d'écart dépassent la tolérance"
764        );
765
766        // La même répartition en pleine précision décimale tombe, elle, à
767        // moins d'un centime : elle doit passer.
768        let tiers = Decimal::ONE / Decimal::from(3);
769        let exact = vec![
770            (Uuid::new_v4(), Uuid::new_v4(), tiers),
771            (Uuid::new_v4(), Uuid::new_v4(), tiers),
772            (Uuid::new_v4(), Uuid::new_v4(), tiers),
773        ];
774        let uc2 = make_use_cases(
775            MockChargeDistributionRepository::new(),
776            MockExpenseRepository::with_expense(expense),
777            MockUnitOwnerRepository::with_building_ownerships(building_id, exact),
778        );
779        assert!(
780            uc2.calculate_and_save_distribution(expense_id)
781                .await
782                .is_ok(),
783            "une traîne d'arrondi sous le centime doit rester admise"
784        );
785    }
786
787    #[tokio::test]
788    async fn test_calculate_and_save_distribution_non_approved_expense() {
789        let building_id = Uuid::new_v4();
790        let expense = make_draft_expense(building_id);
791        let expense_id = expense.id;
792
793        let dist_repo = MockChargeDistributionRepository::new();
794        let expense_repo = MockExpenseRepository::with_expense(expense);
795        let unit_owner_repo = MockUnitOwnerRepository::new();
796
797        let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
798
799        let result = uc.calculate_and_save_distribution(expense_id).await;
800        assert!(result.is_err());
801        let err = result.unwrap_err();
802        assert!(err.contains("non-approved invoice"));
803    }
804
805    #[tokio::test]
806    async fn test_calculate_and_save_distribution_expense_not_found() {
807        let dist_repo = MockChargeDistributionRepository::new();
808        let expense_repo = MockExpenseRepository::new(); // empty
809        let unit_owner_repo = MockUnitOwnerRepository::new();
810
811        let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
812
813        let result = uc.calculate_and_save_distribution(Uuid::new_v4()).await;
814        assert!(result.is_err());
815        assert_eq!(result.unwrap_err(), "Expense/Invoice not found");
816    }
817
818    #[tokio::test]
819    async fn test_calculate_and_save_distribution_no_active_owners() {
820        let building_id = Uuid::new_v4();
821        let expense = make_approved_expense(building_id, dec!(1000));
822        let expense_id = expense.id;
823
824        let dist_repo = MockChargeDistributionRepository::new();
825        let expense_repo = MockExpenseRepository::with_expense(expense);
826        // Empty ownerships for the building
827        let unit_owner_repo =
828            MockUnitOwnerRepository::with_building_ownerships(building_id, vec![]);
829
830        let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
831
832        let result = uc.calculate_and_save_distribution(expense_id).await;
833        assert!(result.is_err());
834        assert_eq!(
835            result.unwrap_err(),
836            "No active unit-owner relationships found for this building"
837        );
838    }
839
840    #[tokio::test]
841    async fn test_get_distribution_by_expense() {
842        let building_id = Uuid::new_v4();
843        let unit1_id = Uuid::new_v4();
844        let owner1_id = Uuid::new_v4();
845
846        let expense = make_approved_expense(building_id, dec!(500));
847        let expense_id = expense.id;
848
849        let ownerships = vec![(unit1_id, owner1_id, Decimal::ONE)]; // 100%
850
851        let dist_repo = MockChargeDistributionRepository::new();
852        let expense_repo = MockExpenseRepository::with_expense(expense);
853        let unit_owner_repo =
854            MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
855
856        let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
857
858        // First calculate and save
859        uc.calculate_and_save_distribution(expense_id)
860            .await
861            .unwrap();
862
863        // Then retrieve by expense
864        let result = uc.get_distribution_by_expense(expense_id).await;
865        assert!(result.is_ok());
866        let distributions = result.unwrap();
867        assert_eq!(distributions.len(), 1);
868        assert_eq!(distributions[0].expense_id, expense_id.to_string());
869        assert_eq!(distributions[0].quota_percentage, Decimal::ONE);
870        assert_eq!(distributions[0].amount_due, dec!(500.00));
871    }
872
873    #[tokio::test]
874    async fn test_get_distribution_by_expense_empty() {
875        let dist_repo = MockChargeDistributionRepository::new();
876        let expense_repo = MockExpenseRepository::new();
877        let unit_owner_repo = MockUnitOwnerRepository::new();
878
879        let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
880
881        let result = uc.get_distribution_by_expense(Uuid::new_v4()).await;
882        assert!(result.is_ok());
883        assert!(result.unwrap().is_empty());
884    }
885
886    #[tokio::test]
887    async fn test_get_total_due_by_owner() {
888        let building_id = Uuid::new_v4();
889        let unit1_id = Uuid::new_v4();
890        let unit2_id = Uuid::new_v4();
891        let owner1_id = Uuid::new_v4();
892        let owner2_id = Uuid::new_v4();
893
894        // Create 2 approved expenses
895        let expense1 = make_approved_expense(building_id, dec!(1000));
896        let expense1_id = expense1.id;
897        let expense2 = make_approved_expense(building_id, dec!(2000));
898        let expense2_id = expense2.id;
899
900        let ownerships = vec![
901            (unit1_id, owner1_id, dec!(0.60)), // 60%
902            (unit2_id, owner2_id, dec!(0.40)), // 40%
903        ];
904
905        let dist_repo = MockChargeDistributionRepository::new();
906        let mut expense_map = HashMap::new();
907        expense_map.insert(expense1.id, expense1);
908        expense_map.insert(expense2.id, expense2);
909        let expense_repo = MockExpenseRepository {
910            expenses: Mutex::new(expense_map),
911        };
912        let unit_owner_repo =
913            MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
914
915        let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
916
917        // Calculate distributions for both expenses
918        uc.calculate_and_save_distribution(expense1_id)
919            .await
920            .unwrap();
921        uc.calculate_and_save_distribution(expense2_id)
922            .await
923            .unwrap();
924
925        // Owner 1 owes 60% of 1000 + 60% of 2000 = 600 + 1200 = 1800
926        let total = uc.get_total_due_by_owner(owner1_id).await.unwrap();
927        assert_eq!(total, dec!(1800.00));
928
929        // Owner 2 owes 40% of 1000 + 40% of 2000 = 400 + 800 = 1200
930        let total = uc.get_total_due_by_owner(owner2_id).await.unwrap();
931        assert_eq!(total, dec!(1200.00));
932    }
933
934    #[tokio::test]
935    async fn test_get_total_due_by_owner_no_distributions() {
936        let dist_repo = MockChargeDistributionRepository::new();
937        let expense_repo = MockExpenseRepository::new();
938        let unit_owner_repo = MockUnitOwnerRepository::new();
939
940        let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
941
942        let total = uc.get_total_due_by_owner(Uuid::new_v4()).await.unwrap();
943        assert_eq!(total, Decimal::ZERO);
944    }
945
946    #[tokio::test]
947    async fn test_calculate_distribution_uses_amount_incl_vat() {
948        let building_id = Uuid::new_v4();
949        let unit_id = Uuid::new_v4();
950        let owner_id = Uuid::new_v4();
951
952        // Expense with amount_incl_vat = 1210, amount = 1000 (backward compat)
953        let now = Utc::now();
954        let expense = Expense {
955            id: Uuid::new_v4(),
956            acp_id: Uuid::new_v4(),
957            organization_id: Uuid::new_v4(),
958            building_id,
959            category: ExpenseCategory::Utilities,
960            description: "Electricity".to_string(),
961            amount: dec!(1000),
962            amount_excl_vat: Some(dec!(1000)),
963            vat_rate: Some(dec!(21)),
964            vat_amount: Some(dec!(210)),
965            amount_incl_vat: Some(dec!(1210)),
966            expense_date: now,
967            invoice_date: Some(now),
968            due_date: None,
969            paid_date: None,
970            approval_status: ApprovalStatus::Approved,
971            submitted_at: Some(now),
972            approved_by: Some(Uuid::new_v4()),
973            approved_at: Some(now),
974            rejection_reason: None,
975            payment_status: PaymentStatus::Pending,
976            supplier: None,
977            invoice_number: None,
978            account_code: None,
979            contractor_report_id: None,
980            created_at: now,
981            updated_at: now,
982        };
983        let expense_id = expense.id;
984
985        let ownerships = vec![(unit_id, owner_id, Decimal::ONE)]; // 100%
986
987        let dist_repo = MockChargeDistributionRepository::new();
988        let expense_repo = MockExpenseRepository::with_expense(expense);
989        let unit_owner_repo =
990            MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
991
992        let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
993
994        let result = uc
995            .calculate_and_save_distribution(expense_id)
996            .await
997            .unwrap();
998
999        // Should use amount_incl_vat (1210), not amount (1000)
1000        assert_eq!(result.len(), 1);
1001        assert_eq!(result[0].amount_due, dec!(1210));
1002    }
1003}