use std::collections::HashMap;
use crate::Error;
#[derive(Debug, Clone)]
pub struct WebhookEvent {
pub id: String,
pub type_: String,
pub account: Option<String>,
pub api_version: Option<String>,
pub created: i64,
pub data: serde_json::Value,
}
impl WebhookEvent {
pub fn from_json(raw_body: &str) -> Result<Self, Error> {
let v: serde_json::Value = serde_json::from_str(raw_body)
.map_err(|e| Error::WebhookVerification(format!("event JSON parse: {e}")))?;
let type_ = v
.get("type")
.and_then(|x| x.as_str())
.ok_or_else(|| Error::WebhookVerification("event missing 'type'".into()))?
.to_string();
Ok(Self {
id: v
.get("id")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string(),
type_,
account: v
.get("account")
.and_then(|x| x.as_str())
.map(str::to_string),
api_version: v
.get("api_version")
.and_then(|x| x.as_str())
.map(str::to_string),
created: v.get("created").and_then(|x| x.as_i64()).unwrap_or(0),
data: v
.get("data")
.and_then(|d| d.get("object"))
.cloned()
.unwrap_or(serde_json::Value::Null),
})
}
}
fn json_str(v: &serde_json::Value, key: &str) -> Option<String> {
v.get(key).and_then(|x| x.as_str()).map(str::to_string)
}
fn json_id(v: &serde_json::Value, key: &str) -> Option<String> {
match v.get(key) {
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(serde_json::Value::Object(o)) => {
o.get("id").and_then(|x| x.as_str()).map(str::to_string)
}
_ => None,
}
}
fn json_i64(v: &serde_json::Value, key: &str) -> Option<i64> {
v.get(key).and_then(|x| x.as_i64())
}
fn json_bool(v: &serde_json::Value, key: &str) -> Option<bool> {
v.get(key).and_then(|x| x.as_bool())
}
fn json_metadata(v: &serde_json::Value) -> HashMap<String, String> {
v.get("metadata")
.and_then(|m| m.as_object())
.map(|o| {
o.iter()
.filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default()
}
pub trait StripeEvent: Send + Sync + 'static {
fn from_raw(event: &WebhookEvent) -> Option<Self>
where
Self: Sized;
}
#[derive(Debug, Clone)]
pub struct StripeSubscriptionUpdated {
pub event_id: String,
pub subscription_id: String,
pub customer_id: String,
}
impl StripeEvent for StripeSubscriptionUpdated {
fn from_raw(event: &WebhookEvent) -> Option<Self> {
if event.type_ != "customer.subscription.updated" {
return None;
}
Some(Self {
event_id: event.id.clone(),
subscription_id: json_str(&event.data, "id")?,
customer_id: json_id(&event.data, "customer")?,
})
}
}
#[derive(Debug, Clone)]
pub struct StripeSubscriptionDeleted {
pub event_id: String,
pub subscription_id: String,
pub customer_id: String,
}
impl StripeEvent for StripeSubscriptionDeleted {
fn from_raw(event: &WebhookEvent) -> Option<Self> {
if event.type_ != "customer.subscription.deleted" {
return None;
}
Some(Self {
event_id: event.id.clone(),
subscription_id: json_str(&event.data, "id")?,
customer_id: json_id(&event.data, "customer")?,
})
}
}
#[derive(Debug, Clone)]
pub struct StripeCheckoutCompleted {
pub event_id: String,
pub session_id: String,
pub payment_intent_id: Option<String>,
pub amount_total_cents: i64,
pub currency: String,
pub metadata: HashMap<String, String>,
pub customer_email: Option<String>,
}
impl StripeEvent for StripeCheckoutCompleted {
fn from_raw(event: &WebhookEvent) -> Option<Self> {
if event.type_ != "checkout.session.completed" {
return None;
}
Some(Self {
event_id: event.id.clone(),
session_id: json_str(&event.data, "id")?,
payment_intent_id: json_id(&event.data, "payment_intent"),
amount_total_cents: json_i64(&event.data, "amount_total").unwrap_or(0),
currency: json_str(&event.data, "currency").unwrap_or_default(),
metadata: json_metadata(&event.data),
customer_email: json_str(&event.data, "customer_email"),
})
}
}
#[derive(Debug, Clone)]
pub struct StripeInvoicePaid {
pub event_id: String,
pub invoice_id: String,
pub customer_id: String,
}
impl StripeEvent for StripeInvoicePaid {
fn from_raw(event: &WebhookEvent) -> Option<Self> {
if event.type_ != "invoice.paid" {
return None;
}
Some(Self {
event_id: event.id.clone(),
invoice_id: json_str(&event.data, "id")?,
customer_id: json_id(&event.data, "customer")?,
})
}
}
#[derive(Debug, Clone)]
pub struct StripeConnectPaymentSucceeded {
pub event_id: String,
pub payment_intent_id: String,
pub connect_account_id: String,
}
impl StripeEvent for StripeConnectPaymentSucceeded {
fn from_raw(event: &WebhookEvent) -> Option<Self> {
if event.type_ != "payment_intent.succeeded" {
return None;
}
Some(Self {
event_id: event.id.clone(),
payment_intent_id: json_str(&event.data, "id")?,
connect_account_id: event.account.clone()?,
})
}
}
#[derive(Debug, Clone)]
pub struct StripeCheckoutExpired {
pub event_id: String,
pub session_id: String,
pub metadata: HashMap<String, String>,
}
impl StripeEvent for StripeCheckoutExpired {
fn from_raw(event: &WebhookEvent) -> Option<Self> {
if event.type_ != "checkout.session.expired" {
return None;
}
Some(Self {
event_id: event.id.clone(),
session_id: json_str(&event.data, "id")?,
metadata: json_metadata(&event.data),
})
}
}
#[derive(Debug, Clone)]
pub struct StripePaymentIntentFailed {
pub event_id: String,
pub payment_intent_id: String,
pub session_id: Option<String>,
pub failure_code: Option<String>,
pub failure_message: Option<String>,
pub metadata: HashMap<String, String>,
}
impl StripeEvent for StripePaymentIntentFailed {
fn from_raw(event: &WebhookEvent) -> Option<Self> {
if event.type_ != "payment_intent.payment_failed" {
return None;
}
let metadata = json_metadata(&event.data);
let last_error = event.data.get("last_payment_error");
Some(Self {
event_id: event.id.clone(),
payment_intent_id: json_str(&event.data, "id")?,
session_id: metadata.get("checkout_session_id").cloned(),
failure_code: last_error.and_then(|e| json_str(e, "code")),
failure_message: last_error.and_then(|e| json_str(e, "message")),
metadata,
})
}
}
#[derive(Debug, Clone)]
pub struct StripePaymentIntentAmountCapturableUpdated {
pub event_id: String,
pub payment_intent_id: String,
pub amount_capturable_cents: i64,
pub currency: String,
pub metadata: HashMap<String, String>,
}
impl StripeEvent for StripePaymentIntentAmountCapturableUpdated {
fn from_raw(event: &WebhookEvent) -> Option<Self> {
if event.type_ != "payment_intent.amount_capturable_updated" {
return None;
}
Some(Self {
event_id: event.id.clone(),
payment_intent_id: json_str(&event.data, "id")?,
amount_capturable_cents: json_i64(&event.data, "amount_capturable").unwrap_or(0),
currency: json_str(&event.data, "currency").unwrap_or_default(),
metadata: json_metadata(&event.data),
})
}
}
#[derive(Debug, Clone)]
pub struct StripePaymentIntentCanceled {
pub event_id: String,
pub payment_intent_id: String,
pub cancellation_reason: Option<String>,
pub metadata: HashMap<String, String>,
}
impl StripeEvent for StripePaymentIntentCanceled {
fn from_raw(event: &WebhookEvent) -> Option<Self> {
if event.type_ != "payment_intent.canceled" {
return None;
}
Some(Self {
event_id: event.id.clone(),
payment_intent_id: json_str(&event.data, "id")?,
cancellation_reason: json_str(&event.data, "cancellation_reason"),
metadata: json_metadata(&event.data),
})
}
}
#[derive(Debug, Clone)]
pub struct StripeChargeRefunded {
pub event_id: String,
pub charge_id: String,
pub payment_intent_id: Option<String>,
pub refund_id: Option<String>,
pub amount_refunded_cents: i64,
pub metadata: HashMap<String, String>,
}
impl StripeEvent for StripeChargeRefunded {
fn from_raw(event: &WebhookEvent) -> Option<Self> {
if event.type_ != "charge.refunded" {
return None;
}
let refund_id = event
.data
.get("refunds")
.and_then(|r| r.get("data"))
.and_then(|d| d.as_array())
.and_then(|arr| arr.first())
.and_then(|first| json_str(first, "id"));
Some(Self {
event_id: event.id.clone(),
charge_id: json_str(&event.data, "id")?,
payment_intent_id: json_id(&event.data, "payment_intent"),
refund_id,
amount_refunded_cents: json_i64(&event.data, "amount_refunded").unwrap_or(0),
metadata: json_metadata(&event.data),
})
}
}
#[derive(Debug, Clone)]
pub struct StripeChargeDisputeCreated {
pub event_id: String,
pub charge_id: String,
pub payment_intent_id: Option<String>,
pub dispute_reason: String,
pub amount_cents: i64,
}
impl StripeEvent for StripeChargeDisputeCreated {
fn from_raw(event: &WebhookEvent) -> Option<Self> {
if event.type_ != "charge.dispute.created" {
return None;
}
Some(Self {
event_id: event.id.clone(),
charge_id: json_id(&event.data, "charge")?,
payment_intent_id: json_id(&event.data, "payment_intent"),
dispute_reason: json_str(&event.data, "reason").unwrap_or_default(),
amount_cents: json_i64(&event.data, "amount").unwrap_or(0),
})
}
}
#[derive(Debug, Clone)]
pub struct StripeConnectAccountUpdated {
pub event_id: String,
pub account_id: String,
pub charges_enabled: bool,
pub payouts_enabled: bool,
pub details_submitted: bool,
}
impl StripeEvent for StripeConnectAccountUpdated {
fn from_raw(event: &WebhookEvent) -> Option<Self> {
if event.type_ != "account.updated" {
return None;
}
Some(Self {
event_id: event.id.clone(),
account_id: json_str(&event.data, "id")?,
charges_enabled: json_bool(&event.data, "charges_enabled").unwrap_or(false),
payouts_enabled: json_bool(&event.data, "payouts_enabled").unwrap_or(false),
details_submitted: json_bool(&event.data, "details_submitted").unwrap_or(false),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn _assert_clone_send_sync<T: Clone + Send + Sync>() {}
fn _assert_stripe_event<T: StripeEvent>() {}
#[test]
fn events_are_clone_send_sync() {
_assert_clone_send_sync::<StripeSubscriptionUpdated>();
_assert_clone_send_sync::<StripeSubscriptionDeleted>();
_assert_clone_send_sync::<StripeCheckoutCompleted>();
_assert_clone_send_sync::<StripeCheckoutExpired>();
_assert_clone_send_sync::<StripeInvoicePaid>();
_assert_clone_send_sync::<StripePaymentIntentFailed>();
_assert_clone_send_sync::<StripePaymentIntentAmountCapturableUpdated>();
_assert_clone_send_sync::<StripePaymentIntentCanceled>();
_assert_clone_send_sync::<StripeChargeRefunded>();
_assert_clone_send_sync::<StripeChargeDisputeCreated>();
_assert_clone_send_sync::<StripeConnectAccountUpdated>();
_assert_clone_send_sync::<StripeConnectPaymentSucceeded>();
}
#[test]
fn all_event_types_implement_stripe_event() {
_assert_stripe_event::<StripeSubscriptionUpdated>();
_assert_stripe_event::<StripeSubscriptionDeleted>();
_assert_stripe_event::<StripeCheckoutCompleted>();
_assert_stripe_event::<StripeCheckoutExpired>();
_assert_stripe_event::<StripeInvoicePaid>();
_assert_stripe_event::<StripePaymentIntentFailed>();
_assert_stripe_event::<StripePaymentIntentAmountCapturableUpdated>();
_assert_stripe_event::<StripePaymentIntentCanceled>();
_assert_stripe_event::<StripeChargeRefunded>();
_assert_stripe_event::<StripeChargeDisputeCreated>();
_assert_stripe_event::<StripeConnectAccountUpdated>();
_assert_stripe_event::<StripeConnectPaymentSucceeded>();
}
#[test]
fn checkout_completed_parses_newer_api_shape_with_metadata() {
let raw = serde_json::json!({
"id": "evt_1",
"object": "event",
"api_version": "2026-05-27.dahlia",
"created": 1_700_000_000_i64,
"type": "checkout.session.completed",
"data": { "object": {
"id": "cs_test_123",
"object": "checkout.session",
"payment_status": "paid",
"status": "complete",
"amount_total": 500,
"currency": "eur",
"payment_intent": "pi_abc",
"metadata": { "order_id": "1", "tenant_id": "1" },
"some_future_field": { "nested": true }
}}
})
.to_string();
let event = WebhookEvent::from_json(&raw).expect("envelope parses");
let typed = StripeCheckoutCompleted::from_raw(&event).expect("typed event matches");
assert_eq!(typed.session_id, "cs_test_123");
assert_eq!(typed.payment_intent_id.as_deref(), Some("pi_abc"));
assert_eq!(typed.amount_total_cents, 500);
assert_eq!(typed.currency, "eur");
assert_eq!(
typed.metadata.get("order_id").map(String::as_str),
Some("1")
);
assert_eq!(
typed.metadata.get("tenant_id").map(String::as_str),
Some("1")
);
}
#[test]
fn wrong_event_type_does_not_match() {
let raw = serde_json::json!({
"id": "evt_2", "type": "invoice.paid",
"data": { "object": { "id": "in_1", "customer": "cus_1" } }
})
.to_string();
let event = WebhookEvent::from_json(&raw).unwrap();
assert!(StripeCheckoutCompleted::from_raw(&event).is_none());
assert!(StripeInvoicePaid::from_raw(&event).is_some());
}
}