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:
parent
3f1c091b87
commit
1b2d4ee132
5 changed files with 62 additions and 6 deletions
|
@ -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);
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE billing_subscriptions ADD COLUMN stripe_cancel_at TIMESTAMP WITHOUT TIME ZONE;
|
|
@ -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?;
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue