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

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
This commit is contained in:
Marshall Bowers 2025-01-08 14:38:10 -05:00 committed by GitHub
parent 69dde8e31d
commit b78396505f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 43 additions and 8 deletions

View file

@ -438,7 +438,8 @@ CREATE TABLE IF NOT EXISTS billing_subscriptions (
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 stripe_cancel_at TIMESTAMP,
stripe_cancellation_reason TEXT
); );
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,2 @@
alter table billing_subscriptions
add column stripe_cancellation_reason text;

View file

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

View file

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

View file

@ -12,6 +12,7 @@ pub struct Model {
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 stripe_cancel_at: Option<DateTime>,
pub stripe_cancellation_reason: Option<StripeCancellationReason>,
pub created_at: DateTime, 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,
}