//! Webhook handlers for subscription lifecycle events (updated, deleted). use crate::{ db::{self, SubscriptionStatus}, email::EmailClient, error::{Result, ResultExt}, payments::{MnwEventName, SubscriptionProduct}, }; use sqlx::PgPool; /// Parse a Stripe subscription status, returning `None` for unknown values. /// /// Stripe periodically adds statuses (e.g. `paused`). Returning an error here /// would propagate `Err` from the webhook handler and pin Stripe in an infinite /// retry storm for any subscription stuck in the new state. Instead, log and /// no-op so the next known-status update naturally resyncs. fn parse_status_or_log( status_str: &str, event_id: &str, stripe_sub_id: &str, ) -> Option { match status_str.parse::() { Ok(s) => Some(s), Err(_) => { tracing::warn!( event_id = %event_id, stripe_sub_id = %stripe_sub_id, status = %status_str, "skipping subscription update: unknown stripe status (treat as no-op so stripe stops retrying)" ); None } } } /// Handle customer.subscription.updated; update status + period pub(super) async fn handle_subscription_updated( db: &PgPool, sub: &crate::payments::SubscriptionLifecycle, event_id: &str, ) -> Result<()> { let stripe_sub_id = sub.stripe_subscription_id.clone(); tracing::info!(stripe_sub_id = %stripe_sub_id, "processing subscription updated"); // SyncKit v2 developer subscription? If Stripe moved it to past_due/unpaid, // mirror that as suspended_unpaid. Active or trialing → 'active'. if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(db, &stripe_sub_id) .await .context("fetch synckit app by stripe sub id")? { let new_status = match sub.status.as_str() { "past_due" | "unpaid" => Some("suspended_unpaid"), "canceled" => Some("canceled"), "active" | "trialing" => Some("active"), _ => None, }; if let Some(s) = new_status { db::synckit_billing::apply_billing_update(db, app_id, Some(s), None) .await .context("synckit apply_billing_update")?; } if let Err(e) = db::subscriptions::log_subscription_event( db, None, event_id, MnwEventName::SubscriptionUpdated(SubscriptionProduct::SyncKit), &serde_json::json!({"status": sub.status, "synckit_app_id": app_id.to_string()}), ) .await { tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event"); } return Ok(()); } // Check if this is an end-user SyncKit app subscription. if db::synckit::get_subscription_by_stripe_id(db, &stripe_sub_id) .await .context("fetch app sync subscription by stripe id")? .is_some() { db::synckit::update_app_sync_subscription_status( db, &stripe_sub_id, sub.status.as_str(), sub.current_period.map(|(_, end)| end), ) .await .context("update app sync subscription status")?; if let Err(e) = db::subscriptions::log_subscription_event( db, None, event_id, MnwEventName::SubscriptionUpdated(SubscriptionProduct::SyncKitAppSub), &serde_json::json!({"status": sub.status}), ) .await { tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event"); } return Ok(()); } // Check if this is a Fan+ subscription if let Some(_fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(db, &stripe_sub_id) .await .context("fetch fan plus by stripe id")? { let status_str = sub.status.as_str(); let Some(status) = parse_status_or_log(status_str, event_id, &stripe_sub_id) else { return Ok(()); }; // Status + period in one guarded write: a canceled Fan+ sub is neither // revived nor period-refreshed by an out-of-order update. The raw Stripe // period goes straight to the writer, which drops a missing/zero end so // an active row never gets an epoch period (CHRONIC C is sealed there). db::fan_plus::apply_stripe_update(db, &stripe_sub_id, Some(status), sub.current_period) .await .context("apply fan plus update")?; // Keep the dashboard flag in sync with Stripe, covers cancellation // initiated via the customer portal as well as our dashboard route. db::fan_plus::set_cancel_at_period_end(db, &stripe_sub_id, sub.cancel_at_period_end) .await .context("sync fan plus cancel_at_period_end")?; if let Err(e) = db::subscriptions::log_subscription_event( db, None, event_id, MnwEventName::SubscriptionUpdated(SubscriptionProduct::FanPlus), &serde_json::json!({"status": status_str}), ) .await { tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event"); } return Ok(()); } // Check if this is a creator tier subscription if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_stripe_id(db, &stripe_sub_id) .await .context("fetch creator sub by stripe id")? { let status_str = sub.status.as_str(); let Some(status) = parse_status_or_log(status_str, event_id, &stripe_sub_id) else { return Ok(()); }; // Status + period in one guarded write (canceled is terminal for both). // Raw Stripe period to the writer; it drops a missing/zero end so an // active row never gets an epoch period (CHRONIC C is sealed there). db::creator_tiers::apply_stripe_update( db, &stripe_sub_id, Some(status), sub.current_period, ) .await .context("apply creator sub update")?; // Sync the denormalized creator_tier column on users db::creator_tiers::sync_user_creator_tier(db, ct_sub.user_id) .await .context("sync user creator tier")?; if let Err(e) = db::subscriptions::log_subscription_event( db, None, event_id, MnwEventName::SubscriptionUpdated(SubscriptionProduct::CreatorTier), &serde_json::json!({"status": status_str}), ) .await { tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event"); } return Ok(()); } let status_str = sub.status.as_str(); let Some(status) = parse_status_or_log(status_str, event_id, &stripe_sub_id) else { return Ok(()); }; // Status + period in one guarded statement, `canceled` is terminal for both, // so a late `updated`(active) can neither revive the row nor refresh its // period. The raw Stripe period goes straight to the writer, which drops a // missing/zero end (the old `unwrap_or((0,0))` + `stripe_timestamp(0)` here // stamped 1970-01-01 and cut off paying fans, CHRONIC C, now sealed). let updated = db::subscriptions::apply_stripe_update( db, &stripe_sub_id, Some(status), sub.current_period, ) .await .context("apply subscription update")?; // Log event let sub_id = updated.as_ref().map(|s| s.id); if let Err(e) = db::subscriptions::log_subscription_event( db, sub_id, event_id, MnwEventName::SubscriptionUpdated(SubscriptionProduct::Undetermined), &serde_json::json!({"status": status.to_string()}), ) .await { tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event"); } Ok(()) } /// Handle customer.subscription.deleted; mark canceled, send email pub(super) async fn handle_subscription_deleted( db: &PgPool, bg: &crate::background::BackgroundTx, email: &EmailClient, sub: &crate::payments::SubscriptionLifecycle, event_id: &str, ) -> Result<()> { let stripe_sub_id = sub.stripe_subscription_id.clone(); tracing::info!(stripe_sub_id = %stripe_sub_id, "processing subscription deleted"); // SyncKit v2 developer subscription? Flip to 'canceled'. if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(db, &stripe_sub_id) .await .context("fetch synckit app by stripe sub id")? { db::synckit_billing::apply_billing_update(db, app_id, Some("canceled"), None) .await .context("synckit billing -> canceled")?; if let Err(e) = db::subscriptions::log_subscription_event( db, None, event_id, MnwEventName::SubscriptionDeleted(SubscriptionProduct::SyncKit), &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}), ).await { tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event"); } return Ok(()); } // Check if this is an end-user SyncKit app subscription. if db::synckit::get_subscription_by_stripe_id(db, &stripe_sub_id) .await .context("fetch app sync subscription by stripe id")? .is_some() { db::synckit::update_app_sync_subscription_status( db, &stripe_sub_id, "canceled", None::, ) .await .context("cancel app sync subscription")?; if let Err(e) = db::subscriptions::log_subscription_event( db, None, event_id, MnwEventName::SubscriptionDeleted(SubscriptionProduct::SyncKitAppSub), &serde_json::json!({"stripe_sub_id": stripe_sub_id}), ) .await { tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event"); } return Ok(()); } // Check if this is a Fan+ subscription if let Some(fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(db, &stripe_sub_id) .await .context("fetch fan plus by stripe id")? { db::fan_plus::cancel_fan_plus(db, &stripe_sub_id) .await .context("cancel fan plus")?; // Send cancellation email (fire-and-forget) if let Ok(Some(user)) = db::users::get_user_by_id(db, fan_sub.user_id).await { let period_end = fan_sub.current_period_end; let user_email = user.email.clone(); let user_name = user.display_name; let email = email.clone(); bg.spawn("Fan+ cancelled", async move { if let Err(e) = email .send_fan_plus_cancelled(&user_email, user_name.as_deref(), period_end.as_ref()) .await { tracing::error!(error = ?e, "failed to send Fan+ cancelled"); } }); } if let Err(e) = db::subscriptions::log_subscription_event( db, None, event_id, MnwEventName::SubscriptionDeleted(SubscriptionProduct::FanPlus), &serde_json::json!({"stripe_sub_id": stripe_sub_id}), ) .await { tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event"); } return Ok(()); } // Check if this is a creator tier subscription if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_stripe_id(db, &stripe_sub_id) .await .context("fetch creator sub by stripe id")? { db::creator_tiers::cancel_creator_sub(db, &stripe_sub_id) .await .context("cancel creator sub")?; db::creator_tiers::sync_user_creator_tier(db, ct_sub.user_id) .await .context("sync user creator tier after cancel")?; tracing::info!( user_id = %ct_sub.user_id, tier = %ct_sub.tier, "creator tier subscription canceled" ); if let Err(e) = db::subscriptions::log_subscription_event( db, None, event_id, MnwEventName::SubscriptionDeleted(SubscriptionProduct::CreatorTier), &serde_json::json!({"stripe_sub_id": stripe_sub_id}), ) .await { tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event"); } return Ok(()); } let canceled = db::subscriptions::cancel_subscription(db, &stripe_sub_id) .await .context("cancel subscription")?; if let Some(ref db_sub) = canceled { // Send cancellation email (fire-and-forget) if let (Ok(Some(subscriber)), Ok(Some(tier)), Ok(Some(project))) = ( db::users::get_user_by_id(db, db_sub.subscriber_id).await, db::subscriptions::get_subscription_tier_by_id(db, db_sub.tier_id).await, async { match db_sub.project_id { Some(pid) => db::projects::get_project_by_id(db, pid).await, None => Ok(None), } } .await, ) { let sub_email = subscriber.email.clone(); let sub_name = subscriber.display_name; let tier_name = tier.name; let project_title = project.title; let email = email.clone(); bg.spawn("subscription cancelled", async move { if let Err(e) = email .send_subscription_cancelled( &sub_email, sub_name.as_deref(), &tier_name, &project_title, ) .await { tracing::error!(error = ?e, "failed to send subscription cancelled"); } }); } } // Log event let sub_id = canceled.as_ref().map(|s| s.id); if let Err(e) = db::subscriptions::log_subscription_event( db, sub_id, event_id, MnwEventName::SubscriptionDeleted(SubscriptionProduct::Undetermined), &serde_json::json!({"stripe_sub_id": stripe_sub_id}), ) .await { tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event"); } Ok(()) } #[cfg(test)] mod tests { //! Status parsing for subscription webhooks. The contract that matters here //! is what happens to a status Stripe has added since we last looked: it //! must be a no-op, not an error, or the handler returns Err and Stripe //! retries that event forever. use super::*; #[test] fn every_known_status_parses() { for s in [ "active", "trialing", "incomplete", "incomplete_expired", "past_due", "canceled", "unpaid", ] { assert!( parse_status_or_log(s, "evt_1", "sub_1").is_some(), "{s} is a status we handle and must parse" ); } } #[test] fn an_unknown_status_is_a_no_op_and_not_an_error() { // `paused` is the real example: Stripe added it after this code was // written. Returning Err here would pin Stripe in a retry storm for // every subscription stuck in the new state. assert!(parse_status_or_log("paused", "evt_1", "sub_1").is_none()); assert!(parse_status_or_log("", "evt_1", "sub_1").is_none()); assert!(parse_status_or_log("ACTIVE", "evt_1", "sub_1").is_none()); } }