Skip to main content

koprogo_api/domain/comptabilite/
payment.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Payment transaction status following Stripe webhook lifecycle
6/// Note: This is different from expense::PaymentStatus which tracks expense payment state
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
8#[serde(rename_all = "snake_case")]
9pub enum TransactionStatus {
10    /// Payment intent created but not yet processed
11    Pending,
12    /// Payment is being processed by payment provider
13    Processing,
14    /// Payment requires additional action (e.g., 3D Secure)
15    RequiresAction,
16    /// Payment succeeded
17    Succeeded,
18    /// Payment failed (card declined, insufficient funds, etc.)
19    Failed,
20    /// Payment cancelled by user or system
21    Cancelled,
22    /// Payment was refunded (partial or full)
23    Refunded,
24}
25
26/// Payment method type (extensible for future methods)
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
28#[serde(rename_all = "snake_case")]
29pub enum PaymentMethodType {
30    /// Credit/debit card via Stripe
31    Card,
32    /// SEPA Direct Debit (Belgian bank transfer)
33    SepaDebit,
34    /// Manual bank transfer
35    BankTransfer,
36    /// Cash payment (recorded manually)
37    Cash,
38}
39
40/// Payment entity - Represents a payment for an expense
41///
42/// Belgian property management context:
43/// - Payments are always in EUR (Belgian currency)
44/// - Linked to Expense entity (charge to co-owners)
45/// - Supports Stripe (cards) and SEPA (bank transfers)
46/// - Includes idempotency key for safe retries
47#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
48pub struct Payment {
49    pub id: Uuid,
50    /// Organization (multi-tenant isolation)
51    pub organization_id: Uuid,
52    /// Building this payment relates to
53    pub building_id: Uuid,
54    /// Owner making the payment
55    pub owner_id: Uuid,
56    /// Expense being paid (optional: could be general account credit)
57    pub expense_id: Option<Uuid>,
58    /// Quote-part de coproprietaire soldee par ce paiement (optionnel).
59    ///
60    /// Le rattachement seul ne solde rien : la contribution passe a `Paid`
61    /// quand le paiement atteint `Succeeded`, pas a sa creation — un paiement
62    /// naissant `Pending` n'est qu'une intention.
63    pub contribution_id: Option<Uuid>,
64    /// Payment amount in cents (EUR) - Stripe uses smallest currency unit
65    pub amount_cents: i64,
66    /// Currency (always EUR for Belgian context)
67    pub currency: String,
68    /// Payment transaction status
69    pub status: TransactionStatus,
70    /// Payment method type used
71    pub payment_method_type: PaymentMethodType,
72    /// Stripe payment intent ID (for card/SEPA payments)
73    pub stripe_payment_intent_id: Option<String>,
74    /// Stripe customer ID (for recurring customers)
75    pub stripe_customer_id: Option<String>,
76    /// Stored payment method ID (if saved for future use)
77    pub payment_method_id: Option<Uuid>,
78    /// Idempotency key for safe retries (prevents duplicate charges)
79    pub idempotency_key: String,
80    /// Optional description
81    pub description: Option<String>,
82    /// Optional metadata (JSON) for extensibility
83    pub metadata: Option<String>,
84    /// Failure reason (if status = Failed)
85    pub failure_reason: Option<String>,
86    /// Refund amount in cents (if status = Refunded)
87    pub refunded_amount_cents: i64,
88    /// Date when payment succeeded (if status = Succeeded)
89    pub succeeded_at: Option<DateTime<Utc>>,
90    /// Date when payment failed (if status = Failed)
91    pub failed_at: Option<DateTime<Utc>>,
92    /// Date when payment was cancelled (if status = Cancelled)
93    pub cancelled_at: Option<DateTime<Utc>>,
94    pub created_at: DateTime<Utc>,
95    pub updated_at: DateTime<Utc>,
96}
97
98impl Payment {
99    /// Create a new payment intent
100    ///
101    /// # Arguments
102    /// * `organization_id` - Organization ID (multi-tenant)
103    /// * `building_id` - Building ID
104    /// * `owner_id` - Owner making the payment
105    /// * `expense_id` - Optional expense being paid
106    /// * `contribution_id` - Optional owner contribution settled by this payment
107    /// * `amount_cents` - Amount in cents (EUR)
108    /// * `payment_method_type` - Payment method type
109    /// * `idempotency_key` - Idempotency key for safe retries
110    /// * `description` - Optional description
111    ///
112    /// # Returns
113    /// * `Ok(Payment)` - New payment with status Pending
114    /// * `Err(String)` - Validation error
115    pub fn new(
116        organization_id: Uuid,
117        building_id: Uuid,
118        owner_id: Uuid,
119        expense_id: Option<Uuid>,
120        contribution_id: Option<Uuid>,
121        amount_cents: i64,
122        payment_method_type: PaymentMethodType,
123        idempotency_key: String,
124        description: Option<String>,
125    ) -> Result<Self, String> {
126        // Validate amount
127        if amount_cents <= 0 {
128            return Err("Amount must be greater than 0".to_string());
129        }
130
131        // Validate idempotency key (min 16 chars for uniqueness)
132        if idempotency_key.trim().is_empty() || idempotency_key.len() < 16 {
133            return Err(
134                "Idempotency key must be at least 16 characters for uniqueness".to_string(),
135            );
136        }
137
138        let now = Utc::now();
139
140        Ok(Self {
141            id: Uuid::new_v4(),
142            organization_id,
143            building_id,
144            owner_id,
145            expense_id,
146            contribution_id,
147            amount_cents,
148            currency: "EUR".to_string(), // Always EUR for Belgian context
149            status: TransactionStatus::Pending,
150            payment_method_type,
151            stripe_payment_intent_id: None,
152            stripe_customer_id: None,
153            payment_method_id: None,
154            idempotency_key,
155            description,
156            metadata: None,
157            failure_reason: None,
158            refunded_amount_cents: 0,
159            succeeded_at: None,
160            failed_at: None,
161            cancelled_at: None,
162            created_at: now,
163            updated_at: now,
164        })
165    }
166
167    /// Mark payment as processing
168    pub fn mark_processing(&mut self) -> Result<(), String> {
169        match self.status {
170            TransactionStatus::Pending => {
171                self.status = TransactionStatus::Processing;
172                self.updated_at = Utc::now();
173                Ok(())
174            }
175            _ => Err(format!(
176                "Cannot mark as processing from status: {:?}",
177                self.status
178            )),
179        }
180    }
181
182    /// Mark payment as requiring action (e.g., 3D Secure authentication)
183    pub fn mark_requires_action(&mut self) -> Result<(), String> {
184        match self.status {
185            TransactionStatus::Pending | TransactionStatus::Processing => {
186                self.status = TransactionStatus::RequiresAction;
187                self.updated_at = Utc::now();
188                Ok(())
189            }
190            _ => Err(format!(
191                "Cannot mark as requires_action from status: {:?}",
192                self.status
193            )),
194        }
195    }
196
197    /// Mark payment as succeeded
198    pub fn mark_succeeded(&mut self) -> Result<(), String> {
199        match self.status {
200            TransactionStatus::Pending
201            | TransactionStatus::Processing
202            | TransactionStatus::RequiresAction => {
203                self.status = TransactionStatus::Succeeded;
204                self.succeeded_at = Some(Utc::now());
205                self.updated_at = Utc::now();
206                Ok(())
207            }
208            _ => Err(format!(
209                "Cannot mark as succeeded from status: {:?}",
210                self.status
211            )),
212        }
213    }
214
215    /// Mark payment as failed
216    pub fn mark_failed(&mut self, reason: String) -> Result<(), String> {
217        match self.status {
218            TransactionStatus::Pending
219            | TransactionStatus::Processing
220            | TransactionStatus::RequiresAction => {
221                self.status = TransactionStatus::Failed;
222                self.failure_reason = Some(reason);
223                self.failed_at = Some(Utc::now());
224                self.updated_at = Utc::now();
225                Ok(())
226            }
227            _ => Err(format!(
228                "Cannot mark as failed from status: {:?}",
229                self.status
230            )),
231        }
232    }
233
234    /// Mark payment as cancelled
235    pub fn mark_cancelled(&mut self) -> Result<(), String> {
236        match self.status {
237            TransactionStatus::Pending
238            | TransactionStatus::Processing
239            | TransactionStatus::RequiresAction => {
240                self.status = TransactionStatus::Cancelled;
241                self.cancelled_at = Some(Utc::now());
242                self.updated_at = Utc::now();
243                Ok(())
244            }
245            _ => Err(format!(
246                "Cannot mark as cancelled from status: {:?}",
247                self.status
248            )),
249        }
250    }
251
252    /// Refund payment (partial or full)
253    pub fn refund(&mut self, refund_amount_cents: i64) -> Result<(), String> {
254        // Can only refund succeeded or partially-refunded payments
255        if self.status != TransactionStatus::Succeeded && self.status != TransactionStatus::Refunded
256        {
257            return Err(format!(
258                "Can only refund succeeded payments, current status: {:?}",
259                self.status
260            ));
261        }
262
263        // Validate refund amount
264        if refund_amount_cents <= 0 {
265            return Err("Refund amount must be greater than 0".to_string());
266        }
267
268        // Check total refunds don't exceeds original amount
269        let total_refunded = self.refunded_amount_cents + refund_amount_cents;
270        if total_refunded > self.amount_cents {
271            return Err(format!(
272                "Total refund ({} cents) would exceeds original payment ({} cents)",
273                total_refunded, self.amount_cents
274            ));
275        }
276
277        self.refunded_amount_cents += refund_amount_cents;
278
279        // Any refund (partial or full) sets status to Refunded
280        self.status = TransactionStatus::Refunded;
281
282        self.updated_at = Utc::now();
283        Ok(())
284    }
285
286    /// Set Stripe payment intent ID
287    pub fn set_stripe_payment_intent_id(&mut self, payment_intent_id: String) {
288        self.stripe_payment_intent_id = Some(payment_intent_id);
289        self.updated_at = Utc::now();
290    }
291
292    /// Set Stripe customer ID
293    pub fn set_stripe_customer_id(&mut self, customer_id: String) {
294        self.stripe_customer_id = Some(customer_id);
295        self.updated_at = Utc::now();
296    }
297
298    /// Set payment method ID (for saved payment methods)
299    pub fn set_payment_method_id(&mut self, payment_method_id: Uuid) {
300        self.payment_method_id = Some(payment_method_id);
301        self.updated_at = Utc::now();
302    }
303
304    /// Set metadata
305    pub fn set_metadata(&mut self, metadata: String) {
306        self.metadata = Some(metadata);
307        self.updated_at = Utc::now();
308    }
309
310    /// Get net amount after refunds (in cents)
311    pub fn get_net_amount_cents(&self) -> i64 {
312        self.amount_cents - self.refunded_amount_cents
313    }
314
315    /// Check if payment is in final state (cannot be modified)
316    pub fn is_final(&self) -> bool {
317        matches!(
318            self.status,
319            TransactionStatus::Succeeded
320                | TransactionStatus::Failed
321                | TransactionStatus::Cancelled
322                | TransactionStatus::Refunded
323        )
324    }
325
326    /// Check if payment can be refunded
327    pub fn can_refund(&self) -> bool {
328        self.status == TransactionStatus::Succeeded
329            && self.refunded_amount_cents < self.amount_cents
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    fn create_test_payment() -> Payment {
338        Payment::new(
339            Uuid::new_v4(),
340            Uuid::new_v4(),
341            Uuid::new_v4(),
342            Some(Uuid::new_v4()),
343            None,
344            10000, // 100.00 EUR
345            PaymentMethodType::Card,
346            "test_idempotency_key_123456789".to_string(),
347            Some("Test payment".to_string()),
348        )
349        .unwrap()
350    }
351
352    #[test]
353    fn test_create_payment_success() {
354        let payment = create_test_payment();
355        assert_eq!(payment.amount_cents, 10000);
356        assert_eq!(payment.currency, "EUR");
357        assert_eq!(payment.status, TransactionStatus::Pending);
358        assert_eq!(payment.refunded_amount_cents, 0);
359    }
360
361    #[test]
362    fn test_create_payment_invalid_amount() {
363        let result = Payment::new(
364            Uuid::new_v4(),
365            Uuid::new_v4(),
366            Uuid::new_v4(),
367            None,
368            None,
369            0, // Invalid: must be > 0
370            PaymentMethodType::Card,
371            "test_idempotency_key_123456789".to_string(),
372            None,
373        );
374        assert!(result.is_err());
375        assert_eq!(result.unwrap_err(), "Amount must be greater than 0");
376    }
377
378    #[test]
379    fn test_create_payment_invalid_idempotency_key() {
380        let result = Payment::new(
381            Uuid::new_v4(),
382            Uuid::new_v4(),
383            Uuid::new_v4(),
384            None,
385            None,
386            10000,
387            PaymentMethodType::Card,
388            "short".to_string(), // Too short
389            None,
390        );
391        assert!(result.is_err());
392        assert!(result.unwrap_err().contains("Idempotency key"));
393    }
394
395    #[test]
396    fn test_payment_lifecycle_success() {
397        let mut payment = create_test_payment();
398
399        // Pending → Processing
400        assert!(payment.mark_processing().is_ok());
401        assert_eq!(payment.status, TransactionStatus::Processing);
402
403        // Processing → Succeeded
404        assert!(payment.mark_succeeded().is_ok());
405        assert_eq!(payment.status, TransactionStatus::Succeeded);
406        assert!(payment.succeeded_at.is_some());
407        assert!(payment.is_final());
408    }
409
410    #[test]
411    fn test_payment_lifecycle_failure() {
412        let mut payment = create_test_payment();
413
414        payment.mark_processing().unwrap();
415
416        // Processing → Failed
417        assert!(payment.mark_failed("Card declined".to_string()).is_ok());
418        assert_eq!(payment.status, TransactionStatus::Failed);
419        assert_eq!(payment.failure_reason, Some("Card declined".to_string()));
420        assert!(payment.failed_at.is_some());
421        assert!(payment.is_final());
422    }
423
424    #[test]
425    fn test_payment_lifecycle_cancelled() {
426        let mut payment = create_test_payment();
427
428        // Pending → Cancelled
429        assert!(payment.mark_cancelled().is_ok());
430        assert_eq!(payment.status, TransactionStatus::Cancelled);
431        assert!(payment.cancelled_at.is_some());
432        assert!(payment.is_final());
433    }
434
435    #[test]
436    fn test_payment_requires_action() {
437        let mut payment = create_test_payment();
438
439        payment.mark_processing().unwrap();
440
441        // Processing → RequiresAction (e.g., 3D Secure)
442        assert!(payment.mark_requires_action().is_ok());
443        assert_eq!(payment.status, TransactionStatus::RequiresAction);
444
445        // RequiresAction → Succeeded (after user completes 3DS)
446        assert!(payment.mark_succeeded().is_ok());
447        assert_eq!(payment.status, TransactionStatus::Succeeded);
448    }
449
450    #[test]
451    fn test_payment_invalid_status_transition() {
452        let mut payment = create_test_payment();
453        payment.mark_succeeded().unwrap();
454
455        // Cannot go from Succeeded to Processing
456        assert!(payment.mark_processing().is_err());
457    }
458
459    #[test]
460    fn test_refund_full() {
461        let mut payment = create_test_payment();
462        payment.mark_succeeded().unwrap();
463
464        assert!(payment.can_refund());
465
466        // Full refund
467        assert!(payment.refund(10000).is_ok());
468        assert_eq!(payment.refunded_amount_cents, 10000);
469        assert_eq!(payment.status, TransactionStatus::Refunded);
470        assert_eq!(payment.get_net_amount_cents(), 0);
471        assert!(!payment.can_refund());
472    }
473
474    #[test]
475    fn test_refund_partial() {
476        let mut payment = create_test_payment();
477        payment.mark_succeeded().unwrap();
478
479        // Partial refund (50%)
480        assert!(payment.refund(5000).is_ok());
481        assert_eq!(payment.refunded_amount_cents, 5000);
482        assert_eq!(payment.status, TransactionStatus::Refunded); // Any refund sets status to Refunded
483        assert_eq!(payment.get_net_amount_cents(), 5000);
484        assert!(!payment.can_refund()); // can_refund() checks Succeeded status only
485
486        // Refund remaining 50%
487        assert!(payment.refund(5000).is_ok());
488        assert_eq!(payment.refunded_amount_cents, 10000);
489        assert_eq!(payment.status, TransactionStatus::Refunded);
490    }
491
492    #[test]
493    fn test_refund_exceeds_amount() {
494        let mut payment = create_test_payment();
495        payment.mark_succeeded().unwrap();
496
497        // Try to refund more than original amount
498        let result = payment.refund(15000);
499        assert!(result.is_err());
500        assert!(result.unwrap_err().contains("exceeds"));
501    }
502
503    #[test]
504    fn test_refund_before_success() {
505        let mut payment = create_test_payment();
506
507        // Cannot refund pending payment
508        let result = payment.refund(5000);
509        assert!(result.is_err());
510        assert!(result.unwrap_err().contains("succeeded payments"));
511    }
512
513    #[test]
514    fn test_set_stripe_data() {
515        let mut payment = create_test_payment();
516
517        payment.set_stripe_payment_intent_id("pi_123456789".to_string());
518        assert_eq!(
519            payment.stripe_payment_intent_id,
520            Some("pi_123456789".to_string())
521        );
522
523        payment.set_stripe_customer_id("cus_123456789".to_string());
524        assert_eq!(
525            payment.stripe_customer_id,
526            Some("cus_123456789".to_string())
527        );
528
529        let method_id = Uuid::new_v4();
530        payment.set_payment_method_id(method_id);
531        assert_eq!(payment.payment_method_id, Some(method_id));
532    }
533}