koprogo_api/application/use_cases/
call_for_funds_use_cases.rs

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    /// Create a new call for funds
29    #[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        // Create the call for funds entity
44        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        // Save to database
59        self.call_for_funds_repository.create(&call_for_funds).await
60    }
61
62    /// Get a call for funds by ID
63    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    /// List all calls for funds for a building
68    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    /// List all calls for funds for an organization
75    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    /// Mark call for funds as sent and generate individual owner contributions
85    /// This is the key operation that automatically creates contributions for all owners
86    pub async fn send_call_for_funds(&self, id: Uuid) -> Result<CallForFunds, String> {
87        // Get the call for funds
88        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        // Mark as sent
95        call_for_funds.mark_as_sent();
96
97        // Update in database
98        let updated_call = self
99            .call_for_funds_repository
100            .update(&call_for_funds)
101            .await?;
102
103        // Generate individual contributions for all owners in the building
104        self.generate_owner_contributions(&updated_call).await?;
105
106        Ok(updated_call)
107    }
108
109    /// Generate individual owner contributions based on ownership percentages
110    async fn generate_owner_contributions(
111        &self,
112        call_for_funds: &CallForFunds,
113    ) -> Result<Vec<OwnerContribution>, String> {
114        // Get all active unit owners for the building
115        // Returns (unit_id, owner_id, percentage)
116        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            // Calculate individual amount based on ownership percentage
129            let individual_amount = call_for_funds.total_amount * percentage;
130
131            // Create contribution description
132            let description = format!(
133                "{} - Quote-part: {:.2}%",
134                call_for_funds.title,
135                percentage * 100.0
136            );
137
138            // Create owner contribution
139            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            // Link to the call for funds
151            contribution.call_for_funds_id = Some(call_for_funds.id);
152
153            // Save contribution
154            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    /// Cancel a call for funds
166    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    /// Get all overdue calls for funds
179    pub async fn get_overdue_calls(&self) -> Result<Vec<CallForFunds>, String> {
180        self.call_for_funds_repository.find_overdue().await
181    }
182
183    /// Delete a call for funds (only if not sent)
184    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        // Don't allow deletion if already sent
192        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    // ── Mock: CallForFundsRepository ──────────────────────────────────
216
217    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    // ── Mock: OwnerContributionRepository ─────────────────────────────
291
292    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    // ── Mock: UnitOwnerRepository ─────────────────────────────────────
358
359    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    // ── Helpers ───────────────────────────────────────────────────────
431
432    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    // ── 1. Create ─────────────────────────────────────────────────────
447
448    #[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        // Verify it was persisted in the mock store
481        assert!(cff_repo.store.lock().unwrap().contains_key(&cff.id));
482    }
483
484    // ── 2. Send (generates contributions) ─────────────────────────────
485
486    #[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        // Send — should generate individual contributions
521        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        // Verify two contributions were created with correct amounts
529        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        // 40% of 5000 = 2000, 60% of 5000 = 3000
535        assert!((amounts[0] - 2_000.0).abs() < 0.01);
536        assert!((amounts[1] - 3_000.0).abs() < 0.01);
537    }
538
539    // ── 3. Cancel ─────────────────────────────────────────────────────
540
541    #[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    // ── 4. Delete (draft only, rejects sent) ──────────────────────────
572
573    #[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        // Send so it is no longer Draft
634        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    // ── 5. Find overdue ───────────────────────────────────────────────
644
645    #[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    // ── 6. List by building ───────────────────────────────────────────
677
678    #[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        // Two calls for our building
691        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        // One call for another building (noise)
722        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}