use anyhow::{Context as _, bail};use axum::{ Extension, Json, Router, extract::{self, Query}, routing::{get, post},};use chrono::{DateTime, SecondsFormat, Utc};use collections::HashSet;use reqwest::StatusCode;use sea_orm::ActiveValue;use serde::{Deserialize, Serialize};use serde_json::json;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 util::{ResultExt, maybe}; use crate::api::events::SnowflakeRow;use crate::db::billing_subscription::{ StripeCancellationReason, StripeSubscriptionStatus, SubscriptionKind,};use crate::llm::db::subscription_usage_meter::CompletionMode;use crate::llm::{AGENT_EXTENDED_TRIAL_FEATURE_FLAG, DEFAULT_MAX_MONTHLY_SPEND};use crate::rpc::{ResultExt as _, Server};use crate::stripe_client::{ StripeCancellationDetailsReason, StripeClient, StripeCustomerId, StripeSubscription, StripeSubscriptionId,};use crate::{AppState, Error, Result};use crate::{db::UserId, llm::db::LlmDatabase};use crate::{ db::{ BillingSubscriptionId, CreateBillingCustomerParams, CreateBillingSubscriptionParams, CreateProcessedStripeEventParams, UpdateBillingCustomerParams, UpdateBillingPreferencesParams, UpdateBillingSubscriptionParams, billing_customer, }, stripe_billing::StripeBilling,}; pub fn router() -> Router { Router::new() .route( "/billing/preferences", get(get_billing_preferences).put(update_billing_preferences), ) .route( "/billing/subscriptions", get(list_billing_subscriptions).post(create_billing_subscription), ) .route( "/billing/subscriptions/manage", post(manage_billing_subscription), ) .route( "/billing/subscriptions/sync", post(sync_billing_subscription), ) .route("/billing/usage", get(get_current_usage))} #[derive(Debug, Deserialize)]struct GetBillingPreferencesParams { github_user_id: i32,} #[derive(Debug, Serialize)]struct BillingPreferencesResponse { trial_started_at: Option<String>, max_monthly_llm_usage_spending_in_cents: i32, model_request_overages_enabled: bool, model_request_overages_spend_limit_in_cents: i32,} async fn get_billing_preferences( Extension(app): Extension<Arc<AppState>>, Query(params): Query<GetBillingPreferencesParams>,) -> Result<Json<BillingPreferencesResponse>> { let user = app .db .get_user_by_github_user_id(params.github_user_id) .await? .context("user not found")?; let billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?; let preferences = app.db.get_billing_preferences(user.id).await?; Ok(Json(BillingPreferencesResponse { trial_started_at: billing_customer .and_then(|billing_customer| billing_customer.trial_started_at) .map(|trial_started_at| { trial_started_at .and_utc() .to_rfc3339_opts(SecondsFormat::Millis, true) }), max_monthly_llm_usage_spending_in_cents: preferences .as_ref() .map_or(DEFAULT_MAX_MONTHLY_SPEND.0 as i32, |preferences| { preferences.max_monthly_llm_usage_spending_in_cents }), model_request_overages_enabled: preferences.as_ref().map_or(false, |preferences| { preferences.model_request_overages_enabled }), model_request_overages_spend_limit_in_cents: preferences .as_ref() .map_or(0, |preferences| { preferences.model_request_overages_spend_limit_in_cents }), }))} #[derive(Debug, Deserialize)]struct UpdateBillingPreferencesBody { github_user_id: i32, #[serde(default)] max_monthly_llm_usage_spending_in_cents: i32, #[serde(default)] model_request_overages_enabled: bool, #[serde(default)] model_request_overages_spend_limit_in_cents: i32,} async fn update_billing_preferences( Extension(app): Extension<Arc<AppState>>, Extension(rpc_server): Extension<Arc<crate::rpc::Server>>, extract::Json(body): extract::Json<UpdateBillingPreferencesBody>,) -> Result<Json<BillingPreferencesResponse>> { let user = app .db .get_user_by_github_user_id(body.github_user_id) .await? .context("user not found")?; let billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?; let max_monthly_llm_usage_spending_in_cents = body.max_monthly_llm_usage_spending_in_cents.max(0); let model_request_overages_spend_limit_in_cents = body.model_request_overages_spend_limit_in_cents.max(0); let billing_preferences = if let Some(_billing_preferences) = app.db.get_billing_preferences(user.id).await? { app.db .update_billing_preferences( user.id, &UpdateBillingPreferencesParams { max_monthly_llm_usage_spending_in_cents: ActiveValue::set( max_monthly_llm_usage_spending_in_cents, ), model_request_overages_enabled: ActiveValue::set( body.model_request_overages_enabled, ), model_request_overages_spend_limit_in_cents: ActiveValue::set( model_request_overages_spend_limit_in_cents, ), }, ) .await? } else { app.db .create_billing_preferences( user.id, &crate::db::CreateBillingPreferencesParams { max_monthly_llm_usage_spending_in_cents, model_request_overages_enabled: body.model_request_overages_enabled, model_request_overages_spend_limit_in_cents, }, ) .await? }; SnowflakeRow::new( "Billing Preferences Updated", Some(user.metrics_id), user.admin, None, json!({ "user_id": user.id, "model_request_overages_enabled": billing_preferences.model_request_overages_enabled, "model_request_overages_spend_limit_in_cents": billing_preferences.model_request_overages_spend_limit_in_cents, "max_monthly_llm_usage_spending_in_cents": billing_preferences.max_monthly_llm_usage_spending_in_cents, }), ) .write(&app.kinesis_client, &app.config.kinesis_stream) .await .log_err(); rpc_server.refresh_llm_tokens_for_user(user.id).await; Ok(Json(BillingPreferencesResponse { trial_started_at: billing_customer .and_then(|billing_customer| billing_customer.trial_started_at) .map(|trial_started_at| { trial_started_at .and_utc() .to_rfc3339_opts(SecondsFormat::Millis, true) }), max_monthly_llm_usage_spending_in_cents: billing_preferences .max_monthly_llm_usage_spending_in_cents, model_request_overages_enabled: billing_preferences.model_request_overages_enabled, model_request_overages_spend_limit_in_cents: billing_preferences .model_request_overages_spend_limit_in_cents, }))} #[derive(Debug, Deserialize)]struct ListBillingSubscriptionsParams { github_user_id: i32,} #[derive(Debug, Serialize)]struct BillingSubscriptionJson { id: BillingSubscriptionId, name: String, status: StripeSubscriptionStatus, period: Option<BillingSubscriptionPeriodJson>, trial_end_at: Option<String>, cancel_at: Option<String>, /// Whether this subscription can be canceled. is_cancelable: bool,} #[derive(Debug, Serialize)]struct BillingSubscriptionPeriodJson { start_at: String, end_at: String,} #[derive(Debug, Serialize)]struct ListBillingSubscriptionsResponse { subscriptions: Vec<BillingSubscriptionJson>,} async fn list_billing_subscriptions( Extension(app): Extension<Arc<AppState>>, Query(params): Query<ListBillingSubscriptionsParams>,) -> Result<Json<ListBillingSubscriptionsResponse>> { let user = app .db .get_user_by_github_user_id(params.github_user_id) .await? .context("user not found")?; let subscriptions = app.db.get_billing_subscriptions(user.id).await?; Ok(Json(ListBillingSubscriptionsResponse { subscriptions: subscriptions .into_iter() .map(|subscription| BillingSubscriptionJson { id: subscription.id, name: match subscription.kind { Some(SubscriptionKind::ZedPro) => "Zed Pro".to_string(), Some(SubscriptionKind::ZedProTrial) => "Zed Pro (Trial)".to_string(), Some(SubscriptionKind::ZedFree) => "Zed Free".to_string(), None => "Zed LLM Usage".to_string(), }, status: subscription.stripe_subscription_status, period: maybe!({ let start_at = subscription.current_period_start_at()?; let end_at = subscription.current_period_end_at()?; Some(BillingSubscriptionPeriodJson { start_at: start_at.to_rfc3339_opts(SecondsFormat::Millis, true), end_at: end_at.to_rfc3339_opts(SecondsFormat::Millis, true), }) }), trial_end_at: if subscription.kind == Some(SubscriptionKind::ZedProTrial) { maybe!({ let end_at = subscription.stripe_current_period_end?; let end_at = DateTime::from_timestamp(end_at, 0)?; Some(end_at.to_rfc3339_opts(SecondsFormat::Millis, true)) }) } else { None }, cancel_at: subscription.stripe_cancel_at.map(|cancel_at| { cancel_at .and_utc() .to_rfc3339_opts(SecondsFormat::Millis, true) }), is_cancelable: subscription.kind != Some(SubscriptionKind::ZedFree) && subscription.stripe_subscription_status.is_cancelable() && subscription.stripe_cancel_at.is_none(), }) .collect(), }))} #[derive(Debug, PartialEq, Clone, Copy, Deserialize)]#[serde(rename_all = "snake_case")]enum ProductCode { ZedPro, ZedProTrial,} #[derive(Debug, Deserialize)]struct CreateBillingSubscriptionBody { github_user_id: i32, product: ProductCode,} #[derive(Debug, Serialize)]struct CreateBillingSubscriptionResponse { checkout_session_url: String,} /// Initiates a Stripe Checkout session for creating a billing subscription.async fn create_billing_subscription( Extension(app): Extension<Arc<AppState>>, extract::Json(body): extract::Json<CreateBillingSubscriptionBody>,) -> Result<Json<CreateBillingSubscriptionResponse>> { let user = app .db .get_user_by_github_user_id(body.github_user_id) .await? .context("user not found")?; 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(), ))? }; if let Some(existing_subscription) = app.db.get_active_billing_subscription(user.id).await? { let is_checkout_allowed = body.product == ProductCode::ZedProTrial && existing_subscription.kind == Some(SubscriptionKind::ZedFree); if !is_checkout_allowed { return Err(Error::http( StatusCode::CONFLICT, "user already has an active subscription".into(), )); } } let existing_billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?; if let Some(existing_billing_customer) = &existing_billing_customer { if existing_billing_customer.has_overdue_invoices { return Err(Error::http( StatusCode::PAYMENT_REQUIRED, "user has overdue invoices".into(), )); } } let customer_id = if let Some(existing_customer) = &existing_billing_customer { StripeCustomerId(existing_customer.stripe_customer_id.clone().into()) } else { stripe_billing .find_or_create_customer_by_email(user.email_address.as_deref()) .await? }; let success_url = format!( "{}/account?checkout_complete=1", app.config.zed_dot_dev_url() ); let checkout_session_url = match body.product { ProductCode::ZedPro => { stripe_billing .checkout_with_zed_pro(&customer_id, &user.github_login, &success_url) .await? } ProductCode::ZedProTrial => { if let Some(existing_billing_customer) = &existing_billing_customer { if existing_billing_customer.trial_started_at.is_some() { return Err(Error::http( StatusCode::FORBIDDEN, "user already used free trial".into(), )); } } let feature_flags = app.db.get_user_flags(user.id).await?; stripe_billing .checkout_with_zed_pro_trial( &customer_id, &user.github_login, feature_flags, &success_url, ) .await? } }; Ok(Json(CreateBillingSubscriptionResponse { checkout_session_url, }))} #[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<String>,} #[derive(Debug, Serialize)]struct ManageBillingSubscriptionResponse { billing_portal_session_url: Option<String>,} /// Initiates a Stripe customer portal session for managing a billing subscription.async fn manage_billing_subscription( Extension(app): Extension<Arc<AppState>>, extract::Json(body): extract::Json<ManageBillingSubscriptionBody>,) -> Result<Json<ManageBillingSubscriptionResponse>> { 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?;Lines 1–500 of 1498. Load more
use anyhow::{Context as _, bail}; use axum::{ Extension, Json, Router, extract::{self, Query}, routing::{get, post}, }; use chrono::{DateTime, SecondsFormat, Utc}; use collections::HashSet; use reqwest::StatusCode; use sea_orm::ActiveValue; use serde::{Deserialize, Serialize}; use serde_json::json; 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 util::{ResultExt, maybe}; use crate::api::events::SnowflakeRow; use crate::db::billing_subscription::{ StripeCancellationReason, StripeSubscriptionStatus, SubscriptionKind, }; use crate::llm::db::subscription_usage_meter::CompletionMode; use crate::llm::{AGENT_EXTENDED_TRIAL_FEATURE_FLAG, DEFAULT_MAX_MONTHLY_SPEND}; use crate::rpc::{ResultExt as _, Server}; use crate::stripe_client::{ StripeCancellationDetailsReason, StripeClient, StripeCustomerId, StripeSubscription, StripeSubscriptionId, }; use crate::{AppState, Error, Result}; use crate::{db::UserId, llm::db::LlmDatabase}; use crate::{ db::{ BillingSubscriptionId, CreateBillingCustomerParams, CreateBillingSubscriptionParams, CreateProcessedStripeEventParams, UpdateBillingCustomerParams, UpdateBillingPreferencesParams, UpdateBillingSubscriptionParams, billing_customer, }, stripe_billing::StripeBilling, }; pub fn router() -> Router { Router::new() .route( "/billing/preferences", get(get_billing_preferences).put(update_billing_preferences), ) .route( "/billing/subscriptions", get(list_billing_subscriptions).post(create_billing_subscription), ) .route( "/billing/subscriptions/manage", post(manage_billing_subscription), ) .route( "/billing/subscriptions/sync", post(sync_billing_subscription), ) .route("/billing/usage", get(get_current_usage)) } #[derive(Debug, Deserialize)] struct GetBillingPreferencesParams { github_user_id: i32, } #[derive(Debug, Serialize)] struct BillingPreferencesResponse { trial_started_at: Option<String>, max_monthly_llm_usage_spending_in_cents: i32, model_request_overages_enabled: bool, model_request_overages_spend_limit_in_cents: i32, } async fn get_billing_preferences( Extension(app): Extension<Arc<AppState>>, Query(params): Query<GetBillingPreferencesParams>, ) -> Result<Json<BillingPreferencesResponse>> { let user = app .db .get_user_by_github_user_id(params.github_user_id) .await? .context("user not found")?; let billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?; let preferences = app.db.get_billing_preferences(user.id).await?; Ok(Json(BillingPreferencesResponse { trial_started_at: billing_customer .and_then(|billing_customer| billing_customer.trial_started_at) .map(|trial_started_at| { trial_started_at .and_utc() .to_rfc3339_opts(SecondsFormat::Millis, true) }), max_monthly_llm_usage_spending_in_cents: preferences .as_ref() .map_or(DEFAULT_MAX_MONTHLY_SPEND.0 as i32, |preferences| { preferences.max_monthly_llm_usage_spending_in_cents }), model_request_overages_enabled: preferences.as_ref().map_or(false, |preferences| { preferences.model_request_overages_enabled }), model_request_overages_spend_limit_in_cents: preferences .as_ref() .map_or(0, |preferences| { preferences.model_request_overages_spend_limit_in_cents }), })) } #[derive(Debug, Deserialize)] struct UpdateBillingPreferencesBody { github_user_id: i32, #[serde(default)] max_monthly_llm_usage_spending_in_cents: i32, #[serde(default)] model_request_overages_enabled: bool, #[serde(default)] model_request_overages_spend_limit_in_cents: i32, } async fn update_billing_preferences( Extension(app): Extension<Arc<AppState>>, Extension(rpc_server): Extension<Arc<crate::rpc::Server>>, extract::Json(body): extract::Json<UpdateBillingPreferencesBody>, ) -> Result<Json<BillingPreferencesResponse>> { let user = app .db .get_user_by_github_user_id(body.github_user_id) .await? .context("user not found")?; let billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?; let max_monthly_llm_usage_spending_in_cents = body.max_monthly_llm_usage_spending_in_cents.max(0); let model_request_overages_spend_limit_in_cents = body.model_request_overages_spend_limit_in_cents.max(0); let billing_preferences = if let Some(_billing_preferences) = app.db.get_billing_preferences(user.id).await? { app.db .update_billing_preferences( user.id, &UpdateBillingPreferencesParams { max_monthly_llm_usage_spending_in_cents: ActiveValue::set( max_monthly_llm_usage_spending_in_cents, ), model_request_overages_enabled: ActiveValue::set( body.model_request_overages_enabled, ), model_request_overages_spend_limit_in_cents: ActiveValue::set( model_request_overages_spend_limit_in_cents, ), }, ) .await? } else { app.db .create_billing_preferences( user.id, &crate::db::CreateBillingPreferencesParams { max_monthly_llm_usage_spending_in_cents, model_request_overages_enabled: body.model_request_overages_enabled, model_request_overages_spend_limit_in_cents, }, ) .await? }; SnowflakeRow::new( "Billing Preferences Updated", Some(user.metrics_id), user.admin, None, json!({ "user_id": user.id, "model_request_overages_enabled": billing_preferences.model_request_overages_enabled, "model_request_overages_spend_limit_in_cents": billing_preferences.model_request_overages_spend_limit_in_cents, "max_monthly_llm_usage_spending_in_cents": billing_preferences.max_monthly_llm_usage_spending_in_cents, }), ) .write(&app.kinesis_client, &app.config.kinesis_stream) .await .log_err(); rpc_server.refresh_llm_tokens_for_user(user.id).await; Ok(Json(BillingPreferencesResponse { trial_started_at: billing_customer .and_then(|billing_customer| billing_customer.trial_started_at) .map(|trial_started_at| { trial_started_at .and_utc() .to_rfc3339_opts(SecondsFormat::Millis, true) }), max_monthly_llm_usage_spending_in_cents: billing_preferences .max_monthly_llm_usage_spending_in_cents, model_request_overages_enabled: billing_preferences.model_request_overages_enabled, model_request_overages_spend_limit_in_cents: billing_preferences .model_request_overages_spend_limit_in_cents, })) } #[derive(Debug, Deserialize)] struct ListBillingSubscriptionsParams { github_user_id: i32, } #[derive(Debug, Serialize)] struct BillingSubscriptionJson { id: BillingSubscriptionId, name: String, status: StripeSubscriptionStatus, period: Option<BillingSubscriptionPeriodJson>, trial_end_at: Option<String>, cancel_at: Option<String>, /// Whether this subscription can be canceled. is_cancelable: bool, } #[derive(Debug, Serialize)] struct BillingSubscriptionPeriodJson { start_at: String, end_at: String, } #[derive(Debug, Serialize)] struct ListBillingSubscriptionsResponse { subscriptions: Vec<BillingSubscriptionJson>, } async fn list_billing_subscriptions( Extension(app): Extension<Arc<AppState>>, Query(params): Query<ListBillingSubscriptionsParams>, ) -> Result<Json<ListBillingSubscriptionsResponse>> { let user = app .db .get_user_by_github_user_id(params.github_user_id) .await? .context("user not found")?; let subscriptions = app.db.get_billing_subscriptions(user.id).await?; Ok(Json(ListBillingSubscriptionsResponse { subscriptions: subscriptions .into_iter() .map(|subscription| BillingSubscriptionJson { id: subscription.id, name: match subscription.kind { Some(SubscriptionKind::ZedPro) => "Zed Pro".to_string(), Some(SubscriptionKind::ZedProTrial) => "Zed Pro (Trial)".to_string(), Some(SubscriptionKind::ZedFree) => "Zed Free".to_string(), None => "Zed LLM Usage".to_string(), }, status: subscription.stripe_subscription_status, period: maybe!({ let start_at = subscription.current_period_start_at()?; let end_at = subscription.current_period_end_at()?; Some(BillingSubscriptionPeriodJson { start_at: start_at.to_rfc3339_opts(SecondsFormat::Millis, true), end_at: end_at.to_rfc3339_opts(SecondsFormat::Millis, true), }) }), trial_end_at: if subscription.kind == Some(SubscriptionKind::ZedProTrial) { maybe!({ let end_at = subscription.stripe_current_period_end?; let end_at = DateTime::from_timestamp(end_at, 0)?; Some(end_at.to_rfc3339_opts(SecondsFormat::Millis, true)) }) } else { None }, cancel_at: subscription.stripe_cancel_at.map(|cancel_at| { cancel_at .and_utc() .to_rfc3339_opts(SecondsFormat::Millis, true) }), is_cancelable: subscription.kind != Some(SubscriptionKind::ZedFree) && subscription.stripe_subscription_status.is_cancelable() && subscription.stripe_cancel_at.is_none(), }) .collect(), })) } #[derive(Debug, PartialEq, Clone, Copy, Deserialize)] #[serde(rename_all = "snake_case")] enum ProductCode { ZedPro, ZedProTrial, } #[derive(Debug, Deserialize)] struct CreateBillingSubscriptionBody { github_user_id: i32, product: ProductCode, } #[derive(Debug, Serialize)] struct CreateBillingSubscriptionResponse { checkout_session_url: String, } /// Initiates a Stripe Checkout session for creating a billing subscription. async fn create_billing_subscription( Extension(app): Extension<Arc<AppState>>, extract::Json(body): extract::Json<CreateBillingSubscriptionBody>, ) -> Result<Json<CreateBillingSubscriptionResponse>> { let user = app .db .get_user_by_github_user_id(body.github_user_id) .await? .context("user not found")?; 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(), ))? }; if let Some(existing_subscription) = app.db.get_active_billing_subscription(user.id).await? { let is_checkout_allowed = body.product == ProductCode::ZedProTrial && existing_subscription.kind == Some(SubscriptionKind::ZedFree); if !is_checkout_allowed { return Err(Error::http( StatusCode::CONFLICT, "user already has an active subscription".into(), )); } } let existing_billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?; if let Some(existing_billing_customer) = &existing_billing_customer { if existing_billing_customer.has_overdue_invoices { return Err(Error::http( StatusCode::PAYMENT_REQUIRED, "user has overdue invoices".into(), )); } } let customer_id = if let Some(existing_customer) = &existing_billing_customer { StripeCustomerId(existing_customer.stripe_customer_id.clone().into()) } else { stripe_billing .find_or_create_customer_by_email(user.email_address.as_deref()) .await? }; let success_url = format!( "{}/account?checkout_complete=1", app.config.zed_dot_dev_url() ); let checkout_session_url = match body.product { ProductCode::ZedPro => { stripe_billing .checkout_with_zed_pro(&customer_id, &user.github_login, &success_url) .await? } ProductCode::ZedProTrial => { if let Some(existing_billing_customer) = &existing_billing_customer { if existing_billing_customer.trial_started_at.is_some() { return Err(Error::http( StatusCode::FORBIDDEN, "user already used free trial".into(), )); } } let feature_flags = app.db.get_user_flags(user.id).await?; stripe_billing .checkout_with_zed_pro_trial( &customer_id, &user.github_login, feature_flags, &success_url, ) .await? } }; Ok(Json(CreateBillingSubscriptionResponse { checkout_session_url, })) } #[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<String>, } #[derive(Debug, Serialize)] struct ManageBillingSubscriptionResponse { billing_portal_session_url: Option<String>, } /// Initiates a Stripe customer portal session for managing a billing subscription. async fn manage_billing_subscription( Extension(app): Extension<Arc<AppState>>, extract::Json(body): extract::Json<ManageBillingSubscriptionBody>, ) -> Result<Json<ManageBillingSubscriptionResponse>> { 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?;