Skip to main content

koprogo_api/domain/entities/
charge_distribution.rs

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