collab: Add `has_overdue_invoices` to `billing_customers` (#24239)

Marshall Bowers created

This PR adds a new `has_overdue_invoices` field to the
`billing_customers` table.

This will be used to statefully track whether a customer has overdue
invoices, and also to reset it when the invoices are paid.

We will set this field to `true` when a subscription is canceled with
the reason `payment_failed`.

Release Notes:

- N/A

Change summary

crates/collab/migrations.sqlite/20221109000000_test_schema.sql                            |  1 
crates/collab/migrations/20250204224004_add_has_overdue_invoices_to_billing_customers.sql |  2 
crates/collab/src/api/billing.rs                                                          | 21 
crates/collab/src/db/queries/billing_customers.rs                                         |  2 
crates/collab/src/db/tables/billing_customer.rs                                           |  1 
5 files changed, 27 insertions(+)

Detailed changes

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

@@ -430,6 +430,7 @@ CREATE TABLE IF NOT EXISTS billing_customers (
     id INTEGER PRIMARY KEY AUTOINCREMENT,
     created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
     user_id INTEGER NOT NULL REFERENCES users(id),
+    has_overdue_invoices BOOLEAN NOT NULL DEFAULT FALSE,
     stripe_customer_id TEXT NOT NULL
 );
 

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

@@ -666,6 +666,27 @@ async fn handle_customer_subscription_event(
             .await?
             .ok_or_else(|| anyhow!("billing customer not found"))?;
 
+    let was_canceled_due_to_payment_failure = subscription.status == SubscriptionStatus::Canceled
+        && subscription
+            .cancellation_details
+            .as_ref()
+            .and_then(|details| details.reason)
+            .map_or(false, |reason| {
+                reason == CancellationDetailsReason::PaymentFailed
+            });
+
+    if was_canceled_due_to_payment_failure {
+        app.db
+            .update_billing_customer(
+                billing_customer.id,
+                &UpdateBillingCustomerParams {
+                    has_overdue_invoices: ActiveValue::set(true),
+                    ..Default::default()
+                },
+            )
+            .await?;
+    }
+
     if let Some(existing_subscription) = app
         .db
         .get_billing_subscription_by_stripe_subscription_id(&subscription.id)

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

@@ -10,6 +10,7 @@ pub struct CreateBillingCustomerParams {
 pub struct UpdateBillingCustomerParams {
     pub user_id: ActiveValue<UserId>,
     pub stripe_customer_id: ActiveValue<String>,
+    pub has_overdue_invoices: ActiveValue<bool>,
 }
 
 impl Database {
@@ -43,6 +44,7 @@ impl Database {
                 id: ActiveValue::set(id),
                 user_id: params.user_id.clone(),
                 stripe_customer_id: params.stripe_customer_id.clone(),
+                has_overdue_invoices: params.has_overdue_invoices.clone(),
                 ..Default::default()
             })
             .exec(&*tx)