Update docs

This commit is contained in:
Jon Staab
2026-05-27 16:56:34 -07:00
parent cd70ca6654
commit 0f47b483aa
11 changed files with 74 additions and 30 deletions
+18 -15
View File
@@ -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<Option<Invoice>> {
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,
+3
View File
@@ -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<u64> {
let price = get_bitcoin_price(&currency.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<f64> {
let http = reqwest::Client::new();
let url = format!("https://api.coinbase.com/v2/prices/BTC-{currency}/spot");
+9 -11
View File
@@ -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<i64>>(
"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,
+12
View File
@@ -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<Vec<String>> {
#[derive(serde::Deserialize)]
struct MembersResponse {
+1 -1
View File
@@ -123,7 +123,7 @@ pub struct Bolt11 {
pub settled_at: Option<i64>,
}
#[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,
+6
View File
@@ -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<Mutex<HashMap<String, CacheEntry>>>,
@@ -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<Self> {
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<String> {
let pubkey = PublicKey::parse(pubkey).ok()?;
let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1);
+2
View File
@@ -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<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
+4
View File
@@ -278,6 +278,9 @@ const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"];
static SUBDOMAIN_RE: LazyLock<Regex> =
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<Relay, ApiError> {
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<Relay, ApiError> {
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")
+6 -1
View File
@@ -48,6 +48,9 @@ pub async fn list_tenants(
.collect::<Vec<_>>())
}
/// 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<Arc<Api>>,
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<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
@@ -158,6 +161,8 @@ pub struct StripeSessionParams {
return_url: Option<String>,
}
/// 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<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
+10 -2
View File
@@ -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<String> {
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,
+3
View File
@@ -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,