Refactor billing module
This commit is contained in:
+84
-685
@@ -1,82 +1,17 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use hmac::{Hmac, Mac};
|
||||
use nwc::prelude::{
|
||||
LookupInvoiceRequest, LookupInvoiceResponse, MakeInvoiceRequest, NWC, NostrWalletConnectURI,
|
||||
PayInvoiceRequest as NwcPayInvoiceRequest, TransactionState,
|
||||
};
|
||||
use sha2::Sha256;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::bitcoin::{Bitcoin, Wallet};
|
||||
use crate::command::Command;
|
||||
use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Relay};
|
||||
use crate::query::Query;
|
||||
use crate::robot::Robot;
|
||||
use crate::stripe::{InvoiceLookupError, Stripe};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
const STRIPE_API: &str = "https://api.stripe.com/v1";
|
||||
const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices";
|
||||
const WEBHOOK_TOLERANCE_SECS: i64 = 300;
|
||||
const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.";
|
||||
const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:";
|
||||
const NWC_ERROR_DM_MAX_CHARS: usize = 240;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum InvoiceLookupError {
|
||||
StripeClient { status: reqwest::StatusCode },
|
||||
Internal(anyhow::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InvoiceLookupError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::StripeClient { status } => {
|
||||
write!(
|
||||
f,
|
||||
"stripe invoice lookup failed with status {}",
|
||||
status.as_u16()
|
||||
)
|
||||
}
|
||||
Self::Internal(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InvoiceLookupError {}
|
||||
|
||||
impl From<anyhow::Error> for InvoiceLookupError {
|
||||
fn from(value: anyhow::Error) -> Self {
|
||||
Self::Internal(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for InvoiceLookupError {
|
||||
fn from(value: reqwest::Error) -> Self {
|
||||
Self::Internal(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct StripeEvent {
|
||||
#[serde(rename = "type")]
|
||||
event_type: String,
|
||||
data: StripeEventData,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct StripeEventData {
|
||||
object: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CoinbaseSpotPriceResponse {
|
||||
data: CoinbaseSpotPriceData,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CoinbaseSpotPriceData {
|
||||
amount: String,
|
||||
}
|
||||
const LIGHTNING_INVOICE_DESCRIPTION: &str = "Relay subscription payment";
|
||||
|
||||
enum NwcInvoicePaymentOutcome {
|
||||
Paid,
|
||||
@@ -86,10 +21,8 @@ enum NwcInvoicePaymentOutcome {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Billing {
|
||||
nwc_url: String,
|
||||
stripe_secret_key: String,
|
||||
stripe_webhook_secret: String,
|
||||
http: reqwest::Client,
|
||||
stripe: Stripe,
|
||||
bitcoin: Bitcoin,
|
||||
query: Query,
|
||||
command: Command,
|
||||
robot: Robot,
|
||||
@@ -97,20 +30,9 @@ pub struct Billing {
|
||||
|
||||
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();
|
||||
if stripe_webhook_secret.trim().is_empty() {
|
||||
panic!("missing STRIPE_WEBHOOK_SECRET environment variable");
|
||||
}
|
||||
Self {
|
||||
nwc_url,
|
||||
stripe_secret_key,
|
||||
stripe_webhook_secret,
|
||||
http: reqwest::Client::new(),
|
||||
stripe: Stripe::from_env(),
|
||||
bitcoin: Bitcoin::from_env(),
|
||||
query,
|
||||
command,
|
||||
robot,
|
||||
@@ -181,21 +103,15 @@ impl Billing {
|
||||
| "complete_relay_sync"
|
||||
);
|
||||
|
||||
if needs_billing_sync {
|
||||
self.sync_relay_subscription(activity).await?;
|
||||
if needs_billing_sync
|
||||
&& let Some(relay) = self.query.get_relay(&activity.resource_id).await?
|
||||
{
|
||||
self.sync_tenant_subscription(&relay.tenant).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_relay_subscription(&self, activity: &Activity) -> Result<()> {
|
||||
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
self.sync_tenant_subscription(&relay.tenant).await
|
||||
}
|
||||
|
||||
/// Reconciles a tenant's single Stripe subscription with the set of relays that
|
||||
/// should be billed.
|
||||
///
|
||||
@@ -237,7 +153,7 @@ impl Billing {
|
||||
// Resolve the live subscription, dropping a stale reference to one that no
|
||||
// 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(subscription_id) => match self.stripe.get_subscription(subscription_id).await? {
|
||||
Some(sub)
|
||||
if !matches!(
|
||||
sub["status"].as_str().unwrap_or_default(),
|
||||
@@ -260,7 +176,7 @@ impl Billing {
|
||||
// No relays to bill: tear everything down.
|
||||
if desired.is_empty() {
|
||||
if let Some(ref subscription_id) = tenant.stripe_subscription_id {
|
||||
self.stripe_cancel_subscription(subscription_id).await?;
|
||||
self.stripe.cancel_subscription(subscription_id).await?;
|
||||
self.command
|
||||
.clear_tenant_subscription(tenant_pubkey)
|
||||
.await?;
|
||||
@@ -283,7 +199,8 @@ impl Billing {
|
||||
match subscription {
|
||||
None => {
|
||||
let (subscription_id, items) = self
|
||||
.stripe_create_subscription(&tenant.stripe_customer_id, &desired)
|
||||
.stripe
|
||||
.create_subscription(&tenant.stripe_customer_id, &desired)
|
||||
.await?;
|
||||
self.command
|
||||
.set_tenant_subscription(tenant_pubkey, &subscription_id)
|
||||
@@ -315,13 +232,15 @@ impl Billing {
|
||||
if quantity < current_quantity {
|
||||
downgraded = true;
|
||||
}
|
||||
self.stripe_set_subscription_item_quantity(&item_id, quantity)
|
||||
self.stripe
|
||||
.set_subscription_item_quantity(&item_id, quantity)
|
||||
.await?;
|
||||
}
|
||||
price_to_item.insert(price_id.clone(), item_id);
|
||||
} else {
|
||||
let item_id = self
|
||||
.stripe_create_subscription_item(&subscription_id, price_id, quantity)
|
||||
.stripe
|
||||
.create_subscription_item(&subscription_id, price_id, quantity)
|
||||
.await?;
|
||||
price_to_item.insert(price_id.clone(), item_id);
|
||||
}
|
||||
@@ -330,7 +249,7 @@ impl Billing {
|
||||
// Items for plans no relay is on anymore.
|
||||
for (_, (item_id, _)) in current {
|
||||
downgraded = true;
|
||||
self.stripe_delete_subscription_item(&item_id).await?;
|
||||
self.stripe.delete_subscription_item(&item_id).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,9 +303,7 @@ impl Billing {
|
||||
}
|
||||
|
||||
pub async fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()> {
|
||||
self.verify_webhook_signature(payload, signature)?;
|
||||
|
||||
let event: StripeEvent = serde_json::from_str(payload)?;
|
||||
let event = self.stripe.construct_event(payload, signature)?;
|
||||
let obj = &event.data.object;
|
||||
|
||||
match event.event_type.as_str() {
|
||||
@@ -429,40 +346,6 @@ impl Billing {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_webhook_signature(&self, payload: &str, sig_header: &str) -> Result<()> {
|
||||
let mut timestamp = None;
|
||||
let mut signature = None;
|
||||
for part in sig_header.split(',') {
|
||||
if let Some(t) = part.strip_prefix("t=") {
|
||||
timestamp = Some(t);
|
||||
} else if let Some(v) = part.strip_prefix("v1=") {
|
||||
signature = Some(v);
|
||||
}
|
||||
}
|
||||
let timestamp = timestamp.ok_or_else(|| anyhow!("missing webhook timestamp"))?;
|
||||
let signature = signature.ok_or_else(|| anyhow!("missing webhook signature"))?;
|
||||
|
||||
let signed_payload = format!("{timestamp}.{payload}");
|
||||
let mut mac = HmacSha256::new_from_slice(self.stripe_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());
|
||||
|
||||
if expected != signature {
|
||||
return Err(anyhow!("webhook signature mismatch"));
|
||||
}
|
||||
|
||||
let ts: i64 = timestamp
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("bad webhook timestamp"))?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS {
|
||||
return Err(anyhow!("webhook timestamp outside tolerance"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_invoice_created(
|
||||
&self,
|
||||
stripe_customer_id: &str,
|
||||
@@ -536,7 +419,8 @@ impl Billing {
|
||||
|
||||
// 2. Card on file: if the tenant has a payment method, Stripe charges automatically
|
||||
if self
|
||||
.stripe_has_payment_method(&tenant.stripe_customer_id)
|
||||
.stripe
|
||||
.has_payment_method(&tenant.stripe_customer_id)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
@@ -686,7 +570,8 @@ impl Billing {
|
||||
|
||||
async fn validate_downgrade_proration(&self, tenant: &crate::models::Tenant, context: &str) {
|
||||
match self
|
||||
.stripe_preview_upcoming_invoice(
|
||||
.stripe
|
||||
.preview_upcoming_invoice(
|
||||
&tenant.stripe_customer_id,
|
||||
tenant.stripe_subscription_id.as_deref(),
|
||||
)
|
||||
@@ -743,7 +628,7 @@ impl Billing {
|
||||
&self,
|
||||
invoice_id: &str,
|
||||
) -> std::result::Result<(serde_json::Value, crate::models::Tenant), InvoiceLookupError> {
|
||||
let invoice = self.stripe_get_invoice(invoice_id).await?;
|
||||
let invoice = self.stripe.get_invoice(invoice_id).await?;
|
||||
let customer_id = invoice["customer"]
|
||||
.as_str()
|
||||
.ok_or_else(|| InvoiceLookupError::Internal(anyhow!("invoice missing customer")))?;
|
||||
@@ -803,92 +688,39 @@ impl Billing {
|
||||
|
||||
pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result<String> {
|
||||
let short_pubkey: String = tenant_pubkey.chars().take(8).collect();
|
||||
let nostr_name = self.robot.fetch_nostr_name(tenant_pubkey).await;
|
||||
let display_name = nostr_name.unwrap_or_else(|| short_pubkey.clone());
|
||||
let idempotency_key = self.idempotency_key(&["create_customer", tenant_pubkey]);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{STRIPE_API}/customers"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.header("Idempotency-Key", idempotency_key)
|
||||
.form(&[
|
||||
("name", display_name.as_str()),
|
||||
("metadata[tenant_pubkey]", tenant_pubkey),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body: serde_json::Value = stripe_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())
|
||||
let display_name = self
|
||||
.robot
|
||||
.fetch_nostr_name(tenant_pubkey)
|
||||
.await
|
||||
.unwrap_or(short_pubkey);
|
||||
self.stripe
|
||||
.create_customer(&display_name, tenant_pubkey)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<serde_json::Value> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{STRIPE_API}/invoices"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.query(&[("customer", customer_id)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body: serde_json::Value = stripe_error_for_status(resp).await?.json().await?;
|
||||
Ok(body["data"].clone())
|
||||
self.stripe.list_invoices(customer_id).await
|
||||
}
|
||||
|
||||
pub async fn stripe_get_invoice(
|
||||
pub async fn stripe_create_portal_session(
|
||||
&self,
|
||||
invoice_id: &str,
|
||||
) -> std::result::Result<serde_json::Value, InvoiceLookupError> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{STRIPE_API}/invoices/{invoice_id}"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_client_error() {
|
||||
return Err(InvoiceLookupError::StripeClient {
|
||||
status: resp.status(),
|
||||
});
|
||||
}
|
||||
|
||||
let body: serde_json::Value = stripe_error_for_status(resp).await?.json().await?;
|
||||
Ok(body)
|
||||
customer_id: &str,
|
||||
return_url: Option<&str>,
|
||||
) -> Result<String> {
|
||||
self.stripe
|
||||
.create_portal_session(customer_id, return_url)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String> {
|
||||
let amount_msats = self.fiat_minor_to_msats(amount_due_minor, currency).await?;
|
||||
|
||||
let system_uri: NostrWalletConnectURI = self
|
||||
.nwc_url
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("invalid system NWC URL"))?;
|
||||
let system_nwc = NWC::new(system_uri);
|
||||
|
||||
let make_req = MakeInvoiceRequest {
|
||||
amount: amount_msats,
|
||||
description: Some("Relay subscription payment".to_string()),
|
||||
description_hash: None,
|
||||
expiry: None,
|
||||
};
|
||||
|
||||
let invoice_response = system_nwc
|
||||
.make_invoice(make_req)
|
||||
let amount_msats = self
|
||||
.bitcoin
|
||||
.fiat_minor_to_msats(amount_due_minor, currency)
|
||||
.await?;
|
||||
self.bitcoin
|
||||
.system_wallet()?
|
||||
.make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION)
|
||||
.await
|
||||
.map_err(|e| anyhow!("failed to create invoice: {e}"))?;
|
||||
|
||||
system_nwc.shutdown().await;
|
||||
|
||||
Ok(invoice_response.invoice)
|
||||
}
|
||||
|
||||
pub async fn pay_outstanding_nwc_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> {
|
||||
@@ -899,7 +731,8 @@ impl Billing {
|
||||
let plain_nwc_url = crate::cipher::decrypt(&tenant.nwc_url)?;
|
||||
|
||||
let invoices = self
|
||||
.stripe_list_invoices(&tenant.stripe_customer_id)
|
||||
.stripe
|
||||
.list_invoices(&tenant.stripe_customer_id)
|
||||
.await?;
|
||||
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
|
||||
|
||||
@@ -967,14 +800,16 @@ impl Billing {
|
||||
|
||||
async fn pay_outstanding_card_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> {
|
||||
if !self
|
||||
.stripe_has_payment_method(&tenant.stripe_customer_id)
|
||||
.stripe
|
||||
.has_payment_method(&tenant.stripe_customer_id)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let invoices = self
|
||||
.stripe_list_invoices(&tenant.stripe_customer_id)
|
||||
.stripe
|
||||
.list_invoices(&tenant.stripe_customer_id)
|
||||
.await?;
|
||||
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
|
||||
|
||||
@@ -987,7 +822,7 @@ impl Billing {
|
||||
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,
|
||||
@@ -999,273 +834,14 @@ impl Billing {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stripe_create_portal_session(
|
||||
&self,
|
||||
customer_id: &str,
|
||||
return_url: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let mut params = vec![("customer", customer_id.to_string())];
|
||||
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.stripe_secret_key)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body: serde_json::Value = stripe_error_for_status(resp).await?.json().await?;
|
||||
let url = body["url"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("missing portal session url"))?
|
||||
.to_string();
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
// --- Stripe API helpers ---
|
||||
|
||||
fn idempotency_key(&self, parts: &[&str]) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(self.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())
|
||||
}
|
||||
|
||||
/// Fetches a subscription, returning `None` if Stripe no longer knows about it
|
||||
/// (so callers can recover from a stale `stripe_subscription_id`).
|
||||
async fn stripe_get_subscription(
|
||||
&self,
|
||||
subscription_id: &str,
|
||||
) -> Result<Option<serde_json::Value>> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{STRIPE_API}/subscriptions/{subscription_id}"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let body: serde_json::Value = stripe_error_for_status(resp).await?.json().await?;
|
||||
Ok(Some(body))
|
||||
}
|
||||
|
||||
/// Creates a subscription with one item per `(price_id, quantity)` entry. Returns
|
||||
/// the subscription id and a map from price id to the created subscription item id.
|
||||
async fn stripe_create_subscription(
|
||||
&self,
|
||||
customer_id: &str,
|
||||
items: &BTreeMap<String, i64>,
|
||||
) -> Result<(String, BTreeMap<String, String>)> {
|
||||
let mut form: Vec<(String, String)> = vec![
|
||||
("customer".to_string(), customer_id.to_string()),
|
||||
(
|
||||
"collection_method".to_string(),
|
||||
"charge_automatically".to_string(),
|
||||
),
|
||||
];
|
||||
let mut key_parts: Vec<String> =
|
||||
vec!["create_subscription".to_string(), customer_id.to_string()];
|
||||
for (index, (price_id, quantity)) in items.iter().enumerate() {
|
||||
form.push((format!("items[{index}][price]"), price_id.clone()));
|
||||
form.push((format!("items[{index}][quantity]"), quantity.to_string()));
|
||||
key_parts.push(format!("{price_id}={quantity}"));
|
||||
}
|
||||
let key_refs: Vec<&str> = key_parts.iter().map(String::as_str).collect();
|
||||
let idempotency_key = self.idempotency_key(&key_refs);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{STRIPE_API}/subscriptions"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.header("Idempotency-Key", idempotency_key)
|
||||
.form(&form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body: serde_json::Value = stripe_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))
|
||||
}
|
||||
|
||||
async fn stripe_create_subscription_item(
|
||||
&self,
|
||||
subscription_id: &str,
|
||||
price_id: &str,
|
||||
quantity: i64,
|
||||
) -> Result<String> {
|
||||
let idempotency_key =
|
||||
self.idempotency_key(&["create_subscription_item", subscription_id, price_id]);
|
||||
let quantity = quantity.to_string();
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{STRIPE_API}/subscription_items"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.header("Idempotency-Key", idempotency_key)
|
||||
.form(&[
|
||||
("subscription", subscription_id),
|
||||
("price", price_id),
|
||||
("quantity", quantity.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body: serde_json::Value = stripe_error_for_status(resp).await?.json().await?;
|
||||
let item_id = body["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("missing subscription item id"))?
|
||||
.to_string();
|
||||
|
||||
Ok(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.
|
||||
async fn stripe_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.stripe_secret_key)
|
||||
.form(&[("quantity", quantity.to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
stripe_error_for_status(resp).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stripe_delete_subscription_item(&self, item_id: &str) -> Result<()> {
|
||||
let resp = self
|
||||
.http
|
||||
.delete(format!("{STRIPE_API}/subscription_items/{item_id}"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.send()
|
||||
.await?;
|
||||
stripe_error_for_status(resp).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stripe_cancel_subscription(&self, subscription_id: &str) -> Result<()> {
|
||||
let resp = self
|
||||
.http
|
||||
.delete(format!("{STRIPE_API}/subscriptions/{subscription_id}"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.send()
|
||||
.await?;
|
||||
stripe_error_for_status(resp).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stripe_pay_invoice(&self, invoice_id: &str) -> Result<()> {
|
||||
let idempotency_key = self.idempotency_key(&["pay_invoice", invoice_id]);
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.header("Idempotency-Key", idempotency_key)
|
||||
.send()
|
||||
.await?;
|
||||
stripe_error_for_status(resp).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stripe_preview_upcoming_invoice(
|
||||
&self,
|
||||
customer_id: &str,
|
||||
subscription_id: Option<&str>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut req = self
|
||||
.http
|
||||
.get(format!("{STRIPE_API}/invoices/upcoming"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.query(&[("customer", customer_id)]);
|
||||
|
||||
if let Some(subscription_id) = subscription_id {
|
||||
req = req.query(&[("subscription", subscription_id)]);
|
||||
}
|
||||
|
||||
let body: serde_json::Value = stripe_error_for_status(req.send().await?)
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
async fn stripe_pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> {
|
||||
let idempotency_key = self.idempotency_key(&["pay_invoice_oob", invoice_id]);
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.header("Idempotency-Key", idempotency_key)
|
||||
.form(&[("paid_out_of_band", "true")])
|
||||
.send()
|
||||
.await?;
|
||||
stripe_error_for_status(resp).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stripe_has_payment_method(&self, customer_id: &str) -> Result<bool> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{STRIPE_API}/payment_methods"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.query(&[("customer", customer_id), ("type", "card")])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body: serde_json::Value = stripe_error_for_status(resp).await?.json().await?;
|
||||
let has_method = body["data"]
|
||||
.as_array()
|
||||
.map(|a| !a.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(has_method)
|
||||
}
|
||||
|
||||
// --- NWC helpers ---
|
||||
// --- Lightning / NWC orchestration ---
|
||||
|
||||
async fn mark_invoice_paid_out_of_band_after_nwc(
|
||||
&self,
|
||||
invoice_id: &str,
|
||||
tenant_pubkey: &str,
|
||||
) -> Result<()> {
|
||||
self.stripe_pay_invoice_out_of_band(invoice_id).await?;
|
||||
self.stripe.pay_invoice_out_of_band(invoice_id).await?;
|
||||
self.command.clear_tenant_nwc_error(tenant_pubkey).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1303,7 +879,7 @@ impl Billing {
|
||||
return Ok(invoice.clone());
|
||||
}
|
||||
|
||||
if let Err(error) = self.stripe_pay_invoice_out_of_band(invoice_id).await {
|
||||
if let Err(error) = self.stripe.pay_invoice_out_of_band(invoice_id).await {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
invoice_id,
|
||||
@@ -1311,37 +887,16 @@ impl Billing {
|
||||
);
|
||||
}
|
||||
|
||||
self.stripe_get_invoice(invoice_id).await
|
||||
self.stripe.get_invoice(invoice_id).await
|
||||
}
|
||||
|
||||
async fn is_manual_lightning_invoice_settled(&self, bolt11: &str) -> Result<bool> {
|
||||
let system_uri = Self::parse_nwc_uri(&self.nwc_url, "system")?;
|
||||
let system_nwc = NWC::new(system_uri);
|
||||
|
||||
let lookup_req = LookupInvoiceRequest {
|
||||
payment_hash: None,
|
||||
invoice: Some(bolt11.to_string()),
|
||||
};
|
||||
|
||||
let lookup_result = system_nwc.lookup_invoice(lookup_req).await;
|
||||
system_nwc.shutdown().await;
|
||||
|
||||
let lookup_response =
|
||||
lookup_result.map_err(|error| anyhow!("failed to lookup invoice: {error}"))?;
|
||||
|
||||
Ok(Self::lookup_invoice_response_is_settled(&lookup_response))
|
||||
}
|
||||
|
||||
fn lookup_invoice_response_is_settled(response: &LookupInvoiceResponse) -> bool {
|
||||
response.state == Some(TransactionState::Settled) || response.settled_at.is_some()
|
||||
}
|
||||
|
||||
fn parse_nwc_uri(nwc_url: &str, role: &str) -> Result<NostrWalletConnectURI> {
|
||||
nwc_url
|
||||
.parse::<NostrWalletConnectURI>()
|
||||
.map_err(|_| anyhow!("invalid {role} NWC URL"))
|
||||
self.bitcoin.system_wallet()?.invoice_settled(bolt11).await
|
||||
}
|
||||
|
||||
/// Charges a Stripe invoice over Lightning: the system wallet issues a bolt11
|
||||
/// invoice for the fiat amount, the tenant's wallet pays it. A `pending` row in
|
||||
/// `invoice_nwc_payment` guards against double-charging across retries.
|
||||
async fn nwc_pay_invoice(
|
||||
&self,
|
||||
invoice_id: &str,
|
||||
@@ -1357,42 +912,29 @@ impl Billing {
|
||||
return Ok(existing_outcome);
|
||||
}
|
||||
|
||||
let amount_msats = match self.fiat_minor_to_msats(amount_due_minor, currency).await {
|
||||
let amount_msats = match self
|
||||
.bitcoin
|
||||
.fiat_minor_to_msats(amount_due_minor, currency)
|
||||
.await
|
||||
{
|
||||
Ok(amount_msats) => amount_msats,
|
||||
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
||||
};
|
||||
|
||||
// Create a bolt11 invoice using the system wallet (self.nwc_url)
|
||||
let system_uri = match Self::parse_nwc_uri(&self.nwc_url, "system") {
|
||||
Ok(system_uri) => system_uri,
|
||||
let system_wallet = match self.bitcoin.system_wallet() {
|
||||
Ok(wallet) => wallet,
|
||||
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
||||
};
|
||||
let system_nwc = NWC::new(system_uri);
|
||||
|
||||
let make_req = MakeInvoiceRequest {
|
||||
amount: amount_msats,
|
||||
description: Some("Relay subscription payment".to_string()),
|
||||
description_hash: None,
|
||||
expiry: None,
|
||||
let bolt11 = match system_wallet
|
||||
.make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION)
|
||||
.await
|
||||
{
|
||||
Ok(bolt11) => bolt11,
|
||||
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
||||
};
|
||||
|
||||
let invoice_response = system_nwc.make_invoice(make_req).await;
|
||||
|
||||
let invoice_response = match invoice_response {
|
||||
Ok(invoice_response) => invoice_response,
|
||||
Err(error) => {
|
||||
system_nwc.shutdown().await;
|
||||
return Ok(NwcInvoicePaymentOutcome::Fallback(anyhow!(
|
||||
"failed to create invoice: {error}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
system_nwc.shutdown().await;
|
||||
|
||||
// Pay the bolt11 invoice using the tenant's wallet
|
||||
let tenant_uri = match Self::parse_nwc_uri(tenant_nwc_url, "tenant") {
|
||||
Ok(tenant_uri) => tenant_uri,
|
||||
let tenant_wallet = match Wallet::parse(tenant_nwc_url, "tenant") {
|
||||
Ok(wallet) => wallet,
|
||||
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
||||
};
|
||||
|
||||
@@ -1412,16 +954,8 @@ impl Billing {
|
||||
));
|
||||
}
|
||||
|
||||
let tenant_nwc = NWC::new(tenant_uri);
|
||||
|
||||
let pay_req = NwcPayInvoiceRequest::new(invoice_response.invoice);
|
||||
|
||||
let pay_result = tenant_nwc.pay_invoice(pay_req).await;
|
||||
|
||||
tenant_nwc.shutdown().await;
|
||||
|
||||
match pay_result {
|
||||
Ok(_) => match self.command.mark_invoice_nwc_payment_paid(invoice_id).await {
|
||||
match tenant_wallet.pay_invoice(bolt11).await {
|
||||
Ok(()) => match self.command.mark_invoice_nwc_payment_paid(invoice_id).await {
|
||||
Ok(()) => Ok(NwcInvoicePaymentOutcome::Paid),
|
||||
Err(error) => Ok(NwcInvoicePaymentOutcome::Pending(anyhow!(
|
||||
"invoice {invoice_id} was charged over NWC but failed to persist paid state: {error}"
|
||||
@@ -1433,98 +967,9 @@ impl Billing {
|
||||
}
|
||||
}
|
||||
|
||||
async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result<u64> {
|
||||
let normalized_currency = currency.to_uppercase();
|
||||
let btc_price = self.fetch_btc_spot_price(&normalized_currency).await?;
|
||||
fiat_minor_to_msats_from_quote(amount_due_minor, &normalized_currency, btc_price)
|
||||
}
|
||||
|
||||
fn should_reactivate_after_payment(relay: &Relay) -> bool {
|
||||
relay.status == RELAY_STATUS_DELINQUENT && Query::is_paid_plan(&relay.plan)
|
||||
}
|
||||
|
||||
async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> {
|
||||
fetch_btc_spot_price_from_base(&self.http, COINBASE_SPOT_API, currency).await
|
||||
}
|
||||
|
||||
fn currency_minor_exponent(currency: &str) -> Result<u8> {
|
||||
let normalized = currency.to_uppercase();
|
||||
let exponent = match normalized.as_str() {
|
||||
// Zero-decimal currencies in Stripe.
|
||||
"BIF" | "CLP" | "DJF" | "GNF" | "JPY" | "KMF" | "KRW" | "MGA" | "PYG" | "RWF"
|
||||
| "UGX" | "VND" | "VUV" | "XAF" | "XOF" | "XPF" => 0,
|
||||
// Three-decimal currencies in Stripe.
|
||||
"BHD" | "JOD" | "KWD" | "OMR" | "TND" => 3,
|
||||
_ if normalized.chars().all(|c| c.is_ascii_alphabetic()) && normalized.len() == 3 => 2,
|
||||
_ => return Err(anyhow!("invalid currency code: {currency}")),
|
||||
};
|
||||
|
||||
Ok(exponent)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
async fn stripe_error_for_status(resp: reqwest::Response) -> Result<reqwest::Response> {
|
||||
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::<serde_json::Value>(&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() {
|
||||
"<empty response body>".to_string()
|
||||
} else {
|
||||
body
|
||||
}
|
||||
});
|
||||
|
||||
Err(anyhow!(
|
||||
"Stripe API request to {url} failed with status {status}: {detail}"
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn fetch_btc_spot_price_from_base(
|
||||
http: &reqwest::Client,
|
||||
api_base: &str,
|
||||
currency: &str,
|
||||
) -> Result<f64> {
|
||||
let pair = format!("BTC-{currency}");
|
||||
let url = format!("{}/{pair}/spot", api_base.trim_end_matches('/'));
|
||||
let resp = http.get(url).send().await?;
|
||||
let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?;
|
||||
|
||||
let amount = body
|
||||
.data
|
||||
.amount
|
||||
.parse::<f64>()
|
||||
.map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))?;
|
||||
|
||||
if amount <= 0.0 {
|
||||
return Err(anyhow!(
|
||||
"invalid non-positive BTC spot quote for {currency}"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(amount)
|
||||
}
|
||||
|
||||
fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
|
||||
@@ -1552,41 +997,9 @@ fn manual_lightning_payment_dm(nwc_error: Option<&str>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fiat_minor_to_msats_from_quote(
|
||||
amount_due_minor: i64,
|
||||
currency: &str,
|
||||
btc_price_in_fiat: f64,
|
||||
) -> Result<u64> {
|
||||
if amount_due_minor <= 0 {
|
||||
return Err(anyhow!("amount_due must be positive"));
|
||||
}
|
||||
|
||||
if btc_price_in_fiat <= 0.0 {
|
||||
return Err(anyhow!("btc_price_in_fiat must be positive"));
|
||||
}
|
||||
|
||||
let exponent = Billing::currency_minor_exponent(currency)?;
|
||||
let divisor = 10_f64.powi(exponent as i32);
|
||||
let amount_fiat = (amount_due_minor as f64) / divisor;
|
||||
let amount_btc = amount_fiat / btc_price_in_fiat;
|
||||
let raw_msats = amount_btc * 100_000_000_000.0;
|
||||
// Guard against tiny floating point artifacts at integer boundaries.
|
||||
let amount_msats = if (raw_msats - raw_msats.round()).abs() < 1e-6 {
|
||||
raw_msats.round()
|
||||
} else {
|
||||
raw_msats.ceil()
|
||||
};
|
||||
|
||||
if !amount_msats.is_finite() || amount_msats <= 0.0 || amount_msats > u64::MAX as f64 {
|
||||
return Err(anyhow!("calculated msat amount is out of bounds"));
|
||||
}
|
||||
|
||||
Ok(amount_msats as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Billing, fiat_minor_to_msats_from_quote};
|
||||
use super::Billing;
|
||||
use crate::models::{
|
||||
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
|
||||
};
|
||||
@@ -1615,20 +1028,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_usd_minor_units_with_quote() {
|
||||
let msats = fiat_minor_to_msats_from_quote(100, "usd", 100_000.0)
|
||||
.expect("conversion should succeed");
|
||||
assert_eq!(msats, 1_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_zero_decimal_currency_with_quote() {
|
||||
let msats = fiat_minor_to_msats_from_quote(100, "jpy", 10_000_000.0)
|
||||
.expect("conversion should succeed");
|
||||
assert_eq!(msats, 1_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactivates_only_delinquent_paid_relays_after_payment() {
|
||||
let delinquent_paid = relay_fixture(RELAY_STATUS_DELINQUENT, "basic");
|
||||
@@ -1846,7 +1245,7 @@ mod tests {
|
||||
Robot::test_stub(),
|
||||
);
|
||||
|
||||
assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
|
||||
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
|
||||
assert_eq!(billing.stripe.secret_key, "sk_test_dummy");
|
||||
assert_eq!(billing.stripe.webhook_secret, "whsec_test_dummy");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user