collab: Add `billing_subscriptions` table (#15448)

Marshall Bowers created

This PR adds a new `billing_subscriptions` table to the database, as
well as some accompanying models/queries.

In this table we store a minimal amount of data from Stripe:

- The Stripe customer ID
- The Stripe subscription ID
- The status of the Stripe subscription

This should be enough for interactions with the Stripe API (e.g., to
[create a customer portal
session](https://docs.stripe.com/api/customer_portal/sessions/create)),
as well as determine whether a subscription is active (based on the
`status`).

Release Notes:

- N/A

Change summary

crates/collab/migrations.sqlite/20221109000000_test_schema.sql       | 13 
crates/collab/migrations/20240729170526_add_billing_subscription.sql | 12 
crates/collab/src/db.rs                                              |  1 
crates/collab/src/db/ids.rs                                          |  1 
crates/collab/src/db/queries.rs                                      |  1 
crates/collab/src/db/queries/billing_subscriptions.rs                | 55 
crates/collab/src/db/tables.rs                                       |  1 
crates/collab/src/db/tables/billing_subscription.rs                  | 58 
crates/collab/src/db/tables/user.rs                                  |  2 
crates/collab/src/db/tests.rs                                        |  1 
crates/collab/src/db/tests/billing_subscription_tests.rs             | 70 
11 files changed, 215 insertions(+)

Detailed changes

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

@@ -416,3 +416,16 @@ CREATE TABLE dev_server_projects (
     dev_server_id INTEGER NOT NULL REFERENCES dev_servers(id),
     paths TEXT NOT NULL
 );
+
+CREATE TABLE IF NOT EXISTS billing_subscriptions (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    user_id INTEGER NOT NULL REFERENCES users(id),
+    stripe_customer_id TEXT NOT NULL,
+    stripe_subscription_id TEXT NOT NULL,
+    stripe_subscription_status TEXT NOT NULL
+);
+
+CREATE INDEX "ix_billing_subscriptions_on_user_id" ON billing_subscriptions (user_id);
+CREATE INDEX "ix_billing_subscriptions_on_stripe_customer_id" ON billing_subscriptions (stripe_customer_id);
+CREATE UNIQUE INDEX "uix_billing_subscriptions_on_stripe_subscription_id" ON billing_subscriptions (stripe_subscription_id);

crates/collab/migrations/20240729170526_add_billing_subscription.sql 🔗

@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS billing_subscriptions (
+    id SERIAL PRIMARY KEY,
+    created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(),
+    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    stripe_customer_id TEXT NOT NULL,
+    stripe_subscription_id TEXT NOT NULL,
+    stripe_subscription_status TEXT NOT NULL
+);
+
+CREATE INDEX "ix_billing_subscriptions_on_user_id" ON billing_subscriptions (user_id);
+CREATE INDEX "ix_billing_subscriptions_on_stripe_customer_id" ON billing_subscriptions (stripe_customer_id);
+CREATE UNIQUE INDEX "uix_billing_subscriptions_on_stripe_subscription_id" ON billing_subscriptions (stripe_subscription_id);

crates/collab/src/db.rs 🔗

@@ -45,6 +45,7 @@ use tokio::sync::{Mutex, OwnedMutexGuard};
 pub use tests::TestDb;
 
 pub use ids::*;
+pub use queries::billing_subscriptions::CreateBillingSubscriptionParams;
 pub use queries::contributors::ContributorSelector;
 pub use sea_orm::ConnectOptions;
 pub use tables::user::Model as User;

crates/collab/src/db/ids.rs 🔗

@@ -68,6 +68,7 @@ macro_rules! id_type {
 }
 
 id_type!(AccessTokenId);
+id_type!(BillingSubscriptionId);
 id_type!(BufferId);
 id_type!(ChannelBufferCollaboratorId);
 id_type!(ChannelChatParticipantId);

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

@@ -1,6 +1,7 @@
 use super::*;
 
 pub mod access_tokens;
+pub mod billing_subscriptions;
 pub mod buffers;
 pub mod channels;
 pub mod contacts;

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

@@ -0,0 +1,55 @@
+use crate::db::billing_subscription::StripeSubscriptionStatus;
+
+use super::*;
+
+#[derive(Debug)]
+pub struct CreateBillingSubscriptionParams {
+    pub user_id: UserId,
+    pub stripe_customer_id: String,
+    pub stripe_subscription_id: String,
+    pub stripe_subscription_status: StripeSubscriptionStatus,
+}
+
+impl Database {
+    /// Creates a new billing subscription.
+    pub async fn create_billing_subscription(
+        &self,
+        params: &CreateBillingSubscriptionParams,
+    ) -> Result<()> {
+        self.transaction(|tx| async move {
+            billing_subscription::Entity::insert(billing_subscription::ActiveModel {
+                user_id: ActiveValue::set(params.user_id),
+                stripe_customer_id: ActiveValue::set(params.stripe_customer_id.clone()),
+                stripe_subscription_id: ActiveValue::set(params.stripe_subscription_id.clone()),
+                stripe_subscription_status: ActiveValue::set(params.stripe_subscription_status),
+                ..Default::default()
+            })
+            .exec_without_returning(&*tx)
+            .await?;
+
+            Ok(())
+        })
+        .await
+    }
+
+    /// Returns all of the active billing subscriptions for the user with the specified ID.
+    pub async fn get_active_billing_subscriptions(
+        &self,
+        user_id: UserId,
+    ) -> Result<Vec<billing_subscription::Model>> {
+        self.transaction(|tx| async move {
+            let subscriptions = billing_subscription::Entity::find()
+                .filter(
+                    billing_subscription::Column::UserId.eq(user_id).and(
+                        billing_subscription::Column::StripeSubscriptionStatus
+                            .eq(StripeSubscriptionStatus::Active),
+                    ),
+                )
+                .all(&*tx)
+                .await?;
+
+            Ok(subscriptions)
+        })
+        .await
+    }
+}

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

@@ -1,4 +1,5 @@
 pub mod access_token;
+pub mod billing_subscription;
 pub mod buffer;
 pub mod buffer_operation;
 pub mod buffer_snapshot;

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

@@ -0,0 +1,58 @@
+use crate::db::{BillingSubscriptionId, UserId};
+use sea_orm::entity::prelude::*;
+
+/// A billing subscription.
+#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "billing_subscriptions")]
+pub struct Model {
+    #[sea_orm(primary_key)]
+    pub id: BillingSubscriptionId,
+    pub user_id: UserId,
+    pub stripe_customer_id: String,
+    pub stripe_subscription_id: String,
+    pub stripe_subscription_status: StripeSubscriptionStatus,
+    pub created_at: DateTime,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+    #[sea_orm(
+        belongs_to = "super::user::Entity",
+        from = "Column::UserId",
+        to = "super::user::Column::Id"
+    )]
+    User,
+}
+
+impl Related<super::user::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::User.def()
+    }
+}
+
+impl ActiveModelBehavior for ActiveModel {}
+
+/// The status of a Stripe subscription.
+///
+/// [Stripe docs](https://docs.stripe.com/api/subscriptions/object#subscription_object-status)
+#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)]
+#[sea_orm(rs_type = "String", db_type = "String(None)")]
+pub enum StripeSubscriptionStatus {
+    #[default]
+    #[sea_orm(string_value = "incomplete")]
+    Incomplete,
+    #[sea_orm(string_value = "incomplete_expired")]
+    IncompleteExpired,
+    #[sea_orm(string_value = "trialing")]
+    Trialing,
+    #[sea_orm(string_value = "active")]
+    Active,
+    #[sea_orm(string_value = "past_due")]
+    PastDue,
+    #[sea_orm(string_value = "canceled")]
+    Canceled,
+    #[sea_orm(string_value = "unpaid")]
+    Unpaid,
+    #[sea_orm(string_value = "paused")]
+    Paused,
+}

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

