diff --git a/backend-rs/Cargo.lock b/backend-rs/Cargo.lock index 3b5f484..c31394f 100644 --- a/backend-rs/Cargo.lock +++ b/backend-rs/Cargo.lock @@ -939,7 +939,16 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac", + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", ] [[package]] @@ -1917,6 +1926,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -2449,7 +2459,7 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac", + "hmac 0.13.0", "itoa", "log", "md-5", @@ -2838,6 +2848,7 @@ dependencies = [ "chrono", "dotenvy", "garde", + "hmac 0.12.1", "jsonwebtoken", "metrics-exporter-prometheus", "password-hash", diff --git a/backend-rs/Cargo.toml b/backend-rs/Cargo.toml index 674616e..d1f3b56 100644 --- a/backend-rs/Cargo.toml +++ b/backend-rs/Cargo.toml @@ -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" diff --git a/backend-rs/crates/api/Cargo.toml b/backend-rs/crates/api/Cargo.toml index 1493666..f2c04cf 100644 --- a/backend-rs/crates/api/Cargo.toml +++ b/backend-rs/crates/api/Cargo.toml @@ -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 diff --git a/backend-rs/crates/api/migrations/0005_billing.sql b/backend-rs/crates/api/migrations/0005_billing.sql new file mode 100644 index 0000000..cd08361 --- /dev/null +++ b/backend-rs/crates/api/migrations/0005_billing.sql @@ -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); diff --git a/backend-rs/crates/api/src/config.rs b/backend-rs/crates/api/src/config.rs index 839aa32..30abf9a 100644 --- a/backend-rs/crates/api/src/config.rs +++ b/backend-rs/crates/api/src/config.rs @@ -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 { @@ -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(',') diff --git a/backend-rs/crates/api/src/db/mod.rs b/backend-rs/crates/api/src/db/mod.rs index 8c6d7da..075caca 100644 --- a/backend-rs/crates/api/src/db/mod.rs +++ b/backend-rs/crates/api/src/db/mod.rs @@ -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; diff --git a/backend-rs/crates/api/src/db/organizations.rs b/backend-rs/crates/api/src/db/organizations.rs index 7ccd0fd..3ef0b30 100644 --- a/backend-rs/crates/api/src/db/organizations.rs +++ b/backend-rs/crates/api/src/db/organizations.rs @@ -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> { + 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(()) +} diff --git a/backend-rs/crates/api/src/db/plans.rs b/backend-rs/crates/api/src/db/plans.rs new file mode 100644 index 0000000..c878f4a --- /dev/null +++ b/backend-rs/crates/api/src/db/plans.rs @@ -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> { + 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> { + Ok( + sqlx::query_as::<_, Plan>("SELECT * FROM plans WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?, + ) +} diff --git a/backend-rs/crates/api/src/db/subscriptions.rs b/backend-rs/crates/api/src/db/subscriptions.rs new file mode 100644 index 0000000..7c07d11 --- /dev/null +++ b/backend-rs/crates/api/src/db/subscriptions.rs @@ -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> { + 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, + pub stripe_subscription_id: String, + pub status: String, + pub current_period_start: Option>, + pub current_period_end: Option>, + pub cancelled_at: Option>, +} + +/// 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(()) +} diff --git a/backend-rs/crates/api/src/domain/mod.rs b/backend-rs/crates/api/src/domain/mod.rs index 73f56d6..717f664 100644 --- a/backend-rs/crates/api/src/domain/mod.rs +++ b/backend-rs/crates/api/src/domain/mod.rs @@ -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; diff --git a/backend-rs/crates/api/src/domain/plan.rs b/backend-rs/crates/api/src/domain/plan.rs new file mode 100644 index 0000000..ceffe80 --- /dev/null +++ b/backend-rs/crates/api/src/domain/plan.rs @@ -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, + pub stripe_price_id_yearly: Option, + 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, +} diff --git a/backend-rs/crates/api/src/domain/subscription.rs b/backend-rs/crates/api/src/domain/subscription.rs new file mode 100644 index 0000000..4179acf --- /dev/null +++ b/backend-rs/crates/api/src/domain/subscription.rs @@ -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, + pub stripe_subscription_id: Option, + pub status: String, + pub trial_ends_at: Option>, + pub current_period_start: Option>, + pub current_period_end: Option>, + pub cancelled_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Subscription { + pub fn is_active(&self) -> bool { + matches!(self.status.as_str(), "active" | "trialing") + } +} diff --git a/backend-rs/crates/api/src/http/billing.rs b/backend-rs/crates/api/src/http/billing.rs new file mode 100644 index 0000000..c4ad8ff --- /dev/null +++ b/backend-rs/crates/api/src/http/billing.rs @@ -0,0 +1,247 @@ +//! Billing endpoints (parity with the Laravel `BillingController` + +//! `StripeWebhookController`): plans, Checkout, Billing Portal, current +//! subscription, and the signed Stripe webhook. + +use axum::body::Bytes; +use axum::extract::{Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use chrono::{DateTime, Utc}; +use garde::Validate; +use serde::Deserialize; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::db; +use crate::db::subscriptions::UpsertSubscription; +use crate::domain::plan::Plan; +use crate::error::{AppError, AppResult}; +use crate::http::guard; +use crate::state::AppState; +use crate::stripe; + +pub fn routes() -> Router { + Router::new() + .route("/v1/plans", get(plans)) + .route("/v1/billing/subscribe", post(subscribe)) + .route("/v1/billing/portal", post(portal)) + .route("/v1/billing/subscription", get(subscription)) + .route("/v1/webhooks/stripe", post(webhook)) +} + +async fn plans(State(state): State, _user: AuthUser) -> AppResult>> { + Ok(Json(db::plans::list_active(&state.db).await?)) +} + +#[derive(Debug, Deserialize, Validate)] +struct SubscribeRequest { + #[garde(skip)] + organization_id: Uuid, + #[garde(skip)] + plan_id: Uuid, + #[garde(skip)] + billing_period: Option, +} + +async fn subscribe( + State(state): State, + AuthUser(user): AuthUser, + Json(req): Json, +) -> AppResult> { + guard::ensure_member(&state, req.organization_id, user.id).await?; + + let plan = db::plans::find(&state.db, req.plan_id) + .await? + .ok_or(AppError::NotFound)?; + + let yearly = req.billing_period.as_deref() == Some("yearly"); + let price_id = if yearly { + plan.stripe_price_id_yearly + } else { + plan.stripe_price_id_monthly + } + .ok_or_else(|| AppError::Validation("this plan has no price for that billing period".into()))?; + + let customer = ensure_customer(&state, req.organization_id, &user.name, &user.email).await?; + + let success_url = format!("{}/settings/billing?status=success", state.config.app_url); + let cancel_url = format!("{}/settings/billing?status=cancelled", state.config.app_url); + + let checkout_url = stripe::create_checkout_session( + &state, + &customer, + &price_id, + &success_url, + &cancel_url, + &req.organization_id.to_string(), + &req.plan_id.to_string(), + ) + .await?; + + Ok(Json(json!({ "checkout_url": checkout_url }))) +} + +#[derive(Debug, Deserialize)] +struct PortalRequest { + organization_id: Uuid, +} + +async fn portal( + State(state): State, + AuthUser(user): AuthUser, + Json(req): Json, +) -> AppResult> { + guard::ensure_member(&state, req.organization_id, user.id).await?; + + let customer = db::organizations::stripe_customer_id(&state.db, req.organization_id) + .await? + .ok_or_else(|| AppError::Validation("no billing account for this organization".into()))?; + + let return_url = format!("{}/settings/billing", state.config.app_url); + let portal_url = stripe::create_portal_session(&state, &customer, &return_url).await?; + + Ok(Json(json!({ "portal_url": portal_url }))) +} + +#[derive(Debug, Deserialize)] +struct SubscriptionQuery { + organization_id: Uuid, +} + +async fn subscription( + State(state): State, + AuthUser(user): AuthUser, + Query(q): Query, +) -> AppResult> { + guard::ensure_member(&state, q.organization_id, user.id).await?; + + let subscription = db::subscriptions::find_for_org(&state.db, q.organization_id).await?; + let plan = match subscription.as_ref().and_then(|s| s.plan_id) { + Some(plan_id) => db::plans::find(&state.db, plan_id).await?, + None => None, + }; + let has_active = subscription.as_ref().is_some_and(|s| s.is_active()); + + Ok(Json(json!({ + "subscription": subscription, + "plan": plan, + "has_active_subscription": has_active, + }))) +} + +/// Stripe webhook. Verifies the signature against the raw body, then syncs +/// subscription state. Always returns `200` for handled/ignored events so +/// Stripe does not retry indefinitely on events we don't care about. +async fn webhook( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> AppResult { + let signature = headers + .get("stripe-signature") + .and_then(|v| v.to_str().ok()) + .ok_or(AppError::Unauthorized)?; + + if !stripe::verify_webhook( + &state.config.stripe_webhook_secret, + &body, + signature, + Utc::now().timestamp(), + ) { + return Err(AppError::Unauthorized); + } + + let event: Value = serde_json::from_slice(&body) + .map_err(|_| AppError::Validation("invalid payload".into()))?; + let event_type = event + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + let object = event + .pointer("/data/object") + .cloned() + .unwrap_or(Value::Null); + + match event_type { + "checkout.session.completed" => { + if let (Some(org), Some(sub_id)) = ( + meta_uuid(&object, "organization_id"), + object.get("subscription").and_then(Value::as_str), + ) { + db::subscriptions::upsert( + &state.db, + UpsertSubscription { + organization_id: org, + plan_id: meta_uuid(&object, "plan_id"), + stripe_subscription_id: sub_id.to_string(), + status: "active".to_string(), + current_period_start: None, + current_period_end: None, + cancelled_at: None, + }, + ) + .await?; + } + } + "customer.subscription.updated" | "customer.subscription.deleted" => { + if let (Some(org), Some(sub_id)) = ( + meta_uuid(&object, "organization_id"), + object.get("id").and_then(Value::as_str), + ) { + let status = object + .get("status") + .and_then(Value::as_str) + .unwrap_or("incomplete") + .to_string(); + db::subscriptions::upsert( + &state.db, + UpsertSubscription { + organization_id: org, + plan_id: meta_uuid(&object, "plan_id"), + stripe_subscription_id: sub_id.to_string(), + status, + current_period_start: epoch(&object, "current_period_start"), + current_period_end: epoch(&object, "current_period_end"), + cancelled_at: epoch(&object, "canceled_at"), + }, + ) + .await?; + } + } + _ => {} + } + + Ok(StatusCode::OK) +} + +/// Resolves the organization's Stripe customer, creating and persisting one on +/// first use. +async fn ensure_customer( + state: &AppState, + org_id: Uuid, + name: &str, + email: &str, +) -> AppResult { + if let Some(existing) = db::organizations::stripe_customer_id(&state.db, org_id).await? { + return Ok(existing); + } + let customer = stripe::create_customer(state, name, email).await?; + db::organizations::set_stripe_customer(&state.db, org_id, &customer).await?; + Ok(customer) +} + +fn meta_uuid(object: &Value, key: &str) -> Option { + object + .pointer(&format!("/metadata/{key}")) + .and_then(Value::as_str) + .and_then(|s| Uuid::parse_str(s).ok()) +} + +fn epoch(object: &Value, key: &str) -> Option> { + object + .get(key) + .and_then(Value::as_i64) + .and_then(|secs| DateTime::from_timestamp(secs, 0)) +} diff --git a/backend-rs/crates/api/src/http/mod.rs b/backend-rs/crates/api/src/http/mod.rs index a102c3e..ba8439f 100644 --- a/backend-rs/crates/api/src/http/mod.rs +++ b/backend-rs/crates/api/src/http/mod.rs @@ -6,6 +6,7 @@ mod alerts; mod analytics; mod auth; +mod billing; mod chat; mod guard; mod health; @@ -40,6 +41,7 @@ fn api_routes() -> Router { .merge(chat::routes()) .merge(alerts::routes()) .merge(analytics::routes()) + .merge(billing::routes()) .route("/metrics", get(metrics::render)) } diff --git a/backend-rs/crates/api/src/main.rs b/backend-rs/crates/api/src/main.rs index 05aa665..1361483 100644 --- a/backend-rs/crates/api/src/main.rs +++ b/backend-rs/crates/api/src/main.rs @@ -14,6 +14,7 @@ mod http; mod observability; mod signaling; mod state; +mod stripe; mod util; use std::net::SocketAddr; diff --git a/backend-rs/crates/api/src/stripe.rs b/backend-rs/crates/api/src/stripe.rs new file mode 100644 index 0000000..4e4f8f4 --- /dev/null +++ b/backend-rs/crates/api/src/stripe.rs @@ -0,0 +1,176 @@ +//! Minimal Stripe client over the shared `reqwest` client. +//! +//! Only the handful of calls the platform needs (customers, Checkout, Billing +//! Portal) plus webhook signature verification — deliberately avoiding the +//! heavyweight `async-stripe` dependency tree for a small, auditable surface. + +use hmac::{Hmac, Mac}; +use serde_json::Value; +use sha2::Sha256; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +const API_BASE: &str = "https://api.stripe.com"; + +/// Webhook signatures older than this (seconds) are rejected (replay defense). +const WEBHOOK_TOLERANCE_SECONDS: i64 = 300; + +type HmacSha256 = Hmac; + +async fn post_form(state: &AppState, path: &str, form: &[(&str, &str)]) -> AppResult { + let response = state + .http + .post(format!("{API_BASE}{path}")) + .bearer_auth(&state.config.stripe_secret) + .form(form) + .send() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("stripe {path} request failed: {e}")))?; + + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(AppError::Internal(anyhow::anyhow!( + "stripe {path} -> {status}: {text}" + ))); + } + + serde_json::from_str(&text) + .map_err(|e| AppError::Internal(anyhow::anyhow!("stripe {path} decode failed: {e}"))) +} + +fn extract_string(value: &Value, key: &str) -> AppResult { + value + .get(key) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("stripe response missing `{key}`"))) +} + +pub async fn create_customer(state: &AppState, name: &str, email: &str) -> AppResult { + let value = post_form(state, "/v1/customers", &[("name", name), ("email", email)]).await?; + extract_string(&value, "id") +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_checkout_session( + state: &AppState, + customer: &str, + price: &str, + success_url: &str, + cancel_url: &str, + organization_id: &str, + plan_id: &str, +) -> AppResult { + let value = post_form( + state, + "/v1/checkout/sessions", + &[ + ("mode", "subscription"), + ("customer", customer), + ("line_items[0][price]", price), + ("line_items[0][quantity]", "1"), + ("success_url", success_url), + ("cancel_url", cancel_url), + ("metadata[organization_id]", organization_id), + ("metadata[plan_id]", plan_id), + ( + "subscription_data[metadata][organization_id]", + organization_id, + ), + ("subscription_data[metadata][plan_id]", plan_id), + ], + ) + .await?; + extract_string(&value, "url") +} + +pub async fn create_portal_session( + state: &AppState, + customer: &str, + return_url: &str, +) -> AppResult { + let value = post_form( + state, + "/v1/billing_portal/sessions", + &[("customer", customer), ("return_url", return_url)], + ) + .await?; + extract_string(&value, "url") +} + +/// Verifies a `Stripe-Signature` header against the raw request body, with a +/// timestamp tolerance to defend against replays. Constant-time comparison. +pub fn verify_webhook(secret: &str, payload: &[u8], signature_header: &str, now: i64) -> bool { + let mut timestamp: Option = None; + let mut signatures: Vec<&str> = Vec::new(); + + for part in signature_header.split(',') { + match part.split_once('=') { + Some(("t", value)) => timestamp = value.parse().ok(), + Some(("v1", value)) => signatures.push(value), + _ => {} + } + } + + let Some(t) = timestamp else { return false }; + if (now - t).abs() > WEBHOOK_TOLERANCE_SECONDS { + return false; + } + + let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else { + return false; + }; + mac.update(t.to_string().as_bytes()); + mac.update(b"."); + mac.update(payload); + let expected = hex_lower(&mac.finalize().into_bytes()); + + signatures + .iter() + .any(|candidate| constant_time_eq(candidate.as_bytes(), expected.as_bytes())) +} + +fn hex_lower(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push_str(&format!("{byte:02x}")); + } + out +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter() + .zip(b.iter()) + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) + == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn webhook_signature_roundtrip() { + let secret = "whsec_test"; + let payload = br#"{"id":"evt_1","type":"checkout.session.completed"}"#; + let t = 1_700_000_000_i64; + + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(t.to_string().as_bytes()); + mac.update(b"."); + mac.update(payload); + let sig = hex_lower(&mac.finalize().into_bytes()); + let header = format!("t={t},v1={sig}"); + + assert!(verify_webhook(secret, payload, &header, t)); + // Tampered body fails. + assert!(!verify_webhook(secret, b"{}", &header, t)); + // Stale timestamp fails. + assert!(!verify_webhook(secret, payload, &header, t + 10_000)); + } +}