Remove InvoiceLookupError
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 0s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 1s

This commit is contained in:
Jon Staab
2026-05-19 17:29:47 -07:00
parent 2d5eb0ca84
commit b49d62f1dd
3 changed files with 32 additions and 78 deletions
+19 -11
View File
@@ -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<Option<(serde_json::Value, crate::models::Tenant)>> {
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<serde_json::Value, InvoiceLookupError> {
) -> Result<serde_json::Value> {
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<serde_json::Value, InvoiceLookupError> {
) -> Result<serde_json::Value> {
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<bool> {
+7 -20
View File
@@ -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<Arc<Api>>,
@@ -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),
}
}
+6 -47
View File
@@ -14,44 +14,6 @@ type HmacSha256 = Hmac<Sha256>;
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<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())
}
}
/// 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<serde_json::Value, InvoiceLookupError> {
/// 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<Option<serde_json::Value>> {
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<()> {