//! Webhook handlers for billing events (invoice payments, refunds). use crate::{ db::{self, SubscriptionStatus}, error::{Result, ResultExt}, helpers::{self, spawn_email, stripe_timestamp}, AppState, }; /// Handle invoice.payment_succeeded; update period, send renewal email (not first invoice) pub(super) async fn handle_invoice_payment_succeeded( state: &AppState, invoice: &crate::payments::InvoiceView, event_id: &str, ) -> Result<()> { let stripe_sub_id = match invoice.subscription_id() { Some(s) => s.to_string(), None => return Ok(()), // Not a subscription invoice }; tracing::info!(stripe_sub_id = %stripe_sub_id, "processing invoice payment succeeded"); let is_renewal = invoice.is_renewal(); // End-user SyncKit app subscription? Apply any pending storage-cap change // and refresh the period. Only meaningful on renewals; the first invoice's // cap was set at checkout. 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 period_end = stripe_timestamp(invoice.period_end); db::synckit::update_app_sync_subscription_status( &state.db, &stripe_sub_id, "active", Some(period_end), ) .await .context("refresh app sync subscription period")?; if is_renewal { db::synckit::apply_pending_storage_cap(&state.db, &stripe_sub_id) .await .context("apply pending storage cap")?; } if let Err(e) = db::subscriptions::log_subscription_event( &state.db, None, event_id, "invoice.payment_succeeded.synckit_app_sub", &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}), ).await { tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); } return Ok(()); } // SyncKit v2 developer subscription? Identified by the local sync_apps row. 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 period_start = stripe_timestamp(invoice.period_start); let period_end = stripe_timestamp(invoice.period_end); let mut tx = state.db.begin().await.context("begin synckit invoice.paid transaction")?; // One guarded write for status + period; only reset usage if the app was // live (a canceled app is refused, so a stray invoice.paid can't refresh // period or usage on it). let applied = db::synckit_billing::apply_billing_update(&mut *tx, app_id, Some("active"), Some((period_start, period_end))).await.context("synckit apply_billing_update")?; if applied { db::synckit_billing::reset_period_usage(&mut *tx, app_id).await.context("synckit reset_period_usage")?; } tx.commit().await.context("commit synckit invoice.paid")?; if let Err(e) = db::subscriptions::log_subscription_event( &state.db, None, event_id, "invoice.payment_succeeded.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 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+ by stripe id")? { // Refresh period (guarded: a canceled Fan+ sub is left untouched). let period_start = stripe_timestamp(invoice.period_start); let period_end = stripe_timestamp(invoice.period_end); db::fan_plus::apply_stripe_update(&state.db, &stripe_sub_id, None, Some((period_start, period_end))).await.context("refresh fan+ period")?; // On renewal, generate a $5 platform-wide promo code and email it if is_renewal { let period_end = chrono::DateTime::from_timestamp(invoice.period_end, 0); // Uniqueness of the generated code is enforced by the DB-level // `UNIQUE(creator_id, upper(code))` partial index on `promo_codes` // (see migration 019, idx_promo_codes_creator_code). The wordlist // gives ~66 bits of entropy (6 words × log₂2048) so a collision // within a single creator's history is astronomically unlikely; // if one ever lands, the INSERT errors out as DB error 23505 and // surfaces to the operator log — no silent overwrite. let code = helpers::generate_key_code(); match db::promo_codes::create_platform_promo_code( &state.db, fan_sub.user_id, code.as_str(), db::CodePurpose::Discount, Some(db::DiscountType::Fixed), Some(500), // $5 credit 0, None, Some(1), // single use period_end, ).await { Ok(pc) => { tracing::info!( promo_code_id = %pc.id, user_id = %fan_sub.user_id, "Fan+ monthly credit promo code generated" ); // Email the credit code (fire-and-forget) if let Ok(Some(user)) = db::users::get_user_by_id(&state.db, fan_sub.user_id).await { let code_str = code.to_string(); let expiry = period_end; let user_email = user.email.clone(); let user_name = user.display_name.clone(); spawn_email!(state, "Fan+ credit", |email| { email.send_fan_plus_credit( &user_email, user_name.as_deref(), &code_str, expiry.as_ref(), ) }); } } Err(e) => { tracing::error!( user_id = %fan_sub.user_id, error = ?e, "failed to generate Fan+ monthly credit promo code" ); if let Some(ref wam) = state.wam { let title = format!("Fan+ credit not issued: user {}", fan_sub.user_id); let body = format!( "Fan+ subscriber {} paid renewal but $5 credit promo code \ generation failed: {e}\n\nManually create a promo code.", fan_sub.user_id, ); wam.create_ticket(&title, Some(&body), "high", "fan-plus-credit-failed", Some(&fan_sub.user_id.to_string())).await; } } } } if let Err(e) = db::subscriptions::log_subscription_event( &state.db, None, event_id, "invoice.payment_succeeded.fan_plus", &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}), ).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 period_start = stripe_timestamp(invoice.period_start); let period_end = stripe_timestamp(invoice.period_end); db::creator_tiers::apply_stripe_update(&state.db, &stripe_sub_id, None, Some((period_start, period_end))).await.context("refresh creator sub period")?; if let Err(e) = db::subscriptions::log_subscription_event( &state.db, None, event_id, "invoice.payment_succeeded.creator_tier", &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}), ).await { tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); } return Ok(()); } // Refresh period for fan subscriptions (guarded: canceled rows untouched). let period_start = stripe_timestamp(invoice.period_start); let period_end = stripe_timestamp(invoice.period_end); db::subscriptions::apply_stripe_update(&state.db, &stripe_sub_id, None, Some((period_start, period_end))).await.context("refresh subscription period")?; // Send renewal email only for renewals (not the first invoice) let db_sub = db::subscriptions::get_subscription_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch subscription by stripe id")?; if is_renewal && let Some(ref db_sub) = db_sub && let (Ok(Some(subscriber)), Ok(Some(tier))) = ( 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, ) { let price = helpers::format_price(tier.price_cents); let sub_email = subscriber.email.clone(); let sub_name = subscriber.display_name.clone(); let tier_name = tier.name.clone(); spawn_email!(state, "subscription renewed", |email| { email.send_subscription_renewed( &sub_email, sub_name.as_deref(), &tier_name, &price, ) }); } // Log event let sub_id = db_sub.as_ref().map(|s| s.id); if let Err(e) = db::subscriptions::log_subscription_event( &state.db, sub_id, event_id, "invoice.payment_succeeded", &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}), ).await { tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); } Ok(()) } /// Handle invoice.payment_failed; set status to past_due pub(super) async fn handle_invoice_payment_failed( state: &AppState, invoice: &crate::payments::InvoiceView, event_id: &str, ) -> Result<()> { let stripe_sub_id = match invoice.subscription_id() { Some(s) => s.to_string(), None => return Ok(()), // Not a subscription invoice }; tracing::info!(stripe_sub_id = %stripe_sub_id, "processing invoice payment failed"); // SyncKit v2 developer subscription? Mark suspended_unpaid. 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("suspended_unpaid"), None).await.context("synckit billing -> suspended_unpaid")?; if let Err(e) = db::subscriptions::log_subscription_event( &state.db, None, event_id, "invoice.payment_failed.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"); } if let Some(ref wam) = state.wam { let title = format!("SyncKit app payment failed: {app_id}"); wam.create_ticket(&title, None, "medium", "synckit-payment-failed", Some(&app_id.to_string())).await; } 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+ by stripe id")? { db::fan_plus::apply_stripe_update(&state.db, &stripe_sub_id, Some(SubscriptionStatus::PastDue), None).await.context("fan+ status -> past_due")?; if let Err(e) = db::subscriptions::log_subscription_event( &state.db, None, event_id, "invoice.payment_failed.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::apply_stripe_update(&state.db, &stripe_sub_id, Some(SubscriptionStatus::PastDue), None).await.context("creator sub status -> past_due")?; 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, "invoice.payment_failed.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 updated = db::subscriptions::apply_stripe_update(&state.db, &stripe_sub_id, Some(SubscriptionStatus::PastDue), None).await.context("subscription status -> past_due")?; // 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, "invoice.payment_failed", &serde_json::json!({"stripe_sub_id": stripe_sub_id}), ).await { tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); } // Create WAM ticket for subscription payment failures if let Some(ref wam) = state.wam { let title = format!("Subscription payment failed: {stripe_sub_id}"); wam.create_ticket(&title, None, "medium", "subscription-payment-failed", Some(&stripe_sub_id)).await; } Ok(()) } /// Handle charge.refunded webhook; revoke license keys on full refund, /// log partial refunds without revoking access. pub(super) async fn handle_charge_refunded( state: &AppState, refund_data: &crate::payments::ChargeRefundData, ) -> Result<()> { let payment_intent_id = &refund_data.payment_intent_id; tracing::info!( payment_intent_id = %payment_intent_id, amount = refund_data.amount.as_i64(), amount_refunded = refund_data.amount_refunded.as_i64(), is_full = refund_data.is_full_refund(), "processing charge refund" ); // Partial refund: log but do not revoke access or keys if !refund_data.is_full_refund() { tracing::info!( payment_intent_id = %payment_intent_id, "partial refund — access and license keys preserved" ); return Ok(()); } let mut db_tx = state.db.begin().await.context("begin refund transaction")?; // Mark transactions as refunded and get their IDs + item_ids // (cart checkouts can have multiple transactions per payment_intent_id) let refunded = db::transactions::refund_transaction_by_payment_intent(&mut *db_tx, payment_intent_id).await.context("refund transaction")?; if !refunded.is_empty() { let mut total_keys_revoked = 0u64; let mut total_children_revoked = 0usize; for (tx_id, item_id) in &refunded { // Project-level transactions store item_id IS NULL — skip the item-scoped // updates for those; the project-members split rows aren't sales-counted. if let Some(item_id) = item_id { db::items::decrement_sales_count(&mut *db_tx, *item_id).await.context("decrement sales count")?; } let revoked = db::license_keys::revoke_keys_by_transaction(&mut db_tx, *tx_id).await.context("revoke license keys")?; total_keys_revoked += revoked; // Revoke child transactions granted via bundle purchase let revoked_children = db::transactions::revoke_child_transactions(&mut *db_tx, *tx_id) .await.context("revoke bundle child transactions")?; for child_item_id in &revoked_children { db::items::decrement_sales_count(&mut *db_tx, *child_item_id) .await .context("decrement child item sales count")?; } total_children_revoked += revoked_children.len(); } // Commit the refund atomically db_tx.commit().await.context("commit refund transaction")?; tracing::info!( transactions_refunded = refunded.len(), keys_revoked = total_keys_revoked, bundle_children_revoked = total_children_revoked, "refund processed" ); } else { // No transaction found — check if this was a tip refund let tip_refunded = db::tips::refund_tip_by_payment_intent(&state.db, payment_intent_id) .await .inspect_err(|e| { tracing::error!( payment_intent_id = %payment_intent_id, error = ?e, "tip refund lookup failed" ); }) .context("refund tip")?; if tip_refunded { tracing::info!(payment_intent_id = %payment_intent_id, "tip refund processed"); } else { // No matching transaction or tip — the payment webhook likely hasn't // arrived yet. Queue the refund for later matching rather than // silently dropping it. tracing::warn!( payment_intent_id = %payment_intent_id, "no completed transaction or tip found — queuing as pending refund" ); db::pending_refunds::insert_pending_refund( &state.db, payment_intent_id, refund_data.amount.as_i64(), refund_data.amount_refunded.as_i64(), ) .await .context("insert pending refund")?; } } Ok(()) }