diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 13d84f6..e138f22 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::{InvoiceLookupError, Stripe}; +use crate::stripe::Stripe; 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."; @@ -631,29 +631,30 @@ impl Billing { // --- Public API helpers --- + /// Returns `Ok(None)` if Stripe has no such invoice; the route turns that into a 404. pub async fn get_invoice_with_tenant( &self, invoice_id: &str, - ) -> std::result::Result<(serde_json::Value, crate::models::Tenant), InvoiceLookupError> { - let invoice = self.stripe.get_invoice(invoice_id).await?; + ) -> 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(|| InvoiceLookupError::Internal(anyhow!("invoice missing customer")))?; + .ok_or_else(|| anyhow!("invoice missing customer"))?; let tenant = self .query .get_tenant_by_stripe_customer_id(customer_id) .await? - .ok_or_else(|| { - InvoiceLookupError::Internal(anyhow!("tenant not found for customer")) - })?; - Ok((invoice, tenant)) + .ok_or_else(|| anyhow!("tenant not found for customer"))?; + Ok(Some((invoice, tenant))) } pub async fn reconcile_manual_lightning_invoice( &self, invoice_id: &str, invoice: &serde_json::Value, - ) -> std::result::Result { + ) -> Result { self.reconcile_manual_lightning_invoice_if_settled(invoice_id, invoice) .await } @@ -853,7 +854,7 @@ impl Billing { &self, invoice_id: &str, invoice: &serde_json::Value, - ) -> std::result::Result { + ) -> Result { if invoice["status"].as_str().unwrap_or_default() != "open" { return Ok(invoice.clone()); } @@ -890,7 +891,14 @@ impl Billing { ); } - self.stripe.get_invoice(invoice_id).await + // The invoice existed when we called pay_invoice_out_of_band a moment ago; + // if Stripe suddenly returns 404, fall back to the pre-reconcile snapshot + // rather than failing the request. + Ok(self + .stripe + .get_invoice(invoice_id) + .await? + .unwrap_or_else(|| invoice.clone())) } async fn is_manual_lightning_invoice_settled(&self, bolt11: &str) -> Result { diff --git a/backend/src/routes/invoices.rs b/backend/src/routes/invoices.rs index 0df1c6b..89e5738 100644 --- a/backend/src/routes/invoices.rs +++ b/backend/src/routes/invoices.rs @@ -1,11 +1,9 @@ use std::sync::Arc; use axum::extract::{Path, State}; -use reqwest::StatusCode; use crate::api::{Api, AuthedPubkey}; -use crate::stripe::InvoiceLookupError; -use crate::web::{ApiError, ApiResult, bad_request, internal, not_found, ok}; +use crate::web::{ApiResult, bad_request, internal, not_found, ok}; pub async fn list_tenant_invoices( State(api): State>, @@ -32,14 +30,15 @@ pub async fn get_invoice( .billing .get_invoice_with_tenant(&id) .await - .map_err(map_invoice_lookup_error)?; + .map_err(internal)? + .ok_or_else(|| not_found("invoice not found"))?; api.require_admin_or_tenant(&auth, &tenant.pubkey)?; let invoice = api .billing .reconcile_manual_lightning_invoice(&id, &invoice) .await - .map_err(map_invoice_lookup_error)?; + .map_err(internal)?; ok(invoice) } @@ -53,14 +52,15 @@ pub async fn get_invoice_bolt11( .billing .get_invoice_with_tenant(&id) .await - .map_err(map_invoice_lookup_error)?; + .map_err(internal)? + .ok_or_else(|| not_found("invoice not found"))?; api.require_admin_or_tenant(&auth, &tenant.pubkey)?; let invoice = api .billing .reconcile_manual_lightning_invoice(&id, &invoice) .await - .map_err(map_invoice_lookup_error)?; + .map_err(internal)?; let status = invoice["status"].as_str().unwrap_or_default(); if status != "open" { @@ -77,16 +77,3 @@ pub async fn get_invoice_bolt11( .map_err(internal)?; ok(serde_json::json!({ "bolt11": bolt11 })) } - -fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError { - match error { - InvoiceLookupError::StripeClient { status } => match status { - StatusCode::NOT_FOUND => not_found("invoice not found"), - _ => { - tracing::warn!(%status, "stripe invoice request returned unexpected status"); - internal("invoice request rejected") - } - }, - InvoiceLookupError::Internal(error) => internal(error), - } -} diff --git a/backend/src/stripe.rs b/backend/src/stripe.rs index a50109c..5a459d7 100644 --- a/backend/src/stripe.rs +++ b/backend/src/stripe.rs @@ -14,44 +14,6 @@ type HmacSha256 = Hmac; const STRIPE_API: &str = "https://api.stripe.com/v1"; const WEBHOOK_TOLERANCE_SECS: i64 = 300; -/// Error returned by invoice lookups, distinguishing a Stripe 4xx (e.g. "no such -/// invoice") — which callers usually want to surface as a client error — from an -/// internal failure. -#[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 for InvoiceLookupError { - fn from(value: anyhow::Error) -> Self { - Self::Internal(value) - } -} - -impl From for InvoiceLookupError { - fn from(value: reqwest::Error) -> Self { - Self::Internal(value.into()) - } -} - /// A Stripe webhook event with its signature already verified. #[derive(serde::Deserialize)] pub struct Event { @@ -263,23 +225,20 @@ impl Stripe { Ok(body["data"].clone()) } - pub async fn get_invoice( - &self, - invoice_id: &str, - ) -> std::result::Result { + /// 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() .await?; - if resp.status().is_client_error() { - return Err(InvoiceLookupError::StripeClient { - status: resp.status(), - }); + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); } let body: serde_json::Value = error_for_status(resp).await?.json().await?; - Ok(body) + Ok(Some(body)) } pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()> {