1use crate::application::dto::ChargeDistributionResponseDto;
2use crate::application::ports::{
3 AcpRepository, BuildingRepository, ChargeDistributionRepository, ExpenseRepository,
4 UnitOwnerRepository,
5};
6use crate::domain::entities::{ApprovalStatus, ChargeDistribution};
7use rust_decimal::Decimal;
8use std::sync::Arc;
9use uuid::Uuid;
10
11pub struct ChargeDistributionUseCases {
12 distribution_repository: Arc<dyn ChargeDistributionRepository>,
13 expense_repository: Arc<dyn ExpenseRepository>,
14 unit_owner_repository: Arc<dyn UnitOwnerRepository>,
15 building_repository: Option<Arc<dyn BuildingRepository>>,
20 acp_repository: Option<Arc<dyn AcpRepository>>,
21}
22
23impl ChargeDistributionUseCases {
24 pub fn new(
25 distribution_repository: Arc<dyn ChargeDistributionRepository>,
26 expense_repository: Arc<dyn ExpenseRepository>,
27 unit_owner_repository: Arc<dyn UnitOwnerRepository>,
28 ) -> Self {
29 Self {
30 distribution_repository,
31 expense_repository,
32 unit_owner_repository,
33 building_repository: None,
34 acp_repository: None,
35 }
36 }
37
38 pub fn with_full_wiring(
40 distribution_repository: Arc<dyn ChargeDistributionRepository>,
41 expense_repository: Arc<dyn ExpenseRepository>,
42 unit_owner_repository: Arc<dyn UnitOwnerRepository>,
43 building_repository: Arc<dyn BuildingRepository>,
44 acp_repository: Arc<dyn AcpRepository>,
45 ) -> Self {
46 Self {
47 distribution_repository,
48 expense_repository,
49 unit_owner_repository,
50 building_repository: Some(building_repository),
51 acp_repository: Some(acp_repository),
52 }
53 }
54
55 async fn assert_acp_conformant(&self, building_id: Uuid) -> Result<(), String> {
58 let (Some(building_repo), Some(acp_repo)) =
59 (&self.building_repository, &self.acp_repository)
60 else {
61 return Ok(());
62 };
63 let building = building_repo
64 .find_by_id(building_id)
65 .await?
66 .ok_or_else(|| "Building not found".to_string())?;
67 let (acp, metrics) = acp_repo
68 .find_by_id_with_metrics(building.acp_id)
69 .await
70 .map_err(|e| e.to_string())?
71 .ok_or_else(|| "ACP not found".to_string())?;
72 acp.assert_conformant(&metrics)?; Ok(())
74 }
75
76 pub async fn calculate_and_save_distribution(
78 &self,
79 expense_id: Uuid,
80 ) -> Result<Vec<ChargeDistributionResponseDto>, String> {
81 let expense = self
83 .expense_repository
84 .find_by_id(expense_id)
85 .await?
86 .ok_or_else(|| "Expense/Invoice not found".to_string())?;
87
88 if expense.approval_status != ApprovalStatus::Approved {
90 return Err(format!(
91 "Cannot calculate distribution for non-approved invoice (status: {:?})",
92 expense.approval_status
93 ));
94 }
95
96 self.assert_acp_conformant(expense.building_id).await?;
100
101 let total_amount = expense.amount_incl_vat.unwrap_or(expense.amount);
103
104 let unit_ownerships = self
111 .unit_owner_repository
112 .find_active_quota_shares_by_building(expense.building_id)
113 .await?;
114
115 if unit_ownerships.is_empty() {
116 return Err("No active unit-owner relationships found for this building".to_string());
117 }
118
119 let lots_detenus: std::collections::HashSet<Uuid> = unit_ownerships
122 .iter()
123 .map(|(unit_id, _, _)| *unit_id)
124 .collect();
125 let somme_des_parts: Decimal = unit_ownerships.iter().map(|(_, _, part)| *part).sum();
126
127 let distributions =
129 ChargeDistribution::calculate_distributions(expense_id, total_amount, unit_ownerships)?;
130
131 if !ChargeDistribution::verify_distribution(&distributions, total_amount) {
157 let reparti = ChargeDistribution::total_distributed(&distributions);
158 return Err(format!(
159 "Distribution does not cover the charge: {reparti} distributed for \
160 {total_amount} due (delta {delta}). La somme des quotes-parts \
161 actives vaut {somme_des_parts} pour 1 attendu, répartie sur \
162 {nb_lots} lot(s) détenu(s).\n\
163 \n\
164 Deux causes possibles, dans cet ordre de fréquence :\n\
165 1. des lots sans détenteur actif — leur quote-part n'est alors \
166 réclamée à personne, et la somme des parts tombe sous 1 ;\n\
167 2. une divergence entre `acps.total_tantiemes` et \
168 `buildings.total_tantiemes`, qui sont saisis séparément et que \
169 rien ne tient ensemble.",
170 reparti = reparti,
171 total_amount = total_amount,
172 delta = reparti - total_amount,
173 somme_des_parts = somme_des_parts,
174 nb_lots = lots_detenus.len(),
175 ));
176 }
177
178 let saved_distributions = self
180 .distribution_repository
181 .create_bulk(&distributions)
182 .await?;
183
184 Ok(saved_distributions
186 .iter()
187 .map(|d| self.to_response_dto(d))
188 .collect())
189 }
190
191 pub async fn get_distribution_by_expense(
193 &self,
194 expense_id: Uuid,
195 ) -> Result<Vec<ChargeDistributionResponseDto>, String> {
196 let distributions = self
197 .distribution_repository
198 .find_by_expense(expense_id)
199 .await?;
200 Ok(distributions
201 .iter()
202 .map(|d| self.to_response_dto(d))
203 .collect())
204 }
205
206 pub async fn get_distributions_by_owner(
208 &self,
209 owner_id: Uuid,
210 ) -> Result<Vec<ChargeDistributionResponseDto>, String> {
211 let distributions = self.distribution_repository.find_by_owner(owner_id).await?;
212 Ok(distributions
213 .iter()
214 .map(|d| self.to_response_dto(d))
215 .collect())
216 }
217
218 pub async fn get_total_due_by_owner(&self, owner_id: Uuid) -> Result<Decimal, String> {
220 self.distribution_repository
221 .get_total_due_by_owner(owner_id)
222 .await
223 }
224
225 pub async fn delete_distribution_by_expense(&self, expense_id: Uuid) -> Result<(), String> {
227 self.distribution_repository
228 .delete_by_expense(expense_id)
229 .await
230 }
231
232 fn to_response_dto(&self, distribution: &ChargeDistribution) -> ChargeDistributionResponseDto {
233 ChargeDistributionResponseDto {
234 id: distribution.id.to_string(),
235 expense_id: distribution.expense_id.to_string(),
236 unit_id: distribution.unit_id.to_string(),
237 owner_id: distribution.owner_id.to_string(),
238 quota_percentage: distribution.quota_percentage,
239 amount_due: distribution.amount_due,
240 distribution_criteria: distribution.distribution_criteria.to_string(),
241 created_at: distribution.created_at.to_rfc3339(),
242 }
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use crate::application::dto::{ExpenseFilters, PageRequest};
250 use crate::application::ports::{
251 ChargeDistributionRepository, ExpenseRepository, UnitOwnerRepository,
252 };
253 use crate::domain::entities::{
254 ApprovalStatus, ChargeDistribution, Expense, ExpenseCategory, PaymentStatus, UnitOwner,
255 };
256 use async_trait::async_trait;
257 use chrono::Utc;
258 use rust_decimal_macros::dec;
259 use std::collections::HashMap;
260 use std::sync::Mutex;
261
262 struct MockChargeDistributionRepository {
265 distributions: Mutex<HashMap<Uuid, ChargeDistribution>>,
266 }
267
268 impl MockChargeDistributionRepository {
269 fn new() -> Self {
270 Self {
271 distributions: Mutex::new(HashMap::new()),
272 }
273 }
274 }
275
276 #[async_trait]
277 impl ChargeDistributionRepository for MockChargeDistributionRepository {
278 async fn create(
279 &self,
280 distribution: &ChargeDistribution,
281 ) -> Result<ChargeDistribution, String> {
282 let mut distributions = self.distributions.lock().unwrap();
283 distributions.insert(distribution.id, distribution.clone());
284 Ok(distribution.clone())
285 }
286
287 async fn create_bulk(
288 &self,
289 distributions: &[ChargeDistribution],
290 ) -> Result<Vec<ChargeDistribution>, String> {
291 let mut store = self.distributions.lock().unwrap();
292 let mut result = Vec::new();
293 for d in distributions {
294 store.insert(d.id, d.clone());
295 result.push(d.clone());
296 }
297 Ok(result)
298 }
299
300 async fn find_by_id(&self, id: Uuid) -> Result<Option<ChargeDistribution>, String> {
301 let distributions = self.distributions.lock().unwrap();
302 Ok(distributions.get(&id).cloned())
303 }
304
305 async fn find_by_expense(
306 &self,
307 expense_id: Uuid,
308 ) -> Result<Vec<ChargeDistribution>, String> {
309 let distributions = self.distributions.lock().unwrap();
310 Ok(distributions
311 .values()
312 .filter(|d| d.expense_id == expense_id)
313 .cloned()
314 .collect())
315 }
316
317 async fn find_by_unit(&self, unit_id: Uuid) -> Result<Vec<ChargeDistribution>, String> {
318 let distributions = self.distributions.lock().unwrap();
319 Ok(distributions
320 .values()
321 .filter(|d| d.unit_id == unit_id)
322 .cloned()
323 .collect())
324 }
325
326 async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<ChargeDistribution>, String> {
327 let distributions = self.distributions.lock().unwrap();
328 Ok(distributions
329 .values()
330 .filter(|d| d.owner_id == owner_id)
331 .cloned()
332 .collect())
333 }
334
335 async fn delete_by_expense(&self, expense_id: Uuid) -> Result<(), String> {
336 let mut distributions = self.distributions.lock().unwrap();
337 distributions.retain(|_, d| d.expense_id != expense_id);
338 Ok(())
339 }
340
341 async fn get_total_due_by_owner(&self, owner_id: Uuid) -> Result<Decimal, String> {
342 let distributions = self.distributions.lock().unwrap();
343 let total = distributions
344 .values()
345 .filter(|d| d.owner_id == owner_id)
346 .map(|d| d.amount_due)
347 .sum();
348 Ok(total)
349 }
350 }
351
352 struct MockExpenseRepository {
355 expenses: Mutex<HashMap<Uuid, Expense>>,
356 }
357
358 impl MockExpenseRepository {
359 fn new() -> Self {
360 Self {
361 expenses: Mutex::new(HashMap::new()),
362 }
363 }
364
365 fn with_expense(expense: Expense) -> Self {
366 let mut map = HashMap::new();
367 map.insert(expense.id, expense);
368 Self {
369 expenses: Mutex::new(map),
370 }
371 }
372 }
373
374 #[async_trait]
375 impl ExpenseRepository for MockExpenseRepository {
376 async fn enregistrer_lignes_de_facture(
377 &self,
378 _expense_id: Uuid,
379 _lignes: &[crate::application::ports::expense_repository::LigneDeFacture],
380 ) -> Result<(), String> {
381 Ok(())
385 }
386
387 async fn create(&self, expense: &Expense) -> Result<Expense, String> {
388 let mut expenses = self.expenses.lock().unwrap();
389 expenses.insert(expense.id, expense.clone());
390 Ok(expense.clone())
391 }
392
393 async fn find_by_id(&self, id: Uuid) -> Result<Option<Expense>, String> {
394 let expenses = self.expenses.lock().unwrap();
395 Ok(expenses.get(&id).cloned())
396 }
397
398 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Expense>, String> {
399 let expenses = self.expenses.lock().unwrap();
400 Ok(expenses
401 .values()
402 .filter(|e| e.building_id == building_id)
403 .cloned()
404 .collect())
405 }
406
407 async fn find_all_paginated(
408 &self,
409 _page_request: &PageRequest,
410 _filters: &ExpenseFilters,
411 ) -> Result<(Vec<Expense>, i64), String> {
412 let expenses = self.expenses.lock().unwrap();
413 let all: Vec<Expense> = expenses.values().cloned().collect();
414 let count = all.len() as i64;
415 Ok((all, count))
416 }
417
418 async fn update(&self, expense: &Expense) -> Result<Expense, String> {
419 let mut expenses = self.expenses.lock().unwrap();
420 expenses.insert(expense.id, expense.clone());
421 Ok(expense.clone())
422 }
423
424 async fn delete(&self, id: Uuid) -> Result<bool, String> {
425 let mut expenses = self.expenses.lock().unwrap();
426 Ok(expenses.remove(&id).is_some())
427 }
428 }
429
430 type BuildingOwnerships = HashMap<Uuid, Vec<(Uuid, Uuid, Decimal)>>;
434
435 struct MockUnitOwnerRepository {
436 building_ownerships: Mutex<BuildingOwnerships>,
438 }
439
440 impl MockUnitOwnerRepository {
441 fn new() -> Self {
442 Self {
443 building_ownerships: Mutex::new(HashMap::new()),
444 }
445 }
446
447 fn with_building_ownerships(
448 building_id: Uuid,
449 ownerships: Vec<(Uuid, Uuid, Decimal)>,
450 ) -> Self {
451 let mut map = HashMap::new();
452 map.insert(building_id, ownerships);
453 Self {
454 building_ownerships: Mutex::new(map),
455 }
456 }
457 }
458
459 #[async_trait]
460 impl UnitOwnerRepository for MockUnitOwnerRepository {
461 async fn create(&self, _unit_owner: &UnitOwner) -> Result<UnitOwner, String> {
462 unimplemented!("not needed for charge distribution tests")
463 }
464
465 async fn find_by_id(&self, _id: Uuid) -> Result<Option<UnitOwner>, String> {
466 unimplemented!("not needed for charge distribution tests")
467 }
468
469 async fn find_current_owners_by_unit(
470 &self,
471 _unit_id: Uuid,
472 ) -> Result<Vec<UnitOwner>, String> {
473 unimplemented!("not needed for charge distribution tests")
474 }
475
476 async fn find_current_units_by_owner(
477 &self,
478 _owner_id: Uuid,
479 ) -> Result<Vec<UnitOwner>, String> {
480 unimplemented!("not needed for charge distribution tests")
481 }
482
483 async fn find_all_owners_by_unit(&self, _unit_id: Uuid) -> Result<Vec<UnitOwner>, String> {
484 unimplemented!("not needed for charge distribution tests")
485 }
486
487 async fn find_all_units_by_owner(&self, _owner_id: Uuid) -> Result<Vec<UnitOwner>, String> {
488 unimplemented!("not needed for charge distribution tests")
489 }
490
491 async fn update(&self, _unit_owner: &UnitOwner) -> Result<UnitOwner, String> {
492 unimplemented!("not needed for charge distribution tests")
493 }
494
495 async fn delete(&self, _id: Uuid) -> Result<(), String> {
496 unimplemented!("not needed for charge distribution tests")
497 }
498
499 async fn has_active_owners(&self, _unit_id: Uuid) -> Result<bool, String> {
500 unimplemented!("not needed for charge distribution tests")
501 }
502
503 async fn get_total_ownership_percentage(&self, _unit_id: Uuid) -> Result<Decimal, String> {
504 unimplemented!("not needed for charge distribution tests")
505 }
506
507 async fn find_active_by_unit_and_owner(
508 &self,
509 _unit_id: Uuid,
510 _owner_id: Uuid,
511 ) -> Result<Option<UnitOwner>, String> {
512 unimplemented!("not needed for charge distribution tests")
513 }
514
515 async fn find_active_by_building(
516 &self,
517 building_id: Uuid,
518 ) -> Result<Vec<(Uuid, Uuid, Decimal)>, String> {
519 let ownerships = self.building_ownerships.lock().unwrap();
520 Ok(ownerships.get(&building_id).cloned().unwrap_or_default())
521 }
522
523 async fn find_active_quota_shares_by_building(
526 &self,
527 building_id: Uuid,
528 ) -> Result<Vec<(Uuid, Uuid, Decimal)>, String> {
529 let ownerships = self.building_ownerships.lock().unwrap();
530 Ok(ownerships.get(&building_id).cloned().unwrap_or_default())
531 }
532
533 async fn find_voting_holders_by_unit(
534 &self,
535 _unit_id: Uuid,
536 ) -> Result<Vec<crate::domain::entities::LotHolder>, String> {
537 Ok(vec![])
538 }
539
540 async fn is_voting_representative(&self, _unit_owner_id: Uuid) -> Result<bool, String> {
541 Ok(false)
542 }
543
544 async fn set_voting_representative(&self, _unit_owner_id: Uuid) -> Result<(), String> {
545 Ok(())
546 }
547 }
548
549 fn make_approved_expense(building_id: Uuid, amount_incl_vat: Decimal) -> Expense {
552 let now = Utc::now();
553 let vat_factor = dec!(1.21);
554 let amount_excl_vat = amount_incl_vat / vat_factor;
555 Expense {
556 id: Uuid::new_v4(),
557 acp_id: Uuid::new_v4(),
558 organization_id: Uuid::new_v4(),
559 building_id,
560 category: ExpenseCategory::Maintenance,
561 description: "Elevator maintenance".to_string(),
562 amount: amount_incl_vat,
563 amount_excl_vat: Some(amount_excl_vat),
564 vat_rate: Some(dec!(21)),
565 vat_amount: Some(amount_incl_vat - amount_excl_vat),
566 amount_incl_vat: Some(amount_incl_vat),
567 expense_date: now,
568 invoice_date: Some(now),
569 due_date: None,
570 paid_date: None,
571 approval_status: ApprovalStatus::Approved,
572 submitted_at: Some(now),
573 approved_by: Some(Uuid::new_v4()),
574 approved_at: Some(now),
575 rejection_reason: None,
576 payment_status: PaymentStatus::Pending,
577 supplier: Some("Schindler SA".to_string()),
578 invoice_number: Some("INV-001".to_string()),
579 account_code: Some("611002".to_string()),
580 contractor_report_id: None,
581 created_at: now,
582 updated_at: now,
583 }
584 }
585
586 fn make_draft_expense(building_id: Uuid) -> Expense {
587 let now = Utc::now();
588 Expense {
589 id: Uuid::new_v4(),
590 acp_id: Uuid::new_v4(),
591 organization_id: Uuid::new_v4(),
592 building_id,
593 category: ExpenseCategory::Maintenance,
594 description: "Draft expense".to_string(),
595 amount: dec!(1000),
596 amount_excl_vat: None,
597 vat_rate: None,
598 vat_amount: None,
599 amount_incl_vat: None,
600 expense_date: now,
601 invoice_date: None,
602 due_date: None,
603 paid_date: None,
604 approval_status: ApprovalStatus::Draft,
605 submitted_at: None,
606 approved_by: None,
607 approved_at: None,
608 rejection_reason: None,
609 payment_status: PaymentStatus::Pending,
610 supplier: None,
611 invoice_number: None,
612 account_code: None,
613 contractor_report_id: None,
614 created_at: now,
615 updated_at: now,
616 }
617 }
618
619 fn make_use_cases(
620 dist_repo: MockChargeDistributionRepository,
621 expense_repo: MockExpenseRepository,
622 unit_owner_repo: MockUnitOwnerRepository,
623 ) -> ChargeDistributionUseCases {
624 ChargeDistributionUseCases::new(
625 Arc::new(dist_repo),
626 Arc::new(expense_repo),
627 Arc::new(unit_owner_repo),
628 )
629 }
630
631 #[tokio::test]
634 async fn test_calculate_and_save_distribution_success() {
635 let building_id = Uuid::new_v4();
636 let unit1_id = Uuid::new_v4();
637 let unit2_id = Uuid::new_v4();
638 let owner1_id = Uuid::new_v4();
639 let owner2_id = Uuid::new_v4();
640
641 let expense = make_approved_expense(building_id, dec!(1000));
642 let expense_id = expense.id;
643
644 let ownerships = vec![
645 (unit1_id, owner1_id, dec!(0.60)), (unit2_id, owner2_id, dec!(0.40)), ];
648
649 let dist_repo = MockChargeDistributionRepository::new();
650 let expense_repo = MockExpenseRepository::with_expense(expense);
651 let unit_owner_repo =
652 MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
653
654 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
655
656 let result = uc.calculate_and_save_distribution(expense_id).await;
657 assert!(result.is_ok());
658
659 let distributions = result.unwrap();
660 assert_eq!(distributions.len(), 2);
661
662 let total_amount: Decimal = distributions.iter().map(|d| d.amount_due).sum();
664 assert_eq!(total_amount, dec!(1000));
665
666 assert!(distributions
668 .iter()
669 .all(|d| d.expense_id == expense_id.to_string()));
670 }
671
672 #[tokio::test]
685 async fn test_repartition_incomplete_est_refusee() {
686 let building_id = Uuid::new_v4();
687 let expense = make_approved_expense(building_id, dec!(1000));
688 let expense_id = expense.id;
689
690 let ownerships = vec![
691 (Uuid::new_v4(), Uuid::new_v4(), dec!(0.30)),
692 (Uuid::new_v4(), Uuid::new_v4(), dec!(0.40)),
693 ];
694
695 let dist_repo = MockChargeDistributionRepository::new();
696 let uc = make_use_cases(
697 dist_repo,
698 MockExpenseRepository::with_expense(expense),
699 MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships),
700 );
701
702 let err = uc
703 .calculate_and_save_distribution(expense_id)
704 .await
705 .expect_err("une répartition à 70 % doit être refusée");
706 assert!(
707 err.contains("does not cover the charge"),
708 "le message doit nommer l'écart : {err}"
709 );
710 assert!(
711 err.contains("700"),
712 "le message doit chiffrer ce qui a été réparti : {err}"
713 );
714 assert!(
725 err.contains("0.70") && err.contains("2 lot"),
726 "le message doit donner la somme des parts et le nombre de lots \
727 détenus, qui sont ce qu'on peut vérifier : {err}"
728 );
729 assert!(
730 err.contains("sans détenteur actif"),
731 "le message doit nommer la cause la plus fréquente en premier : \
732 {err}"
733 );
734 }
735
736 #[tokio::test]
742 async fn test_arrondi_au_centime_reste_accepte() {
743 let building_id = Uuid::new_v4();
744 let expense = make_approved_expense(building_id, dec!(1000));
745 let expense_id = expense.id;
746
747 let trop_grossier = vec![
750 (Uuid::new_v4(), Uuid::new_v4(), dec!(0.3333)),
751 (Uuid::new_v4(), Uuid::new_v4(), dec!(0.3333)),
752 (Uuid::new_v4(), Uuid::new_v4(), dec!(0.3333)),
753 ];
754 let uc = make_use_cases(
755 MockChargeDistributionRepository::new(),
756 MockExpenseRepository::with_expense(expense.clone()),
757 MockUnitOwnerRepository::with_building_ownerships(building_id, trop_grossier),
758 );
759 assert!(
760 uc.calculate_and_save_distribution(expense_id)
761 .await
762 .is_err(),
763 "10 centimes d'écart dépassent la tolérance"
764 );
765
766 let tiers = Decimal::ONE / Decimal::from(3);
769 let exact = vec![
770 (Uuid::new_v4(), Uuid::new_v4(), tiers),
771 (Uuid::new_v4(), Uuid::new_v4(), tiers),
772 (Uuid::new_v4(), Uuid::new_v4(), tiers),
773 ];
774 let uc2 = make_use_cases(
775 MockChargeDistributionRepository::new(),
776 MockExpenseRepository::with_expense(expense),
777 MockUnitOwnerRepository::with_building_ownerships(building_id, exact),
778 );
779 assert!(
780 uc2.calculate_and_save_distribution(expense_id)
781 .await
782 .is_ok(),
783 "une traîne d'arrondi sous le centime doit rester admise"
784 );
785 }
786
787 #[tokio::test]
788 async fn test_calculate_and_save_distribution_non_approved_expense() {
789 let building_id = Uuid::new_v4();
790 let expense = make_draft_expense(building_id);
791 let expense_id = expense.id;
792
793 let dist_repo = MockChargeDistributionRepository::new();
794 let expense_repo = MockExpenseRepository::with_expense(expense);
795 let unit_owner_repo = MockUnitOwnerRepository::new();
796
797 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
798
799 let result = uc.calculate_and_save_distribution(expense_id).await;
800 assert!(result.is_err());
801 let err = result.unwrap_err();
802 assert!(err.contains("non-approved invoice"));
803 }
804
805 #[tokio::test]
806 async fn test_calculate_and_save_distribution_expense_not_found() {
807 let dist_repo = MockChargeDistributionRepository::new();
808 let expense_repo = MockExpenseRepository::new(); let unit_owner_repo = MockUnitOwnerRepository::new();
810
811 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
812
813 let result = uc.calculate_and_save_distribution(Uuid::new_v4()).await;
814 assert!(result.is_err());
815 assert_eq!(result.unwrap_err(), "Expense/Invoice not found");
816 }
817
818 #[tokio::test]
819 async fn test_calculate_and_save_distribution_no_active_owners() {
820 let building_id = Uuid::new_v4();
821 let expense = make_approved_expense(building_id, dec!(1000));
822 let expense_id = expense.id;
823
824 let dist_repo = MockChargeDistributionRepository::new();
825 let expense_repo = MockExpenseRepository::with_expense(expense);
826 let unit_owner_repo =
828 MockUnitOwnerRepository::with_building_ownerships(building_id, vec![]);
829
830 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
831
832 let result = uc.calculate_and_save_distribution(expense_id).await;
833 assert!(result.is_err());
834 assert_eq!(
835 result.unwrap_err(),
836 "No active unit-owner relationships found for this building"
837 );
838 }
839
840 #[tokio::test]
841 async fn test_get_distribution_by_expense() {
842 let building_id = Uuid::new_v4();
843 let unit1_id = Uuid::new_v4();
844 let owner1_id = Uuid::new_v4();
845
846 let expense = make_approved_expense(building_id, dec!(500));
847 let expense_id = expense.id;
848
849 let ownerships = vec![(unit1_id, owner1_id, Decimal::ONE)]; let dist_repo = MockChargeDistributionRepository::new();
852 let expense_repo = MockExpenseRepository::with_expense(expense);
853 let unit_owner_repo =
854 MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
855
856 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
857
858 uc.calculate_and_save_distribution(expense_id)
860 .await
861 .unwrap();
862
863 let result = uc.get_distribution_by_expense(expense_id).await;
865 assert!(result.is_ok());
866 let distributions = result.unwrap();
867 assert_eq!(distributions.len(), 1);
868 assert_eq!(distributions[0].expense_id, expense_id.to_string());
869 assert_eq!(distributions[0].quota_percentage, Decimal::ONE);
870 assert_eq!(distributions[0].amount_due, dec!(500.00));
871 }
872
873 #[tokio::test]
874 async fn test_get_distribution_by_expense_empty() {
875 let dist_repo = MockChargeDistributionRepository::new();
876 let expense_repo = MockExpenseRepository::new();
877 let unit_owner_repo = MockUnitOwnerRepository::new();
878
879 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
880
881 let result = uc.get_distribution_by_expense(Uuid::new_v4()).await;
882 assert!(result.is_ok());
883 assert!(result.unwrap().is_empty());
884 }
885
886 #[tokio::test]
887 async fn test_get_total_due_by_owner() {
888 let building_id = Uuid::new_v4();
889 let unit1_id = Uuid::new_v4();
890 let unit2_id = Uuid::new_v4();
891 let owner1_id = Uuid::new_v4();
892 let owner2_id = Uuid::new_v4();
893
894 let expense1 = make_approved_expense(building_id, dec!(1000));
896 let expense1_id = expense1.id;
897 let expense2 = make_approved_expense(building_id, dec!(2000));
898 let expense2_id = expense2.id;
899
900 let ownerships = vec![
901 (unit1_id, owner1_id, dec!(0.60)), (unit2_id, owner2_id, dec!(0.40)), ];
904
905 let dist_repo = MockChargeDistributionRepository::new();
906 let mut expense_map = HashMap::new();
907 expense_map.insert(expense1.id, expense1);
908 expense_map.insert(expense2.id, expense2);
909 let expense_repo = MockExpenseRepository {
910 expenses: Mutex::new(expense_map),
911 };
912 let unit_owner_repo =
913 MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
914
915 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
916
917 uc.calculate_and_save_distribution(expense1_id)
919 .await
920 .unwrap();
921 uc.calculate_and_save_distribution(expense2_id)
922 .await
923 .unwrap();
924
925 let total = uc.get_total_due_by_owner(owner1_id).await.unwrap();
927 assert_eq!(total, dec!(1800.00));
928
929 let total = uc.get_total_due_by_owner(owner2_id).await.unwrap();
931 assert_eq!(total, dec!(1200.00));
932 }
933
934 #[tokio::test]
935 async fn test_get_total_due_by_owner_no_distributions() {
936 let dist_repo = MockChargeDistributionRepository::new();
937 let expense_repo = MockExpenseRepository::new();
938 let unit_owner_repo = MockUnitOwnerRepository::new();
939
940 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
941
942 let total = uc.get_total_due_by_owner(Uuid::new_v4()).await.unwrap();
943 assert_eq!(total, Decimal::ZERO);
944 }
945
946 #[tokio::test]
947 async fn test_calculate_distribution_uses_amount_incl_vat() {
948 let building_id = Uuid::new_v4();
949 let unit_id = Uuid::new_v4();
950 let owner_id = Uuid::new_v4();
951
952 let now = Utc::now();
954 let expense = Expense {
955 id: Uuid::new_v4(),
956 acp_id: Uuid::new_v4(),
957 organization_id: Uuid::new_v4(),
958 building_id,
959 category: ExpenseCategory::Utilities,
960 description: "Electricity".to_string(),
961 amount: dec!(1000),
962 amount_excl_vat: Some(dec!(1000)),
963 vat_rate: Some(dec!(21)),
964 vat_amount: Some(dec!(210)),
965 amount_incl_vat: Some(dec!(1210)),
966 expense_date: now,
967 invoice_date: Some(now),
968 due_date: None,
969 paid_date: None,
970 approval_status: ApprovalStatus::Approved,
971 submitted_at: Some(now),
972 approved_by: Some(Uuid::new_v4()),
973 approved_at: Some(now),
974 rejection_reason: None,
975 payment_status: PaymentStatus::Pending,
976 supplier: None,
977 invoice_number: None,
978 account_code: None,
979 contractor_report_id: None,
980 created_at: now,
981 updated_at: now,
982 };
983 let expense_id = expense.id;
984
985 let ownerships = vec![(unit_id, owner_id, Decimal::ONE)]; let dist_repo = MockChargeDistributionRepository::new();
988 let expense_repo = MockExpenseRepository::with_expense(expense);
989 let unit_owner_repo =
990 MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
991
992 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
993
994 let result = uc
995 .calculate_and_save_distribution(expense_id)
996 .await
997 .unwrap();
998
999 assert_eq!(result.len(), 1);
1001 assert_eq!(result[0].amount_due, dec!(1210));
1002 }
1003}