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

Release Notes:

- N/A

Co-authored-by: Marshall <marshall@zed.dev>
This commit is contained in:
Max Brunsfeld 2024-07-31 13:36:46 -07:00 committed by GitHub
parent 3f1c091b87
commit 1b2d4ee132
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 62 additions and 6 deletions

View file

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

View file

@ -0,0 +1 @@
ALTER TABLE billing_subscriptions ADD COLUMN stripe_cancel_at TIMESTAMP WITHOUT TIME ZONE;

View file

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

View file

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

View file

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