Refactor billing/invoices a bit

This commit is contained in:
Jon Staab
2026-05-22 09:31:58 -07:00
parent 97b1bd9a02
commit f8a0860045
4 changed files with 102 additions and 45 deletions
+2 -2
View File
@@ -91,7 +91,7 @@ Called when a tenant first sets their NWC URL (via `PUT /tenants/:pubkey`). Atte
- If `tenant.nwc_url` is empty, return early.
- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe.list_invoices`.
- For each invoice with `status == "open"` and `amount_due > 0`:
- Attempt NWC payment via `nwc_pay_invoice`.
- Attempt NWC payment via `pay_invoice_nwc`.
- On success: mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and call `command.clear_tenant_nwc_error`.
- On failure: call `command.set_tenant_nwc_error` and log the error; continue to the next invoice.
@@ -109,7 +109,7 @@ Attempts Stripe-side collection for open invoices when the tenant has a card on
Attempts to pay a new subscription invoice. Because Stripe defaults to pay-in-advance, this webhook fires immediately when a subscription is created (i.e. when a paid relay is added or a plan is upgraded). Payment priority:
1. **NWC auto-pay**: If the tenant has a `nwc_url`, run `nwc_pay_invoice` (decrypting the tenant's stored `nwc_url` first):
1. **NWC auto-pay**: If the tenant has a `nwc_url`, run `pay_invoice_nwc` (decrypting the tenant's stored `nwc_url` first):
- The system wallet (`self.wallet`) issues a bolt11 invoice for the fiat amount; the tenant's wallet (`Wallet::from_url` of the decrypted URL) pays it. A `pending` row in `invoice_nwc_payment` guards against double-charging across retries.
- If payment succeeds: mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and clear `nwc_error` via `command.clear_tenant_nwc_error`. Done.
- If it fails before any charge could have gone out: set `nwc_error` on the tenant via `command.set_tenant_nwc_error`, and fall through to the next option (carrying a short summary of the error into the eventual DM).
+60 -15
View File
@@ -71,7 +71,7 @@ impl Billing {
);
for tenant in tenants {
if let Err(error) = self.sync_tenant(&tenant.pubkey).await {
if let Err(error) = self.reconcile_subscription(&tenant).await {
tracing::error!(
source,
tenant = %tenant.pubkey,
@@ -95,41 +95,39 @@ impl Billing {
| "complete_relay_sync"
);
if needs_billing_sync {
self.sync_tenant(&activity.tenant).await?;
if needs_billing_sync
&& let Some(tenant) = self.query.get_tenant(&activity.tenant).await?
{
self.reconcile_subscription(&tenant).await?;
}
Ok(())
}
// --- Subscriptions ---
/// Reconciles a tenant's single Stripe subscription with the set of relays that
/// should be billed.
///
/// Stripe forbids two subscription items on the same subscription from sharing a
/// price, so billing is modeled as one subscription item per plan (price) with
/// `quantity` equal to the number of the tenant's `active` relays on that plan.
async fn sync_tenant(&self, tenant_pubkey: &str) -> Result<()> {
let Some(tenant) = self.query.get_tenant(tenant_pubkey).await? else {
return Ok(());
};
let quantity_by_price_id = self.get_quantity_by_price_id(&tenant).await?;
async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
let quantity_by_price_id = self.get_quantity_by_price_id(tenant).await?;
// If we've got no subscription items, we can cancel and clear the tenant's subscription
if quantity_by_price_id.is_empty() {
self.ensure_subscription_is_inactive(&tenant).await?;
self.ensure_subscription_is_inactive(tenant).await?;
return Ok(());
}
let subscription = self
.ensure_subscription_is_active(&tenant, &quantity_by_price_id)
.ensure_subscription_is_active(tenant, &quantity_by_price_id)
.await?;
self.ensure_subscription_items(subscription, quantity_by_price_id).await
}
// --- Stripe helpers ---
/// Gets a map of stripe_price_id -> quantity based on the tenant's current relays
async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>> {
let mut quantity_by_price_id = BTreeMap::new();
@@ -225,7 +223,7 @@ impl Billing {
Ok(())
}
// --- lightning helpers ---
// --- Invoices ---
/// return or generate a lightning invoice for an open stripe invoice
pub async fn ensure_lightning_invoice(
@@ -265,7 +263,7 @@ impl Billing {
}
/// Attempt to pay and settle an invoice via nwc
pub async fn nwc_pay_invoice(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()> {
pub async fn pay_invoice_nwc(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()> {
let nwc_url = self.env.decrypt(&tenant.nwc_url)?;
let tenant_wallet = Wallet::from_url(&nwc_url)?;
@@ -284,6 +282,53 @@ impl Billing {
}
}
/// Catch an out-of-band payment we never recorded — e.g. the user paid the
/// invoice but the frontend failed to notify us. If the invoice's bolt11 has
/// settled on the robot wallet, mark it paid and return the refreshed Stripe
/// invoice; otherwise return it unchanged. Meant to run before presenting a
/// payable invoice so we never hand back one that's already been paid.
pub async fn reconcile_invoice(&self, invoice: &StripeInvoice) -> Result<StripeInvoice> {
if invoice.status != "open" {
return Ok(invoice.clone());
}
let Some(row) = self.query.get_lightning_invoice(&invoice.id).await? else {
return Ok(invoice.clone());
};
let settled = match self.wallet.is_settled(&row.bolt11).await {
Ok(settled) => settled,
Err(error) => {
tracing::warn!(
error = %error,
stripe_invoice_id = %invoice.id,
"failed to look up bolt11 invoice settlement"
);
return Ok(invoice.clone());
}
};
if !settled {
return Ok(invoice.clone());
}
if let Err(error) = self.settle_invoice(&invoice.id, &row.tenant_pubkey, "manual").await {
tracing::warn!(
error = %error,
stripe_invoice_id = %invoice.id,
"failed to record settled bolt11 invoice as paid"
);
}
// Re-fetch so the caller sees the now-paid status; fall back to the
// pre-reconcile snapshot if Stripe momentarily 404s.
Ok(self
.stripe
.get_invoice(&invoice.id)
.await?
.unwrap_or_else(|| invoice.clone()))
}
/// Record a settled bolt11 invoice as paid by `method`, mark the Stripe
/// invoice paid out of band, and clear any lingering NWC error. The Stripe
/// call is idempotent across retries, and `mark_lightning_invoice_paid` is
+39 -27
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use axum::extract::{Path, State};
use crate::api::{Api, AuthedPubkey};
use crate::web::{ApiResult, bad_request, internal, not_found, ok};
use crate::web::{ApiResult, internal, not_found, ok};
pub async fn list_tenant_invoices(
State(api): State<Arc<Api>>,
@@ -26,32 +26,7 @@ pub async fn get_invoice(
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let Some(invoice) = api.stripe.get_invoice(stripe_invoice_id).await.map_err(internal)? else {
return Err(not_found("invoice not found"));
};
let Some(_) = api
.query
.get_tenant_by_stripe_customer_id(&invoice.customer)
.await
.map_err(internal)?
else {
return Err(not_found("invoice not found"));
};
api.require_admin_or_tenant(auth, &tenant.pubkey)?;
Ok((invoice, tenant))
ok(invoice)
}
pub async fn get_lightning_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let Some(invoice) = api.stripe.get_invoice(stripe_invoice_id).await.map_err(internal)? else {
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
return Err(not_found("invoice not found"));
};
@@ -64,6 +39,43 @@ pub async fn get_lightning_invoice(
return Err(not_found("invoice not found"));
};
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
let invoice = api
.billing
.reconcile_invoice(&invoice)
.await
.map_err(internal)?;
ok(invoice)
}
pub async fn get_lightning_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
return Err(not_found("invoice not found"));
};
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(&invoice.customer)
.await
.map_err(internal)?
else {
return Err(not_found("invoice not found"));
};
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
let invoice = api
.billing
.reconcile_invoice(&invoice)
.await
.map_err(internal)?;
let lightning_invoice = api
.billing
.ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency)
+1 -1
View File
@@ -136,7 +136,7 @@ async fn handle_invoice_created(
// 1. NWC auto-pay: if the tenant has a nwc_url
if !tenant.nwc_url.is_empty() {
match api.billing.nwc_pay_invoice(&tenant, &invoice).await {
match api.billing.pay_invoice_nwc(&tenant, &invoice).await {
Ok(()) => return Ok(()),
Err(e) => {
let error_msg = format!("{e}");