koprogo_api/application/use_cases/
owner_contribution_use_cases.rs

1use crate::application::ports::OwnerContributionRepository;
2use crate::domain::entities::{ContributionPaymentMethod, ContributionType, OwnerContribution};
3use chrono::{DateTime, Utc};
4use std::sync::Arc;
5use uuid::Uuid;
6
7pub struct OwnerContributionUseCases {
8    repository: Arc<dyn OwnerContributionRepository>,
9}
10
11impl OwnerContributionUseCases {
12    pub fn new(repository: Arc<dyn OwnerContributionRepository>) -> Self {
13        Self { repository }
14    }
15
16    /// Create a new owner contribution (appel de fonds)
17    #[allow(clippy::too_many_arguments)]
18    pub async fn create_contribution(
19        &self,
20        organization_id: Uuid,
21        owner_id: Uuid,
22        unit_id: Option<Uuid>,
23        description: String,
24        amount: f64,
25        contribution_type: ContributionType,
26        contribution_date: DateTime<Utc>,
27        account_code: Option<String>,
28    ) -> Result<OwnerContribution, String> {
29        // Create domain entity (validates business rules)
30        let contribution = OwnerContribution::new(
31            organization_id,
32            owner_id,
33            unit_id,
34            description,
35            amount,
36            contribution_type,
37            contribution_date,
38            account_code,
39        )?;
40
41        // Persist
42        self.repository.create(&contribution).await
43    }
44
45    /// Record payment for a contribution
46    pub async fn record_payment(
47        &self,
48        contribution_id: Uuid,
49        payment_date: DateTime<Utc>,
50        payment_method: ContributionPaymentMethod,
51        payment_reference: Option<String>,
52    ) -> Result<OwnerContribution, String> {
53        // Find contribution
54        let mut contribution = self
55            .repository
56            .find_by_id(contribution_id)
57            .await?
58            .ok_or_else(|| format!("Contribution not found: {}", contribution_id))?;
59
60        // Prevent double payment
61        if contribution.is_paid() {
62            return Err("Contribution is already paid".to_string());
63        }
64
65        // Mark as paid (domain logic)
66        contribution.mark_as_paid(payment_date, payment_method, payment_reference);
67
68        // Update
69        self.repository.update(&contribution).await
70    }
71
72    /// Get contribution by ID
73    pub async fn get_contribution(
74        &self,
75        contribution_id: Uuid,
76    ) -> Result<Option<OwnerContribution>, String> {
77        self.repository.find_by_id(contribution_id).await
78    }
79
80    /// Get all contributions for an organization
81    pub async fn get_contributions_by_organization(
82        &self,
83        organization_id: Uuid,
84    ) -> Result<Vec<OwnerContribution>, String> {
85        self.repository.find_by_organization(organization_id).await
86    }
87
88    /// Get all contributions for an owner
89    pub async fn get_contributions_by_owner(
90        &self,
91        owner_id: Uuid,
92    ) -> Result<Vec<OwnerContribution>, String> {
93        self.repository.find_by_owner(owner_id).await
94    }
95
96    /// Get outstanding (unpaid) contributions for an owner
97    pub async fn get_outstanding_contributions(
98        &self,
99        owner_id: Uuid,
100    ) -> Result<Vec<OwnerContribution>, String> {
101        let contributions = self.repository.find_by_owner(owner_id).await?;
102
103        // Filter unpaid
104        Ok(contributions.into_iter().filter(|c| !c.is_paid()).collect())
105    }
106
107    /// Get overdue contributions for an owner
108    pub async fn get_overdue_contributions(
109        &self,
110        owner_id: Uuid,
111    ) -> Result<Vec<OwnerContribution>, String> {
112        let contributions = self.repository.find_by_owner(owner_id).await?;
113
114        // Filter overdue
115        Ok(contributions
116            .into_iter()
117            .filter(|c| c.is_overdue())
118            .collect())
119    }
120
121    /// Get total outstanding amount for an owner
122    pub async fn get_outstanding_amount(&self, owner_id: Uuid) -> Result<f64, String> {
123        let outstanding = self.get_outstanding_contributions(owner_id).await?;
124        Ok(outstanding.iter().map(|c| c.amount).sum())
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use async_trait::async_trait;
132    use std::collections::HashMap;
133    use std::sync::Mutex;
134
135    struct MockOwnerContributionRepository {
136        items: Mutex<HashMap<Uuid, OwnerContribution>>,
137    }
138
139    impl MockOwnerContributionRepository {
140        fn new() -> Self {
141            Self {
142                items: Mutex::new(HashMap::new()),
143            }
144        }
145    }
146
147    #[async_trait]
148    impl OwnerContributionRepository for MockOwnerContributionRepository {
149        async fn create(
150            &self,
151            contribution: &OwnerContribution,
152        ) -> Result<OwnerContribution, String> {
153            let mut items = self.items.lock().unwrap();
154            items.insert(contribution.id, contribution.clone());
155            Ok(contribution.clone())
156        }
157
158        async fn find_by_id(&self, id: Uuid) -> Result<Option<OwnerContribution>, String> {
159            let items = self.items.lock().unwrap();
160            Ok(items.get(&id).cloned())
161        }
162
163        async fn find_by_organization(
164            &self,
165            organization_id: Uuid,
166        ) -> Result<Vec<OwnerContribution>, String> {
167            let items = self.items.lock().unwrap();
168            Ok(items
169                .values()
170                .filter(|c| c.organization_id == organization_id)
171                .cloned()
172                .collect())
173        }
174
175        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<OwnerContribution>, String> {
176            let items = self.items.lock().unwrap();
177            Ok(items
178                .values()
179                .filter(|c| c.owner_id == owner_id)
180                .cloned()
181                .collect())
182        }
183
184        async fn update(
185            &self,
186            contribution: &OwnerContribution,
187        ) -> Result<OwnerContribution, String> {
188            let mut items = self.items.lock().unwrap();
189            items.insert(contribution.id, contribution.clone());
190            Ok(contribution.clone())
191        }
192    }
193
194    fn make_use_cases(repo: MockOwnerContributionRepository) -> OwnerContributionUseCases {
195        OwnerContributionUseCases::new(Arc::new(repo))
196    }
197
198    #[tokio::test]
199    async fn test_create_contribution_success() {
200        let repo = MockOwnerContributionRepository::new();
201        let use_cases = make_use_cases(repo);
202        let org_id = Uuid::new_v4();
203        let owner_id = Uuid::new_v4();
204        let unit_id = Uuid::new_v4();
205
206        let result = use_cases
207            .create_contribution(
208                org_id,
209                owner_id,
210                Some(unit_id),
211                "Appel de fonds Q1 2026".to_string(),
212                750.0,
213                ContributionType::Regular,
214                Utc::now(),
215                Some("7000".to_string()),
216            )
217            .await;
218
219        assert!(result.is_ok());
220        let contrib = result.unwrap();
221        assert_eq!(contrib.organization_id, org_id);
222        assert_eq!(contrib.owner_id, owner_id);
223        assert_eq!(contrib.unit_id, Some(unit_id));
224        assert_eq!(contrib.amount, 750.0);
225        assert_eq!(contrib.contribution_type, ContributionType::Regular);
226        assert!(!contrib.is_paid());
227    }
228
229    #[tokio::test]
230    async fn test_record_payment_success() {
231        let repo = MockOwnerContributionRepository::new();
232        let org_id = Uuid::new_v4();
233        let owner_id = Uuid::new_v4();
234
235        // Pre-populate with a pending contribution
236        let contrib = OwnerContribution::new(
237            org_id,
238            owner_id,
239            None,
240            "Charges Q2".to_string(),
241            500.0,
242            ContributionType::Regular,
243            Utc::now(),
244            None,
245        )
246        .unwrap();
247        let contrib_id = contrib.id;
248        repo.items.lock().unwrap().insert(contrib.id, contrib);
249
250        let use_cases = make_use_cases(repo);
251        let result = use_cases
252            .record_payment(
253                contrib_id,
254                Utc::now(),
255                ContributionPaymentMethod::BankTransfer,
256                Some("VIR-2026-001".to_string()),
257            )
258            .await;
259
260        assert!(result.is_ok());
261        let paid = result.unwrap();
262        assert!(paid.is_paid());
263        assert!(paid.payment_date.is_some());
264        assert_eq!(
265            paid.payment_method,
266            Some(ContributionPaymentMethod::BankTransfer)
267        );
268        assert_eq!(paid.payment_reference, Some("VIR-2026-001".to_string()));
269    }
270
271    #[tokio::test]
272    async fn test_record_payment_double_payment_rejected() {
273        let repo = MockOwnerContributionRepository::new();
274        let org_id = Uuid::new_v4();
275        let owner_id = Uuid::new_v4();
276
277        // Pre-populate with an already-paid contribution
278        let mut contrib = OwnerContribution::new(
279            org_id,
280            owner_id,
281            None,
282            "Charges Q3".to_string(),
283            300.0,
284            ContributionType::Regular,
285            Utc::now(),
286            None,
287        )
288        .unwrap();
289        contrib.mark_as_paid(Utc::now(), ContributionPaymentMethod::Cash, None);
290        let contrib_id = contrib.id;
291        repo.items.lock().unwrap().insert(contrib.id, contrib);
292
293        let use_cases = make_use_cases(repo);
294        let result = use_cases
295            .record_payment(
296                contrib_id,
297                Utc::now(),
298                ContributionPaymentMethod::BankTransfer,
299                None,
300            )
301            .await;
302
303        assert!(result.is_err());
304        assert_eq!(result.unwrap_err(), "Contribution is already paid");
305    }
306
307    #[tokio::test]
308    async fn test_get_outstanding_contributions() {
309        let repo = MockOwnerContributionRepository::new();
310        let org_id = Uuid::new_v4();
311        let owner_id = Uuid::new_v4();
312
313        // Create one paid and two unpaid contributions
314        let mut paid_contrib = OwnerContribution::new(
315            org_id,
316            owner_id,
317            None,
318            "Charges Q1 - paid".to_string(),
319            200.0,
320            ContributionType::Regular,
321            Utc::now(),
322            None,
323        )
324        .unwrap();
325        paid_contrib.mark_as_paid(Utc::now(), ContributionPaymentMethod::Domiciliation, None);
326
327        let unpaid1 = OwnerContribution::new(
328            org_id,
329            owner_id,
330            None,
331            "Charges Q2 - unpaid".to_string(),
332            300.0,
333            ContributionType::Regular,
334            Utc::now(),
335            None,
336        )
337        .unwrap();
338
339        let unpaid2 = OwnerContribution::new(
340            org_id,
341            owner_id,
342            None,
343            "Travaux extraordinaires".to_string(),
344            1500.0,
345            ContributionType::Extraordinary,
346            Utc::now(),
347            None,
348        )
349        .unwrap();
350
351        {
352            let mut items = repo.items.lock().unwrap();
353            items.insert(paid_contrib.id, paid_contrib);
354            items.insert(unpaid1.id, unpaid1);
355            items.insert(unpaid2.id, unpaid2);
356        }
357
358        let use_cases = make_use_cases(repo);
359        let result = use_cases.get_outstanding_contributions(owner_id).await;
360
361        assert!(result.is_ok());
362        let outstanding = result.unwrap();
363        assert_eq!(outstanding.len(), 2);
364        assert!(outstanding.iter().all(|c| !c.is_paid()));
365    }
366
367    #[tokio::test]
368    async fn test_get_outstanding_amount() {
369        let repo = MockOwnerContributionRepository::new();
370        let org_id = Uuid::new_v4();
371        let owner_id = Uuid::new_v4();
372
373        // Create one paid (should not count) and two unpaid
374        let mut paid = OwnerContribution::new(
375            org_id,
376            owner_id,
377            None,
378            "Paid contribution".to_string(),
379            100.0,
380            ContributionType::Regular,
381            Utc::now(),
382            None,
383        )
384        .unwrap();
385        paid.mark_as_paid(Utc::now(), ContributionPaymentMethod::Check, None);
386
387        let unpaid1 = OwnerContribution::new(
388            org_id,
389            owner_id,
390            None,
391            "Unpaid 1".to_string(),
392            250.0,
393            ContributionType::Regular,
394            Utc::now(),
395            None,
396        )
397        .unwrap();
398
399        let unpaid2 = OwnerContribution::new(
400            org_id,
401            owner_id,
402            None,
403            "Unpaid 2".to_string(),
404            400.0,
405            ContributionType::Extraordinary,
406            Utc::now(),
407            None,
408        )
409        .unwrap();
410
411        {
412            let mut items = repo.items.lock().unwrap();
413            items.insert(paid.id, paid);
414            items.insert(unpaid1.id, unpaid1);
415            items.insert(unpaid2.id, unpaid2);
416        }
417
418        let use_cases = make_use_cases(repo);
419        let result = use_cases.get_outstanding_amount(owner_id).await;
420
421        assert!(result.is_ok());
422        let amount = result.unwrap();
423        assert!((amount - 650.0).abs() < f64::EPSILON);
424    }
425}