//! Webhook signature verification and event extraction. //! //! rc.5 ships no webhook helper, so we keep the local HMAC `verify_signature` //! and a thin `UntypedEvent` envelope. The webhook dispatcher matches on //! `type_` and consumes `data_object` (no per-extractor clones). use hmac::{Hmac, Mac}; use sha2::Sha256; use crate::db::Cents; use crate::error::{AppError, Result}; use super::StripeClient; type HmacSha256 = Hmac; /// A Stripe webhook envelope after signature verification and JSON parsing. /// /// `data_object` is the raw `data.object` JSON value, ready to be consumed /// by `serde_json::from_value` into a typed rc.5 struct. #[derive(Debug, Clone)] pub struct UntypedEvent { pub id: String, pub type_: String, pub data_object: serde_json::Value, } impl UntypedEvent { /// Parse a JSON webhook payload. Caller must verify the signature first. pub fn from_payload(payload: &str) -> Result { let mut v: serde_json::Value = serde_json::from_str(payload).map_err(|e| { tracing::warn!(error.kind = "envelope_json", error = %e, "webhook envelope JSON parse failed"); AppError::BadRequest(format!("Webhook envelope JSON parse failed: {e}")) })?; let id = take_string(&mut v, "id").ok_or_else(|| { tracing::warn!(error.kind = "envelope_missing_field", missing = "id", "webhook envelope missing required field"); AppError::BadRequest("Webhook envelope missing required field: id".to_string()) })?; let type_ = take_string(&mut v, "type").ok_or_else(|| { tracing::warn!(error.kind = "envelope_missing_field", missing = "type", "webhook envelope missing required field"); AppError::BadRequest("Webhook envelope missing required field: type".to_string()) })?; let data_object = v.get_mut("data") .and_then(|d| d.get_mut("object")) .map(std::mem::take) .ok_or_else(|| { tracing::warn!(error.kind = "envelope_missing_field", missing = "data.object", "webhook envelope missing required field"); AppError::BadRequest("Webhook envelope missing required field: data.object".to_string()) })?; Ok(UntypedEvent { id, type_, data_object }) } } fn take_string(v: &mut serde_json::Value, key: &str) -> Option { v.get_mut(key).and_then(|s| match std::mem::take(s) { serde_json::Value::String(s) => Some(s), _ => None, }) } impl StripeClient { /// Verify the webhook signature and return the parsed envelope. /// /// Tries each configured signing secret in turn and accepts on the first /// match. We run multiple endpoints (`mnw-connect`, `mnw-you`), each with /// its own secret; signatures don't carry an endpoint id, so checking /// every secret is the only option. /// /// On failure the returned `AppError::BadRequest` body is specific enough /// to distinguish signature failures ("Invalid webhook signature: ...") from /// payload-shape failures ("Webhook envelope JSON parse failed: ...", /// "Webhook envelope missing required field: ..."). The Stripe Dashboard /// surfaces these bodies for failed webhook deliveries, so wording matters. /// Past incidents (Stripe API version mismatch producing serde /// `missing field` errors) were initially misread as signature failures. #[tracing::instrument(skip_all, name = "payments::verify_webhook")] pub fn verify_webhook(&self, payload: &str, signature: &str) -> Result { let mut last_err: Option = None; for secret in &self.config.webhook_secret { match verify_signature(payload, signature, secret) { Ok(()) => return UntypedEvent::from_payload(payload), Err(e) => last_err = Some(e), } } let reason = last_err.unwrap_or_else(|| "no signing secrets configured".to_string()); tracing::warn!(error.kind = "signature", reason = %reason, "webhook signature verification failed against all configured secrets"); Err(AppError::BadRequest(format!("Invalid webhook signature: {reason}"))) } /// Verify a v2 thin event webhook and return the parsed JSON body. /// /// See `verify_webhook` for the failure-mode taxonomy. #[tracing::instrument(skip_all, name = "payments::verify_webhook_v2")] pub fn verify_webhook_v2(&self, payload: &str, signature: &str) -> Result { let secret = self.config.webhook_secret_v2.as_deref().ok_or_else(|| { AppError::ServiceUnavailable("Stripe v2 webhook secret not configured".to_string()) })?; verify_signature(payload, signature, secret).map_err(|e| { tracing::warn!(error.kind = "signature", reason = %e, "v2 webhook signature verification failed"); AppError::BadRequest(format!("Invalid webhook signature: {e}")) })?; serde_json::from_str(payload).map_err(|e| { tracing::warn!(error.kind = "envelope_json", error = %e, "v2 webhook payload parse failed"); AppError::BadRequest(format!("Webhook payload JSON parse failed: {e}")) }) } } /// Narrow view of a CheckoutSession: only the fields any handler reads. /// /// Built ad-hoc rather than via `stripe_shared::CheckoutSession` to stay /// resilient against new required fields Stripe adds. The original migration /// bug was caused by an over-strict typed struct. #[derive(Debug, Default, serde::Deserialize)] pub struct CheckoutSessionView { pub id: String, #[serde(default)] pub metadata: Option>, #[serde(default, deserialize_with = "deserialize_expandable_id")] pub payment_intent: Option, #[serde(default, deserialize_with = "deserialize_expandable_id")] pub subscription: Option, #[serde(default, deserialize_with = "deserialize_expandable_id")] pub customer: Option, #[serde(default)] pub customer_details: Option, /// Pre-tax line-item total (cents) Stripe computed for the session. Used /// only as a defense-in-depth reconciliation against our server-built line /// items; absent on older/edge events, hence `Option`. #[serde(default)] pub amount_subtotal: Option, } #[derive(Debug, Default, serde::Deserialize)] pub struct CheckoutCustomerDetailsView { pub email: Option, } /// Narrow view of a Subscription: id, status, cancellation flag, and the /// item-level period fields rc.5 promoted from the top level. #[derive(Debug, serde::Deserialize)] pub struct SubscriptionView { pub id: String, pub status: String, #[serde(default)] pub cancel_at_period_end: bool, #[serde(default)] pub items: SubscriptionItemList, } impl SubscriptionView { /// Period from `items.data[0]` (rc.5 moved these off the top-level Subscription). pub fn current_period(&self) -> Option<(i64, i64)> { self.items.data.first().map(|it| (it.current_period_start, it.current_period_end)) } } #[derive(Debug, Default, serde::Deserialize)] pub struct SubscriptionItemList { #[serde(default)] pub data: Vec, } #[derive(Debug, serde::Deserialize)] pub struct SubscriptionItemView { #[serde(default)] pub current_period_start: i64, #[serde(default)] pub current_period_end: i64, } /// Narrow view of an Invoice: subscription id (via legacy `subscription` or /// the rc.5 `parent.subscription_details.subscription` path), period bounds, /// and billing reason. #[derive(Debug, serde::Deserialize)] pub struct InvoiceView { #[serde(default)] pub period_start: i64, #[serde(default)] pub period_end: i64, #[serde(default)] pub billing_reason: Option, #[serde(default, deserialize_with = "deserialize_expandable_id")] pub subscription: Option, #[serde(default)] pub parent: Option, } impl InvoiceView { /// Pull the subscription id from either the legacy or new field path. pub fn subscription_id(&self) -> Option<&str> { if let Some(s) = &self.subscription { return Some(s.as_str()); } self.parent.as_ref()? .subscription_details.as_ref()? .subscription.as_deref() } pub fn is_renewal(&self) -> bool { self.billing_reason.as_deref() == Some("subscription_cycle") } } #[derive(Debug, serde::Deserialize)] pub struct InvoiceParentView { #[serde(default)] pub subscription_details: Option, } #[derive(Debug, serde::Deserialize)] pub struct InvoiceSubscriptionDetailsView { #[serde(default, deserialize_with = "deserialize_expandable_id")] pub subscription: Option, } /// Stripe expandable fields are either a bare id string or a full object with /// an `id` field. Pluck the id either way. fn deserialize_expandable_id<'de, D>(deserializer: D) -> std::result::Result, D::Error> where D: serde::Deserializer<'de> { use serde::Deserialize; let v = serde_json::Value::deserialize(deserializer)?; Ok(match v { serde_json::Value::Null => None, serde_json::Value::String(s) => Some(s), serde_json::Value::Object(mut map) => match map.remove("id") { Some(serde_json::Value::String(s)) => Some(s), _ => None, }, _ => None, }) } /// Account update fields the dispatcher hands to the handler. #[derive(Debug)] pub struct AccountUpdate { pub account_id: String, pub charges_enabled: bool, pub payouts_enabled: bool, pub details_submitted: bool, } impl From for AccountUpdate { fn from(a: stripe_shared::Account) -> Self { AccountUpdate { account_id: a.id.to_string(), charges_enabled: a.charges_enabled.unwrap_or(false), payouts_enabled: a.payouts_enabled.unwrap_or(false), details_submitted: a.details_submitted.unwrap_or(false), } } } /// Narrow view of an Account: only the fields we react to. #[derive(Debug, serde::Deserialize)] pub struct AccountView { pub id: String, #[serde(default)] pub charges_enabled: bool, #[serde(default)] pub payouts_enabled: bool, #[serde(default)] pub details_submitted: bool, } impl From for AccountUpdate { fn from(a: AccountView) -> Self { AccountUpdate { account_id: a.id, charges_enabled: a.charges_enabled, payouts_enabled: a.payouts_enabled, details_submitted: a.details_submitted, } } } /// Narrow view of a Charge for refund processing. #[derive(Debug, serde::Deserialize)] pub struct ChargeView { #[serde(default)] pub amount: i64, #[serde(default)] pub amount_refunded: i64, #[serde(default, deserialize_with = "deserialize_expandable_id")] pub payment_intent: Option, } /// Data extracted from a charge.refunded webhook event. #[derive(Debug)] pub struct ChargeRefundData { pub payment_intent_id: String, pub amount: Cents, pub amount_refunded: Cents, } impl ChargeRefundData { pub fn is_full_refund(&self) -> bool { // Require `amount > 0` so $0 verification charges (which Stripe occasionally // emits with `amount=0, amount_refunded=0`) are not treated as full refunds — // that previously triggered `refund_transaction_by_payment_intent` with a // default `unknown` intent ID. self.amount > Cents::new(0) && self.amount_refunded >= self.amount } /// Build from a parsed charge view. Returns None when there is no /// payment_intent; these events are out of scope here. pub fn from_view(charge: ChargeView) -> Option { Some(ChargeRefundData { payment_intent_id: charge.payment_intent?, amount: Cents::new(charge.amount), amount_refunded: Cents::new(charge.amount_refunded), }) } } // --------------------------------------------------------------------------- // v2 thin event types // --------------------------------------------------------------------------- /// A Stripe v2 "thin" event: contains only the event type and a reference to /// the related object, not the full object snapshot. #[derive(Debug, serde::Deserialize)] pub struct ThinEvent { pub id: String, #[serde(rename = "type")] pub event_type: String, pub related_object: Option, } /// Reference to the object that triggered a v2 event. #[derive(Debug, serde::Deserialize)] pub struct RelatedObject { pub id: String, #[serde(rename = "type")] pub object_type: String, } /// Verify a Stripe webhook signature (v1 scheme, shared by v1 and v2 endpoints). /// /// Parses `t={ts},v1={hex}`, computes HMAC-SHA256 over `{ts}.{payload}`, and /// compares in constant time. Rejects timestamps outside the configured /// tolerance to prevent replay attacks. pub fn verify_signature(payload: &str, header: &str, secret: &str) -> std::result::Result<(), String> { let mut timestamp = None; // Stripe emits a `v1=` value per active secret during rotation; collect // them all and accept if any matches. The previous single-Option only // kept the last value parsed, which silently broke rotation. let mut signatures: Vec<&str> = Vec::new(); for part in header.split(',') { if let Some(t) = part.strip_prefix("t=") { timestamp = Some(t); } else if let Some(s) = part.strip_prefix("v1=") { signatures.push(s); } } let timestamp = timestamp.ok_or("missing timestamp in signature header")?; if signatures.is_empty() { return Err("missing v1 signature in header".to_string()); } let ts_secs: u64 = timestamp.parse().map_err(|_| "invalid timestamp")?; let now_secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|_| "system clock error")? .as_secs(); let tolerance = crate::constants::WEBHOOK_TIMESTAMP_TOLERANCE_SECS; if now_secs > ts_secs && now_secs - ts_secs > tolerance { return Err("timestamp too old".to_string()); } if ts_secs > now_secs && ts_secs - now_secs > tolerance { return Err("timestamp too far in the future".to_string()); } let signed_payload = format!("{}.{}", timestamp, payload); let mut last_err = "signature mismatch".to_string(); for expected_sig in &signatures { let expected_bytes = match hex::decode(expected_sig) { Ok(b) => b, Err(_) => { last_err = "invalid hex in v1 signature".to_string(); continue; } }; let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) .map_err(|_| "invalid HMAC key")?; mac.update(signed_payload.as_bytes()); if mac.verify_slice(&expected_bytes).is_ok() { return Ok(()); } } Err(last_err) } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn parse_envelope_extracts_id_type_and_object() { let payload = r#"{"id":"evt_1","type":"checkout.session.completed","data":{"object":{"id":"cs_1"}}}"#; let evt = UntypedEvent::from_payload(payload).unwrap(); assert_eq!(evt.id, "evt_1"); assert_eq!(evt.type_, "checkout.session.completed"); assert_eq!(evt.data_object["id"], "cs_1"); } #[test] fn parse_envelope_missing_data_object_errors() { assert!(UntypedEvent::from_payload(r#"{"id":"x","type":"y"}"#).is_err()); } #[test] fn parse_envelope_error_messages_name_the_field() { // Each failure mode should produce a body distinct enough that a future // debugger reading Stripe Dashboard or our error logs knows exactly // what was wrong, rather than a generic "Invalid webhook signature". let missing_id = UntypedEvent::from_payload(r#"{"type":"t","data":{"object":{}}}"#).unwrap_err(); assert!(format!("{:?}", missing_id).contains("id"), "got: {:?}", missing_id); let missing_type = UntypedEvent::from_payload(r#"{"id":"i","data":{"object":{}}}"#).unwrap_err(); assert!(format!("{:?}", missing_type).contains("type"), "got: {:?}", missing_type); let missing_obj = UntypedEvent::from_payload(r#"{"id":"i","type":"t"}"#).unwrap_err(); assert!(format!("{:?}", missing_obj).contains("data.object"), "got: {:?}", missing_obj); let bad_json = UntypedEvent::from_payload(r#"not json"#).unwrap_err(); assert!(format!("{:?}", bad_json).contains("parse failed"), "got: {:?}", bad_json); } // CheckoutSession parses from a real captured webhook fixture. #[test] fn checkout_session_parses_from_fixture() { let raw = include_str!("../../tests/fixtures/webhooks/checkout.session.completed.connect.json"); let evt = UntypedEvent::from_payload(raw).unwrap(); let session: stripe_shared::CheckoutSession = serde_json::from_value(evt.data_object).unwrap(); assert_eq!(session.mode, stripe_shared::CheckoutSessionMode::Payment); } // Subscription parses with current_period_* on items.data[0]. #[test] fn subscription_parses_from_fixture_with_items_period() { let raw = include_str!("../../tests/fixtures/webhooks/customer.subscription.updated.json"); let evt = UntypedEvent::from_payload(raw).unwrap(); let sub: stripe_shared::Subscription = serde_json::from_value(evt.data_object).unwrap(); let item = sub.items.data.first().expect("subscription has at least one item"); assert!(item.current_period_start > 0); assert!(item.current_period_end > item.current_period_start); } // Invoice carries the new parent.subscription_details shape. #[test] fn invoice_parses_from_fixture() { let raw = include_str!("../../tests/fixtures/webhooks/invoice.payment_succeeded.json"); let evt = UntypedEvent::from_payload(raw).unwrap(); let inv: stripe_shared::Invoice = serde_json::from_value(evt.data_object).unwrap(); assert!(inv.period_start > 0); } #[test] fn account_update_conversion() { let a: stripe_shared::Account = serde_json::from_value(json!({ "id": "acct_test123", "object": "account", "charges_enabled": true, "payouts_enabled": true, "details_submitted": true, })).unwrap(); let u: AccountUpdate = a.into(); assert_eq!(u.account_id, "acct_test123"); assert!(u.charges_enabled); assert!(u.payouts_enabled); assert!(u.details_submitted); } #[test] fn account_update_defaults_to_false_when_missing() { let a: stripe_shared::Account = serde_json::from_value(json!({ "id": "acct_x", "object": "account", })).unwrap(); let u: AccountUpdate = a.into(); assert!(!u.charges_enabled); assert!(!u.payouts_enabled); assert!(!u.details_submitted); } // ChargeRefundData::from_charge JSON-roundtrip is covered by integration // tests against real `charge.refunded` payloads — rc.5's `Charge` struct // has ~30 non-Optional fields which makes hand-constructing a minimal one // brittle. is_full_refund_* tests below pin the predicate semantics. #[test] fn is_full_refund_boundary() { let exactly = ChargeRefundData { payment_intent_id: "pi_a".to_string(), amount: Cents::new(1000), amount_refunded: Cents::new(1000), }; assert!(exactly.is_full_refund()); let one_under = ChargeRefundData { payment_intent_id: "pi_b".to_string(), amount: Cents::new(1000), amount_refunded: Cents::new(999), }; assert!(!one_under.is_full_refund()); } #[test] fn is_full_refund_over_refunded_still_full() { let over = ChargeRefundData { payment_intent_id: "pi_c".to_string(), amount: Cents::new(1000), amount_refunded: Cents::new(1500), }; assert!(over.is_full_refund()); } #[test] fn is_full_refund_zero_amount_is_not_full() { // Stripe sometimes emits `charge.refunded` events with amount=0 for $0 // verification charges. Treating those as full refunds previously // triggered `refund_transaction_by_payment_intent("unknown")`. let zero = ChargeRefundData { payment_intent_id: "pi_d".to_string(), amount: Cents::new(0), amount_refunded: Cents::new(0), }; assert!(!zero.is_full_refund()); } // --- verify_signature --- fn sign_at(payload: &str, secret: &str, timestamp: u64) -> String { use hmac::Mac; let signed_payload = format!("{}.{}", timestamp, payload); let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); mac.update(signed_payload.as_bytes()); let hex_sig = hex::encode(mac.finalize().into_bytes()); format!("t={},v1={}", timestamp, hex_sig) } fn now_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() } #[test] fn verify_signature_valid_current() { let header = sign_at(r#"{"id":"evt_1"}"#, "whsec_test", now_secs()); assert!(verify_signature(r#"{"id":"evt_1"}"#, &header, "whsec_test").is_ok()); } #[test] fn verify_signature_rejected_stale_timestamp() { let header = sign_at(r#"{"id":"evt_3"}"#, "whsec_test", now_secs() - 600); let err = verify_signature(r#"{"id":"evt_3"}"#, &header, "whsec_test").unwrap_err(); assert!(err.contains("timestamp too old"), "got: {}", err); } #[test] fn verify_signature_rejected_future_timestamp() { let header = sign_at(r#"{"id":"evt_4"}"#, "whsec_test", now_secs() + 600); let err = verify_signature(r#"{"id":"evt_4"}"#, &header, "whsec_test").unwrap_err(); assert!(err.contains("future"), "got: {}", err); } #[test] fn verify_signature_accepted_within_tolerance() { let header = sign_at(r#"{"id":"evt_5"}"#, "whsec_test", now_secs() - 240); assert!(verify_signature(r#"{"id":"evt_5"}"#, &header, "whsec_test").is_ok()); } #[test] fn verify_signature_wrong_secret() { let header = sign_at(r#"{"id":"evt_6"}"#, "whsec_test", now_secs()); let err = verify_signature(r#"{"id":"evt_6"}"#, &header, "wrong").unwrap_err(); assert!(err.contains("mismatch"), "got: {}", err); } }