1use crate::application::ports::OwnerContributionRepository;
2use crate::application::services::expense_accounting_service::ExpenseAccountingService;
3use crate::domain::entities::{ContributionPaymentMethod, ContributionType, OwnerContribution};
4use chrono::{DateTime, Utc};
5use rust_decimal::Decimal;
6use std::sync::Arc;
7use uuid::Uuid;
8
9pub struct OwnerContributionUseCases {
10 repository: Arc<dyn OwnerContributionRepository>,
11 unit_repository: Option<Arc<dyn crate::application::ports::UnitRepository>>,
17 accounting_service: Option<Arc<ExpenseAccountingService>>,
21}
22
23impl OwnerContributionUseCases {
24 pub fn new(repository: Arc<dyn OwnerContributionRepository>) -> Self {
25 Self {
26 repository,
27 unit_repository: None,
28 accounting_service: None,
29 }
30 }
31
32 pub fn with_acp_resolution(
34 mut self,
35 unit_repository: Arc<dyn crate::application::ports::UnitRepository>,
36 ) -> Self {
37 self.unit_repository = Some(unit_repository);
38 self
39 }
40
41 pub fn with_accounting(mut self, accounting_service: Arc<ExpenseAccountingService>) -> Self {
42 self.accounting_service = Some(accounting_service);
43 self
44 }
45
46 pub(crate) async fn enregistrer_encaissement(&self, contribution: &OwnerContribution) {
59 let Some(ref accounting) = self.accounting_service else {
60 return;
61 };
62 if let Err(e) = accounting
68 .generate_contribution_receipt_entry(contribution, None, None, None)
69 .await
70 {
71 log::warn!(
72 "Écriture d'encaissement non générée pour la quote-part {} : {}",
73 contribution.id,
74 e
75 );
76 }
77 }
78
79 async fn resoudre_lacp_creanciere(&self, unit_id: Option<Uuid>) -> Result<Uuid, String> {
87 let unit_id = unit_id.ok_or_else(|| {
88 "Impossible de déterminer l'ACP créancière : la quote-part doit porter un lot"
89 .to_string()
90 })?;
91 let Some(unit_repo) = &self.unit_repository else {
92 return Err(
93 "Impossible de déterminer l'ACP créancière : dépôt de lots non câblé".to_string(),
94 );
95 };
96 let unit = unit_repo
97 .find_by_id(unit_id)
98 .await?
99 .ok_or_else(|| "Lot introuvable".to_string())?;
100 Ok(unit.acp_id)
101 }
102
103 #[allow(clippy::too_many_arguments)]
105 pub async fn create_contribution(
106 &self,
107 organization_id: Uuid,
108 owner_id: Uuid,
109 unit_id: Option<Uuid>,
110 description: String,
111 amount: Decimal,
112 contribution_type: ContributionType,
113 contribution_date: DateTime<Utc>,
114 account_code: Option<String>,
115 ) -> Result<OwnerContribution, String> {
116 let acp_id = self.resoudre_lacp_creanciere(unit_id).await?;
117
118 let contribution = OwnerContribution::new(
120 acp_id,
121 organization_id,
122 owner_id,
123 unit_id,
124 description,
125 amount,
126 contribution_type,
127 contribution_date,
128 account_code,
129 )?;
130
131 self.repository.create(&contribution).await
133 }
134
135 pub async fn record_payment(
137 &self,
138 contribution_id: Uuid,
139 payment_date: DateTime<Utc>,
140 payment_method: ContributionPaymentMethod,
141 payment_reference: Option<String>,
142 ) -> Result<OwnerContribution, String> {
143 let mut contribution = self
145 .repository
146 .find_by_id(contribution_id)
147 .await?
148 .ok_or_else(|| format!("Contribution not found: {}", contribution_id))?;
149
150 if contribution.is_paid() {
152 return Err("Contribution is already paid".to_string());
153 }
154
155 contribution.mark_as_paid(payment_date, payment_method, payment_reference);
157
158 let updated = self.repository.update(&contribution).await?;
160
161 self.enregistrer_encaissement(&updated).await;
163
164 Ok(updated)
165 }
166
167 pub async fn get_contribution(
169 &self,
170 contribution_id: Uuid,
171 ) -> Result<Option<OwnerContribution>, String> {
172 self.repository.find_by_id(contribution_id).await
173 }
174
175 pub async fn get_contributions_by_organization(
177 &self,
178 organization_id: Uuid,
179 ) -> Result<Vec<OwnerContribution>, String> {
180 self.repository.find_by_organization(organization_id).await
181 }
182
183 pub async fn get_contributions_by_owner(
185 &self,
186 owner_id: Uuid,
187 ) -> Result<Vec<OwnerContribution>, String> {
188 self.repository.find_by_owner(owner_id).await
189 }
190
191 pub async fn get_outstanding_contributions(
193 &self,
194 owner_id: Uuid,
195 ) -> Result<Vec<OwnerContribution>, String> {
196 let contributions = self.repository.find_by_owner(owner_id).await?;
197
198 Ok(contributions.into_iter().filter(|c| !c.is_paid()).collect())
200 }
201
202 pub async fn get_overdue_contributions(
204 &self,
205 owner_id: Uuid,
206 ) -> Result<Vec<OwnerContribution>, String> {
207 let contributions = self.repository.find_by_owner(owner_id).await?;
208
209 Ok(contributions
211 .into_iter()
212 .filter(|c| c.is_overdue())
213 .collect())
214 }
215
216 pub async fn get_outstanding_amount(&self, owner_id: Uuid) -> Result<Decimal, String> {
218 let outstanding = self.get_outstanding_contributions(owner_id).await?;
219 Ok(outstanding.iter().map(|c| c.amount).sum())
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use async_trait::async_trait;
227 use std::collections::HashMap;
228 use std::sync::Mutex;
229
230 struct MockOwnerContributionRepository {
231 items: Mutex<HashMap<Uuid, OwnerContribution>>,
232 }
233
234 impl MockOwnerContributionRepository {
235 fn new() -> Self {
236 Self {
237 items: Mutex::new(HashMap::new()),
238 }
239 }
240 }
241
242 #[async_trait]
243 impl OwnerContributionRepository for MockOwnerContributionRepository {
244 async fn create(
245 &self,
246 contribution: &OwnerContribution,
247 ) -> Result<OwnerContribution, String> {
248 let mut items = self.items.lock().unwrap();
249 items.insert(contribution.id, contribution.clone());
250 Ok(contribution.clone())
251 }
252
253 async fn find_by_id(&self, id: Uuid) -> Result<Option<OwnerContribution>, String> {
254 let items = self.items.lock().unwrap();
255 Ok(items.get(&id).cloned())
256 }
257
258 async fn find_by_organization(
259 &self,
260 organization_id: Uuid,
261 ) -> Result<Vec<OwnerContribution>, String> {
262 let items = self.items.lock().unwrap();
263 Ok(items
264 .values()
265 .filter(|c| c.organization_id == organization_id)
266 .cloned()
267 .collect())
268 }
269
270 async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<OwnerContribution>, String> {
271 let items = self.items.lock().unwrap();
272 Ok(items
273 .values()
274 .filter(|c| c.owner_id == owner_id)
275 .cloned()
276 .collect())
277 }
278
279 async fn update(
280 &self,
281 contribution: &OwnerContribution,
282 ) -> Result<OwnerContribution, String> {
283 let mut items = self.items.lock().unwrap();
284 items.insert(contribution.id, contribution.clone());
285 Ok(contribution.clone())
286 }
287 }
288
289 struct MockUnitRepository {
294 acp_id: Uuid,
295 }
296
297 impl MockUnitRepository {
298 fn lot_de(acp_id: Uuid) -> Self {
299 Self { acp_id }
300 }
301
302 fn lot(&self) -> crate::domain::entities::Unit {
303 crate::domain::entities::Unit::new(
304 self.acp_id,
305 Uuid::new_v4(),
306 "A101".to_string(),
307 crate::domain::entities::UnitType::Apartment,
308 Some(1),
309 85.0,
310 rust_decimal_macros::dec!(100),
311 )
312 .expect("lot valide")
313 }
314 }
315
316 #[async_trait::async_trait]
317 impl crate::application::ports::UnitRepository for MockUnitRepository {
318 async fn create(
319 &self,
320 u: &crate::domain::entities::Unit,
321 ) -> Result<crate::domain::entities::Unit, String> {
322 Ok(u.clone())
323 }
324 async fn find_by_id(
325 &self,
326 _id: Uuid,
327 ) -> Result<Option<crate::domain::entities::Unit>, String> {
328 Ok(Some(self.lot()))
329 }
330 async fn find_by_building(
331 &self,
332 _b: Uuid,
333 ) -> Result<Vec<crate::domain::entities::Unit>, String> {
334 Ok(vec![self.lot()])
335 }
336 async fn find_by_owner(
337 &self,
338 _o: Uuid,
339 ) -> Result<Vec<crate::domain::entities::Unit>, String> {
340 Ok(vec![self.lot()])
341 }
342 async fn find_all_paginated(
343 &self,
344 _p: &crate::application::dto::PageRequest,
345 _f: &crate::application::dto::UnitFilters,
346 ) -> Result<(Vec<crate::domain::entities::Unit>, i64), String> {
347 Ok((vec![self.lot()], 1))
348 }
349 async fn update(
350 &self,
351 u: &crate::domain::entities::Unit,
352 ) -> Result<crate::domain::entities::Unit, String> {
353 Ok(u.clone())
354 }
355 async fn delete(&self, _id: Uuid) -> Result<bool, String> {
356 Ok(true)
357 }
358 }
359
360 fn make_use_cases(repo: MockOwnerContributionRepository) -> OwnerContributionUseCases {
361 make_use_cases_pour_lacp(repo, Uuid::new_v4())
362 }
363
364 fn make_use_cases_pour_lacp(
366 repo: MockOwnerContributionRepository,
367 acp_id: Uuid,
368 ) -> OwnerContributionUseCases {
369 OwnerContributionUseCases::new(Arc::new(repo))
370 .with_acp_resolution(Arc::new(MockUnitRepository::lot_de(acp_id)))
371 }
372
373 #[tokio::test]
378 async fn test_la_quote_part_est_due_a_lacp_du_lot_pas_au_syndic() {
379 let acp_creanciere = Uuid::new_v4();
380 let cabinet_emetteur = Uuid::new_v4();
381 let use_cases =
382 make_use_cases_pour_lacp(MockOwnerContributionRepository::new(), acp_creanciere);
383
384 let quote_part = use_cases
385 .create_contribution(
386 cabinet_emetteur,
387 Uuid::new_v4(),
388 Some(Uuid::new_v4()),
389 "Appel de fonds Q1 2026".to_string(),
390 rust_decimal_macros::dec!(750),
391 ContributionType::Regular,
392 Utc::now(),
393 Some("7000".to_string()),
394 )
395 .await
396 .expect("création valide");
397
398 assert_eq!(
399 quote_part.acp_id, acp_creanciere,
400 "la quote-part est due à l'ACP du lot"
401 );
402 assert_eq!(
403 quote_part.organization_id, cabinet_emetteur,
404 "le syndic reste tracé comme émetteur, sans devenir créancier"
405 );
406 }
407
408 #[tokio::test]
410 async fn test_pas_de_quote_part_sans_lot_donc_sans_creanciere() {
411 let use_cases = make_use_cases(MockOwnerContributionRepository::new());
412
413 let erreur = use_cases
414 .create_contribution(
415 Uuid::new_v4(),
416 Uuid::new_v4(),
417 None, "Appel hors lot".to_string(),
419 rust_decimal_macros::dec!(750),
420 ContributionType::Regular,
421 Utc::now(),
422 None,
423 )
424 .await
425 .expect_err("doit refuser");
426
427 assert!(
428 erreur.contains("créancière"),
429 "le refus doit nommer ce qui manque : {erreur}"
430 );
431 }
432
433 #[tokio::test]
434 async fn test_create_contribution_success() {
435 let repo = MockOwnerContributionRepository::new();
436 let use_cases = make_use_cases(repo);
437 let org_id = Uuid::new_v4();
438 let owner_id = Uuid::new_v4();
439 let unit_id = Uuid::new_v4();
440
441 let result = use_cases
442 .create_contribution(
443 org_id,
444 owner_id,
445 Some(unit_id),
446 "Appel de fonds Q1 2026".to_string(),
447 rust_decimal_macros::dec!(750),
448 ContributionType::Regular,
449 Utc::now(),
450 Some("7000".to_string()),
451 )
452 .await;
453
454 assert!(result.is_ok());
455 let contrib = result.unwrap();
456 assert_eq!(contrib.organization_id, org_id);
457 assert_eq!(contrib.owner_id, owner_id);
458 assert_eq!(contrib.unit_id, Some(unit_id));
459 assert_eq!(contrib.amount, rust_decimal_macros::dec!(750));
460 assert_eq!(contrib.contribution_type, ContributionType::Regular);
461 assert!(!contrib.is_paid());
462 }
463
464 #[tokio::test]
465 async fn test_record_payment_success() {
466 let repo = MockOwnerContributionRepository::new();
467 let org_id = Uuid::new_v4();
468 let owner_id = Uuid::new_v4();
469
470 let contrib = OwnerContribution::new(
472 Uuid::new_v4(), org_id,
474 owner_id,
475 None,
476 "Charges Q2".to_string(),
477 rust_decimal_macros::dec!(500),
478 ContributionType::Regular,
479 Utc::now(),
480 None,
481 )
482 .unwrap();
483 let contrib_id = contrib.id;
484 repo.items.lock().unwrap().insert(contrib.id, contrib);
485
486 let use_cases = make_use_cases(repo);
487 let result = use_cases
488 .record_payment(
489 contrib_id,
490 Utc::now(),
491 ContributionPaymentMethod::BankTransfer,
492 Some("VIR-2026-001".to_string()),
493 )
494 .await;
495
496 assert!(result.is_ok());
497 let paid = result.unwrap();
498 assert!(paid.is_paid());
499 assert!(paid.payment_date.is_some());
500 assert_eq!(
501 paid.payment_method,
502 Some(ContributionPaymentMethod::BankTransfer)
503 );
504 assert_eq!(paid.payment_reference, Some("VIR-2026-001".to_string()));
505 }
506
507 #[tokio::test]
508 async fn test_record_payment_double_payment_rejected() {
509 let repo = MockOwnerContributionRepository::new();
510 let org_id = Uuid::new_v4();
511 let owner_id = Uuid::new_v4();
512
513 let mut contrib = OwnerContribution::new(
515 Uuid::new_v4(), org_id,
517 owner_id,
518 None,
519 "Charges Q3".to_string(),
520 rust_decimal_macros::dec!(300),
521 ContributionType::Regular,
522 Utc::now(),
523 None,
524 )
525 .unwrap();
526 contrib.mark_as_paid(Utc::now(), ContributionPaymentMethod::Cash, None);
527 let contrib_id = contrib.id;
528 repo.items.lock().unwrap().insert(contrib.id, contrib);
529
530 let use_cases = make_use_cases(repo);
531 let result = use_cases
532 .record_payment(
533 contrib_id,
534 Utc::now(),
535 ContributionPaymentMethod::BankTransfer,
536 None,
537 )
538 .await;
539
540 assert!(result.is_err());
541 assert_eq!(result.unwrap_err(), "Contribution is already paid");
542 }
543
544 #[tokio::test]
545 async fn test_get_outstanding_contributions() {
546 let repo = MockOwnerContributionRepository::new();
547 let org_id = Uuid::new_v4();
548 let owner_id = Uuid::new_v4();
549
550 let mut paid_contrib = OwnerContribution::new(
552 Uuid::new_v4(), org_id,
554 owner_id,
555 None,
556 "Charges Q1 - paid".to_string(),
557 rust_decimal_macros::dec!(200),
558 ContributionType::Regular,
559 Utc::now(),
560 None,
561 )
562 .unwrap();
563 paid_contrib.mark_as_paid(Utc::now(), ContributionPaymentMethod::Domiciliation, None);
564
565 let unpaid1 = OwnerContribution::new(
566 Uuid::new_v4(), org_id,
568 owner_id,
569 None,
570 "Charges Q2 - unpaid".to_string(),
571 rust_decimal_macros::dec!(300),
572 ContributionType::Regular,
573 Utc::now(),
574 None,
575 )
576 .unwrap();
577
578 let unpaid2 = OwnerContribution::new(
579 Uuid::new_v4(), org_id,
581 owner_id,
582 None,
583 "Travaux extraordinaires".to_string(),
584 rust_decimal_macros::dec!(1500),
585 ContributionType::Extraordinary,
586 Utc::now(),
587 None,
588 )
589 .unwrap();
590
591 {
592 let mut items = repo.items.lock().unwrap();
593 items.insert(paid_contrib.id, paid_contrib);
594 items.insert(unpaid1.id, unpaid1);
595 items.insert(unpaid2.id, unpaid2);
596 }
597
598 let use_cases = make_use_cases(repo);
599 let result = use_cases.get_outstanding_contributions(owner_id).await;
600
601 assert!(result.is_ok());
602 let outstanding = result.unwrap();
603 assert_eq!(outstanding.len(), 2);
604 assert!(outstanding.iter().all(|c| !c.is_paid()));
605 }
606
607 #[tokio::test]
608 async fn test_get_outstanding_amount() {
609 let repo = MockOwnerContributionRepository::new();
610 let org_id = Uuid::new_v4();
611 let owner_id = Uuid::new_v4();
612
613 let mut paid = OwnerContribution::new(
615 Uuid::new_v4(), org_id,
617 owner_id,
618 None,
619 "Paid contribution".to_string(),
620 rust_decimal_macros::dec!(100),
621 ContributionType::Regular,
622 Utc::now(),
623 None,
624 )
625 .unwrap();
626 paid.mark_as_paid(Utc::now(), ContributionPaymentMethod::Check, None);
627
628 let unpaid1 = OwnerContribution::new(
629 Uuid::new_v4(), org_id,
631 owner_id,
632 None,
633 "Unpaid 1".to_string(),
634 rust_decimal_macros::dec!(250),
635 ContributionType::Regular,
636 Utc::now(),
637 None,
638 )
639 .unwrap();
640
641 let unpaid2 = OwnerContribution::new(
642 Uuid::new_v4(), org_id,
644 owner_id,
645 None,
646 "Unpaid 2".to_string(),
647 rust_decimal_macros::dec!(400),
648 ContributionType::Extraordinary,
649 Utc::now(),
650 None,
651 )
652 .unwrap();
653
654 {
655 let mut items = repo.items.lock().unwrap();
656 items.insert(paid.id, paid);
657 items.insert(unpaid1.id, unpaid1);
658 items.insert(unpaid2.id, unpaid2);
659 }
660
661 let use_cases = make_use_cases(repo);
662 let result = use_cases.get_outstanding_amount(owner_id).await;
663
664 assert!(result.is_ok());
665 let amount = result.unwrap();
666 assert_eq!(amount, rust_decimal_macros::dec!(650));
667 }
668}