Skip to main content

koprogo_api/application/use_cases/
payment_use_cases.rs

1use crate::application::dto::{
2    CreatePaymentRequest, PaymentResponse, PaymentStatsResponse, RefundPaymentRequest,
3};
4use crate::application::ports::{
5    OwnerContributionRepository, PaymentMethodRepository, PaymentRepository, PaymentStats,
6};
7use crate::application::services::expense_accounting_service::ExpenseAccountingService;
8use crate::domain::entities::{ContributionPaymentMethod, Payment, TransactionStatus};
9use std::sync::Arc;
10use uuid::Uuid;
11
12pub struct PaymentUseCases {
13    payment_repository: Arc<dyn PaymentRepository>,
14    payment_method_repository: Arc<dyn PaymentMethodRepository>,
15    /// Necessaire pour solder la quote-part rattachee a un paiement reussi.
16    owner_contribution_repository: Arc<dyn OwnerContributionRepository>,
17    /// Ecriture d'encaissement de la quote-part soldee. Optionnel pour
18    /// preserver les constructeurs des tests unitaires.
19    accounting_service: Option<Arc<ExpenseAccountingService>>,
20}
21
22impl PaymentUseCases {
23    pub fn new(
24        payment_repository: Arc<dyn PaymentRepository>,
25        payment_method_repository: Arc<dyn PaymentMethodRepository>,
26        owner_contribution_repository: Arc<dyn OwnerContributionRepository>,
27    ) -> Self {
28        Self {
29            payment_repository,
30            payment_method_repository,
31            owner_contribution_repository,
32            accounting_service: None,
33        }
34    }
35
36    pub fn with_accounting(mut self, accounting_service: Arc<ExpenseAccountingService>) -> Self {
37        self.accounting_service = Some(accounting_service);
38        self
39    }
40
41    /// Create a new payment
42    ///
43    /// Generates a unique idempotency key to prevent duplicate charges.
44    /// Checks for existing payment with same idempotency key (prevents retries from creating duplicates).
45    pub async fn create_payment(
46        &self,
47        organization_id: Uuid,
48        request: CreatePaymentRequest,
49    ) -> Result<PaymentResponse, String> {
50        // Generate idempotency key (organization_id + building_id + owner_id + timestamp + random)
51        let idempotency_key = format!(
52            "{}-{}-{}-{}",
53            organization_id,
54            request.building_id,
55            request.owner_id,
56            Uuid::new_v4()
57        );
58
59        // Check if payment with same idempotency key already exists
60        if let Some(existing_payment) = self
61            .payment_repository
62            .find_by_idempotency_key(organization_id, &idempotency_key)
63            .await?
64        {
65            // Return existing payment (idempotent)
66            return Ok(PaymentResponse::from(existing_payment));
67        }
68
69        // Create new payment
70        let payment = Payment::new(
71            organization_id,
72            request.building_id,
73            request.owner_id,
74            request.expense_id,
75            request.contribution_id,
76            request.amount_cents,
77            request.payment_method_type,
78            idempotency_key,
79            request.description,
80        )?;
81
82        let created = self.payment_repository.create(&payment).await?;
83        Ok(PaymentResponse::from(created))
84    }
85
86    /// Get payment by ID
87    pub async fn get_payment(&self, id: Uuid) -> Result<Option<PaymentResponse>, String> {
88        match self.payment_repository.find_by_id(id).await? {
89            Some(payment) => Ok(Some(PaymentResponse::from(payment))),
90            None => Ok(None),
91        }
92    }
93
94    /// Get payment by Stripe payment intent ID
95    pub async fn get_payment_by_stripe_intent(
96        &self,
97        stripe_payment_intent_id: &str,
98    ) -> Result<Option<PaymentResponse>, String> {
99        match self
100            .payment_repository
101            .find_by_stripe_payment_intent_id(stripe_payment_intent_id)
102            .await?
103        {
104            Some(payment) => Ok(Some(PaymentResponse::from(payment))),
105            None => Ok(None),
106        }
107    }
108
109    /// List payments for an owner
110    pub async fn list_owner_payments(
111        &self,
112        owner_id: Uuid,
113    ) -> Result<Vec<PaymentResponse>, String> {
114        let payments = self.payment_repository.find_by_owner(owner_id).await?;
115        Ok(payments.into_iter().map(PaymentResponse::from).collect())
116    }
117
118    /// List payments for a building
119    pub async fn list_building_payments(
120        &self,
121        building_id: Uuid,
122    ) -> Result<Vec<PaymentResponse>, String> {
123        let payments = self
124            .payment_repository
125            .find_by_building(building_id)
126            .await?;
127        Ok(payments.into_iter().map(PaymentResponse::from).collect())
128    }
129
130    /// List payments for an expense
131    pub async fn list_expense_payments(
132        &self,
133        expense_id: Uuid,
134    ) -> Result<Vec<PaymentResponse>, String> {
135        let payments = self.payment_repository.find_by_expense(expense_id).await?;
136        Ok(payments.into_iter().map(PaymentResponse::from).collect())
137    }
138
139    /// List payments for an organization
140    pub async fn list_organization_payments(
141        &self,
142        organization_id: Uuid,
143    ) -> Result<Vec<PaymentResponse>, String> {
144        let payments = self
145            .payment_repository
146            .find_by_organization(organization_id)
147            .await?;
148        Ok(payments.into_iter().map(PaymentResponse::from).collect())
149    }
150
151    /// List payments by status
152    pub async fn list_payments_by_status(
153        &self,
154        organization_id: Uuid,
155        status: TransactionStatus,
156    ) -> Result<Vec<PaymentResponse>, String> {
157        let payments = self
158            .payment_repository
159            .find_by_status(organization_id, status)
160            .await?;
161        Ok(payments.into_iter().map(PaymentResponse::from).collect())
162    }
163
164    /// List pending payments (for background processing)
165    pub async fn list_pending_payments(
166        &self,
167        organization_id: Uuid,
168    ) -> Result<Vec<PaymentResponse>, String> {
169        let payments = self
170            .payment_repository
171            .find_pending(organization_id)
172            .await?;
173        Ok(payments.into_iter().map(PaymentResponse::from).collect())
174    }
175
176    /// List failed payments (for retry or analysis)
177    pub async fn list_failed_payments(
178        &self,
179        organization_id: Uuid,
180    ) -> Result<Vec<PaymentResponse>, String> {
181        let payments = self.payment_repository.find_failed(organization_id).await?;
182        Ok(payments.into_iter().map(PaymentResponse::from).collect())
183    }
184
185    /// Mark payment as processing
186    pub async fn mark_processing(&self, id: Uuid) -> Result<PaymentResponse, String> {
187        let mut payment = self
188            .payment_repository
189            .find_by_id(id)
190            .await?
191            .ok_or_else(|| "Payment not found".to_string())?;
192
193        payment.mark_processing()?;
194
195        let updated = self.payment_repository.update(&payment).await?;
196        Ok(PaymentResponse::from(updated))
197    }
198
199    /// Mark payment as requiring action (e.g., 3D Secure)
200    pub async fn mark_requires_action(&self, id: Uuid) -> Result<PaymentResponse, String> {
201        let mut payment = self
202            .payment_repository
203            .find_by_id(id)
204            .await?
205            .ok_or_else(|| "Payment not found".to_string())?;
206
207        payment.mark_requires_action()?;
208
209        let updated = self.payment_repository.update(&payment).await?;
210        Ok(PaymentResponse::from(updated))
211    }
212
213    /// Mark payment as succeeded
214    ///
215    /// Si le paiement solde une quote-part (`contribution_id`), celle-ci passe
216    /// a `Paid` ici — et NULLE PART AILLEURS. C'est le seul instant ou l'argent
217    /// est reellement arrive : un paiement naissant `Pending` n'est qu'une
218    /// intention (3DS a valider, virement SEPA en vol), et marquer la
219    /// contribution des sa creation aurait affiche comme paye ce qui ne l'est
220    /// pas encore.
221    pub async fn mark_succeeded(&self, id: Uuid) -> Result<PaymentResponse, String> {
222        let mut payment = self
223            .payment_repository
224            .find_by_id(id)
225            .await?
226            .ok_or_else(|| "Payment not found".to_string())?;
227
228        payment.mark_succeeded()?;
229
230        let updated = self.payment_repository.update(&payment).await?;
231        self.settle_linked_contribution(&updated).await;
232        Ok(PaymentResponse::from(updated))
233    }
234
235    /// Solde la quote-part rattachee a un paiement reussi.
236    ///
237    /// Volontairement INFAILLIBLE : le paiement, lui, a deja abouti et a ete
238    /// persiste. Faire echouer l'appel parce que la contribution est
239    /// introuvable ou deja soldee ferait remonter une erreur sur une operation
240    /// qui a pourtant reussi, et pousserait l'appelant a rejouer un encaissement.
241    /// Un echec est donc journalise, pas propage.
242    async fn settle_linked_contribution(&self, payment: &Payment) {
243        let Some(contribution_id) = payment.contribution_id else {
244            return;
245        };
246
247        let contribution = match self
248            .owner_contribution_repository
249            .find_by_id(contribution_id)
250            .await
251        {
252            Ok(Some(c)) => c,
253            Ok(None) => {
254                tracing::warn!(
255                    payment_id = %payment.id,
256                    contribution_id = %contribution_id,
257                    "paiement reussi rattache a une contribution introuvable",
258                );
259                return;
260            }
261            Err(err) => {
262                tracing::error!(
263                    payment_id = %payment.id,
264                    contribution_id = %contribution_id,
265                    error = %err,
266                    "lecture de la contribution impossible apres un paiement reussi",
267                );
268                return;
269            }
270        };
271
272        // Deja soldee : cas normal d'un second paiement partiel ou d'un rejeu
273        // de webhook. Ne rien faire vaut mieux qu'ecraser la date de paiement
274        // d'origine.
275        if contribution.is_paid() {
276            return;
277        }
278
279        let mut contribution = contribution;
280        contribution.mark_as_paid(
281            payment.succeeded_at.unwrap_or(payment.updated_at),
282            // Le module de paiement raisonne en `PaymentMethodType` (Stripe) et
283            // la contribution en `ContributionPaymentMethod` (comptable). La
284            // correspondance est etablie une seule fois, ici.
285            ContributionPaymentMethod::from(payment.payment_method_type.clone()),
286            Some(payment.id.to_string()),
287        );
288
289        if let Err(err) = self
290            .owner_contribution_repository
291            .update(&contribution)
292            .await
293        {
294            tracing::error!(
295                payment_id = %payment.id,
296                contribution_id = %contribution_id,
297                error = %err,
298                "quote-part non soldee malgre un paiement reussi",
299            );
300            return;
301        }
302
303        // D 550 (banque) / C 400 (coproprietaires) — constat F7.
304        //
305        // La generation est idempotente : la meme quote-part peut aussi etre
306        // soldee par `mark-paid` depuis l'interface, et un meme encaissement ne
307        // doit debiter la banque qu'une fois.
308        if let Some(ref accounting) = self.accounting_service {
309            if let Err(err) = accounting
310                .generate_contribution_receipt_entry(
311                    &contribution,
312                    // Le paiement porte l'immeuble : sans lui, l'ecriture
313                    // n'apparaitrait dans aucun rapport financier par batiment
314                    // (`calculate_account_balances_for_building`).
315                    Some(payment.building_id),
316                    None,
317                    None,
318                )
319                .await
320            {
321                tracing::warn!(
322                    payment_id = %payment.id,
323                    contribution_id = %contribution_id,
324                    error = %err,
325                    "ecriture d'encaissement non generee",
326                );
327            }
328        }
329    }
330
331    /// Mark payment as failed
332    pub async fn mark_failed(&self, id: Uuid, reason: String) -> Result<PaymentResponse, String> {
333        let mut payment = self
334            .payment_repository
335            .find_by_id(id)
336            .await?
337            .ok_or_else(|| "Payment not found".to_string())?;
338
339        payment.mark_failed(reason)?;
340
341        let updated = self.payment_repository.update(&payment).await?;
342        Ok(PaymentResponse::from(updated))
343    }
344
345    /// Mark payment as cancelled
346    pub async fn mark_cancelled(&self, id: Uuid) -> Result<PaymentResponse, String> {
347        let mut payment = self
348            .payment_repository
349            .find_by_id(id)
350            .await?
351            .ok_or_else(|| "Payment not found".to_string())?;
352
353        payment.mark_cancelled()?;
354
355        let updated = self.payment_repository.update(&payment).await?;
356        Ok(PaymentResponse::from(updated))
357    }
358
359    /// Refund payment (partial or full)
360    pub async fn refund_payment(
361        &self,
362        id: Uuid,
363        request: RefundPaymentRequest,
364    ) -> Result<PaymentResponse, String> {
365        let mut payment = self
366            .payment_repository
367            .find_by_id(id)
368            .await?
369            .ok_or_else(|| "Payment not found".to_string())?;
370
371        payment.refund(request.amount_cents)?;
372
373        let updated = self.payment_repository.update(&payment).await?;
374        Ok(PaymentResponse::from(updated))
375    }
376
377    /// Set Stripe payment intent ID
378    pub async fn set_stripe_payment_intent_id(
379        &self,
380        id: Uuid,
381        stripe_payment_intent_id: String,
382    ) -> Result<PaymentResponse, String> {
383        let mut payment = self
384            .payment_repository
385            .find_by_id(id)
386            .await?
387            .ok_or_else(|| "Payment not found".to_string())?;
388
389        payment.set_stripe_payment_intent_id(stripe_payment_intent_id);
390
391        let updated = self.payment_repository.update(&payment).await?;
392        Ok(PaymentResponse::from(updated))
393    }
394
395    /// Set Stripe customer ID
396    pub async fn set_stripe_customer_id(
397        &self,
398        id: Uuid,
399        stripe_customer_id: String,
400    ) -> Result<PaymentResponse, String> {
401        let mut payment = self
402            .payment_repository
403            .find_by_id(id)
404            .await?
405            .ok_or_else(|| "Payment not found".to_string())?;
406
407        payment.set_stripe_customer_id(stripe_customer_id);
408
409        let updated = self.payment_repository.update(&payment).await?;
410        Ok(PaymentResponse::from(updated))
411    }
412
413    /// Set payment method ID
414    pub async fn set_payment_method_id(
415        &self,
416        id: Uuid,
417        payment_method_id: Uuid,
418    ) -> Result<PaymentResponse, String> {
419        // Verify payment method exists
420        let _payment_method = self
421            .payment_method_repository
422            .find_by_id(payment_method_id)
423            .await?
424            .ok_or_else(|| "Payment method not found".to_string())?;
425
426        let mut payment = self
427            .payment_repository
428            .find_by_id(id)
429            .await?
430            .ok_or_else(|| "Payment not found".to_string())?;
431
432        payment.set_payment_method_id(payment_method_id);
433
434        let updated = self.payment_repository.update(&payment).await?;
435        Ok(PaymentResponse::from(updated))
436    }
437
438    /// Delete payment
439    pub async fn delete_payment(&self, id: Uuid) -> Result<bool, String> {
440        self.payment_repository.delete(id).await
441    }
442
443    /// Get total paid for expense
444    pub async fn get_total_paid_for_expense(&self, expense_id: Uuid) -> Result<i64, String> {
445        self.payment_repository
446            .get_total_paid_for_expense(expense_id)
447            .await
448    }
449
450    /// Get total paid by owner
451    pub async fn get_total_paid_by_owner(&self, owner_id: Uuid) -> Result<i64, String> {
452        self.payment_repository
453            .get_total_paid_by_owner(owner_id)
454            .await
455    }
456
457    /// Get total paid for building
458    pub async fn get_total_paid_for_building(&self, building_id: Uuid) -> Result<i64, String> {
459        self.payment_repository
460            .get_total_paid_for_building(building_id)
461            .await
462    }
463
464    /// Get payment statistics for owner
465    pub async fn get_owner_payment_stats(
466        &self,
467        owner_id: Uuid,
468    ) -> Result<PaymentStatsResponse, String> {
469        let stats = self
470            .payment_repository
471            .get_owner_payment_stats(owner_id)
472            .await?;
473        Ok(Self::payment_stats_to_response(stats))
474    }
475
476    /// Get payment statistics for building
477    pub async fn get_building_payment_stats(
478        &self,
479        building_id: Uuid,
480    ) -> Result<PaymentStatsResponse, String> {
481        let stats = self
482            .payment_repository
483            .get_building_payment_stats(building_id)
484            .await?;
485        Ok(Self::payment_stats_to_response(stats))
486    }
487
488    /// Convert PaymentStats to PaymentStatsResponse
489    fn payment_stats_to_response(stats: PaymentStats) -> PaymentStatsResponse {
490        PaymentStatsResponse {
491            total_count: stats.total_count,
492            succeeded_count: stats.succeeded_count,
493            failed_count: stats.failed_count,
494            pending_count: stats.pending_count,
495            total_amount_cents: stats.total_amount_cents,
496            total_succeeded_cents: stats.total_succeeded_cents,
497            total_refunded_cents: stats.total_refunded_cents,
498            net_amount_cents: stats.net_amount_cents,
499        }
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::application::ports::OwnerContributionRepository;
507    use crate::application::ports::{PaymentMethodRepository, PaymentRepository, PaymentStats};
508    use crate::domain::entities::payment_method::{
509        PaymentMethod, PaymentMethodType as PMMethodType,
510    };
511    use crate::domain::entities::{
512        ContributionPaymentStatus, ContributionType, OwnerContribution, Payment, PaymentMethodType,
513        TransactionStatus,
514    };
515    use async_trait::async_trait;
516    use chrono::Utc;
517    use rust_decimal_macros::dec;
518    use std::collections::HashMap;
519    use std::sync::Mutex;
520    use uuid::Uuid;
521
522    // ─── Mock PaymentRepository ───────────────────────────────────────
523
524    struct MockPaymentRepository {
525        payments: Mutex<HashMap<Uuid, Payment>>,
526    }
527
528    impl MockPaymentRepository {
529        fn new() -> Self {
530            Self {
531                payments: Mutex::new(HashMap::new()),
532            }
533        }
534    }
535
536    #[async_trait]
537    impl PaymentRepository for MockPaymentRepository {
538        async fn create(&self, payment: &Payment) -> Result<Payment, String> {
539            self.payments
540                .lock()
541                .unwrap()
542                .insert(payment.id, payment.clone());
543            Ok(payment.clone())
544        }
545
546        async fn find_by_id(&self, id: Uuid) -> Result<Option<Payment>, String> {
547            Ok(self.payments.lock().unwrap().get(&id).cloned())
548        }
549
550        async fn find_by_stripe_payment_intent_id(
551            &self,
552            stripe_payment_intent_id: &str,
553        ) -> Result<Option<Payment>, String> {
554            Ok(self
555                .payments
556                .lock()
557                .unwrap()
558                .values()
559                .find(|p| p.stripe_payment_intent_id.as_deref() == Some(stripe_payment_intent_id))
560                .cloned())
561        }
562
563        async fn find_by_idempotency_key(
564            &self,
565            organization_id: Uuid,
566            idempotency_key: &str,
567        ) -> Result<Option<Payment>, String> {
568            Ok(self
569                .payments
570                .lock()
571                .unwrap()
572                .values()
573                .find(|p| {
574                    p.organization_id == organization_id && p.idempotency_key == idempotency_key
575                })
576                .cloned())
577        }
578
579        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<Payment>, String> {
580            Ok(self
581                .payments
582                .lock()
583                .unwrap()
584                .values()
585                .filter(|p| p.owner_id == owner_id)
586                .cloned()
587                .collect())
588        }
589
590        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Payment>, String> {
591            Ok(self
592                .payments
593                .lock()
594                .unwrap()
595                .values()
596                .filter(|p| p.building_id == building_id)
597                .cloned()
598                .collect())
599        }
600
601        async fn find_by_expense(&self, expense_id: Uuid) -> Result<Vec<Payment>, String> {
602            Ok(self
603                .payments
604                .lock()
605                .unwrap()
606                .values()
607                .filter(|p| p.expense_id == Some(expense_id))
608                .cloned()
609                .collect())
610        }
611
612        async fn find_by_organization(
613            &self,
614            organization_id: Uuid,
615        ) -> Result<Vec<Payment>, String> {
616            Ok(self
617                .payments
618                .lock()
619                .unwrap()
620                .values()
621                .filter(|p| p.organization_id == organization_id)
622                .cloned()
623                .collect())
624        }
625
626        async fn find_by_status(
627            &self,
628            organization_id: Uuid,
629            status: TransactionStatus,
630        ) -> Result<Vec<Payment>, String> {
631            Ok(self
632                .payments
633                .lock()
634                .unwrap()
635                .values()
636                .filter(|p| p.organization_id == organization_id && p.status == status)
637                .cloned()
638                .collect())
639        }
640
641        async fn find_by_building_and_status(
642            &self,
643            building_id: Uuid,
644            status: TransactionStatus,
645        ) -> Result<Vec<Payment>, String> {
646            Ok(self
647                .payments
648                .lock()
649                .unwrap()
650                .values()
651                .filter(|p| p.building_id == building_id && p.status == status)
652                .cloned()
653                .collect())
654        }
655
656        async fn find_pending(&self, organization_id: Uuid) -> Result<Vec<Payment>, String> {
657            self.find_by_status(organization_id, TransactionStatus::Pending)
658                .await
659        }
660
661        async fn find_failed(&self, organization_id: Uuid) -> Result<Vec<Payment>, String> {
662            self.find_by_status(organization_id, TransactionStatus::Failed)
663                .await
664        }
665
666        async fn update(&self, payment: &Payment) -> Result<Payment, String> {
667            self.payments
668                .lock()
669                .unwrap()
670                .insert(payment.id, payment.clone());
671            Ok(payment.clone())
672        }
673
674        async fn delete(&self, id: Uuid) -> Result<bool, String> {
675            Ok(self.payments.lock().unwrap().remove(&id).is_some())
676        }
677
678        async fn get_total_paid_for_expense(&self, expense_id: Uuid) -> Result<i64, String> {
679            Ok(self
680                .payments
681                .lock()
682                .unwrap()
683                .values()
684                .filter(|p| {
685                    p.expense_id == Some(expense_id) && p.status == TransactionStatus::Succeeded
686                })
687                .map(|p| p.amount_cents)
688                .sum())
689        }
690
691        async fn get_total_paid_by_owner(&self, owner_id: Uuid) -> Result<i64, String> {
692            Ok(self
693                .payments
694                .lock()
695                .unwrap()
696                .values()
697                .filter(|p| p.owner_id == owner_id && p.status == TransactionStatus::Succeeded)
698                .map(|p| p.amount_cents)
699                .sum())
700        }
701
702        async fn get_total_paid_for_building(&self, building_id: Uuid) -> Result<i64, String> {
703            Ok(self
704                .payments
705                .lock()
706                .unwrap()
707                .values()
708                .filter(|p| {
709                    p.building_id == building_id && p.status == TransactionStatus::Succeeded
710                })
711                .map(|p| p.amount_cents)
712                .sum())
713        }
714
715        async fn get_owner_payment_stats(&self, owner_id: Uuid) -> Result<PaymentStats, String> {
716            let payments: Vec<_> = self
717                .payments
718                .lock()
719                .unwrap()
720                .values()
721                .filter(|p| p.owner_id == owner_id)
722                .cloned()
723                .collect();
724            Ok(compute_stats(&payments))
725        }
726
727        async fn get_building_payment_stats(
728            &self,
729            building_id: Uuid,
730        ) -> Result<PaymentStats, String> {
731            let payments: Vec<_> = self
732                .payments
733                .lock()
734                .unwrap()
735                .values()
736                .filter(|p| p.building_id == building_id)
737                .cloned()
738                .collect();
739            Ok(compute_stats(&payments))
740        }
741    }
742
743    fn compute_stats(payments: &[Payment]) -> PaymentStats {
744        let total_count = payments.len() as i64;
745        let succeeded_count = payments
746            .iter()
747            .filter(|p| p.status == TransactionStatus::Succeeded)
748            .count() as i64;
749        let failed_count = payments
750            .iter()
751            .filter(|p| p.status == TransactionStatus::Failed)
752            .count() as i64;
753        let pending_count = payments
754            .iter()
755            .filter(|p| p.status == TransactionStatus::Pending)
756            .count() as i64;
757        let total_amount_cents: i64 = payments.iter().map(|p| p.amount_cents).sum();
758        let total_succeeded_cents: i64 = payments
759            .iter()
760            .filter(|p| p.status == TransactionStatus::Succeeded)
761            .map(|p| p.amount_cents)
762            .sum();
763        let total_refunded_cents: i64 = payments.iter().map(|p| p.refunded_amount_cents).sum();
764        PaymentStats {
765            total_count,
766            succeeded_count,
767            failed_count,
768            pending_count,
769            total_amount_cents,
770            total_succeeded_cents,
771            total_refunded_cents,
772            net_amount_cents: total_succeeded_cents - total_refunded_cents,
773        }
774    }
775
776    // ─── Mock PaymentMethodRepository ─────────────────────────────────
777
778    struct MockPaymentMethodRepository {
779        methods: Mutex<HashMap<Uuid, PaymentMethod>>,
780    }
781
782    impl MockPaymentMethodRepository {
783        fn new() -> Self {
784            Self {
785                methods: Mutex::new(HashMap::new()),
786            }
787        }
788
789        fn with_method(method: PaymentMethod) -> Self {
790            let mut map = HashMap::new();
791            map.insert(method.id, method);
792            Self {
793                methods: Mutex::new(map),
794            }
795        }
796    }
797
798    #[async_trait]
799    impl PaymentMethodRepository for MockPaymentMethodRepository {
800        async fn create(&self, pm: &PaymentMethod) -> Result<PaymentMethod, String> {
801            self.methods.lock().unwrap().insert(pm.id, pm.clone());
802            Ok(pm.clone())
803        }
804
805        async fn find_by_id(&self, id: Uuid) -> Result<Option<PaymentMethod>, String> {
806            Ok(self.methods.lock().unwrap().get(&id).cloned())
807        }
808
809        async fn find_by_stripe_payment_method_id(
810            &self,
811            stripe_payment_method_id: &str,
812        ) -> Result<Option<PaymentMethod>, String> {
813            Ok(self
814                .methods
815                .lock()
816                .unwrap()
817                .values()
818                .find(|m| m.stripe_payment_method_id == stripe_payment_method_id)
819                .cloned())
820        }
821
822        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<PaymentMethod>, String> {
823            Ok(self
824                .methods
825                .lock()
826                .unwrap()
827                .values()
828                .filter(|m| m.owner_id == owner_id)
829                .cloned()
830                .collect())
831        }
832
833        async fn find_active_by_owner(&self, owner_id: Uuid) -> Result<Vec<PaymentMethod>, String> {
834            Ok(self
835                .methods
836                .lock()
837                .unwrap()
838                .values()
839                .filter(|m| m.owner_id == owner_id && m.is_active)
840                .cloned()
841                .collect())
842        }
843
844        async fn find_default_by_owner(
845            &self,
846            owner_id: Uuid,
847        ) -> Result<Option<PaymentMethod>, String> {
848            Ok(self
849                .methods
850                .lock()
851                .unwrap()
852                .values()
853                .find(|m| m.owner_id == owner_id && m.is_default)
854                .cloned())
855        }
856
857        async fn find_by_organization(
858            &self,
859            organization_id: Uuid,
860        ) -> Result<Vec<PaymentMethod>, String> {
861            Ok(self
862                .methods
863                .lock()
864                .unwrap()
865                .values()
866                .filter(|m| m.organization_id == organization_id)
867                .cloned()
868                .collect())
869        }
870
871        async fn find_by_owner_and_type(
872            &self,
873            owner_id: Uuid,
874            method_type: PMMethodType,
875        ) -> Result<Vec<PaymentMethod>, String> {
876            Ok(self
877                .methods
878                .lock()
879                .unwrap()
880                .values()
881                .filter(|m| m.owner_id == owner_id && m.method_type == method_type)
882                .cloned()
883                .collect())
884        }
885
886        async fn update(&self, pm: &PaymentMethod) -> Result<PaymentMethod, String> {
887            self.methods.lock().unwrap().insert(pm.id, pm.clone());
888            Ok(pm.clone())
889        }
890
891        async fn delete(&self, id: Uuid) -> Result<bool, String> {
892            Ok(self.methods.lock().unwrap().remove(&id).is_some())
893        }
894
895        async fn set_as_default(&self, id: Uuid, _owner_id: Uuid) -> Result<PaymentMethod, String> {
896            let mut methods = self.methods.lock().unwrap();
897            let pm = methods
898                .get_mut(&id)
899                .ok_or_else(|| "Not found".to_string())?;
900            pm.is_default = true;
901            Ok(pm.clone())
902        }
903
904        async fn count_active_by_owner(&self, owner_id: Uuid) -> Result<i64, String> {
905            Ok(self
906                .methods
907                .lock()
908                .unwrap()
909                .values()
910                .filter(|m| m.owner_id == owner_id && m.is_active)
911                .count() as i64)
912        }
913
914        async fn has_active_payment_methods(&self, owner_id: Uuid) -> Result<bool, String> {
915            Ok(self.count_active_by_owner(owner_id).await? > 0)
916        }
917    }
918
919    // ─── Helpers ──────────────────────────────────────────────────────
920
921    fn make_use_cases(
922        payment_repo: Arc<dyn PaymentRepository>,
923        pm_repo: Arc<dyn PaymentMethodRepository>,
924    ) -> PaymentUseCases {
925        PaymentUseCases::new(
926            payment_repo,
927            pm_repo,
928            Arc::new(MockOwnerContributionRepository::new()),
929        )
930    }
931
932    fn make_use_cases_with_contributions(
933        payment_repo: Arc<dyn PaymentRepository>,
934        pm_repo: Arc<dyn PaymentMethodRepository>,
935        contribution_repo: Arc<MockOwnerContributionRepository>,
936    ) -> PaymentUseCases {
937        PaymentUseCases::new(payment_repo, pm_repo, contribution_repo)
938    }
939
940    struct MockOwnerContributionRepository {
941        contributions: Mutex<HashMap<Uuid, OwnerContribution>>,
942    }
943
944    impl MockOwnerContributionRepository {
945        fn new() -> Self {
946            Self {
947                contributions: Mutex::new(HashMap::new()),
948            }
949        }
950
951        fn with_contribution(contribution: OwnerContribution) -> Self {
952            let mut map = HashMap::new();
953            map.insert(contribution.id, contribution);
954            Self {
955                contributions: Mutex::new(map),
956            }
957        }
958
959        fn get(&self, id: Uuid) -> Option<OwnerContribution> {
960            self.contributions.lock().unwrap().get(&id).cloned()
961        }
962    }
963
964    #[async_trait]
965    impl OwnerContributionRepository for MockOwnerContributionRepository {
966        async fn create(&self, c: &OwnerContribution) -> Result<OwnerContribution, String> {
967            self.contributions.lock().unwrap().insert(c.id, c.clone());
968            Ok(c.clone())
969        }
970        async fn find_by_id(&self, id: Uuid) -> Result<Option<OwnerContribution>, String> {
971            Ok(self.contributions.lock().unwrap().get(&id).cloned())
972        }
973        async fn find_by_organization(
974            &self,
975            organization_id: Uuid,
976        ) -> Result<Vec<OwnerContribution>, String> {
977            Ok(self
978                .contributions
979                .lock()
980                .unwrap()
981                .values()
982                .filter(|c| c.organization_id == organization_id)
983                .cloned()
984                .collect())
985        }
986        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<OwnerContribution>, String> {
987            Ok(self
988                .contributions
989                .lock()
990                .unwrap()
991                .values()
992                .filter(|c| c.owner_id == owner_id)
993                .cloned()
994                .collect())
995        }
996        async fn update(&self, c: &OwnerContribution) -> Result<OwnerContribution, String> {
997            self.contributions.lock().unwrap().insert(c.id, c.clone());
998            Ok(c.clone())
999        }
1000    }
1001
1002    fn seed_contribution(organization_id: Uuid, owner_id: Uuid) -> OwnerContribution {
1003        OwnerContribution::new(
1004            Uuid::new_v4(), // acp_id
1005            organization_id,
1006            owner_id,
1007            None,
1008            "Charges Q3 2026".to_string(),
1009            dec!(2000.00),
1010            ContributionType::Regular,
1011            Utc::now(),
1012            Some("7000".to_string()),
1013        )
1014        .expect("contribution de test valide")
1015    }
1016
1017    fn make_create_request(
1018        building_id: Uuid,
1019        owner_id: Uuid,
1020        amount_cents: i64,
1021    ) -> CreatePaymentRequest {
1022        CreatePaymentRequest {
1023            building_id,
1024            owner_id,
1025            expense_id: None,
1026            contribution_id: None,
1027            amount_cents,
1028            payment_method_type: PaymentMethodType::Card,
1029            payment_method_id: None,
1030            description: Some("Test payment".to_string()),
1031            metadata: None,
1032        }
1033    }
1034
1035    /// Insert a payment directly into the mock repo and return it.
1036    async fn seed_payment(
1037        repo: &Arc<MockPaymentRepository>,
1038        org_id: Uuid,
1039        building_id: Uuid,
1040        owner_id: Uuid,
1041        amount_cents: i64,
1042    ) -> Payment {
1043        let payment = Payment::new(
1044            org_id,
1045            building_id,
1046            owner_id,
1047            None,
1048            None,
1049            amount_cents,
1050            PaymentMethodType::Card,
1051            format!("idem-{}-{}", org_id, Uuid::new_v4()),
1052            Some("seeded".to_string()),
1053        )
1054        .unwrap();
1055        repo.create(&payment).await.unwrap();
1056        payment
1057    }
1058
1059    // ─── Tests ────────────────────────────────────────────────────────
1060
1061    #[tokio::test]
1062    async fn test_create_payment_success() {
1063        let payment_repo = Arc::new(MockPaymentRepository::new());
1064        let pm_repo = Arc::new(MockPaymentMethodRepository::new());
1065        let uc = make_use_cases(payment_repo.clone(), pm_repo);
1066
1067        let org_id = Uuid::new_v4();
1068        let building_id = Uuid::new_v4();
1069        let owner_id = Uuid::new_v4();
1070
1071        let request = make_create_request(building_id, owner_id, 15000);
1072        let result = uc.create_payment(org_id, request).await;
1073
1074        assert!(result.is_ok());
1075        let resp = result.unwrap();
1076        assert_eq!(resp.amount_cents, 15000);
1077        assert_eq!(resp.currency, "EUR");
1078        assert_eq!(resp.status, TransactionStatus::Pending);
1079        assert_eq!(resp.organization_id, org_id);
1080        assert_eq!(resp.building_id, building_id);
1081        assert_eq!(resp.owner_id, owner_id);
1082        assert_eq!(resp.refunded_amount_cents, 0);
1083        // Verify it was persisted
1084        assert_eq!(payment_repo.payments.lock().unwrap().len(), 1);
1085    }
1086
1087    #[tokio::test]
1088    async fn test_create_payment_invalid_amount_zero() {
1089        let payment_repo = Arc::new(MockPaymentRepository::new());
1090        let pm_repo = Arc::new(MockPaymentMethodRepository::new());
1091        let uc = make_use_cases(payment_repo, pm_repo);
1092
1093        let request = make_create_request(Uuid::new_v4(), Uuid::new_v4(), 0);
1094        let result = uc.create_payment(Uuid::new_v4(), request).await;
1095
1096        assert!(result.is_err());
1097        assert!(result
1098            .unwrap_err()
1099            .contains("Amount must be greater than 0"));
1100    }
1101
1102    #[tokio::test]
1103    async fn test_create_payment_invalid_amount_negative() {
1104        let payment_repo = Arc::new(MockPaymentRepository::new());
1105        let pm_repo = Arc::new(MockPaymentMethodRepository::new());
1106        let uc = make_use_cases(payment_repo, pm_repo);
1107
1108        let request = make_create_request(Uuid::new_v4(), Uuid::new_v4(), -500);
1109        let result = uc.create_payment(Uuid::new_v4(), request).await;
1110
1111        assert!(result.is_err());
1112        assert!(result
1113            .unwrap_err()
1114            .contains("Amount must be greater than 0"));
1115    }
1116
1117    // ------------------------------------------------------------------
1118    // Non-regression F4 (rapport du 2026-09-01) — le paiement d'une quote-part
1119    // laissait la contribution en `pending` indefiniment. Le champ
1120    // `contribution_id` n'existait pas et etait jete en silence par serde.
1121    // ------------------------------------------------------------------
1122
1123    /// Un paiement REUSSI solde la quote-part qu'il designe.
1124    #[tokio::test]
1125    async fn test_paiement_reussi_solde_la_quote_part_liee() {
1126        let org_id = Uuid::new_v4();
1127        let owner_id = Uuid::new_v4();
1128        let contribution = seed_contribution(org_id, owner_id);
1129        let contribution_id = contribution.id;
1130        assert_eq!(
1131            contribution.payment_status,
1132            ContributionPaymentStatus::Pending,
1133            "prealable : la contribution part impayee"
1134        );
1135
1136        let payment_repo = Arc::new(MockPaymentRepository::new());
1137        let contrib_repo = Arc::new(MockOwnerContributionRepository::with_contribution(
1138            contribution,
1139        ));
1140        let uc = make_use_cases_with_contributions(
1141            payment_repo.clone(),
1142            Arc::new(MockPaymentMethodRepository::new()),
1143            contrib_repo.clone(),
1144        );
1145
1146        let mut request = make_create_request(Uuid::new_v4(), owner_id, 200_000);
1147        request.contribution_id = Some(contribution_id);
1148        let created = uc.create_payment(org_id, request).await.unwrap();
1149
1150        // Le rattachement est bien persiste...
1151        assert_eq!(created.contribution_id, Some(contribution_id));
1152        // ...mais un paiement naissant `Pending` ne solde RIEN : c'est une
1153        // intention, pas un encaissement. C'est la moitie du defaut F4 que le
1154        // rapport n'avait pas vue.
1155        assert_eq!(
1156            contrib_repo.get(contribution_id).unwrap().payment_status,
1157            ContributionPaymentStatus::Pending,
1158            "une intention de paiement ne doit pas marquer la quote-part payee"
1159        );
1160
1161        uc.mark_processing(created.id).await.unwrap();
1162        uc.mark_succeeded(created.id).await.unwrap();
1163
1164        let apres = contrib_repo.get(contribution_id).unwrap();
1165        assert_eq!(
1166            apres.payment_status,
1167            ContributionPaymentStatus::Paid,
1168            "la quote-part doit etre soldee des que le paiement aboutit"
1169        );
1170        assert!(
1171            apres.payment_date.is_some(),
1172            "la date de paiement est posee"
1173        );
1174        assert_eq!(
1175            apres.payment_reference,
1176            Some(created.id.to_string()),
1177            "la reference doit permettre de remonter au paiement"
1178        );
1179        // `Card` n'a pas d'equivalent comptable : il se traduit en virement.
1180        assert_eq!(
1181            apres.payment_method,
1182            Some(ContributionPaymentMethod::BankTransfer)
1183        );
1184    }
1185
1186    /// Un paiement sans quote-part liee ne touche a rien.
1187    #[tokio::test]
1188    async fn test_paiement_sans_quote_part_ne_solde_rien() {
1189        let org_id = Uuid::new_v4();
1190        let owner_id = Uuid::new_v4();
1191        let contribution = seed_contribution(org_id, owner_id);
1192        let contribution_id = contribution.id;
1193
1194        let payment_repo = Arc::new(MockPaymentRepository::new());
1195        let contrib_repo = Arc::new(MockOwnerContributionRepository::with_contribution(
1196            contribution,
1197        ));
1198        let uc = make_use_cases_with_contributions(
1199            payment_repo.clone(),
1200            Arc::new(MockPaymentMethodRepository::new()),
1201            contrib_repo.clone(),
1202        );
1203
1204        let request = make_create_request(Uuid::new_v4(), owner_id, 200_000);
1205        let created = uc.create_payment(org_id, request).await.unwrap();
1206        uc.mark_processing(created.id).await.unwrap();
1207        uc.mark_succeeded(created.id).await.unwrap();
1208
1209        assert_eq!(
1210            contrib_repo.get(contribution_id).unwrap().payment_status,
1211            ContributionPaymentStatus::Pending,
1212            "un paiement non rattache ne doit solder aucune quote-part"
1213        );
1214    }
1215
1216    /// @edge — une quote-part deja soldee n'est pas ecrasee.
1217    ///
1218    /// Cas reel : rejeu d'un webhook Stripe, ou second paiement partiel. Ecraser
1219    /// la date de paiement d'origine fausserait le suivi des retards.
1220    #[tokio::test]
1221    async fn test_quote_part_deja_soldee_nest_pas_ecrasee() {
1222        let org_id = Uuid::new_v4();
1223        let owner_id = Uuid::new_v4();
1224        let mut contribution = seed_contribution(org_id, owner_id);
1225        let contribution_id = contribution.id;
1226        let date_origine = Utc::now() - chrono::Duration::days(10);
1227        contribution.mark_as_paid(
1228            date_origine,
1229            ContributionPaymentMethod::Cash,
1230            Some("recu-papier-42".to_string()),
1231        );
1232
1233        let payment_repo = Arc::new(MockPaymentRepository::new());
1234        let contrib_repo = Arc::new(MockOwnerContributionRepository::with_contribution(
1235            contribution,
1236        ));
1237        let uc = make_use_cases_with_contributions(
1238            payment_repo.clone(),
1239            Arc::new(MockPaymentMethodRepository::new()),
1240            contrib_repo.clone(),
1241        );
1242
1243        let mut request = make_create_request(Uuid::new_v4(), owner_id, 200_000);
1244        request.contribution_id = Some(contribution_id);
1245        let created = uc.create_payment(org_id, request).await.unwrap();
1246        uc.mark_processing(created.id).await.unwrap();
1247        uc.mark_succeeded(created.id).await.unwrap();
1248
1249        let apres = contrib_repo.get(contribution_id).unwrap();
1250        assert_eq!(
1251            apres.payment_reference,
1252            Some("recu-papier-42".to_string()),
1253            "le paiement d'origine doit rester la reference"
1254        );
1255        assert_eq!(apres.payment_method, Some(ContributionPaymentMethod::Cash));
1256    }
1257
1258    /// @edge — une contribution introuvable ne fait PAS echouer le paiement.
1259    ///
1260    /// Le paiement a abouti et est deja persiste : remonter une erreur ici
1261    /// pousserait l'appelant a rejouer un encaissement.
1262    #[tokio::test]
1263    async fn test_quote_part_introuvable_ne_fait_pas_echouer_le_paiement() {
1264        let org_id = Uuid::new_v4();
1265        let payment_repo = Arc::new(MockPaymentRepository::new());
1266        let uc = make_use_cases_with_contributions(
1267            payment_repo.clone(),
1268            Arc::new(MockPaymentMethodRepository::new()),
1269            Arc::new(MockOwnerContributionRepository::new()),
1270        );
1271
1272        let mut request = make_create_request(Uuid::new_v4(), Uuid::new_v4(), 200_000);
1273        request.contribution_id = Some(Uuid::new_v4()); // n'existe pas
1274        let created = uc.create_payment(org_id, request).await.unwrap();
1275        uc.mark_processing(created.id).await.unwrap();
1276
1277        let resultat = uc.mark_succeeded(created.id).await;
1278        assert!(
1279            resultat.is_ok(),
1280            "le paiement reste reussi meme si la quote-part est introuvable"
1281        );
1282        assert_eq!(resultat.unwrap().status, TransactionStatus::Succeeded);
1283    }
1284
1285    #[tokio::test]
1286    async fn test_status_transition_pending_to_processing_to_succeeded() {
1287        let payment_repo = Arc::new(MockPaymentRepository::new());
1288        let pm_repo = Arc::new(MockPaymentMethodRepository::new());
1289        let uc = make_use_cases(payment_repo.clone(), pm_repo);
1290
1291        let org_id = Uuid::new_v4();
1292        let payment =
1293            seed_payment(&payment_repo, org_id, Uuid::new_v4(), Uuid::new_v4(), 10000).await;
1294        let pid = payment.id;
1295
1296        // Pending -> Processing
1297        let resp = uc.mark_processing(pid).await.unwrap();
1298        assert_eq!(resp.status, TransactionStatus::Processing);
1299
1300        // Processing -> Succeeded
1301        let resp = uc.mark_succeeded(pid).await.unwrap();
1302        assert_eq!(resp.status, TransactionStatus::Succeeded);
1303        assert!(resp.succeeded_at.is_some());
1304    }
1305
1306    #[tokio::test]
1307    async fn test_status_transition_mark_failed() {
1308        let payment_repo = Arc::new(MockPaymentRepository::new());
1309        let pm_repo = Arc::new(MockPaymentMethodRepository::new());
1310        let uc = make_use_cases(payment_repo.clone(), pm_repo);
1311
1312        let org_id = Uuid::new_v4();
1313        let payment =
1314            seed_payment(&payment_repo, org_id, Uuid::new_v4(), Uuid::new_v4(), 5000).await;
1315        let pid = payment.id;
1316
1317        // Pending -> Processing
1318        uc.mark_processing(pid).await.unwrap();
1319
1320        // Processing -> Failed
1321        let resp = uc
1322            .mark_failed(pid, "Card declined".to_string())
1323            .await
1324            .unwrap();
1325        assert_eq!(resp.status, TransactionStatus::Failed);
1326        assert_eq!(resp.failure_reason, Some("Card declined".to_string()));
1327        assert!(resp.failed_at.is_some());
1328    }
1329
1330    #[tokio::test]
1331    async fn test_mark_processing_not_found() {
1332        let payment_repo = Arc::new(MockPaymentRepository::new());
1333        let pm_repo = Arc::new(MockPaymentMethodRepository::new());
1334        let uc = make_use_cases(payment_repo, pm_repo);
1335
1336        let result = uc.mark_processing(Uuid::new_v4()).await;
1337
1338        assert!(result.is_err());
1339        assert!(result.unwrap_err().contains("Payment not found"));
1340    }
1341
1342    #[tokio::test]
1343    async fn test_refund_partial_success() {
1344        let payment_repo = Arc::new(MockPaymentRepository::new());
1345        let pm_repo = Arc::new(MockPaymentMethodRepository::new());
1346        let uc = make_use_cases(payment_repo.clone(), pm_repo);
1347
1348        let org_id = Uuid::new_v4();
1349        let payment =
1350            seed_payment(&payment_repo, org_id, Uuid::new_v4(), Uuid::new_v4(), 20000).await;
1351        let pid = payment.id;
1352
1353        // Move to Succeeded first
1354        uc.mark_succeeded(pid).await.unwrap();
1355
1356        // Partial refund: 8000 out of 20000
1357        let resp = uc
1358            .refund_payment(
1359                pid,
1360                RefundPaymentRequest {
1361                    amount_cents: 8000,
1362                    reason: None,
1363                },
1364            )
1365            .await
1366            .unwrap();
1367        assert_eq!(resp.status, TransactionStatus::Refunded);
1368        assert_eq!(resp.refunded_amount_cents, 8000);
1369        assert_eq!(resp.net_amount_cents, 12000); // 20000 - 8000
1370    }
1371
1372    #[tokio::test]
1373    async fn test_refund_over_refund_prevention() {
1374        let payment_repo = Arc::new(MockPaymentRepository::new());
1375        let pm_repo = Arc::new(MockPaymentMethodRepository::new());
1376        let uc = make_use_cases(payment_repo.clone(), pm_repo);
1377
1378        let org_id = Uuid::new_v4();
1379        let payment =
1380            seed_payment(&payment_repo, org_id, Uuid::new_v4(), Uuid::new_v4(), 10000).await;
1381        let pid = payment.id;
1382
1383        // Move to Succeeded
1384        uc.mark_succeeded(pid).await.unwrap();
1385
1386        // Partial refund: 6000
1387        uc.refund_payment(
1388            pid,
1389            RefundPaymentRequest {
1390                amount_cents: 6000,
1391                reason: None,
1392            },
1393        )
1394        .await
1395        .unwrap();
1396
1397        // Try to refund 6000 more (total would be 12000 > 10000)
1398        let result = uc
1399            .refund_payment(
1400                pid,
1401                RefundPaymentRequest {
1402                    amount_cents: 6000,
1403                    reason: None,
1404                },
1405            )
1406            .await;
1407        assert!(result.is_err());
1408        assert!(result.unwrap_err().contains("exceeds"));
1409    }
1410
1411    #[tokio::test]
1412    async fn test_refund_pending_payment_fails() {
1413        let payment_repo = Arc::new(MockPaymentRepository::new());
1414        let pm_repo = Arc::new(MockPaymentMethodRepository::new());
1415        let uc = make_use_cases(payment_repo.clone(), pm_repo);
1416
1417        let org_id = Uuid::new_v4();
1418        let payment =
1419            seed_payment(&payment_repo, org_id, Uuid::new_v4(), Uuid::new_v4(), 10000).await;
1420        let pid = payment.id;
1421
1422        // Payment is still Pending, refund should fail
1423        let result = uc
1424            .refund_payment(
1425                pid,
1426                RefundPaymentRequest {
1427                    amount_cents: 5000,
1428                    reason: None,
1429                },
1430            )
1431            .await;
1432        assert!(result.is_err());
1433        assert!(result.unwrap_err().contains("succeeded payments"));
1434    }
1435
1436    #[tokio::test]
1437    async fn test_get_payment_found_and_not_found() {
1438        let payment_repo = Arc::new(MockPaymentRepository::new());
1439        let pm_repo = Arc::new(MockPaymentMethodRepository::new());
1440        let uc = make_use_cases(payment_repo.clone(), pm_repo);
1441
1442        let org_id = Uuid::new_v4();
1443        let payment =
1444            seed_payment(&payment_repo, org_id, Uuid::new_v4(), Uuid::new_v4(), 5000).await;
1445
1446        // Found
1447        let result = uc.get_payment(payment.id).await.unwrap();
1448        assert!(result.is_some());
1449        assert_eq!(result.unwrap().amount_cents, 5000);
1450
1451        // Not found
1452        let result = uc.get_payment(Uuid::new_v4()).await.unwrap();
1453        assert!(result.is_none());
1454    }
1455
1456    #[tokio::test]
1457    async fn test_list_owner_and_building_payments() {
1458        let payment_repo = Arc::new(MockPaymentRepository::new());
1459        let pm_repo = Arc::new(MockPaymentMethodRepository::new());
1460        let uc = make_use_cases(payment_repo.clone(), pm_repo);
1461
1462        let org_id = Uuid::new_v4();
1463        let building_id = Uuid::new_v4();
1464        let owner_id = Uuid::new_v4();
1465
1466        // Seed 2 payments for the same owner and building
1467        seed_payment(&payment_repo, org_id, building_id, owner_id, 5000).await;
1468        seed_payment(&payment_repo, org_id, building_id, owner_id, 7000).await;
1469
1470        // Seed 1 payment for a different owner
1471        seed_payment(&payment_repo, org_id, building_id, Uuid::new_v4(), 3000).await;
1472
1473        let owner_payments = uc.list_owner_payments(owner_id).await.unwrap();
1474        assert_eq!(owner_payments.len(), 2);
1475
1476        let building_payments = uc.list_building_payments(building_id).await.unwrap();
1477        assert_eq!(building_payments.len(), 3);
1478    }
1479
1480    #[tokio::test]
1481    async fn test_set_payment_method_id_validates_existence() {
1482        let payment_repo = Arc::new(MockPaymentRepository::new());
1483        // Empty payment method repo -- no methods exist
1484        let pm_repo = Arc::new(MockPaymentMethodRepository::new());
1485        let uc = make_use_cases(payment_repo.clone(), pm_repo);
1486
1487        let org_id = Uuid::new_v4();
1488        let payment =
1489            seed_payment(&payment_repo, org_id, Uuid::new_v4(), Uuid::new_v4(), 5000).await;
1490
1491        // Setting a non-existent payment method should fail
1492        let result = uc.set_payment_method_id(payment.id, Uuid::new_v4()).await;
1493        assert!(result.is_err());
1494        assert!(result.unwrap_err().contains("Payment method not found"));
1495    }
1496
1497    #[tokio::test]
1498    async fn test_set_payment_method_id_success() {
1499        let payment_repo = Arc::new(MockPaymentRepository::new());
1500        let pm = PaymentMethod::new(
1501            Uuid::new_v4(),
1502            Uuid::new_v4(),
1503            PMMethodType::Card,
1504            "pm_test_stripe_id_12345".to_string(),
1505            "cus_test_customer_12345".to_string(),
1506            "Visa **** 4242".to_string(),
1507            true,
1508        )
1509        .unwrap();
1510        let pm_id = pm.id;
1511        let pm_repo = Arc::new(MockPaymentMethodRepository::with_method(pm));
1512        let uc = make_use_cases(payment_repo.clone(), pm_repo);
1513
1514        let org_id = Uuid::new_v4();
1515        let payment =
1516            seed_payment(&payment_repo, org_id, Uuid::new_v4(), Uuid::new_v4(), 5000).await;
1517
1518        let resp = uc.set_payment_method_id(payment.id, pm_id).await.unwrap();
1519        assert_eq!(resp.payment_method_id, Some(pm_id));
1520    }
1521
1522    #[tokio::test]
1523    async fn test_delete_payment() {
1524        let payment_repo = Arc::new(MockPaymentRepository::new());
1525        let pm_repo = Arc::new(MockPaymentMethodRepository::new());
1526        let uc = make_use_cases(payment_repo.clone(), pm_repo);
1527
1528        let org_id = Uuid::new_v4();
1529        let payment =
1530            seed_payment(&payment_repo, org_id, Uuid::new_v4(), Uuid::new_v4(), 5000).await;
1531
1532        assert!(uc.delete_payment(payment.id).await.unwrap());
1533        // After deletion, get should return None
1534        let result = uc.get_payment(payment.id).await.unwrap();
1535        assert!(result.is_none());
1536
1537        // Deleting non-existent returns false
1538        assert!(!uc.delete_payment(Uuid::new_v4()).await.unwrap());
1539    }
1540
1541    #[tokio::test]
1542    async fn test_get_owner_payment_stats() {
1543        let payment_repo = Arc::new(MockPaymentRepository::new());
1544        let pm_repo = Arc::new(MockPaymentMethodRepository::new());
1545        let uc = make_use_cases(payment_repo.clone(), pm_repo);
1546
1547        let org_id = Uuid::new_v4();
1548        let building_id = Uuid::new_v4();
1549        let owner_id = Uuid::new_v4();
1550
1551        // Seed 2 payments, mark one succeeded
1552        let p1 = seed_payment(&payment_repo, org_id, building_id, owner_id, 10000).await;
1553        seed_payment(&payment_repo, org_id, building_id, owner_id, 5000).await;
1554
1555        // Mark p1 as succeeded via the use case
1556        uc.mark_succeeded(p1.id).await.unwrap();
1557
1558        let stats = uc.get_owner_payment_stats(owner_id).await.unwrap();
1559        assert_eq!(stats.total_count, 2);
1560        assert_eq!(stats.succeeded_count, 1);
1561        assert_eq!(stats.pending_count, 1);
1562        assert_eq!(stats.total_amount_cents, 15000);
1563        assert_eq!(stats.total_succeeded_cents, 10000);
1564    }
1565}