Skip to main content

max / makenotwork

7.3 KB · 187 lines History Blame Raw
1 //! Stripe v2 thin event webhook handler.
2 //!
3 //! Stripe's v2 event system sends "thin" events that contain only a reference
4 //! to the affected object, not the full snapshot. The handler verifies the
5 //! signature, parses the event type, fetches the full object via the API, and
6 //! delegates to the same business logic used by the v1 handler.
7
8 use axum::{
9 body::Bytes,
10 extract::State,
11 http::{StatusCode, header::HeaderMap},
12 response::IntoResponse,
13 };
14 use sqlx::PgPool;
15
16 use crate::{
17 Billing, Integrations, db,
18 error::{AppError, Result},
19 payments::{self, ThinEvent},
20 wam_client::WamClient,
21 };
22
23 /// POST /stripe/webhook/v2: Handle Stripe v2 thin events
24 #[tracing::instrument(skip_all, name = "stripe::webhook_v2")]
25 pub(super) async fn webhook_v2(
26 State(db): State<PgPool>,
27 State(integrations): State<Integrations>,
28 State(payments): State<Billing>,
29 State(config): State<crate::config::Config>,
30 headers: HeaderMap,
31 body: Bytes,
32 ) -> Result<impl IntoResponse> {
33 let stripe = payments
34 .stripe
35 .as_ref()
36 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
37
38 let signature = headers
39 .get("stripe-signature")
40 .and_then(|v| v.to_str().ok())
41 .ok_or_else(|| AppError::BadRequest("Missing Stripe signature".to_string()))?;
42
43 let payload = std::str::from_utf8(&body)
44 .map_err(|_| AppError::BadRequest("Invalid payload encoding".to_string()))?;
45
46 // Verify signature and parse JSON. A failure here has no benign cause:
47 // Stripe signs correctly, so it means a wrong signing secret (real events
48 // being dropped) or forged events aimed at the billing path.
49 let body_json = stripe
50 .verify_webhook_v2(payload, signature)
51 .inspect_err(|_| {
52 crate::security_signals::note_webhook_signature_failure("stripe");
53 })?;
54
55 // Parse the thin event
56 let thin: ThinEvent = serde_json::from_value(body_json).map_err(|e| {
57 tracing::warn!(error = ?e, "failed to parse v2 thin event");
58 AppError::BadRequest("Invalid v2 event format".to_string())
59 })?;
60
61 tracing::info!(event_type = %thin.event_type, event_id = %thin.id, "received v2 thin event");
62
63 // Serialize concurrent redeliveries of this event id. Same lock discipline as
64 // the v1 handler; the rationale is documented once, on `webhook::webhook`.
65 let _event_lock = match db::webhook_events::try_lock_event(&db, &thin.id).await {
66 Ok(Some(tx)) => tx,
67 Ok(None) => {
68 tracing::info!(event_id = %thin.id, "concurrent delivery of this v2 event is in flight; returning 503 for redelivery");
69 return Ok(StatusCode::SERVICE_UNAVAILABLE);
70 }
71 Err(e) => {
72 tracing::error!(event_id = %thin.id, error = ?e, "failed to acquire v2 webhook event lock, returning 503 for retry");
73 return Ok(StatusCode::SERVICE_UNAVAILABLE);
74 }
75 };
76
77 // Deduplicate: skip if this event was already processed. Same read-then-mark
78 // discipline as v1, documented on `webhook::webhook`.
79 match db::webhook_events::is_event_processed(&db, &thin.id).await {
80 Ok(true) => {
81 tracing::debug!(event_id = %thin.id, "v2 event already processed, skipping");
82 return Ok(StatusCode::OK);
83 }
84 Err(e) => {
85 // Return 503 so Stripe retries later (matching v1 webhook behavior)
86 tracing::error!(event_id = %thin.id, error = ?e, "v2 dedup check failed, returning 503 for retry");
87 return Ok(StatusCode::SERVICE_UNAVAILABLE);
88 }
89 Ok(false) => {} // first time, proceed
90 }
91
92 if let Err(e) = process_v2_thin_event(
93 &db,
94 integrations.wam.as_ref(),
95 stripe.as_ref(),
96 &config.signing_secret,
97 &thin,
98 )
99 .await
100 {
101 // Persist to the local retry queue (backoff + dead-letter WAM via the
102 // scheduler), matching v1's failure path rather than relying solely on
103 // Stripe's redelivery window. Not marked processed, so a Stripe
104 // redelivery also still re-runs the idempotent handler.
105 //
106 // Deliberate trade (fuzz 2026-07-06, Payments): returning 200 below ACKs
107 // the event to Stripe, so Stripe will not redeliver it on its own 3-day
108 // schedule, the in-house queue + scheduler owns retry from here (richer:
109 // local backoff + dead-lettering). The operational consequence is that
110 // for a persistently-failing money event, **scheduler liveness is the
111 // only retry backstop**; if the scheduler is down, a failed webhook has no
112 // external redelivery. The `insert_failed_event` guard below returns 503
113 // (inviting Stripe redelivery) if even the queue insert fails, so the net
114 // is never "silently dropped".
115 tracing::warn!(event_id = %thin.id, error = ?e, "v2 event processing failed; queueing for retry");
116 if let Err(queue_err) = db::webhook_events::insert_failed_event(
117 &db,
118 "stripe_v2",
119 &thin.event_type,
120 payload,
121 Some(signature),
122 &format!("{e:?}"),
123 )
124 .await
125 {
126 tracing::error!(event_id = %thin.id, error = ?queue_err, "failed to queue v2 event for retry; returning 503 for Stripe redelivery");
127 return Ok(StatusCode::SERVICE_UNAVAILABLE);
128 }
129 return Ok(StatusCode::OK);
130 }
131
132 // Succeeded, record it so a redelivery short-circuits.
133 if let Err(e) = db::webhook_events::mark_event_processed(&db, &thin.id).await {
134 tracing::error!(event_id = %thin.id, error = ?e, "failed to record processed v2 event; returning 503 for redelivery");
135 return Ok(StatusCode::SERVICE_UNAVAILABLE);
136 }
137
138 Ok(StatusCode::OK)
139 }
140
141 /// Route a verified v2 thin event to its handler. Shared by the live webhook and
142 /// the scheduler retry worker (which re-parses the stored payload, the
143 /// signature was already verified when the event was first received).
144 pub(crate) async fn process_v2_thin_event(
145 db: &PgPool,
146 wam: Option<&WamClient>,
147 stripe: &dyn payments::PaymentProvider,
148 signing_secret: &str,
149 thin: &ThinEvent,
150 ) -> Result<()> {
151 if thin.event_type.starts_with("v2.core.account") {
152 handle_account_thin_event(db, wam, stripe, signing_secret, thin).await
153 } else {
154 tracing::debug!(event_type = %thin.event_type, "unhandled v2 event type");
155 Ok(())
156 }
157 }
158
159 /// Fetch the full account object and delegate to the shared account-updated handler.
160 async fn handle_account_thin_event(
161 db: &PgPool,
162 wam: Option<&WamClient>,
163 stripe: &dyn payments::PaymentProvider,
164 signing_secret: &str,
165 thin: &ThinEvent,
166 ) -> Result<()> {
167 let account_id = match &thin.related_object {
168 Some(obj) => &obj.id,
169 None => {
170 tracing::warn!(event_id = %thin.id, "v2 account event missing related_object");
171 return Ok(()); // nothing to fetch, acknowledge
172 }
173 };
174
175 let update = stripe.fetch_account(account_id).await.map_err(|e| {
176 tracing::warn!(account_id = %account_id, error = ?e, "failed to fetch account for v2 event");
177 e
178 })?;
179
180 super::webhook::handle_account_updated_from_v2(db, wam, signing_secret, &update).await.map_err(|e| {
181 tracing::warn!(account_id = %account_id, error = ?e, "failed to process account update from v2 event");
182 e
183 })?;
184
185 Ok(())
186 }
187