diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql index 1e77dda..3bf6c47 100644 --- a/backend/migrations/0001_init.sql +++ b/backend/migrations/0001_init.sql @@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS tenant ( nwc_url TEXT NOT NULL DEFAULT '', nwc_error TEXT, created_at INTEGER NOT NULL, - stripe_customer_id TEXT NOT NULL DEFAULT '', + stripe_customer_id TEXT NOT NULL, stripe_subscription_id TEXT, past_due_at INTEGER ); diff --git a/backend/src/api.rs b/backend/src/api.rs index f1b6720..17a6289 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -370,32 +370,50 @@ async fn get_identity( let pubkey = state.api.extract_auth_pubkey(&headers)?; let is_admin = state.api.admins.iter().any(|a| a == &pubkey); - // Only create if tenant doesn't exist yet - if let Ok(None) = state.api.query.get_tenant(&pubkey).await { - // TODO: Call Stripe API to create a new customer - let stripe_customer_id = String::new(); + // Ensure tenant exists. + match state.api.query.get_tenant(&pubkey).await { + Ok(Some(_)) => {} + Ok(None) => { + let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await { + Ok(id) => id, + Err(e) => { + return Ok(err( + StatusCode::INTERNAL_SERVER_ERROR, + "stripe-customer-create-failed", + &e.to_string(), + )); + } + }; - let tenant = Tenant { - pubkey: pubkey.clone(), - nwc_url: String::new(), - nwc_error: None, - created_at: now_ts(), - stripe_customer_id, - stripe_subscription_id: None, - past_due_at: None, - }; + let tenant = Tenant { + pubkey: pubkey.clone(), + nwc_url: String::new(), + nwc_error: None, + created_at: now_ts(), + stripe_customer_id, + stripe_subscription_id: None, + past_due_at: None, + }; - match state.api.command.create_tenant(&tenant).await { - Ok(()) => {} - Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {} - Err(e) => { - return Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )); - } - }; + match state.api.command.create_tenant(&tenant).await { + Ok(()) => {} + Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {} + Err(e) => { + return Ok(err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + &e.to_string(), + )); + } + }; + } + Err(e) => { + return Ok(err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + &e.to_string(), + )); + } } Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin })) diff --git a/backend/src/billing.rs b/backend/src/billing.rs index a859c3f..a5f60fa 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -56,6 +56,9 @@ impl Billing { pub fn new(query: Query, command: Command, robot: Robot) -> Self { let nwc_url = std::env::var("NWC_URL").unwrap_or_default(); let stripe_secret_key = std::env::var("STRIPE_SECRET_KEY").unwrap_or_default(); + if stripe_secret_key.trim().is_empty() { + panic!("missing STRIPE_SECRET_KEY environment variable"); + } let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(); let btc_quote_api_base = std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string()); @@ -472,6 +475,33 @@ impl Billing { Ok((invoice, tenant)) } + pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result { + let short_pubkey: String = tenant_pubkey.chars().take(12).collect(); + let display_name = format!("Caravel tenant {short_pubkey}"); + + let resp = self + .http + .post(format!("{STRIPE_API}/customers")) + .bearer_auth(&self.stripe_secret_key) + .form(&[ + ("name", display_name.as_str()), + ("metadata[tenant_pubkey]", tenant_pubkey), + ]) + .send() + .await?; + + let body: serde_json::Value = resp.error_for_status()?.json().await?; + let customer_id = body["id"] + .as_str() + .ok_or_else(|| anyhow!("missing customer id"))?; + + if !customer_id.starts_with("cus_") { + return Err(anyhow!("unexpected customer id format")); + } + + Ok(customer_id.to_string()) + } + pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result { let resp = self .http @@ -872,3 +902,109 @@ mod tests { )); } } + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::SqlitePool; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use std::str::FromStr; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + #[allow(unused_unsafe)] + fn set_stripe_secret_key(value: Option<&str>) { + match value { + Some(v) => unsafe { std::env::set_var("STRIPE_SECRET_KEY", v) }, + None => unsafe { std::env::remove_var("STRIPE_SECRET_KEY") }, + } + } + + struct StripeSecretKeyGuard { + previous: Option, + } + + impl StripeSecretKeyGuard { + fn set(value: Option<&str>) -> Self { + let previous = std::env::var("STRIPE_SECRET_KEY").ok(); + set_stripe_secret_key(value); + Self { previous } + } + } + + impl Drop for StripeSecretKeyGuard { + fn drop(&mut self) { + set_stripe_secret_key(self.previous.as_deref()); + } + } + + async fn test_pool() -> SqlitePool { + let connect_options = SqliteConnectOptions::from_str("sqlite::memory:") + .expect("valid sqlite memory url") + .create_if_missing(true); + + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(connect_options) + .await + .expect("connect sqlite memory db"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("run migrations"); + + pool + } + + #[tokio::test] + async fn billing_new_panics_without_stripe_secret_key() { + let _lock = env_lock().lock().expect("acquire env lock"); + let _env = StripeSecretKeyGuard::set(None); + + let pool = test_pool().await; + let query = Query::new(pool.clone()); + let command = Command::new(pool); + let robot = Robot::test_stub(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + Billing::new(query, command, robot) + })); + + let panic_payload = match result { + Ok(_) => panic!("constructor should panic when STRIPE_SECRET_KEY is missing"), + Err(payload) => payload, + }; + let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() { + (*msg).to_string() + } else if let Some(msg) = panic_payload.downcast_ref::() { + msg.clone() + } else { + String::new() + }; + + assert!( + panic_msg.contains("missing STRIPE_SECRET_KEY environment variable"), + "unexpected panic: {panic_msg}" + ); + } + + #[tokio::test] + async fn billing_new_accepts_non_empty_stripe_secret_key() { + let _lock = env_lock().lock().expect("acquire env lock"); + let _env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); + + let pool = test_pool().await; + let billing = Billing::new( + Query::new(pool.clone()), + Command::new(pool), + Robot::test_stub(), + ); + + assert_eq!(billing.stripe_secret_key, "sk_test_dummy"); + } +} diff --git a/backend/src/command.rs b/backend/src/command.rs index ad23467..031977e 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -66,6 +66,10 @@ impl Command { } pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> { + if tenant.stripe_customer_id.trim().is_empty() { + anyhow::bail!("stripe_customer_id is required"); + } + let mut tx = self.pool.begin().await?; sqlx::query( @@ -205,7 +209,8 @@ impl Command { .execute(&mut *tx) .await?; - let activity = Self::insert_activity(&mut tx, activity_type, "relay", relay_id).await?; + let activity = + Self::insert_activity(&mut tx, "deactivate_relay", "relay", &relay_id).await?; tx.commit().await?; self.emit(activity); @@ -327,3 +332,56 @@ impl Command { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::SqlitePool; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use std::str::FromStr; + + async fn test_pool() -> SqlitePool { + let connect_options = SqliteConnectOptions::from_str("sqlite::memory:") + .expect("valid sqlite memory url") + .create_if_missing(true); + + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(connect_options) + .await + .expect("connect sqlite memory db"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("run migrations"); + + pool + } + + #[tokio::test] + async fn create_tenant_rejects_empty_stripe_customer_id() { + let pool = test_pool().await; + let command = Command::new(pool); + + let tenant = Tenant { + pubkey: "tenant_pubkey".to_string(), + nwc_url: String::new(), + nwc_error: None, + created_at: 0, + stripe_customer_id: " ".to_string(), + stripe_subscription_id: None, + past_due_at: None, + }; + + let err = command + .create_tenant(&tenant) + .await + .expect_err("empty customer id must be rejected"); + + assert!( + err.to_string().contains("stripe_customer_id is required"), + "unexpected error: {err}" + ); + } +} diff --git a/backend/src/robot.rs b/backend/src/robot.rs index e002a31..e8195db 100644 --- a/backend/src/robot.rs +++ b/backend/src/robot.rs @@ -254,3 +254,23 @@ async fn set_cached( }, ); } + +#[cfg(test)] +impl Robot { + pub fn test_stub() -> Self { + let keys = Keys::generate(); + let client = Client::new(keys); + + Self { + secret: String::new(), + name: String::new(), + description: String::new(), + picture: String::new(), + outbox_client: client.clone(), + indexer_client: client.clone(), + messaging_client: client, + outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())), + dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())), + } + } +}