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