Skip to main content

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: 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        // 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: {}%",
134                call_for_funds.title,
135                percentage * rust_decimal_macros::dec!(100)
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, 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    // ── Helpers ───────────────────────────────────────────────────────
434
435    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    // ── 1. Create ─────────────────────────────────────────────────────
450
451    #[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        // Verify it was persisted in the mock store
484        assert!(cff_repo.store.lock().unwrap().contains_key(&cff.id));
485    }
486
487    // ── 2. Send (generates contributions) ─────────────────────────────
488
489    #[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        // Send — should generate individual contributions
524        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        // Verify two contributions were created with correct amounts
532        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        // 40% of 5000 = 2000, 60% of 5000 = 3000
539        assert_eq!(amounts[0], rust_decimal_macros::dec!(2_000));
540        assert_eq!(amounts[1], rust_decimal_macros::dec!(3_000));
541    }
542
543    // ── 3. Cancel ─────────────────────────────────────────────────────
544
545    #[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    // ── 4. Delete (draft only, rejects sent) ──────────────────────────
576
577    #[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        // Send so it is no longer Draft
638        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    // ── 5. Find overdue ───────────────────────────────────────────────
648
649    #[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    // ── 6. List by building ───────────────────────────────────────────
681
682    #[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        // Two calls for our building
695        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        // One call for another building (noise)
726        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}