reasonkit_web/stripe/
events.rs

1//! Stripe Event Types
2//!
3//! Strongly-typed representations of Stripe webhook events for SaaS subscriptions.
4
5use std::str::FromStr;
6
7use serde::{Deserialize, Serialize};
8
9use crate::stripe::error::{StripeWebhookError, StripeWebhookResult};
10
11/// Stripe event types we handle
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum StripeEventType {
15    // Customer events
16    #[serde(rename = "customer.created")]
17    CustomerCreated,
18
19    // Subscription events
20    #[serde(rename = "customer.subscription.created")]
21    SubscriptionCreated,
22    #[serde(rename = "customer.subscription.updated")]
23    SubscriptionUpdated,
24    #[serde(rename = "customer.subscription.deleted")]
25    SubscriptionDeleted,
26
27    // Invoice events
28    #[serde(rename = "invoice.payment_succeeded")]
29    InvoicePaymentSucceeded,
30    #[serde(rename = "invoice.payment_failed")]
31    InvoicePaymentFailed,
32
33    // Catch-all for events we don't explicitly handle
34    #[serde(other)]
35    Unknown,
36}
37
38impl FromStr for StripeEventType {
39    type Err = std::convert::Infallible;
40
41    fn from_str(s: &str) -> Result<Self, Self::Err> {
42        Ok(match s {
43            "customer.created" => Self::CustomerCreated,
44            "customer.subscription.created" => Self::SubscriptionCreated,
45            "customer.subscription.updated" => Self::SubscriptionUpdated,
46            "customer.subscription.deleted" => Self::SubscriptionDeleted,
47            "invoice.payment_succeeded" => Self::InvoicePaymentSucceeded,
48            "invoice.payment_failed" => Self::InvoicePaymentFailed,
49            _ => Self::Unknown,
50        })
51    }
52}
53
54impl StripeEventType {
55    /// Get the string representation
56    pub fn as_str(&self) -> &'static str {
57        match self {
58            Self::CustomerCreated => "customer.created",
59            Self::SubscriptionCreated => "customer.subscription.created",
60            Self::SubscriptionUpdated => "customer.subscription.updated",
61            Self::SubscriptionDeleted => "customer.subscription.deleted",
62            Self::InvoicePaymentSucceeded => "invoice.payment_succeeded",
63            Self::InvoicePaymentFailed => "invoice.payment_failed",
64            Self::Unknown => "unknown",
65        }
66    }
67
68    /// Check if this is a known event type
69    pub fn is_known(&self) -> bool {
70        !matches!(self, Self::Unknown)
71    }
72}
73
74/// Generic Stripe event envelope
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct StripeEvent {
77    /// Unique identifier for the event
78    pub id: String,
79
80    /// Type of event
81    #[serde(rename = "type")]
82    pub event_type: String,
83
84    /// Time of event creation (Unix timestamp)
85    pub created: i64,
86
87    /// API version used to render data
88    #[serde(default)]
89    pub api_version: Option<String>,
90
91    /// Whether this is a live mode event
92    pub livemode: bool,
93
94    /// Number of times Stripe has attempted to deliver
95    #[serde(default)]
96    pub pending_webhooks: u32,
97
98    /// Object containing event data
99    pub data: EventData,
100
101    /// Request that caused the event (if applicable)
102    #[serde(default)]
103    pub request: Option<EventRequest>,
104}
105
106impl StripeEvent {
107    /// Parse from raw JSON bytes
108    pub fn from_bytes(bytes: &[u8]) -> StripeWebhookResult<Self> {
109        serde_json::from_slice(bytes).map_err(|e| StripeWebhookError::InvalidPayload(e.to_string()))
110    }
111
112    /// Get the typed event type
113    pub fn typed_event_type(&self) -> StripeEventType {
114        // Infallible error type means this can never fail
115        StripeEventType::from_str(&self.event_type).unwrap()
116    }
117
118    /// Extract subscription from event data
119    pub fn as_subscription(&self) -> StripeWebhookResult<SubscriptionEvent> {
120        match self.typed_event_type() {
121            StripeEventType::SubscriptionCreated
122            | StripeEventType::SubscriptionUpdated
123            | StripeEventType::SubscriptionDeleted => {
124                let subscription: Subscription =
125                    serde_json::from_value(self.data.object.clone())
126                        .map_err(|e| StripeWebhookError::InvalidPayload(e.to_string()))?;
127
128                Ok(SubscriptionEvent {
129                    event_id: self.id.clone(),
130                    event_type: self.typed_event_type(),
131                    subscription,
132                    previous_attributes: self.data.previous_attributes.clone(),
133                })
134            }
135            _ => Err(StripeWebhookError::InvalidPayload(format!(
136                "Event {} is not a subscription event",
137                self.event_type
138            ))),
139        }
140    }
141
142    /// Extract invoice from event data
143    pub fn as_invoice(&self) -> StripeWebhookResult<InvoiceEvent> {
144        match self.typed_event_type() {
145            StripeEventType::InvoicePaymentSucceeded | StripeEventType::InvoicePaymentFailed => {
146                let invoice: Invoice = serde_json::from_value(self.data.object.clone())
147                    .map_err(|e| StripeWebhookError::InvalidPayload(e.to_string()))?;
148
149                Ok(InvoiceEvent {
150                    event_id: self.id.clone(),
151                    event_type: self.typed_event_type(),
152                    invoice,
153                })
154            }
155            _ => Err(StripeWebhookError::InvalidPayload(format!(
156                "Event {} is not an invoice event",
157                self.event_type
158            ))),
159        }
160    }
161
162    /// Extract customer from event data
163    pub fn as_customer(&self) -> StripeWebhookResult<CustomerEvent> {
164        match self.typed_event_type() {
165            StripeEventType::CustomerCreated => {
166                let customer: Customer = serde_json::from_value(self.data.object.clone())
167                    .map_err(|e| StripeWebhookError::InvalidPayload(e.to_string()))?;
168
169                Ok(CustomerEvent {
170                    event_id: self.id.clone(),
171                    event_type: self.typed_event_type(),
172                    customer,
173                })
174            }
175            _ => Err(StripeWebhookError::InvalidPayload(format!(
176                "Event {} is not a customer event",
177                self.event_type
178            ))),
179        }
180    }
181}
182
183/// Event data container
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct EventData {
186    /// The actual event object (subscription, invoice, customer, etc.)
187    pub object: serde_json::Value,
188
189    /// Previous values for updated fields (only in *.updated events)
190    #[serde(default)]
191    pub previous_attributes: Option<serde_json::Value>,
192}
193
194/// Request that triggered the event
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct EventRequest {
197    /// Request ID
198    pub id: Option<String>,
199    /// Idempotency key used in the request
200    pub idempotency_key: Option<String>,
201}
202
203// =============================================================================
204// Subscription Types
205// =============================================================================
206
207/// Subscription event with typed data
208#[derive(Debug, Clone)]
209pub struct SubscriptionEvent {
210    /// The event ID
211    pub event_id: String,
212    /// Type of subscription event
213    pub event_type: StripeEventType,
214    /// The subscription object
215    pub subscription: Subscription,
216    /// Previous values (for updates)
217    pub previous_attributes: Option<serde_json::Value>,
218}
219
220/// Stripe subscription object
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct Subscription {
223    /// Subscription ID (sub_...)
224    pub id: String,
225    /// Customer ID (cus_...)
226    pub customer: String,
227    /// Subscription status
228    pub status: SubscriptionStatus,
229    /// Current billing period start (Unix timestamp)
230    pub current_period_start: i64,
231    /// Current billing period end (Unix timestamp)
232    pub current_period_end: i64,
233    /// Whether subscription will cancel at period end
234    #[serde(default)]
235    pub cancel_at_period_end: bool,
236    /// When the subscription was canceled (if applicable)
237    pub canceled_at: Option<i64>,
238    /// When the subscription ended (if applicable)
239    pub ended_at: Option<i64>,
240    /// Trial end date (if applicable)
241    pub trial_end: Option<i64>,
242    /// Subscription items (plans/prices)
243    pub items: SubscriptionItems,
244    /// Metadata attached to the subscription
245    #[serde(default)]
246    pub metadata: serde_json::Value,
247    /// Whether this is a live mode subscription
248    pub livemode: bool,
249}
250
251/// Subscription status
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "snake_case")]
254pub enum SubscriptionStatus {
255    Active,
256    PastDue,
257    Unpaid,
258    Canceled,
259    Incomplete,
260    IncompleteExpired,
261    Trialing,
262    Paused,
263    #[serde(other)]
264    Unknown,
265}
266
267impl SubscriptionStatus {
268    /// Check if subscription is in a "good" state
269    pub fn is_active(&self) -> bool {
270        matches!(self, Self::Active | Self::Trialing)
271    }
272
273    /// Check if subscription requires payment attention
274    pub fn requires_payment_action(&self) -> bool {
275        matches!(self, Self::PastDue | Self::Unpaid | Self::Incomplete)
276    }
277}
278
279/// Subscription items container
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct SubscriptionItems {
282    /// List of subscription items
283    pub data: Vec<SubscriptionItem>,
284}
285
286/// Individual subscription item
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct SubscriptionItem {
289    /// Item ID
290    pub id: String,
291    /// Price object
292    pub price: Price,
293    /// Quantity
294    #[serde(default = "default_quantity")]
295    pub quantity: u32,
296}
297
298fn default_quantity() -> u32 {
299    1
300}
301
302/// Price object
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct Price {
305    /// Price ID
306    pub id: String,
307    /// Product ID
308    pub product: String,
309    /// Unit amount in cents
310    pub unit_amount: Option<i64>,
311    /// Currency
312    pub currency: String,
313    /// Recurring information
314    pub recurring: Option<Recurring>,
315}
316
317/// Recurring price details
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct Recurring {
320    /// Billing interval (day, week, month, year)
321    pub interval: String,
322    /// Number of intervals
323    pub interval_count: u32,
324}
325
326// =============================================================================
327// Invoice Types
328// =============================================================================
329
330/// Invoice event with typed data
331#[derive(Debug, Clone)]
332pub struct InvoiceEvent {
333    /// The event ID
334    pub event_id: String,
335    /// Type of invoice event
336    pub event_type: StripeEventType,
337    /// The invoice object
338    pub invoice: Invoice,
339}
340
341/// Stripe invoice object
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct Invoice {
344    /// Invoice ID (in_...)
345    pub id: String,
346    /// Customer ID
347    pub customer: String,
348    /// Associated subscription ID (if any)
349    pub subscription: Option<String>,
350    /// Invoice status
351    pub status: InvoiceStatus,
352    /// Total amount in cents
353    pub amount_due: i64,
354    /// Amount paid in cents
355    pub amount_paid: i64,
356    /// Amount remaining in cents
357    pub amount_remaining: i64,
358    /// Currency
359    pub currency: String,
360    /// Billing reason
361    pub billing_reason: Option<String>,
362    /// Customer email at time of invoice
363    pub customer_email: Option<String>,
364    /// Hosted invoice URL
365    pub hosted_invoice_url: Option<String>,
366    /// Invoice PDF URL
367    pub invoice_pdf: Option<String>,
368    /// Payment intent ID (if payment attempted)
369    pub payment_intent: Option<String>,
370    /// When created (Unix timestamp)
371    pub created: i64,
372    /// Period start
373    pub period_start: i64,
374    /// Period end
375    pub period_end: i64,
376    /// Whether this is live mode
377    pub livemode: bool,
378}
379
380/// Invoice status
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(rename_all = "snake_case")]
383pub enum InvoiceStatus {
384    Draft,
385    Open,
386    Paid,
387    Uncollectible,
388    Void,
389    #[serde(other)]
390    Unknown,
391}
392
393// =============================================================================
394// Customer Types
395// =============================================================================
396
397/// Customer event with typed data
398#[derive(Debug, Clone)]
399pub struct CustomerEvent {
400    /// The event ID
401    pub event_id: String,
402    /// Type of customer event
403    pub event_type: StripeEventType,
404    /// The customer object
405    pub customer: Customer,
406}
407
408/// Stripe customer object
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct Customer {
411    /// Customer ID (cus_...)
412    pub id: String,
413    /// Customer email
414    pub email: Option<String>,
415    /// Customer name
416    pub name: Option<String>,
417    /// Customer description
418    pub description: Option<String>,
419    /// When created (Unix timestamp)
420    pub created: i64,
421    /// Metadata
422    #[serde(default)]
423    pub metadata: serde_json::Value,
424    /// Whether this is live mode
425    pub livemode: bool,
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn test_event_type_parsing() {
434        assert_eq!(
435            StripeEventType::from_str("customer.subscription.created").unwrap(),
436            StripeEventType::SubscriptionCreated
437        );
438        assert_eq!(
439            StripeEventType::from_str("invoice.payment_succeeded").unwrap(),
440            StripeEventType::InvoicePaymentSucceeded
441        );
442        assert_eq!(
443            StripeEventType::from_str("unknown.event").unwrap(),
444            StripeEventType::Unknown
445        );
446    }
447
448    #[test]
449    fn test_subscription_status() {
450        assert!(SubscriptionStatus::Active.is_active());
451        assert!(SubscriptionStatus::Trialing.is_active());
452        assert!(!SubscriptionStatus::Canceled.is_active());
453
454        assert!(SubscriptionStatus::PastDue.requires_payment_action());
455        assert!(!SubscriptionStatus::Active.requires_payment_action());
456    }
457
458    #[test]
459    fn test_parse_subscription_event() {
460        let json = r#"{
461            "id": "evt_1234567890",
462            "type": "customer.subscription.created",
463            "created": 1614556800,
464            "livemode": false,
465            "pending_webhooks": 1,
466            "data": {
467                "object": {
468                    "id": "sub_1234567890",
469                    "customer": "cus_1234567890",
470                    "status": "active",
471                    "current_period_start": 1614556800,
472                    "current_period_end": 1617235200,
473                    "cancel_at_period_end": false,
474                    "items": {
475                        "data": [{
476                            "id": "si_1234567890",
477                            "price": {
478                                "id": "price_1234567890",
479                                "product": "prod_1234567890",
480                                "unit_amount": 2000,
481                                "currency": "usd",
482                                "recurring": {
483                                    "interval": "month",
484                                    "interval_count": 1
485                                }
486                            },
487                            "quantity": 1
488                        }]
489                    },
490                    "metadata": {},
491                    "livemode": false
492                }
493            }
494        }"#;
495
496        let event = StripeEvent::from_bytes(json.as_bytes()).unwrap();
497        assert_eq!(event.id, "evt_1234567890");
498        assert_eq!(
499            event.typed_event_type(),
500            StripeEventType::SubscriptionCreated
501        );
502
503        let sub_event = event.as_subscription().unwrap();
504        assert_eq!(sub_event.subscription.id, "sub_1234567890");
505        assert_eq!(sub_event.subscription.status, SubscriptionStatus::Active);
506    }
507
508    #[test]
509    fn test_parse_invoice_event() {
510        let json = r#"{
511            "id": "evt_invoice_1234",
512            "type": "invoice.payment_succeeded",
513            "created": 1614556800,
514            "livemode": false,
515            "pending_webhooks": 1,
516            "data": {
517                "object": {
518                    "id": "in_1234567890",
519                    "customer": "cus_1234567890",
520                    "subscription": "sub_1234567890",
521                    "status": "paid",
522                    "amount_due": 2000,
523                    "amount_paid": 2000,
524                    "amount_remaining": 0,
525                    "currency": "usd",
526                    "created": 1614556800,
527                    "period_start": 1614556800,
528                    "period_end": 1617235200,
529                    "livemode": false
530                }
531            }
532        }"#;
533
534        let event = StripeEvent::from_bytes(json.as_bytes()).unwrap();
535        let invoice_event = event.as_invoice().unwrap();
536
537        assert_eq!(invoice_event.invoice.id, "in_1234567890");
538        assert_eq!(invoice_event.invoice.status, InvoiceStatus::Paid);
539        assert_eq!(invoice_event.invoice.amount_paid, 2000);
540    }
541}