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: rust_decimal::Decimal,
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: {}%",
134 call_for_funds.title,
135 percentage * rust_decimal_macros::dec!(100)
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, rust_decimal::Decimal)>>,
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, rust_decimal::Decimal)>) -> 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(
413 &self,
414 _unit_id: Uuid,
415 ) -> Result<rust_decimal::Decimal, String> {
416 unimplemented!()
417 }
418 async fn find_active_by_unit_and_owner(
419 &self,
420 _unit_id: Uuid,
421 _owner_id: Uuid,
422 ) -> Result<Option<UnitOwner>, String> {
423 unimplemented!()
424 }
425 async fn find_active_by_building(
426 &self,
427 _building_id: Uuid,
428 ) -> Result<Vec<(Uuid, Uuid, rust_decimal::Decimal)>, String> {
429 Ok(self.active_by_building.lock().unwrap().clone())
430 }
431 }
432
433 fn make_use_cases(
436 cff_repo: Arc<dyn CallForFundsRepository>,
437 contrib_repo: Arc<dyn OwnerContributionRepository>,
438 uo_repo: Arc<dyn UnitOwnerRepository>,
439 ) -> CallForFundsUseCases {
440 CallForFundsUseCases::new(cff_repo, contrib_repo, uo_repo)
441 }
442
443 fn sample_dates() -> (chrono::DateTime<Utc>, chrono::DateTime<Utc>) {
444 let call_date = Utc::now();
445 let due_date = call_date + Duration::days(30);
446 (call_date, due_date)
447 }
448
449 #[tokio::test]
452 async fn test_create_call_for_funds_success() {
453 let cff_repo = Arc::new(MockCallForFundsRepo::new());
454 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
455 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
456 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
457
458 let (call_date, due_date) = sample_dates();
459 let org_id = Uuid::new_v4();
460 let building_id = Uuid::new_v4();
461
462 let result = uc
463 .create_call_for_funds(
464 org_id,
465 building_id,
466 "Appel Q1".to_string(),
467 "Charges courantes".to_string(),
468 rust_decimal_macros::dec!(10_000),
469 ContributionType::Regular,
470 call_date,
471 due_date,
472 Some("7000".to_string()),
473 Some(Uuid::new_v4()),
474 )
475 .await;
476
477 assert!(result.is_ok());
478 let cff = result.unwrap();
479 assert_eq!(cff.total_amount, rust_decimal_macros::dec!(10_000));
480 assert_eq!(cff.status, CallForFundsStatus::Draft);
481 assert_eq!(cff.organization_id, org_id);
482 assert_eq!(cff.building_id, building_id);
483 assert!(cff_repo.store.lock().unwrap().contains_key(&cff.id));
485 }
486
487 #[tokio::test]
490 async fn test_send_call_for_funds_generates_contributions() {
491 let cff_repo = Arc::new(MockCallForFundsRepo::new());
492 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
493
494 let unit1 = Uuid::new_v4();
495 let unit2 = Uuid::new_v4();
496 let owner1 = Uuid::new_v4();
497 let owner2 = Uuid::new_v4();
498 let uo_repo = Arc::new(MockUnitOwnerRepo::with_owners(vec![
499 (unit1, owner1, rust_decimal_macros::dec!(0.60)),
500 (unit2, owner2, rust_decimal_macros::dec!(0.40)),
501 ]));
502
503 let uc = make_use_cases(cff_repo.clone(), contrib_repo.clone(), uo_repo);
504
505 let (call_date, due_date) = sample_dates();
506
507 let cff = uc
508 .create_call_for_funds(
509 Uuid::new_v4(),
510 Uuid::new_v4(),
511 "Appel Q2".to_string(),
512 "Charges extraordinaires".to_string(),
513 rust_decimal_macros::dec!(5_000),
514 ContributionType::Extraordinary,
515 call_date,
516 due_date,
517 None,
518 None,
519 )
520 .await
521 .unwrap();
522
523 let result = uc.send_call_for_funds(cff.id).await;
525 assert!(result.is_ok());
526
527 let sent = result.unwrap();
528 assert_eq!(sent.status, CallForFundsStatus::Sent);
529 assert!(sent.sent_date.is_some());
530
531 let contributions = contrib_repo.store.lock().unwrap();
533 assert_eq!(contributions.len(), 2);
534
535 let mut amounts: Vec<rust_decimal::Decimal> =
536 contributions.iter().map(|c| c.amount).collect();
537 amounts.sort();
538 assert_eq!(amounts[0], rust_decimal_macros::dec!(2_000));
540 assert_eq!(amounts[1], rust_decimal_macros::dec!(3_000));
541 }
542
543 #[tokio::test]
546 async fn test_cancel_call_for_funds() {
547 let cff_repo = Arc::new(MockCallForFundsRepo::new());
548 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
549 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
550 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
551
552 let (call_date, due_date) = sample_dates();
553
554 let cff = uc
555 .create_call_for_funds(
556 Uuid::new_v4(),
557 Uuid::new_v4(),
558 "Appel annulable".to_string(),
559 "Description".to_string(),
560 rust_decimal_macros::dec!(1_000),
561 ContributionType::Regular,
562 call_date,
563 due_date,
564 None,
565 None,
566 )
567 .await
568 .unwrap();
569
570 let result = uc.cancel_call_for_funds(cff.id).await;
571 assert!(result.is_ok());
572 assert_eq!(result.unwrap().status, CallForFundsStatus::Cancelled);
573 }
574
575 #[tokio::test]
578 async fn test_delete_call_for_funds_draft_succeeds() {
579 let cff_repo = Arc::new(MockCallForFundsRepo::new());
580 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
581 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
582 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
583
584 let (call_date, due_date) = sample_dates();
585
586 let cff = uc
587 .create_call_for_funds(
588 Uuid::new_v4(),
589 Uuid::new_v4(),
590 "Supprimable".to_string(),
591 "Description".to_string(),
592 rust_decimal_macros::dec!(500),
593 ContributionType::Advance,
594 call_date,
595 due_date,
596 None,
597 None,
598 )
599 .await
600 .unwrap();
601
602 let result = uc.delete_call_for_funds(cff.id).await;
603 assert!(result.is_ok());
604 assert!(result.unwrap());
605 assert!(!cff_repo.store.lock().unwrap().contains_key(&cff.id));
606 }
607
608 #[tokio::test]
609 async fn test_delete_call_for_funds_rejects_non_draft() {
610 let cff_repo = Arc::new(MockCallForFundsRepo::new());
611 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
612 let uo_repo = Arc::new(MockUnitOwnerRepo::with_owners(vec![(
613 Uuid::new_v4(),
614 Uuid::new_v4(),
615 rust_decimal_macros::dec!(1),
616 )]));
617 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
618
619 let (call_date, due_date) = sample_dates();
620
621 let cff = uc
622 .create_call_for_funds(
623 Uuid::new_v4(),
624 Uuid::new_v4(),
625 "Sent call".to_string(),
626 "Description".to_string(),
627 rust_decimal_macros::dec!(500),
628 ContributionType::Regular,
629 call_date,
630 due_date,
631 None,
632 None,
633 )
634 .await
635 .unwrap();
636
637 uc.send_call_for_funds(cff.id).await.unwrap();
639
640 let result = uc.delete_call_for_funds(cff.id).await;
641 assert!(result.is_err());
642 assert!(result
643 .unwrap_err()
644 .contains("Cannot delete a call for funds that has been sent"));
645 }
646
647 #[tokio::test]
650 async fn test_get_overdue_calls() {
651 let call_date = Utc::now() - Duration::days(60);
652 let due_date = Utc::now() - Duration::days(30);
653 let overdue_cff = CallForFunds::new(
654 Uuid::new_v4(),
655 Uuid::new_v4(),
656 "Overdue call".to_string(),
657 "Past due".to_string(),
658 rust_decimal_macros::dec!(2_000),
659 ContributionType::Regular,
660 call_date,
661 due_date,
662 None,
663 )
664 .unwrap();
665
666 let cff_repo = Arc::new(MockCallForFundsRepo::with_overdue(
667 vec![overdue_cff.clone()],
668 ));
669 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
670 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
671 let uc = make_use_cases(cff_repo, contrib_repo, uo_repo);
672
673 let result = uc.get_overdue_calls().await;
674 assert!(result.is_ok());
675 let overdue = result.unwrap();
676 assert_eq!(overdue.len(), 1);
677 assert_eq!(overdue[0].title, "Overdue call");
678 }
679
680 #[tokio::test]
683 async fn test_list_by_building() {
684 let cff_repo = Arc::new(MockCallForFundsRepo::new());
685 let contrib_repo = Arc::new(MockOwnerContributionRepo::new());
686 let uo_repo = Arc::new(MockUnitOwnerRepo::new());
687 let uc = make_use_cases(cff_repo.clone(), contrib_repo, uo_repo);
688
689 let building_id = Uuid::new_v4();
690 let other_building = Uuid::new_v4();
691 let org_id = Uuid::new_v4();
692 let (call_date, due_date) = sample_dates();
693
694 uc.create_call_for_funds(
696 org_id,
697 building_id,
698 "Appel 1".to_string(),
699 "Desc 1".to_string(),
700 rust_decimal_macros::dec!(1_000),
701 ContributionType::Regular,
702 call_date,
703 due_date,
704 None,
705 None,
706 )
707 .await
708 .unwrap();
709
710 uc.create_call_for_funds(
711 org_id,
712 building_id,
713 "Appel 2".to_string(),
714 "Desc 2".to_string(),
715 rust_decimal_macros::dec!(2_000),
716 ContributionType::Extraordinary,
717 call_date,
718 due_date,
719 None,
720 None,
721 )
722 .await
723 .unwrap();
724
725 uc.create_call_for_funds(
727 org_id,
728 other_building,
729 "Autre appel".to_string(),
730 "Autre desc".to_string(),
731 rust_decimal_macros::dec!(500),
732 ContributionType::Regular,
733 call_date,
734 due_date,
735 None,
736 None,
737 )
738 .await
739 .unwrap();
740
741 let result = uc.list_by_building(building_id).await;
742 assert!(result.is_ok());
743 let list = result.unwrap();
744 assert_eq!(list.len(), 2);
745 assert!(list.iter().all(|c| c.building_id == building_id));
746 }
747}