collab: Record cancellation reason on billing subscriptions (#22853)

Marshall Bowers created

This PR updates the `billing_subscriptions` in the database to record
the cancellation reason from Stripe.

We're primarily interested in this so we can check for subscriptions
that were canceled for being `past_due`.

Release Notes:

- N/A

Change summary

crates/collab/migrations.sqlite/20221109000000_test_schema.sql                                      |  3 
crates/collab/migrations/20250108184547_add_stripe_cancellation_reason_to_billing_subscriptions.sql |  2 
crates/collab/src/api/billing.rs                                                                    | 26 
crates/collab/src/db/queries/billing_subscriptions.rs                                               |  4 
crates/collab/src/db/tables/billing_subscription.rs                                                 | 16 
5 files changed, 43 insertions(+), 8 deletions(-)

Detailed changes

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

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

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

@@ -12,8 +12,8 @@ use serde::{Deserialize, Serialize};
 use serde_json::json;
 use std::{str::FromStr, sync::Arc, time::Duration};
 use stripe::{
-    BillingPortalSession, CreateBillingPortalSession, CreateBillingPortalSessionFlowData,
-    CreateBillingPortalSessionFlowDataAfterCompletion,
+    BillingPortalSession, CancellationDetailsReason, CreateBillingPortalSession,
+    CreateBillingPortalSessionFlowData, CreateBillingPortalSessionFlowDataAfterCompletion,
     CreateBillingPortalSessionFlowDataAfterCompletionRedirect,
     CreateBillingPortalSessionFlowDataType, CreateCustomer, Customer, CustomerId, EventObject,
     EventType, Expandable, ListEvents, Subscription, SubscriptionId, SubscriptionStatus,
@@ -21,8 +21,10 @@ use stripe::{
 use util::ResultExt;
 
 use crate::api::events::SnowflakeRow;
+use crate::db::billing_subscription::{StripeCancellationReason, StripeSubscriptionStatus};
 use crate::llm::{DEFAULT_MAX_MONTHLY_SPEND, FREE_TIER_MONTHLY_SPENDING_LIMIT};
 use crate::rpc::{ResultExt as _, Server};
+use crate::{db::UserId, llm::db::LlmDatabase};
 use crate::{
     db::{
         billing_customer, BillingSubscriptionId, CreateBillingCustomerParams,
@@ -32,10 +34,6 @@ use crate::{
     },
     stripe_billing::StripeBilling,
 };
-use crate::{
-    db::{billing_subscription::StripeSubscriptionStatus, UserId},
-    llm::db::LlmDatabase,
-};
 use crate::{AppState, Cents, Error, Result};
 
 pub fn router() -> Router {
@@ -679,6 +677,12 @@ async fn handle_customer_subscription_event(
                             .and_then(|cancel_at| DateTime::from_timestamp(cancel_at, 0))
                             .map(|time| time.naive_utc()),
                     ),
+                    stripe_cancellation_reason: ActiveValue::set(
+                        subscription
+                            .cancellation_details
+                            .and_then(|details| details.reason)
+                            .map(|reason| reason.into()),
+                    ),
                 },
             )
             .await?;
@@ -791,6 +795,16 @@ impl From<SubscriptionStatus> for StripeSubscriptionStatus {
     }
 }
 
+impl From<CancellationDetailsReason> for StripeCancellationReason {
+    fn from(value: CancellationDetailsReason) -> Self {
+        match value {
+            CancellationDetailsReason::CancellationRequested => Self::CancellationRequested,
+            CancellationDetailsReason::PaymentDisputed => Self::PaymentDisputed,
+            CancellationDetailsReason::PaymentFailed => Self::PaymentFailed,
+        }
+    }
+}
+
 /// Finds or creates a billing customer using the provided customer.
 async fn find_or_create_billing_customer(
     app: &Arc<AppState>,

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

@@ -1,4 +1,4 @@
-use crate::db::billing_subscription::StripeSubscriptionStatus;
+use crate::db::billing_subscription::{StripeCancellationReason, StripeSubscriptionStatus};
 
 use super::*;
 
@@ -15,6 +15,7 @@ pub struct UpdateBillingSubscriptionParams {
     pub stripe_subscription_id: ActiveValue<String>,
     pub stripe_subscription_status: ActiveValue<StripeSubscriptionStatus>,
     pub stripe_cancel_at: ActiveValue<Option<DateTime>>,
+    pub stripe_cancellation_reason: ActiveValue<Option<StripeCancellationReason>>,
 }
 
 impl Database {
@@ -51,6 +52,7 @@ impl Database {
                 stripe_subscription_id: params.stripe_subscription_id.clone(),
                 stripe_subscription_status: params.stripe_subscription_status.clone(),
                 stripe_cancel_at: params.stripe_cancel_at.clone(),
+                stripe_cancellation_reason: params.stripe_cancellation_reason.clone(),
                 ..Default::default()
             })
             .exec(&*tx)

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

@@ -12,6 +12,7 @@ pub struct Model {
     pub stripe_subscription_id: String,
     pub stripe_subscription_status: StripeSubscriptionStatus,
     pub stripe_cancel_at: Option<DateTime>,
+    pub stripe_cancellation_reason: Option<StripeCancellationReason>,
     pub created_at: DateTime,
 }
 
@@ -73,3 +74,18 @@ impl StripeSubscriptionStatus {
         }
     }
 }
+
+/// The cancellation reason for a Stripe subscription.
+///
+/// [Stripe docs](https://docs.stripe.com/api/subscriptions/object#subscription_object-cancellation_details-reason)
+#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Hash, Serialize)]
+#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
+#[serde(rename_all = "snake_case")]
+pub enum StripeCancellationReason {
+    #[sea_orm(string_value = "cancellation_requested")]
+    CancellationRequested,
+    #[sea_orm(string_value = "payment_disputed")]
+    PaymentDisputed,
+    #[sea_orm(string_value = "payment_failed")]
+    PaymentFailed,
+}