Refactor billing to manage subscriptions/invoices internally

This commit is contained in:
Jon Staab
2026-05-26 14:25:21 -07:00
parent 28cd7b0a9a
commit 7a2baf6f82
23 changed files with 1464 additions and 1694 deletions
-3
View File
@@ -34,6 +34,3 @@ BLOSSOM_S3_SECRET_KEY=
# Billing # Billing
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000
STRIPE_PRICE_BASIC=
STRIPE_PRICE_GROWTH=
+55 -20
View File
@@ -4,7 +4,8 @@ CREATE TABLE IF NOT EXISTS activity (
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
activity_type TEXT NOT NULL, activity_type TEXT NOT NULL,
resource_type TEXT NOT NULL, resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL resource_id TEXT NOT NULL,
billed_at INTEGER
); );
CREATE TABLE IF NOT EXISTS tenant ( CREATE TABLE IF NOT EXISTS tenant (
@@ -12,9 +13,8 @@ CREATE TABLE IF NOT EXISTS tenant (
nwc_url TEXT NOT NULL DEFAULT '', nwc_url TEXT NOT NULL DEFAULT '',
nwc_error TEXT, nwc_error TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
stripe_customer_id TEXT NOT NULL, billing_anchor INTEGER,
stripe_subscription_id TEXT, stripe_customer_id TEXT NOT NULL
past_due_at INTEGER
); );
CREATE TABLE IF NOT EXISTS relay ( CREATE TABLE IF NOT EXISTS relay (
@@ -38,29 +38,64 @@ CREATE TABLE IF NOT EXISTS relay (
FOREIGN KEY (tenant) REFERENCES tenant(pubkey) FOREIGN KEY (tenant) REFERENCES tenant(pubkey)
); );
CREATE TABLE IF NOT EXISTS lightning_invoice ( CREATE TABLE IF NOT EXISTS invoice (
stripe_invoice_id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
tenant_pubkey TEXT NOT NULL, tenant_pubkey TEXT NOT NULL,
bolt11 TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('open','paid','void','churn')),
status TEXT NOT NULL CHECK (status IN ('pending', 'paid')), method TEXT CHECK (method IS NULL OR method IN ('nwc','stripe','oob')),
paid_method TEXT CHECK (paid_method IN ('nwc', 'manual')), period_start INTEGER NOT NULL,
expires_at INTEGER NOT NULL, period_end INTEGER NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey) FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
); );
CREATE INDEX IF NOT EXISTS idx_tenant_stripe_customer_id CREATE TABLE IF NOT EXISTS invoice_item (
ON tenant (stripe_customer_id); id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
activity_id TEXT NOT NULL,
tenant_pubkey TEXT NOT NULL,
relay_id TEXT NOT NULL,
plan TEXT NOT NULL,
amount INTEGER NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoice(id),
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE INDEX IF NOT EXISTS idx_relay_tenant_id CREATE TABLE IF NOT EXISTS bolt11 (
ON relay (tenant, id); id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
lnbc TEXT NOT NULL,
msats INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
settled_at INTEGER,
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
);
CREATE INDEX IF NOT EXISTS idx_relay_tenant_status_plan CREATE TABLE IF NOT EXISTS intent (
ON relay (tenant, status, plan); id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
);
CREATE INDEX IF NOT EXISTS idx_activity_resource_type_resource_id_created_at_id CREATE INDEX IF NOT EXISTS idx_relay_tenant ON relay (tenant);
ON activity (resource_type, resource_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_lightning_invoice_tenant_pubkey CREATE INDEX IF NOT EXISTS idx_activity_tenant_created ON activity (tenant, created_at);
ON lightning_invoice (tenant_pubkey);
CREATE INDEX IF NOT EXISTS idx_activity_resource_created ON activity (resource_id, created_at);
CREATE INDEX IF NOT EXISTS idx_activity_unbilled ON activity (tenant, created_at) WHERE billed_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoice_tenant_period ON invoice (tenant_pubkey, period_start);
CREATE INDEX IF NOT EXISTS idx_invoice_tenant_created ON invoice (tenant_pubkey, created_at);
CREATE INDEX IF NOT EXISTS idx_invoice_item_invoice ON invoice_item (invoice_id);
CREATE INDEX IF NOT EXISTS idx_bolt11_invoice_created ON bolt11 (invoice_id, created_at);
CREATE INDEX IF NOT EXISTS idx_intent_invoice ON intent (invoice_id);
+25 -32
View File
@@ -28,31 +28,27 @@ use base64::Engine;
use nostr_sdk::{Event, JsonUtil, Kind}; use nostr_sdk::{Event, JsonUtil, Kind};
use crate::billing::Billing; use crate::billing::Billing;
use crate::command::Command; use crate::env;
use crate::env::Env;
use crate::infra::Infra; use crate::infra::Infra;
use crate::models::{Relay, Tenant}; use crate::models::{Relay, Tenant};
use crate::query::Query; use crate::query;
use crate::robot::Robot; use crate::robot::Robot;
use crate::stripe::Stripe; use crate::stripe::Stripe;
use crate::routes::identity::get_identity; use crate::routes::identity::get_identity;
use crate::routes::invoices::{get_invoice, get_lightning_invoice, list_tenant_invoices}; use crate::routes::invoices::{get_invoice, get_invoice_bolt11};
use crate::routes::plans::{get_plan, list_plans}; use crate::routes::plans::{get_plan, list_plans};
use crate::routes::relays::{ use crate::routes::relays::{
create_relay, deactivate_relay, get_relay, list_relay_activity, list_relay_members, create_relay, deactivate_relay, get_relay, list_relay_activity, list_relay_members,
list_relays, reactivate_relay, update_relay, list_relays, reactivate_relay, update_relay,
}; };
use crate::routes::stripe::{create_stripe_session, stripe_webhook};
use crate::routes::tenants::{ use crate::routes::tenants::{
create_tenant, get_tenant, list_tenant_relays, list_tenants, update_tenant, create_stripe_session, create_tenant, get_tenant, list_tenant_invoices, list_tenant_relays,
list_tenants, update_tenant,
}; };
use crate::web::{ApiError, forbidden, internal, not_found, unauthorized}; use crate::web::{ApiError, forbidden, internal, not_found, unauthorized};
#[derive(Clone)] #[derive(Clone)]
pub struct Api { pub struct Api {
pub env: Env,
pub query: Query,
pub command: Command,
pub billing: Billing, pub billing: Billing,
pub stripe: Stripe, pub stripe: Stripe,
pub robot: Robot, pub robot: Robot,
@@ -60,19 +56,8 @@ pub struct Api {
} }
impl Api { impl Api {
pub fn new( pub fn new(billing: Billing, stripe: Stripe, robot: Robot, infra: Infra) -> Self {
query: Query,
command: Command,
billing: Billing,
stripe: Stripe,
robot: Robot,
infra: Infra,
env: &Env,
) -> Self {
Self { Self {
env: env.clone(),
query,
command,
billing, billing,
stripe, stripe,
robot, robot,
@@ -90,24 +75,23 @@ impl Api {
.route("/tenants", get(list_tenants).post(create_tenant)) .route("/tenants", get(list_tenants).post(create_tenant))
.route("/tenants/:pubkey", get(get_tenant).put(update_tenant)) .route("/tenants/:pubkey", get(get_tenant).put(update_tenant))
.route("/tenants/:pubkey/relays", get(list_tenant_relays)) .route("/tenants/:pubkey/relays", get(list_tenant_relays))
.route("/tenants/:pubkey/invoices", get(list_tenant_invoices))
.route("/tenants/:pubkey/stripe/session", get(create_stripe_session))
.route("/relays", get(list_relays).post(create_relay)) .route("/relays", get(list_relays).post(create_relay))
.route("/relays/:id", get(get_relay).put(update_relay)) .route("/relays/:id", get(get_relay).put(update_relay))
.route("/relays/:id/members", get(list_relay_members)) .route("/relays/:id/members", get(list_relay_members))
.route("/relays/:id/activity", get(list_relay_activity)) .route("/relays/:id/activity", get(list_relay_activity))
.route("/relays/:id/deactivate", post(deactivate_relay)) .route("/relays/:id/deactivate", post(deactivate_relay))
.route("/relays/:id/reactivate", post(reactivate_relay)) .route("/relays/:id/reactivate", post(reactivate_relay))
.route("/tenants/:pubkey/invoices", get(list_tenant_invoices))
.route("/invoices/:id", get(get_invoice)) .route("/invoices/:id", get(get_invoice))
.route("/invoices/:id/bolt11", get(get_lightning_invoice)) .route("/invoices/:id/bolt11", get(get_invoice_bolt11))
.route("/tenants/:pubkey/stripe/session", get(create_stripe_session))
.route("/stripe/webhook", post(stripe_webhook))
.with_state(api) .with_state(api)
} }
// --- authorization helpers ---------------------------------------------- // --- authorization helpers ----------------------------------------------
pub fn is_admin(&self, pubkey: &str) -> bool { pub fn is_admin(&self, pubkey: &str) -> bool {
self.env.server_admin_pubkeys.iter().any(|a| a == pubkey) env::get().server_admin_pubkeys.iter().any(|a| a == pubkey)
} }
pub fn require_admin(&self, authorized_pubkey: &str) -> Result<(), ApiError> { pub fn require_admin(&self, authorized_pubkey: &str) -> Result<(), ApiError> {
@@ -118,6 +102,18 @@ impl Api {
} }
} }
pub fn require_tenant(
&self,
authorized_pubkey: &str,
tenant_pubkey: &str,
) -> Result<(), ApiError> {
if authorized_pubkey == tenant_pubkey {
Ok(())
} else {
Err(forbidden("not authorized"))
}
}
pub fn require_admin_or_tenant( pub fn require_admin_or_tenant(
&self, &self,
authorized_pubkey: &str, authorized_pubkey: &str,
@@ -131,7 +127,7 @@ impl Api {
} }
pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result<Tenant, ApiError> { pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result<Tenant, ApiError> {
match self.query.get_tenant(pubkey).await { match query::get_tenant(pubkey).await {
Ok(Some(t)) => Ok(t), Ok(Some(t)) => Ok(t),
Ok(None) => Err(not_found("tenant not found")), Ok(None) => Err(not_found("tenant not found")),
Err(e) => Err(internal(e)), Err(e) => Err(internal(e)),
@@ -139,7 +135,7 @@ impl Api {
} }
pub async fn get_relay_or_404(&self, id: &str) -> Result<Relay, ApiError> { pub async fn get_relay_or_404(&self, id: &str) -> Result<Relay, ApiError> {
match self.query.get_relay(id).await { match query::get_relay(id).await {
Ok(Some(r)) => Ok(r), Ok(Some(r)) => Ok(r),
Ok(None) => Err(not_found("relay not found")), Ok(None) => Err(not_found("relay not found")),
Err(e) => Err(internal(e)), Err(e) => Err(internal(e)),
@@ -188,10 +184,7 @@ impl Api {
.last() .last()
.ok_or_else(|| anyhow!("missing u tag"))?; .ok_or_else(|| anyhow!("missing u tag"))?;
ensure!( ensure!(got_u == env::get().server_host, "authorization host mismatch");
self.env.server_host.is_empty() || got_u.contains(&self.env.server_host),
"authorization host mismatch"
);
Ok(event.pubkey.to_hex()) Ok(event.pubkey.to_hex())
} }
+349 -253
View File
@@ -1,41 +1,44 @@
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use std::collections::BTreeMap; use std::time::Duration;
use crate::bitcoin; use crate::bitcoin;
use crate::command::Command; use crate::command;
use crate::env::Env; use crate::db;
use crate::models::{Activity, LightningInvoice, Tenant, RELAY_STATUS_ACTIVE}; use crate::env;
use crate::query::Query; use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Tenant};
use crate::stripe::{Stripe, StripeInvoice, StripeSubscription}; use crate::query;
use crate::robot::Robot;
use crate::stripe::Stripe;
use crate::wallet::Wallet; use crate::wallet::Wallet;
#[derive(Clone)] #[derive(Clone)]
pub struct Billing { pub struct Billing {
stripe: Stripe, stripe: Stripe,
wallet: Wallet, wallet: Wallet,
query: Query, robot: Robot,
command: Command,
env: Env,
} }
impl Billing { impl Billing {
pub fn new(query: Query, command: Command, env: &Env) -> Self { pub fn new(robot: Robot) -> Self {
Self { Self {
stripe: Stripe::new(env), stripe: Stripe::new(),
wallet: Wallet::from_url(&env.robot_wallet).expect("invalid ROBOT_WALLET"), wallet: Wallet::from_url(&env::get().robot_wallet).expect("invalid ROBOT_WALLET"),
query, robot,
command,
env: env.clone(),
} }
} }
// --- lifecycle methods --- // --- lifecycle methods ---
pub async fn start(self) { pub async fn start(self) {
let mut rx = self.command.notify.subscribe(); let mut rx = db::subscribe();
tokio::spawn({
let billing = self.clone();
async move { billing.poll().await }
});
if let Err(error) = self.reconcile_subscriptions("startup").await { if let Err(error) = self.reconcile_subscriptions("startup").await {
tracing::error!(error = %error, "failed to reconcile relay billing state on startup"); tracing::error!(error = %error, "failed to reconcile subscriptions on startup");
} }
loop { loop {
@@ -46,10 +49,10 @@ impl Billing {
} }
} }
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "billing lagged"); tracing::warn!(missed = n, "billing lagged, reconciling all subscriptions");
if let Err(error) = self.reconcile_subscriptions("lagged").await { if let Err(error) = self.reconcile_subscriptions("lagged").await {
tracing::error!(error = %error, "failed to reconcile relay billing state after lag"); tracing::error!(error = %error, "failed to reconcile after lag");
} }
} }
Err(tokio::sync::broadcast::error::RecvError::Closed) => break, Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
@@ -57,17 +60,78 @@ impl Billing {
} }
} }
async fn reconcile_subscriptions(&self, source: &str) -> Result<()> { async fn poll(&self) {
let tenants = self.query.list_tenants().await?; let mut interval = tokio::time::interval(POLL_INTERVAL);
if tenants.is_empty() { loop {
return Ok(()); interval.tick().await;
if let Err(error) = self.autogenerate_invoices().await {
tracing::error!(error = %error, "billing poll failed");
}
} }
}
async fn autogenerate_invoices(&self) -> Result<()> {
let tenants = query::list_tenants().await?;
tracing::info!(
tenant_count = tenants.len(),
"polling tenants for subscription renewal"
);
for tenant in tenants {
if let Err(error) = self.autogenerate_invoice(&tenant).await {
tracing::error!(
tenant = %tenant.pubkey,
error = ?error,
"failed to autogenerate invoice"
);
}
}
Ok(())
}
/// If `tenant`'s subscription has rolled into a new billing period, claim it by
/// atomically recording an `autogenerate_invoice` activity, then turn that into an invoice.
async fn autogenerate_invoice(&self, tenant: &Tenant) -> Result<()> {
// A subscription only exists once a billing anchor is set; until then
// there is no schedule to renew against.
let Some(billing_anchor) = tenant.billing_anchor else {
return Ok(());
};
let now = chrono::Utc::now().timestamp();
let period_start = period_start_at(billing_anchor, now);
command::try_autogenerate_invoice(&tenant.pubkey, period_start).await?;
Ok(())
}
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let should_sync = matches!(
activity.activity_type.as_str(),
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "autogenerate_invoice"
);
if should_sync
&& let Some(tenant) = query::get_tenant(&activity.tenant).await?
{
self.reconcile_subscription(&tenant).await?;
}
Ok(())
}
async fn reconcile_subscriptions(&self, source: &str) -> Result<()> {
let tenants = query::list_tenants().await?;
tracing::info!( tracing::info!(
source, source,
tenant_count = tenants.len(), tenant_count = tenants.len(),
"reconciling relay billing state" "reconciling all subscriptions"
); );
for tenant in tenants { for tenant in tenants {
@@ -76,7 +140,7 @@ impl Billing {
source, source,
tenant = %tenant.pubkey, tenant = %tenant.pubkey,
error = ?error, error = ?error,
"failed to reconcile relay billing state" "failed to reconcile subscription"
); );
} }
} }
@@ -84,197 +148,148 @@ impl Billing {
Ok(()) Ok(())
} }
async fn handle_activity(&self, activity: &Activity) -> Result<()> { // --- Invoice generation and autopayment ---
let needs_billing_sync = matches!(
activity.activity_type.as_str(),
"create_relay"
| "update_relay"
| "activate_relay"
| "deactivate_relay"
| "fail_relay_sync"
| "complete_relay_sync"
);
if needs_billing_sync /// Scan a tenant's activity for changes not yet reflected in an invoice and,
&& let Some(tenant) = self.query.get_tenant(&activity.tenant).await? /// if there are any, create an invoice with the corresponding line items and
{ /// attempt to collect payment.
self.reconcile_subscription(&tenant).await?; async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
let now = chrono::Utc::now().timestamp();
let invoice_id = uuid::Uuid::new_v4().to_string();
let activities = query::list_billable_activity_for_tenant(&tenant.pubkey).await?;
let billed_activity_ids: Vec<String> = activities.iter().map(|a| a.id.clone()).collect();
let mut invoice_items: Vec<InvoiceItem> = Vec::new();
for activity in &activities {
// TODO: this is gross
let relay = if activity.resource_type == "relay" {
query::get_relay(&activity.resource_id).await?
} else {
None
};
match activity.activity_type.as_str() {
"create_relay" => {
if let Some(relay) = &relay
&& let Some(plan) = query::get_plan(&relay.plan)
&& plan.amount > 0
{
// TODO: prorate amount based on billing anchor
invoice_items.push(InvoiceItem {
id: uuid::Uuid::new_v4().to_string(),
invoice_id: invoice_id.clone(),
activity_id: activity.id.clone(),
tenant_pubkey: tenant.pubkey.clone(),
relay_id: activity.resource_id.clone(),
plan: plan.id,
amount: plan.amount,
description: "New relay created".to_string(),
created_at: activity.created_at,
});
}
}
"update_relay" => {
// TODO: refund/charge prorated amount
}
"activate_relay" => {
// TODO: charge prorated amount
}
"deactivate_relay" => {
// TODO: refund prorated amount
}
"autogenerate_invoice" => {
// TODO: we're at the beginning of a new period, add invoice
// items for all active/paid relays
}
_ => {}
}
} }
Ok(()) // No line items (e.g. only free-plan or not-yet-prorated changes): still
} // stamp the activities billed so a recovery pass doesn't re-scan them.
if invoice_items.is_empty() {
// --- Subscriptions --- command::mark_activities_billed(&billed_activity_ids).await?;
/// Reconciles a tenant's single Stripe subscription with the set of relays that
/// should be billed.
///
/// Stripe forbids two subscription items on the same subscription from sharing a
/// price, so billing is modeled as one subscription item per plan (price) with
/// `quantity` equal to the number of the tenant's `active` relays on that plan.
async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
let quantity_by_price_id = self.get_quantity_by_price_id(tenant).await?;
// If we've got no subscription items, we can cancel and clear the tenant's subscription
if quantity_by_price_id.is_empty() {
self.ensure_subscription_is_inactive(tenant).await?;
return Ok(()); return Ok(());
} }
let subscription = self let period_start = invoice_items
.ensure_subscription_is_active(tenant, &quantity_by_price_id) .iter()
.await?; .map(|item| item.created_at)
.max()
.unwrap_or(now);
let period_end = add_one_month(period_start);
self.ensure_subscription_items(subscription, quantity_by_price_id).await let invoice = command::create_invoice(
&invoice_id,
&tenant.pubkey,
period_start,
period_end,
&invoice_items,
&billed_activity_ids,
tenant.billing_anchor.is_none().then_some(now),
)
.await?;
self.attempt_payment(tenant, &invoice).await?;
Ok(())
} }
/// Gets a map of stripe_price_id -> quantity based on the tenant's current relays pub async fn attempt_payment(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>> { let mut error_message: Option<String> = None;
let mut quantity_by_price_id = BTreeMap::new();
for relay in self.query.list_relays_for_tenant(&tenant.pubkey).await? { // 1. NWC auto-pay: if the tenant has configured an nwc_url, try it first.
if relay.status != RELAY_STATUS_ACTIVE { if !tenant.nwc_url.is_empty() {
continue; match self.attempt_payment_using_nwc(tenant, invoice).await {
Ok(()) => return Ok(()),
Err(e) => error_message = Some(format!("{e}")),
} }
let Some(price_id) = self.query.get_plan(&relay.plan).and_then(|p| p.stripe_price_id) else {
continue;
};
*quantity_by_price_id.entry(price_id).or_insert(0) += 1;
} }
Ok(quantity_by_price_id)
}
/// Fetch the tenant's current subscription from Stripe, if it has one // 2. Payment method on file: if the tenant has one saved, charge it via Stripe.
async fn get_subscription(&self, tenant: &Tenant) -> Result<Option<StripeSubscription>> { if let Some(payment_method) =
let subscription = match &tenant.stripe_subscription_id { self.stripe.get_saved_payment_method(&tenant.stripe_customer_id).await?
Some(id) => self.stripe.get_subscription(id).await?,
None => None,
};
// If it's canceled, clear the subscription id and return nothing for simplicity
if subscription
.as_ref()
.is_some_and(|s| matches!(s.status.as_str(), "canceled" | "incomplete_expired"))
{ {
self.command.clear_tenant_subscription(&tenant.pubkey).await?; match self
return Ok(None); .attempt_payment_using_stripe(tenant, invoice, &payment_method)
.await
{
Ok(()) => return Ok(()),
Err(e) => error_message = Some(format!("{e}")),
}
} }
Ok(subscription) // 3. Manual payment: DM a link to the in-app payment page for this invoice.
} let summary = error_message.as_deref().and_then(summarize_error_message);
if let Err(e) = self.attempt_payment_using_dm(tenant, invoice, summary).await {
/// Make sure the tenant has an active subscription, creating one with the desired tracing::error!(
/// items if it doesn't (Stripe rejects an itemless subscription). tenant = %tenant.pubkey,
async fn ensure_subscription_is_active( error = %e,
&self, "failed to send manual payment DM"
tenant: &Tenant, );
quantity_by_price_id: &BTreeMap<String, i64>,
) -> Result<StripeSubscription> {
if let Some(sub) = self.get_subscription(tenant).await? {
return Ok(sub);
}
let sub = self
.stripe
.create_subscription(&tenant.stripe_customer_id, quantity_by_price_id)
.await?;
self.command.set_tenant_subscription(&tenant.pubkey, &sub.id).await?;
Ok(sub)
}
/// If the tenant has a subscription, cancel and clear it
async fn ensure_subscription_is_inactive(&self, tenant: &Tenant) -> Result<()> {
if let Some(s) = self.get_subscription(tenant).await? {
self.stripe.cancel_subscription(&s.id).await?;
self.command.clear_tenant_subscription(&tenant.pubkey).await?;
} }
Ok(()) Ok(())
} }
/// Sync desired quantity_by_price_id with stripe async fn attempt_payment_using_nwc(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
async fn ensure_subscription_items( let nwc_url = env::get().decrypt(&tenant.nwc_url)?;
&self,
subscription: StripeSubscription,
quantity_by_price_id: BTreeMap<String, i64>,
) -> Result<()> {
let mut current: BTreeMap<String, (String, i64)> = BTreeMap::new();
for item in subscription.items {
current.insert(item.price.id, (item.id, item.quantity));
}
for (price_id, &quantity) in &quantity_by_price_id {
if let Some((item_id, current_quantity)) = current.remove(price_id) {
if current_quantity != quantity {
self.stripe
.set_subscription_item_quantity(&item_id, quantity)
.await?;
}
} else {
self.stripe
.create_subscription_item(&subscription.id, price_id, quantity)
.await?;
}
}
for (_, (item_id, _)) in current {
self.stripe.delete_subscription_item(&item_id).await?;
}
Ok(())
}
// --- Invoices ---
/// return or generate a lightning invoice for an open stripe invoice
pub async fn ensure_lightning_invoice(
&self,
stripe_invoice_id: &str,
tenant_pubkey: &str,
amount_due: i64,
currency: &str,
) -> Result<LightningInvoice> {
let now = chrono::Utc::now().timestamp();
if let Some(existing) = self.query.get_lightning_invoice(stripe_invoice_id).await?
&& (existing.status != "pending" || now < existing.expires_at)
{
return Ok(existing);
}
let expiry: i64 = 3600;
let info = "Relay subscription payment";
let msats = bitcoin::fiat_to_msats(amount_due, currency).await?;
let bolt11 = self.wallet.make_invoice(msats, info, expiry as u64).await?;
let invoice = match self
.command
.insert_lightning_invoice(stripe_invoice_id, tenant_pubkey, &bolt11, now + expiry)
.await?
{
Some(invoice) => invoice,
None => self
.query
.get_lightning_invoice(stripe_invoice_id)
.await?
.ok_or_else(|| anyhow!("lightning_invoice {stripe_invoice_id} missing after upsert"))?,
};
Ok(invoice)
}
/// Attempt to pay and settle an invoice via nwc
pub async fn pay_invoice_nwc(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()> {
let nwc_url = self.env.decrypt(&tenant.nwc_url)?;
let tenant_wallet = Wallet::from_url(&nwc_url)?; let tenant_wallet = Wallet::from_url(&nwc_url)?;
let bolt11 = self.ensure_bolt11(&invoice.id).await?;
match tenant_wallet.pay_invoice(invoice.bolt11.clone()).await { match tenant_wallet.pay_invoice(bolt11.lnbc.clone()).await {
Ok(()) => self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await, Ok(()) => {
command::clear_tenant_nwc_error(&tenant.pubkey).await?;
command::mark_bolt11_settled(&bolt11.id).await?;
command::mark_invoice_paid(&invoice.id, "nwc").await
}
Err(pay_error) => { Err(pay_error) => {
// The pay request errored, but the payment may have landed // The pay request errored, but the invoice may have been paid out of band.
// before the response was lost. Confirm against the system if self.wallet.is_settled(&bolt11.lnbc).await.unwrap_or(false) {
// wallet before reporting failure. command::mark_bolt11_settled(&bolt11.id).await?;
if self.wallet.is_settled(&invoice.bolt11).await.unwrap_or(false) { command::mark_invoice_paid(&invoice.id, "oob").await
self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await
} else { } else {
Err(pay_error) Err(pay_error)
} }
@@ -282,68 +297,149 @@ impl Billing {
} }
} }
async fn attempt_payment_using_stripe(
&self,
tenant: &Tenant,
invoice: &Invoice,
payment_method_id: &str,
) -> Result<()> {
let amount = self.get_invoice_amount(&invoice.id).await?;
// A decline or an off-session authentication demand comes back as Err, so
// the cascade falls back to the manual DM.
let intent_id = self
.stripe
.create_payment_intent(
&tenant.stripe_customer_id,
payment_method_id,
&invoice.id,
amount,
"usd",
)
.await?;
command::insert_intent(&intent_id, &invoice.id).await?;
command::mark_invoice_paid(&invoice.id, "stripe").await
}
async fn attempt_payment_using_dm(
&self,
tenant: &Tenant,
invoice: &Invoice,
error_message: Option<String>,
) -> Result<()> {
let invoice_id = &invoice.id;
let url_base = &env::get().app_url;
let payment_url = format!("{url_base}/account?invoice={invoice_id}");
let base = format!("{MANUAL_PAYMENT_DM}\n\n{payment_url}");
let dm_message = match error_message {
Some(error_message) if !error_message.is_empty() => {
format!("{base}\n\n{USER_ERROR_PREFIX} {error_message}")
}
_ => base,
};
self.robot.send_dm(&tenant.pubkey, &dm_message).await
}
// --- Invoice utils ---
pub async fn get_invoice_amount(&self, invoice_id: &str) -> Result<i64> {
let invoice_items = query::get_invoice_items_for_invoice(invoice_id).await?;
Ok(invoice_items.iter().map(|item| item.amount).sum())
}
// --- Bolt11 utils ---
pub async fn ensure_bolt11(&self, invoice_id: &str) -> Result<Bolt11> {
let now = chrono::Utc::now().timestamp();
if let Some(existing) = query::get_bolt11_for_invoice(invoice_id).await?
&& (existing.settled_at.is_none() || now < existing.expires_at)
{
return Ok(existing);
}
let expiry: i64 = 3600;
let info = "Relay subscription payment";
let amount = self.get_invoice_amount(invoice_id).await?;
let msats = bitcoin::fiat_to_msats(amount, "usd").await?;
let lnbc = self.wallet.make_invoice(msats, info, expiry as u64).await?;
command::insert_bolt11(invoice_id, &lnbc, msats as i64, now + expiry)
.await?
.ok_or_else(|| anyhow!("failed to insert bolt11"))
}
/// Catch an out-of-band payment we never recorded — e.g. the user paid the /// Catch an out-of-band payment we never recorded — e.g. the user paid the
/// invoice but the frontend failed to notify us. If the invoice's bolt11 has /// invoice but the frontend failed to notify us. If the invoice's bolt11 has
/// settled on the robot wallet, mark it paid and return the refreshed Stripe /// settled on the robot wallet, mark it paid and return the refreshed record;
/// invoice; otherwise return it unchanged. Meant to run before presenting a /// otherwise return it unchanged. Meant to run before presenting a payable
/// payable invoice so we never hand back one that's already been paid. /// invoice so we never hand back one that's already been paid.
pub async fn reconcile_invoice(&self, invoice: &StripeInvoice) -> Result<StripeInvoice> { pub async fn ensure_and_reconcile_bolt11(&self, invoice_id: &str) -> Result<Bolt11> {
if invoice.status != "open" { let bolt11 = self.ensure_bolt11(invoice_id).await?;
return Ok(invoice.clone());
if bolt11.settled_at.is_none() && self.wallet.is_settled(&bolt11.lnbc).await? {
command::mark_bolt11_settled(&bolt11.id).await?;
// Re-fetch so the caller sees that it's been settled.
Ok(query::get_bolt11(&bolt11.id).await?.unwrap_or(bolt11))
} else {
Ok(bolt11)
} }
let Some(row) = self.query.get_lightning_invoice(&invoice.id).await? else {
return Ok(invoice.clone());
};
let settled = match self.wallet.is_settled(&row.bolt11).await {
Ok(settled) => settled,
Err(error) => {
tracing::warn!(
error = %error,
stripe_invoice_id = %invoice.id,
"failed to look up bolt11 invoice settlement"
);
return Ok(invoice.clone());
}
};
if !settled {
return Ok(invoice.clone());
}
if let Err(error) = self.settle_invoice(&invoice.id, &row.tenant_pubkey, "manual").await {
tracing::warn!(
error = %error,
stripe_invoice_id = %invoice.id,
"failed to record settled bolt11 invoice as paid"
);
}
// Re-fetch so the caller sees the now-paid status; fall back to the
// pre-reconcile snapshot if Stripe momentarily 404s.
Ok(self
.stripe
.get_invoice(&invoice.id)
.await?
.unwrap_or_else(|| invoice.clone()))
}
/// Record a settled bolt11 invoice as paid by `method`, mark the Stripe
/// invoice paid out of band, and clear any lingering NWC error. The Stripe
/// call is idempotent across retries, and `mark_lightning_invoice_paid` is
/// first-writer-wins, so the recorded method reflects whoever settled first.
async fn settle_invoice(
&self,
stripe_invoice_id: &str,
tenant_pubkey: &str,
method: &str,
) -> Result<()> {
self.command
.mark_lightning_invoice_paid(stripe_invoice_id, method)
.await?;
self.stripe.pay_invoice_out_of_band(stripe_invoice_id).await?;
self.command.clear_tenant_nwc_error(tenant_pubkey).await?;
Ok(())
} }
} }
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;
/// The start of the billing period containing `now`, for monthly periods
/// anchored at `anchor`. Steps forward in whole calendar months so boundaries
/// track months (2831 days) rather than a fixed span of seconds.
fn period_start_at(anchor: i64, now: i64) -> i64 {
use chrono::{DateTime, Months, Utc};
let anchor_dt = DateTime::<Utc>::from_timestamp(anchor, 0).unwrap_or_default();
let mut start = anchor_dt;
let mut months = 1u32;
while let Some(next) = anchor_dt.checked_add_months(Months::new(months)) {
if next.timestamp() > now {
break;
}
start = next;
months += 1;
}
start.timestamp()
}
/// One calendar month after `ts` (a unix timestamp), falling back to `ts` if the
/// shifted date can't be represented.
fn add_one_month(ts: i64) -> i64 {
use chrono::{DateTime, Months, Utc};
DateTime::<Utc>::from_timestamp(ts, 0)
.and_then(|dt| dt.checked_add_months(Months::new(1)))
.map(|dt| dt.timestamp())
.unwrap_or(ts)
}
fn summarize_error_message(error: &str) -> Option<String> {
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
return None;
}
if normalized.chars().count() <= USER_ERROR_MAX_CHARS {
return Some(normalized);
}
let prefix_len = USER_ERROR_MAX_CHARS.saturating_sub(3);
let mut truncated = normalized.chars().take(prefix_len).collect::<String>();
truncated.push_str("...");
Some(truncated)
}
+449 -338
View File
@@ -1,355 +1,466 @@
use anyhow::Result; use anyhow::Result;
use sqlx::{Sqlite, SqlitePool, Transaction}; use sqlx::{Sqlite, Transaction};
use tokio::sync::broadcast;
use crate::db::{pool, publish, with_tx};
use crate::models::{ use crate::models::{
Activity, LightningInvoice, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT,
RELAY_STATUS_INACTIVE, Relay, Tenant, RELAY_STATUS_INACTIVE, Relay, Tenant,
}; };
#[derive(Clone)] // --- Activity ---
pub struct Command {
pool: SqlitePool,
pub notify: broadcast::Sender<Activity>,
}
impl Command { /// Stamp `billed_at` on activities that were reconciled without producing an
pub fn new(pool: SqlitePool) -> Self { /// invoice (e.g. free-plan or not-yet-prorated changes), so a recovery pass
let (notify, _) = broadcast::channel(64); /// doesn't re-scan them.
Self { pool, notify } pub async fn mark_activities_billed(activity_ids: &[String]) -> Result<()> {
if activity_ids.is_empty() {
return Ok(());
} }
// Activity let now = chrono::Utc::now().timestamp();
with_tx(async |tx| mark_activities_billed_tx(tx, activity_ids, now).await).await
}
async fn insert_activity( /// Atomically record an `autogenerate_invoice` activity for the tenant, but only
tx: &mut Transaction<'_, Sqlite>, /// if none has been recorded since `since` (the start of the current billing
activity_type: &str, /// period). Returns whether a new activity was inserted; `false` means the
resource_type: &str, /// period was already claimed.
resource_id: &str, ///
) -> Result<Activity> { /// The existence check and insert are a single statement, which SQLite runs
let tenant = match resource_type { /// atomically, so concurrent pollers (or a restart racing the previous run)
"tenant" => resource_id.to_string(), /// can't both claim the same period. On success the activity is broadcast so the
"relay" => { /// billing consumer reconciles it like any other.
sqlx::query_scalar::<_, String>("SELECT tenant FROM relay WHERE id = ?") pub async fn try_autogenerate_invoice(tenant_pubkey: &str, since: i64) -> Result<bool> {
.bind(resource_id) let id = uuid::Uuid::new_v4().to_string();
.fetch_one(&mut **tx) let created_at = chrono::Utc::now().timestamp();
.await?
}
_ => anyhow::bail!("unknown resource_type: {resource_type}"),
};
let id = uuid::Uuid::new_v4().to_string(); let result = sqlx::query(
let created_at = chrono::Utc::now().timestamp(); "INSERT INTO activity (id, tenant, created_at, activity_type, resource_type, resource_id)
SELECT ?, ?, ?, 'autogenerate_invoice', 'tenant', ?
WHERE NOT EXISTS (
SELECT 1 FROM activity
WHERE tenant = ?
AND activity_type = 'autogenerate_invoice'
AND created_at >= ?
)",
)
.bind(&id)
.bind(tenant_pubkey)
.bind(created_at)
.bind(tenant_pubkey)
.bind(tenant_pubkey)
.bind(since)
.execute(pool())
.await?;
if result.rows_affected() == 0 {
return Ok(false);
}
publish(Activity {
id,
tenant: tenant_pubkey.to_string(),
created_at,
activity_type: "autogenerate_invoice".to_string(),
resource_type: "tenant".to_string(),
resource_id: tenant_pubkey.to_string(),
billed_at: None,
});
Ok(true)
}
// --- Tenants ---
pub async fn create_tenant(tenant: &Tenant) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query( sqlx::query(
"INSERT INTO activity (id, tenant, created_at, activity_type, resource_type, resource_id) "INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id)
VALUES (?, ?, ?, ?, ?, ?)", VALUES (?, ?, ?, ?)",
) )
.bind(&id) .bind(&tenant.pubkey)
.bind(&tenant) .bind(&tenant.nwc_url)
.bind(created_at) .bind(tenant.created_at)
.bind(activity_type) .bind(&tenant.stripe_customer_id)
.bind(resource_type)
.bind(resource_id)
.execute(&mut **tx) .execute(&mut **tx)
.await?; .await?;
insert_activity_tx(tx, "create_tenant", "tenant", &tenant.pubkey).await
Ok(Activity { })
id, .await?;
tenant, publish(activity);
created_at, Ok(())
activity_type: activity_type.to_string(), }
resource_type: resource_type.to_string(),
resource_id: resource_id.to_string(), pub async fn update_tenant(tenant: &Tenant) -> Result<()> {
}) let activity = with_tx(async |tx| {
} sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
.bind(&tenant.nwc_url)
/// Run `f` inside a transaction, record an activity row, commit, and broadcast. .bind(&tenant.pubkey)
async fn with_activity<F>( .execute(&mut **tx)
&self, .await?;
activity_type: &str, insert_activity_tx(tx, "update_tenant", "tenant", &tenant.pubkey).await
resource_type: &str, })
resource_id: &str, .await?;
f: F, publish(activity);
) -> Result<()> Ok(())
where }
F: AsyncFnOnce(&mut Transaction<'_, Sqlite>) -> Result<()>,
{ pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> {
let mut tx = self.pool.begin().await?; sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
f(&mut tx).await?; .bind(pubkey)
let activity = .execute(pool())
Self::insert_activity(&mut tx, activity_type, resource_type, resource_id).await?; .await?;
tx.commit().await?; Ok(())
let _ = self.notify.send(activity); }
Ok(())
} // --- Relays ---
// Tenants pub async fn create_relay(relay: &Relay) -> Result<()> {
let activity = with_tx(async |tx| {
pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> { sqlx::query(
self.with_activity("create_tenant", "tenant", &tenant.pubkey, async |tx| { "INSERT INTO relay (
sqlx::query( id, tenant, subdomain, plan, status, synced, sync_error,
"INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id) info_name, info_icon, info_description,
VALUES (?, ?, ?, ?)", policy_public_join, policy_strip_signatures,
) groups_enabled, management_enabled, blossom_enabled,
.bind(&tenant.pubkey) livekit_enabled, push_enabled
.bind(&tenant.nwc_url) ) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
.bind(tenant.created_at) )
.bind(&tenant.stripe_customer_id) .bind(&relay.id)
.execute(&mut **tx) .bind(&relay.tenant)
.await?; .bind(&relay.subdomain)
Ok(()) .bind(&relay.plan)
}) .bind(&relay.sync_error)
.await .bind(&relay.info_name)
} .bind(&relay.info_icon)
.bind(&relay.info_description)
pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()> { .bind(relay.policy_public_join)
self.with_activity("update_tenant", "tenant", &tenant.pubkey, async |tx| { .bind(relay.policy_strip_signatures)
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?") .bind(relay.groups_enabled)
.bind(&tenant.nwc_url) .bind(relay.management_enabled)
.bind(&tenant.pubkey) .bind(relay.blossom_enabled)
.execute(&mut **tx) .bind(relay.livekit_enabled)
.await?; .bind(relay.push_enabled)
Ok(()) .execute(&mut **tx)
}) .await?;
.await insert_activity_tx(tx, "create_relay", "relay", &relay.id).await
} })
.await?;
pub async fn set_tenant_subscription( publish(activity);
&self, Ok(())
pubkey: &str, }
stripe_subscription_id: &str,
) -> Result<()> { pub async fn update_relay(relay: &Relay) -> Result<()> {
sqlx::query("UPDATE tenant SET stripe_subscription_id = ? WHERE pubkey = ?") let activity = with_tx(async |tx| {
.bind(stripe_subscription_id) sqlx::query(
.bind(pubkey) "UPDATE relay
.execute(&self.pool) SET tenant = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?, synced = 0,
.await?; info_name = ?, info_icon = ?, info_description = ?,
Ok(()) policy_public_join = ?, policy_strip_signatures = ?,
} groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
livekit_enabled = ?, push_enabled = ?
pub async fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()> { WHERE id = ?",
sqlx::query("UPDATE tenant SET stripe_subscription_id = NULL WHERE pubkey = ?") )
.bind(pubkey) .bind(&relay.tenant)
.execute(&self.pool) .bind(&relay.subdomain)
.await?; .bind(&relay.plan)
Ok(()) .bind(&relay.status)
} .bind(&relay.sync_error)
.bind(&relay.info_name)
pub async fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()> { .bind(&relay.info_icon)
sqlx::query("UPDATE tenant SET nwc_error = ? WHERE pubkey = ?") .bind(&relay.info_description)
.bind(error) .bind(relay.policy_public_join)
.bind(pubkey) .bind(relay.policy_strip_signatures)
.execute(&self.pool) .bind(relay.groups_enabled)
.await?; .bind(relay.management_enabled)
Ok(()) .bind(relay.blossom_enabled)
} .bind(relay.livekit_enabled)
.bind(relay.push_enabled)
pub async fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()> { .bind(&relay.id)
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?") .execute(&mut **tx)
.bind(pubkey) .await?;
.execute(&self.pool) insert_activity_tx(tx, "update_relay", "relay", &relay.id).await
.await?; })
Ok(()) .await?;
} publish(activity);
Ok(())
pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()> { }
let now = chrono::Utc::now().timestamp();
sqlx::query("UPDATE tenant SET past_due_at = ? WHERE pubkey = ?") pub async fn deactivate_relay(relay: &Relay) -> Result<()> {
.bind(now) set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay").await
.bind(pubkey) }
.execute(&self.pool)
.await?; #[allow(dead_code)] // wired up by the delinquency flow (not yet implemented)
Ok(()) pub async fn mark_relay_delinquent(relay: &Relay) -> Result<()> {
} set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent").await
}
pub async fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET past_due_at = NULL WHERE pubkey = ?") pub async fn activate_relay(relay: &Relay) -> Result<()> {
.bind(pubkey) set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay").await
.execute(&self.pool) }
.await?;
Ok(()) async fn set_relay_status(relay_id: &str, status: &str, activity_type: &str) -> Result<()> {
} let activity = with_tx(async |tx| {
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
// Relays .bind(status)
.bind(relay_id)
pub async fn create_relay(&self, relay: &Relay) -> Result<()> { .execute(&mut **tx)
self.with_activity("create_relay", "relay", &relay.id, async |tx| { .await?;
sqlx::query( insert_activity_tx(tx, activity_type, "relay", relay_id).await
"INSERT INTO relay ( })
id, tenant, subdomain, plan, status, synced, sync_error, .await?;
info_name, info_icon, info_description, publish(activity);
policy_public_join, policy_strip_signatures, Ok(())
groups_enabled, management_enabled, blossom_enabled, }
livekit_enabled, push_enabled
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", pub async fn fail_relay_sync(relay: &Relay, sync_error: String) -> Result<()> {
) let activity = with_tx(async |tx| {
.bind(&relay.id) sqlx::query("UPDATE relay SET synced = 0, sync_error = ? WHERE id = ?")
.bind(&relay.tenant) .bind(&sync_error)
.bind(&relay.subdomain) .bind(&relay.id)
.bind(&relay.plan) .execute(&mut **tx)
.bind(&relay.sync_error) .await?;
.bind(&relay.info_name) insert_activity_tx(tx, "fail_relay_sync", "relay", &relay.id).await
.bind(&relay.info_icon) })
.bind(&relay.info_description) .await?;
.bind(relay.policy_public_join) publish(activity);
.bind(relay.policy_strip_signatures) Ok(())
.bind(relay.groups_enabled) }
.bind(relay.management_enabled)
.bind(relay.blossom_enabled) pub async fn complete_relay_sync(relay_id: &str) -> Result<()> {
.bind(relay.livekit_enabled) let activity = with_tx(async |tx| {
.bind(relay.push_enabled) sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
.execute(&mut **tx) .bind(relay_id)
.await?; .execute(&mut **tx)
Ok(()) .await?;
}) insert_activity_tx(tx, "complete_relay_sync", "relay", relay_id).await
.await })
} .await?;
publish(activity);
pub async fn update_relay(&self, relay: &Relay) -> Result<()> { Ok(())
self.with_activity("update_relay", "relay", &relay.id, async |tx| { }
sqlx::query(
"UPDATE relay // --- Invoices ---
SET tenant = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?, synced = 0,
info_name = ?, info_icon = ?, info_description = ?, /// Create an invoice with its line items, stamp `billed_at` on the activities
policy_public_join = ?, policy_strip_signatures = ?, /// that produced them, and set the tenant's billing anchor when this is their
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?, /// first invoice — all in one transaction. Returns the inserted invoice.
livekit_enabled = ?, push_enabled = ? pub async fn create_invoice(
WHERE id = ?", invoice_id: &str,
) tenant_pubkey: &str,
.bind(&relay.tenant) period_start: i64,
.bind(&relay.subdomain) period_end: i64,
.bind(&relay.plan) items: &[InvoiceItem],
.bind(&relay.status) billed_activity_ids: &[String],
.bind(&relay.sync_error) new_billing_anchor: Option<i64>,
.bind(&relay.info_name) ) -> Result<Invoice> {
.bind(&relay.info_icon) let now = chrono::Utc::now().timestamp();
.bind(&relay.info_description)
.bind(relay.policy_public_join) with_tx(async |tx| {
.bind(relay.policy_strip_signatures) let invoice =
.bind(relay.groups_enabled) insert_invoice_tx(tx, invoice_id, tenant_pubkey, period_start, period_end).await?;
.bind(relay.management_enabled)
.bind(relay.blossom_enabled) for item in items {
.bind(relay.livekit_enabled) insert_invoice_item_tx(tx, item).await?;
.bind(relay.push_enabled) }
.bind(&relay.id)
.execute(&mut **tx) mark_activities_billed_tx(tx, billed_activity_ids, now).await?;
.await?;
Ok(()) if let Some(anchor) = new_billing_anchor {
}) set_tenant_billing_anchor_tx(tx, tenant_pubkey, anchor).await?;
.await }
}
Ok(invoice)
pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> { })
self.set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay") .await
.await }
}
pub async fn mark_invoice_paid(invoice_id: &str, method: &str) -> Result<()> {
pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()> { let updated_at = chrono::Utc::now().timestamp();
self.set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent")
.await let activity = with_tx(async |tx| {
} sqlx::query("UPDATE invoice SET status = 'paid', method = ?, updated_at = ? WHERE id = ?")
.bind(method)
pub async fn activate_relay(&self, relay: &Relay) -> Result<()> { .bind(updated_at)
self.set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay") .bind(invoice_id)
.await .execute(&mut **tx)
} .await?;
insert_activity_tx(tx, "invoice_paid", "invoice", invoice_id).await
async fn set_relay_status( })
&self, .await?;
relay_id: &str, publish(activity);
status: &str, Ok(())
activity_type: &str, }
) -> Result<()> {
self.with_activity(activity_type, "relay", relay_id, async |tx| { // --- Bolt11 records ---
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
.bind(status) pub async fn insert_bolt11(
.bind(relay_id) invoice_id: &str,
.execute(&mut **tx) lnbc: &str,
.await?; msats: i64,
Ok(()) expires_at: i64,
}) ) -> Result<Option<Bolt11>> {
.await let id = uuid::Uuid::new_v4().to_string();
} let created_at = chrono::Utc::now().timestamp();
pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> { Ok(sqlx::query_as::<_, Bolt11>(
self.with_activity("fail_relay_sync", "relay", &relay.id, async |tx| { "INSERT INTO bolt11 (id, invoice_id, lnbc, msats, created_at, expires_at)
sqlx::query("UPDATE relay SET synced = 0, sync_error = ? WHERE id = ?") VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
.bind(&sync_error) )
.bind(&relay.id) .bind(id)
.execute(&mut **tx) .bind(invoice_id)
.await?; .bind(lnbc)
Ok(()) .bind(msats)
}) .bind(created_at)
.await .bind(expires_at)
} .fetch_optional(pool())
.await?)
pub async fn complete_relay_sync(&self, relay_id: &str) -> Result<()> { }
self.with_activity("complete_relay_sync", "relay", relay_id, async |tx| {
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?") pub async fn mark_bolt11_settled(bolt11_id: &str) -> Result<()> {
.bind(relay_id) let settled_at = chrono::Utc::now().timestamp();
.execute(&mut **tx)
.await?; sqlx::query("UPDATE bolt11 SET settled_at = ? WHERE id = ?")
Ok(()) .bind(settled_at)
}) .bind(bolt11_id)
.await .execute(pool())
} .await?;
Ok(())
// Invoices }
/// Upsert the pending bolt11 for an invoice, returning the resulting row. On // --- Intents ---
/// conflict the stored bolt11/expiry are replaced — this is how an expired
/// invoice is regenerated — except once the invoice is paid, when the /// Record the Stripe PaymentIntent that paid an invoice. Keyed by the Stripe
/// `status = 'pending'` guard makes the update a no-op and `None` is /// PaymentIntent id, so it's idempotent: a retried (idempotent) charge returns
/// returned so the caller can fall back to reading the settled row. /// the same id and the re-insert is a no-op rather than a primary-key conflict.
pub async fn insert_lightning_invoice( pub async fn insert_intent(intent_id: &str, invoice_id: &str) -> Result<()> {
&self, let created_at = chrono::Utc::now().timestamp();
stripe_invoice_id: &str,
tenant_pubkey: &str, sqlx::query(
bolt11: &str, "INSERT INTO intent (id, invoice_id, created_at)
expires_at: i64, VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING",
) -> Result<Option<LightningInvoice>> { )
let now = chrono::Utc::now().timestamp(); .bind(intent_id)
let row = sqlx::query_as::<_, LightningInvoice>( .bind(invoice_id)
"INSERT INTO lightning_invoice .bind(created_at)
(stripe_invoice_id, tenant_pubkey, bolt11, status, expires_at, created_at, updated_at) .execute(pool())
VALUES (?, ?, ?, 'pending', ?, ?, ?) .await?;
ON CONFLICT(stripe_invoice_id) DO UPDATE SET Ok(())
bolt11 = excluded.bolt11, }
expires_at = excluded.expires_at,
updated_at = excluded.updated_at // --- Internal utils that take an explicit transaction ---
WHERE status = 'pending'
RETURNING *", async fn insert_activity_tx(
) tx: &mut Transaction<'_, Sqlite>,
.bind(stripe_invoice_id) activity_type: &str,
.bind(tenant_pubkey) resource_type: &str,
.bind(bolt11) resource_id: &str,
.bind(expires_at) ) -> Result<Activity> {
.bind(now) let tenant = match resource_type {
.bind(now) "tenant" => resource_id.to_string(),
.fetch_optional(&self.pool) "relay" => {
.await?; sqlx::query_scalar::<_, String>("SELECT tenant FROM relay WHERE id = ?")
.bind(resource_id)
Ok(row) .fetch_one(&mut **tx)
} .await?
}
/// Mark a pending invoice paid, recording which method settled it. The _ => anyhow::bail!("unknown resource_type: {resource_type}"),
/// `status = 'pending'` guard makes this idempotent and first-writer-wins: };
/// a later reconcile won't clobber the method recorded by whoever settled
/// it first. let id = uuid::Uuid::new_v4().to_string();
pub async fn mark_lightning_invoice_paid(&self, stripe_invoice_id: &str, method: &str) -> Result<()> { let created_at = chrono::Utc::now().timestamp();
let now = chrono::Utc::now().timestamp();
sqlx::query( sqlx::query(
"UPDATE lightning_invoice "INSERT INTO activity (id, tenant, created_at, activity_type, resource_type, resource_id)
SET status = 'paid', paid_method = ?, updated_at = ? VALUES (?, ?, ?, ?, ?, ?)",
WHERE stripe_invoice_id = ? AND status = 'pending'", )
) .bind(&id)
.bind(method) .bind(&tenant)
.bind(now) .bind(created_at)
.bind(stripe_invoice_id) .bind(activity_type)
.execute(&self.pool) .bind(resource_type)
.await?; .bind(resource_id)
.execute(&mut **tx)
Ok(()) .await?;
}
Ok(Activity {
id,
tenant,
created_at,
activity_type: activity_type.to_string(),
resource_type: resource_type.to_string(),
resource_id: resource_id.to_string(),
billed_at: None,
})
}
async fn insert_invoice_tx(
tx: &mut Transaction<'_, Sqlite>,
invoice_id: &str,
tenant_pubkey: &str,
period_start: i64,
period_end: i64,
) -> Result<Invoice> {
let now = chrono::Utc::now().timestamp();
Ok(sqlx::query_as::<_, Invoice>(
"INSERT INTO invoice (id, tenant_pubkey, status, period_start, period_end, created_at, updated_at)
VALUES (?, ?, 'open', ?, ?, ?, ?) RETURNING *",
)
.bind(invoice_id)
.bind(tenant_pubkey)
.bind(period_start)
.bind(period_end)
.bind(now)
.bind(now)
.fetch_one(&mut **tx)
.await?)
}
async fn insert_invoice_item_tx(tx: &mut Transaction<'_, Sqlite>, item: &InvoiceItem) -> Result<()> {
sqlx::query(
"INSERT INTO invoice_item
(id, invoice_id, activity_id, tenant_pubkey, relay_id, plan, amount, description, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&item.id)
.bind(&item.invoice_id)
.bind(&item.activity_id)
.bind(&item.tenant_pubkey)
.bind(&item.relay_id)
.bind(&item.plan)
.bind(item.amount)
.bind(&item.description)
.bind(item.created_at)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn mark_activities_billed_tx(
tx: &mut Transaction<'_, Sqlite>,
activity_ids: &[String],
billed_at: i64,
) -> Result<()> {
for id in activity_ids {
sqlx::query("UPDATE activity SET billed_at = ? WHERE id = ?")
.bind(billed_at)
.bind(id)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn set_tenant_billing_anchor_tx(
tx: &mut Transaction<'_, Sqlite>,
tenant_pubkey: &str,
billing_anchor: i64,
) -> Result<()> {
sqlx::query("UPDATE tenant SET billing_anchor = ? WHERE pubkey = ?")
.bind(billing_anchor)
.bind(tenant_pubkey)
.execute(&mut **tx)
.await?;
Ok(())
} }
+108
View File
@@ -0,0 +1,108 @@
use std::path::Path;
use std::str::FromStr;
use std::sync::OnceLock;
use anyhow::Result;
use sqlx::{
Sqlite, SqlitePool, Transaction,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
use tokio::sync::broadcast;
use crate::env;
use crate::models::Activity;
/// Process-wide connection pool. Set once at startup via [`init`]; read
/// everywhere else via [`pool`], so command/query stay free functions instead of
/// threading a handle through every service.
static POOL: OnceLock<SqlitePool> = OnceLock::new();
/// Process-wide activity broadcast. Mutations record an [`Activity`] and call
/// [`publish`] after their transaction commits; reactors (billing, infra)
/// [`subscribe`] to react to durable changes.
static NOTIFY: OnceLock<broadcast::Sender<Activity>> = OnceLock::new();
/// Create the connection pool from `env`, run migrations, and store it as the
/// process-wide global. Panics if called more than once.
pub async fn init() -> Result<()> {
let pool = create_pool(&env::get().database_url).await?;
POOL.set(pool).expect("pool already initialized");
let (notify, _) = broadcast::channel(64);
NOTIFY.set(notify).expect("notify already initialized");
Ok(())
}
/// The global pool. Panics if [`init`] hasn't run yet.
pub fn pool() -> &'static SqlitePool {
POOL.get().expect("pool not initialized")
}
/// Subscribe to the activity stream. Panics if [`init`] hasn't run yet.
pub fn subscribe() -> broadcast::Receiver<Activity> {
NOTIFY.get().expect("notify not initialized").subscribe()
}
/// Broadcast an activity to subscribers. Called after the writing transaction
/// commits, so reactors only ever observe durable rows. A send with no current
/// subscribers is intentionally ignored.
pub fn publish(activity: Activity) {
if let Some(notify) = NOTIFY.get() {
let _ = notify.send(activity);
}
}
/// Run `f` inside a transaction, commit on success, and roll back (on drop) if
/// it returns an error. Returns whatever `f` produces. Callers compose the
/// transaction-scoped `command`/`query` functions inside `f` to make a
/// multi-step write atomic.
pub async fn with_tx<F, T>(f: F) -> Result<T>
where
F: AsyncFnOnce(&mut Transaction<'_, Sqlite>) -> Result<T>,
{
let mut tx = pool().begin().await?;
let value = f(&mut tx).await?;
tx.commit().await?;
Ok(value)
}
async fn create_pool(database_url: &str) -> Result<SqlitePool> {
let database_url = normalize_sqlite_url(database_url);
if let Some(path) = database_url.strip_prefix("sqlite://")
&& !path.is_empty()
&& path != ":memory:"
&& let Some(parent) = Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(connect_options)
.await?;
sqlx::query("PRAGMA journal_mode = WAL;")
.execute(&pool)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
fn normalize_sqlite_url(url: &str) -> String {
let Some(path) = url.strip_prefix("sqlite://") else {
return url.to_string();
};
if path.is_empty() || path == ":memory:" || Path::new(path).is_absolute() {
return url.to_string();
}
format!("sqlite://{}/{}", env!("CARGO_MANIFEST_DIR"), path)
}
+19 -7
View File
@@ -1,6 +1,24 @@
use std::sync::OnceLock;
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
/// Process-wide configuration, loaded once from the environment at startup via
/// [`init`] and read everywhere else via [`get`].
static ENV: OnceLock<Env> = OnceLock::new();
/// Load configuration from the environment and store it as the global. Panics
/// if a required variable is missing or if called more than once.
pub fn init() {
ENV.set(Env::load())
.unwrap_or_else(|_| panic!("env already initialized"));
}
/// The global configuration. Panics if [`init`] hasn't run yet.
pub fn get() -> &'static Env {
ENV.get().expect("env not initialized")
}
#[derive(Clone)] #[derive(Clone)]
pub struct Env { pub struct Env {
pub server_host: String, pub server_host: String,
@@ -27,15 +45,12 @@ pub struct Env {
pub livekit_api_key: String, pub livekit_api_key: String,
pub livekit_api_secret: String, pub livekit_api_secret: String,
pub stripe_secret_key: String, pub stripe_secret_key: String,
pub stripe_webhook_secret: String,
pub stripe_price_basic: String,
pub stripe_price_growth: String,
/// Parsed from `robot_secret`; used for nostr signing and nip44 encryption. /// Parsed from `robot_secret`; used for nostr signing and nip44 encryption.
pub keys: Keys, pub keys: Keys,
} }
impl Env { impl Env {
pub fn load() -> Self { fn load() -> Self {
let keys = Keys::parse(&require_str("ROBOT_SECRET")) let keys = Keys::parse(&require_str("ROBOT_SECRET"))
.expect("ROBOT_SECRET is not a valid nostr secret key"); .expect("ROBOT_SECRET is not a valid nostr secret key");
@@ -64,9 +79,6 @@ impl Env {
livekit_api_key: require_str("LIVEKIT_API_KEY"), livekit_api_key: require_str("LIVEKIT_API_KEY"),
livekit_api_secret: require_str("LIVEKIT_API_SECRET"), livekit_api_secret: require_str("LIVEKIT_API_SECRET"),
stripe_secret_key: require_str("STRIPE_SECRET_KEY"), stripe_secret_key: require_str("STRIPE_SECRET_KEY"),
stripe_webhook_secret: require_str("STRIPE_WEBHOOK_SECRET"),
stripe_price_basic: require_str("STRIPE_PRICE_BASIC"),
stripe_price_growth: require_str("STRIPE_PRICE_GROWTH"),
keys, keys,
} }
} }
+26 -35
View File
@@ -2,33 +2,26 @@ use anyhow::Result;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use std::time::Duration; use std::time::Duration;
use crate::command::Command; use crate::command;
use crate::env::Env; use crate::db;
use crate::env;
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay}; use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
use crate::query::Query; use crate::query;
const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30; const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30;
const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60; const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60;
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6; const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
#[derive(Clone)] #[derive(Clone)]
pub struct Infra { pub struct Infra;
env: Env,
query: Query,
command: Command,
}
impl Infra { impl Infra {
pub fn new(query: Query, command: Command, env: &Env) -> Self { pub fn new() -> Self {
Self { Self
env: env.clone(),
query,
command,
}
} }
pub async fn start(self) { pub async fn start(self) {
let mut rx = self.command.notify.subscribe(); let mut rx = db::subscribe();
if let Err(error) = self.reconcile_relay_state("startup").await { if let Err(error) = self.reconcile_relay_state("startup").await {
tracing::error!(error = %error, "failed to reconcile relay state on startup"); tracing::error!(error = %error, "failed to reconcile relay state on startup");
@@ -68,7 +61,7 @@ impl Infra {
return Ok(()); return Ok(());
} }
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else { let Some(relay) = query::get_relay(&activity.resource_id).await? else {
return Ok(()); return Ok(());
}; };
@@ -77,7 +70,7 @@ impl Infra {
} }
async fn reconcile_relay_state(&self, source: &str) -> Result<()> { async fn reconcile_relay_state(&self, source: &str) -> Result<()> {
let relays = self.query.list_relays_pending_sync().await?; let relays = query::list_relays_pending_sync().await?;
if relays.is_empty() { if relays.is_empty() {
return Ok(()); return Ok(());
@@ -112,7 +105,7 @@ impl Infra {
Some(Duration::from_secs(delay_secs)) Some(Duration::from_secs(delay_secs))
} }
let activities = self.query.list_activity_for_resource(relay_id).await?; let activities = query::list_activity_for_resource(relay_id).await?;
let consecutive_failures = activities let consecutive_failures = activities
.iter() .iter()
.take_while(|activity| activity.activity_type == "fail_relay_sync") .take_while(|activity| activity.activity_type == "fail_relay_sync")
@@ -142,7 +135,7 @@ impl Infra {
tokio::spawn(async move { tokio::spawn(async move {
tokio::time::sleep(delay).await; tokio::time::sleep(delay).await;
match infra.query.get_relay(&relay_id).await { match query::get_relay(&relay_id).await {
Ok(Some(relay)) => infra.sync_relay(&relay).await, Ok(Some(relay)) => infra.sync_relay(&relay).await,
Ok(None) => {} Ok(None) => {}
Err(e) => { Err(e) => {
@@ -158,13 +151,13 @@ impl Infra {
match self.try_sync_relay(relay).await { match self.try_sync_relay(relay).await {
Ok(()) => { Ok(()) => {
tracing::info!(relay = %relay.id, "relay sync succeeded"); tracing::info!(relay = %relay.id, "relay sync succeeded");
if let Err(e) = self.command.complete_relay_sync(&relay.id).await { if let Err(e) = command::complete_relay_sync(&relay.id).await {
tracing::error!(relay = %relay.id, error = %e, "failed to mark sync complete"); tracing::error!(relay = %relay.id, error = %e, "failed to mark sync complete");
} }
} }
Err(e) => { Err(e) => {
tracing::warn!(relay = %relay.id, error = %e, "relay sync failed"); tracing::warn!(relay = %relay.id, error = %e, "relay sync failed");
if let Err(e2) = self.command.fail_relay_sync(relay, e.to_string()).await { if let Err(e2) = command::fail_relay_sync(relay, e.to_string()).await {
tracing::error!(relay = %relay.id, error = %e2, "failed to record sync failure"); tracing::error!(relay = %relay.id, error = %e2, "failed to record sync failure");
} }
} }
@@ -177,14 +170,12 @@ impl Infra {
// otherwise check the activity history so that a re-sync after an update // otherwise check the activity history so that a re-sync after an update
// (which resets `synced` to 0) PATCHes instead of clobbering the secret. // (which resets `synced` to 0) PATCHes instead of clobbering the secret.
let is_new = relay.synced != 1 let is_new = relay.synced != 1
&& self && query::get_latest_activity_for_resource_and_type(&relay.id, "complete_relay_sync")
.query
.get_latest_activity_for_resource_and_type(&relay.id, "complete_relay_sync")
.await? .await?
.is_none(); .is_none();
let mut body = serde_json::json!({ let mut body = serde_json::json!({
"host": format!("{}.{}", relay.subdomain, self.env.relay_domain), "host": format!("{}.{}", relay.subdomain, env::get().relay_domain),
"schema": relay.id, "schema": relay.id,
"inactive": relay.status == RELAY_STATUS_INACTIVE "inactive": relay.status == RELAY_STATUS_INACTIVE
|| relay.status == RELAY_STATUS_DELINQUENT, || relay.status == RELAY_STATUS_DELINQUENT,
@@ -205,11 +196,11 @@ impl Infra {
"enabled": true, "enabled": true,
"adapter": "s3", "adapter": "s3",
"s3": { "s3": {
"endpoint": self.env.blossom_s3_endpoint, "endpoint": env::get().blossom_s3_endpoint,
"region": self.env.blossom_s3_region, "region": env::get().blossom_s3_region,
"bucket": self.env.blossom_s3_bucket, "bucket": env::get().blossom_s3_bucket,
"access_key": self.env.blossom_s3_access_key, "access_key": env::get().blossom_s3_access_key,
"secret_key": self.env.blossom_s3_secret_key, "secret_key": env::get().blossom_s3_secret_key,
"key_prefix": relay.id, "key_prefix": relay.id,
}, },
}) })
@@ -219,9 +210,9 @@ impl Infra {
"livekit": if relay.livekit_enabled == 1 { "livekit": if relay.livekit_enabled == 1 {
serde_json::json!({ serde_json::json!({
"enabled": true, "enabled": true,
"server_url": self.env.livekit_url, "server_url": env::get().livekit_url,
"api_key": self.env.livekit_api_key, "api_key": env::get().livekit_api_key,
"api_secret": self.env.livekit_api_secret, "api_secret": env::get().livekit_api_secret,
}) })
} else { } else {
serde_json::json!({ "enabled": false }) serde_json::json!({ "enabled": false })
@@ -274,10 +265,10 @@ impl Infra {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(5))
.build()?; .build()?;
let base = self.env.zooid_api_url.trim_end_matches('/'); let base = env::get().zooid_api_url.trim_end_matches('/');
let path = path.trim_start_matches('/'); let path = path.trim_start_matches('/');
let url = format!("{base}/{path}"); let url = format!("{base}/{path}");
let auth = self.env.make_auth(&url, method).await?; let auth = env::get().make_auth(&url, method).await?;
let reqwest_method = match method { let reqwest_method = match method {
HttpMethod::GET => reqwest::Method::GET, HttpMethod::GET => reqwest::Method::GET,
+1 -1
View File
@@ -5,7 +5,7 @@ pub mod command;
pub mod env; pub mod env;
pub mod infra; pub mod infra;
pub mod models; pub mod models;
pub mod pool; pub mod db;
pub mod query; pub mod query;
pub mod robot; pub mod robot;
pub mod routes; pub mod routes;
+16 -15
View File
@@ -5,7 +5,7 @@ mod command;
mod env; mod env;
mod infra; mod infra;
mod models; mod models;
mod pool; mod db;
mod query; mod query;
mod robot; mod robot;
mod routes; mod routes;
@@ -20,10 +20,7 @@ use tower_http::cors::{AllowOrigin, CorsLayer, Any};
use crate::api::Api; use crate::api::Api;
use crate::billing::Billing; use crate::billing::Billing;
use crate::command::Command;
use crate::env::Env;
use crate::infra::Infra; use crate::infra::Infra;
use crate::query::Query;
use crate::robot::Robot; use crate::robot::Robot;
use crate::stripe::Stripe; use crate::stripe::Stripe;
@@ -36,18 +33,17 @@ async fn main() -> Result<()> {
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
let env = Env::load(); env::init();
let pool = pool::create_pool(&env.database_url).await?; db::init().await?;
let robot = Robot::new(&env).await?;
let stripe = Stripe::new(&env);
let query = Query::new(pool.clone(), &env);
let command = Command::new(pool);
let billing = Billing::new(query.clone(), command.clone(), &env);
let infra = Infra::new(query.clone(), command.clone(), &env);
let api = Api::new(query, command, billing.clone(), stripe, robot, infra.clone(), &env);
let parsed = env let robot = Robot::new().await?;
let stripe = Stripe::new();
let infra = Infra::new();
let billing = Billing::new(robot.clone());
let api = Api::new(billing.clone(), stripe, robot, infra.clone());
let parsed = env::get()
.server_allow_origins .server_allow_origins
.iter() .iter()
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok()) .filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
@@ -68,7 +64,12 @@ async fn main() -> Result<()> {
}); });
let listener = let listener =
tokio::net::TcpListener::bind(format!("{}:{}", env.server_host, env.server_port)).await?; tokio::net::TcpListener::bind(format!(
"{}:{}",
env::get().server_host,
env::get().server_port
))
.await?;
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
Ok(()) Ok(())
} }
+45 -15
View File
@@ -12,6 +12,7 @@ pub struct Activity {
pub activity_type: String, pub activity_type: String,
pub resource_type: String, pub resource_type: String,
pub resource_id: String, pub resource_id: String,
pub billed_at: Option<i64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -22,7 +23,6 @@ pub struct Plan {
pub members: Option<i64>, pub members: Option<i64>,
pub blossom: bool, pub blossom: bool,
pub livekit: bool, pub livekit: bool,
pub stripe_price_id: Option<String>,
} }
#[derive(Debug, Default, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Default, Clone, Serialize, Deserialize, sqlx::FromRow)]
@@ -31,21 +31,8 @@ pub struct Tenant {
pub nwc_url: String, pub nwc_url: String,
pub nwc_error: Option<String>, pub nwc_error: Option<String>,
pub created_at: i64, pub created_at: i64,
pub billing_anchor: Option<i64>,
pub stripe_customer_id: String, pub stripe_customer_id: String,
pub stripe_subscription_id: Option<String>,
pub past_due_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct LightningInvoice {
pub stripe_invoice_id: String,
pub tenant_pubkey: String,
pub bolt11: String,
pub status: String,
pub paid_method: Option<String>,
pub expires_at: i64,
pub created_at: i64,
pub updated_at: i64,
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
@@ -92,3 +79,46 @@ impl Default for Relay {
} }
} }
} }
#[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,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct InvoiceItem {
pub id: String,
pub invoice_id: String,
pub activity_id: String,
pub tenant_pubkey: String,
pub relay_id: String,
pub plan: String,
pub amount: i64,
pub description: String,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Bolt11 {
pub id: String,
pub invoice_id: String,
pub lnbc: String,
pub msats: i64,
pub created_at: i64,
pub expires_at: i64,
pub settled_at: Option<i64>,
}
#[allow(dead_code)] // backs the `intent` table for the (not yet implemented) Stripe intent flow
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Intent {
pub id: String,
pub invoice_id: String,
pub created_at: i64,
}
-48
View File
@@ -1,48 +0,0 @@
use std::path::Path;
use std::str::FromStr;
use anyhow::Result;
use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
pub async fn create_pool(database_url: &str) -> Result<SqlitePool> {
let database_url = normalize_sqlite_url(database_url);
if let Some(path) = database_url.strip_prefix("sqlite://")
&& !path.is_empty()
&& path != ":memory:"
&& let Some(parent) = Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(connect_options)
.await?;
sqlx::query("PRAGMA journal_mode = WAL;")
.execute(&pool)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
fn normalize_sqlite_url(url: &str) -> String {
let Some(path) = url.strip_prefix("sqlite://") else {
return url.to_string();
};
if path.is_empty() || path == ":memory:" || Path::new(path).is_absolute() {
return url.to_string();
}
format!("sqlite://{}/{}", env!("CARGO_MANIFEST_DIR"), path)
}
+164 -158
View File
@@ -1,8 +1,7 @@
use anyhow::Result; use anyhow::Result;
use sqlx::SqlitePool;
use crate::env::Env; use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Plan, Relay, Tenant};
use crate::models::{Activity, LightningInvoice, Plan, Relay, Tenant}; use crate::db::pool;
fn select_tenant(tail: &str) -> String { fn select_tenant(tail: &str) -> String {
format!("SELECT * FROM tenant {tail}") format!("SELECT * FROM tenant {tail}")
@@ -16,161 +15,168 @@ fn select_activity(tail: &str) -> String {
format!("SELECT * FROM activity {tail}") format!("SELECT * FROM activity {tail}")
} }
#[derive(Clone)] // Plans
pub struct Query {
pool: SqlitePool, pub fn list_plans() -> Vec<Plan> {
env: Env, vec![
Plan {
id: "free".to_string(),
name: "Free".to_string(),
amount: 0,
members: Some(10),
blossom: false,
livekit: false,
},
Plan {
id: "basic".to_string(),
name: "Basic".to_string(),
amount: 500,
members: Some(100),
blossom: true,
livekit: true,
},
Plan {
id: "growth".to_string(),
name: "Growth".to_string(),
amount: 2500,
members: None,
blossom: true,
livekit: true,
},
]
} }
impl Query { pub fn get_plan(plan_id: &str) -> Option<Plan> {
pub fn new(pool: SqlitePool, env: &Env) -> Self { list_plans().into_iter().find(|p| p.id == plan_id)
Self { }
pool,
env: env.clone(), // Tenants
}
} pub async fn list_tenants() -> Result<Vec<Tenant>> {
Ok(sqlx::query_as::<_, Tenant>(&select_tenant(""))
// Plans .fetch_all(pool())
.await?)
pub fn list_plans(&self) -> Vec<Plan> { }
vec![
Plan { pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
id: "free".to_string(), Ok(sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
name: "Free".to_string(), .bind(pubkey)
amount: 0, .fetch_optional(pool())
members: Some(10), .await?)
blossom: false, }
livekit: false,
stripe_price_id: None, // Relays
},
Plan { pub async fn list_relays() -> Result<Vec<Relay>> {
id: "basic".to_string(), Ok(sqlx::query_as::<_, Relay>(&select_relay(""))
name: "Basic".to_string(), .fetch_all(pool())
amount: 500, .await?)
members: Some(100), }
blossom: true,
livekit: true, pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
stripe_price_id: Some(self.env.stripe_price_basic.clone()), Ok(
}, sqlx::query_as::<_, Relay>(&select_relay("WHERE synced = 0 OR TRIM(sync_error) != ''"))
Plan { .fetch_all(pool())
id: "growth".to_string(), .await?,
name: "Growth".to_string(), )
amount: 2500, }
members: None,
blossom: true, pub async fn list_relays_for_tenant(tenant_id: &str) -> Result<Vec<Relay>> {
livekit: true, Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant = ?"))
stripe_price_id: Some(self.env.stripe_price_growth.clone()), .bind(tenant_id)
}, .fetch_all(pool())
] .await?)
} }
pub fn get_plan(&self, plan_id: &str) -> Option<Plan> { pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
self.list_plans().into_iter().find(|p| p.id == plan_id) Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE id = ?"))
} .bind(id)
.fetch_optional(pool())
pub fn is_paid_plan(&self, plan_id: &str) -> bool { .await?)
self.get_plan(plan_id).is_some_and(|p| p.amount > 0) }
}
// Invoices
// Tenants
pub async fn get_invoice(invoice_id: &str) -> Result<Option<Invoice>> {
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> { Ok(sqlx::query_as::<_, Invoice>("SELECT * FROM invoice WHERE id = ?")
let rows = sqlx::query_as::<_, Tenant>(&select_tenant("")) .bind(invoice_id)
.fetch_all(&self.pool) .fetch_optional(pool())
.await?; .await?)
Ok(rows) }
}
pub async fn list_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> { Ok(sqlx::query_as::<_, Invoice>(
let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?")) "SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC",
.bind(pubkey) )
.fetch_optional(&self.pool) .bind(tenant_pubkey)
.await?; .fetch_all(pool())
Ok(row) .await?)
} }
pub async fn get_tenant_by_stripe_customer_id( pub async fn get_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<InvoiceItem>> {
&self, Ok(
stripe_customer_id: &str, sqlx::query_as::<_, InvoiceItem>("SELECT * FROM invoice_item WHERE invoice_id = ?")
) -> Result<Option<Tenant>> { .bind(invoice_id)
let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE stripe_customer_id = ?")) .fetch_all(pool())
.bind(stripe_customer_id) .await?,
.fetch_optional(&self.pool) )
.await?; }
Ok(row)
} pub async fn get_bolt11(bolt11_id: &str) -> Result<Option<Bolt11>> {
Ok(sqlx::query_as::<_, Bolt11>("SELECT * FROM bolt11 WHERE id = ?")
// Relays .bind(bolt11_id)
.fetch_optional(pool())
pub async fn list_relays(&self) -> Result<Vec<Relay>> { .await?)
let rows = sqlx::query_as::<_, Relay>(&select_relay("")) }
.fetch_all(&self.pool)
.await?; pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result<Option<Bolt11>> {
Ok(rows) Ok(sqlx::query_as::<_, Bolt11>(
} "SELECT * FROM bolt11 WHERE invoice_id = ? ORDER BY created_at DESC LIMIT 1",
)
pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>> { .bind(invoice_id)
let rows = sqlx::query_as::<_, Relay>(&select_relay( .fetch_optional(pool())
"WHERE synced = 0 OR TRIM(sync_error) != ''", .await?)
)) }
.fetch_all(&self.pool)
.await?; // Activity
Ok(rows)
} /// Billable activity for a tenant not yet folded into an invoice. The
/// activity-type filter and the `billed_at IS NULL` guard live here so the
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> { /// caller reconciles off a precise marker rather than a timestamp watermark.
let rows = sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant = ?")) /// Ordered oldest-first so line items and proration apply in event order.
.bind(tenant_id) pub async fn list_billable_activity_for_tenant(tenant_pubkey: &str) -> Result<Vec<Activity>> {
.fetch_all(&self.pool) Ok(sqlx::query_as::<_, Activity>(&select_activity(
.await?; "WHERE tenant = ?
Ok(rows) AND billed_at IS NULL
} AND activity_type IN (
'create_relay', 'update_relay', 'activate_relay',
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> { 'deactivate_relay', 'autogenerate_invoice'
let row = sqlx::query_as::<_, Relay>(&select_relay("WHERE id = ?")) )
.bind(id) ORDER BY created_at ASC",
.fetch_optional(&self.pool) ))
.await?; .bind(tenant_pubkey)
Ok(row) .fetch_all(pool())
} .await?)
}
// Invoices
pub async fn list_activity_for_resource(resource_id: &str) -> Result<Vec<Activity>> {
pub async fn get_lightning_invoice( Ok(sqlx::query_as::<_, Activity>(&select_activity(
&self, "WHERE resource_id = ? ORDER BY created_at DESC",
stripe_invoice_id: &str, ))
) -> Result<Option<LightningInvoice>> { .bind(resource_id)
let row = sqlx::query_as::<_, LightningInvoice>( .fetch_all(pool())
"SELECT * FROM lightning_invoice WHERE stripe_invoice_id = ?", .await?)
) }
.bind(stripe_invoice_id)
.fetch_optional(&self.pool) pub async fn get_latest_activity_for_resource_and_type(
.await?; resource_id: &str,
Ok(row) activity_type: &str,
} ) -> Result<Option<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
// Activity "WHERE resource_id = ? AND activity_type = ? ORDER BY created_at DESC LIMIT 1",
))
pub async fn list_activity_for_resource(&self, resource_id: &str) -> Result<Vec<Activity>> { .bind(resource_id)
let rows = sqlx::query_as::<_, Activity>(&select_activity("WHERE resource_id = ? ORDER BY created_at DESC")) .bind(activity_type)
.bind(resource_id) .fetch_optional(pool())
.fetch_all(&self.pool) .await?)
.await?;
Ok(rows)
}
pub async fn get_latest_activity_for_resource_and_type(
&self,
resource_id: &str,
activity_type: &str,
) -> Result<Option<Activity>> {
let row = sqlx::query_as::<_, Activity>(&select_activity(
"WHERE resource_id = ? AND activity_type = ? ORDER BY created_at DESC LIMIT 1",
))
.bind(resource_id)
.bind(activity_type)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
} }
+15 -17
View File
@@ -5,11 +5,10 @@ use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::env::Env; use crate::env;
#[derive(Clone)] #[derive(Clone)]
pub struct Robot { pub struct Robot {
env: Env,
outbox_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>, outbox_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
dm_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>, dm_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
} }
@@ -21,9 +20,8 @@ struct CacheEntry {
} }
impl Robot { impl Robot {
pub async fn new(env: &Env) -> Result<Self> { pub async fn new() -> Result<Self> {
let robot = Self { let robot = Self {
env: env.clone(),
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())), outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())), dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
}; };
@@ -33,7 +31,7 @@ impl Robot {
} }
async fn make_client(&self, relays: &[String]) -> Result<Client> { async fn make_client(&self, relays: &[String]) -> Result<Client> {
let client = Client::new(self.env.keys.clone()); let client = Client::new(env::get().keys.clone());
for relay in relays { for relay in relays {
client.add_relay(relay).await?; client.add_relay(relay).await?;
} }
@@ -46,24 +44,24 @@ impl Robot {
&self, &self,
) -> Result<()> { ) -> Result<()> {
let mut metadata = Metadata::new(); let mut metadata = Metadata::new();
if !self.env.robot_name.is_empty() { if !env::get().robot_name.is_empty() {
metadata = metadata.name(&self.env.robot_name); metadata = metadata.name(&env::get().robot_name);
} }
if !self.env.robot_description.is_empty() { if !env::get().robot_description.is_empty() {
metadata = metadata.about(&self.env.robot_description); metadata = metadata.about(&env::get().robot_description);
} }
if !self.env.robot_picture.is_empty() { if !env::get().robot_picture.is_empty() {
metadata = metadata.picture(Url::parse(&self.env.robot_picture)?); metadata = metadata.picture(Url::parse(&env::get().robot_picture)?);
} }
let outbox_client = self.make_client(&self.env.robot_outbox_relays).await?; let outbox_client = self.make_client(&env::get().robot_outbox_relays).await?;
let indexer_client = self.make_client(&self.env.robot_indexer_relays).await?; let indexer_client = self.make_client(&env::get().robot_indexer_relays).await?;
outbox_client outbox_client
.send_event_builder(EventBuilder::metadata(&metadata)) .send_event_builder(EventBuilder::metadata(&metadata))
.await?; .await?;
let outbox_tags = self.env.robot_outbox_relays let outbox_tags = env::get().robot_outbox_relays
.iter() .iter()
.map(|r| Tag::parse(["r", r.as_str()])) .map(|r| Tag::parse(["r", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?; .collect::<std::result::Result<Vec<_>, _>>()?;
@@ -71,7 +69,7 @@ impl Robot {
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags)) .send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
.await?; .await?;
let messaging_tags = self.env.robot_messaging_relays let messaging_tags = env::get().robot_messaging_relays
.iter() .iter()
.map(|r| Tag::parse(["relay", r.as_str()])) .map(|r| Tag::parse(["relay", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?; .collect::<std::result::Result<Vec<_>, _>>()?;
@@ -108,7 +106,7 @@ impl Robot {
let pubkey = PublicKey::parse(recipient)?; let pubkey = PublicKey::parse(recipient)?;
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002)); let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002));
let client = self.make_client(&self.env.robot_indexer_relays).await?; let client = self.make_client(&env::get().robot_indexer_relays).await?;
let events = client.fetch_events(filter, Duration::from_secs(5)).await?; let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
let mut relays = Vec::new(); let mut relays = Vec::new();
@@ -128,7 +126,7 @@ impl Robot {
pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String> { pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String> {
let pubkey = PublicKey::parse(pubkey).ok()?; let pubkey = PublicKey::parse(pubkey).ok()?;
let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1); let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1);
let client = self.make_client(&self.env.robot_indexer_relays).await.ok()?; let client = self.make_client(&env::get().robot_indexer_relays).await.ok()?;
let events = client.fetch_events(filter, Duration::from_secs(5)).await.ok()?; let events = client.fetch_events(filter, Duration::from_secs(5)).await.ok()?;
let event = events.into_iter().max_by_key(|e| e.created_at)?; let event = events.into_iter().max_by_key(|e| e.created_at)?;
let content: serde_json::Value = serde_json::from_str(&event.content).ok()?; let content: serde_json::Value = serde_json::from_str(&event.content).ok()?;
+12 -55
View File
@@ -3,84 +3,41 @@ use std::sync::Arc;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use crate::api::{Api, AuthedPubkey}; use crate::api::{Api, AuthedPubkey};
use crate::query;
use crate::web::{ApiResult, internal, not_found, ok}; use crate::web::{ApiResult, internal, not_found, ok};
pub async fn list_tenant_invoices(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
let invoices = api
.stripe
.list_invoices(&tenant.stripe_customer_id)
.await
.map_err(internal)?;
ok(invoices)
}
pub async fn get_invoice( pub async fn get_invoice(
State(api): State<Arc<Api>>, State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey, AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>, Path(id): Path<String>,
) -> ApiResult { ) -> ApiResult {
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else { let invoice = query::get_invoice(&id)
return Err(not_found("invoice not found"));
};
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(&invoice.customer)
.await .await
.map_err(internal)? .map_err(internal)?
else { .ok_or_else(|| not_found("invoice not found"))?;
return Err(not_found("invoice not found"));
};
api.require_admin_or_tenant(&auth, &tenant.pubkey)?; api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
let invoice = api
.billing
.reconcile_invoice(&invoice)
.await
.map_err(internal)?;
ok(invoice) ok(invoice)
} }
pub async fn get_lightning_invoice( pub async fn get_invoice_bolt11(
State(api): State<Arc<Api>>, State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey, AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>, Path(invoice_id): Path<String>,
) -> ApiResult { ) -> ApiResult {
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else { let invoice = query::get_invoice(&invoice_id)
return Err(not_found("invoice not found"));
};
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(&invoice.customer)
.await .await
.map_err(internal)? .map_err(internal)?
else { .ok_or_else(|| not_found("invoice not found"))?;
return Err(not_found("invoice not found"));
};
api.require_admin_or_tenant(&auth, &tenant.pubkey)?; api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
let invoice = api let bolt11 = api
.billing .billing
.reconcile_invoice(&invoice) .ensure_and_reconcile_bolt11(&invoice_id)
.await .await
.map_err(internal)?; .map_err(internal)?;
let lightning_invoice = api ok(serde_json::json!(bolt11))
.billing
.ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency)
.await
.map_err(internal)?;
ok(serde_json::json!(lightning_invoice))
} }
-1
View File
@@ -2,5 +2,4 @@ pub mod identity;
pub mod invoices; pub mod invoices;
pub mod plans; pub mod plans;
pub mod relays; pub mod relays;
pub mod stripe;
pub mod tenants; pub mod tenants;
+5 -4
View File
@@ -3,14 +3,15 @@ use std::sync::Arc;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use crate::api::Api; use crate::api::Api;
use crate::query;
use crate::web::{ApiResult, not_found, ok}; use crate::web::{ApiResult, not_found, ok};
pub async fn list_plans(State(api): State<Arc<Api>>) -> ApiResult { pub async fn list_plans(State(_api): State<Arc<Api>>) -> ApiResult {
ok(api.query.list_plans()) ok(query::list_plans())
} }
pub async fn get_plan(State(api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult { pub async fn get_plan(State(_api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
match api.query.get_plan(&id) { match query::get_plan(&id) {
Some(plan) => ok(plan), Some(plan) => ok(plan),
None => Err(not_found("plan not found")), None => Err(not_found("plan not found")),
} }
+51 -54
View File
@@ -9,6 +9,7 @@ use regex::Regex;
use serde::Deserialize; use serde::Deserialize;
use crate::api::{Api, AuthedPubkey}; use crate::api::{Api, AuthedPubkey};
use crate::{command, query};
use crate::models::{ use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
}; };
@@ -17,46 +18,13 @@ use crate::web::{
parse_bool_default, unprocessable, parse_bool_default, unprocessable,
}; };
#[derive(Deserialize)]
pub struct CreateRelayRequest {
pub tenant: String,
pub subdomain: String,
pub plan: String,
pub info_name: String,
pub info_icon: String,
pub info_description: String,
pub policy_public_join: i64,
pub policy_strip_signatures: i64,
pub groups_enabled: i64,
pub management_enabled: i64,
pub blossom_enabled: i64,
pub livekit_enabled: i64,
pub push_enabled: i64,
}
#[derive(Deserialize)]
pub struct UpdateRelayRequest {
pub subdomain: Option<String>,
pub plan: Option<String>,
pub info_name: Option<String>,
pub info_icon: Option<String>,
pub info_description: Option<String>,
pub policy_public_join: Option<i64>,
pub policy_strip_signatures: Option<i64>,
pub groups_enabled: Option<i64>,
pub management_enabled: Option<i64>,
pub blossom_enabled: Option<i64>,
pub livekit_enabled: Option<i64>,
pub push_enabled: Option<i64>,
}
pub async fn list_relays( pub async fn list_relays(
State(api): State<Arc<Api>>, State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey, AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult { ) -> ApiResult {
api.require_admin(&auth)?; api.require_admin(&auth)?;
let relays = api.query.list_relays().await.map_err(internal)?; let relays = query::list_relays().await.map_err(internal)?;
ok(relays) ok(relays)
} }
@@ -78,9 +46,7 @@ pub async fn list_relay_activity(
let relay = api.get_relay_or_404(&id).await?; let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?; api.require_admin_or_tenant(&auth, &relay.tenant)?;
let activity = api let activity = query::list_activity_for_resource(&id)
.query
.list_activity_for_resource(&id)
.await .await
.map_err(internal)?; .map_err(internal)?;
ok(serde_json::json!({ "activity": activity })) ok(serde_json::json!({ "activity": activity }))
@@ -98,6 +64,23 @@ pub async fn list_relay_members(
ok(serde_json::json!({ "members": members })) ok(serde_json::json!({ "members": members }))
} }
#[derive(Deserialize)]
pub struct CreateRelayRequest {
pub tenant: String,
pub subdomain: String,
pub plan: String,
pub info_name: String,
pub info_icon: String,
pub info_description: String,
pub policy_public_join: i64,
pub policy_strip_signatures: i64,
pub groups_enabled: i64,
pub management_enabled: i64,
pub blossom_enabled: i64,
pub livekit_enabled: i64,
pub push_enabled: i64,
}
pub async fn create_relay( pub async fn create_relay(
State(api): State<Arc<Api>>, State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey, AuthedPubkey(auth): AuthedPubkey,
@@ -129,15 +112,31 @@ pub async fn create_relay(
..Default::default() ..Default::default()
}; };
let relay = prepare_relay(&api, relay)?; let relay = prepare_relay(relay)?;
api.command command::create_relay(&relay)
.create_relay(&relay)
.await .await
.map_err(map_relay_write_error)?; .map_err(map_relay_write_error)?;
created(relay) created(relay)
} }
#[derive(Deserialize)]
pub struct UpdateRelayRequest {
pub subdomain: Option<String>,
pub plan: Option<String>,
pub info_name: Option<String>,
pub info_icon: Option<String>,
pub info_description: Option<String>,
pub policy_public_join: Option<i64>,
pub policy_strip_signatures: Option<i64>,
pub groups_enabled: Option<i64>,
pub management_enabled: Option<i64>,
pub blossom_enabled: Option<i64>,
pub livekit_enabled: Option<i64>,
pub push_enabled: Option<i64>,
}
pub async fn update_relay( pub async fn update_relay(
State(api): State<Arc<Api>>, State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey, AuthedPubkey(auth): AuthedPubkey,
@@ -145,6 +144,7 @@ pub async fn update_relay(
Json(payload): Json<UpdateRelayRequest>, Json(payload): Json<UpdateRelayRequest>,
) -> ApiResult { ) -> ApiResult {
let mut relay = api.get_relay_or_404(&id).await?; let mut relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?; api.require_admin_or_tenant(&auth, &relay.tenant)?;
let current_plan = relay.plan.clone(); let current_plan = relay.plan.clone();
@@ -187,17 +187,15 @@ pub async fn update_relay(
relay.push_enabled = v; relay.push_enabled = v;
} }
let relay = prepare_relay(&api, relay)?; let relay = prepare_relay(relay)?;
let plan_changed = requested_plan let plan_changed = requested_plan
.as_deref() .as_deref()
.is_some_and(|requested| requested != current_plan); .is_some_and(|requested| requested != current_plan);
if plan_changed { if plan_changed {
let selected_plan = api let selected_plan =
.query query::get_plan(&relay.plan).expect("validated plan must exist");
.get_plan(&relay.plan)
.expect("validated plan must exist");
if let Some(limit) = selected_plan.members { if let Some(limit) = selected_plan.members {
let current_members = fetch_relay_members(&api, &relay) let current_members = fetch_relay_members(&api, &relay)
.await .await
@@ -214,10 +212,10 @@ pub async fn update_relay(
} }
} }
api.command command::update_relay(&relay)
.update_relay(&relay)
.await .await
.map_err(map_relay_write_error)?; .map_err(map_relay_write_error)?;
ok(relay) ok(relay)
} }
@@ -237,10 +235,10 @@ pub async fn deactivate_relay(
return Err(bad_request("relay-is-inactive", "relay is already inactive")); return Err(bad_request("relay-is-inactive", "relay is already inactive"));
} }
api.command command::deactivate_relay(&relay)
.deactivate_relay(&relay)
.await .await
.map_err(internal)?; .map_err(internal)?;
ok(()) ok(())
} }
@@ -260,7 +258,8 @@ pub async fn reactivate_relay(
return Err(bad_request("relay-is-active", "relay is already active")); return Err(bad_request("relay-is-active", "relay is already active"));
} }
api.command.activate_relay(&relay).await.map_err(internal)?; command::activate_relay(&relay).await.map_err(internal)?;
ok(()) ok(())
} }
@@ -279,15 +278,13 @@ const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"];
static SUBDOMAIN_RE: LazyLock<Regex> = static SUBDOMAIN_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$").unwrap()); LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$").unwrap());
fn prepare_relay(api: &Api, mut relay: Relay) -> Result<Relay, ApiError> { fn prepare_relay(mut relay: Relay) -> Result<Relay, ApiError> {
if !SUBDOMAIN_RE.is_match(&relay.subdomain) if !SUBDOMAIN_RE.is_match(&relay.subdomain)
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str()) { || RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str()) {
return Err(unprocessable("invalid-subdomain", "subdomain is invalid")); return Err(unprocessable("invalid-subdomain", "subdomain is invalid"));
} }
let plan = api let plan = query::get_plan(&relay.plan)
.query
.get_plan(&relay.plan)
.ok_or_else(|| unprocessable("invalid-plan", "plan not found"))?; .ok_or_else(|| unprocessable("invalid-plan", "plan not found"))?;
if (!plan.blossom && relay.blossom_enabled == 1) || (!plan.livekit && relay.livekit_enabled == 1) { if (!plan.blossom && relay.blossom_enabled == 1) || (!plan.livekit && relay.livekit_enabled == 1) {
-349
View File
@@ -1,349 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use axum::{
body::Bytes,
extract::{Path, Query as QueryParams, State},
http::HeaderMap,
};
use serde::Deserialize;
use crate::api::{Api, AuthedPubkey};
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT};
use crate::web::{ApiResult, bad_request, internal, ok};
const MANUAL_LIGHTNING_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 NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:";
const NWC_ERROR_DM_MAX_CHARS: usize = 240;
#[derive(Deserialize)]
pub struct StripeSessionParams {
return_url: Option<String>,
}
pub async fn create_stripe_session(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
QueryParams(params): QueryParams<StripeSessionParams>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
let url = api
.stripe
.create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
.await
.map_err(internal)?;
ok(serde_json::json!({ "url": url }))
}
/// Stripe webhook endpoint. Authenticated via `Stripe-Signature` verification
/// on the raw body, not via NIP-98, so it does not use `AuthedPubkey`.
pub async fn stripe_webhook(
State(api): State<Arc<Api>>,
headers: HeaderMap,
body: Bytes,
) -> ApiResult {
let signature = headers
.get("Stripe-Signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let payload = std::str::from_utf8(&body)
.map_err(|_| bad_request("bad-request", "invalid payload"))?;
handle_webhook(&api, payload, signature)
.await
.map_err(|e| bad_request("webhook-error", &e.to_string()))?;
ok(())
}
// --- Webhook event handlers ---
//
// These translate verified Stripe events into domain actions. The Stripe HTTP
// calls and Lightning/NWC payment orchestration they invoke live in
// [`crate::stripe`] and [`crate::billing`] respectively.
async fn handle_webhook(api: &Api, payload: &str, signature: &str) -> Result<()> {
let event = api.stripe.get_webhook_event(payload, signature)?;
let obj = &event.data.object;
match event.event_type.as_str() {
"invoice.created" => {
let customer = obj["customer"].as_str().unwrap_or_default();
let amount_due = obj["amount_due"].as_i64().unwrap_or(0);
let currency = obj["currency"].as_str().unwrap_or("usd");
let stripe_invoice_id = obj["id"].as_str().unwrap_or_default();
handle_invoice_created(api, customer, amount_due, currency, stripe_invoice_id).await?;
}
"invoice.paid" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_invoice_paid(api, customer).await?;
}
"invoice.payment_failed" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_invoice_payment_failed(api, customer).await?;
}
"invoice.overdue" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_invoice_overdue(api, customer).await?;
}
"customer.subscription.updated" => {
let customer = obj["customer"].as_str().unwrap_or_default();
let status = obj["status"].as_str().unwrap_or_default();
handle_subscription_updated(api, customer, status).await?;
}
"customer.subscription.deleted" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_subscription_deleted(api, customer).await?;
}
"payment_method.attached" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_payment_method_attached(api, customer).await?;
}
_ => {}
}
Ok(())
}
async fn handle_invoice_created(
api: &Api,
stripe_customer_id: &str,
amount_due: i64,
currency: &str,
stripe_invoice_id: &str,
) -> Result<()> {
if amount_due == 0 {
return Ok(());
}
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
let invoice = api
.billing
.ensure_lightning_invoice(stripe_invoice_id, &tenant.pubkey, amount_due, currency)
.await?;
let mut nwc_error_for_dm: Option<String> = None;
// 1. NWC auto-pay: if the tenant has a nwc_url
if !tenant.nwc_url.is_empty() {
match api.billing.pay_invoice_nwc(&tenant, &invoice).await {
Ok(()) => return Ok(()),
Err(e) => {
let error_msg = format!("{e}");
api.command
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
.await?;
tracing::warn!(
error = %e,
tenant_pubkey = %tenant.pubkey,
stripe_customer_id,
stripe_invoice_id,
"nwc auto-payment failed for invoice.created"
);
nwc_error_for_dm = summarize_nwc_error_for_dm(&error_msg);
// Fall through to card / manual payment
}
}
}
// 2. Card on file: if the tenant has a payment method, Stripe charges automatically
if api
.stripe
.has_payment_method(&tenant.stripe_customer_id)
.await?
{
return Ok(());
}
// 3. Manual payment: DM a link to the in-app payment page for this invoice
let url_base = &api.env.app_url;
let payment_url = format!("{url_base}/account?invoice={stripe_invoice_id}");
let base = format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{payment_url}");
let dm_message = match nwc_error_for_dm {
Some(error) if !error.is_empty() => {
format!("{base}\n\n{NWC_ERROR_DM_PREFIX} {error}")
}
_ => base,
};
api.robot.send_dm(&tenant.pubkey, &dm_message).await?;
Ok(())
}
async fn handle_invoice_paid(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
if tenant.past_due_at.is_some() {
api.command.clear_tenant_past_due(&tenant.pubkey).await?;
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_DELINQUENT && api.query.is_paid_plan(&relay.plan) {
api.command.activate_relay(&relay).await?;
}
}
}
Ok(())
}
async fn handle_invoice_payment_failed(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
if tenant.past_due_at.is_none() {
api.command.set_tenant_past_due(&tenant.pubkey).await?;
api.robot
.send_dm(
&tenant.pubkey,
"Your payment has failed. Your relays may be deactivated if not resolved within a week.",
)
.await?;
}
Ok(())
}
async fn handle_subscription_updated(
api: &Api,
stripe_customer_id: &str,
status: &str,
) -> Result<()> {
if status != "canceled" && status != "unpaid" {
return Ok(());
}
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
api.command
.clear_tenant_subscription(&tenant.pubkey)
.await?;
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE && api.query.is_paid_plan(&relay.plan) {
api.command.mark_relay_delinquent(&relay).await?;
}
}
Ok(())
}
async fn handle_subscription_deleted(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
api.command
.clear_tenant_subscription(&tenant.pubkey)
.await?;
Ok(())
}
async fn handle_invoice_overdue(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE && api.query.is_paid_plan(&relay.plan) {
api.command.mark_relay_delinquent(&relay).await?;
}
}
api.robot
.send_dm(
&tenant.pubkey,
"Your paid relays have been deactivated due to non-payment.",
)
.await?;
Ok(())
}
async fn handle_payment_method_attached(api: &Api, stripe_customer_id: &str) -> Result<()> {
if stripe_customer_id.is_empty() {
return Ok(());
}
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
let invoices = api
.stripe
.list_invoices(&tenant.stripe_customer_id)
.await?;
for invoice in &invoices {
if invoice.status != "open" || invoice.amount_due == 0 {
continue;
}
if let Err(error) = api.stripe.pay_invoice(&invoice.id).await {
tracing::error!(
error = %error,
stripe_invoice_id = %invoice.id,
"failed to retry card payment for outstanding invoice"
);
}
}
Ok(())
}
fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
return None;
}
if normalized.chars().count() <= NWC_ERROR_DM_MAX_CHARS {
return Some(normalized);
}
let prefix_len = NWC_ERROR_DM_MAX_CHARS.saturating_sub(3);
let mut truncated = normalized.chars().take(prefix_len).collect::<String>();
truncated.push_str("...");
Some(truncated)
}
+55 -22
View File
@@ -2,7 +2,7 @@ use std::sync::Arc;
use axum::{ use axum::{
Json, Json,
extract::{Path, State}, extract::{Path, Query, State},
}; };
use chrono::Utc; use chrono::Utc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -10,6 +10,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::web::{ApiResult, internal, map_unique_error, ok}; use crate::web::{ApiResult, internal, map_unique_error, ok};
use crate::{command, env, query};
#[derive(Serialize)] #[derive(Serialize)]
pub struct TenantResponse { pub struct TenantResponse {
@@ -17,9 +18,8 @@ pub struct TenantResponse {
pub nwc_is_set: bool, pub nwc_is_set: bool,
pub nwc_error: Option<String>, pub nwc_error: Option<String>,
pub created_at: i64, pub created_at: i64,
pub billing_anchor: Option<i64>,
pub stripe_customer_id: String, pub stripe_customer_id: String,
pub stripe_subscription_id: Option<String>,
pub past_due_at: Option<i64>,
} }
impl From<Tenant> for TenantResponse { impl From<Tenant> for TenantResponse {
@@ -29,39 +29,30 @@ impl From<Tenant> for TenantResponse {
pubkey: t.pubkey, pubkey: t.pubkey,
nwc_error: t.nwc_error, nwc_error: t.nwc_error,
created_at: t.created_at, created_at: t.created_at,
billing_anchor: t.billing_anchor,
stripe_customer_id: t.stripe_customer_id, stripe_customer_id: t.stripe_customer_id,
stripe_subscription_id: t.stripe_subscription_id,
past_due_at: t.past_due_at,
} }
} }
} }
#[derive(Deserialize)]
pub struct UpdateTenantRequest {
pub nwc_url: Option<String>,
}
pub async fn list_tenants( pub async fn list_tenants(
State(api): State<Arc<Api>>, State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey, AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult { ) -> ApiResult {
api.require_admin(&auth)?; api.require_admin(&auth)?;
let tenants = api.query.list_tenants().await.map_err(internal)?; let tenants = query::list_tenants().await.map_err(internal)?;
ok(tenants ok(tenants
.into_iter() .into_iter()
.map(TenantResponse::from) .map(TenantResponse::from)
.collect::<Vec<_>>()) .collect::<Vec<_>>())
} }
/// Creates the tenant row for the calling pubkey. Idempotent: if the tenant
/// already exists (including a unique-constraint race) we return the existing
/// row.
pub async fn create_tenant( pub async fn create_tenant(
State(api): State<Arc<Api>>, State(api): State<Arc<Api>>,
AuthedPubkey(pubkey): AuthedPubkey, AuthedPubkey(pubkey): AuthedPubkey,
) -> ApiResult { ) -> ApiResult {
if let Some(t) = api.query.get_tenant(&pubkey).await.map_err(internal)? { if let Some(t) = query::get_tenant(&pubkey).await.map_err(internal)? {
return ok(TenantResponse::from(t)); return ok(TenantResponse::from(t));
} }
@@ -84,10 +75,10 @@ pub async fn create_tenant(
..Default::default() ..Default::default()
}; };
match api.command.create_tenant(&tenant).await { match command::create_tenant(&tenant).await {
Ok(()) => ok(TenantResponse::from(tenant)), Ok(()) => ok(TenantResponse::from(tenant)),
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => { Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
match api.query.get_tenant(&pubkey).await { match query::get_tenant(&pubkey).await {
Ok(Some(t)) => ok(TenantResponse::from(t)), Ok(Some(t)) => ok(TenantResponse::from(t)),
Ok(None) => Err(internal("tenant row missing after unique-constraint race")), Ok(None) => Err(internal("tenant row missing after unique-constraint race")),
Err(e) => Err(internal(e)), Err(e) => Err(internal(e)),
@@ -107,6 +98,11 @@ pub async fn get_tenant(
ok(TenantResponse::from(tenant)) ok(TenantResponse::from(tenant))
} }
#[derive(Deserialize)]
pub struct UpdateTenantRequest {
pub nwc_url: Option<String>,
}
pub async fn update_tenant( pub async fn update_tenant(
State(api): State<Arc<Api>>, State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey, AuthedPubkey(auth): AuthedPubkey,
@@ -121,11 +117,11 @@ pub async fn update_tenant(
if nwc_url.is_empty() { if nwc_url.is_empty() {
tenant.nwc_url = String::new(); tenant.nwc_url = String::new();
} else { } else {
tenant.nwc_url = api.env.encrypt(&nwc_url).map_err(internal)?; tenant.nwc_url = env::get().encrypt(&nwc_url).map_err(internal)?;
} }
} }
api.command.update_tenant(&tenant).await.map_err(internal)?; command::update_tenant(&tenant).await.map_err(internal)?;
ok(TenantResponse::from(tenant)) ok(TenantResponse::from(tenant))
} }
@@ -136,10 +132,47 @@ pub async fn list_tenant_relays(
) -> ApiResult { ) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?; api.require_admin_or_tenant(&auth, &pubkey)?;
let relays = api let relays = query::list_relays_for_tenant(&pubkey)
.query
.list_relays_for_tenant(&pubkey)
.await .await
.map_err(internal)?; .map_err(internal)?;
ok(relays) ok(relays)
} }
pub async fn list_tenant_invoices(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let invoices = query::list_invoices(&pubkey)
.await
.map_err(internal)?;
ok(invoices)
}
#[derive(Deserialize)]
pub struct StripeSessionParams {
return_url: Option<String>,
}
pub async fn create_stripe_session(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
Query(params): Query<StripeSessionParams>,
) -> ApiResult {
api.require_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
let url = api
.stripe
.create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
.await
.map_err(internal)?;
ok(serde_json::json!({ "url": url }))
}
+68 -265
View File
@@ -7,91 +7,21 @@
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use sha2::Sha256; use sha2::Sha256;
use std::collections::BTreeMap;
use crate::env::Env; use crate::env;
const STRIPE_API: &str = "https://api.stripe.com/v1"; const STRIPE_API: &str = "https://api.stripe.com/v1";
// Webhooks
const WEBHOOK_TOLERANCE_SECS: i64 = 300;
#[derive(serde::Deserialize)]
pub struct StripeWebhookEvent {
#[serde(rename = "type")]
pub event_type: String,
pub data: StripeWebhookEventData,
}
#[derive(serde::Deserialize)]
pub struct StripeWebhookEventData {
pub object: serde_json::Value,
}
// API return types
#[derive(serde::Deserialize)]
pub struct StripeSubscription {
pub id: String,
pub status: String,
#[serde(deserialize_with = "deserialize_list")]
pub items: Vec<StripeSubscriptionItem>,
}
#[derive(serde::Deserialize)]
pub struct StripeSubscriptionItem {
pub id: String,
pub price: StripePrice,
#[serde(default = "default_quantity")]
pub quantity: i64,
}
#[derive(serde::Deserialize)]
pub struct StripePrice {
pub id: String,
}
#[derive(serde::Deserialize, serde::Serialize, Clone)]
pub struct StripeInvoice {
pub id: String,
pub customer: String,
pub status: String,
pub amount_due: i64,
pub currency: String,
pub period_start: i64,
pub period_end: i64,
}
#[derive(serde::Deserialize)]
struct StripeList<T> {
data: Vec<T>,
}
fn deserialize_list<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de>,
{
Ok(<StripeList<T> as serde::Deserialize>::deserialize(deserializer)?.data)
}
fn default_quantity() -> i64 {
1
}
// Stripe struct and impl // Stripe struct and impl
#[derive(Clone)] #[derive(Clone)]
pub struct Stripe { pub struct Stripe {
env: Env,
http: reqwest::Client, http: reqwest::Client,
} }
impl Stripe { impl Stripe {
pub fn new(env: &Env) -> Self { pub fn new() -> Self {
Self { Self {
env: env.clone(),
http: reqwest::Client::new(), http: reqwest::Client::new(),
} }
} }
@@ -101,23 +31,17 @@ impl Stripe {
fn get(&self, path: &str) -> reqwest::RequestBuilder { fn get(&self, path: &str) -> reqwest::RequestBuilder {
self.http self.http
.get(format!("{STRIPE_API}{path}")) .get(format!("{STRIPE_API}{path}"))
.bearer_auth(&self.env.stripe_secret_key) .bearer_auth(&env::get().stripe_secret_key)
} }
fn post(&self, path: &str) -> reqwest::RequestBuilder { fn post(&self, path: &str) -> reqwest::RequestBuilder {
self.http self.http
.post(format!("{STRIPE_API}{path}")) .post(format!("{STRIPE_API}{path}"))
.bearer_auth(&self.env.stripe_secret_key) .bearer_auth(&env::get().stripe_secret_key)
}
fn delete(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.delete(format!("{STRIPE_API}{path}"))
.bearer_auth(&self.env.stripe_secret_key)
} }
fn idempotency_key(&self, parts: &[&str]) -> String { fn idempotency_key(&self, parts: &[&str]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(self.env.stripe_secret_key.as_bytes()) let mut mac = Hmac::<Sha256>::new_from_slice(env::get().stripe_secret_key.as_bytes())
.expect("HMAC accepts any key length"); .expect("HMAC accepts any key length");
for (i, part) in parts.iter().enumerate() { for (i, part) in parts.iter().enumerate() {
if i > 0 { if i > 0 {
@@ -146,153 +70,74 @@ impl Stripe {
Ok(customer_id.to_string()) Ok(customer_id.to_string())
} }
// --- Subscriptions ---
pub async fn get_subscription(
&self,
subscription_id: &str,
) -> Result<Option<StripeSubscription>> {
let body = self
.get(&format!("/subscriptions/{subscription_id}"))
.send_optional_json()
.await?;
body.map(serde_json::from_value)
.transpose()
.map_err(Into::into)
}
/// Stripe requires at least one item to create a subscription, so the desired
/// items are sent inline here; [`crate::billing`] reconciles from there.
pub async fn create_subscription(
&self,
customer_id: &str,
items: &BTreeMap<String, i64>,
) -> Result<StripeSubscription> {
let mut form: Vec<(String, String)> = vec![
("customer".to_string(), customer_id.to_string()),
(
"collection_method".to_string(),
"charge_automatically".to_string(),
),
];
let mut key_parts: Vec<String> =
vec!["create_subscription".to_string(), customer_id.to_string()];
for (index, (price_id, quantity)) in items.iter().enumerate() {
form.push((format!("items[{index}][price]"), price_id.clone()));
form.push((format!("items[{index}][quantity]"), quantity.to_string()));
key_parts.push(format!("{price_id}={quantity}"));
}
let key_refs: Vec<&str> = key_parts.iter().map(String::as_str).collect();
Ok(self
.post("/subscriptions")
.header("Idempotency-Key", self.idempotency_key(&key_refs))
.form(&form)
.send_ok()
.await?
.json()
.await?)
}
pub async fn create_subscription_item(
&self,
subscription_id: &str,
price_id: &str,
quantity: i64,
) -> Result<()> {
let quantity = quantity.to_string();
self.post("/subscription_items")
.header(
"Idempotency-Key",
self.idempotency_key(&["create_subscription_item", subscription_id, price_id]),
)
.form(&[
("subscription", subscription_id),
("price", price_id),
("quantity", quantity.as_str()),
])
.send_ok()
.await?;
Ok(())
}
pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()> {
self.post(&format!("/subscription_items/{item_id}"))
.form(&[("quantity", quantity.to_string())])
.send_ok()
.await?;
Ok(())
}
pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()> {
self.delete(&format!("/subscription_items/{item_id}"))
.send_ok()
.await?;
Ok(())
}
pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()> {
self.delete(&format!("/subscriptions/{subscription_id}"))
.send_ok()
.await?;
Ok(())
}
// --- Invoices ---
pub async fn list_invoices(&self, customer_id: &str) -> Result<Vec<StripeInvoice>> {
let list: StripeList<StripeInvoice> = self
.get("/invoices")
.query(&[("customer", customer_id)])
.send_ok()
.await?
.json()
.await?;
Ok(list.data)
}
pub async fn get_invoice(&self, invoice_id: &str) -> Result<Option<StripeInvoice>> {
let body = self
.get(&format!("/invoices/{invoice_id}"))
.send_optional_json()
.await?;
body.map(serde_json::from_value)
.transpose()
.map_err(Into::into)
}
pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()> {
self.post(&format!("/invoices/{invoice_id}/pay"))
.header(
"Idempotency-Key",
self.idempotency_key(&["pay_invoice", invoice_id]),
)
.send_ok()
.await?;
Ok(())
}
pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> {
self.post(&format!("/invoices/{invoice_id}/pay"))
.header(
"Idempotency-Key",
self.idempotency_key(&["pay_invoice_oob", invoice_id]),
)
.form(&[("paid_out_of_band", "true")])
.send_ok()
.await?;
Ok(())
}
// --- Payment methods --- // --- Payment methods ---
pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool> { /// Return the id of the customer's first saved payment method, or `None` if
/// they have none. The returned `pm_…` id can be charged off-session via
/// [`Self::create_payment_intent`]. We don't track a Stripe "default" payment
/// method, so the first one Stripe lists is the one we'll charge.
pub async fn get_saved_payment_method(&self, customer_id: &str) -> Result<Option<String>> {
let body = self let body = self
.get("/payment_methods") .get("/payment_methods")
.query(&[("customer", customer_id), ("type", "card")]) .query(&[("customer", customer_id), ("type", "card")])
.send_json() .send_json()
.await?; .await?;
Ok(body["data"].as_array().is_some_and(|a| !a.is_empty())) Ok(body["data"]
.as_array()
.and_then(|methods| methods.first())
.and_then(|method| method["id"].as_str())
.map(str::to_string))
}
// --- Intents ---
/// Create and immediately confirm an off-session PaymentIntent charging a
/// saved payment method. `amount` is in the currency's minor units (cents for
/// `usd`). Returns the PaymentIntent id on success.
///
/// A decline or an issuer authentication demand (`authentication_required`,
/// which we can't satisfy off-session) comes back from Stripe as an HTTP
/// error, so the caller naturally falls through to another payment method.
/// The charge is made idempotent on `invoice_id`, so a retried collection
/// reuses the same charge instead of billing the payment method twice.
pub async fn create_payment_intent(
&self,
customer_id: &str,
payment_method_id: &str,
invoice_id: &str,
amount: i64,
currency: &str,
) -> Result<String> {
let amount = amount.to_string();
let body = self
.post("/payment_intents")
.header(
"Idempotency-Key",
self.idempotency_key(&["payment_intent", invoice_id]),
)
.form(&[
("amount", amount.as_str()),
("currency", currency),
("customer", customer_id),
("payment_method", payment_method_id),
("off_session", "true"),
("confirm", "true"),
])
.send_json()
.await?;
// A successful off-session charge settles synchronously. Anything
// else (e.g. `requires_action`) can't be completed without the customer,
// so treat it as a failure and let the caller fall back.
let status = body["status"].as_str().unwrap_or_default();
if status != "succeeded" {
return Err(anyhow!("payment intent not succeeded (status: {status})"));
}
body["id"]
.as_str()
.map(str::to_string)
.ok_or_else(|| anyhow!("missing payment intent id"))
} }
// --- Portal --- // --- Portal ---
@@ -316,47 +161,13 @@ impl Stripe {
.map(str::to_string) .map(str::to_string)
.ok_or_else(|| anyhow!("missing portal session url")) .ok_or_else(|| anyhow!("missing portal session url"))
} }
// --- Webhooks ---
pub fn get_webhook_event(&self, payload: &str, signature: &str) -> Result<StripeWebhookEvent> {
let mut timestamp = None;
let mut sig = None;
for part in signature.split(',') {
if let Some(t) = part.strip_prefix("t=") {
timestamp = Some(t);
} else if let Some(v) = part.strip_prefix("v1=") {
sig = Some(v);
}
}
let timestamp = timestamp.ok_or_else(|| anyhow!("missing webhook timestamp"))?;
let signature = sig.ok_or_else(|| anyhow!("missing webhook signature"))?;
let signed_payload = format!("{timestamp}.{payload}");
let mut mac = Hmac::<Sha256>::new_from_slice(self.env.stripe_webhook_secret.as_bytes())
.map_err(|e| anyhow!("invalid webhook secret: {e}"))?;
mac.update(signed_payload.as_bytes());
let expected = hex::encode(mac.finalize().into_bytes());
if expected != signature {
return Err(anyhow!("webhook signature mismatch"));
}
let ts: i64 = timestamp
.parse()
.map_err(|_| anyhow!("bad webhook timestamp"))?;
let now = chrono::Utc::now().timestamp();
if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS {
return Err(anyhow!("webhook timestamp outside tolerance"));
}
Ok(serde_json::from_str(payload)?)
}
} }
// Stripe request util
trait StripeRequest { trait StripeRequest {
async fn send_ok(self) -> Result<reqwest::Response>; async fn send_ok(self) -> Result<reqwest::Response>;
async fn send_json(self) -> Result<serde_json::Value>; async fn send_json(self) -> Result<serde_json::Value>;
async fn send_optional_json(self) -> Result<Option<serde_json::Value>>;
} }
impl StripeRequest for reqwest::RequestBuilder { impl StripeRequest for reqwest::RequestBuilder {
@@ -367,14 +178,6 @@ impl StripeRequest for reqwest::RequestBuilder {
async fn send_json(self) -> Result<serde_json::Value> { async fn send_json(self) -> Result<serde_json::Value> {
Ok(self.send_ok().await?.json().await?) Ok(self.send_ok().await?.json().await?)
} }
async fn send_optional_json(self) -> Result<Option<serde_json::Value>> {
let resp = self.send().await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
Ok(Some(error_for_status(resp).await?.json().await?))
}
} }
/// Give callers an actionable message instead of a bare "400 Bad Request" /// Give callers an actionable message instead of a bare "400 Bad Request"
+1 -1
View File
@@ -33,7 +33,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const planById = new Map(plans().map((p) => [p.id, p])) const planById = new Map(plans().map((p) => [p.id, p]))
return (relays() ?? []) return (relays() ?? [])
.map((relay) => ({ relay, plan: planById.get(relay.plan) })) .map((relay) => ({ relay, plan: planById.get(relay.plan) }))
.filter((entry) => entry.plan?.stripe_price_id) .filter((entry) => entry.plan?.amount > 0)
}) })
async function loadBolt11() { async function loadBolt11() {
-1
View File
@@ -35,7 +35,6 @@ export type Plan = {
id: string id: string
name: string name: string
amount: number amount: number
stripe_price_id: string | null
members: number | null members: number | null
blossom: boolean blossom: boolean
livekit: boolean livekit: boolean