Allow users to stop a previously scheduled cancelation of their Zed Pro plan (#15562)

Max Brunsfeld and Marshall created

Release Notes:

- N/A

Co-authored-by: Marshall <marshall@zed.dev>

Change summary

crates/collab/migrations.sqlite/20221109000000_test_schema.sql                            |  3 
crates/collab/migrations/20240731120800_add_stripe_cancel_at_to_billing_subscriptions.sql |  1 
crates/collab/src/api/billing.rs                                                          | 61 
crates/collab/src/db/queries/billing_subscriptions.rs                                     |  2 
crates/collab/src/db/tables/billing_subscription.rs                                       |  1 
5 files changed, 62 insertions(+), 6 deletions(-)

Detailed changes

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -432,7 +432,8 @@ CREATE TABLE IF NOT EXISTS billing_subscriptions (
     created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
     billing_customer_id INTEGER NOT NULL REFERENCES billing_customers(id),
     stripe_subscription_id TEXT NOT NULL,
-    stripe_subscription_status TEXT NOT NULL
+    stripe_subscription_status TEXT NOT NULL,
+    stripe_cancel_at TIMESTAMP
 );
 
 CREATE INDEX "ix_billing_subscriptions_on_billing_customer_id" ON billing_subscriptions (billing_customer_id);

crates/collab/src/api/billing.rs 🔗

@@ -8,6 +8,7 @@ use axum::{
     routing::{get, post},
     Extension, Json, Router,
 };
+use chrono::{DateTime, SecondsFormat};
 use reqwest::StatusCode;
 use sea_orm::ActiveValue;
 use serde::{Deserialize, Serialize};
@@ -17,7 +18,7 @@ use stripe::{
     CreateBillingPortalSessionFlowDataAfterCompletionRedirect,
     CreateBillingPortalSessionFlowDataType, CreateCheckoutSession, CreateCheckoutSessionLineItems,
     CreateCustomer, Customer, CustomerId, EventObject, EventType, Expandable, ListEvents,
-    SubscriptionStatus,
+    Subscription, SubscriptionId, SubscriptionStatus,
 };
 use util::ResultExt;
 
@@ -51,6 +52,7 @@ struct BillingSubscriptionJson {
     id: BillingSubscriptionId,
     name: String,
     status: StripeSubscriptionStatus,
+    cancel_at: Option<String>,
     /// Whether this subscription can be canceled.
     is_cancelable: bool,
 }
@@ -79,7 +81,13 @@ async fn list_billing_subscriptions(
                 id: subscription.id,
                 name: "Zed Pro".to_string(),
                 status: subscription.stripe_subscription_status,
-                is_cancelable: subscription.stripe_subscription_status.is_cancelable(),
+                cancel_at: subscription.stripe_cancel_at.map(|cancel_at| {
+                    cancel_at
+                        .and_utc()
+                        .to_rfc3339_opts(SecondsFormat::Millis, true)
+                }),
+                is_cancelable: subscription.stripe_subscription_status.is_cancelable()
+                    && subscription.stripe_cancel_at.is_none(),
             })
             .collect(),
     }))
@@ -157,11 +165,13 @@ async fn create_billing_subscription(
     }))
 }
 
-#[derive(Debug, Deserialize)]
+#[derive(Debug, PartialEq, Deserialize)]
 #[serde(rename_all = "snake_case")]
 enum ManageSubscriptionIntent {
     /// The user intends to cancel their subscription.
     Cancel,
+    /// The user intends to stop the cancelation of their subscription.
+    StopCancelation,
 }
 
 #[derive(Debug, Deserialize)]
@@ -174,7 +184,7 @@ struct ManageBillingSubscriptionBody {
 
 #[derive(Debug, Serialize)]
 struct ManageBillingSubscriptionResponse {
-    billing_portal_session_url: String,
+    billing_portal_session_url: Option<String>,
 }
 
 /// Initiates a Stripe customer portal session for managing a billing subscription.
@@ -210,6 +220,40 @@ async fn manage_billing_subscription(
         .await?
         .ok_or_else(|| anyhow!("subscription not found"))?;
 
+    if body.intent == ManageSubscriptionIntent::StopCancelation {
+        let subscription_id = SubscriptionId::from_str(&subscription.stripe_subscription_id)
+            .context("failed to parse subscription ID")?;
+
+        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::Cancel => CreateBillingPortalSessionFlowData {
             type_: CreateBillingPortalSessionFlowDataType::SubscriptionCancel,
@@ -228,6 +272,7 @@ async fn manage_billing_subscription(
             ),
             ..Default::default()
         },
+        ManageSubscriptionIntent::StopCancelation => unreachable!(),
     };
 
     let mut params = CreateBillingPortalSession::new(customer_id);
@@ -237,7 +282,7 @@ async fn manage_billing_subscription(
     let session = BillingPortalSession::create(&stripe_client, params).await?;
 
     Ok(Json(ManageBillingSubscriptionResponse {
-        billing_portal_session_url: session.url,
+        billing_portal_session_url: Some(session.url),
     }))
 }
 
@@ -443,6 +488,12 @@ async fn handle_customer_subscription_event(
                     billing_customer_id: ActiveValue::set(billing_customer.id),
                     stripe_subscription_id: ActiveValue::set(subscription.id.to_string()),
                     stripe_subscription_status: ActiveValue::set(subscription.status.into()),
+                    stripe_cancel_at: ActiveValue::set(
+                        subscription
+                            .cancel_at
+                            .and_then(|cancel_at| DateTime::from_timestamp(cancel_at, 0))
+                            .map(|time| time.naive_utc()),
+                    ),
                 },
             )
             .await?;

crates/collab/src/db/queries/billing_subscriptions.rs 🔗

@@ -14,6 +14,7 @@ pub struct UpdateBillingSubscriptionParams {
     pub billing_customer_id: ActiveValue<BillingCustomerId>,
     pub stripe_subscription_id: ActiveValue<String>,
     pub stripe_subscription_status: ActiveValue<StripeSubscriptionStatus>,
+    pub stripe_cancel_at: ActiveValue<Option<DateTime>>,
 }
 
 impl Database {
@@ -49,6 +50,7 @@ impl Database {
                 billing_customer_id: params.billing_customer_id.clone(),
                 stripe_subscription_id: params.stripe_subscription_id.clone(),
                 stripe_subscription_status: params.stripe_subscription_status.clone(),
+                stripe_cancel_at: params.stripe_cancel_at.clone(),
                 ..Default::default()
             })
             .exec(&*tx)

crates/collab/src/db/tables/billing_subscription.rs 🔗

@@ -11,6 +11,7 @@ pub struct Model {
     pub billing_customer_id: BillingCustomerId,
     pub stripe_subscription_id: String,
     pub stripe_subscription_status: StripeSubscriptionStatus,
+    pub stripe_cancel_at: Option<DateTime>,
     pub created_at: DateTime,
 }