forked from coracle/caravel
Refactor billing/invoices a bit
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}");
|
||||
|
||||
Reference in New Issue
Block a user