Add dunning

This commit is contained in:
Jon Staab
2026-05-29 11:32:06 -07:00
parent f7bd3e53fe
commit d5047dedb1
13 changed files with 331 additions and 74 deletions
-2
View File
@@ -1,5 +1,3 @@
ref ref
target target
.agents
.playwright-cli
node_modules node_modules
+8 -3
View File
@@ -2,10 +2,12 @@ CREATE TABLE IF NOT EXISTS tenant (
pubkey TEXT PRIMARY KEY, pubkey TEXT PRIMARY KEY,
nwc_url TEXT NOT NULL DEFAULT '', nwc_url TEXT NOT NULL DEFAULT '',
nwc_error TEXT, nwc_error TEXT,
stripe_error TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
billing_anchor INTEGER, billing_anchor INTEGER,
stripe_customer_id TEXT NOT NULL, stripe_customer_id TEXT NOT NULL,
renewed_at INTEGER renewed_at INTEGER,
churned_at INTEGER
); );
CREATE TABLE IF NOT EXISTS activity ( CREATE TABLE IF NOT EXISTS activity (
@@ -45,12 +47,12 @@ CREATE TABLE IF NOT EXISTS relay (
CREATE TABLE IF NOT EXISTS invoice ( CREATE TABLE IF NOT EXISTS invoice (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
tenant_pubkey TEXT NOT NULL, 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')), method TEXT CHECK (method IS NULL OR method IN ('nwc','stripe','oob')),
period_start INTEGER NOT NULL, period_start INTEGER NOT NULL,
period_end INTEGER NOT NULL, period_end INTEGER NOT NULL,
created_at 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) 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); 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_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; CREATE INDEX IF NOT EXISTS idx_invoice_item_outstanding ON invoice_item (tenant_pubkey) WHERE invoice_id IS NULL;
+113 -33
View File
@@ -4,12 +4,21 @@ use std::time::Duration;
use crate::bitcoin; use crate::bitcoin;
use crate::command; use crate::command;
use crate::env; 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::query;
use crate::robot::Robot; use crate::robot::Robot;
use crate::stripe::Stripe; use crate::stripe::Stripe;
use crate::wallet::Wallet; 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, /// Owns subscription billing: it reconciles tenant activity into invoice items,
/// renews subscriptions each period, and collects payment (Lightning, then a /// renews subscriptions each period, and collects payment (Lightning, then a
/// card on file, then a manual DM link). /// card on file, then a manual DM link).
@@ -63,16 +72,26 @@ impl Billing {
// --- Reconciliation of activity/renewals --- // --- Reconciliation of activity/renewals ---
/// Lists billable activity, setting the tenant's billing anchor to the first /// Reconciles a tenant's billing: re-activates them if a churned tenant has
/// activity in the process. Generates an invoice for the current period if due /// re-engaged, folds billable activity into line items (setting the billing
/// for renewal or any billable activities have occurred. Attempts payment if /// anchor on the first), renews the current period if due, claims outstanding
/// an invoice is generated. /// 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<()> { pub async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
let mut tenant = tenant.clone(); 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 // Reconcile all activity, setting the tenant's billing anchor on the first
// positive-balance line item if not already set. // activity if not already set.
for activity in query::list_billable_activity_for_tenant(&tenant.pubkey).await? { for activity in activities {
if tenant.billing_anchor.is_none() { if tenant.billing_anchor.is_none() {
tenant.billing_anchor = Some(activity.created_at); tenant.billing_anchor = Some(activity.created_at);
command::set_tenant_billing_anchor(&tenant).await?; command::set_tenant_billing_anchor(&tenant).await?;
@@ -81,20 +100,19 @@ impl Billing {
self.reconcile_activity(&tenant, &activity).await?; self.reconcile_activity(&tenant, &activity).await?;
} }
// If the tenant has no billing anchor, they have nothing to bill // If the tenant has a billing anchor, renew the current period if due and
let Some(period) = BillingPeriod::current(&tenant) else { // claim any outstanding items onto an invoice.
return Ok(()); 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. command::create_invoice(&tenant, &period).await?;
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 // Retry payment on every open invoice (this also pays one just created),
if let Some(invoice) = command::create_invoice(&tenant, &period).await? { // churning the tenant if the oldest has aged past the grace period.
self.attempt_payment(&tenant, &invoice).await?; self.collect_open_invoices(&tenant).await?;
};
Ok(()) Ok(())
} }
@@ -127,9 +145,10 @@ impl Billing {
} }
} }
/// A prorated charge (or credit, with `sign` = -1) for the relay's current /// A prorated charge (or credit, with `sign` = -1) for the plan recorded on
/// plan, covering the fraction of the period remaining at the activity. /// the activity's snapshot — the plan as of the activity, not the relay's
/// `None` for a missing relay or a free plan. /// current plan — covering the fraction of the period remaining at the
/// activity. `None` for a free plan.
async fn make_prorated_item( async fn make_prorated_item(
&self, &self,
tenant: &Tenant, tenant: &Tenant,
@@ -137,10 +156,8 @@ impl Billing {
sign: i64, sign: i64,
description: &str, description: &str,
) -> Result<Option<InvoiceItem>> { ) -> Result<Option<InvoiceItem>> {
let Some(relay) = query::get_relay(&activity.resource_id).await? else { let Snapshot::Relay { plan: plan_id, .. } = &*activity.snapshot;
return Err(anyhow!("activity resource was not a valid relay")); let plan = query::get_plan(plan_id)?;
};
let plan = query::get_plan(&relay.plan_id)?;
if plan.amount <= 0 { if plan.amount <= 0 {
return Ok(None); return Ok(None);
} }
@@ -216,7 +233,7 @@ impl Billing {
/// a relay created/activated *within* the period isn't active before the /// a relay created/activated *within* the period isn't active before the
/// boundary, so it's covered by its own prorated charge instead. /// boundary, so it's covered by its own prorated charge instead.
async fn reconcile_renewal(&self, tenant: &Tenant, period: &BillingPeriod) -> Result<()> { 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(); let mut line_items = Vec::new();
for relay in relays { for relay in relays {
@@ -253,6 +270,10 @@ impl Billing {
// --- Payments --- // --- 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<()> { pub async fn attempt_payment(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
let mut error_message: Option<String> = None; let mut error_message: Option<String> = None;
@@ -260,7 +281,11 @@ impl Billing {
if !tenant.nwc_url.is_empty() { if !tenant.nwc_url.is_empty() {
match self.attempt_payment_using_nwc(tenant, invoice).await { match self.attempt_payment_using_nwc(tenant, invoice).await {
Ok(()) => return Ok(()), 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 .await
{ {
Ok(()) => return Ok(()), 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?; .await?;
command::insert_intent(&intent_id, &invoice.id).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 command::mark_invoice_paid(&invoice.id, "stripe").await
} }
@@ -358,6 +388,61 @@ impl Billing {
self.robot.send_dm(&tenant.pubkey, &dm_message).await 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 --- // --- Invoice utils ---
pub async fn get_invoice_amount(&self, invoice_id: &str) -> Result<i64> { pub async fn get_invoice_amount(&self, invoice_id: &str) -> Result<i64> {
@@ -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 /// One tenant's monthly billing period containing some timestamp, anchored at
/// the tenant's `billing_anchor`. Half-open `[start, end)` so a moment at /// the tenant's `billing_anchor`. Half-open `[start, end)` so a moment at
/// exactly `end` belongs to the next period. /// exactly `end` belongs to the next period.
+65 -7
View File
@@ -43,6 +43,15 @@ pub async fn set_tenant_billing_anchor(tenant: &Tenant) -> Result<()> {
Ok(()) 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<()> { pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?") sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
.bind(pubkey) .bind(pubkey)
@@ -51,6 +60,34 @@ pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> {
Ok(()) 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<i64>) -> Result<()> {
sqlx::query("UPDATE tenant SET churned_at = ? WHERE pubkey = ?")
.bind(churned_at)
.bind(pubkey)
.execute(pool())
.await?;
Ok(())
}
// --- Relays --- // --- Relays ---
pub async fn create_relay(relay: &Relay) -> Result<()> { 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 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<()> { pub async fn mark_relay_delinquent(relay: &Relay) -> Result<()> {
set_relay_status(relay, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent").await 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<()> { async fn set_relay_status(relay: &Relay, status: &str, activity_type: &str) -> Result<()> {
let activity = with_tx(async |tx| { let activity = with_tx(async |tx| {
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?") sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
@@ -306,17 +349,33 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<O
} }
pub async fn mark_invoice_paid(invoice_id: &str, method: &str) -> Result<()> { pub async fn mark_invoice_paid(invoice_id: &str, method: &str) -> 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(method)
.bind(updated_at) .bind(paid_at)
.bind(invoice_id) .bind(invoice_id)
.execute(pool()) .execute(pool())
.await?; .await?;
Ok(()) 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 --- // --- Bolt11 records ---
pub async fn insert_bolt11( pub async fn insert_bolt11(
@@ -430,15 +489,14 @@ async fn insert_invoice_tx(
let invoice_id = uuid::Uuid::new_v4().to_string(); let invoice_id = uuid::Uuid::new_v4().to_string();
Ok(sqlx::query_as::<_, Invoice>( Ok(sqlx::query_as::<_, Invoice>(
"INSERT INTO invoice (id, tenant_pubkey, status, period_start, period_end, created_at, updated_at) "INSERT INTO invoice (id, tenant_pubkey, period_start, period_end, created_at)
VALUES (?, ?, 'open', ?, ?, ?, ?) RETURNING *", VALUES (?, ?, ?, ?, ?) RETURNING *",
) )
.bind(invoice_id) .bind(invoice_id)
.bind(&tenant.pubkey) .bind(&tenant.pubkey)
.bind(&period.start) .bind(&period.start)
.bind(period.end) .bind(period.end)
.bind(now) .bind(now)
.bind(now)
.fetch_one(&mut **tx) .fetch_one(&mut **tx)
.await?) .await?)
} }
+25 -3
View File
@@ -37,13 +37,23 @@ pub struct Plan {
pub struct Tenant { pub struct Tenant {
pub pubkey: String, pub pubkey: String,
pub nwc_url: 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<String>, pub nwc_error: Option<String>,
/// Last Stripe auto-payment error, with the same semantics as `nwc_error`.
pub stripe_error: Option<String>,
pub created_at: i64, pub created_at: i64,
pub billing_anchor: Option<i64>, pub billing_anchor: Option<i64>,
pub stripe_customer_id: String, pub stripe_customer_id: String,
/// `period_start` of the most recent period this tenant was renewed for, or /// `period_start` of the most recent period this tenant was renewed for, or
/// `None` if never renewed. The per-period renewal idempotency marker. /// `None` if never renewed. The per-period renewal idempotency marker.
pub renewed_at: Option<i64>, pub renewed_at: Option<i64>,
/// 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<i64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[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)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Invoice { pub struct Invoice {
pub id: String, pub id: String,
pub tenant_pubkey: String, pub tenant_pubkey: String,
pub status: String,
pub period_start: i64, pub period_start: i64,
pub period_end: i64, pub period_end: i64,
pub created_at: i64, pub created_at: i64,
pub updated_at: i64, pub paid_at: Option<i64>,
pub voided_at: Option<i64>,
}
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)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
@@ -140,7 +161,8 @@ pub struct Bolt11 {
pub settled_at: Option<i64>, pub settled_at: Option<i64>,
} }
#[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)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Intent { pub struct Intent {
pub id: String, pub id: String,
+32 -5
View File
@@ -1,6 +1,6 @@
use anyhow::{Result, anyhow}; 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; use crate::db::pool;
fn select_tenant(tail: &str) -> String { fn select_tenant(tail: &str) -> String {
@@ -84,7 +84,7 @@ pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
) )
} }
pub async fn list_relays_for_tenant(tenant_pubkey: &str) -> Result<Vec<Relay>> { pub async fn list_relays(tenant_pubkey: &str) -> Result<Vec<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant_pubkey = ?")) Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant_pubkey = ?"))
.bind(tenant_pubkey) .bind(tenant_pubkey)
.fetch_all(pool()) .fetch_all(pool())
@@ -125,7 +125,7 @@ pub async fn get_invoice(invoice_id: &str) -> Result<Option<Invoice>> {
.await?) .await?)
} }
pub async fn list_invoices_for_tenant(tenant_pubkey: &str) -> Result<Vec<Invoice>> { pub async fn list_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
Ok(sqlx::query_as::<_, Invoice>( Ok(sqlx::query_as::<_, Invoice>(
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC", "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<Vec<Invoice
.await?) .await?)
} }
pub async fn get_latest_invoice_for_tenant(tenant_pubkey: &str) -> Result<Option<Invoice>> { pub async fn get_latest_invoice(tenant_pubkey: &str) -> Result<Option<Invoice>> {
Ok(sqlx::query_as::<_, Invoice>( Ok(sqlx::query_as::<_, Invoice>(
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC LIMIT 1", "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<Option
.await?) .await?)
} }
/// A tenant's open invoices — neither paid nor voided — oldest first. Dunning
/// retries each and treats the oldest one's `created_at` as the grace-period start.
pub async fn list_open_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
Ok(sqlx::query_as::<_, Invoice>(
"SELECT * FROM invoice
WHERE tenant_pubkey = ? AND paid_at IS NULL AND voided_at IS NULL
ORDER BY created_at ASC",
)
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
}
pub async fn get_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<InvoiceItem>> { pub async fn get_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<InvoiceItem>> {
Ok( Ok(
sqlx::query_as::<_, InvoiceItem>("SELECT * FROM invoice_item WHERE invoice_id = ?") sqlx::query_as::<_, InvoiceItem>("SELECT * FROM invoice_item WHERE invoice_id = ?")
@@ -152,6 +165,20 @@ pub async fn get_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<Invoi
) )
} }
// --- Intents ---
/// The Stripe PaymentIntents recorded against an invoice, newest first.
pub async fn list_intents_for_invoice(invoice_id: &str) -> Result<Vec<Intent>> {
Ok(
sqlx::query_as::<_, Intent>(
"SELECT * FROM intent WHERE invoice_id = ? ORDER BY created_at DESC",
)
.bind(invoice_id)
.fetch_all(pool())
.await?,
)
}
// --- Bolt11 --- // --- Bolt11 ---
pub async fn get_bolt11(bolt11_id: &str) -> Result<Option<Bolt11>> { pub async fn get_bolt11(bolt11_id: &str) -> Result<Option<Bolt11>> {
@@ -176,7 +203,7 @@ pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result<Option<Bolt11>>
/// activity-type filter and the `billed_at IS NULL` guard live here so the /// activity-type filter and the `billed_at IS NULL` guard live here so the
/// caller reconciles off a precise marker rather than a timestamp watermark. /// caller reconciles off a precise marker rather than a timestamp watermark.
/// Ordered oldest-first so line items and proration apply in event order. /// Ordered oldest-first so line items and proration apply in event order.
pub async fn list_billable_activity_for_tenant(tenant_pubkey: &str) -> Result<Vec<Activity>> { pub async fn list_billable_activity(tenant_pubkey: &str) -> Result<Vec<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity( Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE tenant_pubkey = ? "WHERE tenant_pubkey = ?
AND billed_at IS NULL AND billed_at IS NULL
+48 -3
View File
@@ -1,11 +1,53 @@
use std::sync::Arc; use std::sync::Arc;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use serde::Serialize;
use crate::api::{Api, AuthedPubkey}; use crate::api::{Api, AuthedPubkey};
use crate::models::{Intent, Invoice};
use crate::query; use crate::query;
use crate::web::{ApiResult, internal, not_found, ok}; use crate::web::{ApiResult, internal, not_found, ok};
/// An invoice for the client, with its lifecycle flattened to a derived `status`
/// ("open" | "paid" | "void") alongside the underlying timestamps, plus the
/// Stripe PaymentIntents that settled it (empty unless requested).
#[derive(Serialize)]
pub struct InvoiceResponse {
pub id: String,
pub tenant_pubkey: String,
pub status: String,
pub period_start: i64,
pub period_end: i64,
pub created_at: i64,
pub paid_at: Option<i64>,
pub voided_at: Option<i64>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub intents: Vec<Intent>,
}
impl From<Invoice> for InvoiceResponse {
fn from(i: Invoice) -> Self {
let status = if i.is_open() {
"open"
} else if i.paid_at.is_some() {
"paid"
} else {
"void"
};
InvoiceResponse {
status: status.to_string(),
id: i.id,
tenant_pubkey: i.tenant_pubkey,
period_start: i.period_start,
period_end: i.period_end,
created_at: i.created_at,
paid_at: i.paid_at,
voided_at: i.voided_at,
intents: Vec::new(),
}
}
}
/// The tenant's most recent invoice, after first materializing any outstanding /// The tenant's most recent invoice, after first materializing any outstanding
/// line items into a fresh one — so the frontend can collect payment right after /// line items into a fresh one — so the frontend can collect payment right after
/// a change (e.g. creating a relay). Payment isn't attempted here; the caller /// a change (e.g. creating a relay). Payment isn't attempted here; the caller
@@ -21,9 +63,9 @@ pub async fn get_tenant_latest_invoice(
api.billing.reconcile_subscription(&tenant).await.map_err(internal)?; api.billing.reconcile_subscription(&tenant).await.map_err(internal)?;
let invoice = query::get_latest_invoice_for_tenant(&pubkey).await.map_err(internal)?; let invoice = query::get_latest_invoice(&pubkey).await.map_err(internal)?;
ok(invoice) ok(invoice.map(InvoiceResponse::from))
} }
pub async fn get_invoice( pub async fn get_invoice(
@@ -38,7 +80,10 @@ pub async fn get_invoice(
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?; api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
ok(invoice) let mut response = InvoiceResponse::from(invoice);
response.intents = query::list_intents_for_invoice(&id).await.map_err(internal)?;
ok(response)
} }
/// Return a payable Lightning invoice (bolt11) for an invoice, minting one if /// Return a payable Lightning invoice (bolt11) for an invoice, minting one if
+13 -3
View File
@@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::api::{Api, AuthedPubkey}; use crate::api::{Api, AuthedPubkey};
use crate::models::Tenant; use crate::models::Tenant;
use crate::routes::invoices::InvoiceResponse;
use crate::web::{ApiResult, internal, map_unique_error, ok}; use crate::web::{ApiResult, internal, map_unique_error, ok};
use crate::{command, env, query}; use crate::{command, env, query};
@@ -17,9 +18,13 @@ pub struct TenantResponse {
pub pubkey: String, pub pubkey: String,
pub nwc_is_set: bool, pub nwc_is_set: bool,
pub nwc_error: Option<String>, pub nwc_error: Option<String>,
pub stripe_error: Option<String>,
pub created_at: i64, pub created_at: i64,
pub billing_anchor: Option<i64>, pub billing_anchor: Option<i64>,
pub stripe_customer_id: String, pub stripe_customer_id: String,
/// Set when billing has churned the tenant; the UI uses it to warn that the
/// account is delinquent until billing is re-activated.
pub churned_at: Option<i64>,
} }
impl From<Tenant> for TenantResponse { impl From<Tenant> for TenantResponse {
@@ -28,9 +33,11 @@ impl From<Tenant> for TenantResponse {
nwc_is_set: !t.nwc_url.is_empty(), nwc_is_set: !t.nwc_url.is_empty(),
pubkey: t.pubkey, pubkey: t.pubkey,
nwc_error: t.nwc_error, nwc_error: t.nwc_error,
stripe_error: t.stripe_error,
created_at: t.created_at, created_at: t.created_at,
billing_anchor: t.billing_anchor, billing_anchor: t.billing_anchor,
stripe_customer_id: t.stripe_customer_id, stripe_customer_id: t.stripe_customer_id,
churned_at: t.churned_at,
} }
} }
} }
@@ -135,7 +142,7 @@ pub async fn list_tenant_relays(
) -> ApiResult { ) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?; api.require_admin_or_tenant(&auth, &pubkey)?;
let relays = query::list_relays_for_tenant(&pubkey) let relays = query::list_relays(&pubkey)
.await .await
.map_err(internal)?; .map_err(internal)?;
ok(relays) ok(relays)
@@ -149,11 +156,14 @@ pub async fn list_tenant_invoices(
) -> ApiResult { ) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?; api.require_admin_or_tenant(&auth, &pubkey)?;
let invoices = query::list_invoices_for_tenant(&pubkey) let invoices = query::list_invoices(&pubkey)
.await .await
.map_err(internal)?; .map_err(internal)?;
ok(invoices) ok(invoices
.into_iter()
.map(InvoiceResponse::from)
.collect::<Vec<_>>())
} }
#[derive(Deserialize)] #[derive(Deserialize)]
+3 -3
View File
@@ -45,7 +45,7 @@ export default function AppShell(props: { children?: any }) {
createEffect(async () => { createEffect(async () => {
const t = tenant() const t = tenant()
if (!t?.past_due_at) { if (!t?.churned_at) {
setPastDueInvoice(undefined) setPastDueInvoice(undefined)
return return
} }
@@ -158,9 +158,9 @@ export default function AppShell(props: { children?: any }) {
</aside> </aside>
<div class="md:pl-[260px] min-h-screen pb-20 md:pb-0"> <div class="md:pl-[260px] min-h-screen pb-20 md:pb-0">
<Show when={tenant()?.past_due_at}> <Show when={tenant()?.churned_at}>
<div class="bg-red-600 text-white px-4 py-3 text-sm flex items-center justify-between"> <div class="bg-red-600 text-white px-4 py-3 text-sm flex items-center justify-between">
<span>Your account has an overdue balance.</span> <span>Your account is past due and some relays are paused. Update your payment method to restore service.</span>
<Show when={pastDueInvoice()}> <Show when={pastDueInvoice()}>
<button <button
type="button" type="button"
+2 -4
View File
@@ -98,9 +98,9 @@ export type Tenant = {
nwc_is_set: boolean nwc_is_set: boolean
created_at: number created_at: number
stripe_customer_id: string stripe_customer_id: string
stripe_subscription_id: string | null
past_due_at: number | null
nwc_error: string | null nwc_error: string | null
stripe_error: string | null
churned_at: number | null
} }
export type Invoice = { export type Invoice = {
@@ -142,8 +142,6 @@ export async function makeAuth(): Promise<string | undefined> {
kind: 27235, kind: 27235,
content: "", content: "",
created_at: Math.floor(now / 1000), created_at: Math.floor(now / 1000),
// Intentional session-style auth: sign the API base URL once, then reuse
// the header briefly to avoid prompting the signer on every request.
tags: [["u", API_URL]], tags: [["u", API_URL]],
}) })
+1 -1
View File
@@ -135,7 +135,7 @@ export const reactivateRelayById = (id: string) => reactivateRelay(id)
export async function tenantNeedsPaymentSetup(): Promise<boolean> { export async function tenantNeedsPaymentSetup(): Promise<boolean> {
const tenant = await getTenant(account()!.pubkey) const tenant = await getTenant(account()!.pubkey)
return !tenant.nwc_is_set && !tenant.stripe_subscription_id return !tenant.nwc_is_set
} }
export async function getLatestOpenInvoice(): Promise<Invoice | null> { export async function getLatestOpenInvoice(): Promise<Invoice | null> {
+9 -1
View File
@@ -142,8 +142,16 @@ export default function Account() {
{saving() ? "Saving..." : "Save"} {saving() ? "Saving..." : "Save"}
</button> </button>
</div> </div>
<Show when={tenant()?.churned_at}>
<p class="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
Your account is past due and some relays have been paused. Update your payment method below to restore service.
</p>
</Show>
<Show when={tenant()?.nwc_error}> <Show when={tenant()?.nwc_error}>
<p class="mt-3 text-sm text-red-600">{tenant()!.nwc_error}</p> <p class="mt-3 text-sm text-red-600">Lightning auto-payment failed: {tenant()!.nwc_error}</p>
</Show>
<Show when={tenant()?.stripe_error}>
<p class="mt-3 text-sm text-red-600">Card auto-payment failed: {tenant()!.stripe_error}</p>
</Show> </Show>
<Show when={error()}> <Show when={error()}>
<p class="mt-3 text-sm text-red-600">{error()}</p> <p class="mt-3 text-sm text-red-600">{error()}</p>
+12 -6
View File
@@ -14,8 +14,8 @@ export default function AdminTenantDetail() {
const [relays] = useAdminTenantRelays(tenantId) const [relays] = useAdminTenantRelays(tenantId)
const loading = useMinLoading(() => tenant.loading || relays.loading) const loading = useMinLoading(() => tenant.loading || relays.loading)
const pastDueLabel = () => { const churnedLabel = () => {
const ts = tenant()?.past_due_at const ts = tenant()?.churned_at
if (!ts) return null if (!ts) return null
return new Date(ts * 1000).toLocaleString() return new Date(ts * 1000).toLocaleString()
} }
@@ -34,7 +34,7 @@ export default function AdminTenantDetail() {
<dl class="grid gap-y-3 text-sm"> <dl class="grid gap-y-3 text-sm">
<div class="flex gap-2"> <div class="flex gap-2">
<dt class="text-gray-500">Status:</dt> <dt class="text-gray-500">Status:</dt>
<dd class="font-medium uppercase tracking-wide">{t().past_due_at ? "past due" : "active"}</dd> <dd class="font-medium uppercase tracking-wide">{t().churned_at ? "delinquent" : "active"}</dd>
</div> </div>
<Show when={t().stripe_customer_id}> <Show when={t().stripe_customer_id}>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -42,10 +42,10 @@ export default function AdminTenantDetail() {
<dd class="font-mono text-xs">{t().stripe_customer_id}</dd> <dd class="font-mono text-xs">{t().stripe_customer_id}</dd>
</div> </div>
</Show> </Show>
<Show when={pastDueLabel()}> <Show when={churnedLabel()}>
<div class="flex gap-2"> <div class="flex gap-2">
<dt class="text-gray-500">Past Due Since:</dt> <dt class="text-gray-500">Delinquent Since:</dt>
<dd class="text-red-600 font-medium">{pastDueLabel()}</dd> <dd class="text-red-600 font-medium">{churnedLabel()}</dd>
</div> </div>
</Show> </Show>
<Show when={t().nwc_error}> <Show when={t().nwc_error}>
@@ -54,6 +54,12 @@ export default function AdminTenantDetail() {
<dd class="text-red-600">{t().nwc_error}</dd> <dd class="text-red-600">{t().nwc_error}</dd>
</div> </div>
</Show> </Show>
<Show when={t().stripe_error}>
<div class="flex gap-2">
<dt class="text-gray-500">Stripe Error:</dt>
<dd class="text-red-600">{t().stripe_error}</dd>
</div>
</Show>
</dl> </dl>
)} )}
</Show> </Show>