koprogo_api/domain/entities/
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)]
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)]
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)]
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    /// Payment amount in cents (EUR) - Stripe uses smallest currency unit
59    pub amount_cents: i64,
60    /// Currency (always EUR for Belgian context)
61    pub currency: String,
62    /// Payment transaction status
63    pub status: TransactionStatus,
64    /// Payment method type used
65    pub payment_method_type: PaymentMethodType,
66    /// Stripe payment intent ID (for card/SEPA payments)
67    pub stripe_payment_intent_id: Option<String>,
68    /// Stripe customer ID (for recurring customers)
69    pub stripe_customer_id: Option<String>,
70    /// Stored payment method ID (if saved for future use)
71    pub payment_method_id: Option<Uuid>,
72    /// Idempotency key for safe retries (prevents duplicate charges)
73    pub idempotency_key: String,
74    /// Optional description
75    pub description: Option<String>,
76    /// Optional metadata (JSON) for extensibility
77    pub metadata: Option<String>,
78    /// Failure reason (if status = Failed)
79    pub failure_reason: Option<String>,
80    /// Refund amount in cents (if status = Refunded)
81    pub refunded_amount_cents: i64,
82    /// Date when payment succeeded (if status = Succeeded)
83    pub succeeded_at: Option<DateTime<Utc>>,
84    /// Date when payment failed (if status = Failed)
85    pub failed_at: Option<DateTime<Utc>>,
86    /// Date when payment was cancelled (if status = Cancelled)
87    pub cancelled_at: Option<DateTime<Utc>>,
88    pub created_at: DateTime<Utc>,
89    pub updated_at: DateTime<Utc>,
90}
91
92impl Payment {
93    /// Create a new payment intent
94    ///
95    /// # Arguments
96    /// * `organization_id` - Organization ID (multi-tenant)
97    /// * `building_id` - Building ID
98    /// * `owner_id` - Owner making the payment
99    /// * `expense_id` - Optional expense being paid
100    /// * `amount_cents` - Amount in cents (EUR)
101    /// * `payment_method_type` - Payment method type
102    /// * `idempotency_key` - Idempotency key for safe retries
103    /// * `description` - Optional description
104    ///
105    /// # Returns
106    /// * `Ok(Payment)` - New payment with status Pending
107    /// * `Err(String)` - Validation error
108    pub fn new(
109        organization_id: Uuid,
110        building_id: Uuid,
111        owner_id: Uuid,
112        expense_id: Option<Uuid>,
113        amount_cents: i64,
114        payment_method_type: PaymentMethodType,
115        idempotency_key: String,
116        description: Option<String>,
117    ) -> Result<Self, String> {
118        // Validate amount
119        if amount_cents <= 0 {
120            return Err("Amount must be greater than 0".to_string());
121        }
122
123        // Validate idempotency key (min 16 chars for uniqueness)
124        if idempotency_key.trim().is_empty() || idempotency_key.len() < 16 {
125            return Err(
126                "Idempotency key must be at least 16 characters for uniqueness".to_string(),
127            );
128        }
129
130        let now = Utc::now();
131
132        Ok(Self {
133            id: Uuid::new_v4(),
134            organization_id,
135            building_id,
136            owner_id,
137            expense_id,
138            amount_cents,
139            currency: "EUR".to_string(), // Always EUR for Belgian context
140            status: TransactionStatus::Pending,
141            payment_method_type,
142            stripe_payment_intent_id: None,
143            stripe_customer_id: None,
144            payment_method_id: None,
145            idempotency_key,
146            description,
147            metadata: None,
148            failure_reason: None,
149            refunded_amount_cents: 0,
150            succeeded_at: None,
151            failed_at: None,
152            cancelled_at: None,
153            created_at: now,
154            updated_at: now,
155        })
156    }
157
158    /// Mark payment as processing
159    pub fn mark_processing(&mut self) -> Result<(), String> {
160        match self.status {
161            TransactionStatus::Pending => {
162                self.status = TransactionStatus::Processing;
163                self.updated_at = Utc::now();
164                Ok(())
165            }
166            _ => Err(format!(
167                "Cannot mark as processing from status: {:?}",
168                self.status
169            )),
170        }
171    }
172
173    /// Mark payment as requiring action (e.g., 3D Secure authentication)
174    pub fn mark_requires_action(&mut self) -> Result<(), String> {
175        match self.status {
176            TransactionStatus::Pending | TransactionStatus::Processing => {
177                self.status = TransactionStatus::RequiresAction;
178                self.updated_at = Utc::now();
179                Ok(())
180            }
181            _ => Err(format!(
182                "Cannot mark as requires_action from status: {:?}",
183                self.status
184            )),
185        }
186    }
187
188    /// Mark payment as succeeded
189    pub fn mark_succeeded(&mut self) -> Result<(), String> {
190        match self.status {
191            TransactionStatus::Pending
192            | TransactionStatus::Processing
193            | TransactionStatus::RequiresAction => {
194                self.status = TransactionStatus::Succeeded;
195                self.succeeded_at = Some(Utc::now());
196                self.updated_at = Utc::now();
197                Ok(())
198            }
199            _ => Err(format!(
200                "Cannot mark as succeeded from status: {:?}",
201                self.status
202            )),
203        }
204    }
205
206    /// Mark payment as failed
207    pub fn mark_failed(&mut self, reason: String) -> Result<(), String> {
208        match self.status {
209            TransactionStatus::Pending
210            | TransactionStatus::Processing
211            | TransactionStatus::RequiresAction => {
212                self.status = TransactionStatus::Failed;
213                self.failure_reason = Some(reason);
214                self.failed_at = Some(Utc::now());
215                self.updated_at = Utc::now();
216                Ok(())
217            }
218            _ => Err(format!(
219                "Cannot mark as failed from status: {:?}",
220                self.status
221            )),
222        }
223    }
224
225    /// Mark payment as cancelled
226    pub fn mark_cancelled(&mut self) -> Result<(), String> {
227        match self.status {
228            TransactionStatus::Pending
229            | TransactionStatus::Processing
230            | TransactionStatus::RequiresAction => {
231                self.status = TransactionStatus::Cancelled;
232                self.cancelled_at = Some(Utc::now());
233                self.updated_at = Utc::now();
234                Ok(())
235            }
236            _ => Err(format!(
237                "Cannot mark as cancelled from status: {:?}",
238                self.status
239            )),
240        }
241    }
242
243    /// Refund payment (partial or full)
244    pub fn refund(&mut self, refund_amount_cents: i64) -> Result<(), String> {
245        // Can only refund succeeded payments
246        if self.status != TransactionStatus::Succeeded {
247            return Err(format!(
248                "Can only refund succeeded payments, current status: {:?}",
249                self.status
250            ));
251        }
252
253        // Validate refund amount
254        if refund_amount_cents <= 0 {
255            return Err("Refund amount must be greater than 0".to_string());
256        }
257
258        // Check total refunds don't exceed original amount
259        let total_refunded = self.refunded_amount_cents + refund_amount_cents;
260        if total_refunded > self.amount_cents {
261            return Err(format!(
262                "Total refund ({} cents) would exceed original payment ({} cents)",
263                total_refunded, self.amount_cents
264            ));
265        }
266
267        self.refunded_amount_cents += refund_amount_cents;
268
269        // If fully refunded, update status
270        if self.refunded_amount_cents == self.amount_cents {
271            self.status = TransactionStatus::Refunded;
272        }
273
274        self.updated_at = Utc::now();
275        Ok(())
276    }
277
278    /// Set Stripe payment intent ID
279    pub fn set_stripe_payment_intent_id(&mut self, payment_intent_id: String) {
280        self.stripe_payment_intent_id = Some(payment_intent_id);
281        self.updated_at = Utc::now();
282    }
283
284    /// Set Stripe customer ID
285    pub fn set_stripe_customer_id(&mut self, customer_id: String) {
286        self.stripe_customer_id = Some(customer_id);
287        self.updated_at = Utc::now();
288    }
289
290    /// Set payment method ID (for saved payment methods)
291    pub fn set_payment_method_id(&mut self, payment_method_id: Uuid) {
292        self.payment_method_id = Some(payment_method_id);
293        self.updated_at = Utc::now();
294    }
295
296    /// Set metadata
297    pub fn set_metadata(&mut self, metadata: String) {
298        self.metadata = Some(metadata);
299        self.updated_at = Utc::now();
300    }
301
302    /// Get net amount after refunds (in cents)
303    pub fn get_net_amount_cents(&self) -> i64 {
304        self.amount_cents - self.refunded_amount_cents
305    }
306
307    /// Check if payment is in final state (cannot be modified)
308    pub fn is_final(&self) -> bool {
309        matches!(
310            self.status,
311            TransactionStatus::Succeeded
312                | TransactionStatus::Failed
313                | TransactionStatus::Cancelled
314                | TransactionStatus::Refunded
315        )
316    }
317
318    /// Check if payment can be refunded
319    pub fn can_refund(&self) -> bool {
320        self.status == TransactionStatus::Succeeded
321            && self.refunded_amount_cents < self.amount_cents
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn create_test_payment() -> Payment {
330        Payment::new(
331            Uuid::new_v4(),
332            Uuid::new_v4(),
333            Uuid::new_v4(),
334            Some(Uuid::new_v4()),
335            10000, // 100.00 EUR
336            PaymentMethodType::Card,
337            "test_idempotency_key_123456789".to_string(),
338            Some("Test payment".to_string()),
339        )
340        .unwrap()
341    }
342
343    #[test]
344    fn test_create_payment_success() {
345        let payment = create_test_payment();
346        assert_eq!(payment.amount_cents, 10000);
347        assert_eq!(payment.currency, "EUR");
348        assert_eq!(payment.status, TransactionStatus::Pending);
349        assert_eq!(payment.refunded_amount_cents, 0);
350    }
351
352    #[test]
353    fn test_create_payment_invalid_amount() {
354        let result = Payment::new(
355            Uuid::new_v4(),
356            Uuid::new_v4(),
357            Uuid::new_v4(),
358            None,
359            0, // Invalid: must be > 0
360            PaymentMethodType::Card,
361            "test_idempotency_key_123456789".to_string(),
362            None,
363        );
364        assert!(result.is_err());
365        assert_eq!(result.unwrap_err(), "Amount must be greater than 0");
366    }
367
368    #[test]
369    fn test_create_payment_invalid_idempotency_key() {
370        let result = Payment::new(
371            Uuid::new_v4(),
372            Uuid::new_v4(),
373            Uuid::new_v4(),
374            None,
375            10000,
376            PaymentMethodType::Card,
377            "short".to_string(), // Too short
378            None,
379        );
380        assert!(result.is_err());
381        assert!(result.unwrap_err().contains("Idempotency key"));
382    }
383
384    #[test]
385    fn test_payment_lifecycle_success() {
386        let mut payment = create_test_payment();
387
388        // Pending → Processing
389        assert!(payment.mark_processing().is_ok());
390        assert_eq!(payment.status, TransactionStatus::Processing);
391
392        // Processing → Succeeded
393        assert!(payment.mark_succeeded().is_ok());
394        assert_eq!(payment.status, TransactionStatus::Succeeded);
395        assert!(payment.succeeded_at.is_some());
396        assert!(payment.is_final());
397    }
398
399    #[test]
400    fn test_payment_lifecycle_failure() {
401        let mut payment = create_test_payment();
402
403        payment.mark_processing().unwrap();
404
405        // Processing → Failed
406        assert!(payment.mark_failed("Card declined".to_string()).is_ok());
407        assert_eq!(payment.status, TransactionStatus::Failed);
408        assert_eq!(payment.failure_reason, Some("Card declined".to_string()));
409        assert!(payment.failed_at.is_some());
410        assert!(payment.is_final());
411    }
412
413    #[test]
414    fn test_payment_lifecycle_cancelled() {
415        let mut payment = create_test_payment();
416
417        // Pending → Cancelled
418        assert!(payment.mark_cancelled().is_ok());
419        assert_eq!(payment.status, TransactionStatus::Cancelled);
420        assert!(payment.cancelled_at.is_some());
421        assert!(payment.is_final());
422    }
423
424    #[test]
425    fn test_payment_requires_action() {
426        let mut payment = create_test_payment();
427
428        payment.mark_processing().unwrap();
429
430        // Processing → RequiresAction (e.g., 3D Secure)
431        assert!(payment.mark_requires_action().is_ok());
432        assert_eq!(payment.status, TransactionStatus::RequiresAction);
433
434        // RequiresAction → Succeeded (after user completes 3DS)
435        assert!(payment.mark_succeeded().is_ok());
436        assert_eq!(payment.status, TransactionStatus::Succeeded);
437    }
438
439    #[test]
440    fn test_payment_invalid_status_transition() {
441        let mut payment = create_test_payment();
442        payment.mark_succeeded().unwrap();
443
444        // Cannot go from Succeeded to Processing
445        assert!(payment.mark_processing().is_err());
446    }
447
448    #[test]
449    fn test_refund_full() {
450        let mut payment = create_test_payment();
451        payment.mark_succeeded().unwrap();
452
453        assert!(payment.can_refund());
454
455        // Full refund
456        assert!(payment.refund(10000).is_ok());
457        assert_eq!(payment.refunded_amount_cents, 10000);
458        assert_eq!(payment.status, TransactionStatus::Refunded);
459        assert_eq!(payment.get_net_amount_cents(), 0);
460        assert!(!payment.can_refund());
461    }
462
463    #[test]
464    fn test_refund_partial() {
465        let mut payment = create_test_payment();
466        payment.mark_succeeded().unwrap();
467
468        // Partial refund (50%)
469        assert!(payment.refund(5000).is_ok());
470        assert_eq!(payment.refunded_amount_cents, 5000);
471        assert_eq!(payment.status, TransactionStatus::Succeeded); // Still succeeded
472        assert_eq!(payment.get_net_amount_cents(), 5000);
473        assert!(payment.can_refund());
474
475        // Refund remaining 50%
476        assert!(payment.refund(5000).is_ok());
477        assert_eq!(payment.refunded_amount_cents, 10000);
478        assert_eq!(payment.status, TransactionStatus::Refunded);
479    }
480
481    #[test]
482    fn test_refund_exceeds_amount() {
483        let mut payment = create_test_payment();
484        payment.mark_succeeded().unwrap();
485
486        // Try to refund more than original amount
487        let result = payment.refund(15000);
488        assert!(result.is_err());
489        assert!(result.unwrap_err().contains("exceed original payment"));
490    }
491
492    #[test]
493    fn test_refund_before_success() {
494        let mut payment = create_test_payment();
495
496        // Cannot refund pending payment
497        let result = payment.refund(5000);
498        assert!(result.is_err());
499        assert!(result.unwrap_err().contains("succeeded payments"));
500    }
501
502    #[test]
503    fn test_set_stripe_data() {
504        let mut payment = create_test_payment();
505
506        payment.set_stripe_payment_intent_id("pi_123456789".to_string());
507        assert_eq!(
508            payment.stripe_payment_intent_id,
509            Some("pi_123456789".to_string())
510        );
511
512        payment.set_stripe_customer_id("cus_123456789".to_string());
513        assert_eq!(
514            payment.stripe_customer_id,
515            Some("cus_123456789".to_string())
516        );
517
518        let method_id = Uuid::new_v4();
519        payment.set_payment_method_id(method_id);
520        assert_eq!(payment.payment_method_id, Some(method_id));
521    }
522}