koprogo_api/domain/entities/
charge_distribution.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Représente la répartition d'une charge/facture par lot et propriétaire
6/// Calculée automatiquement lors de l'approbation d'une facture
7/// Basée sur les quotes-parts (ownership percentages) des copropriétaires
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub struct ChargeDistribution {
10    pub id: Uuid,
11    pub expense_id: Uuid, // Référence à la facture
12    pub unit_id: Uuid,    // Lot concerné
13    pub owner_id: Uuid,   // Propriétaire du lot
14
15    pub quota_percentage: f64, // Quote-part (ex: 0.15 pour 15%)
16    pub amount_due: f64,       // Montant à payer par ce propriétaire
17
18    pub created_at: DateTime<Utc>,
19}
20
21impl ChargeDistribution {
22    pub fn new(
23        expense_id: Uuid,
24        unit_id: Uuid,
25        owner_id: Uuid,
26        quota_percentage: f64,
27        total_amount: f64,
28    ) -> Result<Self, String> {
29        // Validations
30        if !(0.0..=1.0).contains(&quota_percentage) {
31            return Err(format!(
32                "Quota percentage must be between 0 and 1 (got: {})",
33                quota_percentage
34            ));
35        }
36        if total_amount < 0.0 {
37            return Err("Total amount cannot be negative".to_string());
38        }
39
40        // Calcul du montant dû
41        let amount_due = total_amount * quota_percentage;
42
43        Ok(Self {
44            id: Uuid::new_v4(),
45            expense_id,
46            unit_id,
47            owner_id,
48            quota_percentage,
49            amount_due,
50            created_at: Utc::now(),
51        })
52    }
53
54    /// Recalcule le montant dû si la quote-part ou le total change
55    pub fn recalculate(&mut self, total_amount: f64) -> Result<(), String> {
56        if self.quota_percentage < 0.0 || self.quota_percentage > 1.0 {
57            return Err("Quota percentage must be between 0 and 1".to_string());
58        }
59        if total_amount < 0.0 {
60            return Err("Total amount cannot be negative".to_string());
61        }
62
63        self.amount_due = total_amount * self.quota_percentage;
64        Ok(())
65    }
66
67    /// Calcule la distribution pour une facture donnée et une liste de quotes-parts
68    /// Retourne une distribution pour chaque (unit, owner, quota)
69    pub fn calculate_distributions(
70        expense_id: Uuid,
71        total_amount: f64,
72        unit_ownerships: Vec<(Uuid, Uuid, f64)>, // (unit_id, owner_id, quota_percentage)
73    ) -> Result<Vec<ChargeDistribution>, String> {
74        if total_amount < 0.0 {
75            return Err("Total amount cannot be negative".to_string());
76        }
77
78        // Vérifier que la somme des quotes-parts ne dépasse pas 100%
79        let total_quota: f64 = unit_ownerships.iter().map(|(_, _, q)| q).sum();
80        if total_quota > 1.0001 {
81            // Tolérance pour arrondi
82            return Err(format!(
83                "Total quota percentage exceeds 100% (got: {:.4})",
84                total_quota * 100.0
85            ));
86        }
87
88        let mut distributions = Vec::new();
89        for (unit_id, owner_id, quota) in unit_ownerships {
90            let distribution =
91                ChargeDistribution::new(expense_id, unit_id, owner_id, quota, total_amount)?;
92            distributions.push(distribution);
93        }
94
95        Ok(distributions)
96    }
97
98    /// Calcule le montant total distribué (somme des amount_due)
99    pub fn total_distributed(distributions: &[ChargeDistribution]) -> f64 {
100        distributions.iter().map(|d| d.amount_due).sum()
101    }
102
103    /// Vérifie que la distribution est complète (somme = total_amount à 0.01€ près)
104    pub fn verify_distribution(distributions: &[ChargeDistribution], expected_total: f64) -> bool {
105        let total = Self::total_distributed(distributions);
106        (total - expected_total).abs() < 0.01 // Tolérance de 1 centime
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn test_create_charge_distribution_success() {
116        let expense_id = Uuid::new_v4();
117        let unit_id = Uuid::new_v4();
118        let owner_id = Uuid::new_v4();
119
120        let distribution = ChargeDistribution::new(expense_id, unit_id, owner_id, 0.25, 1000.0);
121
122        assert!(distribution.is_ok());
123        let distribution = distribution.unwrap();
124        assert_eq!(distribution.expense_id, expense_id);
125        assert_eq!(distribution.unit_id, unit_id);
126        assert_eq!(distribution.owner_id, owner_id);
127        assert_eq!(distribution.quota_percentage, 0.25);
128        assert_eq!(distribution.amount_due, 250.0); // 25% de 1000€
129    }
130
131    #[test]
132    fn test_create_charge_distribution_negative_quota_fails() {
133        let expense_id = Uuid::new_v4();
134        let unit_id = Uuid::new_v4();
135        let owner_id = Uuid::new_v4();
136
137        let distribution = ChargeDistribution::new(expense_id, unit_id, owner_id, -0.1, 1000.0);
138
139        assert!(distribution.is_err());
140        assert!(distribution
141            .unwrap_err()
142            .contains("Quota percentage must be between 0 and 1"));
143    }
144
145    #[test]
146    fn test_create_charge_distribution_quota_above_1_fails() {
147        let expense_id = Uuid::new_v4();
148        let unit_id = Uuid::new_v4();
149        let owner_id = Uuid::new_v4();
150
151        let distribution = ChargeDistribution::new(expense_id, unit_id, owner_id, 1.5, 1000.0);
152
153        assert!(distribution.is_err());
154    }
155
156    #[test]
157    fn test_recalculate_amount_due() {
158        let expense_id = Uuid::new_v4();
159        let unit_id = Uuid::new_v4();
160        let owner_id = Uuid::new_v4();
161
162        let mut distribution =
163            ChargeDistribution::new(expense_id, unit_id, owner_id, 0.20, 1000.0).unwrap();
164
165        assert_eq!(distribution.amount_due, 200.0);
166
167        // Recalculer avec un nouveau montant total
168        distribution.recalculate(1500.0).unwrap();
169        assert_eq!(distribution.amount_due, 300.0); // 20% de 1500€
170    }
171
172    #[test]
173    fn test_calculate_distributions_success() {
174        let expense_id = Uuid::new_v4();
175        let unit1_id = Uuid::new_v4();
176        let unit2_id = Uuid::new_v4();
177        let unit3_id = Uuid::new_v4();
178        let owner1_id = Uuid::new_v4();
179        let owner2_id = Uuid::new_v4();
180        let owner3_id = Uuid::new_v4();
181
182        let unit_ownerships = vec![
183            (unit1_id, owner1_id, 0.25), // 25%
184            (unit2_id, owner2_id, 0.35), // 35%
185            (unit3_id, owner3_id, 0.40), // 40%
186        ];
187
188        let distributions =
189            ChargeDistribution::calculate_distributions(expense_id, 1000.0, unit_ownerships);
190
191        assert!(distributions.is_ok());
192        let distributions = distributions.unwrap();
193        assert_eq!(distributions.len(), 3);
194
195        // Vérifier les montants
196        assert_eq!(distributions[0].amount_due, 250.0);
197        assert_eq!(distributions[1].amount_due, 350.0);
198        assert_eq!(distributions[2].amount_due, 400.0);
199
200        // Vérifier le total
201        let total = ChargeDistribution::total_distributed(&distributions);
202        assert_eq!(total, 1000.0);
203    }
204
205    #[test]
206    fn test_calculate_distributions_quota_exceeds_100_fails() {
207        let expense_id = Uuid::new_v4();
208        let unit1_id = Uuid::new_v4();
209        let unit2_id = Uuid::new_v4();
210        let owner1_id = Uuid::new_v4();
211        let owner2_id = Uuid::new_v4();
212
213        let unit_ownerships = vec![
214            (unit1_id, owner1_id, 0.60), // 60%
215            (unit2_id, owner2_id, 0.50), // 50% -> Total 110%
216        ];
217
218        let distributions =
219            ChargeDistribution::calculate_distributions(expense_id, 1000.0, unit_ownerships);
220
221        assert!(distributions.is_err());
222        assert!(distributions
223            .unwrap_err()
224            .contains("Total quota percentage exceeds 100%"));
225    }
226
227    #[test]
228    fn test_calculate_distributions_empty_list() {
229        let expense_id = Uuid::new_v4();
230        let unit_ownerships = vec![];
231
232        let distributions =
233            ChargeDistribution::calculate_distributions(expense_id, 1000.0, unit_ownerships);
234
235        assert!(distributions.is_ok());
236        let distributions = distributions.unwrap();
237        assert_eq!(distributions.len(), 0);
238    }
239
240    #[test]
241    fn test_verify_distribution_exact_match() {
242        let expense_id = Uuid::new_v4();
243        let unit_ownerships = vec![
244            (Uuid::new_v4(), Uuid::new_v4(), 0.50),
245            (Uuid::new_v4(), Uuid::new_v4(), 0.50),
246        ];
247
248        let distributions =
249            ChargeDistribution::calculate_distributions(expense_id, 1000.0, unit_ownerships)
250                .unwrap();
251
252        assert!(ChargeDistribution::verify_distribution(
253            &distributions,
254            1000.0
255        ));
256    }
257
258    #[test]
259    fn test_verify_distribution_with_rounding() {
260        let expense_id = Uuid::new_v4();
261        let unit_ownerships = vec![
262            (Uuid::new_v4(), Uuid::new_v4(), 0.333333), // 1/3
263            (Uuid::new_v4(), Uuid::new_v4(), 0.333333), // 1/3
264            (Uuid::new_v4(), Uuid::new_v4(), 0.333334), // 1/3 avec arrondi
265        ];
266
267        let distributions =
268            ChargeDistribution::calculate_distributions(expense_id, 1000.0, unit_ownerships)
269                .unwrap();
270
271        // Le total sera ~999.999 ou 1000.001 à cause des arrondis
272        // Devrait passer avec tolérance de 1 centime
273        assert!(ChargeDistribution::verify_distribution(
274            &distributions,
275            1000.0
276        ));
277    }
278
279    #[test]
280    fn test_calculate_distributions_complex_scenario() {
281        // Scénario réaliste: immeuble avec 5 lots, quotes-parts variées
282        let expense_id = Uuid::new_v4();
283        let unit_ownerships = vec![
284            (Uuid::new_v4(), Uuid::new_v4(), 0.25), // Lot A: 25%
285            (Uuid::new_v4(), Uuid::new_v4(), 0.20), // Lot B: 20%
286            (Uuid::new_v4(), Uuid::new_v4(), 0.20), // Lot C: 20%
287            (Uuid::new_v4(), Uuid::new_v4(), 0.20), // Lot D: 20%
288            (Uuid::new_v4(), Uuid::new_v4(), 0.15), // Lot E: 15%
289        ];
290
291        let total_invoice = 5000.0;
292        let distributions =
293            ChargeDistribution::calculate_distributions(expense_id, total_invoice, unit_ownerships)
294                .unwrap();
295
296        assert_eq!(distributions.len(), 5);
297        assert_eq!(distributions[0].amount_due, 1250.0); // 25%
298        assert_eq!(distributions[1].amount_due, 1000.0); // 20%
299        assert_eq!(distributions[2].amount_due, 1000.0); // 20%
300        assert_eq!(distributions[3].amount_due, 1000.0); // 20%
301        assert_eq!(distributions[4].amount_due, 750.0); // 15%
302
303        assert!(ChargeDistribution::verify_distribution(
304            &distributions,
305            total_invoice
306        ));
307    }
308
309    #[test]
310    fn test_total_distributed_empty() {
311        let distributions: Vec<ChargeDistribution> = vec![];
312        assert_eq!(ChargeDistribution::total_distributed(&distributions), 0.0);
313    }
314
315    #[test]
316    fn test_quota_percentage_zero_is_valid() {
317        // Un lot peut avoir 0% de quote-part (cas particulier)
318        let expense_id = Uuid::new_v4();
319        let unit_id = Uuid::new_v4();
320        let owner_id = Uuid::new_v4();
321
322        let distribution = ChargeDistribution::new(expense_id, unit_id, owner_id, 0.0, 1000.0);
323
324        assert!(distribution.is_ok());
325        let distribution = distribution.unwrap();
326        assert_eq!(distribution.amount_due, 0.0);
327    }
328
329    #[test]
330    fn test_quota_percentage_exactly_one_is_valid() {
331        // Un seul propriétaire avec 100% de quote-part
332        let expense_id = Uuid::new_v4();
333        let unit_id = Uuid::new_v4();
334        let owner_id = Uuid::new_v4();
335
336        let distribution = ChargeDistribution::new(expense_id, unit_id, owner_id, 1.0, 1000.0);
337
338        assert!(distribution.is_ok());
339        let distribution = distribution.unwrap();
340        assert_eq!(distribution.amount_due, 1000.0);
341    }
342}