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 #[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 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 self.repository.create(&contribution).await
43 }
44
45 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 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 if contribution.is_paid() {
62 return Err("Contribution is already paid".to_string());
63 }
64
65 contribution.mark_as_paid(payment_date, payment_method, payment_reference);
67
68 self.repository.update(&contribution).await
70 }
71
72 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 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 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 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 Ok(contributions.into_iter().filter(|c| !c.is_paid()).collect())
105 }
106
107 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 Ok(contributions
116 .into_iter()
117 .filter(|c| c.is_overdue())
118 .collect())
119 }
120
121 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 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 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 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 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}