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.
|
- If `tenant.nwc_url` is empty, return early.
|
||||||
- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe.list_invoices`.
|
- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe.list_invoices`.
|
||||||
- For each invoice with `status == "open"` and `amount_due > 0`:
|
- 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 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.
|
- 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:
|
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.
|
- 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 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).
|
- 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 {
|
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!(
|
tracing::error!(
|
||||||
source,
|
source,
|
||||||
tenant = %tenant.pubkey,
|
tenant = %tenant.pubkey,
|
||||||
@@ -95,41 +95,39 @@ impl Billing {
|
|||||||
| "complete_relay_sync"
|
| "complete_relay_sync"
|
||||||
);
|
);
|
||||||
|
|
||||||
if needs_billing_sync {
|
if needs_billing_sync
|
||||||
self.sync_tenant(&activity.tenant).await?;
|
&& let Some(tenant) = self.query.get_tenant(&activity.tenant).await?
|
||||||
|
{
|
||||||
|
self.reconcile_subscription(&tenant).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Subscriptions ---
|
||||||
|
|
||||||
/// Reconciles a tenant's single Stripe subscription with the set of relays that
|
/// Reconciles a tenant's single Stripe subscription with the set of relays that
|
||||||
/// should be billed.
|
/// should be billed.
|
||||||
///
|
///
|
||||||
/// Stripe forbids two subscription items on the same subscription from sharing a
|
/// 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
|
/// 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.
|
/// `quantity` equal to the number of the tenant's `active` relays on that plan.
|
||||||
async fn sync_tenant(&self, tenant_pubkey: &str) -> Result<()> {
|
async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
|
||||||
let Some(tenant) = self.query.get_tenant(tenant_pubkey).await? else {
|
let quantity_by_price_id = self.get_quantity_by_price_id(tenant).await?;
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
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 we've got no subscription items, we can cancel and clear the tenant's subscription
|
||||||
if quantity_by_price_id.is_empty() {
|
if quantity_by_price_id.is_empty() {
|
||||||
self.ensure_subscription_is_inactive(&tenant).await?;
|
self.ensure_subscription_is_inactive(tenant).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let subscription = self
|
let subscription = self
|
||||||
.ensure_subscription_is_active(&tenant, &quantity_by_price_id)
|
.ensure_subscription_is_active(tenant, &quantity_by_price_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
self.ensure_subscription_items(subscription, 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
|
/// 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>> {
|
async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>> {
|
||||||
let mut quantity_by_price_id = BTreeMap::new();
|
let mut quantity_by_price_id = BTreeMap::new();
|
||||||
@@ -225,7 +223,7 @@ impl Billing {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- lightning helpers ---
|
// --- Invoices ---
|
||||||
|
|
||||||
/// return or generate a lightning invoice for an open stripe invoice
|
/// return or generate a lightning invoice for an open stripe invoice
|
||||||
pub async fn ensure_lightning_invoice(
|
pub async fn ensure_lightning_invoice(
|
||||||
@@ -265,7 +263,7 @@ impl Billing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Attempt to pay and settle an invoice via nwc
|
/// 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 nwc_url = self.env.decrypt(&tenant.nwc_url)?;
|
||||||
let tenant_wallet = Wallet::from_url(&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
|
/// 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
|
/// invoice paid out of band, and clear any lingering NWC error. The Stripe
|
||||||
/// call is idempotent across retries, and `mark_lightning_invoice_paid` is
|
/// 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 axum::extract::{Path, State};
|
||||||
|
|
||||||
use crate::api::{Api, AuthedPubkey};
|
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(
|
pub async fn list_tenant_invoices(
|
||||||
State(api): State<Arc<Api>>,
|
State(api): State<Arc<Api>>,
|
||||||
@@ -26,32 +26,7 @@ pub async fn get_invoice(
|
|||||||
AuthedPubkey(auth): AuthedPubkey,
|
AuthedPubkey(auth): AuthedPubkey,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> ApiResult {
|
) -> 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"));
|
|
||||||
};
|
|
||||||
|
|
||||||
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 {
|
|
||||||
return Err(not_found("invoice not found"));
|
return Err(not_found("invoice not found"));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,6 +39,43 @@ pub async fn get_lightning_invoice(
|
|||||||
return Err(not_found("invoice not found"));
|
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
|
let lightning_invoice = api
|
||||||
.billing
|
.billing
|
||||||
.ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency)
|
.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
|
// 1. NWC auto-pay: if the tenant has a nwc_url
|
||||||
if !tenant.nwc_url.is_empty() {
|
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(()),
|
Ok(()) => return Ok(()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error_msg = format!("{e}");
|
let error_msg = format!("{e}");
|
||||||
|
|||||||
Reference in New Issue
Block a user