diff --git a/.fdignore b/.fdignore index 31148e7..fd0f04f 100644 --- a/.fdignore +++ b/.fdignore @@ -1,5 +1,3 @@ ref target -.agents -.playwright-cli node_modules diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql index e2a2f05..fa1ba3f 100644 --- a/backend/migrations/0001_init.sql +++ b/backend/migrations/0001_init.sql @@ -2,10 +2,12 @@ CREATE TABLE IF NOT EXISTS tenant ( pubkey TEXT PRIMARY KEY, nwc_url TEXT NOT NULL DEFAULT '', nwc_error TEXT, + stripe_error TEXT, created_at INTEGER NOT NULL, billing_anchor INTEGER, stripe_customer_id TEXT NOT NULL, - renewed_at INTEGER + renewed_at INTEGER, + churned_at INTEGER ); CREATE TABLE IF NOT EXISTS activity ( @@ -45,12 +47,12 @@ CREATE TABLE IF NOT EXISTS relay ( CREATE TABLE IF NOT EXISTS invoice ( id TEXT PRIMARY KEY, tenant_pubkey TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('open','paid','void','churn')), method TEXT CHECK (method IS NULL OR method IN ('nwc','stripe','oob')), period_start INTEGER NOT NULL, period_end INTEGER NOT NULL, created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, + paid_at INTEGER, + voided_at INTEGER, FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey) ); @@ -96,6 +98,9 @@ CREATE INDEX IF NOT EXISTS idx_relay_tenant_pubkey ON relay (tenant_pubkey); CREATE INDEX IF NOT EXISTS idx_invoice_tenant_created ON invoice (tenant_pubkey, created_at); +-- Dunning scans a tenant's still-open invoices oldest-first to retry payment. +CREATE INDEX IF NOT EXISTS idx_invoice_open ON invoice (tenant_pubkey, created_at) WHERE paid_at IS NULL AND voided_at IS NULL; + CREATE INDEX IF NOT EXISTS idx_invoice_item_invoice ON invoice_item (invoice_id); CREATE INDEX IF NOT EXISTS idx_invoice_item_outstanding ON invoice_item (tenant_pubkey) WHERE invoice_id IS NULL; diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 72b55c1..7f91cf1 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -4,12 +4,21 @@ use std::time::Duration; use crate::bitcoin; use crate::command; use crate::env; -use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, Snapshot, Tenant}; +use crate::models::{ + Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Snapshot, + Tenant, +}; use crate::query; use crate::robot::Robot; use crate::stripe::Stripe; use crate::wallet::Wallet; +const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60); +const GRACE_PERIOD_SECS: i64 = 7 * 24 * 60 * 60; +const MANUAL_PAYMENT_DM: &str = "Payment is due for your relay subscription. Open the link below to review the invoice and pay by Lightning or card:"; +const USER_ERROR_PREFIX: &str = "Auto-payment failed:"; +const USER_ERROR_MAX_CHARS: usize = 240; + /// Owns subscription billing: it reconciles tenant activity into invoice items, /// renews subscriptions each period, and collects payment (Lightning, then a /// card on file, then a manual DM link). @@ -63,16 +72,26 @@ impl Billing { // --- Reconciliation of activity/renewals --- - /// Lists billable activity, setting the tenant's billing anchor to the first - /// 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. + /// Reconciles a tenant's billing: re-activates them if a churned tenant has + /// re-engaged, folds billable activity into line items (setting the billing + /// anchor on the first), renews the current period if due, claims outstanding + /// items onto an invoice, and then collects every open invoice — churning the + /// tenant if one has gone unpaid past the grace period. pub async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> { let mut tenant = tenant.clone(); + let activities = query::list_billable_activity(&tenant.pubkey).await?; + + // A churned tenant with fresh billable activity is using the service + // again: re-activate billing (and restore their relays) before billing it. + if tenant.churned_at.is_some() && !activities.is_empty() { + self.reactivate_tenant(&tenant).await?; + tenant.churned_at = None; + } + // 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? { + // activity if not already set. + for activity in activities { if tenant.billing_anchor.is_none() { tenant.billing_anchor = Some(activity.created_at); command::set_tenant_billing_anchor(&tenant).await?; @@ -81,20 +100,19 @@ impl Billing { 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 the tenant has a billing anchor, renew the current period if due and + // claim any outstanding items onto an invoice. + if let Some(period) = BillingPeriod::current(&tenant) { + if tenant.renewed_at.is_none_or(|at| at < period.start) { + self.reconcile_renewal(&tenant, &period).await?; + } - // 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?; + command::create_invoice(&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?; - }; + // Retry payment on every open invoice (this also pays one just created), + // churning the tenant if the oldest has aged past the grace period. + self.collect_open_invoices(&tenant).await?; Ok(()) } @@ -127,9 +145,10 @@ impl Billing { } } - /// A prorated charge (or credit, with `sign` = -1) for the relay's current - /// plan, covering the fraction of the period remaining at the activity. - /// `None` for a missing relay or a free plan. + /// A prorated charge (or credit, with `sign` = -1) for the plan recorded on + /// the activity's snapshot — the plan as of the activity, not the relay's + /// current plan — covering the fraction of the period remaining at the + /// activity. `None` for a free plan. async fn make_prorated_item( &self, tenant: &Tenant, @@ -137,10 +156,8 @@ impl Billing { sign: i64, description: &str, ) -> Result> { - let Some(relay) = query::get_relay(&activity.resource_id).await? else { - return Err(anyhow!("activity resource was not a valid relay")); - }; - let plan = query::get_plan(&relay.plan_id)?; + let Snapshot::Relay { plan: plan_id, .. } = &*activity.snapshot; + let plan = query::get_plan(plan_id)?; if plan.amount <= 0 { return Ok(None); } @@ -216,7 +233,7 @@ impl Billing { /// a relay created/activated *within* the period isn't active before the /// boundary, so it's covered by its own prorated charge instead. async fn reconcile_renewal(&self, tenant: &Tenant, period: &BillingPeriod) -> Result<()> { - let relays = query::list_relays_for_tenant(&tenant.pubkey).await?; + let relays = query::list_relays(&tenant.pubkey).await?; let mut line_items = Vec::new(); for relay in relays { @@ -253,6 +270,10 @@ impl Billing { // --- Payments --- + /// Collect an invoice via NWC, then a saved card, then a manual DM. A failing + /// method's error is stored on the tenant (to warn them in the UI) but never + /// aborts the cascade or future retries; a method's error is cleared when it + /// next succeeds. pub async fn attempt_payment(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> { let mut error_message: Option = None; @@ -260,7 +281,11 @@ impl Billing { if !tenant.nwc_url.is_empty() { match self.attempt_payment_using_nwc(tenant, invoice).await { Ok(()) => return Ok(()), - Err(e) => error_message = Some(format!("{e}")), + Err(e) => { + let message = format!("{e}"); + command::set_tenant_nwc_error(&tenant.pubkey, &message).await?; + error_message = Some(message); + } } } @@ -273,7 +298,11 @@ impl Billing { .await { Ok(()) => return Ok(()), - Err(e) => error_message = Some(format!("{e}")), + Err(e) => { + let message = format!("{e}"); + command::set_tenant_stripe_error(&tenant.pubkey, &message).await?; + error_message = error_message.or_else(|| Some(message)); + } } } @@ -335,6 +364,7 @@ impl Billing { .await?; command::insert_intent(&intent_id, &invoice.id).await?; + command::clear_tenant_stripe_error(&tenant.pubkey).await?; command::mark_invoice_paid(&invoice.id, "stripe").await } @@ -358,6 +388,61 @@ impl Billing { self.robot.send_dm(&tenant.pubkey, &dm_message).await } + // --- Dunning --- + + /// Dunning pass over a tenant's open invoices: if the oldest has been unpaid + /// past the grace period, churn the tenant; otherwise retry payment on each. + async fn collect_open_invoices(&self, tenant: &Tenant) -> Result<()> { + let open = query::list_open_invoices(&tenant.pubkey).await?; + let Some(oldest) = open.first() else { + return Ok(()); + }; + + let now = chrono::Utc::now().timestamp(); + if now - oldest.created_at >= GRACE_PERIOD_SECS { + if tenant.churned_at.is_none() { + self.churn_tenant(tenant, now).await?; + } + return Ok(()); + } + + for invoice in &open { + self.attempt_payment(tenant, invoice).await?; + } + + Ok(()) + } + + /// Churn a tenant whose grace period has elapsed: record the churn, mark every + /// active relay delinquent, and void the unpaid invoices (we've given up + /// collecting them — re-activation never requires they be paid). + async fn churn_tenant(&self, tenant: &Tenant, now: i64) -> Result<()> { + command::set_tenant_churned_at(&tenant.pubkey, Some(now)).await?; + + for relay in query::list_relays(&tenant.pubkey).await? { + if relay.status == RELAY_STATUS_ACTIVE { + command::mark_relay_delinquent(&relay).await?; + } + } + + command::void_open_invoices(&tenant.pubkey).await + } + + /// Re-activate a churned tenant: clear the churn marker and restore every + /// delinquent relay to active. Any still-open invoices are voided so old debt + /// is never a precondition for coming back. + async fn reactivate_tenant(&self, tenant: &Tenant) -> Result<()> { + command::set_tenant_churned_at(&tenant.pubkey, None).await?; + + for relay in query::list_relays(&tenant.pubkey).await? { + if relay.status == RELAY_STATUS_DELINQUENT { + command::unmark_relay_delinquent(&relay).await?; + } + } + + command::void_open_invoices(&tenant.pubkey).await + } + // --- Invoice utils --- pub async fn get_invoice_amount(&self, invoice_id: &str) -> Result { @@ -407,11 +492,6 @@ impl Billing { } } -const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60); -const MANUAL_PAYMENT_DM: &str = "Payment is due for your relay subscription. Open the link below to review the invoice and pay by Lightning or card:"; -const USER_ERROR_PREFIX: &str = "NWC auto-payment failed:"; -const USER_ERROR_MAX_CHARS: usize = 240; - /// One tenant's monthly billing period containing some timestamp, anchored at /// the tenant's `billing_anchor`. Half-open `[start, end)` so a moment at /// exactly `end` belongs to the next period. diff --git a/backend/src/command.rs b/backend/src/command.rs index baa090c..db4e16d 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -43,6 +43,15 @@ pub async fn set_tenant_billing_anchor(tenant: &Tenant) -> Result<()> { Ok(()) } +pub async fn set_tenant_nwc_error(pubkey: &str, error: &str) -> Result<()> { + sqlx::query("UPDATE tenant SET nwc_error = ? WHERE pubkey = ?") + .bind(error) + .bind(pubkey) + .execute(pool()) + .await?; + Ok(()) +} + pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> { sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?") .bind(pubkey) @@ -51,6 +60,34 @@ pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> { Ok(()) } +pub async fn set_tenant_stripe_error(pubkey: &str, error: &str) -> Result<()> { + sqlx::query("UPDATE tenant SET stripe_error = ? WHERE pubkey = ?") + .bind(error) + .bind(pubkey) + .execute(pool()) + .await?; + Ok(()) +} + +pub async fn clear_tenant_stripe_error(pubkey: &str) -> Result<()> { + sqlx::query("UPDATE tenant SET stripe_error = NULL WHERE pubkey = ?") + .bind(pubkey) + .execute(pool()) + .await?; + Ok(()) +} + +/// Set or clear the tenant's churn marker. Set when an invoice ages past the +/// grace period, cleared when billing is re-activated. +pub async fn set_tenant_churned_at(pubkey: &str, churned_at: Option) -> Result<()> { + sqlx::query("UPDATE tenant SET churned_at = ? WHERE pubkey = ?") + .bind(churned_at) + .bind(pubkey) + .execute(pool()) + .await?; + Ok(()) +} + // --- Relays --- pub async fn create_relay(relay: &Relay) -> Result<()> { @@ -140,11 +177,17 @@ pub async fn deactivate_relay(relay: &Relay) -> Result<()> { set_relay_status(relay, RELAY_STATUS_INACTIVE, "deactivate_relay").await } -#[allow(dead_code)] // wired up by the delinquency flow (not yet implemented) pub async fn mark_relay_delinquent(relay: &Relay) -> Result<()> { set_relay_status(relay, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent").await } +/// Restore a delinquent relay to active when billing is re-activated. Unlike +/// `activate_relay` this records a non-billable activity, so resuming service +/// after churn doesn't levy a fresh prorated charge. +pub async fn unmark_relay_delinquent(relay: &Relay) -> Result<()> { + set_relay_status(relay, RELAY_STATUS_ACTIVE, "unmark_relay_delinquent").await +} + async fn set_relay_status(relay: &Relay, status: &str, activity_type: &str) -> Result<()> { let activity = with_tx(async |tx| { sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?") @@ -306,17 +349,33 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result Result<()> { - let updated_at = chrono::Utc::now().timestamp(); + let paid_at = chrono::Utc::now().timestamp(); - sqlx::query("UPDATE invoice SET status = 'paid', method = ?, updated_at = ? WHERE id = ?") + sqlx::query("UPDATE invoice SET method = ?, paid_at = ? WHERE id = ?") .bind(method) - .bind(updated_at) + .bind(paid_at) .bind(invoice_id) .execute(pool()) .await?; Ok(()) } +/// Void all of a tenant's open invoices, forgiving the balance — used when a +/// tenant churns or re-activates, so old debt never has to be collected. +pub async fn void_open_invoices(tenant_pubkey: &str) -> Result<()> { + let voided_at = chrono::Utc::now().timestamp(); + + sqlx::query( + "UPDATE invoice SET voided_at = ? + WHERE tenant_pubkey = ? AND paid_at IS NULL AND voided_at IS NULL", + ) + .bind(voided_at) + .bind(tenant_pubkey) + .execute(pool()) + .await?; + Ok(()) +} + // --- Bolt11 records --- pub async fn insert_bolt11( @@ -430,15 +489,14 @@ async fn insert_invoice_tx( let invoice_id = uuid::Uuid::new_v4().to_string(); Ok(sqlx::query_as::<_, Invoice>( - "INSERT INTO invoice (id, tenant_pubkey, status, period_start, period_end, created_at, updated_at) - VALUES (?, ?, 'open', ?, ?, ?, ?) RETURNING *", + "INSERT INTO invoice (id, tenant_pubkey, period_start, period_end, created_at) + VALUES (?, ?, ?, ?, ?) RETURNING *", ) .bind(invoice_id) .bind(&tenant.pubkey) .bind(&period.start) .bind(period.end) .bind(now) - .bind(now) .fetch_one(&mut **tx) .await?) } diff --git a/backend/src/models.rs b/backend/src/models.rs index 00cbd55..ce7d49d 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -37,13 +37,23 @@ pub struct Plan { pub struct Tenant { pub pubkey: String, pub nwc_url: String, + /// Last NWC auto-payment error, or `None` when the wallet last paid (or has + /// never been tried). Surfaced in the UI to warn the user; it never blocks a + /// retry — the next reconcile attempts payment again regardless. pub nwc_error: Option, + /// Last Stripe auto-payment error, with the same semantics as `nwc_error`. + pub stripe_error: Option, pub created_at: i64, pub billing_anchor: Option, pub stripe_customer_id: String, /// `period_start` of the most recent period this tenant was renewed for, or /// `None` if never renewed. The per-period renewal idempotency marker. pub renewed_at: Option, + /// When the tenant was churned because an invoice went unpaid past the grace + /// period; its relays are delinquent while this is set. Cleared when billing + /// is re-activated (the tenant has new billable activity), at which point the + /// then-open invoices are voided rather than collected. `None` in good standing. + pub churned_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] @@ -103,15 +113,26 @@ impl Default for Relay { } } +/// A tenant's bill for one period. Its lifecycle is recorded as timestamps +/// rather than a status column: open while both `paid_at` and `voided_at` are +/// null, paid once `paid_at` is set, and void once `voided_at` is set (e.g. a +/// balance forgiven when the tenant churns). #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Invoice { pub id: String, pub tenant_pubkey: String, - pub status: String, pub period_start: i64, pub period_end: i64, pub created_at: i64, - pub updated_at: i64, + pub paid_at: Option, + pub voided_at: Option, +} + +impl Invoice { + /// An invoice is open — still collectible — until it's either paid or voided. + pub fn is_open(&self) -> bool { + self.paid_at.is_none() && self.voided_at.is_none() + } } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] @@ -140,7 +161,8 @@ pub struct Bolt11 { pub settled_at: Option, } -#[allow(dead_code)] // mirrors the `intent` table; rows record paid Stripe PaymentIntents but aren't read back into this struct yet +/// A Stripe PaymentIntent that paid an invoice, mirrored for the audit trail and +/// read back to show the user how an invoice was settled. #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Intent { pub id: String, diff --git a/backend/src/query.rs b/backend/src/query.rs index fd961af..44fe0da 100644 --- a/backend/src/query.rs +++ b/backend/src/query.rs @@ -1,6 +1,6 @@ use anyhow::{Result, anyhow}; -use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Plan, Relay, Tenant}; +use crate::models::{Activity, Bolt11, Intent, Invoice, InvoiceItem, Plan, Relay, Tenant}; use crate::db::pool; fn select_tenant(tail: &str) -> String { @@ -84,7 +84,7 @@ pub async fn list_relays_pending_sync() -> Result> { ) } -pub async fn list_relays_for_tenant(tenant_pubkey: &str) -> Result> { +pub async fn list_relays(tenant_pubkey: &str) -> Result> { Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant_pubkey = ?")) .bind(tenant_pubkey) .fetch_all(pool()) @@ -125,7 +125,7 @@ pub async fn get_invoice(invoice_id: &str) -> Result> { .await?) } -pub async fn list_invoices_for_tenant(tenant_pubkey: &str) -> Result> { +pub async fn list_invoices(tenant_pubkey: &str) -> Result> { Ok(sqlx::query_as::<_, Invoice>( "SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC", ) @@ -134,7 +134,7 @@ pub async fn list_invoices_for_tenant(tenant_pubkey: &str) -> Result Result> { +pub async fn get_latest_invoice(tenant_pubkey: &str) -> Result> { Ok(sqlx::query_as::<_, Invoice>( "SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC LIMIT 1", ) @@ -143,6 +143,19 @@ pub async fn get_latest_invoice_for_tenant(tenant_pubkey: &str) -> Result