diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 086019314ad533a5a4a90b0c63a59688180f5c09..01b18e8fd7963e5e1d84c701c0ee6897d76c73c5 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/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); diff --git a/crates/collab/migrations/20240731120800_add_stripe_cancel_at_to_billing_subscriptions.sql b/crates/collab/migrations/20240731120800_add_stripe_cancel_at_to_billing_subscriptions.sql new file mode 100644 index 0000000000000000000000000000000000000000..b09640bb1eabafda7b9eef9d0763db31d78d1e96 --- /dev/null +++ b/crates/collab/migrations/20240731120800_add_stripe_cancel_at_to_billing_subscriptions.sql @@ -0,0 +1 @@ +ALTER TABLE billing_subscriptions ADD COLUMN stripe_cancel_at TIMESTAMP WITHOUT TIME ZONE; diff --git a/crates/collab/src/api/billing.rs b/crates/collab/src/api/billing.rs index c3e9b29ee643b3fbb61a363143c533ed3b73729b..b33344c53cac05785e107b141c4c7c46c7391939 100644 --- a/crates/collab/src/api/billing.rs +++ b/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, /// 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, } /// 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?; diff --git a/crates/collab/src/db/queries/billing_subscriptions.rs b/crates/collab/src/db/queries/billing_subscriptions.rs index 72494d1b3a59beb0a93fb2e0f1e27bbc0a20056b..7a7ba31f166988ace0a82d62a969b557e4371894 100644 --- a/crates/collab/src/db/queries/billing_subscriptions.rs +++ b/crates/collab/src/db/queries/billing_subscriptions.rs @@ -14,6 +14,7 @@ pub struct UpdateBillingSubscriptionParams { pub billing_customer_id: ActiveValue, pub stripe_subscription_id: ActiveValue, pub stripe_subscription_status: ActiveValue, + pub stripe_cancel_at: ActiveValue>, } 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) diff --git a/crates/collab/src/db/tables/billing_subscription.rs b/crates/collab/src/db/tables/billing_subscription.rs index d5d61c3420ed3c8d1ab309cd984b45e46f3838a2..b2b345d475353a2b2fe664f815dea49bb4440a2f 100644 --- a/crates/collab/src/db/tables/billing_subscription.rs +++ b/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, pub created_at: DateTime, }