From 085d41b121ae948341bd88b4d7f649c5527bcbad Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 29 Jul 2024 14:32:13 -0400 Subject: [PATCH] collab: Add `billing_subscriptions` table (#15448) 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 --- .../20221109000000_test_schema.sql | 13 ++++ ...0240729170526_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 + .../src/db/queries/billing_subscriptions.rs | 55 +++++++++++++++ crates/collab/src/db/tables.rs | 1 + .../src/db/tables/billing_subscription.rs | 58 +++++++++++++++ crates/collab/src/db/tables/user.rs | 2 + crates/collab/src/db/tests.rs | 1 + .../db/tests/billing_subscription_tests.rs | 70 +++++++++++++++++++ 11 files changed, 215 insertions(+) create mode 100644 crates/collab/migrations/20240729170526_add_billing_subscription.sql create mode 100644 crates/collab/src/db/queries/billing_subscriptions.rs create mode 100644 crates/collab/src/db/tables/billing_subscription.rs create mode 100644 crates/collab/src/db/tests/billing_subscription_tests.rs diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 8e3068645c..161ab2c03b 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/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); diff --git a/crates/collab/migrations/20240729170526_add_billing_subscription.sql b/crates/collab/migrations/20240729170526_add_billing_subscription.sql new file mode 100644 index 0000000000..acec4b3ddb --- /dev/null +++ b/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); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index d4ed9ea5e7..58f08827ec 100644 --- a/crates/collab/src/db.rs +++ b/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; diff --git a/crates/collab/src/db/ids.rs b/crates/collab/src/db/ids.rs index 699189a1bd..67c833ab26 100644 --- a/crates/collab/src/db/ids.rs +++ b/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); diff --git a/crates/collab/src/db/queries.rs b/crates/collab/src/db/queries.rs index 31301a0df8..45e259ceb2 100644 --- a/crates/collab/src/db/queries.rs +++ b/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; diff --git a/crates/collab/src/db/queries/billing_subscriptions.rs b/crates/collab/src/db/queries/billing_subscriptions.rs new file mode 100644 index 0000000000..ce782e5eb3 --- /dev/null +++ b/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> { + 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 + } +} diff --git a/crates/collab/src/db/tables.rs b/crates/collab/src/db/tables.rs index b9a3c5d9d8..2e5a9bebc1 100644 --- a/crates/collab/src/db/tables.rs +++ b/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; diff --git a/crates/collab/src/db/tables/billing_subscription.rs b/crates/collab/src/db/tables/billing_subscription.rs new file mode 100644 index 0000000000..936352ff1a --- /dev/null +++ b/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 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, +} diff --git a/crates/collab/src/db/tables/user.rs b/crates/collab/src/db/tables/user.rs index 5c9166adab..979abe9299 100644 --- a/crates/collab/src/db/tables/user.rs +++ b/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")] diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index b8460c41e8..f6730f96f2 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -1,3 +1,4 @@ +mod billing_subscription_tests; mod buffer_tests; mod channel_tests; mod contributor_tests; diff --git a/crates/collab/src/db/tests/billing_subscription_tests.rs b/crates/collab/src/db/tests/billing_subscription_tests.rs new file mode 100644 index 0000000000..26a86fe449 --- /dev/null +++ b/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) { + // 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); + } +}