Skip to main content

koprogo_api/domain/services/
expense_calculator.rs

1//! Domain service: ExpenseCalculator
2//!
3//! MONETARY: All sums and shares use rust_decimal::Decimal (cf. ADR-0007).
4
5use crate::domain::entities::{Expense, Unit};
6use rust_decimal::Decimal;
7use rust_decimal_macros::dec;
8
9const QUOTA_DIVISOR: Decimal = dec!(1000);
10
11/// Service de domaine pour calculer la répartition des charges
12pub struct ExpenseCalculator;
13
14impl ExpenseCalculator {
15    /// Calcule le montant dû par un lot selon sa quote-part (quote-part / 1000)
16    pub fn calculate_unit_share(expense: &Expense, unit: &Unit) -> Decimal {
17        expense.amount * (unit.quota / QUOTA_DIVISOR)
18    }
19
20    /// Calcule le total des charges pour un ensemble de dépenses
21    pub fn calculate_total_expenses(expenses: &[Expense]) -> Decimal {
22        expenses.iter().map(|e| e.amount).sum()
23    }
24
25    /// Calcule le montant total payé
26    pub fn calculate_paid_expenses(expenses: &[Expense]) -> Decimal {
27        expenses
28            .iter()
29            .filter(|e| e.is_paid())
30            .map(|e| e.amount)
31            .sum()
32    }
33
34    /// Calcule le montant total impayé
35    pub fn calculate_unpaid_expenses(expenses: &[Expense]) -> Decimal {
36        expenses
37            .iter()
38            .filter(|e| !e.is_paid())
39            .map(|e| e.amount)
40            .sum()
41    }
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47    use crate::domain::entities::{ExpenseCategory, UnitType};
48    use chrono::Utc;
49    use uuid::Uuid;
50
51    #[test]
52    fn test_calculate_unit_share() {
53        let org_id = Uuid::new_v4();
54        let building_id = Uuid::new_v4();
55
56        let expense = Expense::new(
57            Uuid::new_v4(), // acp_id
58            org_id,
59            building_id,
60            ExpenseCategory::Maintenance,
61            "Test".to_string(),
62            dec!(1000),
63            Utc::now(),
64            None,
65            None,
66            None, // account_code
67        )
68        .unwrap();
69
70        let unit = Unit::new(
71            org_id,
72            building_id,
73            "A101".to_string(),
74            UnitType::Apartment,
75            Some(1),
76            75.0,
77            dec!(50), // 50/1000 = 5%
78        )
79        .unwrap();
80
81        let share = ExpenseCalculator::calculate_unit_share(&expense, &unit);
82        assert_eq!(share, dec!(50)); // 5% de 1000€ = 50€
83    }
84
85    #[test]
86    fn test_calculate_total_expenses() {
87        let org_id = Uuid::new_v4();
88        let building_id = Uuid::new_v4();
89
90        let expenses = vec![
91            Expense::new(
92                Uuid::new_v4(), // acp_id
93                org_id,
94                building_id,
95                ExpenseCategory::Maintenance,
96                "Test 1".to_string(),
97                dec!(100),
98                Utc::now(),
99                None,
100                None,
101                None, // account_code
102            )
103            .unwrap(),
104            Expense::new(
105                Uuid::new_v4(), // acp_id
106                org_id,
107                building_id,
108                ExpenseCategory::Repairs,
109                "Test 2".to_string(),
110                dec!(200),
111                Utc::now(),
112                None,
113                None,
114                None, // account_code
115            )
116            .unwrap(),
117        ];
118
119        let total = ExpenseCalculator::calculate_total_expenses(&expenses);
120        assert_eq!(total, dec!(300));
121    }
122
123    #[test]
124    fn test_calculate_paid_and_unpaid() {
125        let org_id = Uuid::new_v4();
126        let building_id = Uuid::new_v4();
127        let syndic_id = Uuid::new_v4();
128
129        let mut expense1 = Expense::new(
130            Uuid::new_v4(), // acp_id
131            org_id,
132            building_id,
133            ExpenseCategory::Maintenance,
134            "Test 1".to_string(),
135            dec!(100),
136            Utc::now(),
137            None,
138            None,
139            None, // account_code
140        )
141        .unwrap();
142        // Follow approval workflow before payment
143        expense1.submit_for_approval().unwrap();
144        expense1.approve(syndic_id).unwrap();
145        let _ = expense1.mark_as_paid();
146
147        let expense2 = Expense::new(
148            Uuid::new_v4(), // acp_id
149            org_id,
150            building_id,
151            ExpenseCategory::Repairs,
152            "Test 2".to_string(),
153            dec!(200),
154            Utc::now(),
155            None,
156            None,
157            None, // account_code
158        )
159        .unwrap();
160
161        let expenses = vec![expense1, expense2];
162
163        let paid = ExpenseCalculator::calculate_paid_expenses(&expenses);
164        let unpaid = ExpenseCalculator::calculate_unpaid_expenses(&expenses);
165
166        assert_eq!(paid, dec!(100));
167        assert_eq!(unpaid, dec!(200));
168    }
169}