diff --git a/backend/src/api.rs b/backend/src/api.rs index 17a6289..6f68808 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -12,7 +12,7 @@ use base64::Engine; use nostr_sdk::{Event, JsonUtil, Kind}; use serde::{Deserialize, Serialize}; -use crate::billing::Billing; +use crate::billing::{Billing, InvoiceLookupError}; use crate::command::Command; use crate::models::{ RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant, @@ -72,6 +72,11 @@ enum ApiError { Unauthorized(anyhow::Error), Forbidden(&'static str), NotFound(&'static str), + Client { + status: StatusCode, + code: &'static str, + message: &'static str, + }, Internal(String), } @@ -81,11 +86,36 @@ impl IntoResponse for ApiError { Self::Unauthorized(e) => err(StatusCode::UNAUTHORIZED, "unauthorized", &e.to_string()), Self::Forbidden(message) => err(StatusCode::FORBIDDEN, "forbidden", message), Self::NotFound(message) => err(StatusCode::NOT_FOUND, "not-found", message), + Self::Client { + status, + code, + message, + } => err(status, code, message), Self::Internal(message) => err(StatusCode::INTERNAL_SERVER_ERROR, "internal", &message), } } } +fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError { + match error { + InvoiceLookupError::StripeClient { status } => { + let status = StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_REQUEST); + match status { + StatusCode::NOT_FOUND => ApiError::NotFound("invoice not found"), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + ApiError::Forbidden("invoice access denied") + } + _ => ApiError::Client { + status, + code: "invoice-request-rejected", + message: "invoice request rejected", + }, + } + } + InvoiceLookupError::Internal(error) => ApiError::Internal(error.to_string()), + } +} + impl Api { pub fn new(query: Query, command: Command, billing: Billing) -> Self { let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); @@ -808,7 +838,7 @@ async fn get_invoice( .billing .get_invoice_with_tenant(&id) .await - .map_err(|e| ApiError::Internal(e.to_string()))?; + .map_err(map_invoice_lookup_error)?; state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?; Ok(ok(StatusCode::OK, invoice)) @@ -825,7 +855,7 @@ async fn get_invoice_bolt11( .billing .get_invoice_with_tenant(&id) .await - .map_err(|e| ApiError::Internal(e.to_string()))?; + .map_err(map_invoice_lookup_error)?; state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?; let status = invoice["status"].as_str().unwrap_or_default(); diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 4f3a7f5..8a41265 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -18,6 +18,41 @@ 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(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()) + } +} + #[derive(serde::Deserialize)] struct StripeEvent { #[serde(rename = "type")] @@ -462,16 +497,18 @@ impl Billing { pub async fn get_invoice_with_tenant( &self, invoice_id: &str, - ) -> Result<(serde_json::Value, crate::models::Tenant)> { + ) -> std::result::Result<(serde_json::Value, crate::models::Tenant), InvoiceLookupError> { let invoice = self.stripe_get_invoice(invoice_id).await?; let customer_id = invoice["customer"] .as_str() - .ok_or_else(|| anyhow!("invoice missing customer"))?; + .ok_or_else(|| InvoiceLookupError::Internal(anyhow!("invoice missing customer")))?; let tenant = self .query .get_tenant_by_stripe_customer_id(customer_id) .await? - .ok_or_else(|| anyhow!("tenant not found for customer"))?; + .ok_or_else(|| { + InvoiceLookupError::Internal(anyhow!("tenant not found for customer")) + })?; Ok((invoice, tenant)) } @@ -515,7 +552,10 @@ impl Billing { Ok(body["data"].clone()) } - pub async fn stripe_get_invoice(&self, invoice_id: &str) -> Result { + pub async fn stripe_get_invoice( + &self, + invoice_id: &str, + ) -> std::result::Result { let resp = self .http .get(format!("{STRIPE_API}/invoices/{invoice_id}")) @@ -523,6 +563,12 @@ impl Billing { .send() .await?; + if resp.status().is_client_error() { + return Err(InvoiceLookupError::StripeClient { + status: resp.status(), + }); + } + let body: serde_json::Value = resp.error_for_status()?.json().await?; Ok(body) }