forked from coracle/caravel
Update docs
This commit is contained in:
+18
-15
@@ -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,
|
||||
|
||||
@@ -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(¤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<f64> {
|
||||
let http = reqwest::Client::new();
|
||||
let url = format!("https://api.coinbase.com/v2/prices/BTC-{currency}/spot");
|
||||
|
||||
+9
-11
@@ -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,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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user