1use crate::application::ports::{
2 CallForFundsRepository, OwnerContributionRepository, UnitOwnerRepository,
3};
4use crate::domain::entities::{CallForFunds, ContributionType, OwnerContribution};
5use chrono::{DateTime, Utc};
6use std::sync::Arc;
7use uuid::Uuid;
8
9pub struct CallForFundsUseCases {
10 call_for_funds_repository: Arc<dyn CallForFundsRepository>,
11 owner_contribution_repository: Arc<dyn OwnerContributionRepository>,
12 unit_owner_repository: Arc<dyn UnitOwnerRepository>,
13}
14
15impl CallForFundsUseCases {
16 pub fn new(
17 call_for_funds_repository: Arc<dyn CallForFundsRepository>,
18 owner_contribution_repository: Arc<dyn OwnerContributionRepository>,
19 unit_owner_repository: Arc<dyn UnitOwnerRepository>,
20 ) -> Self {
21 Self {
22 call_for_funds_repository,
23 owner_contribution_repository,
24 unit_owner_repository,
25 }
26 }
27
28 #[allow(clippy::too_many_arguments)]
30 pub async fn create_call_for_funds(
31 &self,
32 organization_id: Uuid,
33 building_id: Uuid,
34 title: String,
35 description: String,
36 total_amount: f64,
37 contribution_type: ContributionType,
38 call_date: DateTime<Utc>,
39 due_date: DateTime<Utc>,
40 account_code: Option<String>,
41 created_by: Option<Uuid>,
42 ) -> Result<CallForFunds, String> {
43 let mut call_for_funds = CallForFunds::new(
45 organization_id,
46 building_id,
47 title,
48 description,
49 total_amount,
50 contribution_type.clone(),
51 call_date,
52 due_date,
53 account_code,
54 )?;
55
56 call_for_funds.created_by = created_by;
57
58 self.call_for_funds_repository.create(&call_for_funds).await
60 }
61
62 pub async fn get_call_for_funds(&self, id: Uuid) -> Result<Option<CallForFunds>, String> {
64 self.call_for_funds_repository.find_by_id(id).await
65 }
66
67 pub async fn list_by_building(&self, building_id: Uuid) -> Result<Vec<CallForFunds>, String> {
69 self.call_for_funds_repository
70 .find_by_building(building_id)
71 .await
72 }
73
74 pub async fn list_by_organization(
76 &self,
77 organization_id: Uuid,
78 ) -> Result<Vec<CallForFunds>, String> {
79 self.call_for_funds_repository
80 .find_by_organization(organization_id)
81 .await
82 }
83
84 pub async fn send_call_for_funds(&self, id: Uuid) -> Result<CallForFunds, String> {
87 let mut call_for_funds = self
89 .call_for_funds_repository
90 .find_by_id(id)
91 .await?
92 .ok_or_else(|| "Call for funds not found".to_string())?;
93
94 call_for_funds.mark_as_sent();
96
97 let updated_call = self
99 .call_for_funds_repository
100 .update(&call_for_funds)
101 .await?;
102
103 self.generate_owner_contributions(&updated_call).await?;
105
106 Ok(updated_call)
107 }
108
109 async fn generate_owner_contributions(
111 &self,
112 call_for_funds: &CallForFunds,
113 ) -> Result<Vec<OwnerContribution>, String> {
114 let unit_owners = self
117 .unit_owner_repository
118 .find_active_by_building(call_for_funds.building_id)
119 .await?;
120
121 if unit_owners.is_empty() {
122 return Err("No active owners found for this building".to_string());
123 }
124
125 let mut contributions = Vec::new();
126
127 for (unit_id, owner_id, percentage) in unit_owners {
128 let individual_amount = call_for_funds.total_amount * percentage;
130
131 let description = format!(
133 "{} - Quote-part: {:.2}%",
134 call_for_funds.title,
135 percentage * 100.0
136 );
137
138 let mut contribution = OwnerContribution::new(
140 call_for_funds.organization_id,
141 owner_id,
142 Some(unit_id),
143 description,
144 individual_amount,
145 call_for_funds.contribution_type.clone(),
146 call_for_funds.call_date,
147 call_for_funds.account_code.clone(),
148 )?;
149
150 contribution.call_for_funds_id = Some(call_for_funds.id);
152
153 let saved = self
155 .owner_contribution_repository
156 .create(&contribution)
157 .await?;
158
159 contributions.push(saved);
160 }
161
162 Ok(contributions)
163 }
164
165 pub async fn cancel_call_for_funds(&self, id: Uuid) -> Result<CallForFunds, String> {
167 let mut call_for_funds = self
168 .call_for_funds_repository
169 .find_by_id(id)
170 .await?
171 .ok_or_else(|| "Call for funds not found".to_string())?;
172
173 call_for_funds.cancel();
174
175 self.call_for_funds_repository.update(&call_for_funds).await
176 }
177
178 pub async fn get_overdue_calls(&self) -> Result<Vec<CallForFunds>, String> {
180 self.call_for_funds_repository.find_overdue().await
181 }
182
183 pub async fn delete_call_for_funds(&self, id: Uuid) -> Result<bool, String> {
185 let call_for_funds = self
186 .call_for_funds_repository
187 .find_by_id(id)
188 .await?
189 .ok_or_else(|| "Call for funds not found".to_string())?;
190
191 if call_for_funds.status != crate::domain::entities::CallForFundsStatus::Draft {
193 return Err("Cannot delete a call for funds that has been sent".to_string());
194 }
195
196 self.call_for_funds_repository.delete(id).await
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::application::ports::{
204 CallForFundsRepository, OwnerContributionRepository, UnitOwnerRepository,
205 };
206 use crate::domain::entities::{
207 CallForFunds, CallForFundsStatus, ContributionType, OwnerContribution, UnitOwner,
208 };
209 use async_trait::async_trait;
210 use chrono::{Duration, Utc};
211 use std::collections::HashMap;
212 use std::sync::{Arc, Mutex};
213 use uuid::Uuid;
214
215 struct MockCallForFundsRepo {
218 store: Mutex<HashMap<Uuid, CallForFunds>>,
219 overdue: Mutex<Vec<CallForFunds>>,
220 }
221
222 impl MockCallForFundsRepo {
223 fn new() -> Self {
224 Self {
225 store: Mutex::new(HashMap::new()),
226 overdue: Mutex::new(Vec::new()),
227 }
228 }
229
230 fn with_overdue(overdue: Vec<CallForFunds>) -> Self {
231 Self {
232 store: Mutex::new(HashMap::new()),
233 overdue: Mutex::new(overdue),
234 }
235 }
236 }
237
238 #[async_trait]
239 impl CallForFundsRepository for MockCallForFundsRepo {
240 async fn create(&self, cff: &CallForFunds) -> Result<CallForFunds, String> {
241 let mut store = self.store.lock().unwrap();
242 store.insert(cff.id, cff.clone());
243 Ok(cff.clone())
244 }
245
246 async fn find_by_id(&self, id: Uuid) -> Result<Option<CallForFunds>, String> {
247 Ok(self.store.lock().unwrap().get(&id).cloned())
248 }
249
250 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<CallForFunds>, String> {
251 Ok(self
252 .store
253 .lock()
254 .unwrap()
255 .values()
256 .filter(|c| c.building_id == building_id)
257 .cloned()
258 .collect())
259 }
260
261 async fn find_by_organization(
262 &self,
263 organization_id: Uuid,
264 ) -> Result<Vec<CallForFunds>, String> {
265 Ok(self
266 .store
267 .lock()
268 .unwrap()
269 .values()
270 .filter(|c| c.organization_id == organization_id)
271 .cloned()
272 .collect())
273 }
274
275 async fn update(&self, cff: &CallForFunds) -> Result<CallForFunds, String> {
276 let mut store = self.store.lock().unwrap();
277 store.insert(cff.id, cff.clone());
278 Ok(cff.clone())
279 }
280
281 async fn delete(&self, id: Uuid) -> Result<bool, String> {
282 Ok(self.store.lock().unwrap().remove(&id).is_some())
283 }
284
285 async fn find_overdue(&self) -> Result<Vec<CallForFunds>, String> {
286 Ok(self.overdue.lock().unwrap().clone())
287 }
288 }
289
290 struct MockOwnerContributionRepo {
293 store: Mutex<Vec<OwnerContribution>>,
294 }
295
296 impl MockOwnerContributionRepo {
297 fn new() -> Self {
298 Self {
299 store: Mutex::new(Vec::new()),
300 }
301 }
302 }
303
304 #[async_trait]
305 impl OwnerContributionRepository for MockOwnerContributionRepo {
306 async fn create(
307 &self,
308 contribution: &OwnerContribution,
309 ) -> Result<OwnerContribution, String> {
310 self.store.lock().unwrap().push(contribution.clone());
311 Ok(contribution.clone())
312 }
313
314 async fn find_by_id(&self, id: Uuid) -> Result<Option<OwnerContribution>, String> {
315 Ok(self
316 .store
317 .lock()
318 .unwrap()
319 .iter()
320 .find(|c| c.id == id)
321 .cloned())
322 }
323
324 async fn find_by_organization(
325 &self,
326 organization_id: Uuid,
327 ) -> Result<Vec<OwnerContribution>, String> {
328 Ok(self
329 .store
330 .lock()
331 .unwrap()
332 .iter()
333 .filter(|c| c.organization_id == organization_id)
334 .cloned()
335 .collect())
336 }
337
338 async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<OwnerContribution>, String> {
339 Ok(self
340 .store
341 .lock()
342 .unwrap()
343 .iter()
344 .filter(|c| c.owner_id == owner_id)
345 .cloned()
346 .collect())
347 }
348
349 async fn update(
350 &self,
351 contribution: &OwnerContribution,
352 ) -> Result<OwnerContribution, String> {
353 Ok(contribution.clone())
354 }
355 }
356
357 struct MockUnitOwnerRepo {
360 active_by_building: Mutex<Vec<(Uuid, Uuid, f64)>>,
361 }
362
363 impl MockUnitOwnerRepo {
364 fn new() -> Self {
365 Self {
366 active_by_building: Mutex::new(Vec::new()),
367 }
368 }
369
370 fn with_owners(owners: Vec<(Uuid, Uuid, f64)>) -> Self {
371 Self {
372 active_by_building: Mutex::new(owners),
373 }
374 }
375 }
376
377 #[async_trait]
378 impl UnitOwnerRepository for MockUnitOwnerRepo {
379 async fn create(&self, _uo: &UnitOwner) -> Result<UnitOwner, String> {
380 unimplemented!()
381 }
382 async fn find_by_id(&self, _id: Uuid) -> Result<Option<UnitOwner>, String> {
383 unimplemented!()
384 }
385 async fn find_current_owners_by_unit(
386 &self,
387 _unit_id: Uuid,
388 ) -> Result<Vec<UnitOwner>, String> {
389 unimplemented!()
390 }
391 async fn find_current_units_by_owner(
392 &self,
393 _owner_id: Uuid,
394 ) -> Result<Vec<UnitOwner>, String> {
395 unimplemented!()
396 }
397 async fn find_all_owners_by_unit(&self, _unit_id: Uuid) -> Result<Vec<UnitOwner>, String> {
398 unimplemented!()
399 }
400 async fn find_all_units_by_owner(&self, _owner_id: Uuid) -> Result<Vec<UnitOwner>, String> {
401 unimplemented!()
402 }
403 async fn update(&self, _uo: &UnitOwner) -> Result<UnitOwner, String> {
404 unimplemented!()
405 }
406 async fn delete(&self, _id: Uuid) -> Result<(), String> {
407 unimplemented!()
408 }
409 async fn has_active_owners(&self, _unit_id: Uuid) -> Result<bool, String> {
410 unimplemented!()
411 }
412 async fn get_total_ownership_percentage(&self, _unit_id: Uuid) -> Result<f64, String> {
413 unimplemented!()
414 }
415 async fn find_active_by_unit_and_owner(
416 &self,
417 _unit_id: Uuid,
418 _owner_id: Uuid,
419 ) -> Result<Option<UnitOwner>, String> {
420 unimplemented!()
421 }
422 async fn find_active_by_building(
423 &self,
424 _building_id: Uuid,
425 ) -> Result<Vec<(Uuid, Uuid, f64)>, String> {
426 Ok(self.active_by_building.lock().unwrap().clone())
427 }
428 }
429
430 fn make_use_cases(
433 cff_repo: Arc<dyn CallForFundsRepository>,
434 contrib_repo: Arc<dyn OwnerContributionRepository>,
435 uo_repo: Arc<dyn UnitOwnerRepository>,
436 ) -> CallForFundsUseCases {
437 CallForFundsUseCases::new(cff_repo, contrib_repo, uo_repo)
438 }
439
440 fn sample_dates() -> (chrono::DateTime<Utc>, chrono::DateTime<Utc>) {
441 let call_date = Utc::now();
442 let due_date = call_date + Duration::days(30);
443 (call_date, due_date)
444 }
445
446 #[tokio::test]
449 async fn test_create_call_for_funds_success() {
450 let cff_repo = Arc::new(MockCallForFundsRepo::new());
451 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
452 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
453 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
454
455 let (call_date, due_date) = sample_dates();
456 let org_id = Uuid::new_v4();
457 let building_id = Uuid::new_v4();
458
459 let result = uc
460 .create_call_for_funds(
461 org_id,
462 building_id,
463 "Appel Q1".to_string(),
464 "Charges courantes".to_string(),
465 10_000.0,
466 ContributionType::Regular,
467 call_date,
468 due_date,
469 Some("7000".to_string()),
470 Some(Uuid::new_v4()),
471 )
472 .await;
473
474 assert!(result.is_ok());
475 let cff = result.unwrap();
476 assert_eq!(cff.total_amount, 10_000.0);
477 assert_eq!(cff.status, CallForFundsStatus::Draft);
478 assert_eq!(cff.organization_id, org_id);
479 assert_eq!(cff.building_id, building_id);
480 assert!(cff_repo.store.lock().unwrap().contains_key(&cff.id));
482 }
483
484 #[tokio::test]
487 async fn test_send_call_for_funds_generates_contributions() {
488 let cff_repo = Arc::new(MockCallForFundsRepo::new());
489 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
490
491 let unit1 = Uuid::new_v4();
492 let unit2 = Uuid::new_v4();
493 let owner1 = Uuid::new_v4();
494 let owner2 = Uuid::new_v4();
495 let uo_repo = Arc::new(MockUnitOwnerRepo::with_owners(vec![
496 (unit1, owner1, 0.60),
497 (unit2, owner2, 0.40),
498 ]));
499
500 let uc = make_use_cases(cff_repo.clone(), contrib_repo.clone(), uo_repo);
501
502 let (call_date, due_date) = sample_dates();
503
504 let cff = uc
505 .create_call_for_funds(
506 Uuid::new_v4(),
507 Uuid::new_v4(),
508 "Appel Q2".to_string(),
509 "Charges extraordinaires".to_string(),
510 5_000.0,
511 ContributionType::Extraordinary,
512 call_date,
513 due_date,
514 None,
515 None,
516 )
517 .await
518 .unwrap();
519
520 let result = uc.send_call_for_funds(cff.id).await;
522 assert!(result.is_ok());
523
524 let sent = result.unwrap();
525 assert_eq!(sent.status, CallForFundsStatus::Sent);
526 assert!(sent.sent_date.is_some());
527
528 let contributions = contrib_repo.store.lock().unwrap();
530 assert_eq!(contributions.len(), 2);
531
532 let mut amounts: Vec<f64> = contributions.iter().map(|c| c.amount).collect();
533 amounts.sort_by(|a, b| a.partial_cmp(b).unwrap());
534 assert!((amounts[0] - 2_000.0).abs() < 0.01);
536 assert!((amounts[1] - 3_000.0).abs() < 0.01);
537 }
538
539 #[tokio::test]
542 async fn test_cancel_call_for_funds() {
543 let cff_repo = Arc::new(MockCallForFundsRepo::new());
544 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
545 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
546 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
547
548 let (call_date, due_date) = sample_dates();
549
550 let cff = uc
551 .create_call_for_funds(
552 Uuid::new_v4(),
553 Uuid::new_v4(),
554 "Appel annulable".to_string(),
555 "Description".to_string(),
556 1_000.0,
557 ContributionType::Regular,
558 call_date,
559 due_date,
560 None,
561 None,
562 )
563 .await
564 .unwrap();
565
566 let result = uc.cancel_call_for_funds(cff.id).await;
567 assert!(result.is_ok());
568 assert_eq!(result.unwrap().status, CallForFundsStatus::Cancelled);
569 }
570
571 #[tokio::test]
574 async fn test_delete_call_for_funds_draft_succeeds() {
575 let cff_repo = Arc::new(MockCallForFundsRepo::new());
576 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
577 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
578 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
579
580 let (call_date, due_date) = sample_dates();
581
582 let cff = uc
583 .create_call_for_funds(
584 Uuid::new_v4(),
585 Uuid::new_v4(),
586 "Supprimable".to_string(),
587 "Description".to_string(),
588 500.0,
589 ContributionType::Advance,
590 call_date,
591 due_date,
592 None,
593 None,
594 )
595 .await
596 .unwrap();
597
598 let result = uc.delete_call_for_funds(cff.id).await;
599 assert!(result.is_ok());
600 assert!(result.unwrap());
601 assert!(!cff_repo.store.lock().unwrap().contains_key(&cff.id));
602 }
603
604 #[tokio::test]
605 async fn test_delete_call_for_funds_rejects_non_draft() {
606 let cff_repo = Arc::new(MockCallForFundsRepo::new());
607 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
608 let uo_repo = Arc::new(MockUnitOwnerRepo::with_owners(vec![(
609 Uuid::new_v4(),
610 Uuid::new_v4(),
611 1.0,
612 )]));
613 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
614
615 let (call_date, due_date) = sample_dates();
616
617 let cff = uc
618 .create_call_for_funds(
619 Uuid::new_v4(),
620 Uuid::new_v4(),
621 "Sent call".to_string(),
622 "Description".to_string(),
623 500.0,
624 ContributionType::Regular,
625 call_date,
626 due_date,
627 None,
628 None,
629 )
630 .await
631 .unwrap();
632
633 uc.send_call_for_funds(cff.id).await.unwrap();
635
636 let result = uc.delete_call_for_funds(cff.id).await;
637 assert!(result.is_err());
638 assert!(result
639 .unwrap_err()
640 .contains("Cannot delete a call for funds that has been sent"));
641 }
642
643 #[tokio::test]
646 async fn test_get_overdue_calls() {
647 let call_date = Utc::now() - Duration::days(60);
648 let due_date = Utc::now() - Duration::days(30);
649 let overdue_cff = CallForFunds::new(
650 Uuid::new_v4(),
651 Uuid::new_v4(),
652 "Overdue call".to_string(),
653 "Past due".to_string(),
654 2_000.0,
655 ContributionType::Regular,
656 call_date,
657 due_date,
658 None,
659 )
660 .unwrap();
661
662 let cff_repo = Arc::new(MockCallForFundsRepo::with_overdue(
663 vec![overdue_cff.clone()],
664 ));
665 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
666 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
667 let uc = make_use_cases(cff_repo, contrib_repo, uo_repo);
668
669 let result = uc.get_overdue_calls().await;
670 assert!(result.is_ok());
671 let overdue = result.unwrap();
672 assert_eq!(overdue.len(), 1);
673 assert_eq!(overdue[0].title, "Overdue call");
674 }
675
676 #[tokio::test]
679 async fn test_list_by_building() {
680 let cff_repo = Arc::new(MockCallForFundsRepo::new());
681 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
682 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
683 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
684
685 let building_id = Uuid::new_v4();
686 let other_building = Uuid::new_v4();
687 let org_id = Uuid::new_v4();
688 let (call_date, due_date) = sample_dates();
689
690 uc.create_call_for_funds(
692 org_id,
693 building_id,
694 "Appel 1".to_string(),
695 "Desc 1".to_string(),
696 1_000.0,
697 ContributionType::Regular,
698 call_date,
699 due_date,
700 None,
701 None,
702 )
703 .await
704 .unwrap();
705
706 uc.create_call_for_funds(
707 org_id,
708 building_id,
709 "Appel 2".to_string(),
710 "Desc 2".to_string(),
711 2_000.0,
712 ContributionType::Extraordinary,
713 call_date,
714 due_date,
715 None,
716 None,
717 )
718 .await
719 .unwrap();
720
721 uc.create_call_for_funds(
723 org_id,
724 other_building,
725 "Autre appel".to_string(),
726 "Autre desc".to_string(),
727 500.0,
728 ContributionType::Regular,
729 call_date,
730 due_date,
731 None,
732 None,
733 )
734 .await
735 .unwrap();
736
737 let result = uc.list_by_building(building_id).await;
738 assert!(result.is_ok());
739 let list = result.unwrap();
740 assert_eq!(list.len(), 2);
741 assert!(list.iter().all(|c| c.building_id == building_id));
742 }
743}