From 0f47b483aa555e000bcead9f07aa963a2dd7093c Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Wed, 27 May 2026 16:56:34 -0700 Subject: [PATCH] Update docs --- backend/src/billing.rs | 33 ++++++++++++++++++--------------- backend/src/bitcoin.rs | 3 +++ backend/src/command.rs | 20 +++++++++----------- backend/src/infra.rs | 12 ++++++++++++ backend/src/models.rs | 2 +- backend/src/robot.rs | 6 ++++++ backend/src/routes/invoices.rs | 2 ++ backend/src/routes/relays.rs | 4 ++++ backend/src/routes/tenants.rs | 7 ++++++- backend/src/stripe.rs | 12 ++++++++++-- backend/src/wallet.rs | 3 +++ 11 files changed, 74 insertions(+), 30 deletions(-) diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 487770a..a9fd127 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -12,6 +12,9 @@ use crate::robot::Robot; use crate::stripe::Stripe; use crate::wallet::Wallet; +/// 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). #[derive(Clone)] pub struct Billing { stripe: Stripe, @@ -188,8 +191,8 @@ impl Billing { } /// A prorated charge (or credit, with `sign` = -1) for the relay's current - /// plan. `None` for a missing relay or a free plan. Mid-period items don't - /// stamp `period_start` — the renewal decides coverage from activity history. + /// plan, covering the fraction of the period remaining at the activity. + /// `None` for a missing relay or a free plan. async fn make_prorated_item( &self, tenant: &Tenant, @@ -262,11 +265,12 @@ impl Billing { ))) } - /// Reconcile pending activity, add this period's renewals for any relay due, - /// and claim everything outstanding onto an invoice. Shared by the poll and - /// the on-demand invoice endpoint — safe to call either way: renewals are - /// per-relay idempotent. No payment is attempted here; callers that want - /// auto-pay do it on the returned invoice. `None` when nothing is owed. + /// Reconcile pending activity, add this period's renewals if they're due, and + /// claim everything outstanding onto an invoice. Shared by the poll and the + /// on-demand invoice endpoint — safe to call either way: renewal is idempotent + /// per period (see [`command::renew_tenant`]). No payment is attempted here; + /// callers that want auto-pay do it on the returned invoice. `None` when + /// nothing is owed. pub async fn generate_invoice(&self, tenant: &Tenant) -> Result> { self.reconcile_subscription(tenant).await?; @@ -294,10 +298,11 @@ impl Billing { /// Charge a full-period renewal for every relay that was active on a paid plan /// as of `period_start`, reconstructing that state from the activity log - /// (status from create/activate/deactivate, plan from create/update). Per-relay - /// idempotent via `period_start`, so calling it on every generation can't - /// renew a relay twice; a relay created/activated *within* the period isn't - /// active before the boundary, so it's covered by its own prorated charge. + /// (status from create/activate/deactivate, plan from create/update). + /// Idempotent per period via the tenant's `renewed_at` marker, so calling it + /// on every generation can't renew twice; 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 renew_period(&self, tenant: &Tenant, period_start: i64) -> Result<()> { let activities = query::list_relay_activity_before(&tenant.pubkey, period_start).await?; @@ -541,8 +546,7 @@ fn add_one_month(ts: i64) -> i64 { } /// Fraction of the current billing period still unused at `at`, in `[0.0, 1.0]`, -/// for prorating a mid-period charge or credit. With no billing anchor yet the -/// period is only just beginning, so the whole period remains (full price). +/// for prorating a mid-period charge or credit. fn period_fraction_remaining(billing_anchor: i64, at: i64) -> f64 { let period_start = period_start_at(billing_anchor, at); let period_end = add_one_month(period_start); @@ -560,8 +564,7 @@ fn prorate(amount: i64, fraction: f64) -> i64 { } /// Build an outstanding (unassigned, `invoice_id = None`) line item from a -/// reconciled activity. `period_start` is `Some` only for coverage charges -/// (creation/activation), which mark the relay-period as paid. +/// reconciled activity. fn line_item( activity: &Activity, relay_id: &str, diff --git a/backend/src/bitcoin.rs b/backend/src/bitcoin.rs index b768dfe..58025ac 100644 --- a/backend/src/bitcoin.rs +++ b/backend/src/bitcoin.rs @@ -1,5 +1,7 @@ use anyhow::{Result, anyhow}; +/// Convert a fiat amount in minor units (e.g. USD cents) to millisatoshis at the +/// current spot price, for pricing a Lightning invoice from an invoice total. pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result { let price = get_bitcoin_price(¤cy.to_uppercase()).await?; let divisor = 10_f64.powi(currency_minor_exponent(currency)? as i32); @@ -18,6 +20,7 @@ struct CoinbaseSpotPriceData { amount: String, } +/// The current Bitcoin spot price in `currency`, from Coinbase. pub async fn get_bitcoin_price(currency: &str) -> Result { let http = reqwest::Client::new(); let url = format!("https://api.coinbase.com/v2/prices/BTC-{currency}/spot"); diff --git a/backend/src/command.rs b/backend/src/command.rs index 4d4596a..44e1ffb 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -188,6 +188,8 @@ pub async fn complete_relay_sync(relay_id: &str) -> Result<()> { // --- Invoice items (the outstanding-charge ledger) --- +/// Persist a reconciled activity's line item and mark the activity billed in one +/// transaction, so a recovery pass never re-bills it. pub async fn insert_invoice_item_for_activity(invoice_item: &InvoiceItem, activity_id: &str) -> Result<()> { let now = chrono::Utc::now().timestamp(); @@ -208,21 +210,18 @@ pub async fn mark_activity_billed(activity_id: &str) -> Result<()> { with_tx(async |tx| mark_activity_billed_tx(tx, activity_id, now).await).await } -/// Insert renewal line items, skipping any relay already covered for the item's -/// `period_start`. The per-relay existence check and insert are a single -/// statement, so neither a re-tick nor a relay's own creation/activation charge -/// (which also stamps `period_start`) can bill the same relay-period twice. +/// Insert this period's renewal items and advance the tenant's `renewed_at` +/// marker to `period_start`, atomically. Idempotent: a repeat call for an +/// already-renewed period is a no-op, so a crash mid-renewal or a poll racing +/// the on-demand endpoint can't bill the same period twice. pub async fn renew_tenant( tenant_pubkey: &str, period_start: i64, items: &[InvoiceItem], ) -> Result<()> { with_tx(async |tx| { - // In-tx guard: bail if this tenant has already been renewed for this - // period (or later). This is the correctness backstop — it keeps renewal - // idempotent under a crash mid-renewal or a poll racing the eager - // endpoint, since the item inserts and the `renewed_at` write commit - // together. + // Re-read the marker inside the transaction so the guard and the writes + // commit together — this is the real idempotency backstop. let renewed_at = sqlx::query_scalar::<_, Option>( "SELECT renewed_at FROM tenant WHERE pubkey = ?", ) @@ -254,8 +253,7 @@ pub async fn renew_tenant( /// Claim all of a tenant's outstanding items onto a new invoice — but only if /// they sum to a positive amount. A non-positive balance (net credit or nothing /// owed) leaves the items outstanding so the credit carries to the next positive -/// invoice. The sum, insert, and claim run in one transaction. Returns the -/// invoice, or `None` when there's nothing to bill. +/// invoice. Returns the invoice, or `None` when there's nothing to bill. pub async fn claim_outstanding_into_invoice( invoice_id: &str, tenant_pubkey: &str, diff --git a/backend/src/infra.rs b/backend/src/infra.rs index 7e12260..177a6b4 100644 --- a/backend/src/infra.rs +++ b/backend/src/infra.rs @@ -12,14 +12,25 @@ const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30; const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60; const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6; +/// The relay-provisioning reactor: it keeps the external relay backend (the +/// zooid API) in sync with our relay rows, reacting to relay activity and +/// retrying failed syncs with backoff. #[derive(Clone)] pub struct Infra; +impl Default for Infra { + fn default() -> Self { + Self::new() + } +} + impl Infra { pub fn new() -> Self { Self } + /// Run the reactor for the life of the process: reconcile any relays left + /// unsynced from a previous run, then sync each relay as its activity arrives. pub async fn start(self) { let mut rx = db::subscribe(); @@ -238,6 +249,7 @@ impl Infra { Ok(()) } + /// Fetch the member pubkeys of a relay from the zooid API. pub async fn list_relay_members(&self, relay_id: &str) -> Result> { #[derive(serde::Deserialize)] struct MembersResponse { diff --git a/backend/src/models.rs b/backend/src/models.rs index 7bce69d..80a8e5d 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -123,7 +123,7 @@ pub struct Bolt11 { pub settled_at: Option, } -#[allow(dead_code)] // backs the `intent` table for the (not yet implemented) Stripe intent flow +#[allow(dead_code)] // mirrors the `intent` table; rows record paid Stripe PaymentIntents but aren't read back into this struct yet #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Intent { pub id: String, diff --git a/backend/src/robot.rs b/backend/src/robot.rs index dce50e1..b878553 100644 --- a/backend/src/robot.rs +++ b/backend/src/robot.rs @@ -7,6 +7,9 @@ use tokio::sync::Mutex; use crate::env; +/// The service's Nostr identity: it publishes the robot's profile and relay +/// lists and sends encrypted direct messages to tenants, caching recipients' +/// relay lists between sends. #[derive(Clone)] pub struct Robot { outbox_cache: std::sync::Arc>>, @@ -20,6 +23,7 @@ struct CacheEntry { } impl Robot { + /// Build the robot and publish its Nostr identity (profile and relay lists). pub async fn new() -> Result { let robot = Self { outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())), @@ -80,6 +84,7 @@ impl Robot { Ok(()) } + /// Send an encrypted direct message to a recipient over their messaging relays. pub async fn send_dm(&self, recipient: &str, message: &str) -> Result<()> { let outbox = self.fetch_outbox_relays(recipient).await?; if outbox.is_empty() { @@ -123,6 +128,7 @@ impl Robot { Ok(relays) } + /// The recipient's display name from their Nostr profile, if they have one. pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option { let pubkey = PublicKey::parse(pubkey).ok()?; let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1); diff --git a/backend/src/routes/invoices.rs b/backend/src/routes/invoices.rs index 7dd0de9..415a507 100644 --- a/backend/src/routes/invoices.rs +++ b/backend/src/routes/invoices.rs @@ -46,6 +46,8 @@ pub async fn get_invoice( ok(invoice) } +/// Return a payable Lightning invoice (bolt11) for an invoice, minting one if +/// needed and first settling it if it was already paid out of band. pub async fn get_invoice_bolt11( State(api): State>, AuthedPubkey(auth): AuthedPubkey, diff --git a/backend/src/routes/relays.rs b/backend/src/routes/relays.rs index 40ba233..fd606aa 100644 --- a/backend/src/routes/relays.rs +++ b/backend/src/routes/relays.rs @@ -278,6 +278,9 @@ const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"]; static SUBDOMAIN_RE: LazyLock = LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$").unwrap()); +/// Validate and normalize a relay before persistence: enforce the subdomain +/// format and reserved names, require an existing plan that permits any enabled +/// premium features, and coerce the boolean columns to 0/1. fn prepare_relay(mut relay: Relay) -> Result { if !SUBDOMAIN_RE.is_match(&relay.subdomain) || RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str()) { @@ -302,6 +305,7 @@ fn prepare_relay(mut relay: Relay) -> Result { Ok(relay) } +/// Translate a duplicate-subdomain write into a 422; anything else is a 500. fn map_relay_write_error(e: anyhow::Error) -> ApiError { if matches!(map_unique_error(&e), Some("subdomain-exists")) { unprocessable("subdomain-exists", "subdomain already exists") diff --git a/backend/src/routes/tenants.rs b/backend/src/routes/tenants.rs index 2efbd43..974dd9c 100644 --- a/backend/src/routes/tenants.rs +++ b/backend/src/routes/tenants.rs @@ -48,6 +48,9 @@ pub async fn list_tenants( .collect::>()) } +/// Create the tenant row for the calling pubkey and provision its Stripe +/// customer. Idempotent: an existing tenant (including one created by a +/// concurrent unique-constraint race) is returned as-is. pub async fn create_tenant( State(api): State>, AuthedPubkey(pubkey): AuthedPubkey, @@ -138,7 +141,7 @@ pub async fn list_tenant_relays( ok(relays) } - +/// List a tenant's invoices, most recent first. pub async fn list_tenant_invoices( State(api): State>, AuthedPubkey(auth): AuthedPubkey, @@ -158,6 +161,8 @@ pub struct StripeSessionParams { return_url: Option, } +/// Create a Stripe billing-portal session for the tenant to manage their saved +/// payment methods, returning the portal URL. pub async fn create_stripe_session( State(api): State>, AuthedPubkey(auth): AuthedPubkey, diff --git a/backend/src/stripe.rs b/backend/src/stripe.rs index 81db160..7ac8f2f 100644 --- a/backend/src/stripe.rs +++ b/backend/src/stripe.rs @@ -12,13 +12,17 @@ use crate::env; const STRIPE_API: &str = "https://api.stripe.com/v1"; -// Stripe struct and impl - #[derive(Clone)] pub struct Stripe { http: reqwest::Client, } +impl Default for Stripe { + fn default() -> Self { + Self::new() + } +} + impl Stripe { pub fn new() -> Self { Self { @@ -54,6 +58,8 @@ impl Stripe { // --- Customers --- + /// Create a Stripe customer for a tenant and return its id. Idempotent on + /// `tenant_pubkey` so retrying a tenant's creation reuses the same customer. pub async fn create_customer(&self, tenant_pubkey: &str, name: &str) -> Result { let body = self .post("/customers") @@ -142,6 +148,8 @@ impl Stripe { // --- Portal --- + /// Open a Stripe billing-portal session for the customer, returning the URL + /// where they can manage their saved payment methods. pub async fn create_portal_session( &self, customer_id: &str, diff --git a/backend/src/wallet.rs b/backend/src/wallet.rs index 7e36022..d8f9e3a 100644 --- a/backend/src/wallet.rs +++ b/backend/src/wallet.rs @@ -4,6 +4,9 @@ use nwc::prelude::{ TransactionState, }; +/// A Nostr Wallet Connect wallet, used both as the service's receiving wallet +/// and as a tenant's paying wallet. Each call spins up and shuts down its own +/// short-lived NWC client; nothing is pooled across calls. #[derive(Clone)] pub struct Wallet { url: NostrWalletConnectURI,