@@ -24,6 +24,8 @@ pub struct Model {
 pub enum Relation {
     #[sea_orm(has_many = "super::access_token::Entity")]
     AccessToken,
+    #[sea_orm(has_many = "super::billing_subscription::Entity")]
+    BillingSubscription,
     #[sea_orm(has_one = "super::room_participant::Entity")]
     RoomParticipant,
     #[sea_orm(has_many = "super::project::Entity")]

crates/collab/src/db/tests/billing_subscription_tests.rs 🔗

@@ -0,0 +1,70 @@
+use std::sync::Arc;
+
+use crate::db::billing_subscription::StripeSubscriptionStatus;
+use crate::db::tests::new_test_user;
+use crate::db::CreateBillingSubscriptionParams;
+use crate::test_both_dbs;
+
+use super::Database;
+
+test_both_dbs!(
+    test_get_active_billing_subscriptions,
+    test_get_active_billing_subscriptions_postgres,
+    test_get_active_billing_subscriptions_sqlite
+);
+
+async fn test_get_active_billing_subscriptions(db: &Arc<Database>) {
+    // A user with no subscription has no active billing subscriptions.
+    {
+        let user_id = new_test_user(db, "no-subscription-user@example.com").await;
+        let subscriptions = db.get_active_billing_subscriptions(user_id).await.unwrap();
+
+        assert_eq!(subscriptions.len(), 0);
+    }
+
+    // A user with an active subscription has one active billing subscription.
+    {
+        let user_id = new_test_user(db, "active-user@example.com").await;
+        db.create_billing_subscription(&CreateBillingSubscriptionParams {
+            user_id,
+            stripe_customer_id: "cus_active_user".into(),
+            stripe_subscription_id: "sub_active_user".into(),
+            stripe_subscription_status: StripeSubscriptionStatus::Active,
+        })
+        .await
+        .unwrap();
+
+        let subscriptions = db.get_active_billing_subscriptions(user_id).await.unwrap();
+        assert_eq!(subscriptions.len(), 1);
+
+        let subscription = &subscriptions[0];
+        assert_eq!(
+            subscription.stripe_customer_id,
+            "cus_active_user".to_string()
+        );
+        assert_eq!(
+            subscription.stripe_subscription_id,
+            "sub_active_user".to_string()
+        );
+        assert_eq!(
+            subscription.stripe_subscription_status,
+            StripeSubscriptionStatus::Active
+        );
+    }
+
+    // A user with a past-due subscription has no active billing subscriptions.
+    {
+        let user_id = new_test_user(db, "past-due-user@example.com").await;
+        db.create_billing_subscription(&CreateBillingSubscriptionParams {
+            user_id,
+            stripe_customer_id: "cus_past_due_user".into(),
+            stripe_subscription_id: "sub_past_due_user".into(),
+            stripe_subscription_status: StripeSubscriptionStatus::PastDue,
+        })
+        .await
+        .unwrap();
+
+        let subscriptions = db.get_active_billing_subscriptions(user_id).await.unwrap();
+        assert_eq!(subscriptions.len(), 0);
+    }
+}