diff --git a/crates/collab/src/api/billing.rs b/crates/collab/src/api/billing.rs index bd7b99b3eb..9a27e22f87 100644 --- a/crates/collab/src/api/billing.rs +++ b/crates/collab/src/api/billing.rs @@ -5,16 +5,8 @@ use collections::{HashMap, HashSet}; use reqwest::StatusCode; use sea_orm::ActiveValue; use serde::{Deserialize, Serialize}; -use std::{str::FromStr, sync::Arc, time::Duration}; -use stripe::{ - BillingPortalSession, CancellationDetailsReason, CreateBillingPortalSession, - CreateBillingPortalSessionFlowData, CreateBillingPortalSessionFlowDataAfterCompletion, - CreateBillingPortalSessionFlowDataAfterCompletionRedirect, - CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm, - CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems, - CreateBillingPortalSessionFlowDataType, CustomerId, EventObject, EventType, ListEvents, - PaymentMethod, Subscription, SubscriptionId, SubscriptionStatus, -}; +use std::{sync::Arc, time::Duration}; +use stripe::{CancellationDetailsReason, EventObject, EventType, ListEvents, SubscriptionStatus}; use util::{ResultExt, maybe}; use zed_llm_client::LanguageModelProvider; @@ -31,7 +23,7 @@ use crate::{AppState, Error, Result}; use crate::{db::UserId, llm::db::LlmDatabase}; use crate::{ db::{ - BillingSubscriptionId, CreateBillingCustomerParams, CreateBillingSubscriptionParams, + CreateBillingCustomerParams, CreateBillingSubscriptionParams, CreateProcessedStripeEventParams, UpdateBillingCustomerParams, UpdateBillingSubscriptionParams, billing_customer, }, @@ -39,260 +31,10 @@ use crate::{ }; pub fn router() -> Router { - Router::new() - .route( - "/billing/subscriptions/manage", - post(manage_billing_subscription), - ) - .route( - "/billing/subscriptions/sync", - post(sync_billing_subscription), - ) -} - -#[derive(Debug, PartialEq, Deserialize)] -#[serde(rename_all = "snake_case")] -enum ManageSubscriptionIntent { - /// The user intends to manage their subscription. - /// - /// This will open the Stripe billing portal without putting the user in a specific flow. - ManageSubscription, - /// The user intends to update their payment method. - UpdatePaymentMethod, - /// The user intends to upgrade to Zed Pro. - UpgradeToPro, - /// The user intends to cancel their subscription. - Cancel, - /// The user intends to stop the cancellation of their subscription. - StopCancellation, -} - -#[derive(Debug, Deserialize)] -struct ManageBillingSubscriptionBody { - github_user_id: i32, - intent: ManageSubscriptionIntent, - /// The ID of the subscription to manage. - subscription_id: BillingSubscriptionId, - redirect_to: Option, -} - -#[derive(Debug, Serialize)] -struct ManageBillingSubscriptionResponse { - billing_portal_session_url: Option, -} - -/// Initiates a Stripe customer portal session for managing a billing subscription. -async fn manage_billing_subscription( - Extension(app): Extension>, - extract::Json(body): extract::Json, -) -> Result> { - let user = app - .db - .get_user_by_github_user_id(body.github_user_id) - .await? - .context("user not found")?; - - let Some(stripe_client) = app.real_stripe_client.clone() else { - log::error!("failed to retrieve Stripe client"); - Err(Error::http( - StatusCode::NOT_IMPLEMENTED, - "not supported".into(), - ))? - }; - - let Some(stripe_billing) = app.stripe_billing.clone() else { - log::error!("failed to retrieve Stripe billing object"); - Err(Error::http( - StatusCode::NOT_IMPLEMENTED, - "not supported".into(), - ))? - }; - - let customer = app - .db - .get_billing_customer_by_user_id(user.id) - .await? - .context("billing customer not found")?; - let customer_id = CustomerId::from_str(&customer.stripe_customer_id) - .context("failed to parse customer ID")?; - - let subscription = app - .db - .get_billing_subscription_by_id(body.subscription_id) - .await? - .context("subscription not found")?; - let subscription_id = SubscriptionId::from_str(&subscription.stripe_subscription_id) - .context("failed to parse subscription ID")?; - - if body.intent == ManageSubscriptionIntent::StopCancellation { - let updated_stripe_subscription = Subscription::update( - &stripe_client, - &subscription_id, - stripe::UpdateSubscription { - cancel_at_period_end: Some(false), - ..Default::default() - }, - ) - .await?; - - app.db - .update_billing_subscription( - subscription.id, - &UpdateBillingSubscriptionParams { - stripe_cancel_at: ActiveValue::set( - updated_stripe_subscription - .cancel_at - .and_then(|cancel_at| DateTime::from_timestamp(cancel_at, 0)) - .map(|time| time.naive_utc()), - ), - ..Default::default() - }, - ) - .await?; - - return Ok(Json(ManageBillingSubscriptionResponse { - billing_portal_session_url: None, - })); - } - - let flow = match body.intent { - ManageSubscriptionIntent::ManageSubscription => None, - ManageSubscriptionIntent::UpgradeToPro => { - let zed_pro_price_id: stripe::PriceId = - stripe_billing.zed_pro_price_id().await?.try_into()?; - let zed_free_price_id: stripe::PriceId = - stripe_billing.zed_free_price_id().await?.try_into()?; - - let stripe_subscription = - Subscription::retrieve(&stripe_client, &subscription_id, &[]).await?; - - let is_on_zed_pro_trial = stripe_subscription.status == SubscriptionStatus::Trialing - && stripe_subscription.items.data.iter().any(|item| { - item.price - .as_ref() - .map_or(false, |price| price.id == zed_pro_price_id) - }); - if is_on_zed_pro_trial { - let payment_methods = PaymentMethod::list( - &stripe_client, - &stripe::ListPaymentMethods { - customer: Some(stripe_subscription.customer.id()), - ..Default::default() - }, - ) - .await?; - - let has_payment_method = !payment_methods.data.is_empty(); - if !has_payment_method { - return Err(Error::http( - StatusCode::BAD_REQUEST, - "missing payment method".into(), - )); - } - - // If the user is already on a Zed Pro trial and wants to upgrade to Pro, we just need to end their trial early. - Subscription::update( - &stripe_client, - &stripe_subscription.id, - stripe::UpdateSubscription { - trial_end: Some(stripe::Scheduled::now()), - ..Default::default() - }, - ) - .await?; - - return Ok(Json(ManageBillingSubscriptionResponse { - billing_portal_session_url: None, - })); - } - - let subscription_item_to_update = stripe_subscription - .items - .data - .iter() - .find_map(|item| { - let price = item.price.as_ref()?; - - if price.id == zed_free_price_id { - Some(item.id.clone()) - } else { - None - } - }) - .context("No subscription item to update")?; - - Some(CreateBillingPortalSessionFlowData { - type_: CreateBillingPortalSessionFlowDataType::SubscriptionUpdateConfirm, - subscription_update_confirm: Some( - CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm { - subscription: subscription.stripe_subscription_id, - items: vec![ - CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems { - id: subscription_item_to_update.to_string(), - price: Some(zed_pro_price_id.to_string()), - quantity: Some(1), - }, - ], - discounts: None, - }, - ), - ..Default::default() - }) - } - ManageSubscriptionIntent::UpdatePaymentMethod => Some(CreateBillingPortalSessionFlowData { - type_: CreateBillingPortalSessionFlowDataType::PaymentMethodUpdate, - after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion { - type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect, - redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect { - return_url: format!( - "{}{path}", - app.config.zed_dot_dev_url(), - path = body.redirect_to.unwrap_or_else(|| "/account".to_string()) - ), - }), - ..Default::default() - }), - ..Default::default() - }), - ManageSubscriptionIntent::Cancel => { - if subscription.kind == Some(SubscriptionKind::ZedFree) { - return Err(Error::http( - StatusCode::BAD_REQUEST, - "free subscription cannot be canceled".into(), - )); - } - - Some(CreateBillingPortalSessionFlowData { - type_: CreateBillingPortalSessionFlowDataType::SubscriptionCancel, - after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion { - type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect, - redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect { - return_url: format!("{}/account", app.config.zed_dot_dev_url()), - }), - ..Default::default() - }), - subscription_cancel: Some( - stripe::CreateBillingPortalSessionFlowDataSubscriptionCancel { - subscription: subscription.stripe_subscription_id, - retention: None, - }, - ), - ..Default::default() - }) - } - ManageSubscriptionIntent::StopCancellation => unreachable!(), - }; - - let mut params = CreateBillingPortalSession::new(customer_id); - params.flow_data = flow; - let return_url = format!("{}/account", app.config.zed_dot_dev_url()); - params.return_url = Some(&return_url); - - let session = BillingPortalSession::create(&stripe_client, params).await?; - - Ok(Json(ManageBillingSubscriptionResponse { - billing_portal_session_url: Some(session.url), - })) + Router::new().route( + "/billing/subscriptions/sync", + post(sync_billing_subscription), + ) } #[derive(Debug, Deserialize)]