Skip to main content

koprogo_api/domain/comptabilite/
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    /// Story H12 — critère légal sous lequel cette ligne a été calculée
26    /// (valeur / utilité / mixte, Art. 3.84/3.86). Défaut : `Value`.
27    #[serde(default)]
28    pub distribution_criteria: DistributionCriteria,
29
30    pub created_at: DateTime<Utc>,
31}
32
33/// Tolerance for distribution sum vs total (1 centime).
34const DISTRIBUTION_TOLERANCE: Decimal = dec!(0.01);
35/// Tolerance for total quota sum to allow rounding errors (1.0001 = 100.01%).
36const QUOTA_SUM_TOLERANCE: Decimal = dec!(1.0001);
37
38/// Domain-typed validation error for charge distribution (quote-part exactness).
39///
40/// Pure domain type — no infrastructure/application dependency (hexagonal
41/// purity). Follows the codebase precedent `JournalEntryError`
42/// (journal_entry.rs) / `ProxyValidationError` (vote.rs): the entity returns
43/// its own typed error; the application layer maps it to `AppError`
44/// (see `impl From<ChargeDistributionError> for AppError`) so a malformed
45/// distribution surfaces as a 400 validation error, not a 500 Internal
46/// (#433 / WP-A4 — EXP-005).
47#[derive(Debug, Clone, PartialEq)]
48pub enum ChargeDistributionError {
49    /// Quota percentage is outside the valid [0, 1] range.
50    QuotaOutOfRange(Decimal),
51    /// Total amount to distribute is negative.
52    NegativeTotalAmount,
53    /// Sum of all quotas exceeds 100% beyond the rounding tolerance.
54    /// Over-distribution would over-charge owners — financial integrity guard.
55    QuotaSumExceeds { total_quota: Decimal },
56    /// Story H12 — base de tantièmes invalide (acte de base ≤ 0) : impossible de
57    /// calculer une quote-part de lot (division par zéro / base négative).
58    InvalidTotalTantiemes(Decimal),
59    /// Story H12 — critère de répartition non reconnu (≠ value/utility/mixed).
60    /// Garde @security : un critère non prévu par la loi (Art. 3.84/3.86) est
61    /// refusé, pas appliqué silencieusement.
62    UnknownCriteria(String),
63}
64
65impl std::fmt::Display for ChargeDistributionError {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            Self::QuotaOutOfRange(q) => {
69                write!(f, "Quota percentage must be between 0 and 1 (got: {})", q)
70            }
71            Self::NegativeTotalAmount => write!(f, "Total amount cannot be negative"),
72            Self::QuotaSumExceeds { total_quota } => write!(
73                f,
74                "Total quota percentage exceeds 100% (got: {})",
75                total_quota * dec!(100)
76            ),
77            Self::InvalidTotalTantiemes(t) => write!(
78                f,
79                "Total tantièmes (acte de base) must be strictly positive (got: {})",
80                t
81            ),
82            Self::UnknownCriteria(c) => write!(
83                f,
84                "Unknown distribution criteria '{}' (expected value|utility|mixed)",
85                c
86            ),
87        }
88    }
89}
90
91impl std::error::Error for ChargeDistributionError {}
92
93/// Bridge so existing `Result<_, String>` use-cases keep compiling while the
94/// entity is typed (the use-case/port String→AppError cascade is a distinct,
95/// broader slice — out of WP-A4 scope, mirrors WP-A3). Pure, std-only.
96impl From<ChargeDistributionError> for String {
97    fn from(e: ChargeDistributionError) -> String {
98        e.to_string()
99    }
100}
101
102/// Critère légal de répartition des charges communes (Story H12, Art. 3.84 /
103/// 3.86 CC).
104///
105/// - `Value` (valeur) : répartition selon la quote-part / valeur respective du
106///   lot, c.-à-d. les tantièmes de l'acte de base. **Critère par défaut.**
107/// - `Utility` (utilité) : base alternative selon l'utilité de la partie commune
108///   pour chaque lot (ex. ascenseur réparti selon l'étage) — Art. 3.86.
109/// - `Mixed` (mixte) : combinaison valeur + utilité.
110///
111/// Pur domaine : sérialisé/persisté en texte (`value`/`utility`/`mixed`).
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
113#[serde(rename_all = "lowercase")]
114pub enum DistributionCriteria {
115    #[default]
116    Value,
117    Utility,
118    Mixed,
119}
120
121impl DistributionCriteria {
122    pub fn as_str(&self) -> &'static str {
123        match self {
124            Self::Value => "value",
125            Self::Utility => "utility",
126            Self::Mixed => "mixed",
127        }
128    }
129}
130
131impl std::fmt::Display for DistributionCriteria {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.write_str(self.as_str())
134    }
135}
136
137impl std::str::FromStr for DistributionCriteria {
138    type Err = ChargeDistributionError;
139
140    /// Strict : un critère non reconnu est REFUSÉ (garde @security H12), jamais
141    /// rabattu silencieusement sur une valeur par défaut.
142    fn from_str(s: &str) -> Result<Self, Self::Err> {
143        match s.trim().to_lowercase().as_str() {
144            "value" => Ok(Self::Value),
145            "utility" => Ok(Self::Utility),
146            "mixed" => Ok(Self::Mixed),
147            other => Err(ChargeDistributionError::UnknownCriteria(other.to_string())),
148        }
149    }
150}
151
152impl ChargeDistribution {
153    pub fn new(
154        expense_id: Uuid,
155        unit_id: Uuid,
156        owner_id: Uuid,
157        quota_percentage: Decimal,
158        total_amount: Decimal,
159    ) -> Result<Self, ChargeDistributionError> {
160        Self::new_with_criteria(
161            expense_id,
162            unit_id,
163            owner_id,
164            quota_percentage,
165            total_amount,
166            DistributionCriteria::default(),
167        )
168    }
169
170    /// Story H12 — constructeur avec critère de répartition explicite.
171    pub fn new_with_criteria(
172        expense_id: Uuid,
173        unit_id: Uuid,
174        owner_id: Uuid,
175        quota_percentage: Decimal,
176        total_amount: Decimal,
177        distribution_criteria: DistributionCriteria,
178    ) -> Result<Self, ChargeDistributionError> {
179        // Validations
180        if quota_percentage < Decimal::ZERO || quota_percentage > Decimal::ONE {
181            return Err(ChargeDistributionError::QuotaOutOfRange(quota_percentage));
182        }
183        if total_amount < Decimal::ZERO {
184            return Err(ChargeDistributionError::NegativeTotalAmount);
185        }
186
187        // Calcul du montant dû
188        let amount_due = total_amount * quota_percentage;
189
190        Ok(Self {
191            id: Uuid::new_v4(),
192            expense_id,
193            unit_id,
194            owner_id,
195            quota_percentage,
196            amount_due,
197            distribution_criteria,
198            created_at: Utc::now(),
199        })
200    }
201
202    /// Story H12 — quote-part effective d'un copropriétaire pour une charge.
203    ///
204    /// Clarifie les DEUX niveaux de répartition (DoD H12) :
205    /// - `unit_quota / total_tantiemes` = part du **lot** dans les communs
206    ///   (valeur respective, acte de base — Art. 3.84) ;
207    /// - `× ownership_percentage` = part du **copropriétaire** dans le lot
208    ///   (indivision / démembrement — `unit_owners`).
209    ///
210    /// Retourne la fraction `[0, 1]` à appliquer au montant total de la charge.
211    pub fn resolve_owner_quota(
212        unit_quota: Decimal,
213        total_tantiemes: Decimal,
214        ownership_percentage: Decimal,
215    ) -> Result<Decimal, ChargeDistributionError> {
216        if total_tantiemes <= Decimal::ZERO {
217            return Err(ChargeDistributionError::InvalidTotalTantiemes(
218                total_tantiemes,
219            ));
220        }
221        if unit_quota < Decimal::ZERO {
222            return Err(ChargeDistributionError::QuotaOutOfRange(unit_quota));
223        }
224        if ownership_percentage < Decimal::ZERO || ownership_percentage > Decimal::ONE {
225            return Err(ChargeDistributionError::QuotaOutOfRange(
226                ownership_percentage,
227            ));
228        }
229        Ok((unit_quota / total_tantiemes) * ownership_percentage)
230    }
231
232    /// Recalcule le montant dû si la quote-part ou le total change
233    pub fn recalculate(&mut self, total_amount: Decimal) -> Result<(), ChargeDistributionError> {
234        if self.quota_percentage < Decimal::ZERO || self.quota_percentage > Decimal::ONE {
235            return Err(ChargeDistributionError::QuotaOutOfRange(
236                self.quota_percentage,
237            ));
238        }
239        if total_amount < Decimal::ZERO {
240            return Err(ChargeDistributionError::NegativeTotalAmount);
241        }
242
243        self.amount_due = total_amount * self.quota_percentage;
244        Ok(())
245    }
246
247    /// Calcule la distribution pour une facture donnée et une liste de quotes-parts
248    /// Retourne une distribution pour chaque (unit, owner, quota)
249    pub fn calculate_distributions(
250        expense_id: Uuid,
251        total_amount: Decimal,
252        unit_ownerships: Vec<(Uuid, Uuid, Decimal)>, // (unit_id, owner_id, quota_percentage)
253    ) -> Result<Vec<ChargeDistribution>, ChargeDistributionError> {
254        Self::calculate_distributions_with_criteria(
255            expense_id,
256            total_amount,
257            unit_ownerships,
258            DistributionCriteria::default(),
259        )
260    }
261
262    /// Story H12 — répartition avec critère explicite (valeur / utilité / mixte).
263    /// Sous `Value`, les quotités proviennent de l'acte de base ; sous `Utility`,
264    /// elles proviennent d'une base d'utilité (coefficients alternatifs). Le
265    /// critère est enregistré sur chaque ligne pour la traçabilité.
266    pub fn calculate_distributions_with_criteria(
267        expense_id: Uuid,
268        total_amount: Decimal,
269        unit_ownerships: Vec<(Uuid, Uuid, Decimal)>, // (unit_id, owner_id, quota_percentage)
270        criteria: DistributionCriteria,
271    ) -> Result<Vec<ChargeDistribution>, ChargeDistributionError> {
272        if total_amount < Decimal::ZERO {
273            return Err(ChargeDistributionError::NegativeTotalAmount);
274        }
275
276        // Vérifier que la somme des quotes-parts ne dépasse pas 100%
277        let total_quota: Decimal = unit_ownerships.iter().map(|(_, _, q)| *q).sum();
278        if total_quota > QUOTA_SUM_TOLERANCE {
279            // Tolérance pour arrondi
280            return Err(ChargeDistributionError::QuotaSumExceeds { total_quota });
281        }
282
283        let mut distributions = Vec::new();
284        for (unit_id, owner_id, quota) in unit_ownerships {
285            let distribution = ChargeDistribution::new_with_criteria(
286                expense_id,
287                unit_id,
288                owner_id,
289                quota,
290                total_amount,
291                criteria,
292            )?;
293            distributions.push(distribution);
294        }
295
296        Ok(distributions)
297    }
298
299    /// Calcule le montant total distribué (somme des amount_due)
300    pub fn total_distributed(distributions: &[ChargeDistribution]) -> Decimal {
301        distributions.iter().map(|d| d.amount_due).sum()
302    }
303
304    /// Vérifie que la distribution est complète (somme = total_amount à 0.01€ près)
305    pub fn verify_distribution(
306        distributions: &[ChargeDistribution],
307        expected_total: Decimal,
308    ) -> bool {
309        let total = Self::total_distributed(distributions);
310        (total - expected_total).abs() < DISTRIBUTION_TOLERANCE
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_create_charge_distribution_success() {
320        let expense_id = Uuid::new_v4();
321        let unit_id = Uuid::new_v4();
322        let owner_id = Uuid::new_v4();
323
324        let distribution =
325            ChargeDistribution::new(expense_id, unit_id, owner_id, dec!(0.25), dec!(1000));
326
327        assert!(distribution.is_ok());
328        let distribution = distribution.unwrap();
329        assert_eq!(distribution.expense_id, expense_id);
330        assert_eq!(distribution.unit_id, unit_id);
331        assert_eq!(distribution.owner_id, owner_id);
332        assert_eq!(distribution.quota_percentage, dec!(0.25));
333        assert_eq!(distribution.amount_due, dec!(250.00)); // 25% de 1000€
334    }
335
336    #[test]
337    fn test_create_charge_distribution_negative_quota_fails() {
338        let expense_id = Uuid::new_v4();
339        let unit_id = Uuid::new_v4();
340        let owner_id = Uuid::new_v4();
341
342        let distribution =
343            ChargeDistribution::new(expense_id, unit_id, owner_id, dec!(-0.1), dec!(1000));
344
345        assert!(distribution.is_err());
346        assert!(matches!(
347            distribution.unwrap_err(),
348            ChargeDistributionError::QuotaOutOfRange(_)
349        ));
350    }
351
352    #[test]
353    fn test_create_charge_distribution_quota_above_1_fails() {
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, dec!(1.5), dec!(1000));
360
361        assert!(distribution.is_err());
362    }
363
364    #[test]
365    fn test_recalculate_amount_due() {
366        let expense_id = Uuid::new_v4();
367        let unit_id = Uuid::new_v4();
368        let owner_id = Uuid::new_v4();
369
370        let mut distribution =
371            ChargeDistribution::new(expense_id, unit_id, owner_id, dec!(0.20), dec!(1000)).unwrap();
372
373        assert_eq!(distribution.amount_due, dec!(200.00));
374
375        // Recalculer avec un nouveau montant total
376        distribution.recalculate(dec!(1500)).unwrap();
377        assert_eq!(distribution.amount_due, dec!(300.00)); // 20% de 1500€
378    }
379
380    #[test]
381    fn test_calculate_distributions_success() {
382        let expense_id = Uuid::new_v4();
383        let unit1_id = Uuid::new_v4();
384        let unit2_id = Uuid::new_v4();
385        let unit3_id = Uuid::new_v4();
386        let owner1_id = Uuid::new_v4();
387        let owner2_id = Uuid::new_v4();
388        let owner3_id = Uuid::new_v4();
389
390        let unit_ownerships = vec![
391            (unit1_id, owner1_id, dec!(0.25)), // 25%
392            (unit2_id, owner2_id, dec!(0.35)), // 35%
393            (unit3_id, owner3_id, dec!(0.40)), // 40%
394        ];
395
396        let distributions =
397            ChargeDistribution::calculate_distributions(expense_id, dec!(1000), unit_ownerships);
398
399        assert!(distributions.is_ok());
400        let distributions = distributions.unwrap();
401        assert_eq!(distributions.len(), 3);
402
403        // Vérifier les montants (Decimal exact)
404        assert_eq!(distributions[0].amount_due, dec!(250.00));
405        assert_eq!(distributions[1].amount_due, dec!(350.00));
406        assert_eq!(distributions[2].amount_due, dec!(400.00));
407
408        // Vérifier le total
409        let total = ChargeDistribution::total_distributed(&distributions);
410        assert_eq!(total, dec!(1000.00));
411    }
412
413    #[test]
414    fn test_calculate_distributions_quota_exceeds_100_fails() {
415        let expense_id = Uuid::new_v4();
416        let unit1_id = Uuid::new_v4();
417        let unit2_id = Uuid::new_v4();
418        let owner1_id = Uuid::new_v4();
419        let owner2_id = Uuid::new_v4();
420
421        let unit_ownerships = vec![
422            (unit1_id, owner1_id, dec!(0.60)), // 60%
423            (unit2_id, owner2_id, dec!(0.50)), // 50% -> Total 110%
424        ];
425
426        let distributions =
427            ChargeDistribution::calculate_distributions(expense_id, dec!(1000), unit_ownerships);
428
429        assert!(distributions.is_err());
430        assert!(matches!(
431            distributions.unwrap_err(),
432            ChargeDistributionError::QuotaSumExceeds { .. }
433        ));
434    }
435
436    #[test]
437    fn test_calculate_distributions_empty_list() {
438        let expense_id = Uuid::new_v4();
439        let unit_ownerships = vec![];
440
441        let distributions =
442            ChargeDistribution::calculate_distributions(expense_id, dec!(1000), unit_ownerships);
443
444        assert!(distributions.is_ok());
445        let distributions = distributions.unwrap();
446        assert_eq!(distributions.len(), 0);
447    }
448
449    #[test]
450    fn test_verify_distribution_exact_match() {
451        let expense_id = Uuid::new_v4();
452        let unit_ownerships = vec![
453            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.50)),
454            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.50)),
455        ];
456
457        let distributions =
458            ChargeDistribution::calculate_distributions(expense_id, dec!(1000), unit_ownerships)
459                .unwrap();
460
461        assert!(ChargeDistribution::verify_distribution(
462            &distributions,
463            dec!(1000)
464        ));
465    }
466
467    #[test]
468    fn test_verify_distribution_with_rounding() {
469        let expense_id = Uuid::new_v4();
470        let unit_ownerships = vec![
471            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.333333)), // 1/3
472            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.333333)), // 1/3
473            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.333334)), // 1/3 avec arrondi
474        ];
475
476        let distributions =
477            ChargeDistribution::calculate_distributions(expense_id, dec!(1000), unit_ownerships)
478                .unwrap();
479
480        // Le total sera ~999.999 ou 1000.001 à cause des arrondis
481        // Devrait passer avec tolérance de 1 centime
482        assert!(ChargeDistribution::verify_distribution(
483            &distributions,
484            dec!(1000)
485        ));
486    }
487
488    #[test]
489    fn test_calculate_distributions_complex_scenario() {
490        // Scénario réaliste: immeuble avec 5 lots, quotes-parts variées
491        let expense_id = Uuid::new_v4();
492        let unit_ownerships = vec![
493            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.25)), // Lot A: 25%
494            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.20)), // Lot B: 20%
495            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.20)), // Lot C: 20%
496            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.20)), // Lot D: 20%
497            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.15)), // Lot E: 15%
498        ];
499
500        let total_invoice = dec!(5000);
501        let distributions =
502            ChargeDistribution::calculate_distributions(expense_id, total_invoice, unit_ownerships)
503                .unwrap();
504
505        assert_eq!(distributions.len(), 5);
506        assert_eq!(distributions[0].amount_due, dec!(1250.00)); // 25%
507        assert_eq!(distributions[1].amount_due, dec!(1000.00)); // 20%
508        assert_eq!(distributions[2].amount_due, dec!(1000.00)); // 20%
509        assert_eq!(distributions[3].amount_due, dec!(1000.00)); // 20%
510        assert_eq!(distributions[4].amount_due, dec!(750.00)); // 15%
511
512        assert!(ChargeDistribution::verify_distribution(
513            &distributions,
514            total_invoice
515        ));
516    }
517
518    #[test]
519    fn test_total_distributed_empty() {
520        let distributions: Vec<ChargeDistribution> = vec![];
521        assert_eq!(
522            ChargeDistribution::total_distributed(&distributions),
523            Decimal::ZERO
524        );
525    }
526
527    #[test]
528    fn test_quota_percentage_zero_is_valid() {
529        // Un lot peut avoir 0% de quote-part (cas particulier)
530        let expense_id = Uuid::new_v4();
531        let unit_id = Uuid::new_v4();
532        let owner_id = Uuid::new_v4();
533
534        let distribution =
535            ChargeDistribution::new(expense_id, unit_id, owner_id, Decimal::ZERO, dec!(1000));
536
537        assert!(distribution.is_ok());
538        let distribution = distribution.unwrap();
539        assert_eq!(distribution.amount_due, Decimal::ZERO);
540    }
541
542    #[test]
543    fn test_quota_percentage_exactly_one_is_valid() {
544        // Un seul propriétaire avec 100% de quote-part
545        let expense_id = Uuid::new_v4();
546        let unit_id = Uuid::new_v4();
547        let owner_id = Uuid::new_v4();
548
549        let distribution =
550            ChargeDistribution::new(expense_id, unit_id, owner_id, Decimal::ONE, dec!(1000));
551
552        assert!(distribution.is_ok());
553        let distribution = distribution.unwrap();
554        assert_eq!(distribution.amount_due, dec!(1000));
555    }
556
557    /// @edge — Decimal exactness preserved on cumul (ADR-0007).
558    #[test]
559    fn edge_distribution_decimal_exactness() {
560        // 1/10 * 3 = 0.3 exact en Decimal (en f64, 0.1+0.1+0.1 != 0.3)
561        let dist1 = ChargeDistribution::new(
562            Uuid::new_v4(),
563            Uuid::new_v4(),
564            Uuid::new_v4(),
565            dec!(0.1),
566            dec!(1),
567        )
568        .unwrap();
569        let dist2 = ChargeDistribution::new(
570            Uuid::new_v4(),
571            Uuid::new_v4(),
572            Uuid::new_v4(),
573            dec!(0.1),
574            dec!(1),
575        )
576        .unwrap();
577        let dist3 = ChargeDistribution::new(
578            Uuid::new_v4(),
579            Uuid::new_v4(),
580            Uuid::new_v4(),
581            dec!(0.1),
582            dec!(1),
583        )
584        .unwrap();
585
586        let dists = vec![dist1, dist2, dist3];
587        assert_eq!(ChargeDistribution::total_distributed(&dists), dec!(0.3));
588    }
589
590    // ------------------------------------------------------------------------
591    // 4 catégories #433/WP-A4 — taxonomie typée (CRITICAL.md #3). Le glue BDD
592    // (charge_distribution.feature) fixe une répartition valide à 100% via le
593    // Background et ne peut donc pas exercer comportementalement les chemins de
594    // rejet : ces invariants de l'entité domaine sont vérifiés ici en unitaire,
595    // sur l'erreur typée `ChargeDistributionError` (précédent WP-A3
596    // journal_entry.rs / commentaire journal_entries.feature).
597    // ------------------------------------------------------------------------
598
599    /// @happy — Nominal distribution: quotes-parts somment à 100%, total réparti
600    /// exactement, équilibre vérifié à 1 centime.
601    #[test]
602    fn happy_distribution_balances_to_total() {
603        let expense_id = Uuid::new_v4();
604        let ownerships = vec![
605            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.40)),
606            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.35)),
607            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.25)),
608        ];
609
610        let dists = ChargeDistribution::calculate_distributions(expense_id, dec!(1000), ownerships)
611            .unwrap();
612
613        assert_eq!(dists.len(), 3);
614        assert_eq!(ChargeDistribution::total_distributed(&dists), dec!(1000.00));
615        assert!(ChargeDistribution::verify_distribution(&dists, dec!(1000)));
616    }
617
618    /// @edge — Borne exacte de la tolérance de somme des quotités :
619    /// 100.01% (= QUOTA_SUM_TOLERANCE) passe, 100.011% est rejeté.
620    #[test]
621    fn edge_quota_sum_at_tolerance_boundary() {
622        let expense_id = Uuid::new_v4();
623
624        // Exactement 1.0001 (100.01%) — accepté (borne stricte `>`).
625        let at_boundary = vec![
626            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.5000)),
627            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.5001)),
628        ];
629        assert!(
630            ChargeDistribution::calculate_distributions(expense_id, dec!(1000), at_boundary)
631                .is_ok(),
632            "Σ quotités == 1.0001 doit passer (borne de tolérance)"
633        );
634
635        // 1.00011 (100.011%) — au-delà de la tolérance, rejeté.
636        let over_boundary = vec![
637            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.50000)),
638            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.50011)),
639        ];
640        assert!(matches!(
641            ChargeDistribution::calculate_distributions(expense_id, dec!(1000), over_boundary)
642                .unwrap_err(),
643            ChargeDistributionError::QuotaSumExceeds { .. }
644        ));
645    }
646
647    /// @negative — Quote-part > 1 ou négative rejetée (erreur typée, pas de panic).
648    #[test]
649    fn negative_quota_out_of_range_rejected() {
650        let above = ChargeDistribution::new(
651            Uuid::new_v4(),
652            Uuid::new_v4(),
653            Uuid::new_v4(),
654            dec!(1.5),
655            dec!(1000),
656        );
657        assert!(matches!(
658            above.unwrap_err(),
659            ChargeDistributionError::QuotaOutOfRange(_)
660        ));
661
662        let negative = ChargeDistribution::new(
663            Uuid::new_v4(),
664            Uuid::new_v4(),
665            Uuid::new_v4(),
666            dec!(-0.1),
667            dec!(1000),
668        );
669        assert!(matches!(
670            negative.unwrap_err(),
671            ChargeDistributionError::QuotaOutOfRange(_)
672        ));
673    }
674
675    /// @negative — Montant total négatif rejeté (erreur typée).
676    #[test]
677    fn negative_total_amount_rejected() {
678        let result = ChargeDistribution::new(
679            Uuid::new_v4(),
680            Uuid::new_v4(),
681            Uuid::new_v4(),
682            dec!(0.25),
683            dec!(-1000),
684        );
685        assert!(matches!(
686            result.unwrap_err(),
687            ChargeDistributionError::NegativeTotalAmount
688        ));
689    }
690
691    /// @security — Une table de quotités falsifiée sommant à > 100% ne doit
692    /// jamais permettre de sur-répartir une charge (sur-facturation des
693    /// copropriétaires) : invariant d'intégrité financière (#433/WP-A4).
694    #[test]
695    fn security_quota_sum_overflow_prevents_overcharge() {
696        let expense_id = Uuid::new_v4();
697        // Σ = 130% — tentative de sur-distribution.
698        let tampered = vec![
699            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.70)),
700            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.60)),
701        ];
702
703        let result = ChargeDistribution::calculate_distributions(expense_id, dec!(10000), tampered);
704
705        assert!(matches!(
706            result.unwrap_err(),
707            ChargeDistributionError::QuotaSumExceeds { .. }
708        ));
709    }
710
711    // ------------------------------------------------------------------------
712    // Story H12 (CL4) — DistributionCriteria + formule deux niveaux (4-cat).
713    // ------------------------------------------------------------------------
714
715    /// @happy — Critère `value` : quote-part effective d'un copropriétaire =
716    /// (quotité du lot / total tantièmes acte de base) × part dans le lot.
717    #[test]
718    fn happy_resolve_owner_quota_by_value() {
719        // Lot 250/1000 (acte de base 1000), copropriétaire unique (100%).
720        let q =
721            ChargeDistribution::resolve_owner_quota(dec!(250), dec!(1000), Decimal::ONE).unwrap();
722        assert_eq!(q, dec!(0.25));
723        // Même lot sur base 10000 (acte de base à 10000) : 2500/10000 = 0.25.
724        let q10000 =
725            ChargeDistribution::resolve_owner_quota(dec!(2500), dec!(10000), Decimal::ONE).unwrap();
726        assert_eq!(q10000, dec!(0.25));
727        // Indivision 50/50 : 0.25 × 0.5 = 0.125.
728        let half =
729            ChargeDistribution::resolve_owner_quota(dec!(250), dec!(1000), dec!(0.5)).unwrap();
730        assert_eq!(half, dec!(0.125));
731    }
732
733    /// @edge — Critère `utility` (base alternative) enregistré distinctement de
734    /// `value` ; le défaut reste `value`.
735    #[test]
736    fn edge_utility_criteria_is_recorded() {
737        let expense_id = Uuid::new_v4();
738        let ownerships = vec![
739            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.70)), // utilité (ex. ascenseur)
740            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.30)),
741        ];
742        let dists = ChargeDistribution::calculate_distributions_with_criteria(
743            expense_id,
744            dec!(1000),
745            ownerships,
746            DistributionCriteria::Utility,
747        )
748        .unwrap();
749        assert_eq!(dists.len(), 2);
750        assert!(dists
751            .iter()
752            .all(|d| d.distribution_criteria == DistributionCriteria::Utility));
753
754        let dflt = ChargeDistribution::new(
755            expense_id,
756            Uuid::new_v4(),
757            Uuid::new_v4(),
758            dec!(0.5),
759            dec!(1000),
760        )
761        .unwrap();
762        assert_eq!(dflt.distribution_criteria, DistributionCriteria::Value);
763    }
764
765    /// @security — Un critère non prévu par la loi (≠ value/utility/mixed) est
766    /// refusé (erreur typée), jamais rabattu silencieusement.
767    #[test]
768    fn security_unknown_criteria_rejected() {
769        use std::str::FromStr;
770        assert_eq!(
771            DistributionCriteria::from_str("value").unwrap(),
772            DistributionCriteria::Value
773        );
774        assert_eq!(
775            DistributionCriteria::from_str("UTILITY").unwrap(),
776            DistributionCriteria::Utility
777        );
778        let err = DistributionCriteria::from_str("au_pif").unwrap_err();
779        assert!(matches!(err, ChargeDistributionError::UnknownCriteria(_)));
780    }
781
782    /// @negative — Somme des lignes ≠ total → distribution non équilibrée
783    /// détectée ; base de tantièmes nulle → erreur typée (pas de division par 0).
784    #[test]
785    fn negative_sum_mismatch_and_zero_tantiemes() {
786        let expense_id = Uuid::new_v4();
787        // Σ quotités = 80% sur 1000 → total réparti 800 ≠ 1000.
788        let ownerships = vec![
789            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.40)),
790            (Uuid::new_v4(), Uuid::new_v4(), dec!(0.40)),
791        ];
792        let dists = ChargeDistribution::calculate_distributions(expense_id, dec!(1000), ownerships)
793            .unwrap();
794        assert_eq!(ChargeDistribution::total_distributed(&dists), dec!(800.00));
795        assert!(!ChargeDistribution::verify_distribution(&dists, dec!(1000)));
796
797        // Base de tantièmes 0 → erreur typée (acte de base invalide, pas de div/0).
798        let err = ChargeDistribution::resolve_owner_quota(dec!(250), Decimal::ZERO, Decimal::ONE)
799            .unwrap_err();
800        assert!(matches!(
801            err,
802            ChargeDistributionError::InvalidTotalTantiemes(_)
803        ));
804    }
805}