1use chrono::{DateTime, Utc};
7use rust_decimal::Decimal;
8use rust_decimal_macros::dec;
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct ChargeDistribution {
17 pub id: Uuid,
18 pub expense_id: Uuid, pub unit_id: Uuid, pub owner_id: Uuid, pub quota_percentage: Decimal, pub amount_due: Decimal, pub created_at: DateTime<Utc>,
26}
27
28const DISTRIBUTION_TOLERANCE: Decimal = dec!(0.01);
30const 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 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 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 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 pub fn calculate_distributions(
82 expense_id: Uuid,
83 total_amount: Decimal,
84 unit_ownerships: Vec<(Uuid, Uuid, Decimal)>, ) -> Result<Vec<ChargeDistribution>, String> {
86 if total_amount < Decimal::ZERO {
87 return Err("Total amount cannot be negative".to_string());
88 }
89
90 let total_quota: Decimal = unit_ownerships.iter().map(|(_, _, q)| *q).sum();
92 if total_quota > QUOTA_SUM_TOLERANCE {
93 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 pub fn total_distributed(distributions: &[ChargeDistribution]) -> Decimal {
112 distributions.iter().map(|d| d.amount_due).sum()
113 }
114
115 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)); }
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 distribution.recalculate(dec!(1500)).unwrap();
187 assert_eq!(distribution.amount_due, dec!(300.00)); }
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)), (unit2_id, owner2_id, dec!(0.35)), (unit3_id, owner3_id, dec!(0.40)), ];
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 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 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)), (unit2_id, owner2_id, dec!(0.50)), ];
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)), (Uuid::new_v4(), Uuid::new_v4(), dec!(0.333333)), (Uuid::new_v4(), Uuid::new_v4(), dec!(0.333334)), ];
284
285 let distributions =
286 ChargeDistribution::calculate_distributions(expense_id, dec!(1000), unit_ownerships)
287 .unwrap();
288
289 assert!(ChargeDistribution::verify_distribution(
292 &distributions,
293 dec!(1000)
294 ));
295 }
296
297 #[test]
298 fn test_calculate_distributions_complex_scenario() {
299 let expense_id = Uuid::new_v4();
301 let unit_ownerships = vec![
302 (Uuid::new_v4(), Uuid::new_v4(), dec!(0.25)), (Uuid::new_v4(), Uuid::new_v4(), dec!(0.20)), (Uuid::new_v4(), Uuid::new_v4(), dec!(0.20)), (Uuid::new_v4(), Uuid::new_v4(), dec!(0.20)), (Uuid::new_v4(), Uuid::new_v4(), dec!(0.15)), ];
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)); assert_eq!(distributions[1].amount_due, dec!(1000.00)); assert_eq!(distributions[2].amount_due, dec!(1000.00)); assert_eq!(distributions[3].amount_due, dec!(1000.00)); assert_eq!(distributions[4].amount_due, dec!(750.00)); 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 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 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 #[test]
368 fn edge_distribution_decimal_exactness() {
369 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}