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
This commit is contained in:
Marshall Bowers 2024-07-29 14:32:13 -04:00 committed by GitHub
parent 0702ed5cd6
commit 085d41b121
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 215 additions and 0 deletions

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

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

View File

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

View File

@ -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
}
}

View File

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

View File

@ -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,
}

View File

@ -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")]

View File

@ -1,3 +1,4 @@
mod billing_subscription_tests;
mod buffer_tests;
mod channel_tests;
mod contributor_tests;

View File

@ -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);
}
}