Skip to main content

max / makenotwork

7.3 KB · 178 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 headers: HeaderMap,
30 body: Bytes,
31 ) -> Result<impl IntoResponse> {
32 let stripe = payments
33 .stripe
34 .as_ref()
35 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
36
37 let signature = headers
38 .get("stripe-signature")
39 .and_then(|v| v.to_str().ok())
40 .ok_or_else(|| AppError::BadRequest("Missing Stripe signature".to_string()))?;
41
42 let payload = std::str::from_utf8(&body)
43 .map_err(|_| AppError::BadRequest("Invalid payload encoding".to_string()))?;
44
45 // Verify signature and parse JSON
46 let body_json = stripe.verify_webhook_v2(payload, signature)?;
47
48 // Parse the thin event
49 let thin: ThinEvent = serde_json::from_value(body_json).map_err(|e| {
50 tracing::warn!(error = ?e, "failed to parse v2 thin event");
51 AppError::BadRequest("Invalid v2 event format".to_string())
52 })?;
53
54 tracing::info!(event_type = %thin.event_type, event_id = %thin.id, "received v2 thin event");
55
56 // Serialize concurrent redeliveries of this event id (see
57 // `db::webhook_events::try_lock_event`). Held across dedup-read -> process ->
58 // mark, it makes the check-then-act read below race-free. We *try* the lock
59 // rather than block: a same-event delivery arriving mid-flight gets `None` and
60 // returns 503 immediately instead of parking a pooled connection (Run 23
61 // Conc/Perf). Dropping `_event_lock` on any return releases the lock.
62 let _event_lock = match db::webhook_events::try_lock_event(&db, &thin.id).await {
63 Ok(Some(tx)) => tx,
64 Ok(None) => {
65 tracing::info!(event_id = %thin.id, "concurrent delivery of this v2 event is in flight; returning 503 for redelivery");
66 return Ok(StatusCode::SERVICE_UNAVAILABLE);
67 }
68 Err(e) => {
69 tracing::error!(event_id = %thin.id, error = ?e, "failed to acquire v2 webhook event lock, returning 503 for retry");
70 return Ok(StatusCode::SERVICE_UNAVAILABLE);
71 }
72 };
73
74 // Deduplicate: skip if this event was already processed. Read-only; the
75 // "processed" row is written only after the handler succeeds (below), so a
76 // crash mid-processing leaves no marker and Stripe redelivers (the handler
77 // is idempotent, it re-fetches and re-applies account state).
78 match db::webhook_events::is_event_processed(&db, &thin.id).await {
79 Ok(true) => {
80 tracing::debug!(event_id = %thin.id, "v2 event already processed, skipping");
81 return Ok(StatusCode::OK);
82 }
83 Err(e) => {
84 // Return 503 so Stripe retries later (matching v1 webhook behavior)
85 tracing::error!(event_id = %thin.id, error = ?e, "v2 dedup check failed, returning 503 for retry");
86 return Ok(StatusCode::SERVICE_UNAVAILABLE);
87 }
88 Ok(false) => {} // first time, proceed
89 }
90
91 if let Err(e) =
92 process_v2_thin_event(&db, integrations.wam.as_ref(), stripe.as_ref(), &thin).await
93 {
94 // Persist to the local retry queue (backoff + dead-letter WAM via the
95 // scheduler), matching v1's failure path rather than relying solely on
96 // Stripe's redelivery window. Not marked processed, so a Stripe
97 // redelivery also still re-runs the idempotent handler.
98 //
99 // Deliberate trade (fuzz 2026-07-06, Payments): returning 200 below ACKs
100 // the event to Stripe, so Stripe will not redeliver it on its own 3-day
101 // schedule, the in-house queue + scheduler owns retry from here (richer:
102 // local backoff + dead-lettering). The operational consequence is that
103 // for a persistently-failing money event, **scheduler liveness is the
104 // only retry backstop**; if the scheduler is down, a failed webhook has no
105 // external redelivery. The `insert_failed_event` guard below returns 503
106 // (inviting Stripe redelivery) if even the queue insert fails, so the net
107 // is never "silently dropped".
108 tracing::warn!(event_id = %thin.id, error = ?e, "v2 event processing failed; queueing for retry");
109 if let Err(queue_err) = db::webhook_events::insert_failed_event(
110 &db,
111 "stripe_v2",
112 &thin.event_type,
113 payload,
114 Some(signature),
115 &format!("{e:?}"),
116 )
117 .await
118 {
119 tracing::error!(event_id = %thin.id, error = ?queue_err, "failed to queue v2 event for retry; returning 503 for Stripe redelivery");
120 return Ok(StatusCode::SERVICE_UNAVAILABLE);
121 }
122 return Ok(StatusCode::OK);
123 }
124
125 // Succeeded, record it so a redelivery short-circuits.
126 if let Err(e) = db::webhook_events::mark_event_processed(&db, &thin.id).await {
127 tracing::error!(event_id = %thin.id, error = ?e, "failed to record processed v2 event; returning 503 for redelivery");
128 return Ok(StatusCode::SERVICE_UNAVAILABLE);
129 }
130
131 Ok(StatusCode::OK)
132 }
133
134 /// Route a verified v2 thin event to its handler. Shared by the live webhook and
135 /// the scheduler retry worker (which re-parses the stored payload, the
136 /// signature was already verified when the event was first received).
137 pub(crate) async fn process_v2_thin_event(
138 db: &PgPool,
139 wam: Option<&WamClient>,
140 stripe: &dyn payments::PaymentProvider,
141 thin: &ThinEvent,
142 ) -> Result<()> {
143 if thin.event_type.starts_with("v2.core.account") {
144 handle_account_thin_event(db, wam, stripe, thin).await
145 } else {
146 tracing::debug!(event_type = %thin.event_type, "unhandled v2 event type");
147 Ok(())
148 }
149 }
150
151 /// Fetch the full account object and delegate to the shared account-updated handler.
152 async fn handle_account_thin_event(
153 db: &PgPool,
154 wam: Option<&WamClient>,
155 stripe: &dyn payments::PaymentProvider,
156 thin: &ThinEvent,
157 ) -> Result<()> {
158 let account_id = match &thin.related_object {
159 Some(obj) => &obj.id,
160 None => {
161 tracing::warn!(event_id = %thin.id, "v2 account event missing related_object");
162 return Ok(()); // nothing to fetch, acknowledge
163 }
164 };
165
166 let update = stripe.fetch_account(account_id).await.map_err(|e| {
167 tracing::warn!(account_id = %account_id, error = ?e, "failed to fetch account for v2 event");
168 e
169 })?;
170
171 super::webhook::handle_account_updated_from_v2(db, wam, &update).await.map_err(|e| {
172 tracing::warn!(account_id = %account_id, error = ?e, "failed to process account update from v2 event");
173 e
174 })?;
175
176 Ok(())
177 }
178