use ferro_events::Event;
#[derive(Debug, Clone)]
pub struct StripeSubscriptionUpdated {
pub event_json: String,
pub subscription_id: String,
pub customer_id: String,
}
impl Event for StripeSubscriptionUpdated {
fn name(&self) -> &'static str {
"stripe.customer.subscription.updated"
}
}
#[derive(Debug, Clone)]
pub struct StripeSubscriptionDeleted {
pub event_json: String,
pub subscription_id: String,
pub customer_id: String,
}
impl Event for StripeSubscriptionDeleted {
fn name(&self) -> &'static str {
"stripe.customer.subscription.deleted"
}
}
#[derive(Debug, Clone)]
pub struct StripeCheckoutCompleted {
pub event_json: String,
pub session_id: String,
pub customer_id: Option<String>,
}
impl Event for StripeCheckoutCompleted {
fn name(&self) -> &'static str {
"stripe.checkout.session.completed"
}
}
#[derive(Debug, Clone)]
pub struct StripeInvoicePaid {
pub event_json: String,
pub invoice_id: String,
pub customer_id: String,
}
impl Event for StripeInvoicePaid {
fn name(&self) -> &'static str {
"stripe.invoice.paid"
}
}
#[derive(Debug, Clone)]
pub struct StripeConnectPaymentSucceeded {
pub event_json: String,
pub payment_intent_id: String,
pub connect_account_id: String,
}
impl Event for StripeConnectPaymentSucceeded {
fn name(&self) -> &'static str {
"stripe.connect.payment_intent.succeeded"
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProcessStripeWebhook {
pub event_type: String,
pub event_json: String,
pub connect_account_id: Option<String>,
}
#[ferro_queue::async_trait]
impl ferro_queue::Job for ProcessStripeWebhook {
async fn handle(&self) -> Result<(), ferro_queue::Error> {
match self.event_type.as_str() {
"customer.subscription.updated" => {
if let Some(event) = parse_subscription_updated(&self.event_json) {
event.dispatch_sync();
}
}
"customer.subscription.deleted" => {
if let Some(event) = parse_subscription_deleted(&self.event_json) {
event.dispatch_sync();
}
}
"checkout.session.completed" => {
if let Some(event) = parse_checkout_completed(&self.event_json) {
event.dispatch_sync();
}
}
"invoice.paid" => {
if let Some(event) = parse_invoice_paid(&self.event_json) {
event.dispatch_sync();
}
}
"payment_intent.succeeded" => {
if let Some(connect_id) = self.connect_account_id.clone() {
if let Some(event) =
parse_connect_payment_succeeded(&self.event_json, connect_id)
{
event.dispatch_sync();
}
}
}
_ => {}
}
Ok(())
}
fn name(&self) -> &'static str {
"ProcessStripeWebhook"
}
}
fn parse_subscription_updated(event_json: &str) -> Option<StripeSubscriptionUpdated> {
let v: serde_json::Value = serde_json::from_str(event_json).ok()?;
let sub = v.get("data")?.get("object")?;
let subscription_id = sub.get("id")?.as_str()?.to_string();
let customer_id = sub.get("customer")?.as_str()?.to_string();
Some(StripeSubscriptionUpdated {
event_json: event_json.to_string(),
subscription_id,
customer_id,
})
}
fn parse_subscription_deleted(event_json: &str) -> Option<StripeSubscriptionDeleted> {
let v: serde_json::Value = serde_json::from_str(event_json).ok()?;
let sub = v.get("data")?.get("object")?;
let subscription_id = sub.get("id")?.as_str()?.to_string();
let customer_id = sub.get("customer")?.as_str()?.to_string();
Some(StripeSubscriptionDeleted {
event_json: event_json.to_string(),
subscription_id,
customer_id,
})
}
fn parse_checkout_completed(event_json: &str) -> Option<StripeCheckoutCompleted> {
let v: serde_json::Value = serde_json::from_str(event_json).ok()?;
let session = v.get("data")?.get("object")?;
let session_id = session.get("id")?.as_str()?.to_string();
let customer_id = session
.get("customer")
.and_then(|c| c.as_str())
.map(|s| s.to_string());
Some(StripeCheckoutCompleted {
event_json: event_json.to_string(),
session_id,
customer_id,
})
}
fn parse_invoice_paid(event_json: &str) -> Option<StripeInvoicePaid> {
let v: serde_json::Value = serde_json::from_str(event_json).ok()?;
let invoice = v.get("data")?.get("object")?;
let invoice_id = invoice.get("id")?.as_str()?.to_string();
let customer_id = invoice.get("customer")?.as_str()?.to_string();
Some(StripeInvoicePaid {
event_json: event_json.to_string(),
invoice_id,
customer_id,
})
}
fn parse_connect_payment_succeeded(
event_json: &str,
connect_account_id: String,
) -> Option<StripeConnectPaymentSucceeded> {
let v: serde_json::Value = serde_json::from_str(event_json).ok()?;
let pi = v.get("data")?.get("object")?;
let payment_intent_id = pi.get("id")?.as_str()?.to_string();
Some(StripeConnectPaymentSucceeded {
event_json: event_json.to_string(),
payment_intent_id,
connect_account_id,
})
}
pub fn signed_webhook_payload(payload: &str, secret: &str) -> (String, i64) {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let timestamp = chrono::Utc::now().timestamp();
let signed_payload = format!("{timestamp}.{payload}");
let mut mac =
Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
mac.update(signed_payload.as_bytes());
let result = mac.finalize();
let signature = hex::encode(result.into_bytes());
let header = format!("t={timestamp},v1={signature}");
(header, timestamp)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subscription_updated_event_name() {
let event = StripeSubscriptionUpdated {
event_json: "{}".to_string(),
subscription_id: "sub_123".to_string(),
customer_id: "cus_123".to_string(),
};
assert_eq!(event.name(), "stripe.customer.subscription.updated");
}
#[test]
fn subscription_deleted_event_name() {
let event = StripeSubscriptionDeleted {
event_json: "{}".to_string(),
subscription_id: "sub_123".to_string(),
customer_id: "cus_123".to_string(),
};
assert_eq!(event.name(), "stripe.customer.subscription.deleted");
}
#[test]
fn checkout_completed_event_name() {
let event = StripeCheckoutCompleted {
event_json: "{}".to_string(),
session_id: "cs_123".to_string(),
customer_id: None,
};
assert_eq!(event.name(), "stripe.checkout.session.completed");
}
#[test]
fn invoice_paid_event_name() {
let event = StripeInvoicePaid {
event_json: "{}".to_string(),
invoice_id: "in_123".to_string(),
customer_id: "cus_123".to_string(),
};
assert_eq!(event.name(), "stripe.invoice.paid");
}
#[test]
fn connect_payment_succeeded_event_name() {
let event = StripeConnectPaymentSucceeded {
event_json: "{}".to_string(),
payment_intent_id: "pi_123".to_string(),
connect_account_id: "acct_123".to_string(),
};
assert_eq!(event.name(), "stripe.connect.payment_intent.succeeded");
}
fn _assert_clone_send_sync<T: Clone + Send + Sync>() {}
#[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::<StripeInvoicePaid>();
_assert_clone_send_sync::<StripeConnectPaymentSucceeded>();
}
#[test]
fn signed_webhook_payload_generates_valid_signature() {
let payload = r#"{"id":"evt_test","type":"invoice.paid"}"#;
let (sig, _ts) = signed_webhook_payload(payload, "whsec_test");
assert!(sig.starts_with("t="), "signature should start with t=");
assert!(sig.contains(",v1="), "signature should contain ,v1=");
}
#[test]
fn process_stripe_webhook_job_name() {
let job = ProcessStripeWebhook {
event_type: "invoice.paid".to_string(),
event_json: "{}".to_string(),
connect_account_id: None,
};
use ferro_queue::Job;
assert_eq!(job.name(), "ProcessStripeWebhook");
}
}