//! Stripe webhook event processing. mod billing; mod checkout; pub(crate) mod checkout_helpers; mod subscriptions; use axum::{ body::Bytes, extract::State, http::{StatusCode, header::HeaderMap}, response::IntoResponse, }; use sqlx::PgPool; use crate::{ Billing, Integrations, config::Config, db, email::EmailClient, error::{AppError, Result, ResultExt}, payments::{AccountUpdate, CheckoutCompletion, CheckoutKind, MnwEvent}, wam_client::WamClient, }; /// POST /stripe/webhook - Handle Stripe webhook events #[tracing::instrument(skip_all, name = "stripe::webhook")] #[allow(clippy::too_many_arguments)] pub(in crate::routes::stripe) async fn webhook( State(db): State, State(bg): State, State(email): State, State(integrations): State, State(payments): State, State(config): State, headers: HeaderMap, body: Bytes, ) -> Result { let stripe = payments .payments .as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; // Get the signature header let signature = headers .get("stripe-signature") .and_then(|v| v.to_str().ok()) .ok_or_else(|| AppError::BadRequest("Missing Stripe signature".to_string()))?; // Parse and verify the webhook let payload = std::str::from_utf8(&body) .map_err(|_| AppError::BadRequest("Invalid payload encoding".to_string()))?; // A failure here has no benign cause: Stripe signs correctly, so it means // a wrong signing secret (real events being dropped) or forged events. let event = stripe.verify_webhook(payload, signature).inspect_err(|_| { crate::security_signals::note_webhook_signature_failure("stripe"); })?; tracing::info!(event_type = %event.type_, event_id = %event.id, "received webhook event"); // Serialize concurrent redeliveries of this event id. Held across the whole // dedup-read -> process -> mark sequence below, the lock makes the dedup read // race-free. We *try* the lock rather than block on it: if a second delivery // of the same event arrives while this one is mid-flight, it gets `None` and // returns 503 immediately instead of parking a pooled connection for the // duration of the in-flight handler (Run 23 Conc/Perf). Stripe redelivers // after this one commits its processed-event mark, and the redelivery's dedup // read then short-circuits. Dropping `_event_lock` on any return path rolls // the (write-free) lock transaction back and releases it, it cannot leak. // See `db::webhook_events::try_lock_event`. let _event_lock = match db::webhook_events::try_lock_event(&db, &event.id).await { Ok(Some(tx)) => tx, Ok(None) => { tracing::info!(event_id = %event.id, "concurrent delivery of this webhook event is in flight; returning 503 for redelivery"); return Ok(StatusCode::SERVICE_UNAVAILABLE); } Err(e) => { tracing::error!(event_id = %event.id, error = ?e, "failed to acquire webhook event lock, returning 503 for retry"); return Ok(StatusCode::SERVICE_UNAVAILABLE); } }; // Deduplicate: skip if we already processed this event ID. This is a READ; // the "processed" row is written only after the handler succeeds (below), so // a crash mid-processing leaves no marker and Stripe's redelivery reprocesses. // The `_event_lock` above closes the check-then-act race, so this read now // guarantees exactly-once dispatch regardless of handler idempotency; per- // handler atomic guards (status-guarded UPDATEs, ON CONFLICT writes, the // `FanPlusCreditClaim` witness) remain as defense in depth. match db::webhook_events::is_event_processed(&db, &event.id).await { Ok(true) => { tracing::info!(event_id = %event.id, "duplicate webhook event, skipping"); return Ok(StatusCode::OK); } Err(e) => { tracing::error!(event_id = %event.id, error = ?e, "webhook dedup check failed, returning 503 for retry"); return Ok(StatusCode::SERVICE_UNAVAILABLE); } Ok(false) => {} // First time seeing this event } // For retry-queue persistence we need id+type after `event` is consumed. // `normalize_webhook` takes the whole envelope (the shape the retry worker // can produce), so these are two short String clones against a path already // several DB round trips deep. let event_id = event.id.clone(); let event_type_str = event.type_.clone(); // Normalize before dispatch, so what follows reasons about an MNW event // rather than a Stripe event-name string. Through the provider, so the // wire names stay in the Stripe implementor. A payload that will not parse // fails here, with the same wording and the same retry-queue treatment it // had when each match arm parsed for itself. let result = match stripe.normalize_webhook(event) { Ok(mnw_event) => { process_webhook_event( &db, &bg, &email, integrations.wam.as_ref(), &payments, &config, mnw_event, &event_id, ) .await } Err(e) => Err(e), }; match result { Ok(()) => { // Work is durably committed, now record the event as processed so a // redelivery short-circuits. If this write fails, return 503: Stripe // redelivers, the idempotent handler re-runs, and the mark is retried. // No event is lost; at worst it is processed twice (safe). if let Err(e) = db::webhook_events::mark_event_processed(&db, &event_id).await { tracing::error!(event_id = %event_id, error = ?e, "failed to record processed webhook event; returning 503 for redelivery"); return Ok(StatusCode::SERVICE_UNAVAILABLE); } } Err(ref e) => { tracing::error!( event_id = %event_id, event_type = %event_type_str, error = ?e, "webhook handler failed, queueing for retry" ); // Not marked processed, so redelivery (or the retry worker) reruns it. if let Err(queue_err) = db::webhook_events::insert_failed_event( &db, "stripe", &event_type_str, payload, Some(signature), &format!("{e:?}"), ) .await { tracing::error!(error = ?queue_err, "failed to queue webhook event for retry; returning 503 to trigger Stripe redelivery"); return Ok(StatusCode::SERVICE_UNAVAILABLE); } } } Ok(StatusCode::OK) } /// Dispatch a normalized webhook event. /// /// Extracted so the caller can catch errors and persist to the retry queue; /// shared by the live webhook handler and the scheduler's retry worker, which /// is the point — both normalize through [`MnwEvent`] first, so neither can /// grow its own idea of what an event type means. /// /// The match is on an enum, so an unhandled Stripe type is /// [`MnwEvent::Unhandled`] by construction. A misspelt string can no longer /// become a silently ignored event. #[allow(clippy::too_many_arguments)] pub(crate) async fn process_webhook_event( db: &PgPool, bg: &crate::background::BackgroundTx, email: &EmailClient, wam: Option<&WamClient>, payments: &Billing, config: &Config, event: MnwEvent, event_id: &str, ) -> Result<()> { match event { MnwEvent::Checkout { kind, session } => { dispatch_checkout_session( db, bg, email, wam, payments, config, kind, &session, event_id, ) .await?; } // No funds were captured, so there is nothing to deliver; the pending // transaction (and any reserved promo hold) is released by the // stale-pending cleanup sweeper. Logged rather than silently dropped. MnwEvent::CheckoutAsyncPaymentFailed { session_id } => { tracing::warn!( %session_id, "checkout async payment failed; no funds captured, pending rows will be released by cleanup" ); } MnwEvent::AccountUpdated(update) => { handle_account_updated(db, wam, &config.signing_secret, &update).await?; } // Direct webhook: queue as pending if the matching payment hasn't landed // yet. Out-of-band (dashboard) FULL refunds are handled here; per-line // refunds land via RefundSettled below. A charge with no payment intent // is out of scope and normalizes to `None`. MnwEvent::ChargeRefunded(Some(refund_data)) => { billing::handle_charge_refunded(db, &refund_data, true).await?; } MnwEvent::ChargeRefunded(None) => {} // Line-scoped self-service refunds tag the Stripe refund with // mnw_transaction_id; this marks/revokes exactly that transaction so a // cart line refund leaves the order's other lines untouched (Run #2 // Payments SERIOUS). Untagged refunds are no-ops here. MnwEvent::RefundSettled(refund) => { billing::handle_refund_created(db, &refund).await?; } MnwEvent::SubscriptionUpdated(sub) => { subscriptions::handle_subscription_updated(db, &sub, event_id).await?; } MnwEvent::SubscriptionDeleted(sub) => { subscriptions::handle_subscription_deleted(db, bg, email, &sub, event_id).await?; } MnwEvent::InvoicePaymentSucceeded(invoice) => { billing::handle_invoice_payment_succeeded(db, bg, email, wam, &invoice, event_id) .await?; } MnwEvent::InvoicePaymentFailed(invoice) => { billing::handle_invoice_payment_failed(db, wam, &invoice, event_id).await?; } MnwEvent::Unhandled { stripe_type } => { tracing::debug!(event_type = %stripe_type, "unhandled webhook event type"); } } Ok(()) } /// Route a completed checkout to its handler. /// /// The kind was settled during normalization, from the metadata MNW itself /// wrote at checkout creation, so this is a match rather than a ladder of /// predicates. /// /// Subscription-mode sessions (Fan+, creator tier, SyncKit app sub, project /// subscription) capture no funds at checkout — the subscription lifecycle /// bills separately — so they run unconditionally. One-time payment-mode /// sessions (tip, guest, cart, single purchase) capture funds now and are /// therefore gated on `settled`: an async method that reports /// `payment_status = "unpaid"` on `checkout.session.completed` is deferred /// until Stripe re-delivers the settled session via /// `checkout.session.async_payment_succeeded`. Without this gate, enabling any /// async payment method on a connected account would mint license keys and /// grant downloads before funds settle. #[allow(clippy::too_many_arguments)] async fn dispatch_checkout_session( db: &PgPool, bg: &crate::background::BackgroundTx, email: &EmailClient, wam: Option<&WamClient>, payments: &Billing, config: &Config, kind: CheckoutKind, session: &CheckoutCompletion, event_id: &str, ) -> Result<()> { // One-time payment-mode: funds captured now. Deliver only once settled. // Asked before the match so the gate cannot be forgotten on a new // funds-capturing kind: `captures_funds_at_checkout` is exhaustive over // `CheckoutKind`, so adding one is a compile error until it answers. if kind.captures_funds_at_checkout() && !session.settled { tracing::info!( session_id = %session.session_id, ?kind, "one-time checkout not yet settled (async payment); deferring finalize until async_payment_succeeded" ); return Ok(()); } match kind { CheckoutKind::FanPlus => { checkout::handle_fan_plus_checkout_completed(db, bg, email, session, event_id).await } CheckoutKind::CreatorTier => { checkout::handle_creator_tier_checkout_completed( db, bg, wam, payments, session, event_id, ) .await } CheckoutKind::SyncKitAppSub => { checkout::handle_synckit_app_sub_checkout_completed(db, session, event_id).await } CheckoutKind::ProjectSubscription => { checkout::handle_subscription_checkout_completed(db, bg, email, session, event_id).await } CheckoutKind::Tip => { checkout::handle_tip_checkout_completed(db, bg, email, wam, config, session, event_id) .await } CheckoutKind::Guest => { checkout::handle_guest_checkout_completed(db, bg, email, wam, config, session, event_id) .await } CheckoutKind::Cart => { checkout::handle_cart_checkout_completed(db, bg, email, wam, config, session, event_id) .await } CheckoutKind::Purchase => { checkout::handle_purchase_checkout_completed( db, bg, email, wam, config, session, event_id, ) .await } } } /// Handle account.updated from the v2 thin event endpoint. pub(in crate::routes::stripe) async fn handle_account_updated_from_v2( db: &PgPool, wam: Option<&WamClient>, signing_secret: &str, update: &AccountUpdate, ) -> Result<()> { handle_account_updated(db, wam, signing_secret, update).await } /// Handle account.updated webhook async fn handle_account_updated( db: &PgPool, wam: Option<&WamClient>, signing_secret: &str, update: &AccountUpdate, ) -> Result<()> { tracing::info!( account_id = %update.account_id, charges_enabled = %update.charges_enabled, payouts_enabled = %update.payouts_enabled, details_submitted = %update.details_submitted, settlement_currency = ?update.settlement_currency, "account updated" ); // Read the stored currency before overwriting it, so a genuine change can be // told apart from Stripe restating the same value on one of the many // `account.updated` events it sends. let previous_currency = db::users::get_settlement_currency_by_stripe_account(db, &update.account_id) .await .unwrap_or(None); // Update the user's Stripe status db::users::update_user_stripe_status( db, &update.account_id, update.details_submitted, update.payouts_enabled, update.charges_enabled, update.settlement_currency, ) .await .with_context(|| format!("update Stripe status for account {}", update.account_id))?; // A settlement currency change is not a status change: it silently // redenominates every price the creator has set. Their 1000 was ten pounds // and is now ten euros, and only they can decide what the number should be. // Nothing here rewrites their prices, because guessing at a rate is exactly // what this design refuses to do; it raises the alarm so a person acts. // // Two alarms, for two audiences. The pending acknowledgement tells the // creator and keeps telling them until they confirm they read it, because // they are the only one who can fix the prices and a single email that // landed in spam is indistinguishable from one that was ignored. The wam // ticket tells us, immediately, because somebody should know that a // creator's catalogue is mispriced right now rather than in a month. if let (Some(new_currency), Some(old_currency)) = (update.settlement_currency, previous_currency) && new_currency != old_currency { tracing::warn!( account_id = %update.account_id, %old_currency, %new_currency, "settlement currency changed; the creator's existing prices now mean different money" ); // Keyed on the pair, so a currency that flaps between the same two // values keeps one open alert while a genuinely new change opens its // own. A failure here must not fail the webhook: Stripe would retry the // whole event, and the status update above has already been applied. match db::users::get_user_id_by_stripe_account(db, &update.account_id).await { Ok(Some(user_id)) => { let details = serde_json::json!({ "from": old_currency.to_string(), "to": new_currency.to_string(), }); let opened = db::acknowledgements::open( db, user_id, db::AckKind::SettlementCurrencyChanged, &format!("{old_currency}->{new_currency}"), details, signing_secret, ) .await; match opened { Ok(true) => tracing::info!( %user_id, "opened a settlement-currency acknowledgement for the creator" ), Ok(false) => {} Err(e) => tracing::error!( error = ?e, %user_id, "could not open the settlement-currency acknowledgement; the wam \ ticket below is the only alarm left" ), } } Ok(None) => tracing::warn!( account_id = %update.account_id, "settlement currency changed on an account with no user; cannot notify anyone" ), Err(e) => tracing::error!( error = ?e, "could not resolve the user behind the settlement-currency change" ), } if let Some(wam) = wam { let title = format!("Settlement currency changed: {}", update.account_id); let body = format!( "This creator's Stripe account moved from {old_currency} to {new_currency}.\n\n\ Every price they have already set is stored as a bare number, so those \ numbers now mean {new_currency} instead of {old_currency}. Nothing has been \ converted and nothing has been rewritten.\n\n\ They need to re-check their prices. Contact them." ); wam.create_ticket( &title, Some(&body), "high", "settlement-currency-changed", Some(&update.account_id), ) .await; } } // Alert if charges or payouts became disabled (creator can't receive payments) if (!update.charges_enabled || !update.payouts_enabled) && let Some(wam) = wam { let title = format!("Stripe Connect degraded: {}", update.account_id); let body = format!( "charges_enabled: {}\npayouts_enabled: {}\ndetails_submitted: {}", update.charges_enabled, update.payouts_enabled, update.details_submitted, ); wam.create_ticket( &title, Some(&body), "high", "stripe-connect-degraded", Some(&update.account_id), ) .await; } Ok(()) }