Skip to main content

koprogo_api/application/use_cases/
owner_contribution_use_cases.rs

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    /// Résolution de l'ACP créancière, depuis le lot.
12    ///
13    /// Optionnel comme `accounting_service`, pour ne pas casser les
14    /// constructeurs des tests unitaires — mais son absence fait échouer la
15    /// création, elle ne la laisse pas passer avec un identifiant de repli.
16    unit_repository: Option<Arc<dyn crate::application::ports::UnitRepository>>,
17    /// Optionnel pour préserver les constructeurs des tests unitaires, qui ne
18    /// montent qu'un dépôt de contributions. Le câblage de `main.rs` le
19    /// fournit toujours.
20    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    /// Câble la résolution de l'ACP créancière depuis le lot.
33    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    /// Enregistre l'écriture d'encaissement d'une quote-part soldée.
47    ///
48    /// Volontairement INFAILLIBLE, comme du côté des dépenses : le paiement a
49    /// été enregistré et persisté ; refuser l'opération à cause d'une écriture
50    /// comptable ferait perdre l'encaissement lui-même.
51    ///
52    /// Ce silence a un précédent coûteux — c'est lui qui a laissé la
53    /// génération automatique côté dépense échouer pendant des mois sur des
54    /// codes de compte inexistants (constat F7 du 2026-09-01). Le garde-fou
55    /// n'est donc pas ici mais en amont :
56    /// `test_les_comptes_utilises_existent_dans_le_plan` vérifie que les
57    /// comptes référencés existent bel et bien dans le plan provisionné.
58    pub(crate) async fn enregistrer_encaissement(&self, contribution: &OwnerContribution) {
59        let Some(ref accounting) = self.accounting_service else {
60            return;
61        };
62        // `building_id` à None : la quote-part ne porte qu'un `unit_id`
63        // optionnel, et remonter au bâtiment demanderait un dépôt de lots ici.
64        // Conséquence assumée et limitée : l'écriture est bien au grand livre,
65        // mais n'apparaît pas dans les rapports financiers PAR IMMEUBLE. La
66        // voie `/payments`, elle, connaît son immeuble et le renseigne.
67        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    /// L'ACP à laquelle la quote-part est due, lue sur le lot.
80    ///
81    /// Le lot porte déjà son ACP (Story H15) : le rattachement vient de l'acte
82    /// de base, il ne dépend ni de l'appelant ni du mandat en cours. On échoue
83    /// si on ne peut pas le lire, plutôt que de retomber sur l'identifiant du
84    /// syndic — une quote-part due à personne n'est pas une quote-part
85    /// (ADR-0045).
86    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    /// Create a new owner contribution (appel de fonds)
104    #[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        // Create domain entity (validates business rules)
119        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        // Persist
132        self.repository.create(&contribution).await
133    }
134
135    /// Record payment for a contribution
136    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        // Find contribution
144        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        // Prevent double payment
151        if contribution.is_paid() {
152            return Err("Contribution is already paid".to_string());
153        }
154
155        // Mark as paid (domain logic)
156        contribution.mark_as_paid(payment_date, payment_method, payment_reference);
157
158        // Update
159        let updated = self.repository.update(&contribution).await?;
160
161        // D 550 (banque) / C 400 (copropriétaires) — constat F7.
162        self.enregistrer_encaissement(&updated).await;
163
164        Ok(updated)
165    }
166
167    /// Get contribution by ID
168    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    /// Get all contributions for an organization
176    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    /// Get all contributions for an owner
184    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    /// Get outstanding (unpaid) contributions for an owner
192    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        // Filter unpaid
199        Ok(contributions.into_iter().filter(|c| !c.is_paid()).collect())
200    }
201
202    /// Get overdue contributions for an owner
203    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        // Filter overdue
210        Ok(contributions
211            .into_iter()
212            .filter(|c| c.is_overdue())
213            .collect())
214    }
215
216    /// Get total outstanding amount for an owner
217    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    /// Dépôt de lots minimal : un lot rattaché à une ACP nommée.
290    ///
291    /// Il n'existait pas ici parce que la quote-part ne cherchait pas son
292    /// créancier. Elle le cherche désormais (ADR-0045).
293    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    /// Les mêmes use-cases, en nommant l'ACP à laquelle le lot est rattaché.
365    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    /// Art. 3.86 § 3 et ADR-0045 : la quote-part est due à l'ACP.
374    ///
375    /// L'ACP est lue sur le lot, jamais sur l'appelant. Un cabinet ne peut
376    /// donc pas émettre une quote-part au profit d'une ACP qu'il désigne.
377    #[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    /// Sans lot, on ne sait pas à quelle ACP la somme est due. On refuse.
409    #[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, // pas de lot
418                "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        // Pre-populate with a pending contribution
471        let contrib = OwnerContribution::new(
472            Uuid::new_v4(), // acp_id
473            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        // Pre-populate with an already-paid contribution
514        let mut contrib = OwnerContribution::new(
515            Uuid::new_v4(), // acp_id
516            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        // Create one paid and two unpaid contributions
551        let mut paid_contrib = OwnerContribution::new(
552            Uuid::new_v4(), // acp_id
553            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(), // acp_id
567            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(), // acp_id
580            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        // Create one paid (should not count) and two unpaid
614        let mut paid = OwnerContribution::new(
615            Uuid::new_v4(), // acp_id
616            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(), // acp_id
630            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(), // acp_id
643            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}