Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions backend-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion backend-rs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@ password-hash = { version = "0.5", features = ["getrandom"] }
bcrypt = "0.19"
jsonwebtoken = "10"
sha2 = "0.10"
hmac = "0.12"
rand = "0.9"
# Billing / storage / jobs
async-stripe = { version = "0.41", features = ["runtime-tokio-hyper-rustls"] }
aws-sdk-s3 = "1"
apalis = "0.7"
redis = { version = "1", features = ["tokio-comp", "connection-manager"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "http2"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "form", "rustls", "http2"] }
# Validation / errors
garde = { version = "0.23", features = ["derive", "email"] }
thiserror = "2"
Expand Down
1 change: 1 addition & 0 deletions backend-rs/crates/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ argon2.workspace = true
password-hash.workspace = true
bcrypt.workspace = true
sha2.workspace = true
hmac.workspace = true
rand.workspace = true
jsonwebtoken.workspace = true
reqwest.workspace = true
Expand Down
37 changes: 37 additions & 0 deletions backend-rs/crates/api/migrations/0005_billing.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
-- Plans & subscriptions (B4 — billing).
-- Monetary amounts are stored as integer cents (idiomatic; avoids float/decimal).
-- `status` is a varchar (not an enum) so Stripe's status strings map directly.

CREATE TABLE plans (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name varchar(100) NOT NULL UNIQUE,
display_name varchar(255) NOT NULL,
price_monthly_cents integer NOT NULL DEFAULT 0,
price_yearly_cents integer NOT NULL DEFAULT 0,
stripe_price_id_monthly varchar(255),
stripe_price_id_yearly varchar(255),
max_workspaces integer NOT NULL DEFAULT 1,
max_rooms integer NOT NULL DEFAULT 1,
max_hosts_per_room integer NOT NULL DEFAULT 1,
max_viewers_per_room integer NOT NULL DEFAULT 50,
max_storage_gb integer NOT NULL DEFAULT 1,
features jsonb NOT NULL DEFAULT '{}'::jsonb,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE subscriptions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
plan_id uuid REFERENCES plans (id),
stripe_subscription_id varchar(255) UNIQUE,
status varchar(20) NOT NULL DEFAULT 'incomplete',
trial_ends_at timestamptz,
current_period_start timestamptz,
current_period_end timestamptz,
cancelled_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_subscriptions_org ON subscriptions (organization_id, status);
9 changes: 9 additions & 0 deletions backend-rs/crates/api/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ pub struct Config {
pub signaling_secret: String,
/// HS256 key used to mint short-lived client signaling tokens.
pub jwt_secret: String,
/// Public base URL of the frontend (for Stripe redirect URLs).
pub app_url: String,
/// Stripe secret API key.
pub stripe_secret: String,
/// Stripe webhook signing secret (`whsec_…`).
pub stripe_webhook_secret: String,
}

impl Config {
Expand All @@ -35,6 +41,9 @@ impl Config {
signaling_secret: env::var("SIGNALING_SECRET")
.unwrap_or_else(|_| "dev-signaling-secret".to_string()),
jwt_secret: env::var("JWT_SECRET").unwrap_or_else(|_| "dev-jwt-secret".to_string()),
app_url: env::var("APP_URL").unwrap_or_else(|_| "http://localhost:5173".to_string()),
stripe_secret: env::var("STRIPE_SECRET").unwrap_or_default(),
stripe_webhook_secret: env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(),
cors_origins: env::var("CORS_ORIGINS")
.map(|raw| {
raw.split(',')
Expand Down
2 changes: 2 additions & 0 deletions backend-rs/crates/api/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ pub mod analytics;
pub mod chat;
pub mod organizations;
pub mod participants;
pub mod plans;
pub mod rooms;
pub mod sessions;
pub mod subscriptions;
pub mod tokens;
pub mod users;
pub mod workspaces;
21 changes: 21 additions & 0 deletions backend-rs/crates/api/src/db/organizations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,24 @@ pub async fn is_member(pool: &PgPool, organization_id: Uuid, user_id: Uuid) -> A
.fetch_one(pool)
.await?)
}

/// Returns the organization's Stripe customer id, if one has been created.
pub async fn stripe_customer_id(pool: &PgPool, org_id: Uuid) -> AppResult<Option<String>> {
Ok(
sqlx::query_scalar("SELECT stripe_customer_id FROM organizations WHERE id = $1")
.bind(org_id)
.fetch_one(pool)
.await?,
)
}

pub async fn set_stripe_customer(pool: &PgPool, org_id: Uuid, customer_id: &str) -> AppResult<()> {
sqlx::query(
"UPDATE organizations SET stripe_customer_id = $2, updated_at = now() WHERE id = $1",
)
.bind(org_id)
.bind(customer_id)
.execute(pool)
.await?;
Ok(())
}
24 changes: 24 additions & 0 deletions backend-rs/crates/api/src/db/plans.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//! Plan repository.

use sqlx::PgPool;
use uuid::Uuid;

use crate::domain::plan::Plan;
use crate::error::AppResult;

pub async fn list_active(pool: &PgPool) -> AppResult<Vec<Plan>> {
Ok(sqlx::query_as::<_, Plan>(
"SELECT * FROM plans WHERE is_active = true ORDER BY price_monthly_cents",
)
.fetch_all(pool)
.await?)
}

pub async fn find(pool: &PgPool, id: Uuid) -> AppResult<Option<Plan>> {
Ok(
sqlx::query_as::<_, Plan>("SELECT * FROM plans WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?,
)
}
55 changes: 55 additions & 0 deletions backend-rs/crates/api/src/db/subscriptions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! Subscription repository.

use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;

use crate::domain::subscription::Subscription;
use crate::error::AppResult;

pub async fn find_for_org(pool: &PgPool, org_id: Uuid) -> AppResult<Option<Subscription>> {
Ok(sqlx::query_as::<_, Subscription>(
"SELECT * FROM subscriptions WHERE organization_id = $1 ORDER BY created_at DESC LIMIT 1",
)
.bind(org_id)
.fetch_optional(pool)
.await?)
}

/// Fields synced from a Stripe subscription/checkout event.
pub struct UpsertSubscription {
pub organization_id: Uuid,
pub plan_id: Option<Uuid>,
pub stripe_subscription_id: String,
pub status: String,
pub current_period_start: Option<DateTime<Utc>>,
pub current_period_end: Option<DateTime<Utc>>,
pub cancelled_at: Option<DateTime<Utc>>,
}

/// Inserts or updates a subscription keyed by its Stripe subscription id.
pub async fn upsert(pool: &PgPool, s: UpsertSubscription) -> AppResult<()> {
sqlx::query(
"INSERT INTO subscriptions \
(organization_id, plan_id, stripe_subscription_id, status, \
current_period_start, current_period_end, cancelled_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
ON CONFLICT (stripe_subscription_id) DO UPDATE SET \
status = EXCLUDED.status, \
plan_id = COALESCE(EXCLUDED.plan_id, subscriptions.plan_id), \
current_period_start = EXCLUDED.current_period_start, \
current_period_end = EXCLUDED.current_period_end, \
cancelled_at = EXCLUDED.cancelled_at, \
updated_at = now()",
)
.bind(s.organization_id)
.bind(s.plan_id)
.bind(&s.stripe_subscription_id)
.bind(&s.status)
.bind(s.current_period_start)
.bind(s.current_period_end)
.bind(s.cancelled_at)
.execute(pool)
.await?;
Ok(())
}
2 changes: 2 additions & 0 deletions backend-rs/crates/api/src/domain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ pub mod alert;
pub mod chat;
pub mod organization;
pub mod participant;
pub mod plan;
pub mod room;
pub mod subscription;
pub mod user;
pub mod workspace;
23 changes: 23 additions & 0 deletions backend-rs/crates/api/src/domain/plan.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//! Plan domain model.

use serde::Serialize;
use sqlx::FromRow;
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, FromRow)]
pub struct Plan {
pub id: Uuid,
pub name: String,
pub display_name: String,
pub price_monthly_cents: i32,
pub price_yearly_cents: i32,
pub stripe_price_id_monthly: Option<String>,
pub stripe_price_id_yearly: Option<String>,
pub max_workspaces: i32,
pub max_rooms: i32,
pub max_hosts_per_room: i32,
pub max_viewers_per_room: i32,
pub max_storage_gb: i32,
pub features: serde_json::Value,
pub is_active: bool,
}
27 changes: 27 additions & 0 deletions backend-rs/crates/api/src/domain/subscription.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//! Subscription domain model.

use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::FromRow;
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, FromRow)]
pub struct Subscription {
pub id: Uuid,
pub organization_id: Uuid,
pub plan_id: Option<Uuid>,
pub stripe_subscription_id: Option<String>,
pub status: String,
pub trial_ends_at: Option<DateTime<Utc>>,
pub current_period_start: Option<DateTime<Utc>>,
pub current_period_end: Option<DateTime<Utc>>,
pub cancelled_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

impl Subscription {
pub fn is_active(&self) -> bool {
matches!(self.status.as_str(), "active" | "trialing")
}
}
Loading
Loading