diff --git a/backend/src/billing.rs b/backend/src/billing.rs
index 66d55b9..1c1ebc0 100644
--- a/backend/src/billing.rs
+++ b/backend/src/billing.rs
@@ -4,7 +4,6 @@ use std::time::Duration;
use crate::bitcoin;
use crate::command;
-use crate::db;
use crate::env;
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Tenant};
use crate::query;
@@ -34,107 +33,25 @@ impl Billing {
// --- lifecycle methods ---
pub async fn start(self) {
- let mut rx = db::subscribe();
-
- tokio::spawn({
- let billing = self.clone();
- async move { billing.poll().await }
- });
-
- if let Err(error) = self.reconcile_subscriptions("startup").await {
- tracing::error!(error = %error, "failed to reconcile subscriptions on startup");
- }
-
- loop {
- match rx.recv().await {
- Ok(activity) => {
- if let Err(e) = self.handle_activity(&activity).await {
- tracing::error!(error = %e, "billing handle_activity failed");
- }
- }
- Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
- tracing::warn!(missed = n, "billing lagged, reconciling all subscriptions");
-
- if let Err(error) = self.reconcile_subscriptions("lagged").await {
- tracing::error!(error = %error, "failed to reconcile after lag");
- }
- }
- Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
- }
- }
- }
-
- async fn poll(&self) {
let mut interval = tokio::time::interval(POLL_INTERVAL);
loop {
interval.tick().await;
- if let Err(error) = self.autogenerate_invoices().await {
+ if let Err(error) = self.reconcile_subscriptions().await {
tracing::error!(error = %error, "billing poll failed");
}
}
}
- async fn autogenerate_invoices(&self) -> Result<()> {
+ async fn reconcile_subscriptions(&self) -> Result<()> {
let tenants = query::list_tenants().await?;
- tracing::info!(
- tenant_count = tenants.len(),
- "polling tenants for subscription renewal"
- );
-
- for tenant in tenants {
- if let Err(error) = self.autogenerate_invoice(&tenant).await {
- tracing::error!(
- tenant = %tenant.pubkey,
- error = ?error,
- "failed to autogenerate invoice"
- );
- }
- }
-
- Ok(())
- }
-
- /// Periodically generate the tenant's invoice for the current period
- /// (adding any due renewals) and, if one results, attempt payment.
- async fn autogenerate_invoice(&self, tenant: &Tenant) -> Result<()> {
- if let Some(invoice) = self.generate_invoice(tenant).await? {
- self.attempt_payment(tenant, &invoice).await?;
- }
-
- Ok(())
- }
-
- async fn handle_activity(&self, activity: &Activity) -> Result<()> {
- let should_reconcile = matches!(
- activity.activity_type.as_str(),
- "create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
- );
-
- if should_reconcile
- && let Some(tenant) = query::get_tenant(&activity.tenant).await?
- {
- self.reconcile_subscription(&tenant).await?;
- }
-
- Ok(())
- }
-
- async fn reconcile_subscriptions(&self, source: &str) -> Result<()> {
- let tenants = query::list_tenants().await?;
-
- tracing::info!(
- source,
- tenant_count = tenants.len(),
- "reconciling all subscriptions"
- );
+ tracing::info!(tenant_count = tenants.len(), "reconciling all subscriptions");
for tenant in tenants {
if let Err(error) = self.reconcile_subscription(&tenant).await {
tracing::error!(
- source,
tenant = %tenant.pubkey,
error = ?error,
"failed to reconcile subscription"
@@ -145,22 +62,41 @@ impl Billing {
Ok(())
}
- // --- Reconciliation, renewal, and on-demand billing ---
+ // --- Reconciliation of activity/renewals ---
/// Lists billable activity, setting the tenant's billing anchor to the first
- /// activity in the process.
- async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
+ /// activity in the process. Generates an invoice for the current period if due
+ /// for renewal or any billable activities have occurred. Attempts payment if
+ /// an invoice is generated.
+ pub async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
let mut tenant = tenant.clone();
+ // Reconcile all activity, setting the tenant's billing anchor on the first
+ // positive-balance line item if not already set.
for activity in query::list_billable_activity_for_tenant(&tenant.pubkey).await? {
if tenant.billing_anchor.is_none() {
tenant.billing_anchor = Some(activity.created_at);
command::set_tenant_billing_anchor(&tenant).await?;
}
- self.reconcile_activity(&tenant, &activity).await?;
+ let invoice_item = self.reconcile_activity(&tenant, &activity).await?;
}
+ // If the tenant has no billing anchor, they have nothing to bill
+ let Some(period) = BillingPeriod::current(&tenant) else {
+ return Ok(());
+ };
+
+ // If tenant is due for renewal, bill any active relays.
+ if tenant.renewed_at.is_none_or(|at| at < period.start) {
+ self.reconcile_renewal(&tenant, &period).await?;
+ }
+
+ // Create the invoice, but only if non-zero and attempt payment
+ if let Some(invoice) = command::create_invoice(&tenant, &period).await? {
+ self.attempt_payment(&tenant, &invoice).await?;
+ };
+
Ok(())
}
@@ -168,7 +104,7 @@ impl Billing {
/// persist it with the activity's billed marker. Activities that produce no
/// item (e.g. free-plan changes) are still marked billed so they aren't
/// re-scanned.
- async fn reconcile_activity(&self, tenant: &Tenant, activity: &Activity) -> Result<()> {
+ async fn reconcile_activity(&self, tenant: &Tenant, activity: &Activity) -> Result