| 7 |
7 |
|
|
| 8 |
8 |
|
use axum::{
|
| 9 |
9 |
|
body::Bytes,
|
| 10 |
|
- |
extract::{FromRef, State},
|
|
10 |
+ |
extract::State,
|
| 11 |
11 |
|
http::{header::HeaderMap, StatusCode},
|
| 12 |
12 |
|
response::IntoResponse,
|
| 13 |
13 |
|
};
|
|
14 |
+ |
use sqlx::PgPool;
|
| 14 |
15 |
|
|
| 15 |
16 |
|
use crate::{
|
|
17 |
+ |
config::Config,
|
| 16 |
18 |
|
db,
|
|
19 |
+ |
email::EmailClient,
|
| 17 |
20 |
|
error::{AppError, Result, ResultExt},
|
| 18 |
21 |
|
payments::{
|
| 19 |
22 |
|
self, AccountUpdate, AccountView, ChargeRefundData, ChargeView,
|
| 20 |
23 |
|
CheckoutSessionView, InvoiceView, RefundView, SubscriptionView, UntypedEvent,
|
| 21 |
24 |
|
},
|
| 22 |
|
- |
AppState,
|
|
25 |
+ |
wam_client::WamClient,
|
|
26 |
+ |
Billing, Integrations,
|
| 23 |
27 |
|
};
|
| 24 |
28 |
|
|
| 25 |
29 |
|
/// POST /stripe/webhook - Handle Stripe webhook events
|
| 26 |
30 |
|
#[tracing::instrument(skip_all, name = "stripe::webhook")]
|
|
31 |
+ |
#[allow(clippy::too_many_arguments)]
|
| 27 |
32 |
|
pub(in crate::routes::stripe) async fn webhook(
|
| 28 |
|
- |
State(state): State<AppState>,
|
|
33 |
+ |
State(db): State<PgPool>,
|
|
34 |
+ |
State(bg): State<crate::background::BackgroundTx>,
|
|
35 |
+ |
State(email): State<EmailClient>,
|
|
36 |
+ |
State(integrations): State<Integrations>,
|
|
37 |
+ |
State(payments): State<Billing>,
|
|
38 |
+ |
State(config): State<Config>,
|
| 29 |
39 |
|
headers: HeaderMap,
|
| 30 |
40 |
|
body: Bytes,
|
| 31 |
41 |
|
) -> Result<impl IntoResponse> {
|
| 32 |
|
- |
let stripe = state.stripe.as_ref()
|
|
42 |
+ |
let stripe = payments.stripe.as_ref()
|
| 33 |
43 |
|
.ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
|
| 34 |
44 |
|
|
| 35 |
45 |
|
// Get the signature header
|
| 55 |
65 |
|
// read then short-circuits. Dropping `_event_lock` on any return path rolls
|
| 56 |
66 |
|
// the (write-free) lock transaction back and releases it — it cannot leak.
|
| 57 |
67 |
|
// See `db::webhook_events::try_lock_event`.
|
| 58 |
|
- |
let _event_lock = match db::webhook_events::try_lock_event(&state.db, &event.id).await {
|
|
68 |
+ |
let _event_lock = match db::webhook_events::try_lock_event(&db, &event.id).await {
|
| 59 |
69 |
|
Ok(Some(tx)) => tx,
|
| 60 |
70 |
|
Ok(None) => {
|
| 61 |
71 |
|
tracing::info!(event_id = %event.id, "concurrent delivery of this webhook event is in flight; returning 503 for redelivery");
|
| 74 |
84 |
|
// guarantees exactly-once dispatch regardless of handler idempotency; per-
|
| 75 |
85 |
|
// handler atomic guards (status-guarded UPDATEs, ON CONFLICT writes, the
|
| 76 |
86 |
|
// `FanPlusCreditClaim` witness) remain as defense in depth.
|
| 77 |
|
- |
match db::webhook_events::is_event_processed(&state.db, &event.id).await {
|
|
87 |
+ |
match db::webhook_events::is_event_processed(&db, &event.id).await {
|
| 78 |
88 |
|
Ok(true) => {
|
| 79 |
89 |
|
tracing::info!(event_id = %event.id, "duplicate webhook event, skipping");
|
| 80 |
90 |
|
return Ok(StatusCode::OK);
|
| 89 |
99 |
|
// For retry-queue persistence we need id+type after `event` is consumed.
|
| 90 |
100 |
|
// Move both out without cloning the underlying allocations.
|
| 91 |
101 |
|
let UntypedEvent { id: event_id, type_: event_type_str, data_object } = event;
|
| 92 |
|
- |
let result = process_webhook_event(&state, &event_type_str, &event_id, data_object).await;
|
|
102 |
+ |
let result = process_webhook_event(&db, &bg, &email, integrations.wam.as_ref(), &payments, &config, &event_type_str, &event_id, data_object).await;
|
| 93 |
103 |
|
|
| 94 |
104 |
|
match result {
|
| 95 |
105 |
|
Ok(()) => {
|
| 97 |
107 |
|
// redelivery short-circuits. If this write fails, return 503: Stripe
|
| 98 |
108 |
|
// redelivers, the idempotent handler re-runs, and the mark is retried.
|
| 99 |
109 |
|
// No event is lost; at worst it is processed twice (safe).
|
| 100 |
|
- |
if let Err(e) = db::webhook_events::mark_event_processed(&state.db, &event_id).await {
|
|
110 |
+ |
if let Err(e) = db::webhook_events::mark_event_processed(&db, &event_id).await {
|
| 101 |
111 |
|
tracing::error!(event_id = %event_id, error = ?e, "failed to record processed webhook event; returning 503 for redelivery");
|
| 102 |
112 |
|
return Ok(StatusCode::SERVICE_UNAVAILABLE);
|
| 103 |
113 |
|
}
|
| 109 |
119 |
|
);
|
| 110 |
120 |
|
// Not marked processed, so redelivery (or the retry worker) reruns it.
|
| 111 |
121 |
|
if let Err(queue_err) = db::webhook_events::insert_failed_event(
|
| 112 |
|
- |
&state.db,
|
|
122 |
+ |
&db,
|
| 113 |
123 |
|
"stripe",
|
| 114 |
124 |
|
&event_type_str,
|
| 115 |
125 |
|
payload,
|
| 131 |
141 |
|
/// Dispatch a verified Stripe webhook event. Consumes `data_object` exactly
|
| 132 |
142 |
|
/// once into a typed rc.5 struct based on `event_type`. Shared by the live
|
| 133 |
143 |
|
/// webhook handler and the scheduler retry worker.
|
|
144 |
+ |
#[allow(clippy::too_many_arguments)]
|
| 134 |
145 |
|
pub(crate) async fn process_webhook_event(
|
| 135 |
|
- |
state: &AppState,
|
|
146 |
+ |
db: &PgPool,
|
|
147 |
+ |
bg: &crate::background::BackgroundTx,
|
|
148 |
+ |
email: &EmailClient,
|
|
149 |
+ |
wam: Option<&WamClient>,
|
|
150 |
+ |
payments: &Billing,
|
|
151 |
+ |
config: &Config,
|
| 136 |
152 |
|
event_type: &str,
|
| 137 |
153 |
|
event_id: &str,
|
| 138 |
154 |
|
data_object: serde_json::Value,
|
| 145 |
161 |
|
"checkout.session.completed" | "checkout.session.async_payment_succeeded" => {
|
| 146 |
162 |
|
let session: CheckoutSessionView = serde_json::from_value(data_object)
|
| 147 |
163 |
|
.map_err(|e| AppError::BadRequest(format!("Failed to parse CheckoutSession: {e}")))?;
|
| 148 |
|
- |
dispatch_checkout_session(state, &session, event_id).await?;
|
|
164 |
+ |
dispatch_checkout_session(db, bg, email, wam, payments, config, &session, event_id).await?;
|
| 149 |
165 |
|
}
|
| 150 |
166 |
|
// The buyer's async payment (ACH/SEPA/Bacs) never cleared. No funds were
|
| 151 |
167 |
|
// captured, so there is nothing to deliver; the pending transaction (and
|
| 162 |
178 |
|
"account.updated" => {
|
| 163 |
179 |
|
let account: AccountView = serde_json::from_value(data_object)
|
| 164 |
180 |
|
.map_err(|e| AppError::BadRequest(format!("Failed to parse Account: {e}")))?;
|
| 165 |
|
- |
handle_account_updated(state, &AccountUpdate::from(account)).await?;
|
|
181 |
+ |
handle_account_updated(db, wam, &AccountUpdate::from(account)).await?;
|
| 166 |
182 |
|
}
|
| 167 |
183 |
|
"charge.refunded" => {
|
| 168 |
184 |
|
let charge: ChargeView = serde_json::from_value(data_object)
|
| 171 |
187 |
|
// Direct webhook: queue as pending if the matching payment hasn't
|
| 172 |
188 |
|
// landed yet. Out-of-band (dashboard) FULL refunds are handled
|
| 173 |
189 |
|
// here; per-line refunds land via refund.created below.
|
| 174 |
|
- |
billing::handle_charge_refunded(&state.db, &refund_data, true).await?;
|
|
190 |
+ |
billing::handle_charge_refunded(db, &refund_data, true).await?;
|
| 175 |
191 |
|
}
|
| 176 |
192 |
|
}
|
| 177 |
193 |
|
"refund.created" | "refund.updated" => {
|
| 181 |
197 |
|
// (Run #2 Payments SERIOUS). Untagged refunds are no-ops here.
|
| 182 |
198 |
|
let refund: RefundView = serde_json::from_value(data_object)
|
| 183 |
199 |
|
.map_err(|e| AppError::BadRequest(format!("Failed to parse Refund: {e}")))?;
|
| 184 |
|
- |
billing::handle_refund_created(&state.db, &refund).await?;
|
|
200 |
+ |
billing::handle_refund_created(db, &refund).await?;
|
| 185 |
201 |
|
}
|
| 186 |
202 |
|
"customer.subscription.updated" => {
|
| 187 |
203 |
|
let sub: SubscriptionView = serde_json::from_value(data_object)
|
| 188 |
204 |
|
.map_err(|e| AppError::BadRequest(format!("Failed to parse Subscription: {e}")))?;
|
| 189 |
|
- |
subscriptions::handle_subscription_updated(&state.db, &sub, event_id).await?;
|
|
205 |
+ |
subscriptions::handle_subscription_updated(db, &sub, event_id).await?;
|
| 190 |
206 |
|
}
|
| 191 |
207 |
|
"customer.subscription.deleted" => {
|
| 192 |
208 |
|
let sub: SubscriptionView = serde_json::from_value(data_object)
|
| 193 |
209 |
|
.map_err(|e| AppError::BadRequest(format!("Failed to parse Subscription: {e}")))?;
|
| 194 |
|
- |
subscriptions::handle_subscription_deleted(&state.db, &state.bg, &state.email, &sub, event_id).await?;
|
|
210 |
+ |
subscriptions::handle_subscription_deleted(db, bg, email, &sub, event_id).await?;
|
| 195 |
211 |
|
}
|
| 196 |
212 |
|
"invoice.payment_succeeded" => {
|
| 197 |
213 |
|
let invoice: InvoiceView = serde_json::from_value(data_object)
|
| 198 |
214 |
|
.map_err(|e| AppError::BadRequest(format!("Failed to parse Invoice: {e}")))?;
|
| 199 |
|
- |
billing::handle_invoice_payment_succeeded(&state.db, &state.bg, &state.email, state.wam.as_ref(), &invoice, event_id).await?;
|
|
215 |
+ |
billing::handle_invoice_payment_succeeded(db, bg, email, wam, &invoice, event_id).await?;
|
| 200 |
216 |
|
}
|
| 201 |
217 |
|
"invoice.payment_failed" => {
|
| 202 |
218 |
|
let invoice: InvoiceView = serde_json::from_value(data_object)
|
| 203 |
219 |
|
.map_err(|e| AppError::BadRequest(format!("Failed to parse Invoice: {e}")))?;
|
| 204 |
|
- |
billing::handle_invoice_payment_failed(&state.db, state.wam.as_ref(), &invoice, event_id).await?;
|
|
220 |
+ |
billing::handle_invoice_payment_failed(db, wam, &invoice, event_id).await?;
|
| 205 |
221 |
|
}
|
| 206 |
222 |
|
other => {
|
| 207 |
223 |
|
tracing::debug!(event_type = %other, "unhandled webhook event type");
|
| 222 |
238 |
|
/// re-delivers the settled session via `checkout.session.async_payment_succeeded`.
|
| 223 |
239 |
|
/// Without this gate, enabling any async payment method on a connected account
|
| 224 |
240 |
|
/// would mint license keys and grant downloads before funds settle.
|
|
241 |
+ |
#[allow(clippy::too_many_arguments)]
|
| 225 |
242 |
|
async fn dispatch_checkout_session(
|
| 226 |
|
- |
state: &AppState,
|
|
243 |
+ |
db: &PgPool,
|
|
244 |
+ |
bg: &crate::background::BackgroundTx,
|
|
245 |
+ |
email: &EmailClient,
|
|
246 |
+ |
wam: Option<&WamClient>,
|
|
247 |
+ |
payments: &Billing,
|
|
248 |
+ |
config: &Config,
|
| 227 |
249 |
|
session: &CheckoutSessionView,
|
| 228 |
250 |
|
event_id: &str,
|
| 229 |
251 |
|
) -> Result<()> {
|
| 231 |
253 |
|
|
| 232 |
254 |
|
// Subscription-mode: no funds captured at checkout, no settlement gate.
|
| 233 |
255 |
|
if payments::is_fan_plus_checkout(meta) {
|
| 234 |
|
- |
return checkout::handle_fan_plus_checkout_completed(&state.db, &state.bg, &state.email, session, event_id).await;
|
|
256 |
+ |
return checkout::handle_fan_plus_checkout_completed(db, bg, email, session, event_id).await;
|
| 235 |
257 |
|
}
|
| 236 |
258 |
|
if payments::is_creator_tier_checkout(meta) {
|
| 237 |
|
- |
return checkout::handle_creator_tier_checkout_completed(&state.db, &state.bg, state.wam.as_ref(), &crate::Billing::from_ref(state), session, event_id).await;
|
|
259 |
+ |
return checkout::handle_creator_tier_checkout_completed(db, bg, wam, payments, session, event_id).await;
|
| 238 |
260 |
|
}
|
| 239 |
261 |
|
if payments::is_synckit_app_sub_checkout(meta) {
|
| 240 |
|
- |
return checkout::handle_synckit_app_sub_checkout_completed(&state.db, session, event_id).await;
|
|
262 |
+ |
return checkout::handle_synckit_app_sub_checkout_completed(db, session, event_id).await;
|
| 241 |
263 |
|
}
|
| 242 |
264 |
|
if payments::is_subscription_checkout(meta) {
|
| 243 |
|
- |
return checkout::handle_subscription_checkout_completed(&state.db, &state.bg, &state.email, session, event_id).await;
|
|
265 |
+ |
return checkout::handle_subscription_checkout_completed(db, bg, email, session, event_id).await;
|
| 244 |
266 |
|
}
|
| 245 |
267 |
|
|
| 246 |
268 |
|
// One-time payment-mode: funds captured now. Deliver only once settled.
|
| 254 |
276 |
|
}
|
| 255 |
277 |
|
|
| 256 |
278 |
|
if payments::is_tip_checkout(meta) {
|
| 257 |
|
- |
checkout::handle_tip_checkout_completed(&state.db, &state.bg, &state.email, state.wam.as_ref(), &state.config, session, event_id).await
|
|
279 |
+ |
checkout::handle_tip_checkout_completed(db, bg, email, wam, config, session, event_id).await
|
| 258 |
280 |
|
} else if payments::is_guest_checkout(meta) {
|
| 259 |
|
- |
checkout::handle_guest_checkout_completed(&state.db, &state.bg, &state.email, state.wam.as_ref(), &state.config, session, event_id).await
|
|
281 |
+ |
checkout::handle_guest_checkout_completed(db, bg, email, wam, config, session, event_id).await
|
| 260 |
282 |
|
} else if payments::is_cart_checkout(meta) {
|
| 261 |
|
- |
checkout::handle_cart_checkout_completed(&state.db, &state.bg, &state.email, state.wam.as_ref(), &state.config, session, event_id).await
|
|
283 |
+ |
checkout::handle_cart_checkout_completed(db, bg, email, wam, config, session, event_id).await
|
| 262 |
284 |
|
} else {
|
| 263 |
|
- |
checkout::handle_purchase_checkout_completed(&state.db, &state.bg, &state.email, state.wam.as_ref(), &state.config, session, event_id).await
|
|
285 |
+ |
checkout::handle_purchase_checkout_completed(db, bg, email, wam, config, session, event_id).await
|
| 264 |
286 |
|
}
|
| 265 |
287 |
|
}
|
| 266 |
288 |
|
|
| 267 |
289 |
|
/// Handle account.updated from the v2 thin event endpoint.
|
| 268 |
290 |
|
pub(in crate::routes::stripe) async fn handle_account_updated_from_v2(
|
| 269 |
|
- |
state: &AppState,
|
|
291 |
+ |
db: &PgPool,
|
|
292 |
+ |
wam: Option<&WamClient>,
|
| 270 |
293 |
|
update: &AccountUpdate,
|
| 271 |
294 |
|
) -> Result<()> {
|
| 272 |
|
- |
handle_account_updated(state, update).await
|
|
295 |
+ |
handle_account_updated(db, wam, update).await
|
| 273 |
296 |
|
}
|
| 274 |
297 |
|
|
| 275 |
298 |
|
/// Handle account.updated webhook
|
| 276 |
299 |
|
async fn handle_account_updated(
|
| 277 |
|
- |
state: &AppState,
|
|
300 |
+ |
db: &PgPool,
|
|
301 |
+ |
wam: Option<&WamClient>,
|
| 278 |
302 |
|
update: &AccountUpdate,
|
| 279 |
303 |
|
) -> Result<()> {
|
| 280 |
304 |
|
tracing::info!(
|
| 285 |
309 |
|
|
| 286 |
310 |
|
// Update the user's Stripe status
|
| 287 |
311 |
|
db::users::update_user_stripe_status(
|
| 288 |
|
- |
&state.db,
|
|
312 |
+ |
db,
|
| 289 |
313 |
|
&update.account_id,
|
| 290 |
314 |
|
update.details_submitted,
|
| 291 |
315 |
|
update.payouts_enabled,
|
| 295 |
319 |
|
|
| 296 |
320 |
|
// Alert if charges or payouts became disabled (creator can't receive payments)
|
| 297 |
321 |
|
if (!update.charges_enabled || !update.payouts_enabled)
|
| 298 |
|
- |
&& let Some(ref wam) = state.wam
|
|
322 |
+ |
&& let Some(wam) = wam
|
| 299 |
323 |
|
{
|
| 300 |
324 |
|
let title = format!("Stripe Connect degraded: {}", update.account_id);
|
| 301 |
325 |
|
let body = format!(
|