//! Webhook handlers for billing events (invoice payments, refunds). use crate::{ db::{self, SubscriptionStatus}, email::EmailClient, error::{Result, ResultExt}, helpers, payments::{MnwEventName, SubscriptionProduct}, wam_client::WamClient, }; use sqlx::PgPool; /// Handle invoice.payment_succeeded; update period, send renewal email (not first invoice) pub(super) async fn handle_invoice_payment_succeeded( db: &PgPool, bg: &crate::background::BackgroundTx, email: &EmailClient, wam: Option<&WamClient>, invoice: &crate::payments::InvoiceOutcome, event_id: &str, ) -> Result<()> { let stripe_sub_id = match invoice.subscription_id.as_deref() { 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(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, "active", Some(invoice.period_end), ) .await .context("refresh app sync subscription period")?; if is_renewal { db::synckit::apply_pending_storage_cap(db, &stripe_sub_id) .await .context("apply pending storage cap")?; } if let Err(e) = db::subscriptions::log_subscription_event( db, None, event_id, MnwEventName::InvoicePaymentSucceeded(SubscriptionProduct::SyncKitAppSub), &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}), ) .await { tracing::error!(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(db, &stripe_sub_id) .await .context("fetch synckit app by stripe sub id")? { let mut tx = 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). Raw Stripe period to the sealed writer. let applied = db::synckit_billing::apply_billing_update( &mut *tx, app_id, Some("active"), Some((invoice.period_start, invoice.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( db, None, event_id, MnwEventName::InvoicePaymentSucceeded(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 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+ by stripe id")? { // Refresh period (guarded: a canceled Fan+ sub is left untouched). Raw // Stripe period to the sealed writer, which drops a non-positive end. db::fan_plus::apply_stripe_update( db, &stripe_sub_id, None, Some((invoice.period_start, invoice.period_end)), ) .await .context("refresh fan+ period")?; // On renewal, generate a $5 platform-wide promo code and email it. // // Idempotency: webhook dedup (`webhook/mod.rs`) is a check-then-act read // that two concurrent deliveries of the same `invoice.payment_succeeded` // both pass, so the credit, a money-moving side-effect, serializes on // its own atomic write here. `try_claim_fan_plus_credit` inserts a // `(stripe_sub_id, period_end)` row ON CONFLICT DO NOTHING and hands the // winner a `FanPlusCreditClaim` witness; `issue_fan_plus_credit_code` // requires that witness, so the mint+email path is unreachable without // it. A redelivery (or duplicate concurrent delivery) gets `None` and // skips, so a renewal issues at most one $5 credit. if is_renewal && let Some(claim) = db::promo_codes::try_claim_fan_plus_credit(db, &stripe_sub_id, invoice.period_end) .await .context("claim fan+ credit issuance slot")? { 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::issue_fan_plus_credit_code( &claim, db, fan_sub.user_id, code.as_str(), 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(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; let email = email.clone(); bg.spawn("Fan+ credit", async move { if let Err(e) = email .send_fan_plus_credit( &user_email, user_name.as_deref(), &code_str, expiry.as_ref(), ) .await { tracing::error!(error = ?e, "failed to send Fan+ credit"); } }); } } Err(e) => { tracing::error!( user_id = %fan_sub.user_id, error = ?e, "failed to generate Fan+ monthly credit promo code" ); if let Some(wam) = 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( db, None, event_id, MnwEventName::InvoicePaymentSucceeded(SubscriptionProduct::FanPlus), &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}), ) .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::apply_stripe_update( db, &stripe_sub_id, None, Some((invoice.period_start, invoice.period_end)), ) .await .context("refresh creator sub period")?; if let Err(e) = db::subscriptions::log_subscription_event( db, None, event_id, MnwEventName::InvoicePaymentSucceeded(SubscriptionProduct::CreatorTier), &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}), ) .await { tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event"); } return Ok(()); } // Refresh period for fan subscriptions (guarded: canceled rows untouched). db::subscriptions::apply_stripe_update( db, &stripe_sub_id, None, Some((invoice.period_start, invoice.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(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(db, db_sub.subscriber_id).await, db::subscriptions::get_subscription_tier_by_id(db, db_sub.tier_id).await, ) { // The tier's Stripe Price was minted in the creator's currency, and the // creator is the project owner, not the subscriber whose email this is. let creator_currency = match tier.project_id { Some(pid) => db::projects::get_project_by_id(db, pid) .await .ok() .flatten() .map(|p| p.user_id), None => None, }; let creator_currency = match creator_currency { Some(uid) => db::users::get_user_by_id(db, uid) .await .ok() .flatten() .map(|u| u.settlement_currency) .unwrap_or_default(), None => crate::currency::SettlementCurrency::default(), }; let price = helpers::format_price(tier.price_cents, creator_currency); let sub_email = subscriber.email.clone(); let sub_name = subscriber.display_name; let tier_name = tier.name; let email = email.clone(); bg.spawn("subscription renewed", async move { if let Err(e) = email .send_subscription_renewed(&sub_email, sub_name.as_deref(), &tier_name, &price) .await { tracing::error!(error = ?e, "failed to send subscription renewed"); } }); } // Log event let sub_id = db_sub.as_ref().map(|s| s.id); if let Err(e) = db::subscriptions::log_subscription_event( db, sub_id, event_id, MnwEventName::InvoicePaymentSucceeded(SubscriptionProduct::Undetermined), &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}), ) .await { tracing::error!(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( db: &PgPool, wam: Option<&WamClient>, invoice: &crate::payments::InvoiceOutcome, event_id: &str, ) -> Result<()> { let stripe_sub_id = match invoice.subscription_id.as_deref() { 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(db, &stripe_sub_id) .await .context("fetch synckit app by stripe sub id")? { db::synckit_billing::apply_billing_update(db, app_id, Some("suspended_unpaid"), None) .await .context("synckit billing -> suspended_unpaid")?; if let Err(e) = db::subscriptions::log_subscription_event( db, None, event_id, MnwEventName::InvoicePaymentFailed(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"); } if let Some(wam) = 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(db, &stripe_sub_id) .await .context("fetch fan+ by stripe id")? { db::fan_plus::apply_stripe_update( db, &stripe_sub_id, Some(SubscriptionStatus::PastDue), None, ) .await .context("fan+ status -> past_due")?; if let Err(e) = db::subscriptions::log_subscription_event( db, None, event_id, MnwEventName::InvoicePaymentFailed(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::apply_stripe_update( db, &stripe_sub_id, Some(SubscriptionStatus::PastDue), None, ) .await .context("creator sub status -> past_due")?; 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::InvoicePaymentFailed(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 updated = db::subscriptions::apply_stripe_update( 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( db, sub_id, event_id, MnwEventName::InvoicePaymentFailed(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"); } // Create WAM ticket for subscription payment failures if let Some(wam) = 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(()) } /// Revoke a single refunded transaction: decrement its item's sales count, /// revoke its license keys, and revoke + decrement any bundle-child transactions. /// Returns `(keys_revoked, children_revoked)` for logging. Caller must have /// already transitioned the row to `refunded` (so this runs exactly once per /// transaction). Shared by the PI-wide `charge.refunded` path and the /// line-scoped `refund.created` path. async fn revoke_refunded_transaction( db_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, tx_id: db::TransactionId, item_id: Option, ) -> Result<(u64, usize)> { // 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 keys = db::license_keys::revoke_keys_by_transaction(db_tx, tx_id) .await .context("revoke license keys")?; // 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")?; } Ok((keys, revoked_children.len())) } /// Handle a `refund.created` / `refund.updated` webhook for a line-scoped refund. /// /// The self-service refund tags the Stripe refund with `mnw_transaction_id`; we /// mark and revoke exactly that transaction. Cart lines share a PaymentIntent, so /// this is what keeps a single-line refund from touching the order's other lines /// (Run #2 Payments SERIOUS). Refunds without our metadata (e.g. issued from the /// Stripe dashboard) are left to the `charge.refunded` path. Idempotent: the /// `status = 'completed'` transition guard means re-delivery is a no-op, and a /// later `charge.refunded` for the same refund finds nothing left to mark. pub(super) async fn handle_refund_created( db: &PgPool, refund: &crate::payments::RefundOutcome, ) -> Result<()> { if !refund.succeeded { return Ok(()); } let Some(tx_id_str) = refund.mnw_transaction_id.as_deref() else { return Ok(()); // out-of-band refund; charge.refunded handles full ones }; let Ok(tx_id) = tx_id_str.parse::() else { tracing::warn!(mnw_transaction_id = %tx_id_str, "refund metadata transaction id unparseable; ignoring"); return Ok(()); }; let mut db_tx = db.begin().await.context("begin line refund")?; let refunded = db::transactions::refund_transaction_by_id(&mut *db_tx, tx_id) .await .context("refund transaction by id")?; match refunded { Some((tx_id, item_id)) => { let (keys, children) = revoke_refunded_transaction(&mut db_tx, tx_id, item_id).await?; db_tx.commit().await.context("commit line refund")?; tracing::info!( transaction_id = %tx_id, keys_revoked = keys, bundle_children_revoked = children, "line refund processed" ); } None => { tracing::info!( transaction_id = %tx_id, "line refund: transaction not in a completed state (already refunded); no-op" ); } } Ok(()) } /// Handle charge.refunded webhook; revoke license keys on full refund, /// log partial refunds without revoking access. /// Process a full refund: revoke transactions/keys/access, or (for the direct /// `charge.refunded` webhook) queue it as pending when no matching transaction /// or tip exists yet. `requeue_if_unmatched` is false when called from the /// pending-refund claim path, that row is already claimed, so re-queuing would /// insert a duplicate (the partial-unique index only covers `matched_at IS /// NULL`); instead we signal "still unmatched" so the caller releases the claim /// and the stale sweep escalates it (MINOR, Run #23). pub(super) async fn handle_charge_refunded( db: &PgPool, refund_data: &crate::payments::ChargeRefundData, requeue_if_unmatched: bool, ) -> 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 = 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() { // No transaction found, check if this was a tip refund let tip_refunded = db::tips::refund_tip_by_payment_intent(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 if db::transactions::transaction_exists_for_payment_intent(db, payment_intent_id) .await .context("check transaction existence for refund")? { // Transactions exist for this PI but none were 'completed', they were // already refunded (line-scoped refund.created marked them, or a prior // delivery did). Idempotent no-op; do NOT queue a pending refund. tracing::info!( payment_intent_id = %payment_intent_id, "charge.refunded: transactions already refunded; no-op" ); } else if requeue_if_unmatched { // No transaction at all (and no 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 transaction or tip found, queuing as pending refund" ); db::pending_refunds::insert_pending_refund( db, payment_intent_id, refund_data.amount.as_i64(), refund_data.amount_refunded.as_i64(), ) .await .context("insert pending refund")?; } else { // Claim path: the pending row is already claimed. Don't insert a // duplicate, report it still-unmatched so the caller releases the // claim and the stale-refund sweep escalates it for manual handling. return Err(crate::error::AppError::Internal(anyhow::anyhow!( "pending refund {payment_intent_id} still has no matching transaction or tip" ))); } } else { let mut total_keys_revoked = 0u64; let mut total_children_revoked = 0usize; for (tx_id, item_id) in &refunded { let (keys, children) = revoke_refunded_transaction(&mut db_tx, *tx_id, *item_id).await?; total_keys_revoked += keys; total_children_revoked += children; } // 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" ); } Ok(()) }