From 5214439abb0fad9e09cb5c11b49f483070424409 Mon Sep 17 00:00:00 2001 From: userAdityaa Date: Sat, 11 Apr 2026 23:36:23 +0545 Subject: [PATCH] fix (billing): use fx quote for stripe to lightning --- backend/src/api.rs | 3 +- backend/src/billing.rs | 129 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 122 insertions(+), 10 deletions(-) diff --git a/backend/src/api.rs b/backend/src/api.rs index e193ff4..e39de4b 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -800,8 +800,9 @@ async fn get_invoice_bolt11( } let amount_due = invoice["amount_due"].as_i64().unwrap_or(0); + let currency = invoice["currency"].as_str().unwrap_or("usd"); - match state.api.billing.create_bolt11(amount_due).await { + match state.api.billing.create_bolt11(amount_due, currency).await { Ok(bolt11) => Ok(ok(StatusCode::OK, serde_json::json!({ "bolt11": bolt11 }))), Err(e) => Ok(err( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 89d84e8..2519803 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -13,6 +13,7 @@ use crate::robot::Robot; type HmacSha256 = Hmac; 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; #[derive(serde::Deserialize)] @@ -27,6 +28,16 @@ struct StripeEventData { object: serde_json::Value, } +#[derive(serde::Deserialize)] +struct CoinbaseSpotPriceResponse { + data: CoinbaseSpotPriceData, +} + +#[derive(serde::Deserialize)] +struct CoinbaseSpotPriceData { + amount: String, +} + #[derive(Clone)] pub struct Billing { nwc_url: String, @@ -186,8 +197,9 @@ impl Billing { "invoice.created" => { let customer = obj["customer"].as_str().unwrap_or_default(); let amount_due = obj["amount_due"].as_i64().unwrap_or(0); + let currency = obj["currency"].as_str().unwrap_or("usd"); let invoice_id = obj["id"].as_str().unwrap_or_default(); - self.handle_invoice_created(customer, amount_due, invoice_id) + self.handle_invoice_created(customer, amount_due, currency, invoice_id) .await?; } "invoice.paid" => { @@ -253,6 +265,7 @@ impl Billing { &self, stripe_customer_id: &str, amount_due: i64, + currency: &str, invoice_id: &str, ) -> Result<()> { if amount_due == 0 { @@ -269,7 +282,10 @@ impl Billing { // 1. NWC auto-pay: if the tenant has a nwc_url if !tenant.nwc_url.is_empty() { - match self.nwc_pay_invoice(amount_due, &tenant.nwc_url).await { + match self + .nwc_pay_invoice(amount_due, currency, &tenant.nwc_url) + .await + { Ok(()) => { self.stripe_pay_invoice_out_of_band(invoice_id).await?; self.command @@ -467,8 +483,8 @@ impl Billing { Ok(body) } - pub async fn create_bolt11(&self, amount_due_cents: i64) -> Result { - let amount_msats = (amount_due_cents as u64) * 1000; // placeholder conversion + pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result { + 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"))?; @@ -642,11 +658,13 @@ impl Billing { // --- NWC helpers --- - async fn nwc_pay_invoice(&self, amount_due_cents: i64, tenant_nwc_url: &str) -> Result<()> { - // Convert USD cents to millisatoshis (approximate: 1 sat ≈ variable USD) - // amount_due is in cents from Stripe. We create a Lightning invoice for the exact amount. - // The NWC make_invoice amount is in millisatoshis. - let amount_msats = (amount_due_cents as u64) * 1000; // placeholder conversion, actual rate would come from exchange + async fn nwc_pay_invoice( + &self, + amount_due_minor: i64, + currency: &str, + tenant_nwc_url: &str, + ) -> Result<()> { + let amount_msats = self.fiat_minor_to_msats(amount_due_minor, currency).await?; // Create a bolt11 invoice using the system wallet (self.nwc_url) let system_uri: NostrWalletConnectURI = self.nwc_url.parse() @@ -683,4 +701,97 @@ impl Billing { Ok(()) } + + async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result { + let normalized_currency = currency.to_uppercase(); + let btc_price = self.fetch_btc_spot_price(&normalized_currency).await?; + Self::fiat_minor_to_msats_from_quote(amount_due_minor, &normalized_currency, btc_price) + } + + async fn fetch_btc_spot_price(&self, currency: &str) -> Result { + let pair = format!("BTC-{currency}"); + let url = format!("{COINBASE_SPOT_API}/{pair}/spot"); + let resp = self.http.get(url).send().await?; + let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?; + + let amount = body + .data + .amount + .parse::() + .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 fiat_minor_to_msats_from_quote( + amount_due_minor: i64, + currency: &str, + btc_price_in_fiat: f64, + ) -> Result { + 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 = Self::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) + } + + fn currency_minor_exponent(currency: &str) -> Result { + 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) + } +} + +#[cfg(test)] +mod tests { + use super::Billing; + + #[test] + fn converts_usd_minor_units_with_quote() { + let msats = Billing::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 = Billing::fiat_minor_to_msats_from_quote(100, "jpy", 10_000_000.0) + .expect("conversion should succeed"); + assert_eq!(msats, 1_000_000); + } }