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, #[serde(default)]
28 pub distribution_criteria: DistributionCriteria,
29
30 pub created_at: DateTime<Utc>,
31}
32
33const DISTRIBUTION_TOLERANCE: Decimal = dec!(0.01);
35const QUOTA_SUM_TOLERANCE: Decimal = dec!(1.0001);
37
38#[derive(Debug, Clone, PartialEq)]
48pub enum ChargeDistributionError {
49 QuotaOutOfRange(Decimal),
51 NegativeTotalAmount,
53 QuotaSumExceeds { total_quota: Decimal },
56 InvalidTotalTantiemes(Decimal),
59 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
93impl From<ChargeDistributionError> for String {
97 fn from(e: ChargeDistributionError) -> String {
98 e.to_string()
99 }
100}
101
102#[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 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 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 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 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 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 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 pub fn calculate_distributions(
250 expense_id: Uuid,
251 total_amount: Decimal,
252 unit_ownerships: Vec<(Uuid, Uuid, Decimal)>, ) -> 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 pub fn calculate_distributions_with_criteria(
267 expense_id: Uuid,
268 total_amount: Decimal,
269 unit_ownerships: Vec<(Uuid, Uuid, Decimal)>, criteria: DistributionCriteria,
271 ) -> Result<Vec<ChargeDistribution>, ChargeDistributionError> {
272 if total_amount < Decimal::ZERO {
273 return Err(ChargeDistributionError::NegativeTotalAmount);
274 }
275
276 let total_quota: Decimal = unit_ownerships.iter().map(|(_, _, q)| *q).sum();
278 if total_quota > QUOTA_SUM_TOLERANCE {
279 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 pub fn total_distributed(distributions: &[ChargeDistribution]) -> Decimal {
301 distributions.iter().map(|d| d.amount_due).sum()
302 }
303
304 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)); }
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 distribution.recalculate(dec!(1500)).unwrap();
377 assert_eq!(distribution.amount_due, dec!(300.00)); }
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)), (unit2_id, owner2_id, dec!(0.35)), (unit3_id, owner3_id, dec!(0.40)), ];
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 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 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)), (unit2_id, owner2_id, dec!(0.50)), ];
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)), (Uuid::new_v4(), Uuid::new_v4(), dec!(0.333333)), (Uuid::new_v4(), Uuid::new_v4(), dec!(0.333334)), ];
475
476 let distributions =
477 ChargeDistribution::calculate_distributions(expense_id, dec!(1000), unit_ownerships)
478 .unwrap();
479
480 assert!(ChargeDistribution::verify_distribution(
483 &distributions,
484 dec!(1000)
485 ));
486 }
487
488 #[test]
489 fn test_calculate_distributions_complex_scenario() {
490 let expense_id = Uuid::new_v4();
492 let unit_ownerships = vec![
493 (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)), ];
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)); 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(
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 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 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 #[test]
559 fn edge_distribution_decimal_exactness() {
560 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 #[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 #[test]
621 fn edge_quota_sum_at_tolerance_boundary() {
622 let expense_id = Uuid::new_v4();
623
624 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 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 #[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 #[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 #[test]
695 fn security_quota_sum_overflow_prevents_overcharge() {
696 let expense_id = Uuid::new_v4();
697 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 #[test]
718 fn happy_resolve_owner_quota_by_value() {
719 let q =
721 ChargeDistribution::resolve_owner_quota(dec!(250), dec!(1000), Decimal::ONE).unwrap();
722 assert_eq!(q, dec!(0.25));
723 let q10000 =
725 ChargeDistribution::resolve_owner_quota(dec!(2500), dec!(10000), Decimal::ONE).unwrap();
726 assert_eq!(q10000, dec!(0.25));
727 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 #[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)), (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 #[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 #[test]
785 fn negative_sum_mismatch_and_zero_tantiemes() {
786 let expense_id = Uuid::new_v4();
787 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 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}