//! A thin async wrapper around the subset of the Stripe REST API this service uses. //! //! Nothing here knows about relays, tenants, or our database — it just speaks HTTP //! to Stripe and hands back `serde_json::Value` (or small typed results). The //! domain logic lives in [`crate::billing`]. use anyhow::{Result, anyhow}; use hmac::{Hmac, Mac}; use sha2::Sha256; use crate::env; const STRIPE_API: &str = "https://api.stripe.com/v1"; #[derive(Clone)] pub struct Stripe { http: reqwest::Client, } impl Default for Stripe { fn default() -> Self { Self::new() } } impl Stripe { pub fn new() -> Self { Self { http: reqwest::Client::new(), } } // --- Request helpers --- fn get(&self, path: &str) -> reqwest::RequestBuilder { self.http .get(format!("{STRIPE_API}{path}")) .bearer_auth(&env::get().stripe_secret_key) } fn post(&self, path: &str) -> reqwest::RequestBuilder { self.http .post(format!("{STRIPE_API}{path}")) .bearer_auth(&env::get().stripe_secret_key) } fn idempotency_key(&self, parts: &[&str]) -> String { let mut mac = Hmac::::new_from_slice(env::get().stripe_secret_key.as_bytes()) .expect("HMAC accepts any key length"); for (i, part) in parts.iter().enumerate() { if i > 0 { mac.update(b":"); } mac.update(part.as_bytes()); } hex::encode(mac.finalize().into_bytes()) } // --- Customers --- /// Create a Stripe customer for a tenant and return its id. Idempotent on /// `tenant_pubkey` so retrying a tenant's creation reuses the same customer. pub async fn create_customer(&self, tenant_pubkey: &str, name: &str) -> Result { let body = self .post("/customers") .header( "Idempotency-Key", self.idempotency_key(&["create_customer", tenant_pubkey]), ) .form(&[("name", name), ("metadata[tenant_pubkey]", tenant_pubkey)]) .send_json() .await?; let customer_id = body["id"] .as_str() .ok_or_else(|| anyhow!("missing customer id"))?; Ok(customer_id.to_string()) } // --- Payment methods --- /// Return the id of the customer's first saved payment method, or `None` if /// they have none. The returned `pm_…` id can be charged off-session via /// [`Self::create_payment_intent`]. We don't track a Stripe "default" payment /// method, so the first one Stripe lists is the one we'll charge. pub async fn get_saved_payment_method(&self, customer_id: &str) -> Result> { let body = self .get("/payment_methods") .query(&[("customer", customer_id), ("type", "card")]) .send_json() .await?; Ok(body["data"] .as_array() .and_then(|methods| methods.first()) .and_then(|method| method["id"].as_str()) .map(str::to_string)) } // --- Intents --- /// Create and immediately confirm an off-session PaymentIntent charging a /// saved payment method. `amount` is in the currency's minor units (cents for /// `usd`). Returns the PaymentIntent id on success. /// /// A decline or an issuer authentication demand (`authentication_required`, /// which we can't satisfy off-session) comes back from Stripe as an HTTP /// error, so the caller naturally falls through to another payment method. /// The charge is made idempotent on `invoice_id` and `payment_method_id`, /// so a retried collection against the same method reuses the same charge /// instead of billing twice, while a fall-back to a different method issues /// a distinct charge instead of colliding on the original key. pub async fn create_payment_intent( &self, customer_id: &str, payment_method_id: &str, invoice_id: &str, amount: i64, currency: &str, ) -> Result { let amount = amount.to_string(); let body = self .post("/payment_intents") .header( "Idempotency-Key", self.idempotency_key(&["payment_intent", invoice_id, payment_method_id]), ) .form(&[ ("amount", amount.as_str()), ("currency", currency), ("customer", customer_id), ("payment_method", payment_method_id), ("metadata[invoice_id]", invoice_id), ("off_session", "true"), ("confirm", "true"), ]) .send_json() .await?; // A successful off-session charge settles synchronously. Anything // else (e.g. `requires_action`) can't be completed without the customer, // so treat it as a failure and let the caller fall back. let status = body["status"].as_str().unwrap_or_default(); if status != "succeeded" { return Err(anyhow!("payment intent not succeeded (status: {status})")); } body["id"] .as_str() .map(str::to_string) .ok_or_else(|| anyhow!("missing payment intent id")) } // --- Checkout --- /// Open a hosted Stripe Checkout session that charges `amount` (in the /// currency's minor units) for a single invoice on-session, so the customer /// can satisfy a 3D Secure authentication that an off-session saved-card /// charge can't. Returns the session id, its hosted URL, and its expiry. The /// session and the PaymentIntent it creates both carry `invoice_id` in /// metadata so the charge is traceable back to our ledger. pub async fn create_checkout_session( &self, customer_id: &str, invoice_id: &str, amount: i64, currency: &str, success_url: &str, cancel_url: &str, ) -> Result<(String, String, i64)> { let amount = amount.to_string(); let body = self .post("/checkout/sessions") .form(&[ ("mode", "payment"), ("customer", customer_id), ("success_url", success_url), ("cancel_url", cancel_url), ("line_items[0][quantity]", "1"), ("line_items[0][price_data][currency]", currency), ("line_items[0][price_data][unit_amount]", amount.as_str()), ( "line_items[0][price_data][product_data][name]", "Relay subscription", ), ("payment_intent_data[metadata][invoice_id]", invoice_id), ("metadata[invoice_id]", invoice_id), ]) .send_json() .await?; let session_id = body["id"] .as_str() .ok_or_else(|| anyhow!("missing checkout session id"))?; let url = body["url"] .as_str() .ok_or_else(|| anyhow!("missing checkout session url"))?; let expires_at = body["expires_at"] .as_i64() .ok_or_else(|| anyhow!("missing checkout session expiry"))?; Ok((session_id.to_string(), url.to_string(), expires_at)) } /// Whether a Checkout session has been paid. Used to reconcile an invoice /// once the customer returns from (or later completes) the hosted page. pub async fn is_checkout_paid(&self, session_id: &str) -> Result { let body = self .get(&format!("/checkout/sessions/{session_id}")) .send_json() .await?; Ok(body["payment_status"].as_str() == Some("paid")) } /// Expire a Checkout session so it can no longer be completed. Used to close /// out a still-open session once its invoice has been paid another way, /// preventing a double charge. Errors if the session isn't open (already /// completed or expired), which the caller treats as best-effort. pub async fn expire_checkout_session(&self, session_id: &str) -> Result<()> { self.post(&format!("/checkout/sessions/{session_id}/expire")) .send_ok() .await?; Ok(()) } // --- Portal --- /// Open a Stripe billing-portal session for the customer, returning the URL /// where they can manage their saved payment methods. pub async fn create_portal_session( &self, customer_id: &str, return_url: Option<&str>, ) -> Result { let mut params = vec![("customer", customer_id.to_string())]; if let Some(url) = return_url { params.push(("return_url", url.to_string())); } let body = self .post("/billing_portal/sessions") .form(¶ms) .send_json() .await?; body["url"] .as_str() .map(str::to_string) .ok_or_else(|| anyhow!("missing portal session url")) } } // Stripe request util trait StripeRequest { async fn send_ok(self) -> Result; async fn send_json(self) -> Result; } impl StripeRequest for reqwest::RequestBuilder { async fn send_ok(self) -> Result { error_for_status(self.send().await?).await } async fn send_json(self) -> Result { Ok(self.send_ok().await?.json().await?) } } /// Give callers an actionable message instead of a bare "400 Bad Request" async fn error_for_status(resp: reqwest::Response) -> Result { let status = resp.status(); if !status.is_client_error() && !status.is_server_error() { return Ok(resp); } let url = resp.url().clone(); let body = resp.text().await.unwrap_or_default(); let detail = serde_json::from_str::(&body) .ok() .and_then(|json| { let error = &json["error"]; let message = error["message"].as_str()?.to_string(); let mut detail = message; if let Some(code) = error["type"].as_str().or_else(|| error["code"].as_str()) { detail.push_str(&format!(" [{code}]")); } if let Some(param) = error["param"].as_str() { detail.push_str(&format!(" (param: {param})")); } Some(detail) }) .unwrap_or_else(|| { if body.trim().is_empty() { "".to_string() } else { body } }); Err(anyhow!( "Stripe API request to {url} failed with status {status}: {detail}" )) }