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, 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    /// 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 or partially-refunded payments
246        if self.status != TransactionStatus::Succeeded && self.status != TransactionStatus::Refunded
247        {
248            return Err(format!(
249                "Can only refund succeeded payments, current status: {:?}",
250                self.status
251            ));
252        }
253
254        // Validate refund amount
255        if refund_amount_cents <= 0 {
256            return Err("Refund amount must be greater than 0".to_string());
257        }
258
259        // Check total refunds don't exceeds original amount
260        let total_refunded = self.refunded_amount_cents + refund_amount_cents;
261        if total_refunded > self.amount_cents {
262            return Err(format!(
263                "Total refund ({} cents) would exceeds original payment ({} cents)",
264                total_refunded, self.amount_cents
265            ));
266        }
267
268        self.refunded_amount_cents += refund_amount_cents;
269
270        // Any refund (partial or full) sets status to Refunded
271        self.status = TransactionStatus::Refunded;
272
273        self.updated_at = Utc::now();
274        Ok(())
275    }
276
277    /// Set Stripe payment intent ID
278    pub fn set_stripe_payment_intent_id(&mut self, payment_intent_id: String) {
279        self.stripe_payment_intent_id = Some(payment_intent_id);
280        self.updated_at = Utc::now();
281    }
282
283    /// Set Stripe customer ID
284    pub fn set_stripe_customer_id(&mut self, customer_id: String) {
285        self.stripe_customer_id = Some(customer_id);
286        self.updated_at = Utc::now();
287    }
288
289    /// Set payment method ID (for saved payment methods)
290    pub fn set_payment_method_id(&mut self, payment_method_id: Uuid) {
291        self.payment_method_id = Some(payment_method_id);
292        self.updated_at = Utc::now();
293    }
294
295    /// Set metadata
296    pub fn set_metadata(&mut self, metadata: String) {
297        self.metadata = Some(metadata);
298        self.updated_at = Utc::now();
299    }
300
301    /// Get net amount after refunds (in cents)
302    pub fn get_net_amount_cents(&self) -> i64 {
303        self.amount_cents - self.refunded_amount_cents
304    }
305
306    /// Check if payment is in final state (cannot be modified)
307    pub fn is_final(&self) -> bool {
308        matches!(
309            self.status,
310            TransactionStatus::Succeeded
311                | TransactionStatus::Failed
312                | TransactionStatus::Cancelled
313                | TransactionStatus::Refunded
314        )
315    }
316
317    /// Check if payment can be refunded
318    pub fn can_refund(&self) -> bool {
319        self.status == TransactionStatus::Succeeded
320            && self.refunded_amount_cents < self.amount_cents
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn create_test_payment() -> Payment {
329        Payment::new(
330            Uuid::new_v4(),
331            Uuid::new_v4(),
332            Uuid::new_v4(),
333            Some(Uuid::new_v4()),
334            10000, // 100.00 EUR
335            PaymentMethodType::Card,
336            "test_idempotency_key_123456789".to_string(),
337            Some("Test payment".to_string()),
338        )
339        .unwrap()
340    }
341
342    #[test]
343    fn test_create_payment_success() {
344        let payment = create_test_payment();
345        assert_eq!(payment.amount_cents, 10000);
346        assert_eq!(payment.currency, "EUR");
347        assert_eq!(payment.status, TransactionStatus::Pending);
348        assert_eq!(payment.refunded_amount_cents, 0);
349    }
350
351    #[test]
352    fn test_create_payment_invalid_amount() {
353        let result = Payment::new(
354            Uuid::new_v4(),
355            Uuid::new_v4(),
356            Uuid::new_v4(),
357            None,
358            0, // Invalid: must be > 0
359            PaymentMethodType::Card,
360            "test_idempotency_key_123456789".to_string(),
361            None,
362        );
363        assert!(result.is_err());
364        assert_eq!(result.unwrap_err(), "Amount must be greater than 0");
365    }
366
367    #[test]
368    fn test_create_payment_invalid_idempotency_key() {
369        let result = Payment::new(
370            Uuid::new_v4(),
371            Uuid::new_v4(),
372            Uuid::new_v4(),
373            None,
374            10000,
375            PaymentMethodType::Card,
376            "short".to_string(), // Too short
377            None,
378        );
379        assert!(result.is_err());
380        assert!(result.unwrap_err().contains("Idempotency key"));
381    }
382
383    #[test]
384    fn test_payment_lifecycle_success() {
385        let mut payment = create_test_payment();
386
387        // Pending → Processing
388        assert!(payment.mark_processing().is_ok());
389        assert_eq!(payment.status, TransactionStatus::Processing);
390
391        // Processing → Succeeded
392        assert!(payment.mark_succeeded().is_ok());
393        assert_eq!(payment.status, TransactionStatus::Succeeded);
394        assert!(payment.succeeded_at.is_some());
395        assert!(payment.is_final());
396    }
397
398    #[test]
399    fn test_payment_lifecycle_failure() {
400        let mut payment = create_test_payment();
401
402        payment.mark_processing().unwrap();
403
404        // Processing → Failed
405        assert!(payment.mark_failed("Card declined".to_string()).is_ok());
406        assert_eq!(payment.status, TransactionStatus::Failed);
407        assert_eq!(payment.failure_reason, Some("Card declined".to_string()));
408        assert!(payment.failed_at.is_some());
409        assert!(payment.is_final());
410    }
411
412    #[test]
413    fn test_payment_lifecycle_cancelled() {
414        let mut payment = create_test_payment();
415
416        // Pending → Cancelled
417        assert!(payment.mark_cancelled().is_ok());
418        assert_eq!(payment.status, TransactionStatus::Cancelled);
419        assert!(payment.cancelled_at.is_some());
420        assert!(payment.is_final());
421    }
422
423    #[test]
424    fn test_payment_requires_action() {
425        let mut payment = create_test_payment();
426
427        payment.mark_processing().unwrap();
428
429        // Processing → RequiresAction (e.g., 3D Secure)
430        assert!(payment.mark_requires_action().is_ok());
431        assert_eq!(payment.status, TransactionStatus::RequiresAction);
432
433        // RequiresAction → Succeeded (after user completes 3DS)
434        assert!(payment.mark_succeeded().is_ok());
435        assert_eq!(payment.status, TransactionStatus::Succeeded);
436    }
437
438    #[test]
439    fn test_payment_invalid_status_transition() {
440        let mut payment = create_test_payment();
441        payment.mark_succeeded().unwrap();
442
443        // Cannot go from Succeeded to Processing
444        assert!(payment.mark_processing().is_err());
445    }
446
447    #[test]
448    fn test_refund_full() {
449        let mut payment = create_test_payment();
450        payment.mark_succeeded().unwrap();
451
452        assert!(payment.can_refund());
453
454        // Full refund
455        assert!(payment.refund(10000).is_ok());
456        assert_eq!(payment.refunded_amount_cents, 10000);
457        assert_eq!(payment.status, TransactionStatus::Refunded);
458        assert_eq!(payment.get_net_amount_cents(), 0);
459        assert!(!payment.can_refund());
460    }
461
462    #[test]
463    fn test_refund_partial() {
464        let mut payment = create_test_payment();
465        payment.mark_succeeded().unwrap();
466
467        // Partial refund (50%)
468        assert!(payment.refund(5000).is_ok());
469        assert_eq!(payment.refunded_amount_cents, 5000);
470        assert_eq!(payment.status, TransactionStatus::Refunded); // Any refund sets status to Refunded
471        assert_eq!(payment.get_net_amount_cents(), 5000);
472        assert!(!payment.can_refund()); // can_refund() checks Succeeded status only
473
474        // Refund remaining 50%
475        assert!(payment.refund(5000).is_ok());
476        assert_eq!(payment.refunded_amount_cents, 10000);
477        assert_eq!(payment.status, TransactionStatus::Refunded);
478    }
479
480    #[test]
481    fn test_refund_exceeds_amount() {
482        let mut payment = create_test_payment();
483        payment.mark_succeeded().unwrap();
484
485        // Try to refund more than original amount
486        let result = payment.refund(15000);
487        assert!(result.is_err());
488        assert!(result.unwrap_err().contains("exceeds"));
489    }
490
491    #[test]
492    fn test_refund_before_success() {
493        let mut payment = create_test_payment();
494
495        // Cannot refund pending payment
496        let result = payment.refund(5000);
497        assert!(result.is_err());
498        assert!(result.unwrap_err().contains("succeeded payments"));
499    }
500
501    #[test]
502    fn test_set_stripe_data() {
503        let mut payment = create_test_payment();
504
505        payment.set_stripe_payment_intent_id("pi_123456789".to_string());
506        assert_eq!(
507            payment.stripe_payment_intent_id,
508            Some("pi_123456789".to_string())
509        );
510
511        payment.set_stripe_customer_id("cus_123456789".to_string());
512        assert_eq!(
513            payment.stripe_customer_id,
514            Some("cus_123456789".to_string())
515        );
516
517        let method_id = Uuid::new_v4();
518        payment.set_payment_method_id(method_id);
519        assert_eq!(payment.payment_method_id, Some(method_id));
520    }
521}