forked from coracle/caravel
Refactor stripe module
This commit is contained in:
@@ -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
|
- `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`
|
- Idempotent on `invoice_id`
|
||||||
|
|
||||||
## `pub async fn preview_upcoming_invoice(&self, customer_id: &str, subscription_id: Option<&str>) -> Result<Value>`
|
## `pub async fn preview_invoice(&self, customer_id: &str, subscription_id: Option<&str>) -> Result<Value>`
|
||||||
|
|
||||||
- `GET /v1/invoices/upcoming?customer=…[&subscription=…]`
|
- `GET /v1/invoices/upcoming?customer=…[&subscription=…]`
|
||||||
- Used to validate proration when a subscription is downgraded
|
- Used to validate proration when a subscription is downgraded
|
||||||
|
|||||||
+34
-73
@@ -7,7 +7,7 @@ use crate::env::Env;
|
|||||||
use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT};
|
use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT};
|
||||||
use crate::query::Query;
|
use crate::query::Query;
|
||||||
use crate::robot::Robot;
|
use crate::robot::Robot;
|
||||||
use crate::stripe::Stripe;
|
use crate::stripe::{Stripe, StripeInvoice};
|
||||||
use crate::wallet::Wallet;
|
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.";
|
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.
|
// longer exists or has been canceled.
|
||||||
let subscription = match tenant.stripe_subscription_id.as_deref() {
|
let subscription = match tenant.stripe_subscription_id.as_deref() {
|
||||||
Some(subscription_id) => match self.stripe.get_subscription(subscription_id).await? {
|
Some(subscription_id) => match self.stripe.get_subscription(subscription_id).await? {
|
||||||
Some(sub)
|
Some(sub) if !matches!(sub.status.as_str(), "canceled" | "incomplete_expired") => {
|
||||||
if !matches!(
|
|
||||||
sub["status"].as_str().unwrap_or_default(),
|
|
||||||
"canceled" | "incomplete_expired"
|
|
||||||
) =>
|
|
||||||
{
|
|
||||||
Some(sub)
|
Some(sub)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -205,32 +200,25 @@ impl Billing {
|
|||||||
|
|
||||||
match subscription {
|
match subscription {
|
||||||
None => {
|
None => {
|
||||||
let (subscription_id, items) = self
|
let sub = self
|
||||||
.stripe
|
.stripe
|
||||||
.create_subscription(&tenant.stripe_customer_id, &desired)
|
.create_subscription(&tenant.stripe_customer_id, &desired)
|
||||||
.await?;
|
.await?;
|
||||||
self.command
|
self.command
|
||||||
.set_tenant_subscription(tenant_pubkey, &subscription_id)
|
.set_tenant_subscription(tenant_pubkey, &sub.id)
|
||||||
.await?;
|
.await?;
|
||||||
tenant.stripe_subscription_id = Some(subscription_id);
|
for item in sub.items {
|
||||||
price_to_item = items;
|
price_to_item.insert(item.price.id, item.id);
|
||||||
|
}
|
||||||
|
tenant.stripe_subscription_id = Some(sub.id);
|
||||||
}
|
}
|
||||||
Some(sub) => {
|
Some(sub) => {
|
||||||
let subscription_id = sub["id"]
|
let subscription_id = sub.id;
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| anyhow!("missing subscription id"))?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// price id -> (item id, quantity) for items currently on the subscription.
|
// price id -> (item id, quantity) for items currently on the subscription.
|
||||||
let mut current: BTreeMap<String, (String, i64)> = BTreeMap::new();
|
let mut current: BTreeMap<String, (String, i64)> = BTreeMap::new();
|
||||||
for item in sub["items"]["data"].as_array().into_iter().flatten() {
|
for item in sub.items {
|
||||||
let (Some(item_id), Some(price_id)) =
|
current.insert(item.price.id, (item.id, item.quantity));
|
||||||
(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 (price_id, &quantity) in &desired {
|
for (price_id, &quantity) in &desired {
|
||||||
@@ -310,7 +298,7 @@ impl Billing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()> {
|
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;
|
let obj = &event.data.object;
|
||||||
|
|
||||||
match event.event_type.as_str() {
|
match event.event_type.as_str() {
|
||||||
@@ -578,35 +566,22 @@ impl Billing {
|
|||||||
async fn validate_downgrade_proration(&self, tenant: &crate::models::Tenant, context: &str) {
|
async fn validate_downgrade_proration(&self, tenant: &crate::models::Tenant, context: &str) {
|
||||||
match self
|
match self
|
||||||
.stripe
|
.stripe
|
||||||
.preview_upcoming_invoice(
|
.preview_invoice(
|
||||||
&tenant.stripe_customer_id,
|
&tenant.stripe_customer_id,
|
||||||
tenant.stripe_subscription_id.as_deref(),
|
tenant.stripe_subscription_id.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(upcoming) => {
|
Ok(upcoming) => {
|
||||||
let lines = upcoming["lines"]["data"]
|
let proration_lines = upcoming.lines.iter().filter(|line| line.proration).count();
|
||||||
.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();
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
tenant_pubkey = %tenant.pubkey,
|
tenant_pubkey = %tenant.pubkey,
|
||||||
stripe_customer_id = %tenant.stripe_customer_id,
|
stripe_customer_id = %tenant.stripe_customer_id,
|
||||||
context,
|
context,
|
||||||
preview_id,
|
|
||||||
proration_lines,
|
proration_lines,
|
||||||
amount_due,
|
amount_due = upcoming.amount_due,
|
||||||
currency,
|
currency = %upcoming.currency,
|
||||||
"validated Stripe proration preview for downgrade"
|
"validated Stripe proration preview for downgrade"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -635,16 +610,13 @@ impl Billing {
|
|||||||
pub async fn get_invoice_with_tenant(
|
pub async fn get_invoice_with_tenant(
|
||||||
&self,
|
&self,
|
||||||
invoice_id: &str,
|
invoice_id: &str,
|
||||||
) -> Result<Option<(serde_json::Value, crate::models::Tenant)>> {
|
) -> Result<Option<(StripeInvoice, crate::models::Tenant)>> {
|
||||||
let Some(invoice) = self.stripe.get_invoice(invoice_id).await? else {
|
let Some(invoice) = self.stripe.get_invoice(invoice_id).await? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let customer_id = invoice["customer"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| anyhow!("invoice missing customer"))?;
|
|
||||||
let tenant = self
|
let tenant = self
|
||||||
.query
|
.query
|
||||||
.get_tenant_by_stripe_customer_id(customer_id)
|
.get_tenant_by_stripe_customer_id(&invoice.customer)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow!("tenant not found for customer"))?;
|
.ok_or_else(|| anyhow!("tenant not found for customer"))?;
|
||||||
Ok(Some((invoice, tenant)))
|
Ok(Some((invoice, tenant)))
|
||||||
@@ -653,8 +625,8 @@ impl Billing {
|
|||||||
pub async fn reconcile_manual_lightning_invoice(
|
pub async fn reconcile_manual_lightning_invoice(
|
||||||
&self,
|
&self,
|
||||||
invoice_id: &str,
|
invoice_id: &str,
|
||||||
invoice: &serde_json::Value,
|
invoice: &StripeInvoice,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<StripeInvoice> {
|
||||||
self.reconcile_manual_lightning_invoice_if_settled(invoice_id, invoice)
|
self.reconcile_manual_lightning_invoice_if_settled(invoice_id, invoice)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -702,11 +674,11 @@ impl Billing {
|
|||||||
.await
|
.await
|
||||||
.unwrap_or(short_pubkey);
|
.unwrap_or(short_pubkey);
|
||||||
self.stripe
|
self.stripe
|
||||||
.create_customer(&display_name, tenant_pubkey)
|
.create_customer(tenant_pubkey, &display_name)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<serde_json::Value> {
|
pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<Vec<StripeInvoice>> {
|
||||||
self.stripe.list_invoices(customer_id).await
|
self.stripe.list_invoices(customer_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,24 +710,19 @@ impl Billing {
|
|||||||
.stripe
|
.stripe
|
||||||
.list_invoices(&tenant.stripe_customer_id)
|
.list_invoices(&tenant.stripe_customer_id)
|
||||||
.await?;
|
.await?;
|
||||||
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
|
|
||||||
|
|
||||||
for invoice in &invoices_arr {
|
for invoice in &invoices {
|
||||||
let status = invoice["status"].as_str().unwrap_or_default();
|
if invoice.status != "open" || invoice.amount_due == 0 {
|
||||||
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() {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let invoice_id = invoice.id.as_str();
|
||||||
|
|
||||||
match self
|
match self
|
||||||
.nwc_pay_invoice(
|
.nwc_pay_invoice(
|
||||||
invoice_id,
|
invoice_id,
|
||||||
&tenant.pubkey,
|
&tenant.pubkey,
|
||||||
amount_due,
|
invoice.amount_due,
|
||||||
currency,
|
&invoice.currency,
|
||||||
&plain_nwc_url,
|
&plain_nwc_url,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -815,21 +782,15 @@ impl Billing {
|
|||||||
.stripe
|
.stripe
|
||||||
.list_invoices(&tenant.stripe_customer_id)
|
.list_invoices(&tenant.stripe_customer_id)
|
||||||
.await?;
|
.await?;
|
||||||
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
|
|
||||||
|
|
||||||
for invoice in &invoices_arr {
|
for invoice in &invoices {
|
||||||
let status = invoice["status"].as_str().unwrap_or_default();
|
if invoice.status != "open" || invoice.amount_due == 0 {
|
||||||
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() {
|
|
||||||
continue;
|
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!(
|
tracing::error!(
|
||||||
error = %error,
|
error = %error,
|
||||||
invoice_id,
|
invoice_id = %invoice.id,
|
||||||
"failed to retry card payment for outstanding invoice"
|
"failed to retry card payment for outstanding invoice"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -853,9 +814,9 @@ impl Billing {
|
|||||||
async fn reconcile_manual_lightning_invoice_if_settled(
|
async fn reconcile_manual_lightning_invoice_if_settled(
|
||||||
&self,
|
&self,
|
||||||
invoice_id: &str,
|
invoice_id: &str,
|
||||||
invoice: &serde_json::Value,
|
invoice: &StripeInvoice,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<StripeInvoice> {
|
||||||
if invoice["status"].as_str().unwrap_or_default() != "open" {
|
if invoice.status != "open" {
|
||||||
return Ok(invoice.clone());
|
return Ok(invoice.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,17 +62,18 @@ pub async fn get_invoice_bolt11(
|
|||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
|
|
||||||
let status = invoice["status"].as_str().unwrap_or_default();
|
if invoice.status != "open" {
|
||||||
if status != "open" {
|
|
||||||
return Err(bad_request("invoice-not-open", "invoice is not 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
|
let bolt11 = api
|
||||||
.billing
|
.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
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
ok(serde_json::json!({ "bolt11": bolt11 }))
|
ok(serde_json::json!({ "bolt11": bolt11 }))
|
||||||
|
|||||||
+190
-175
@@ -9,24 +9,91 @@ use hmac::{Hmac, Mac};
|
|||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
|
||||||
|
|
||||||
const STRIPE_API: &str = "https://api.stripe.com/v1";
|
const STRIPE_API: &str = "https://api.stripe.com/v1";
|
||||||
|
|
||||||
|
// Webhooks
|
||||||
|
|
||||||
const WEBHOOK_TOLERANCE_SECS: i64 = 300;
|
const WEBHOOK_TOLERANCE_SECS: i64 = 300;
|
||||||
|
|
||||||
/// A Stripe webhook event with its signature already verified.
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct Event {
|
pub struct StripeWebhookEvent {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub event_type: String,
|
pub event_type: String,
|
||||||
pub data: EventData,
|
pub data: StripeWebhookEventData,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct EventData {
|
pub struct StripeWebhookEventData {
|
||||||
pub object: serde_json::Value,
|
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<StripeSubscriptionItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<StripeInvoiceLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct StripeInvoicePreview {
|
||||||
|
pub amount_due: i64,
|
||||||
|
pub currency: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_list")]
|
||||||
|
pub lines: Vec<StripeInvoiceLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, serde::Serialize, Clone)]
|
||||||
|
pub struct StripeInvoiceLine {
|
||||||
|
#[serde(default)]
|
||||||
|
pub proration: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct StripeList<T> {
|
||||||
|
data: Vec<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_list<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
T: serde::Deserialize<'de>,
|
||||||
|
{
|
||||||
|
Ok(<StripeList<T> as serde::Deserialize>::deserialize(deserializer)?.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_quantity() -> i64 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stripe struct and impl
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Stripe {
|
pub struct Stripe {
|
||||||
pub(crate) secret_key: String,
|
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::<Sha256>::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 ---
|
// --- Customers ---
|
||||||
|
|
||||||
/// Creates a customer with the given display name, tagging it with the tenant
|
pub async fn create_customer(&self, tenant_pubkey: &str, name: &str) -> Result<String> {
|
||||||
/// pubkey in metadata. Idempotent on the tenant pubkey.
|
let body = self
|
||||||
pub async fn create_customer(&self, name: &str, tenant_pubkey: &str) -> Result<String> {
|
.post("/customers")
|
||||||
let resp = self
|
|
||||||
.http
|
|
||||||
.post(format!("{STRIPE_API}/customers"))
|
|
||||||
.bearer_auth(&self.secret_key)
|
|
||||||
.header(
|
.header(
|
||||||
"Idempotency-Key",
|
"Idempotency-Key",
|
||||||
self.idempotency_key(&["create_customer", tenant_pubkey]),
|
self.idempotency_key(&["create_customer", tenant_pubkey]),
|
||||||
)
|
)
|
||||||
.form(&[("name", name), ("metadata[tenant_pubkey]", tenant_pubkey)])
|
.form(&[("name", name), ("metadata[tenant_pubkey]", tenant_pubkey)])
|
||||||
.send()
|
.send_json()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let body: serde_json::Value = error_for_status(resp).await?.json().await?;
|
|
||||||
let customer_id = body["id"]
|
let customer_id = body["id"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| anyhow!("missing customer id"))?;
|
.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())
|
Ok(customer_id.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Subscriptions ---
|
// --- 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(
|
pub async fn get_subscription(
|
||||||
&self,
|
&self,
|
||||||
subscription_id: &str,
|
subscription_id: &str,
|
||||||
) -> Result<Option<serde_json::Value>> {
|
) -> Result<Option<StripeSubscription>> {
|
||||||
let resp = self
|
let body = self
|
||||||
.http
|
.get(&format!("/subscriptions/{subscription_id}"))
|
||||||
.get(format!("{STRIPE_API}/subscriptions/{subscription_id}"))
|
.send_optional_json()
|
||||||
.bearer_auth(&self.secret_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
.await?;
|
||||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
body.map(serde_json::from_value)
|
||||||
return Ok(None);
|
.transpose()
|
||||||
}
|
.map_err(Into::into)
|
||||||
let body: serde_json::Value = error_for_status(resp).await?.json().await?;
|
|
||||||
Ok(Some(body))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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(
|
pub async fn create_subscription(
|
||||||
&self,
|
&self,
|
||||||
customer_id: &str,
|
customer_id: &str,
|
||||||
items: &BTreeMap<String, i64>,
|
items: &BTreeMap<String, i64>,
|
||||||
) -> Result<(String, BTreeMap<String, String>)> {
|
) -> Result<StripeSubscription> {
|
||||||
let mut form: Vec<(String, String)> = vec![
|
let mut form: Vec<(String, String)> = vec![
|
||||||
("customer".to_string(), customer_id.to_string()),
|
("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 key_refs: Vec<&str> = key_parts.iter().map(String::as_str).collect();
|
||||||
|
|
||||||
let resp = self
|
Ok(self
|
||||||
.http
|
.post("/subscriptions")
|
||||||
.post(format!("{STRIPE_API}/subscriptions"))
|
|
||||||
.bearer_auth(&self.secret_key)
|
|
||||||
.header("Idempotency-Key", self.idempotency_key(&key_refs))
|
.header("Idempotency-Key", self.idempotency_key(&key_refs))
|
||||||
.form(&form)
|
.form(&form)
|
||||||
.send()
|
.send_ok()
|
||||||
.await?;
|
.await?
|
||||||
let body: serde_json::Value = error_for_status(resp).await?.json().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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_subscription_item(
|
pub async fn create_subscription_item(
|
||||||
@@ -152,10 +213,8 @@ impl Stripe {
|
|||||||
quantity: i64,
|
quantity: i64,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let quantity = quantity.to_string();
|
let quantity = quantity.to_string();
|
||||||
let resp = self
|
let body = self
|
||||||
.http
|
.post("/subscription_items")
|
||||||
.post(format!("{STRIPE_API}/subscription_items"))
|
|
||||||
.bearer_auth(&self.secret_key)
|
|
||||||
.header(
|
.header(
|
||||||
"Idempotency-Key",
|
"Idempotency-Key",
|
||||||
self.idempotency_key(&["create_subscription_item", subscription_id, price_id]),
|
self.idempotency_key(&["create_subscription_item", subscription_id, price_id]),
|
||||||
@@ -165,147 +224,106 @@ impl Stripe {
|
|||||||
("price", price_id),
|
("price", price_id),
|
||||||
("quantity", quantity.as_str()),
|
("quantity", quantity.as_str()),
|
||||||
])
|
])
|
||||||
.send()
|
.send_json()
|
||||||
.await?;
|
.await?;
|
||||||
let body: serde_json::Value = error_for_status(resp).await?.json().await?;
|
|
||||||
body["id"]
|
body["id"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.ok_or_else(|| anyhow!("missing subscription item id"))
|
.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<()> {
|
pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()> {
|
||||||
let resp = self
|
self.post(&format!("/subscription_items/{item_id}"))
|
||||||
.http
|
|
||||||
.post(format!("{STRIPE_API}/subscription_items/{item_id}"))
|
|
||||||
.bearer_auth(&self.secret_key)
|
|
||||||
.form(&[("quantity", quantity.to_string())])
|
.form(&[("quantity", quantity.to_string())])
|
||||||
.send()
|
.send_ok()
|
||||||
.await?;
|
.await?;
|
||||||
error_for_status(resp).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()> {
|
pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()> {
|
||||||
let resp = self
|
self.delete(&format!("/subscription_items/{item_id}"))
|
||||||
.http
|
.send_ok()
|
||||||
.delete(format!("{STRIPE_API}/subscription_items/{item_id}"))
|
|
||||||
.bearer_auth(&self.secret_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
.await?;
|
||||||
error_for_status(resp).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()> {
|
pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()> {
|
||||||
let resp = self
|
self.delete(&format!("/subscriptions/{subscription_id}"))
|
||||||
.http
|
.send_ok()
|
||||||
.delete(format!("{STRIPE_API}/subscriptions/{subscription_id}"))
|
|
||||||
.bearer_auth(&self.secret_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
.await?;
|
||||||
error_for_status(resp).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Invoices ---
|
// --- Invoices ---
|
||||||
|
|
||||||
/// Returns the `data` array of the customer's invoices.
|
pub async fn list_invoices(&self, customer_id: &str) -> Result<Vec<StripeInvoice>> {
|
||||||
pub async fn list_invoices(&self, customer_id: &str) -> Result<serde_json::Value> {
|
let list: StripeList<StripeInvoice> = self
|
||||||
let resp = self
|
.get("/invoices")
|
||||||
.http
|
|
||||||
.get(format!("{STRIPE_API}/invoices"))
|
|
||||||
.bearer_auth(&self.secret_key)
|
|
||||||
.query(&[("customer", customer_id)])
|
.query(&[("customer", customer_id)])
|
||||||
.send()
|
.send_ok()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
.await?;
|
.await?;
|
||||||
let body: serde_json::Value = error_for_status(resp).await?.json().await?;
|
Ok(list.data)
|
||||||
Ok(body["data"].clone())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches an invoice, returning `None` if Stripe no longer knows about it
|
pub async fn get_invoice(&self, invoice_id: &str) -> Result<Option<StripeInvoice>> {
|
||||||
/// (so callers can surface a 404 to the user).
|
let body = self
|
||||||
pub async fn get_invoice(&self, invoice_id: &str) -> Result<Option<serde_json::Value>> {
|
.get(&format!("/invoices/{invoice_id}"))
|
||||||
let resp = self
|
.send_optional_json()
|
||||||
.http
|
|
||||||
.get(format!("{STRIPE_API}/invoices/{invoice_id}"))
|
|
||||||
.bearer_auth(&self.secret_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
.await?;
|
||||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
body.map(serde_json::from_value)
|
||||||
return Ok(None);
|
.transpose()
|
||||||
}
|
.map_err(Into::into)
|
||||||
let body: serde_json::Value = error_for_status(resp).await?.json().await?;
|
|
||||||
Ok(Some(body))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()> {
|
pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()> {
|
||||||
let resp = self
|
self.post(&format!("/invoices/{invoice_id}/pay"))
|
||||||
.http
|
|
||||||
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
|
|
||||||
.bearer_auth(&self.secret_key)
|
|
||||||
.header(
|
.header(
|
||||||
"Idempotency-Key",
|
"Idempotency-Key",
|
||||||
self.idempotency_key(&["pay_invoice", invoice_id]),
|
self.idempotency_key(&["pay_invoice", invoice_id]),
|
||||||
)
|
)
|
||||||
.send()
|
.send_ok()
|
||||||
.await?;
|
.await?;
|
||||||
error_for_status(resp).await?;
|
|
||||||
Ok(())
|
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<()> {
|
pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> {
|
||||||
let resp = self
|
self.post(&format!("/invoices/{invoice_id}/pay"))
|
||||||
.http
|
|
||||||
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
|
|
||||||
.bearer_auth(&self.secret_key)
|
|
||||||
.header(
|
.header(
|
||||||
"Idempotency-Key",
|
"Idempotency-Key",
|
||||||
self.idempotency_key(&["pay_invoice_oob", invoice_id]),
|
self.idempotency_key(&["pay_invoice_oob", invoice_id]),
|
||||||
)
|
)
|
||||||
.form(&[("paid_out_of_band", "true")])
|
.form(&[("paid_out_of_band", "true")])
|
||||||
.send()
|
.send_ok()
|
||||||
.await?;
|
.await?;
|
||||||
error_for_status(resp).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn preview_upcoming_invoice(
|
pub async fn preview_invoice(
|
||||||
&self,
|
&self,
|
||||||
customer_id: &str,
|
customer_id: &str,
|
||||||
subscription_id: Option<&str>,
|
subscription_id: Option<&str>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<StripeInvoicePreview> {
|
||||||
let mut req = self
|
let mut req = self.get("/invoices/upcoming").query(&[("customer", customer_id)]);
|
||||||
.http
|
|
||||||
.get(format!("{STRIPE_API}/invoices/upcoming"))
|
|
||||||
.bearer_auth(&self.secret_key)
|
|
||||||
.query(&[("customer", customer_id)]);
|
|
||||||
if let Some(subscription_id) = subscription_id {
|
if let Some(subscription_id) = subscription_id {
|
||||||
req = req.query(&[("subscription", subscription_id)]);
|
req = req.query(&[("subscription", subscription_id)]);
|
||||||
}
|
}
|
||||||
let body: serde_json::Value = error_for_status(req.send().await?).await?.json().await?;
|
Ok(req.send_ok().await?.json().await?)
|
||||||
Ok(body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Payment methods ---
|
// --- Payment methods ---
|
||||||
|
|
||||||
pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool> {
|
pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool> {
|
||||||
let resp = self
|
let body = self
|
||||||
.http
|
.get("/payment_methods")
|
||||||
.get(format!("{STRIPE_API}/payment_methods"))
|
|
||||||
.bearer_auth(&self.secret_key)
|
|
||||||
.query(&[("customer", customer_id), ("type", "card")])
|
.query(&[("customer", customer_id), ("type", "card")])
|
||||||
.send()
|
.send_json()
|
||||||
.await?;
|
.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()))
|
Ok(body["data"].as_array().is_some_and(|a| !a.is_empty()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Billing portal ---
|
// --- Portal ---
|
||||||
|
|
||||||
pub async fn create_portal_session(
|
pub async fn create_portal_session(
|
||||||
&self,
|
&self,
|
||||||
@@ -316,14 +334,11 @@ impl Stripe {
|
|||||||
if let Some(url) = return_url {
|
if let Some(url) = return_url {
|
||||||
params.push(("return_url", url.to_string()));
|
params.push(("return_url", url.to_string()));
|
||||||
}
|
}
|
||||||
let resp = self
|
let body = self
|
||||||
.http
|
.post("/billing_portal/sessions")
|
||||||
.post(format!("{STRIPE_API}/billing_portal/sessions"))
|
|
||||||
.bearer_auth(&self.secret_key)
|
|
||||||
.form(¶ms)
|
.form(¶ms)
|
||||||
.send()
|
.send_json()
|
||||||
.await?;
|
.await?;
|
||||||
let body: serde_json::Value = error_for_status(resp).await?.json().await?;
|
|
||||||
body["url"]
|
body["url"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
@@ -332,28 +347,21 @@ impl Stripe {
|
|||||||
|
|
||||||
// --- Webhooks ---
|
// --- Webhooks ---
|
||||||
|
|
||||||
/// Verifies the `Stripe-Signature` header against the configured webhook secret
|
pub fn get_webhook_event(&self, payload: &str, signature: &str) -> Result<StripeWebhookEvent> {
|
||||||
/// (including the timestamp tolerance check) and parses the event body.
|
|
||||||
pub fn construct_event(&self, payload: &str, sig_header: &str) -> Result<Event> {
|
|
||||||
self.verify_webhook_signature(payload, sig_header)?;
|
|
||||||
Ok(serde_json::from_str(payload)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_webhook_signature(&self, payload: &str, sig_header: &str) -> Result<()> {
|
|
||||||
let mut timestamp = None;
|
let mut timestamp = None;
|
||||||
let mut signature = None;
|
let mut sig = None;
|
||||||
for part in sig_header.split(',') {
|
for part in signature.split(',') {
|
||||||
if let Some(t) = part.strip_prefix("t=") {
|
if let Some(t) = part.strip_prefix("t=") {
|
||||||
timestamp = Some(t);
|
timestamp = Some(t);
|
||||||
} else if let Some(v) = part.strip_prefix("v1=") {
|
} 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 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 signed_payload = format!("{timestamp}.{payload}");
|
||||||
let mut mac = HmacSha256::new_from_slice(self.webhook_secret.as_bytes())
|
let mut mac = Hmac::<Sha256>::new_from_slice(self.webhook_secret.as_bytes())
|
||||||
.map_err(|e| anyhow!("invalid webhook secret: {e}"))?;
|
.map_err(|e| anyhow!("invalid webhook secret: {e}"))?;
|
||||||
mac.update(signed_payload.as_bytes());
|
mac.update(signed_payload.as_bytes());
|
||||||
let expected = hex::encode(mac.finalize().into_bytes());
|
let expected = hex::encode(mac.finalize().into_bytes());
|
||||||
@@ -368,29 +376,36 @@ impl Stripe {
|
|||||||
if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS {
|
if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS {
|
||||||
return Err(anyhow!("webhook timestamp outside tolerance"));
|
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.
|
trait StripeRequest {
|
||||||
fn idempotency_key(&self, parts: &[&str]) -> String {
|
async fn send_ok(self) -> Result<reqwest::Response>;
|
||||||
let mut mac = HmacSha256::new_from_slice(self.secret_key.as_bytes())
|
async fn send_json(self) -> Result<serde_json::Value>;
|
||||||
.expect("HMAC accepts any key length");
|
async fn send_optional_json(self) -> Result<Option<serde_json::Value>>;
|
||||||
for (i, part) in parts.iter().enumerate() {
|
}
|
||||||
if i > 0 {
|
|
||||||
mac.update(b":");
|
impl StripeRequest for reqwest::RequestBuilder {
|
||||||
}
|
async fn send_ok(self) -> Result<reqwest::Response> {
|
||||||
mac.update(part.as_bytes());
|
error_for_status(self.send().await?).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_json(self) -> Result<serde_json::Value> {
|
||||||
|
Ok(self.send_ok().await?.json().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_optional_json(self) -> Result<Option<serde_json::Value>> {
|
||||||
|
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
|
/// Give callers an actionable message instead of a bare "400 Bad Request"
|
||||||
/// 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.
|
|
||||||
async fn error_for_status(resp: reqwest::Response) -> Result<reqwest::Response> {
|
async fn error_for_status(resp: reqwest::Response) -> Result<reqwest::Response> {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if !status.is_client_error() && !status.is_server_error() {
|
if !status.is_client_error() && !status.is_server_error() {
|
||||||
|
|||||||
Reference in New Issue
Block a user