Skip to main content

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