//! Stripe v2 thin event webhook handler. //! //! Stripe's v2 event system sends "thin" events that contain only a reference //! to the affected object, not the full snapshot. The handler verifies the //! signature, parses the event type, fetches the full object via the API, and //! delegates to the same business logic used by the v1 handler. use axum::{ body::Bytes, extract::State, http::{header::HeaderMap, StatusCode}, response::IntoResponse, }; use crate::{ db, error::{AppError, Result}, payments::{self, ThinEvent}, AppState, }; /// POST /stripe/webhook/v2: Handle Stripe v2 thin events #[tracing::instrument(skip_all, name = "stripe::webhook_v2")] pub(super) async fn webhook_v2( State(state): State, headers: HeaderMap, body: Bytes, ) -> Result { let stripe = state.stripe.as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; let signature = headers .get("stripe-signature") .and_then(|v| v.to_str().ok()) .ok_or_else(|| AppError::BadRequest("Missing Stripe signature".to_string()))?; let payload = std::str::from_utf8(&body) .map_err(|_| AppError::BadRequest("Invalid payload encoding".to_string()))?; // Verify signature and parse JSON let body_json = stripe.verify_webhook_v2(payload, signature)?; // Parse the thin event let thin: ThinEvent = serde_json::from_value(body_json).map_err(|e| { tracing::warn!(error = ?e, "failed to parse v2 thin event"); AppError::BadRequest("Invalid v2 event format".to_string()) })?; tracing::info!(event_type = %thin.event_type, event_id = %thin.id, "received v2 thin event"); // Deduplicate: skip if this event was already processed match db::webhook_events::try_mark_event_processed(&state.db, &thin.id).await { Ok(true) => {} // first time — proceed Ok(false) => { tracing::debug!(event_id = %thin.id, "v2 event already processed, skipping"); return Ok(StatusCode::OK); } Err(e) => { // Return 503 so Stripe retries later (matching v1 webhook behavior) tracing::error!(event_id = %thin.id, error = ?e, "v2 dedup check failed, returning 503 for retry"); return Ok(StatusCode::SERVICE_UNAVAILABLE); } } // Route by event type if thin.event_type.starts_with("v2.core.account") { if let Err(e) = handle_account_thin_event(&state, stripe.as_ref(), &thin).await { // Unmark so Stripe retries delivery (retries for up to 3 days) tracing::warn!(event_id = %thin.id, error = ?e, "v2 event processing failed, unmarking for retry"); let _ = db::webhook_events::unmark_event_processed(&state.db, &thin.id).await; return Err(e); } } else { tracing::debug!(event_type = %thin.event_type, "unhandled v2 event type"); } Ok(StatusCode::OK) } /// Fetch the full account object and delegate to the shared account-updated handler. async fn handle_account_thin_event( state: &AppState, stripe: &dyn payments::PaymentProvider, thin: &ThinEvent, ) -> Result<()> { let account_id = match &thin.related_object { Some(obj) => &obj.id, None => { tracing::warn!(event_id = %thin.id, "v2 account event missing related_object"); return Ok(()); // nothing to fetch — acknowledge } }; let update = stripe.fetch_account(account_id).await.map_err(|e| { tracing::warn!(account_id = %account_id, error = ?e, "failed to fetch account for v2 event"); e })?; super::webhook::handle_account_updated_from_v2(state, &update).await.map_err(|e| { tracing::warn!(account_id = %account_id, error = ?e, "failed to process account update from v2 event"); e })?; Ok(()) }