koprogo_api/domain/entities/
charge_distribution.rs1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub struct ChargeDistribution {
10 pub id: Uuid,
11 pub expense_id: Uuid, pub unit_id: Uuid, pub owner_id: Uuid, pub quota_percentage: f64, pub amount_due: f64, 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 if !(0.0..=1.0).contains("a_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 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 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 pub fn calculate_distributions(
70 expense_id: Uuid,
71 total_amount: f64,
72 unit_ownerships: Vec<(Uuid, Uuid, f64)>, ) -> Result<Vec<ChargeDistribution>, String> {
74 if total_amount < 0.0 {
75 return Err("Total amount cannot be negative".to_string());
76 }
77
78 let total_quota: f64 = unit_ownerships.iter().map(|(_, _, q)| q).sum();
80 if total_quota > 1.0001 {
81 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 pub fn total_distributed(distributions: &[ChargeDistribution]) -> f64 {
100 distributions.iter().map(|d| d.amount_due).sum()
101 }
102
103 pub fn verify_distribution(distributions: &[ChargeDistribution], expected_total: f64) -> bool {
105 let total = Self::total_distributed(distributions);
106 (total - expected_total).abs() < 0.01 }
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); }
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 distribution.recalculate(1500.0).unwrap();
169 assert_eq!(distribution.amount_due, 300.0); }
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), (unit2_id, owner2_id, 0.35), (unit3_id, owner3_id, 0.40), ];
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 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 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), (unit2_id, owner2_id, 0.50), ];
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), (Uuid::new_v4(), Uuid::new_v4(), 0.333333), (Uuid::new_v4(), Uuid::new_v4(), 0.333334), ];
266
267 let distributions =
268 ChargeDistribution::calculate_distributions(expense_id, 1000.0, unit_ownerships)
269 .unwrap();
270
271 assert!(ChargeDistribution::verify_distribution(
274 &distributions,
275 1000.0
276 ));
277 }
278
279 #[test]
280 fn test_calculate_distributions_complex_scenario() {
281 let expense_id = Uuid::new_v4();
283 let unit_ownerships = vec![
284 (Uuid::new_v4(), Uuid::new_v4(), 0.25), (Uuid::new_v4(), Uuid::new_v4(), 0.20), (Uuid::new_v4(), Uuid::new_v4(), 0.20), (Uuid::new_v4(), Uuid::new_v4(), 0.20), (Uuid::new_v4(), Uuid::new_v4(), 0.15), ];
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); assert_eq!(distributions[1].amount_due, 1000.0); assert_eq!(distributions[2].amount_due, 1000.0); assert_eq!(distributions[3].amount_due, 1000.0); assert_eq!(distributions[4].amount_due, 750.0); 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 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 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}