collab: Prevent users from creating a new subscription when they have overdue subscriptions (#22870)

This PR adjusts the create billing subscription endpoint to prevent
initiating a checkout flow when a user has existing subscriptions that
are overdue.

A subscription is considered "overdue" when either:

- The status is `past_due`
- The status is `canceled` and the cancellation reason is
`payment_failed`

In Stripe, when a subscription has failed payment a certain number of
times, it is canceled with a reason of `payment_failed`. However, today
there is nothing stopping someone from simply creating a new
subscription without paying the outstanding invoices. With this change a
user will need to reconcile their outstanding invoices before they can
sign up for a new subscription.

Release Notes:

- N/A
This commit is contained in:
Marshall Bowers
2025-01-08 22:50:48 +00:00
committed by GitHub
parent a0fca24e3f
commit a4dd92fe06
3 changed files with 162 additions and 1 deletions
@@ -7,6 +7,7 @@ pub struct CreateBillingSubscriptionParams {
pub billing_customer_id: BillingCustomerId,
pub stripe_subscription_id: String,
pub stripe_subscription_status: StripeSubscriptionStatus,
pub stripe_cancellation_reason: Option<StripeCancellationReason>,
}
#[derive(Debug, Default)]
@@ -29,6 +30,7 @@ impl Database {
billing_customer_id: ActiveValue::set(params.billing_customer_id),
stripe_subscription_id: ActiveValue::set(params.stripe_subscription_id.clone()),
stripe_subscription_status: ActiveValue::set(params.stripe_subscription_status),
stripe_cancellation_reason: ActiveValue::set(params.stripe_cancellation_reason),
..Default::default()
})
.exec_without_returning(&*tx)
@@ -168,4 +170,40 @@ impl Database {
})
.await
}
/// Returns whether the user has any overdue billing subscriptions.
pub async fn has_overdue_billing_subscriptions(&self, user_id: UserId) -> Result<bool> {
Ok(self.count_overdue_billing_subscriptions(user_id).await? > 0)
}
/// Returns the count of the overdue billing subscriptions for the user with the specified ID.
///
/// This includes subscriptions:
/// - Whose status is `past_due`
/// - Whose status is `canceled` and the cancellation reason is `payment_failed`
pub async fn count_overdue_billing_subscriptions(&self, user_id: UserId) -> Result<usize> {
self.transaction(|tx| async move {
let past_due = billing_subscription::Column::StripeSubscriptionStatus
.eq(StripeSubscriptionStatus::PastDue);
let payment_failed = billing_subscription::Column::StripeSubscriptionStatus
.eq(StripeSubscriptionStatus::Canceled)
.and(
billing_subscription::Column::StripeCancellationReason
.eq(StripeCancellationReason::PaymentFailed),
);
let count = billing_subscription::Entity::find()
.inner_join(billing_customer::Entity)
.filter(
billing_customer::Column::UserId
.eq(user_id)
.and(past_due.or(payment_failed)),
)
.count(&*tx)
.await?;
Ok(count as usize)
})
.await
}
}