From a654096f2524b646765270bd6d679f2c8f630e00 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 19 May 2026 18:19:58 -0700 Subject: [PATCH] Refactor stripe module --- backend/spec/stripe.md | 2 +- backend/src/billing.rs | 107 +++------- backend/src/routes/invoices.rs | 13 +- backend/src/stripe.rs | 365 +++++++++++++++++---------------- 4 files changed, 232 insertions(+), 255 deletions(-) diff --git a/backend/spec/stripe.md b/backend/spec/stripe.md index cf7b2e8..2245105 100644 --- a/backend/spec/stripe.md +++ b/backend/spec/stripe.md @@ -77,7 +77,7 @@ Constructs the client with a fresh `reqwest::Client` from explicit keys (does no - `POST /v1/invoices/:id/pay` with `paid_out_of_band: true` — used when payment was collected over Lightning rather than through Stripe - Idempotent on `invoice_id` -## `pub async fn preview_upcoming_invoice(&self, customer_id: &str, subscription_id: Option<&str>) -> Result` +## `pub async fn preview_invoice(&self, customer_id: &str, subscription_id: Option<&str>) -> Result` - `GET /v1/invoices/upcoming?customer=…[&subscription=…]` - Used to validate proration when a subscription is downgraded diff --git a/backend/src/billing.rs b/backend/src/billing.rs index e138f22..c652da0 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -7,7 +7,7 @@ use crate::env::Env; use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT}; use crate::query::Query; use crate::robot::Robot; -use crate::stripe::Stripe; +use crate::stripe::{Stripe, StripeInvoice}; use crate::wallet::Wallet; const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment."; @@ -161,12 +161,7 @@ impl Billing { // longer exists or has been canceled. let subscription = match tenant.stripe_subscription_id.as_deref() { Some(subscription_id) => match self.stripe.get_subscription(subscription_id).await? { - Some(sub) - if !matches!( - sub["status"].as_str().unwrap_or_default(), - "canceled" | "incomplete_expired" - ) => - { + Some(sub) if !matches!(sub.status.as_str(), "canceled" | "incomplete_expired") => { Some(sub) } _ => { @@ -205,32 +200,25 @@ impl Billing { match subscription { None => { - let (subscription_id, items) = self + let sub = self .stripe .create_subscription(&tenant.stripe_customer_id, &desired) .await?; self.command - .set_tenant_subscription(tenant_pubkey, &subscription_id) + .set_tenant_subscription(tenant_pubkey, &sub.id) .await?; - tenant.stripe_subscription_id = Some(subscription_id); - price_to_item = items; + for item in sub.items { + price_to_item.insert(item.price.id, item.id); + } + tenant.stripe_subscription_id = Some(sub.id); } Some(sub) => { - let subscription_id = sub["id"] - .as_str() - .ok_or_else(|| anyhow!("missing subscription id"))? - .to_string(); + let subscription_id = sub.id; // price id -> (item id, quantity) for items currently on the subscription. let mut current: BTreeMap = BTreeMap::new(); - for item in sub["items"]["data"].as_array().into_iter().flatten() { - let (Some(item_id), Some(price_id)) = - (item["id"].as_str(), item["price"]["id"].as_str()) - else { - continue; - }; - let quantity = item["quantity"].as_i64().unwrap_or(1); - current.insert(price_id.to_string(), (item_id.to_string(), quantity)); + for item in sub.items { + current.insert(item.price.id, (item.id, item.quantity)); } for (price_id, &quantity) in &desired { @@ -310,7 +298,7 @@ impl Billing { } pub async fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()> { - let event = self.stripe.construct_event(payload, signature)?; + let event = self.stripe.get_webhook_event(payload, signature)?; let obj = &event.data.object; match event.event_type.as_str() { @@ -578,35 +566,22 @@ impl Billing { async fn validate_downgrade_proration(&self, tenant: &crate::models::Tenant, context: &str) { match self .stripe - .preview_upcoming_invoice( + .preview_invoice( &tenant.stripe_customer_id, tenant.stripe_subscription_id.as_deref(), ) .await { Ok(upcoming) => { - let lines = upcoming["lines"]["data"] - .as_array() - .cloned() - .unwrap_or_default(); - let proration_lines = lines - .iter() - .filter(|line| line["proration"].as_bool().unwrap_or(false)) - .count(); - let amount_due = upcoming["amount_due"] - .as_i64() - .unwrap_or_else(|| upcoming["total"].as_i64().unwrap_or(0)); - let currency = upcoming["currency"].as_str().unwrap_or("usd"); - let preview_id = upcoming["id"].as_str().unwrap_or_default(); + let proration_lines = upcoming.lines.iter().filter(|line| line.proration).count(); tracing::info!( tenant_pubkey = %tenant.pubkey, stripe_customer_id = %tenant.stripe_customer_id, context, - preview_id, proration_lines, - amount_due, - currency, + amount_due = upcoming.amount_due, + currency = %upcoming.currency, "validated Stripe proration preview for downgrade" ); @@ -635,16 +610,13 @@ impl Billing { pub async fn get_invoice_with_tenant( &self, invoice_id: &str, - ) -> Result> { + ) -> Result> { let Some(invoice) = self.stripe.get_invoice(invoice_id).await? else { return Ok(None); }; - let customer_id = invoice["customer"] - .as_str() - .ok_or_else(|| anyhow!("invoice missing customer"))?; let tenant = self .query - .get_tenant_by_stripe_customer_id(customer_id) + .get_tenant_by_stripe_customer_id(&invoice.customer) .await? .ok_or_else(|| anyhow!("tenant not found for customer"))?; Ok(Some((invoice, tenant))) @@ -653,8 +625,8 @@ impl Billing { pub async fn reconcile_manual_lightning_invoice( &self, invoice_id: &str, - invoice: &serde_json::Value, - ) -> Result { + invoice: &StripeInvoice, + ) -> Result { self.reconcile_manual_lightning_invoice_if_settled(invoice_id, invoice) .await } @@ -702,11 +674,11 @@ impl Billing { .await .unwrap_or(short_pubkey); self.stripe - .create_customer(&display_name, tenant_pubkey) + .create_customer(tenant_pubkey, &display_name) .await } - pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result { + pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result> { self.stripe.list_invoices(customer_id).await } @@ -738,24 +710,19 @@ impl Billing { .stripe .list_invoices(&tenant.stripe_customer_id) .await?; - let invoices_arr = invoices.as_array().cloned().unwrap_or_default(); - for invoice in &invoices_arr { - let status = invoice["status"].as_str().unwrap_or_default(); - let amount_due = invoice["amount_due"].as_i64().unwrap_or(0); - let invoice_id = invoice["id"].as_str().unwrap_or_default(); - let currency = invoice["currency"].as_str().unwrap_or("usd"); - - if status != "open" || amount_due == 0 || invoice_id.is_empty() { + for invoice in &invoices { + if invoice.status != "open" || invoice.amount_due == 0 { continue; } + let invoice_id = invoice.id.as_str(); match self .nwc_pay_invoice( invoice_id, &tenant.pubkey, - amount_due, - currency, + invoice.amount_due, + &invoice.currency, &plain_nwc_url, ) .await? @@ -815,21 +782,15 @@ impl Billing { .stripe .list_invoices(&tenant.stripe_customer_id) .await?; - let invoices_arr = invoices.as_array().cloned().unwrap_or_default(); - for invoice in &invoices_arr { - let status = invoice["status"].as_str().unwrap_or_default(); - let amount_due = invoice["amount_due"].as_i64().unwrap_or(0); - let invoice_id = invoice["id"].as_str().unwrap_or_default(); - - if status != "open" || amount_due == 0 || invoice_id.is_empty() { + for invoice in &invoices { + if invoice.status != "open" || invoice.amount_due == 0 { continue; } - - if let Err(error) = self.stripe.pay_invoice(invoice_id).await { + if let Err(error) = self.stripe.pay_invoice(&invoice.id).await { tracing::error!( error = %error, - invoice_id, + invoice_id = %invoice.id, "failed to retry card payment for outstanding invoice" ); } @@ -853,9 +814,9 @@ impl Billing { async fn reconcile_manual_lightning_invoice_if_settled( &self, invoice_id: &str, - invoice: &serde_json::Value, - ) -> Result { - if invoice["status"].as_str().unwrap_or_default() != "open" { + invoice: &StripeInvoice, + ) -> Result { + if invoice.status != "open" { return Ok(invoice.clone()); } diff --git a/backend/src/routes/invoices.rs b/backend/src/routes/invoices.rs index 89e5738..a6d6366 100644 --- a/backend/src/routes/invoices.rs +++ b/backend/src/routes/invoices.rs @@ -62,17 +62,18 @@ pub async fn get_invoice_bolt11( .await .map_err(internal)?; - let status = invoice["status"].as_str().unwrap_or_default(); - if status != "open" { + if invoice.status != "open" { return Err(bad_request("invoice-not-open", "invoice is not open")); } - let amount_due = invoice["amount_due"].as_i64().unwrap_or(0); - let currency = invoice["currency"].as_str().unwrap_or("usd"); - let bolt11 = api .billing - .get_or_create_manual_lightning_bolt11(&id, &tenant.pubkey, amount_due, currency) + .get_or_create_manual_lightning_bolt11( + &id, + &tenant.pubkey, + invoice.amount_due, + &invoice.currency, + ) .await .map_err(internal)?; ok(serde_json::json!({ "bolt11": bolt11 })) diff --git a/backend/src/stripe.rs b/backend/src/stripe.rs index 5a459d7..b582611 100644 --- a/backend/src/stripe.rs +++ b/backend/src/stripe.rs @@ -9,24 +9,91 @@ use hmac::{Hmac, Mac}; use sha2::Sha256; use std::collections::BTreeMap; -type HmacSha256 = Hmac; - const STRIPE_API: &str = "https://api.stripe.com/v1"; + +// Webhooks + const WEBHOOK_TOLERANCE_SECS: i64 = 300; -/// A Stripe webhook event with its signature already verified. #[derive(serde::Deserialize)] -pub struct Event { +pub struct StripeWebhookEvent { #[serde(rename = "type")] pub event_type: String, - pub data: EventData, + pub data: StripeWebhookEventData, } #[derive(serde::Deserialize)] -pub struct EventData { +pub struct StripeWebhookEventData { pub object: serde_json::Value, } +// API return types + +#[derive(serde::Deserialize)] +pub struct StripeSubscription { + pub id: String, + pub status: String, + #[serde(deserialize_with = "deserialize_list")] + pub items: Vec, +} + +#[derive(serde::Deserialize)] +pub struct StripeSubscriptionItem { + pub id: String, + pub price: StripePrice, + #[serde(default = "default_quantity")] + pub quantity: i64, +} + +#[derive(serde::Deserialize)] +pub struct StripePrice { + pub id: String, +} + +#[derive(serde::Deserialize, serde::Serialize, Clone)] +pub struct StripeInvoice { + pub id: String, + pub customer: String, + pub status: String, + pub amount_due: i64, + pub currency: String, + #[serde(deserialize_with = "deserialize_list")] + pub lines: Vec, +} + +#[derive(serde::Deserialize)] +pub struct StripeInvoicePreview { + pub amount_due: i64, + pub currency: String, + #[serde(deserialize_with = "deserialize_list")] + pub lines: Vec, +} + +#[derive(serde::Deserialize, serde::Serialize, Clone)] +pub struct StripeInvoiceLine { + #[serde(default)] + pub proration: bool, +} + +#[derive(serde::Deserialize)] +struct StripeList { + data: Vec, +} + +fn deserialize_list<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + Ok( as serde::Deserialize>::deserialize(deserializer)?.data) +} + +fn default_quantity() -> i64 { + 1 +} + +// Stripe struct and impl + #[derive(Clone)] pub struct Stripe { pub(crate) secret_key: String, @@ -43,62 +110,76 @@ impl Stripe { } } + // --- Request helpers --- + + fn get(&self, path: &str) -> reqwest::RequestBuilder { + self.http + .get(format!("{STRIPE_API}{path}")) + .bearer_auth(&self.secret_key) + } + + fn post(&self, path: &str) -> reqwest::RequestBuilder { + self.http + .post(format!("{STRIPE_API}{path}")) + .bearer_auth(&self.secret_key) + } + + fn delete(&self, path: &str) -> reqwest::RequestBuilder { + self.http + .delete(format!("{STRIPE_API}{path}")) + .bearer_auth(&self.secret_key) + } + + fn idempotency_key(&self, parts: &[&str]) -> String { + let mut mac = Hmac::::new_from_slice(self.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 --- - /// Creates a customer with the given display name, tagging it with the tenant - /// pubkey in metadata. Idempotent on the tenant pubkey. - pub async fn create_customer(&self, name: &str, tenant_pubkey: &str) -> Result { - let resp = self - .http - .post(format!("{STRIPE_API}/customers")) - .bearer_auth(&self.secret_key) + 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() + .send_json() .await?; - - let body: serde_json::Value = error_for_status(resp).await?.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()) } // --- Subscriptions --- - /// Fetches a subscription, returning `None` if Stripe no longer knows about it - /// (so callers can recover from a stale subscription id). pub async fn get_subscription( &self, subscription_id: &str, - ) -> Result> { - let resp = self - .http - .get(format!("{STRIPE_API}/subscriptions/{subscription_id}")) - .bearer_auth(&self.secret_key) - .send() + ) -> Result> { + let body = self + .get(&format!("/subscriptions/{subscription_id}")) + .send_optional_json() .await?; - if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(None); - } - let body: serde_json::Value = error_for_status(resp).await?.json().await?; - Ok(Some(body)) + body.map(serde_json::from_value) + .transpose() + .map_err(Into::into) } - /// Creates a subscription with one item per `(price_id, quantity)` entry, billed - /// automatically. Returns the subscription id and a map from price id to the - /// created subscription item id. Idempotent on the customer and the item set. pub async fn create_subscription( &self, customer_id: &str, items: &BTreeMap, - ) -> Result<(String, BTreeMap)> { + ) -> Result { let mut form: Vec<(String, String)> = vec![ ("customer".to_string(), customer_id.to_string()), ( @@ -115,34 +196,14 @@ impl Stripe { } let key_refs: Vec<&str> = key_parts.iter().map(String::as_str).collect(); - let resp = self - .http - .post(format!("{STRIPE_API}/subscriptions")) - .bearer_auth(&self.secret_key) + Ok(self + .post("/subscriptions") .header("Idempotency-Key", self.idempotency_key(&key_refs)) .form(&form) - .send() - .await?; - let body: serde_json::Value = error_for_status(resp).await?.json().await?; - - let subscription_id = body["id"] - .as_str() - .ok_or_else(|| anyhow!("missing subscription id"))? - .to_string(); - let mut price_to_item = BTreeMap::new(); - for item in body["items"]["data"] - .as_array() - .ok_or_else(|| anyhow!("missing subscription items"))? - { - let item_id = item["id"] - .as_str() - .ok_or_else(|| anyhow!("missing subscription item id"))?; - let price_id = item["price"]["id"] - .as_str() - .ok_or_else(|| anyhow!("missing subscription item price id"))?; - price_to_item.insert(price_id.to_string(), item_id.to_string()); - } - Ok((subscription_id, price_to_item)) + .send_ok() + .await? + .json() + .await?) } pub async fn create_subscription_item( @@ -152,10 +213,8 @@ impl Stripe { quantity: i64, ) -> Result { let quantity = quantity.to_string(); - let resp = self - .http - .post(format!("{STRIPE_API}/subscription_items")) - .bearer_auth(&self.secret_key) + let body = self + .post("/subscription_items") .header( "Idempotency-Key", self.idempotency_key(&["create_subscription_item", subscription_id, price_id]), @@ -165,147 +224,106 @@ impl Stripe { ("price", price_id), ("quantity", quantity.as_str()), ]) - .send() + .send_json() .await?; - let body: serde_json::Value = error_for_status(resp).await?.json().await?; body["id"] .as_str() .map(str::to_string) .ok_or_else(|| anyhow!("missing subscription item id")) } - /// Sets a subscription item's quantity. No idempotency key: this is a - /// reconcile-to-desired-state write, and re-applying the same target is a no-op. pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()> { - let resp = self - .http - .post(format!("{STRIPE_API}/subscription_items/{item_id}")) - .bearer_auth(&self.secret_key) + self.post(&format!("/subscription_items/{item_id}")) .form(&[("quantity", quantity.to_string())]) - .send() + .send_ok() .await?; - error_for_status(resp).await?; Ok(()) } pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()> { - let resp = self - .http - .delete(format!("{STRIPE_API}/subscription_items/{item_id}")) - .bearer_auth(&self.secret_key) - .send() + self.delete(&format!("/subscription_items/{item_id}")) + .send_ok() .await?; - error_for_status(resp).await?; Ok(()) } pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()> { - let resp = self - .http - .delete(format!("{STRIPE_API}/subscriptions/{subscription_id}")) - .bearer_auth(&self.secret_key) - .send() + self.delete(&format!("/subscriptions/{subscription_id}")) + .send_ok() .await?; - error_for_status(resp).await?; Ok(()) } // --- Invoices --- - /// Returns the `data` array of the customer's invoices. - pub async fn list_invoices(&self, customer_id: &str) -> Result { - let resp = self - .http - .get(format!("{STRIPE_API}/invoices")) - .bearer_auth(&self.secret_key) + pub async fn list_invoices(&self, customer_id: &str) -> Result> { + let list: StripeList = self + .get("/invoices") .query(&[("customer", customer_id)]) - .send() + .send_ok() + .await? + .json() .await?; - let body: serde_json::Value = error_for_status(resp).await?.json().await?; - Ok(body["data"].clone()) + Ok(list.data) } - /// Fetches an invoice, returning `None` if Stripe no longer knows about it - /// (so callers can surface a 404 to the user). - pub async fn get_invoice(&self, invoice_id: &str) -> Result> { - let resp = self - .http - .get(format!("{STRIPE_API}/invoices/{invoice_id}")) - .bearer_auth(&self.secret_key) - .send() + pub async fn get_invoice(&self, invoice_id: &str) -> Result> { + let body = self + .get(&format!("/invoices/{invoice_id}")) + .send_optional_json() .await?; - if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(None); - } - let body: serde_json::Value = error_for_status(resp).await?.json().await?; - Ok(Some(body)) + body.map(serde_json::from_value) + .transpose() + .map_err(Into::into) } pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()> { - let resp = self - .http - .post(format!("{STRIPE_API}/invoices/{invoice_id}/pay")) - .bearer_auth(&self.secret_key) + self.post(&format!("/invoices/{invoice_id}/pay")) .header( "Idempotency-Key", self.idempotency_key(&["pay_invoice", invoice_id]), ) - .send() + .send_ok() .await?; - error_for_status(resp).await?; Ok(()) } - /// Marks an invoice paid out of band — used when we've collected payment over - /// Lightning rather than through Stripe. pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> { - let resp = self - .http - .post(format!("{STRIPE_API}/invoices/{invoice_id}/pay")) - .bearer_auth(&self.secret_key) + self.post(&format!("/invoices/{invoice_id}/pay")) .header( "Idempotency-Key", self.idempotency_key(&["pay_invoice_oob", invoice_id]), ) .form(&[("paid_out_of_band", "true")]) - .send() + .send_ok() .await?; - error_for_status(resp).await?; Ok(()) } - pub async fn preview_upcoming_invoice( + pub async fn preview_invoice( &self, customer_id: &str, subscription_id: Option<&str>, - ) -> Result { - let mut req = self - .http - .get(format!("{STRIPE_API}/invoices/upcoming")) - .bearer_auth(&self.secret_key) - .query(&[("customer", customer_id)]); + ) -> Result { + let mut req = self.get("/invoices/upcoming").query(&[("customer", customer_id)]); if let Some(subscription_id) = subscription_id { req = req.query(&[("subscription", subscription_id)]); } - let body: serde_json::Value = error_for_status(req.send().await?).await?.json().await?; - Ok(body) + Ok(req.send_ok().await?.json().await?) } // --- Payment methods --- pub async fn has_payment_method(&self, customer_id: &str) -> Result { - let resp = self - .http - .get(format!("{STRIPE_API}/payment_methods")) - .bearer_auth(&self.secret_key) + let body = self + .get("/payment_methods") .query(&[("customer", customer_id), ("type", "card")]) - .send() + .send_json() .await?; - let body: serde_json::Value = error_for_status(resp).await?.json().await?; Ok(body["data"].as_array().is_some_and(|a| !a.is_empty())) } - // --- Billing portal --- + // --- Portal --- pub async fn create_portal_session( &self, @@ -316,14 +334,11 @@ impl Stripe { if let Some(url) = return_url { params.push(("return_url", url.to_string())); } - let resp = self - .http - .post(format!("{STRIPE_API}/billing_portal/sessions")) - .bearer_auth(&self.secret_key) + let body = self + .post("/billing_portal/sessions") .form(¶ms) - .send() + .send_json() .await?; - let body: serde_json::Value = error_for_status(resp).await?.json().await?; body["url"] .as_str() .map(str::to_string) @@ -332,28 +347,21 @@ impl Stripe { // --- Webhooks --- - /// Verifies the `Stripe-Signature` header against the configured webhook secret - /// (including the timestamp tolerance check) and parses the event body. - pub fn construct_event(&self, payload: &str, sig_header: &str) -> Result { - self.verify_webhook_signature(payload, sig_header)?; - Ok(serde_json::from_str(payload)?) - } - - fn verify_webhook_signature(&self, payload: &str, sig_header: &str) -> Result<()> { + pub fn get_webhook_event(&self, payload: &str, signature: &str) -> Result { let mut timestamp = None; - let mut signature = None; - for part in sig_header.split(',') { + let mut sig = None; + for part in signature.split(',') { if let Some(t) = part.strip_prefix("t=") { timestamp = Some(t); } else if let Some(v) = part.strip_prefix("v1=") { - signature = Some(v); + sig = Some(v); } } let timestamp = timestamp.ok_or_else(|| anyhow!("missing webhook timestamp"))?; - let signature = signature.ok_or_else(|| anyhow!("missing webhook signature"))?; + let signature = sig.ok_or_else(|| anyhow!("missing webhook signature"))?; let signed_payload = format!("{timestamp}.{payload}"); - let mut mac = HmacSha256::new_from_slice(self.webhook_secret.as_bytes()) + let mut mac = Hmac::::new_from_slice(self.webhook_secret.as_bytes()) .map_err(|e| anyhow!("invalid webhook secret: {e}"))?; mac.update(signed_payload.as_bytes()); let expected = hex::encode(mac.finalize().into_bytes()); @@ -368,29 +376,36 @@ impl Stripe { if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS { return Err(anyhow!("webhook timestamp outside tolerance")); } - Ok(()) + Ok(serde_json::from_str(payload)?) } - // --- Internals --- +} - /// Derives a stable idempotency key by HMAC-ing `parts` with the secret key. - fn idempotency_key(&self, parts: &[&str]) -> String { - let mut mac = HmacSha256::new_from_slice(self.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()); +trait StripeRequest { + async fn send_ok(self) -> Result; + async fn send_json(self) -> Result; + async fn send_optional_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?) + } + + async fn send_optional_json(self) -> Result> { + let resp = self.send().await?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); } - hex::encode(mac.finalize().into_bytes()) + Ok(Some(error_for_status(resp).await?.json().await?)) } } -/// Like [`reqwest::Response::error_for_status`], but on a 4xx/5xx response it reads -/// the body and folds Stripe's JSON error payload (`error.message`/`code`/`param`) -/// into the returned error, so callers get an actionable message instead of a bare -/// "400 Bad Request" with only the URL. +/// 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() {