Skip to main content

koprogo_api/domain/comptabilite/
payment_method.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Payment method entity - Represents a stored payment method
6///
7/// Belgian property management context:
8/// - Store payment methods for recurring charges
9/// - Support cards (Stripe) and SEPA mandates (Belgian bank accounts)
10/// - PCI-DSS compliant: Never store raw card data, only Stripe tokens
11#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
12pub struct PaymentMethod {
13    pub id: Uuid,
14    /// Organization (multi-tenant isolation)
15    pub organization_id: Uuid,
16    /// Owner who owns this payment method
17    pub owner_id: Uuid,
18    /// Payment method type
19    pub method_type: PaymentMethodType,
20    /// Stripe payment method ID (pm_xxx for cards, sepa_debit_xxx for SEPA)
21    pub stripe_payment_method_id: String,
22    /// Stripe customer ID (links payment method to customer)
23    pub stripe_customer_id: String,
24    /// Display label for UI (e.g., "Visa •••• 4242", "SEPA BE68 5390 0754")
25    pub display_label: String,
26    /// Is this the default payment method for the owner?
27    pub is_default: bool,
28    /// Is this payment method active? (can be deactivated)
29    pub is_active: bool,
30    /// Card/SEPA specific metadata (JSON) - stores last4, brand, expiry, etc.
31    pub metadata: Option<String>,
32    /// Expiry date for cards (not applicable for SEPA)
33    pub expires_at: Option<DateTime<Utc>>,
34    pub created_at: DateTime<Utc>,
35    pub updated_at: DateTime<Utc>,
36}
37
38/// Type d'un moyen de paiement **enregistré**, c'est-à-dire d'un instrument
39/// conservé chez Stripe et réutilisable.
40///
41/// Deux variantes seulement, et c'est correct : on ne peut pas « enregistrer »
42/// du liquide, ni un virement manuel. Un instrument enregistré porte un
43/// `stripe_payment_method_id` et un `stripe_customer_id` — l'entité l'exige.
44///
45/// **À ne pas confondre avec `payment::PaymentMethodType`**, qui décrit
46/// comment un paiement a été REÇU et compte quatre variantes, dont le
47/// virement manuel et l'espèce.
48///
49/// Les deux types portaient le même nom Rust ET le même nom de schéma. utoipa
50/// n'en publie qu'un sous un nom donné : le contrat annonçait donc
51/// `["card", "sepa_debit"]` partout, y compris pour le champ
52/// `CreatePaymentRequest.payment_method_type` — interdisant à tout client
53/// engendré depuis le contrat d'enregistrer un paiement en espèces ou par
54/// virement, deux façons parfaitement ordinaires de payer ses charges.
55///
56/// D'où le nom de schéma distinct. Le doc-comment précédent affirmait
57/// « aligned with Payment entity » alors qu'il en avait deux variantes sur
58/// quatre. Voir #819.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
60#[schema(as = StoredPaymentMethodType)]
61#[serde(rename_all = "snake_case")]
62pub enum PaymentMethodType {
63    /// Credit/debit card via Stripe
64    Card,
65    /// SEPA Direct Debit (Belgian bank transfer)
66    SepaDebit,
67}
68
69impl PaymentMethod {
70    /// Create a new payment method
71    ///
72    /// # Arguments
73    /// * `organization_id` - Organization ID (multi-tenant)
74    /// * `owner_id` - Owner who owns this payment method
75    /// * `method_type` - Payment method type (Card or SepaDebit)
76    /// * `stripe_payment_method_id` - Stripe payment method ID
77    /// * `stripe_customer_id` - Stripe customer ID
78    /// * `display_label` - Display label for UI
79    /// * `is_default` - Is this the default payment method?
80    ///
81    /// # Returns
82    /// * `Ok(PaymentMethod)` - New payment method
83    /// * `Err(String)` - Validation error
84    pub fn new(
85        organization_id: Uuid,
86        owner_id: Uuid,
87        method_type: PaymentMethodType,
88        stripe_payment_method_id: String,
89        stripe_customer_id: String,
90        display_label: String,
91        is_default: bool,
92    ) -> Result<Self, String> {
93        // Validate Stripe IDs
94        if stripe_payment_method_id.trim().is_empty() {
95            return Err("Stripe payment method ID cannot be empty".to_string());
96        }
97        if stripe_customer_id.trim().is_empty() {
98            return Err("Stripe customer ID cannot be empty".to_string());
99        }
100
101        // Validate display label
102        if display_label.trim().is_empty() {
103            return Err("Display label cannot be empty".to_string());
104        }
105
106        let now = Utc::now();
107
108        Ok(Self {
109            id: Uuid::new_v4(),
110            organization_id,
111            owner_id,
112            method_type,
113            stripe_payment_method_id,
114            stripe_customer_id,
115            display_label,
116            is_default,
117            is_active: true, // Active by default
118            metadata: None,
119            expires_at: None,
120            created_at: now,
121            updated_at: now,
122        })
123    }
124
125    /// Set as default payment method
126    pub fn set_default(&mut self) {
127        self.is_default = true;
128        self.updated_at = Utc::now();
129    }
130
131    /// Unset as default payment method
132    pub fn unset_default(&mut self) {
133        self.is_default = false;
134        self.updated_at = Utc::now();
135    }
136
137    /// Deactivate payment method (soft delete)
138    pub fn deactivate(&mut self) -> Result<(), String> {
139        if !self.is_active {
140            return Err("Payment method is already inactive".to_string());
141        }
142
143        self.is_active = false;
144        self.updated_at = Utc::now();
145        Ok(())
146    }
147
148    /// Reactivate payment method
149    pub fn reactivate(&mut self) -> Result<(), String> {
150        if self.is_active {
151            return Err("Payment method is already active".to_string());
152        }
153
154        self.is_active = true;
155        self.updated_at = Utc::now();
156        Ok(())
157    }
158
159    /// Set metadata (JSON)
160    pub fn set_metadata(&mut self, metadata: String) {
161        self.metadata = Some(metadata);
162        self.updated_at = Utc::now();
163    }
164
165    /// Set expiry date (for cards only)
166    pub fn set_expiry(&mut self, expires_at: DateTime<Utc>) -> Result<(), String> {
167        if self.method_type != PaymentMethodType::Card {
168            return Err("Only cards have expiry dates".to_string());
169        }
170
171        self.expires_at = Some(expires_at);
172        self.updated_at = Utc::now();
173        Ok(())
174    }
175
176    /// Check if payment method is expired (cards only)
177    pub fn is_expired(&self) -> bool {
178        if let Some(expires_at) = self.expires_at {
179            expires_at < Utc::now()
180        } else {
181            false
182        }
183    }
184
185    /// Check if payment method is usable (active and not expired)
186    pub fn is_usable(&self) -> bool {
187        self.is_active && !self.is_expired()
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    fn create_test_card() -> PaymentMethod {
196        PaymentMethod::new(
197            Uuid::new_v4(),
198            Uuid::new_v4(),
199            PaymentMethodType::Card,
200            "pm_test_card_123456789".to_string(),
201            "cus_test_123456789".to_string(),
202            "Visa •••• 4242".to_string(),
203            true,
204        )
205        .unwrap()
206    }
207
208    fn create_test_sepa() -> PaymentMethod {
209        PaymentMethod::new(
210            Uuid::new_v4(),
211            Uuid::new_v4(),
212            PaymentMethodType::SepaDebit,
213            "sepa_debit_test_123456789".to_string(),
214            "cus_test_123456789".to_string(),
215            "SEPA BE68 5390 0754".to_string(),
216            false,
217        )
218        .unwrap()
219    }
220
221    #[test]
222    fn test_create_card_success() {
223        let card = create_test_card();
224        assert_eq!(card.method_type, PaymentMethodType::Card);
225        assert_eq!(card.display_label, "Visa •••• 4242");
226        assert!(card.is_default);
227        assert!(card.is_active);
228        assert!(card.is_usable());
229    }
230
231    #[test]
232    fn test_create_sepa_success() {
233        let sepa = create_test_sepa();
234        assert_eq!(sepa.method_type, PaymentMethodType::SepaDebit);
235        assert_eq!(sepa.display_label, "SEPA BE68 5390 0754");
236        assert!(!sepa.is_default);
237        assert!(sepa.is_active);
238        assert!(sepa.is_usable());
239    }
240
241    #[test]
242    fn test_create_invalid_stripe_id() {
243        let result = PaymentMethod::new(
244            Uuid::new_v4(),
245            Uuid::new_v4(),
246            PaymentMethodType::Card,
247            "".to_string(), // Empty Stripe ID
248            "cus_123".to_string(),
249            "Visa 4242".to_string(),
250            false,
251        );
252        assert!(result.is_err());
253        assert!(result.unwrap_err().contains("payment method ID"));
254    }
255
256    #[test]
257    fn test_create_invalid_display_label() {
258        let result = PaymentMethod::new(
259            Uuid::new_v4(),
260            Uuid::new_v4(),
261            PaymentMethodType::Card,
262            "pm_123".to_string(),
263            "cus_123".to_string(),
264            "".to_string(), // Empty label
265            false,
266        );
267        assert!(result.is_err());
268        assert!(result.unwrap_err().contains("Display label"));
269    }
270
271    #[test]
272    fn test_set_unset_default() {
273        let mut card = create_test_card();
274        assert!(card.is_default);
275
276        card.unset_default();
277        assert!(!card.is_default);
278
279        card.set_default();
280        assert!(card.is_default);
281    }
282
283    #[test]
284    fn test_deactivate_reactivate() {
285        let mut card = create_test_card();
286        assert!(card.is_active);
287        assert!(card.is_usable());
288
289        // Deactivate
290        assert!(card.deactivate().is_ok());
291        assert!(!card.is_active);
292        assert!(!card.is_usable());
293
294        // Try deactivating again (should fail)
295        assert!(card.deactivate().is_err());
296
297        // Reactivate
298        assert!(card.reactivate().is_ok());
299        assert!(card.is_active);
300        assert!(card.is_usable());
301    }
302
303    #[test]
304    fn test_card_expiry() {
305        let mut card = create_test_card();
306        assert!(!card.is_expired());
307
308        // Set expiry in the past
309        let past = Utc::now() - chrono::Duration::days(30);
310        assert!(card.set_expiry(past).is_ok());
311        assert!(card.is_expired());
312        assert!(!card.is_usable()); // Not usable because expired
313
314        // Set expiry in the future
315        let future = Utc::now() + chrono::Duration::days(365);
316        assert!(card.set_expiry(future).is_ok());
317        assert!(!card.is_expired());
318        assert!(card.is_usable());
319    }
320
321    #[test]
322    fn test_sepa_no_expiry() {
323        let mut sepa = create_test_sepa();
324
325        // SEPA should not have expiry
326        let future = Utc::now() + chrono::Duration::days(365);
327        let result = sepa.set_expiry(future);
328        assert!(result.is_err());
329        assert!(result.unwrap_err().contains("Only cards"));
330    }
331
332    #[test]
333    fn test_set_metadata() {
334        let mut card = create_test_card();
335        assert!(card.metadata.is_none());
336
337        let metadata =
338            r#"{"brand": "visa", "last4": "4242", "exp_month": 12, "exp_year": 2025}"#.to_string();
339        card.set_metadata(metadata.clone());
340        assert_eq!(card.metadata, Some(metadata));
341    }
342}