Compare commits

...

2 Commits

+69 -11
View File
@@ -17,6 +17,9 @@ 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 {
@@ -80,7 +83,6 @@ pub struct Billing {
nwc_url: String,
stripe_secret_key: String,
stripe_webhook_secret: String,
btc_quote_api_base: String,
http: reqwest::Client,
query: Query,
command: Command,
@@ -98,13 +100,10 @@ impl Billing {
if stripe_webhook_secret.trim().is_empty() {
panic!("missing STRIPE_WEBHOOK_SECRET environment variable");
}
let btc_quote_api_base =
std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string());
Self {
nwc_url,
stripe_secret_key,
stripe_webhook_secret,
btc_quote_api_base,
http: reqwest::Client::new(),
query,
command,
@@ -360,6 +359,8 @@ impl Billing {
return Ok(());
};
let mut nwc_error_for_dm: Option<String> = None;
// 1. NWC auto-pay: if the tenant has a nwc_url
if !tenant.nwc_url.is_empty() {
match self
@@ -376,6 +377,14 @@ impl Billing {
self.command
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
.await?;
tracing::warn!(
error = %e,
tenant_pubkey = %tenant.pubkey,
stripe_customer_id,
invoice_id,
"nwc auto-payment failed for invoice.created"
);
nwc_error_for_dm = summarize_nwc_error_for_dm(&error_msg);
// Fall through to next option
}
}
@@ -390,12 +399,8 @@ impl Billing {
}
// 3. Manual payment: send a DM
self.robot
.send_dm(
&tenant.pubkey,
"Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.",
)
.await?;
let dm_message = manual_lightning_payment_dm(nwc_error_for_dm.as_deref());
self.robot.send_dm(&tenant.pubkey, &dm_message).await?;
Ok(())
}
@@ -638,11 +643,13 @@ impl Billing {
pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result<String> {
let short_pubkey: String = tenant_pubkey.chars().take(12).collect();
let display_name = format!("Caravel tenant {short_pubkey}");
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),
@@ -829,15 +836,30 @@ impl Billing {
// --- 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())
}
async fn stripe_create_subscription(
&self,
customer_id: &str,
price_id: &str,
) -> Result<(String, String)> {
let idempotency_key =
self.idempotency_key(&["create_subscription", customer_id, price_id]);
let resp = self
.http
.post(format!("{STRIPE_API}/subscriptions"))
.bearer_auth(&self.stripe_secret_key)
.header("Idempotency-Key", idempotency_key)
.form(&[
("customer", customer_id),
("collection_method", "charge_automatically"),
@@ -864,10 +886,13 @@ impl Billing {
subscription_id: &str,
price_id: &str,
) -> Result<String> {
let idempotency_key =
self.idempotency_key(&["create_subscription_item", subscription_id, price_id]);
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)])
.send()
.await?;
@@ -886,10 +911,13 @@ impl Billing {
item_id: &str,
price_id: &str,
) -> Result<String> {
let idempotency_key =
self.idempotency_key(&["update_subscription_item", item_id, price_id]);
let resp = self
.http
.post(format!("{STRIPE_API}/subscription_items/{item_id}"))
.bearer_auth(&self.stripe_secret_key)
.header("Idempotency-Key", idempotency_key)
.form(&[("price", price_id)])
.send()
.await?;
@@ -926,9 +954,11 @@ impl Billing {
}
async fn stripe_pay_invoice(&self, invoice_id: &str) -> Result<()> {
let idempotency_key = self.idempotency_key(&["pay_invoice", invoice_id]);
self.http
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
.bearer_auth(&self.stripe_secret_key)
.header("Idempotency-Key", idempotency_key)
.send()
.await?
.error_for_status()?;
@@ -968,9 +998,11 @@ impl Billing {
}
async fn stripe_pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> {
let idempotency_key = self.idempotency_key(&["pay_invoice_oob", invoice_id]);
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?
@@ -1057,7 +1089,7 @@ impl Billing {
}
async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> {
fetch_btc_spot_price_from_base(&self.http, &self.btc_quote_api_base, currency).await
fetch_btc_spot_price_from_base(&self.http, COINBASE_SPOT_API, currency).await
}
fn currency_minor_exponent(currency: &str) -> Result<u8> {
@@ -1101,6 +1133,31 @@ pub async fn fetch_btc_spot_price_from_base(
Ok(amount)
}
fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
return None;
}
if normalized.chars().count() <= NWC_ERROR_DM_MAX_CHARS {
return Some(normalized);
}
let prefix_len = NWC_ERROR_DM_MAX_CHARS.saturating_sub(3);
let mut truncated = normalized.chars().take(prefix_len).collect::<String>();
truncated.push_str("...");
Some(truncated)
}
fn manual_lightning_payment_dm(nwc_error: Option<&str>) -> String {
match nwc_error {
Some(error) if !error.is_empty() => {
format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{NWC_ERROR_DM_PREFIX} {error}")
}
_ => MANUAL_LIGHTNING_PAYMENT_DM.to_string(),
}
}
pub fn fiat_minor_to_msats_from_quote(
amount_due_minor: i64,
currency: &str,
@@ -1398,4 +1455,5 @@ mod tests {
assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
}
}