1use crate::application::ports::{
2 AcpRepository, BuildingRepository, CallForFundsRepository, OwnerContributionRepository,
3 UnitOwnerRepository,
4};
5use crate::domain::entities::{CallForFunds, ContributionType, OwnerContribution};
6use chrono::{DateTime, Utc};
7use std::sync::Arc;
8use uuid::Uuid;
9
10pub struct CallForFundsUseCases {
11 call_for_funds_repository: Arc<dyn CallForFundsRepository>,
12 owner_contribution_repository: Arc<dyn OwnerContributionRepository>,
13 unit_owner_repository: Arc<dyn UnitOwnerRepository>,
14 building_repository: Option<Arc<dyn BuildingRepository>>,
18 acp_repository: Option<Arc<dyn AcpRepository>>,
19}
20
21impl CallForFundsUseCases {
22 pub fn new(
23 call_for_funds_repository: Arc<dyn CallForFundsRepository>,
24 owner_contribution_repository: Arc<dyn OwnerContributionRepository>,
25 unit_owner_repository: Arc<dyn UnitOwnerRepository>,
26 ) -> Self {
27 Self {
28 call_for_funds_repository,
29 owner_contribution_repository,
30 unit_owner_repository,
31 building_repository: None,
32 acp_repository: None,
33 }
34 }
35
36 pub fn with_full_wiring(
38 call_for_funds_repository: Arc<dyn CallForFundsRepository>,
39 owner_contribution_repository: Arc<dyn OwnerContributionRepository>,
40 unit_owner_repository: Arc<dyn UnitOwnerRepository>,
41 building_repository: Arc<dyn BuildingRepository>,
42 acp_repository: Arc<dyn AcpRepository>,
43 ) -> Self {
44 Self {
45 call_for_funds_repository,
46 owner_contribution_repository,
47 unit_owner_repository,
48 building_repository: Some(building_repository),
49 acp_repository: Some(acp_repository),
50 }
51 }
52
53 async fn resoudre_lacp_conforme(&self, building_id: Uuid) -> Result<Uuid, String> {
69 let Some(building_repo) = &self.building_repository else {
70 return Err(
71 "Impossible d'appeler des fonds : l'ACP créancière n'est pas résoluble \
72 (dépôt d'immeubles non câblé)"
73 .to_string(),
74 );
75 };
76 let building = building_repo
77 .find_by_id(building_id)
78 .await?
79 .ok_or_else(|| "Building not found".to_string())?;
80
81 self.verifier_conformite(building.acp_id).await?;
82 Ok(building.acp_id)
83 }
84
85 async fn verifier_conformite(&self, acp_id: Uuid) -> Result<(), String> {
91 let Some(acp_repo) = &self.acp_repository else {
92 return Ok(());
93 };
94 let (acp, metrics) = acp_repo
95 .find_by_id_with_metrics(acp_id)
96 .await
97 .map_err(|e| e.to_string())?
98 .ok_or_else(|| "ACP not found".to_string())?;
99 acp.assert_conformant(&metrics)?; Ok(())
101 }
102
103 #[allow(clippy::too_many_arguments)]
105 pub async fn create_call_for_funds(
106 &self,
107 organization_id: Uuid,
108 building_id: Uuid,
109 title: String,
110 description: String,
111 total_amount: rust_decimal::Decimal,
112 contribution_type: ContributionType,
113 call_date: DateTime<Utc>,
114 due_date: DateTime<Utc>,
115 account_code: Option<String>,
116 created_by: Option<Uuid>,
117 reserve_fund_share: rust_decimal::Decimal,
118 ) -> Result<CallForFunds, String> {
119 let acp_id = self.resoudre_lacp_conforme(building_id).await?;
122
123 let mut call_for_funds = CallForFunds::new(
125 acp_id,
126 organization_id,
127 building_id,
128 title,
129 description,
130 total_amount,
131 contribution_type.clone(),
132 call_date,
133 due_date,
134 account_code,
135 reserve_fund_share,
136 )?;
137
138 call_for_funds.created_by = created_by;
139
140 self.call_for_funds_repository.create(&call_for_funds).await
142 }
143
144 pub async fn get_call_for_funds(&self, id: Uuid) -> Result<Option<CallForFunds>, String> {
146 self.call_for_funds_repository.find_by_id(id).await
147 }
148
149 pub async fn list_by_building(&self, building_id: Uuid) -> Result<Vec<CallForFunds>, String> {
151 self.call_for_funds_repository
152 .find_by_building(building_id)
153 .await
154 }
155
156 pub async fn list_by_organization(
158 &self,
159 organization_id: Uuid,
160 ) -> Result<Vec<CallForFunds>, String> {
161 self.call_for_funds_repository
162 .find_by_organization(organization_id)
163 .await
164 }
165
166 pub async fn send_call_for_funds(&self, id: Uuid) -> Result<CallForFunds, String> {
169 let mut call_for_funds = self
171 .call_for_funds_repository
172 .find_by_id(id)
173 .await?
174 .ok_or_else(|| "Call for funds not found".to_string())?;
175
176 self.verifier_conformite(call_for_funds.acp_id).await?;
183
184 call_for_funds.mark_as_sent();
186
187 let updated_call = self
189 .call_for_funds_repository
190 .update(&call_for_funds)
191 .await?;
192
193 self.generate_owner_contributions(&updated_call).await?;
195
196 Ok(updated_call)
197 }
198
199 async fn generate_owner_contributions(
201 &self,
202 call_for_funds: &CallForFunds,
203 ) -> Result<Vec<OwnerContribution>, String> {
204 let unit_owners = self
215 .unit_owner_repository
216 .find_active_quota_shares_by_building(call_for_funds.building_id)
217 .await?;
218
219 if unit_owners.is_empty() {
220 return Err("No active owners found for this building".to_string());
221 }
222
223 let mut contributions = Vec::new();
224
225 for (unit_id, owner_id, percentage) in unit_owners {
226 let individual_amount = call_for_funds.total_amount * percentage;
228
229 let description = format!(
231 "{} - Quote-part: {}%",
232 call_for_funds.title,
233 percentage * rust_decimal_macros::dec!(100)
234 );
235
236 let mut contribution = OwnerContribution::new(
238 call_for_funds.acp_id,
241 call_for_funds.organization_id,
242 owner_id,
243 Some(unit_id),
244 description,
245 individual_amount,
246 call_for_funds.contribution_type.clone(),
247 call_for_funds.call_date,
248 call_for_funds.account_code.clone(),
249 )?;
250
251 contribution.call_for_funds_id = Some(call_for_funds.id);
253
254 let saved = self
256 .owner_contribution_repository
257 .create(&contribution)
258 .await?;
259
260 contributions.push(saved);
261 }
262
263 Ok(contributions)
264 }
265
266 pub async fn cancel_call_for_funds(&self, id: Uuid) -> Result<CallForFunds, String> {
268 let mut call_for_funds = self
269 .call_for_funds_repository
270 .find_by_id(id)
271 .await?
272 .ok_or_else(|| "Call for funds not found".to_string())?;
273
274 call_for_funds.cancel();
275
276 self.call_for_funds_repository.update(&call_for_funds).await
277 }
278
279 pub async fn get_overdue_calls(
286 &self,
287 organization_id: Uuid,
288 ) -> Result<Vec<CallForFunds>, String> {
289 self.call_for_funds_repository
290 .find_overdue(organization_id)
291 .await
292 }
293
294 pub async fn delete_call_for_funds(&self, id: Uuid) -> Result<bool, String> {
296 let call_for_funds = self
297 .call_for_funds_repository
298 .find_by_id(id)
299 .await?
300 .ok_or_else(|| "Call for funds not found".to_string())?;
301
302 if call_for_funds.status != crate::domain::entities::CallForFundsStatus::Draft {
304 return Err("Cannot delete a call for funds that has been sent".to_string());
305 }
306
307 self.call_for_funds_repository.delete(id).await
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use crate::application::ports::{
315 CallForFundsRepository, OwnerContributionRepository, UnitOwnerRepository,
316 };
317 use crate::domain::entities::{
318 CallForFunds, CallForFundsStatus, ContributionType, OwnerContribution, UnitOwner,
319 };
320 use async_trait::async_trait;
321 use chrono::{Duration, Utc};
322 use rust_decimal_macros::dec;
323 use std::collections::HashMap;
324 use std::sync::{Arc, Mutex};
325 use uuid::Uuid;
326
327 struct MockCallForFundsRepo {
330 store: Mutex<HashMap<Uuid, CallForFunds>>,
331 overdue: Mutex<Vec<CallForFunds>>,
332 }
333
334 impl MockCallForFundsRepo {
335 fn new() -> Self {
336 Self {
337 store: Mutex::new(HashMap::new()),
338 overdue: Mutex::new(Vec::new()),
339 }
340 }
341
342 fn with_overdue(overdue: Vec<CallForFunds>) -> Self {
343 Self {
344 store: Mutex::new(HashMap::new()),
345 overdue: Mutex::new(overdue),
346 }
347 }
348 }
349
350 #[async_trait]
351 impl CallForFundsRepository for MockCallForFundsRepo {
352 async fn create(&self, cff: &CallForFunds) -> Result<CallForFunds, String> {
353 let mut store = self.store.lock().unwrap();
354 store.insert(cff.id, cff.clone());
355 Ok(cff.clone())
356 }
357
358 async fn find_by_id(&self, id: Uuid) -> Result<Option<CallForFunds>, String> {
359 Ok(self.store.lock().unwrap().get(&id).cloned())
360 }
361
362 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<CallForFunds>, String> {
363 Ok(self
364 .store
365 .lock()
366 .unwrap()
367 .values()
368 .filter(|c| c.building_id == building_id)
369 .cloned()
370 .collect())
371 }
372
373 async fn find_by_organization(
374 &self,
375 organization_id: Uuid,
376 ) -> Result<Vec<CallForFunds>, String> {
377 Ok(self
378 .store
379 .lock()
380 .unwrap()
381 .values()
382 .filter(|c| c.organization_id == organization_id)
383 .cloned()
384 .collect())
385 }
386
387 async fn update(&self, cff: &CallForFunds) -> Result<CallForFunds, String> {
388 let mut store = self.store.lock().unwrap();
389 store.insert(cff.id, cff.clone());
390 Ok(cff.clone())
391 }
392
393 async fn delete(&self, id: Uuid) -> Result<bool, String> {
394 Ok(self.store.lock().unwrap().remove(&id).is_some())
395 }
396
397 async fn find_overdue(&self, organization_id: Uuid) -> Result<Vec<CallForFunds>, String> {
398 Ok(self
399 .overdue
400 .lock()
401 .unwrap()
402 .iter()
403 .filter(|c| c.organization_id == organization_id)
404 .cloned()
405 .collect())
406 }
407 }
408
409 struct MockOwnerContributionRepo {
412 store: Mutex<Vec<OwnerContribution>>,
413 }
414
415 impl MockOwnerContributionRepo {
416 fn new() -> Self {
417 Self {
418 store: Mutex::new(Vec::new()),
419 }
420 }
421 }
422
423 #[async_trait]
424 impl OwnerContributionRepository for MockOwnerContributionRepo {
425 async fn create(
426 &self,
427 contribution: &OwnerContribution,
428 ) -> Result<OwnerContribution, String> {
429 self.store.lock().unwrap().push(contribution.clone());
430 Ok(contribution.clone())
431 }
432
433 async fn find_by_id(&self, id: Uuid) -> Result<Option<OwnerContribution>, String> {
434 Ok(self
435 .store
436 .lock()
437 .unwrap()
438 .iter()
439 .find(|c| c.id == id)
440 .cloned())
441 }
442
443 async fn find_by_organization(
444 &self,
445 organization_id: Uuid,
446 ) -> Result<Vec<OwnerContribution>, String> {
447 Ok(self
448 .store
449 .lock()
450 .unwrap()
451 .iter()
452 .filter(|c| c.organization_id == organization_id)
453 .cloned()
454 .collect())
455 }
456
457 async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<OwnerContribution>, String> {
458 Ok(self
459 .store
460 .lock()
461 .unwrap()
462 .iter()
463 .filter(|c| c.owner_id == owner_id)
464 .cloned()
465 .collect())
466 }
467
468 async fn update(
469 &self,
470 contribution: &OwnerContribution,
471 ) -> Result<OwnerContribution, String> {
472 Ok(contribution.clone())
473 }
474 }
475
476 struct MockUnitOwnerRepo {
479 active_by_building: Mutex<Vec<(Uuid, Uuid, rust_decimal::Decimal)>>,
481 quota_shares: Mutex<Vec<(Uuid, Uuid, rust_decimal::Decimal)>>,
488 }
489
490 impl MockUnitOwnerRepo {
491 fn new() -> Self {
492 Self {
493 active_by_building: Mutex::new(Vec::new()),
494 quota_shares: Mutex::new(Vec::new()),
495 }
496 }
497
498 fn with_owners(owners: Vec<(Uuid, Uuid, rust_decimal::Decimal)>) -> Self {
499 Self {
500 active_by_building: Mutex::new(owners.clone()),
501 quota_shares: Mutex::new(owners),
502 }
503 }
504
505 fn with_divergent(
508 brut: Vec<(Uuid, Uuid, rust_decimal::Decimal)>,
509 parts: Vec<(Uuid, Uuid, rust_decimal::Decimal)>,
510 ) -> Self {
511 Self {
512 active_by_building: Mutex::new(brut),
513 quota_shares: Mutex::new(parts),
514 }
515 }
516 }
517
518 #[async_trait]
519 impl UnitOwnerRepository for MockUnitOwnerRepo {
520 async fn create(&self, _uo: &UnitOwner) -> Result<UnitOwner, String> {
521 unimplemented!()
522 }
523 async fn find_by_id(&self, _id: Uuid) -> Result<Option<UnitOwner>, String> {
524 unimplemented!()
525 }
526 async fn find_current_owners_by_unit(
527 &self,
528 _unit_id: Uuid,
529 ) -> Result<Vec<UnitOwner>, String> {
530 unimplemented!()
531 }
532 async fn find_current_units_by_owner(
533 &self,
534 _owner_id: Uuid,
535 ) -> Result<Vec<UnitOwner>, String> {
536 unimplemented!()
537 }
538 async fn find_all_owners_by_unit(&self, _unit_id: Uuid) -> Result<Vec<UnitOwner>, String> {
539 unimplemented!()
540 }
541 async fn find_all_units_by_owner(&self, _owner_id: Uuid) -> Result<Vec<UnitOwner>, String> {
542 unimplemented!()
543 }
544 async fn update(&self, _uo: &UnitOwner) -> Result<UnitOwner, String> {
545 unimplemented!()
546 }
547 async fn delete(&self, _id: Uuid) -> Result<(), String> {
548 unimplemented!()
549 }
550 async fn has_active_owners(&self, _unit_id: Uuid) -> Result<bool, String> {
551 unimplemented!()
552 }
553 async fn get_total_ownership_percentage(
554 &self,
555 _unit_id: Uuid,
556 ) -> Result<rust_decimal::Decimal, String> {
557 unimplemented!()
558 }
559 async fn find_active_by_unit_and_owner(
560 &self,
561 _unit_id: Uuid,
562 _owner_id: Uuid,
563 ) -> Result<Option<UnitOwner>, String> {
564 unimplemented!()
565 }
566 async fn find_active_by_building(
567 &self,
568 _building_id: Uuid,
569 ) -> Result<Vec<(Uuid, Uuid, rust_decimal::Decimal)>, String> {
570 Ok(self.active_by_building.lock().unwrap().clone())
571 }
572
573 async fn find_active_quota_shares_by_building(
574 &self,
575 _building_id: Uuid,
576 ) -> Result<Vec<(Uuid, Uuid, rust_decimal::Decimal)>, String> {
577 Ok(self.quota_shares.lock().unwrap().clone())
578 }
579
580 async fn find_voting_holders_by_unit(
581 &self,
582 _unit_id: Uuid,
583 ) -> Result<Vec<crate::domain::entities::LotHolder>, String> {
584 Ok(vec![])
585 }
586
587 async fn is_voting_representative(&self, _unit_owner_id: Uuid) -> Result<bool, String> {
588 Ok(false)
589 }
590
591 async fn set_voting_representative(&self, _unit_owner_id: Uuid) -> Result<(), String> {
592 Ok(())
593 }
594 }
595
596 struct MockBuildingRepo {
604 acp_id: Uuid,
605 }
606
607 impl MockBuildingRepo {
608 fn rattache_a(acp_id: Uuid) -> Self {
609 Self { acp_id }
610 }
611
612 fn immeuble(&self) -> crate::domain::entities::Building {
613 crate::domain::entities::Building::new(
614 self.acp_id,
615 "Résidence du Parc".to_string(),
616 "12 Rue de la Loi".to_string(),
617 "Brussels".to_string(),
618 "1000".to_string(),
619 "Belgium".to_string(),
620 10,
621 1000,
622 Some(2015),
623 )
624 .expect("immeuble valide")
625 }
626 }
627
628 #[async_trait]
629 impl BuildingRepository for MockBuildingRepo {
630 async fn create(
631 &self,
632 b: &crate::domain::entities::Building,
633 ) -> Result<crate::domain::entities::Building, String> {
634 Ok(b.clone())
635 }
636 async fn find_by_id(
637 &self,
638 _id: Uuid,
639 ) -> Result<Option<crate::domain::entities::Building>, String> {
640 Ok(Some(self.immeuble()))
641 }
642 async fn find_all(&self) -> Result<Vec<crate::domain::entities::Building>, String> {
643 Ok(vec![self.immeuble()])
644 }
645 async fn find_all_paginated(
646 &self,
647 _p: &crate::application::dto::PageRequest,
648 _f: &crate::application::dto::BuildingFilters,
649 ) -> Result<(Vec<crate::domain::entities::Building>, i64), String> {
650 Ok((vec![self.immeuble()], 1))
651 }
652 async fn update(
653 &self,
654 b: &crate::domain::entities::Building,
655 ) -> Result<crate::domain::entities::Building, String> {
656 Ok(b.clone())
657 }
658 async fn delete(&self, _id: Uuid) -> Result<bool, String> {
659 Ok(true)
660 }
661 async fn find_by_slug(
662 &self,
663 _slug: &str,
664 ) -> Result<Option<crate::domain::entities::Building>, String> {
665 Ok(Some(self.immeuble()))
666 }
667 async fn find_by_id_with_metrics(
668 &self,
669 _id: Uuid,
670 ) -> Result<
671 Option<(
672 crate::domain::entities::Building,
673 crate::domain::entities::BuildingMetrics,
674 )>,
675 String,
676 > {
677 Ok(None)
678 }
679 }
680
681 fn make_use_cases(
684 cff_repo: Arc<dyn CallForFundsRepository>,
685 contrib_repo: Arc<dyn OwnerContributionRepository>,
686 uo_repo: Arc<dyn UnitOwnerRepository>,
687 ) -> CallForFundsUseCases {
688 make_use_cases_pour_lacp(cff_repo, contrib_repo, uo_repo, Uuid::new_v4())
689 }
690
691 fn make_use_cases_pour_lacp(
693 cff_repo: Arc<dyn CallForFundsRepository>,
694 contrib_repo: Arc<dyn OwnerContributionRepository>,
695 uo_repo: Arc<dyn UnitOwnerRepository>,
696 acp_id: Uuid,
697 ) -> CallForFundsUseCases {
698 let mut uc = CallForFundsUseCases::new(cff_repo, contrib_repo, uo_repo);
699 uc.building_repository = Some(Arc::new(MockBuildingRepo::rattache_a(acp_id)));
700 uc
701 }
702
703 fn sample_dates() -> (chrono::DateTime<Utc>, chrono::DateTime<Utc>) {
704 let call_date = Utc::now();
705 let due_date = call_date + Duration::days(30);
706 (call_date, due_date)
707 }
708
709 #[tokio::test]
717 async fn test_lappel_de_fonds_a_pour_creanciere_lacp_de_limmeuble() {
718 let acp_creanciere = Uuid::new_v4();
719 let cabinet_emetteur = Uuid::new_v4();
720
721 let uc = make_use_cases_pour_lacp(
722 Arc::new(MockCallForFundsRepo::new()),
723 Arc::new(MockOwnerContributionRepo::new()),
724 Arc::new(MockUnitOwnerRepo::new()),
725 acp_creanciere,
726 );
727 let (call_date, due_date) = sample_dates();
728
729 let appel = uc
730 .create_call_for_funds(
731 cabinet_emetteur,
732 Uuid::new_v4(),
733 "Provision T1 2026".to_string(),
734 "Charges ordinaires".to_string(),
735 dec!(10000),
736 ContributionType::Regular,
737 call_date,
738 due_date,
739 None,
740 None,
741 rust_decimal::Decimal::ZERO, )
743 .await
744 .expect("création valide");
745
746 assert_eq!(
747 appel.acp_id, acp_creanciere,
748 "les fonds sont appelés au nom de l'ACP de l'immeuble"
749 );
750 assert_eq!(
751 appel.organization_id, cabinet_emetteur,
752 "le syndic reste tracé comme émetteur, sans devenir créancier"
753 );
754 }
755
756 #[tokio::test]
760 async fn test_pas_dappel_de_fonds_sans_creanciere_resoluble() {
761 let uc = CallForFundsUseCases::new(
762 Arc::new(MockCallForFundsRepo::new()),
763 Arc::new(MockOwnerContributionRepo::new()),
764 Arc::new(MockUnitOwnerRepo::new()),
765 );
766 let (call_date, due_date) = sample_dates();
767
768 let resultat = uc
769 .create_call_for_funds(
770 Uuid::new_v4(),
771 Uuid::new_v4(),
772 "Provision".to_string(),
773 "Charges".to_string(),
774 dec!(10000),
775 ContributionType::Regular,
776 call_date,
777 due_date,
778 None,
779 None,
780 rust_decimal::Decimal::ZERO, )
782 .await;
783
784 let erreur = resultat.expect_err("doit refuser");
785 assert!(
786 erreur.contains("créancière"),
787 "le refus doit nommer ce qui manque, pas échouer obscurément : {erreur}"
788 );
789 }
790
791 #[tokio::test]
792 async fn test_create_call_for_funds_success() {
793 let cff_repo = Arc::new(MockCallForFundsRepo::new());
794 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
795 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
796 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
797
798 let (call_date, due_date) = sample_dates();
799 let org_id = Uuid::new_v4();
800 let building_id = Uuid::new_v4();
801
802 let result = uc
803 .create_call_for_funds(
804 org_id,
805 building_id,
806 "Appel Q1".to_string(),
807 "Charges courantes".to_string(),
808 rust_decimal_macros::dec!(10_000),
809 ContributionType::Regular,
810 call_date,
811 due_date,
812 Some("7000".to_string()),
813 Some(Uuid::new_v4()),
814 rust_decimal::Decimal::ZERO, )
816 .await;
817
818 assert!(result.is_ok());
819 let cff = result.unwrap();
820 assert_eq!(cff.total_amount, rust_decimal_macros::dec!(10_000));
821 assert_eq!(cff.status, CallForFundsStatus::Draft);
822 assert_eq!(cff.organization_id, org_id);
823 assert_eq!(cff.building_id, building_id);
824 assert!(cff_repo.store.lock().unwrap().contains_key(&cff.id));
826 }
827
828 #[tokio::test]
831 async fn test_send_call_for_funds_generates_contributions() {
832 let cff_repo = Arc::new(MockCallForFundsRepo::new());
833 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
834
835 let unit1 = Uuid::new_v4();
836 let unit2 = Uuid::new_v4();
837 let owner1 = Uuid::new_v4();
838 let owner2 = Uuid::new_v4();
839 let uo_repo = Arc::new(MockUnitOwnerRepo::with_owners(vec![
840 (unit1, owner1, rust_decimal_macros::dec!(0.60)),
841 (unit2, owner2, rust_decimal_macros::dec!(0.40)),
842 ]));
843
844 let uc = make_use_cases(cff_repo.clone(), contrib_repo.clone(), uo_repo);
845
846 let (call_date, due_date) = sample_dates();
847
848 let cff = uc
849 .create_call_for_funds(
850 Uuid::new_v4(),
851 Uuid::new_v4(),
852 "Appel Q2".to_string(),
853 "Charges extraordinaires".to_string(),
854 rust_decimal_macros::dec!(5_000),
855 ContributionType::Extraordinary,
856 call_date,
857 due_date,
858 None,
859 None,
860 rust_decimal::Decimal::ZERO, )
862 .await
863 .unwrap();
864
865 let result = uc.send_call_for_funds(cff.id).await;
867 assert!(result.is_ok());
868
869 let sent = result.unwrap();
870 assert_eq!(sent.status, CallForFundsStatus::Sent);
871 assert!(sent.sent_date.is_some());
872
873 let contributions = contrib_repo.store.lock().unwrap();
875 assert_eq!(contributions.len(), 2);
876
877 let mut amounts: Vec<rust_decimal::Decimal> =
878 contributions.iter().map(|c| c.amount).collect();
879 amounts.sort();
880 assert_eq!(amounts[0], rust_decimal_macros::dec!(2_000));
882 assert_eq!(amounts[1], rust_decimal_macros::dec!(3_000));
883 }
884
885 #[tokio::test]
907 async fn test_appel_de_fonds_utilise_les_quotes_parts_pas_les_detentions() {
908 let cff_repo = Arc::new(MockCallForFundsRepo::new());
909 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
910
911 let lots: Vec<Uuid> = (0..4).map(|_| Uuid::new_v4()).collect();
912 let proprios: Vec<Uuid> = (0..4).map(|_| Uuid::new_v4()).collect();
913
914 let brut: Vec<_> = lots
917 .iter()
918 .zip(&proprios)
919 .map(|(u, o)| (*u, *o, rust_decimal_macros::dec!(1.0)))
920 .collect();
921 let parts = vec![
923 (lots[0], proprios[0], rust_decimal_macros::dec!(0.2)),
924 (lots[1], proprios[1], rust_decimal_macros::dec!(0.2)),
925 (lots[2], proprios[2], rust_decimal_macros::dec!(0.3)),
926 (lots[3], proprios[3], rust_decimal_macros::dec!(0.3)),
927 ];
928
929 let uo_repo = Arc::new(MockUnitOwnerRepo::with_divergent(brut, parts));
930 let uc = make_use_cases(cff_repo.clone(), contrib_repo.clone(), uo_repo);
931 let (call_date, due_date) = sample_dates();
932
933 let cff = uc
934 .create_call_for_funds(
935 Uuid::new_v4(),
936 Uuid::new_v4(),
937 "Charges Q3".to_string(),
938 "Non-régression répartition".to_string(),
939 rust_decimal_macros::dec!(10_000),
940 ContributionType::Regular,
941 call_date,
942 due_date,
943 None,
944 None,
945 rust_decimal::Decimal::ZERO, )
947 .await
948 .unwrap();
949
950 uc.send_call_for_funds(cff.id).await.expect("envoi accepté");
951
952 let contributions = contrib_repo.store.lock().unwrap();
953 assert_eq!(contributions.len(), 4, "une quote-part par lot");
954
955 let mut montants: Vec<rust_decimal::Decimal> =
956 contributions.iter().map(|c| c.amount).collect();
957 montants.sort();
958 assert_eq!(
959 montants,
960 vec![
961 rust_decimal_macros::dec!(2_000),
962 rust_decimal_macros::dec!(2_000),
963 rust_decimal_macros::dec!(3_000),
964 rust_decimal_macros::dec!(3_000),
965 ],
966 "chaque copropriétaire doit être appelé au prorata de ses tantièmes"
967 );
968
969 let total: rust_decimal::Decimal = montants.iter().sum();
972 assert_eq!(
973 total,
974 rust_decimal_macros::dec!(10_000),
975 "la somme appelée doit égaler le montant de l'appel de fonds"
976 );
977 }
978
979 #[tokio::test]
982 async fn test_cancel_call_for_funds() {
983 let cff_repo = Arc::new(MockCallForFundsRepo::new());
984 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
985 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
986 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
987
988 let (call_date, due_date) = sample_dates();
989
990 let cff = uc
991 .create_call_for_funds(
992 Uuid::new_v4(),
993 Uuid::new_v4(),
994 "Appel annulable".to_string(),
995 "Description".to_string(),
996 rust_decimal_macros::dec!(1_000),
997 ContributionType::Regular,
998 call_date,
999 due_date,
1000 None,
1001 None,
1002 rust_decimal::Decimal::ZERO, )
1004 .await
1005 .unwrap();
1006
1007 let result = uc.cancel_call_for_funds(cff.id).await;
1008 assert!(result.is_ok());
1009 assert_eq!(result.unwrap().status, CallForFundsStatus::Cancelled);
1010 }
1011
1012 #[tokio::test]
1015 async fn test_delete_call_for_funds_draft_succeeds() {
1016 let cff_repo = Arc::new(MockCallForFundsRepo::new());
1017 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
1018 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
1019 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
1020
1021 let (call_date, due_date) = sample_dates();
1022
1023 let cff = uc
1024 .create_call_for_funds(
1025 Uuid::new_v4(),
1026 Uuid::new_v4(),
1027 "Supprimable".to_string(),
1028 "Description".to_string(),
1029 rust_decimal_macros::dec!(500),
1030 ContributionType::Advance,
1031 call_date,
1032 due_date,
1033 None,
1034 None,
1035 rust_decimal::Decimal::ZERO, )
1037 .await
1038 .unwrap();
1039
1040 let result = uc.delete_call_for_funds(cff.id).await;
1041 assert!(result.is_ok());
1042 assert!(result.unwrap());
1043 assert!(!cff_repo.store.lock().unwrap().contains_key(&cff.id));
1044 }
1045
1046 #[tokio::test]
1047 async fn test_delete_call_for_funds_rejects_non_draft() {
1048 let cff_repo = Arc::new(MockCallForFundsRepo::new());
1049 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
1050 let uo_repo = Arc::new(MockUnitOwnerRepo::with_owners(vec![(
1051 Uuid::new_v4(),
1052 Uuid::new_v4(),
1053 rust_decimal_macros::dec!(1),
1054 )]));
1055 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
1056
1057 let (call_date, due_date) = sample_dates();
1058
1059 let cff = uc
1060 .create_call_for_funds(
1061 Uuid::new_v4(),
1062 Uuid::new_v4(),
1063 "Sent call".to_string(),
1064 "Description".to_string(),
1065 rust_decimal_macros::dec!(500),
1066 ContributionType::Regular,
1067 call_date,
1068 due_date,
1069 None,
1070 None,
1071 rust_decimal::Decimal::ZERO, )
1073 .await
1074 .unwrap();
1075
1076 uc.send_call_for_funds(cff.id).await.unwrap();
1078
1079 let result = uc.delete_call_for_funds(cff.id).await;
1080 assert!(result.is_err());
1081 assert!(result
1082 .unwrap_err()
1083 .contains("Cannot delete a call for funds that has been sent"));
1084 }
1085
1086 fn cff_en_retard(organization_id: Uuid, titre: &str) -> CallForFunds {
1089 let call_date = Utc::now() - Duration::days(60);
1090 let due_date = Utc::now() - Duration::days(30);
1091 CallForFunds::new(
1092 Uuid::new_v4(), organization_id,
1094 Uuid::new_v4(), titre.to_string(),
1096 "Past due".to_string(),
1097 rust_decimal_macros::dec!(2_000),
1098 ContributionType::Regular,
1099 call_date,
1100 due_date,
1101 None,
1102 rust_decimal::Decimal::ZERO, )
1104 .unwrap()
1105 }
1106
1107 #[tokio::test]
1109 async fn happy_get_overdue_calls_rend_les_arrieres_de_lorganisation_appelante() {
1110 let organisation_a = Uuid::new_v4();
1111 let overdue_cff = cff_en_retard(organisation_a, "Overdue call");
1112
1113 let cff_repo = Arc::new(MockCallForFundsRepo::with_overdue(
1114 vec![overdue_cff.clone()],
1115 ));
1116 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
1117 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
1118 let uc = make_use_cases(cff_repo, contrib_repo, uo_repo);
1119
1120 let result = uc.get_overdue_calls(organisation_a).await;
1121 assert!(result.is_ok());
1122 let overdue = result.unwrap();
1123 assert_eq!(overdue.len(), 1);
1124 assert_eq!(overdue[0].title, "Overdue call");
1125 }
1126
1127 #[tokio::test]
1132 async fn security_get_overdue_calls_ne_rend_jamais_larrierage_dune_autre_organisation() {
1133 let organisation_a = Uuid::new_v4();
1134 let organisation_b = Uuid::new_v4();
1135 let arriere_a = cff_en_retard(organisation_a, "Arriéré cabinet A");
1136 let arriere_b = cff_en_retard(organisation_b, "Arriéré cabinet B");
1137
1138 let cff_repo = Arc::new(MockCallForFundsRepo::with_overdue(vec![
1139 arriere_a.clone(),
1140 arriere_b.clone(),
1141 ]));
1142 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
1143 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
1144 let uc = make_use_cases(cff_repo, contrib_repo, uo_repo);
1145
1146 let result = uc.get_overdue_calls(organisation_a).await.unwrap();
1147
1148 assert_eq!(result.len(), 1);
1149 assert!(
1150 result.iter().all(|c| c.organization_id == organisation_a),
1151 "la réponse au cabinet A contient un appel de fonds d'une autre organisation"
1152 );
1153 assert!(
1154 !result.iter().any(|c| c.id == arriere_b.id),
1155 "l'arriéré du cabinet B est visible depuis le cabinet A"
1156 );
1157 }
1158
1159 #[tokio::test]
1163 async fn edge_get_overdue_calls_exige_une_organisation_a_lappel() {
1164 let organisation = Uuid::new_v4();
1165 let cff_repo = Arc::new(MockCallForFundsRepo::with_overdue(Vec::new()));
1166 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
1167 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
1168 let uc = make_use_cases(cff_repo, contrib_repo, uo_repo);
1169
1170 let result = uc.get_overdue_calls(organisation).await;
1175 assert!(result.is_ok());
1176 assert!(result.unwrap().is_empty());
1177 }
1178
1179 #[tokio::test]
1182 async fn test_list_by_building() {
1183 let cff_repo = Arc::new(MockCallForFundsRepo::new());
1184 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
1185 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
1186 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
1187
1188 let building_id = Uuid::new_v4();
1189 let other_building = Uuid::new_v4();
1190 let org_id = Uuid::new_v4();
1191 let (call_date, due_date) = sample_dates();
1192
1193 uc.create_call_for_funds(
1195 org_id,
1196 building_id,
1197 "Appel 1".to_string(),
1198 "Desc 1".to_string(),
1199 rust_decimal_macros::dec!(1_000),
1200 ContributionType::Regular,
1201 call_date,
1202 due_date,
1203 None,
1204 None,
1205 rust_decimal::Decimal::ZERO, )
1207 .await
1208 .unwrap();
1209
1210 uc.create_call_for_funds(
1211 org_id,
1212 building_id,
1213 "Appel 2".to_string(),
1214 "Desc 2".to_string(),
1215 rust_decimal_macros::dec!(2_000),
1216 ContributionType::Extraordinary,
1217 call_date,
1218 due_date,
1219 None,
1220 None,
1221 rust_decimal::Decimal::ZERO, )
1223 .await
1224 .unwrap();
1225
1226 uc.create_call_for_funds(
1228 org_id,
1229 other_building,
1230 "Autre appel".to_string(),
1231 "Autre desc".to_string(),
1232 rust_decimal_macros::dec!(500),
1233 ContributionType::Regular,
1234 call_date,
1235 due_date,
1236 None,
1237 None,
1238 rust_decimal::Decimal::ZERO, )
1240 .await
1241 .unwrap();
1242
1243 let result = uc.list_by_building(building_id).await;
1244 assert!(result.is_ok());
1245 let list = result.unwrap();
1246 assert_eq!(list.len(), 2);
1247 assert!(list.iter().all(|c| c.building_id == building_id));
1248 }
1249}