From a8d69dc915d215b28e3ab6f1fd24dbb08335e11b Mon Sep 17 00:00:00 2001 From: userAdityaa Date: Sat, 11 Apr 2026 17:28:58 +0545 Subject: [PATCH] fix(billing): ensure all tenants have valid Stripe customer IDs --- backend/migrations/0001_init.sql | 2 +- backend/src/api.rs | 66 ++++++++++++++++++++------------ backend/src/billing.rs | 30 +++++++++++++++ backend/src/command.rs | 7 +++- 4 files changed, 79 insertions(+), 26 deletions(-) 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..6d13f14 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 diff --git a/backend/src/command.rs b/backend/src/command.rs index ad23467..aeba583 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);