//! Webhook handlers for subscription lifecycle events (updated, deleted). use crate::{ db::{self, SubscriptionStatus}, error::{Result, ResultExt}, helpers::{spawn_email, stripe_timestamp}, AppState, }; /// 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( state: &AppState, sub: &crate::payments::SubscriptionView, event_id: &str, ) -> Result<()> { let stripe_sub_id = sub.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(&state.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(&state.db, app_id, Some(s), None).await.context("synckit apply_billing_update")?; } if let Err(e) = db::subscriptions::log_subscription_event( &state.db, None, event_id, "customer.subscription.updated.synckit", &serde_json::json!({"status": sub.status, "synckit_app_id": app_id.to_string()}), ).await { tracing::warn!(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(&state.db, &stripe_sub_id) .await .context("fetch app sync subscription by stripe id")? .is_some() { let (_, end_ts) = sub.current_period().unwrap_or((0, 0)); let period_end = if end_ts > 0 { Some(stripe_timestamp(end_ts)) } else { None }; db::synckit::update_app_sync_subscription_status( &state.db, &stripe_sub_id, sub.status.as_str(), period_end, ) .await .context("update app sync subscription status")?; if let Err(e) = db::subscriptions::log_subscription_event( &state.db, None, event_id, "customer.subscription.updated.synckit_app_sub", &serde_json::json!({"status": sub.status}), ).await { tracing::warn!(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(&state.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. let (start_ts, end_ts) = sub.current_period().unwrap_or((0, 0)); let period = Some((stripe_timestamp(start_ts), stripe_timestamp(end_ts))); db::fan_plus::apply_stripe_update(&state.db, &stripe_sub_id, Some(status), 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(&state.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( &state.db, None, event_id, "customer.subscription.updated.fan_plus", &serde_json::json!({"status": status_str}), ).await { tracing::warn!(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(&state.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). let (start_ts, end_ts) = sub.current_period().unwrap_or((0, 0)); let period = Some((stripe_timestamp(start_ts), stripe_timestamp(end_ts))); db::creator_tiers::apply_stripe_update(&state.db, &stripe_sub_id, Some(status), period).await.context("apply creator sub update")?; // Sync the denormalized creator_tier column on users db::creator_tiers::sync_user_creator_tier(&state.db, ct_sub.user_id).await.context("sync user creator tier")?; if let Err(e) = db::subscriptions::log_subscription_event( &state.db, None, event_id, "customer.subscription.updated.creator_tier", &serde_json::json!({"status": status_str}), ).await { tracing::warn!(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. let (start_ts, end_ts) = sub.current_period().unwrap_or((0, 0)); let period = Some((stripe_timestamp(start_ts), stripe_timestamp(end_ts))); let updated = db::subscriptions::apply_stripe_update(&state.db, &stripe_sub_id, Some(status), 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( &state.db, sub_id, event_id, "customer.subscription.updated", &serde_json::json!({"status": status.to_string()}), ).await { tracing::warn!(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( state: &AppState, sub: &crate::payments::SubscriptionView, event_id: &str, ) -> Result<()> { let stripe_sub_id = sub.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(&state.db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? { db::synckit_billing::apply_billing_update(&state.db, app_id, Some("canceled"), None).await.context("synckit billing -> canceled")?; if let Err(e) = db::subscriptions::log_subscription_event( &state.db, None, event_id, "customer.subscription.deleted.synckit", &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}), ).await { tracing::warn!(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(&state.db, &stripe_sub_id) .await .context("fetch app sync subscription by stripe id")? .is_some() { db::synckit::update_app_sync_subscription_status( &state.db, &stripe_sub_id, "canceled", None, ) .await .context("cancel app sync subscription")?; if let Err(e) = db::subscriptions::log_subscription_event( &state.db, None, event_id, "customer.subscription.deleted.synckit_app_sub", &serde_json::json!({"stripe_sub_id": stripe_sub_id}), ).await { tracing::warn!(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(&state.db, &stripe_sub_id).await.context("fetch fan plus by stripe id")? { db::fan_plus::cancel_fan_plus(&state.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(&state.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.clone(); spawn_email!(state, "Fan+ cancelled", |email| { email.send_fan_plus_cancelled(&user_email, user_name.as_deref(), period_end.as_ref()) }); } if let Err(e) = db::subscriptions::log_subscription_event( &state.db, None, event_id, "customer.subscription.deleted.fan_plus", &serde_json::json!({"stripe_sub_id": stripe_sub_id}), ).await { tracing::warn!(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(&state.db, &stripe_sub_id).await.context("fetch creator sub by stripe id")? { db::creator_tiers::cancel_creator_sub(&state.db, &stripe_sub_id).await.context("cancel creator sub")?; db::creator_tiers::sync_user_creator_tier(&state.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( &state.db, None, event_id, "customer.subscription.deleted.creator_tier", &serde_json::json!({"stripe_sub_id": stripe_sub_id}), ).await { tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); } return Ok(()); } let canceled = db::subscriptions::cancel_subscription(&state.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(&state.db, db_sub.subscriber_id).await, db::subscriptions::get_subscription_tier_by_id(&state.db, db_sub.tier_id).await, async { match db_sub.project_id { Some(pid) => db::projects::get_project_by_id(&state.db, pid).await, None => Ok(None) } }.await, ) { let sub_email = subscriber.email.clone(); let sub_name = subscriber.display_name.clone(); let tier_name = tier.name.clone(); let project_title = project.title.clone(); spawn_email!(state, "subscription cancelled", |email| { email.send_subscription_cancelled( &sub_email, sub_name.as_deref(), &tier_name, &project_title, ) }); } } // Log event let sub_id = canceled.as_ref().map(|s| s.id); if let Err(e) = db::subscriptions::log_subscription_event( &state.db, sub_id, event_id, "customer.subscription.deleted", &serde_json::json!({"stripe_sub_id": stripe_sub_id}), ).await { tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); } Ok(()) }