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
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,
activity_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 (
@@ -12,9 +13,8 @@ CREATE TABLE IF NOT EXISTS tenant (
nwc_url TEXT NOT NULL DEFAULT '',
nwc_error TEXT,
created_at INTEGER NOT NULL,
stripe_customer_id TEXT NOT NULL,
stripe_subscription_id TEXT,
past_due_at INTEGER
billing_anchor INTEGER,
stripe_customer_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS relay (
@@ -38,29 +38,64 @@ CREATE TABLE IF NOT EXISTS relay (
FOREIGN KEY (tenant) REFERENCES tenant(pubkey)
);
CREATE TABLE IF NOT EXISTS lightning_invoice (
stripe_invoice_id TEXT PRIMARY KEY,
CREATE TABLE IF NOT EXISTS invoice (
id TEXT PRIMARY KEY,
tenant_pubkey TEXT NOT NULL,
bolt11 TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'paid')),
paid_method TEXT CHECK (paid_method IN ('nwc', 'manual')),
expires_at INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ('open','paid','void','churn')),
method TEXT CHECK (method IS NULL OR method IN ('nwc','stripe','oob')),
period_start INTEGER NOT NULL,
period_end INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE INDEX IF NOT EXISTS idx_tenant_stripe_customer_id
ON tenant (stripe_customer_id);
CREATE TABLE IF NOT EXISTS invoice_item (
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
ON relay (tenant, id);
CREATE TABLE IF NOT EXISTS bolt11 (
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
ON relay (tenant, status, plan);
CREATE TABLE IF NOT EXISTS intent (
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
ON activity (resource_type, resource_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_relay_tenant ON relay (tenant);
CREATE INDEX IF NOT EXISTS idx_lightning_invoice_tenant_pubkey
ON lightning_invoice (tenant_pubkey);
CREATE INDEX IF NOT EXISTS idx_activity_tenant_created ON activity (tenant, created_at);
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 crate::billing::Billing;
use crate::command::Command;
use crate::env::Env;
use crate::env;
use crate::infra::Infra;
use crate::models::{Relay, Tenant};
use crate::query::Query;
use crate::query;
use crate::robot::Robot;
use crate::stripe::Stripe;
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::relays::{
create_relay, deactivate_relay, get_relay, list_relay_activity, list_relay_members,
list_relays, reactivate_relay, update_relay,
};
use crate::routes::stripe::{create_stripe_session, stripe_webhook};
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};
#[derive(Clone)]
pub struct Api {
pub env: Env,
pub query: Query,
pub command: Command,
pub billing: Billing,
pub stripe: Stripe,
pub robot: Robot,
@@ -60,19 +56,8 @@ pub struct Api {
}
impl Api {
pub fn new(
query: Query,
command: Command,
billing: Billing,
stripe: Stripe,
robot: Robot,
infra: Infra,
env: &Env,
) -> Self {
pub fn new(billing: Billing, stripe: Stripe, robot: Robot, infra: Infra) -> Self {
Self {
env: env.clone(),
query,
command,
billing,
stripe,
robot,
@@ -90,24 +75,23 @@ impl Api {
.route("/tenants", get(list_tenants).post(create_tenant))
.route("/tenants/:pubkey", get(get_tenant).put(update_tenant))
.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/:id", get(get_relay).put(update_relay))
.route("/relays/:id/members", get(list_relay_members))
.route("/relays/:id/activity", get(list_relay_activity))
.route("/relays/:id/deactivate", post(deactivate_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/bolt11", get(get_lightning_invoice))
.route("/tenants/:pubkey/stripe/session", get(create_stripe_session))
.route("/stripe/webhook", post(stripe_webhook))
.route("/invoices/:id/bolt11", get(get_invoice_bolt11))
.with_state(api)
}
// --- authorization helpers ----------------------------------------------
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> {
@@ -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(
&self,
authorized_pubkey: &str,
@@ -131,7 +127,7 @@ impl Api {
}
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(None) => Err(not_found("tenant not found")),
Err(e) => Err(internal(e)),
@@ -139,7 +135,7 @@ impl Api {
}
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(None) => Err(not_found("relay not found")),
Err(e) => Err(internal(e)),
@@ -188,10 +184,7 @@ impl Api {
.last()
.ok_or_else(|| anyhow!("missing u tag"))?;
ensure!(
self.env.server_host.is_empty() || got_u.contains(&self.env.server_host),
"authorization host mismatch"
);
ensure!(got_u == env::get().server_host, "authorization host mismatch");
Ok(event.pubkey.to_hex())
}
+349 -253
View File
@@ -1,41 +1,44 @@
use anyhow::{Result, anyhow};
use std::collections::BTreeMap;
use std::time::Duration;
use crate::bitcoin;
use crate::command::Command;
use crate::env::Env;
use crate::models::{Activity, LightningInvoice, Tenant, RELAY_STATUS_ACTIVE};
use crate::query::Query;
use crate::stripe::{Stripe, StripeInvoice, StripeSubscription};
use crate::command;
use crate::db;
use crate::env;
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Tenant};
use crate::query;
use crate::robot::Robot;
use crate::stripe::Stripe;
use crate::wallet::Wallet;
#[derive(Clone)]
pub struct Billing {
stripe: Stripe,
wallet: Wallet,
query: Query,
command: Command,
env: Env,
robot: Robot,
}
impl Billing {
pub fn new(query: Query, command: Command, env: &Env) -> Self {
pub fn new(robot: Robot) -> Self {
Self {
stripe: Stripe::new(env),
wallet: Wallet::from_url(&env.robot_wallet).expect("invalid ROBOT_WALLET"),
query,
command,
env: env.clone(),
stripe: Stripe::new(),
wallet: Wallet::from_url(&env::get().robot_wallet).expect("invalid ROBOT_WALLET"),
robot,
}
}
// --- lifecycle methods ---
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 {
tracing::error!(error = %error, "failed to reconcile relay billing state on startup");
tracing::error!(error = %error, "failed to reconcile subscriptions on startup");
}
loop {
@@ -46,10 +49,10 @@ impl Billing {
}
}
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 {
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,
@@ -57,17 +60,78 @@ impl Billing {
}
}
async fn reconcile_subscriptions(&self, source: &str) -> Result<()> {
let tenants = self.query.list_tenants().await?;
async fn poll(&self) {
let mut interval = tokio::time::interval(POLL_INTERVAL);
if tenants.is_empty() {
return Ok(());
loop {
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!(
source,
tenant_count = tenants.len(),
"reconciling relay billing state"
"reconciling all subscriptions"
);
for tenant in tenants {
@@ -76,7 +140,7 @@ impl Billing {
source,
tenant = %tenant.pubkey,
error = ?error,
"failed to reconcile relay billing state"
"failed to reconcile subscription"
);
}
}
@@ -84,197 +148,148 @@ impl Billing {
Ok(())
}
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_billing_sync = matches!(
activity.activity_type.as_str(),
"create_relay"
| "update_relay"
| "activate_relay"
| "deactivate_relay"
| "fail_relay_sync"
| "complete_relay_sync"
);
// --- Invoice generation and autopayment ---
if needs_billing_sync
&& let Some(tenant) = self.query.get_tenant(&activity.tenant).await?
{
self.reconcile_subscription(&tenant).await?;
/// Scan a tenant's activity for changes not yet reflected in an invoice and,
/// if there are any, create an invoice with the corresponding line items and
/// attempt to collect payment.
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(())
}
// --- Subscriptions ---
/// 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?;
// 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() {
command::mark_activities_billed(&billed_activity_ids).await?;
return Ok(());
}
let subscription = self
.ensure_subscription_is_active(tenant, &quantity_by_price_id)
.await?;
let period_start = invoice_items
.iter()
.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
async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>> {
let mut quantity_by_price_id = BTreeMap::new();
for relay in self.query.list_relays_for_tenant(&tenant.pubkey).await? {
if relay.status != RELAY_STATUS_ACTIVE {
continue;
pub async fn attempt_payment(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
let mut error_message: Option<String> = None;
// 1. NWC auto-pay: if the tenant has configured an nwc_url, try it first.
if !tenant.nwc_url.is_empty() {
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
async fn get_subscription(&self, tenant: &Tenant) -> Result<Option<StripeSubscription>> {
let subscription = match &tenant.stripe_subscription_id {
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"))
// 2. Payment method on file: if the tenant has one saved, charge it via Stripe.
if let Some(payment_method) =
self.stripe.get_saved_payment_method(&tenant.stripe_customer_id).await?
{
self.command.clear_tenant_subscription(&tenant.pubkey).await?;
return Ok(None);
match self
.attempt_payment_using_stripe(tenant, invoice, &payment_method)
.await
{
Ok(()) => return Ok(()),
Err(e) => error_message = Some(format!("{e}")),
}
}
Ok(subscription)
}
/// Make sure the tenant has an active subscription, creating one with the desired
/// items if it doesn't (Stripe rejects an itemless subscription).
async fn ensure_subscription_is_active(
&self,
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?;
// 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 {
tracing::error!(
tenant = %tenant.pubkey,
error = %e,
"failed to send manual payment DM"
);
}
Ok(())
}
/// Sync desired quantity_by_price_id with stripe
async fn ensure_subscription_items(
&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)?;
async fn attempt_payment_using_nwc(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
let nwc_url = env::get().decrypt(&tenant.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 {
Ok(()) => self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await,
match tenant_wallet.pay_invoice(bolt11.lnbc.clone()).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) => {
// The pay request errored, but the payment may have landed
// before the response was lost. Confirm against the system
// wallet before reporting failure.
if self.wallet.is_settled(&invoice.bolt11).await.unwrap_or(false) {
self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await
// The pay request errored, but the invoice may have been paid out of band.
if self.wallet.is_settled(&bolt11.lnbc).await.unwrap_or(false) {
command::mark_bolt11_settled(&bolt11.id).await?;
command::mark_invoice_paid(&invoice.id, "oob").await
} else {
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
/// 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
/// invoice; otherwise return it unchanged. Meant to run before presenting a
/// payable invoice so we never hand back one that's already been paid.
pub async fn reconcile_invoice(&self, invoice: &StripeInvoice) -> Result<StripeInvoice> {
if invoice.status != "open" {
return Ok(invoice.clone());
/// settled on the robot wallet, mark it paid and return the refreshed record;
/// otherwise return it unchanged. Meant to run before presenting a payable
/// invoice so we never hand back one that's already been paid.
pub async fn ensure_and_reconcile_bolt11(&self, invoice_id: &str) -> Result<Bolt11> {
let bolt11 = self.ensure_bolt11(invoice_id).await?;
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 sqlx::{Sqlite, SqlitePool, Transaction};
use tokio::sync::broadcast;
use sqlx::{Sqlite, Transaction};
use crate::db::{pool, publish, with_tx};
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,
};
#[derive(Clone)]
pub struct Command {
pool: SqlitePool,
pub notify: broadcast::Sender<Activity>,
}
// --- Activity ---
impl Command {
pub fn new(pool: SqlitePool) -> Self {
let (notify, _) = broadcast::channel(64);
Self { pool, notify }
/// Stamp `billed_at` on activities that were reconciled without producing an
/// invoice (e.g. free-plan or not-yet-prorated changes), so a recovery pass
/// doesn't re-scan them.
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(
tx: &mut Transaction<'_, Sqlite>,
activity_type: &str,
resource_type: &str,
resource_id: &str,
) -> Result<Activity> {
let tenant = match resource_type {
"tenant" => resource_id.to_string(),
"relay" => {
sqlx::query_scalar::<_, String>("SELECT tenant FROM relay WHERE id = ?")
.bind(resource_id)
.fetch_one(&mut **tx)
.await?
}
_ => anyhow::bail!("unknown resource_type: {resource_type}"),
};
/// Atomically record an `autogenerate_invoice` activity for the tenant, but only
/// if none has been recorded since `since` (the start of the current billing
/// period). Returns whether a new activity was inserted; `false` means the
/// period was already claimed.
///
/// The existence check and insert are a single statement, which SQLite runs
/// atomically, so concurrent pollers (or a restart racing the previous run)
/// can't both claim the same period. On success the activity is broadcast so the
/// billing consumer reconciles it like any other.
pub async fn try_autogenerate_invoice(tenant_pubkey: &str, since: i64) -> Result<bool> {
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().timestamp();
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().timestamp();
let result = sqlx::query(
"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(
"INSERT INTO activity (id, tenant, created_at, activity_type, resource_type, resource_id)
VALUES (?, ?, ?, ?, ?, ?)",
"INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id)
VALUES (?, ?, ?, ?)",
)
.bind(&id)
.bind(&tenant)
.bind(created_at)
.bind(activity_type)
.bind(resource_type)
.bind(resource_id)
.bind(&tenant.pubkey)
.bind(&tenant.nwc_url)
.bind(tenant.created_at)
.bind(&tenant.stripe_customer_id)
.execute(&mut **tx)
.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(),
})
}
/// Run `f` inside a transaction, record an activity row, commit, and broadcast.
async fn with_activity<F>(
&self,
activity_type: &str,
resource_type: &str,
resource_id: &str,
f: F,
) -> Result<()>
where
F: AsyncFnOnce(&mut Transaction<'_, Sqlite>) -> Result<()>,
{
let mut tx = self.pool.begin().await?;
f(&mut tx).await?;
let activity =
Self::insert_activity(&mut tx, activity_type, resource_type, resource_id).await?;
tx.commit().await?;
let _ = self.notify.send(activity);
Ok(())
}
// Tenants
pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> {
self.with_activity("create_tenant", "tenant", &tenant.pubkey, async |tx| {
sqlx::query(
"INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id)
VALUES (?, ?, ?, ?)",
)
.bind(&tenant.pubkey)
.bind(&tenant.nwc_url)
.bind(tenant.created_at)
.bind(&tenant.stripe_customer_id)
.execute(&mut **tx)
.await?;
Ok(())
})
.await
}
pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()> {
self.with_activity("update_tenant", "tenant", &tenant.pubkey, async |tx| {
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
.bind(&tenant.nwc_url)
.bind(&tenant.pubkey)
.execute(&mut **tx)
.await?;
Ok(())
})
.await
}
pub async fn set_tenant_subscription(
&self,
pubkey: &str,
stripe_subscription_id: &str,
) -> Result<()> {
sqlx::query("UPDATE tenant SET stripe_subscription_id = ? WHERE pubkey = ?")
.bind(stripe_subscription_id)
.bind(pubkey)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET stripe_subscription_id = NULL WHERE pubkey = ?")
.bind(pubkey)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET nwc_error = ? WHERE pubkey = ?")
.bind(error)
.bind(pubkey)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
.bind(pubkey)
.execute(&self.pool)
.await?;
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 = ?")
.bind(now)
.bind(pubkey)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET past_due_at = NULL WHERE pubkey = ?")
.bind(pubkey)
.execute(&self.pool)
.await?;
Ok(())
}
// Relays
pub async fn create_relay(&self, relay: &Relay) -> Result<()> {
self.with_activity("create_relay", "relay", &relay.id, async |tx| {
sqlx::query(
"INSERT INTO relay (
id, tenant, subdomain, plan, status, synced, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&relay.id)
.bind(&relay.tenant)
.bind(&relay.subdomain)
.bind(&relay.plan)
.bind(&relay.sync_error)
.bind(&relay.info_name)
.bind(&relay.info_icon)
.bind(&relay.info_description)
.bind(relay.policy_public_join)
.bind(relay.policy_strip_signatures)
.bind(relay.groups_enabled)
.bind(relay.management_enabled)
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.execute(&mut **tx)
.await?;
Ok(())
})
.await
}
pub async fn update_relay(&self, relay: &Relay) -> Result<()> {
self.with_activity("update_relay", "relay", &relay.id, async |tx| {
sqlx::query(
"UPDATE relay
SET tenant = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?, synced = 0,
info_name = ?, info_icon = ?, info_description = ?,
policy_public_join = ?, policy_strip_signatures = ?,
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
livekit_enabled = ?, push_enabled = ?
WHERE id = ?",
)
.bind(&relay.tenant)
.bind(&relay.subdomain)
.bind(&relay.plan)
.bind(&relay.status)
.bind(&relay.sync_error)
.bind(&relay.info_name)
.bind(&relay.info_icon)
.bind(&relay.info_description)
.bind(relay.policy_public_join)
.bind(relay.policy_strip_signatures)
.bind(relay.groups_enabled)
.bind(relay.management_enabled)
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.bind(&relay.id)
.execute(&mut **tx)
.await?;
Ok(())
})
.await
}
pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> {
self.set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay")
.await
}
pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()> {
self.set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent")
.await
}
pub async fn activate_relay(&self, relay: &Relay) -> Result<()> {
self.set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay")
.await
}
async fn set_relay_status(
&self,
relay_id: &str,
status: &str,
activity_type: &str,
) -> Result<()> {
self.with_activity(activity_type, "relay", relay_id, async |tx| {
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
.bind(status)
.bind(relay_id)
.execute(&mut **tx)
.await?;
Ok(())
})
.await
}
pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> {
self.with_activity("fail_relay_sync", "relay", &relay.id, async |tx| {
sqlx::query("UPDATE relay SET synced = 0, sync_error = ? WHERE id = ?")
.bind(&sync_error)
.bind(&relay.id)
.execute(&mut **tx)
.await?;
Ok(())
})
.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 = ?")
.bind(relay_id)
.execute(&mut **tx)
.await?;
Ok(())
})
.await
}
// Invoices
/// Upsert the pending bolt11 for an invoice, returning the resulting row. On
/// conflict the stored bolt11/expiry are replaced — this is how an expired
/// invoice is regenerated — except once the invoice is paid, when the
/// `status = 'pending'` guard makes the update a no-op and `None` is
/// returned so the caller can fall back to reading the settled row.
pub async fn insert_lightning_invoice(
&self,
stripe_invoice_id: &str,
tenant_pubkey: &str,
bolt11: &str,
expires_at: i64,
) -> Result<Option<LightningInvoice>> {
let now = chrono::Utc::now().timestamp();
let row = sqlx::query_as::<_, LightningInvoice>(
"INSERT INTO lightning_invoice
(stripe_invoice_id, tenant_pubkey, bolt11, status, expires_at, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?, ?)
ON CONFLICT(stripe_invoice_id) DO UPDATE SET
bolt11 = excluded.bolt11,
expires_at = excluded.expires_at,
updated_at = excluded.updated_at
WHERE status = 'pending'
RETURNING *",
)
.bind(stripe_invoice_id)
.bind(tenant_pubkey)
.bind(bolt11)
.bind(expires_at)
.bind(now)
.bind(now)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
/// Mark a pending invoice paid, recording which method settled it. The
/// `status = 'pending'` guard makes this idempotent and first-writer-wins:
/// a later reconcile won't clobber the method recorded by whoever settled
/// it first.
pub async fn mark_lightning_invoice_paid(&self, stripe_invoice_id: &str, method: &str) -> Result<()> {
let now = chrono::Utc::now().timestamp();
sqlx::query(
"UPDATE lightning_invoice
SET status = 'paid', paid_method = ?, updated_at = ?
WHERE stripe_invoice_id = ? AND status = 'pending'",
)
.bind(method)
.bind(now)
.bind(stripe_invoice_id)
.execute(&self.pool)
.await?;
Ok(())
}
insert_activity_tx(tx, "create_tenant", "tenant", &tenant.pubkey).await
})
.await?;
publish(activity);
Ok(())
}
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)
.bind(&tenant.pubkey)
.execute(&mut **tx)
.await?;
insert_activity_tx(tx, "update_tenant", "tenant", &tenant.pubkey).await
})
.await?;
publish(activity);
Ok(())
}
pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
.bind(pubkey)
.execute(pool())
.await?;
Ok(())
}
// --- Relays ---
pub async fn create_relay(relay: &Relay) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query(
"INSERT INTO relay (
id, tenant, subdomain, plan, status, synced, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&relay.id)
.bind(&relay.tenant)
.bind(&relay.subdomain)
.bind(&relay.plan)
.bind(&relay.sync_error)
.bind(&relay.info_name)
.bind(&relay.info_icon)
.bind(&relay.info_description)
.bind(relay.policy_public_join)
.bind(relay.policy_strip_signatures)
.bind(relay.groups_enabled)
.bind(relay.management_enabled)
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.execute(&mut **tx)
.await?;
insert_activity_tx(tx, "create_relay", "relay", &relay.id).await
})
.await?;
publish(activity);
Ok(())
}
pub async fn update_relay(relay: &Relay) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query(
"UPDATE relay
SET tenant = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?, synced = 0,
info_name = ?, info_icon = ?, info_description = ?,
policy_public_join = ?, policy_strip_signatures = ?,
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
livekit_enabled = ?, push_enabled = ?
WHERE id = ?",
)
.bind(&relay.tenant)
.bind(&relay.subdomain)
.bind(&relay.plan)
.bind(&relay.status)
.bind(&relay.sync_error)
.bind(&relay.info_name)
.bind(&relay.info_icon)
.bind(&relay.info_description)
.bind(relay.policy_public_join)
.bind(relay.policy_strip_signatures)
.bind(relay.groups_enabled)
.bind(relay.management_enabled)
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.bind(&relay.id)
.execute(&mut **tx)
.await?;
insert_activity_tx(tx, "update_relay", "relay", &relay.id).await
})
.await?;
publish(activity);
Ok(())
}
pub async fn deactivate_relay(relay: &Relay) -> Result<()> {
set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay").await
}
#[allow(dead_code)] // wired up by the delinquency flow (not yet implemented)
pub async fn mark_relay_delinquent(relay: &Relay) -> Result<()> {
set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent").await
}
pub async fn activate_relay(relay: &Relay) -> Result<()> {
set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay").await
}
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 = ?")
.bind(status)
.bind(relay_id)
.execute(&mut **tx)
.await?;
insert_activity_tx(tx, activity_type, "relay", relay_id).await
})
.await?;
publish(activity);
Ok(())
}
pub async fn fail_relay_sync(relay: &Relay, sync_error: String) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query("UPDATE relay SET synced = 0, sync_error = ? WHERE id = ?")
.bind(&sync_error)
.bind(&relay.id)
.execute(&mut **tx)
.await?;
insert_activity_tx(tx, "fail_relay_sync", "relay", &relay.id).await
})
.await?;
publish(activity);
Ok(())
}
pub async fn complete_relay_sync(relay_id: &str) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
.bind(relay_id)
.execute(&mut **tx)
.await?;
insert_activity_tx(tx, "complete_relay_sync", "relay", relay_id).await
})
.await?;
publish(activity);
Ok(())
}
// --- Invoices ---
/// Create an invoice with its line items, stamp `billed_at` on the activities
/// that produced them, and set the tenant's billing anchor when this is their
/// first invoice — all in one transaction. Returns the inserted invoice.
pub async fn create_invoice(
invoice_id: &str,
tenant_pubkey: &str,
period_start: i64,
period_end: i64,
items: &[InvoiceItem],
billed_activity_ids: &[String],
new_billing_anchor: Option<i64>,
) -> Result<Invoice> {
let now = chrono::Utc::now().timestamp();
with_tx(async |tx| {
let invoice =
insert_invoice_tx(tx, invoice_id, tenant_pubkey, period_start, period_end).await?;
for item in items {
insert_invoice_item_tx(tx, item).await?;
}
mark_activities_billed_tx(tx, billed_activity_ids, now).await?;
if let Some(anchor) = new_billing_anchor {
set_tenant_billing_anchor_tx(tx, tenant_pubkey, anchor).await?;
}
Ok(invoice)
})
.await
}
pub async fn mark_invoice_paid(invoice_id: &str, method: &str) -> Result<()> {
let updated_at = chrono::Utc::now().timestamp();
let activity = with_tx(async |tx| {
sqlx::query("UPDATE invoice SET status = 'paid', method = ?, updated_at = ? WHERE id = ?")
.bind(method)
.bind(updated_at)
.bind(invoice_id)
.execute(&mut **tx)
.await?;
insert_activity_tx(tx, "invoice_paid", "invoice", invoice_id).await
})
.await?;
publish(activity);
Ok(())
}
// --- Bolt11 records ---
pub async fn insert_bolt11(
invoice_id: &str,
lnbc: &str,
msats: i64,
expires_at: i64,
) -> Result<Option<Bolt11>> {
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().timestamp();
Ok(sqlx::query_as::<_, Bolt11>(
"INSERT INTO bolt11 (id, invoice_id, lnbc, msats, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
)
.bind(id)
.bind(invoice_id)
.bind(lnbc)
.bind(msats)
.bind(created_at)
.bind(expires_at)
.fetch_optional(pool())
.await?)
}
pub async fn mark_bolt11_settled(bolt11_id: &str) -> Result<()> {
let settled_at = chrono::Utc::now().timestamp();
sqlx::query("UPDATE bolt11 SET settled_at = ? WHERE id = ?")
.bind(settled_at)
.bind(bolt11_id)
.execute(pool())
.await?;
Ok(())
}
// --- Intents ---
/// Record the Stripe PaymentIntent that paid an invoice. Keyed by the Stripe
/// PaymentIntent id, so it's idempotent: a retried (idempotent) charge returns
/// the same id and the re-insert is a no-op rather than a primary-key conflict.
pub async fn insert_intent(intent_id: &str, invoice_id: &str) -> Result<()> {
let created_at = chrono::Utc::now().timestamp();
sqlx::query(
"INSERT INTO intent (id, invoice_id, created_at)
VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING",
)
.bind(intent_id)
.bind(invoice_id)
.bind(created_at)
.execute(pool())
.await?;
Ok(())
}
// --- Internal utils that take an explicit transaction ---
async fn insert_activity_tx(
tx: &mut Transaction<'_, Sqlite>,
activity_type: &str,
resource_type: &str,
resource_id: &str,
) -> Result<Activity> {
let tenant = match resource_type {
"tenant" => resource_id.to_string(),
"relay" => {
sqlx::query_scalar::<_, String>("SELECT tenant FROM relay WHERE id = ?")
.bind(resource_id)
.fetch_one(&mut **tx)
.await?
}
_ => anyhow::bail!("unknown resource_type: {resource_type}"),
};
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().timestamp();
sqlx::query(
"INSERT INTO activity (id, tenant, created_at, activity_type, resource_type, resource_id)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&tenant)
.bind(created_at)
.bind(activity_type)
.bind(resource_type)
.bind(resource_id)
.execute(&mut **tx)
.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 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)]
pub struct Env {
pub server_host: String,
@@ -27,15 +45,12 @@ pub struct Env {
pub livekit_api_key: String,
pub livekit_api_secret: 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.
pub keys: Keys,
}
impl Env {
pub fn load() -> Self {
fn load() -> Self {
let keys = Keys::parse(&require_str("ROBOT_SECRET"))
.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_secret: require_str("LIVEKIT_API_SECRET"),
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,
}
}
+26 -35
View File
@@ -2,33 +2,26 @@ use anyhow::Result;
use nostr_sdk::prelude::*;
use std::time::Duration;
use crate::command::Command;
use crate::env::Env;
use crate::command;
use crate::db;
use crate::env;
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_MAX_DELAY_SECS: u64 = 15 * 60;
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
#[derive(Clone)]
pub struct Infra {
env: Env,
query: Query,
command: Command,
}
pub struct Infra;
impl Infra {
pub fn new(query: Query, command: Command, env: &Env) -> Self {
Self {
env: env.clone(),
query,
command,
}
pub fn new() -> Self {
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 {
tracing::error!(error = %error, "failed to reconcile relay state on startup");
@@ -68,7 +61,7 @@ impl Infra {
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(());
};
@@ -77,7 +70,7 @@ impl Infra {
}
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() {
return Ok(());
@@ -112,7 +105,7 @@ impl Infra {
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
.iter()
.take_while(|activity| activity.activity_type == "fail_relay_sync")
@@ -142,7 +135,7 @@ impl Infra {
tokio::spawn(async move {
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(None) => {}
Err(e) => {
@@ -158,13 +151,13 @@ impl Infra {
match self.try_sync_relay(relay).await {
Ok(()) => {
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");
}
}
Err(e) => {
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");
}
}
@@ -177,14 +170,12 @@ impl Infra {
// otherwise check the activity history so that a re-sync after an update
// (which resets `synced` to 0) PATCHes instead of clobbering the secret.
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?
.is_none();
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,
"inactive": relay.status == RELAY_STATUS_INACTIVE
|| relay.status == RELAY_STATUS_DELINQUENT,
@@ -205,11 +196,11 @@ impl Infra {
"enabled": true,
"adapter": "s3",
"s3": {
"endpoint": self.env.blossom_s3_endpoint,
"region": self.env.blossom_s3_region,
"bucket": self.env.blossom_s3_bucket,
"access_key": self.env.blossom_s3_access_key,
"secret_key": self.env.blossom_s3_secret_key,
"endpoint": env::get().blossom_s3_endpoint,
"region": env::get().blossom_s3_region,
"bucket": env::get().blossom_s3_bucket,
"access_key": env::get().blossom_s3_access_key,
"secret_key": env::get().blossom_s3_secret_key,
"key_prefix": relay.id,
},
})
@@ -219,9 +210,9 @@ impl Infra {
"livekit": if relay.livekit_enabled == 1 {
serde_json::json!({
"enabled": true,
"server_url": self.env.livekit_url,
"api_key": self.env.livekit_api_key,
"api_secret": self.env.livekit_api_secret,
"server_url": env::get().livekit_url,
"api_key": env::get().livekit_api_key,
"api_secret": env::get().livekit_api_secret,
})
} else {
serde_json::json!({ "enabled": false })
@@ -274,10 +265,10 @@ impl Infra {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.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 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 {
HttpMethod::GET => reqwest::Method::GET,
+1 -1
View File
@@ -5,7 +5,7 @@ pub mod command;
pub mod env;
pub mod infra;
pub mod models;
pub mod pool;
pub mod db;
pub mod query;
pub mod robot;
pub mod routes;
+16 -15
View File
@@ -5,7 +5,7 @@ mod command;
mod env;
mod infra;
mod models;
mod pool;
mod db;
mod query;
mod robot;
mod routes;
@@ -20,10 +20,7 @@ use tower_http::cors::{AllowOrigin, CorsLayer, Any};
use crate::api::Api;
use crate::billing::Billing;
use crate::command::Command;
use crate::env::Env;
use crate::infra::Infra;
use crate::query::Query;
use crate::robot::Robot;
use crate::stripe::Stripe;
@@ -36,18 +33,17 @@ async fn main() -> Result<()> {
.with(tracing_subscriber::fmt::layer())
.init();
let env = Env::load();
env::init();
let pool = pool::create_pool(&env.database_url).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);
db::init().await?;
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
.iter()
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
@@ -68,7 +64,12 @@ async fn main() -> Result<()> {
});
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?;
Ok(())
}
+45 -15
View File
@@ -12,6 +12,7 @@ pub struct Activity {
pub activity_type: String,
pub resource_type: String,
pub resource_id: String,
pub billed_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -22,7 +23,6 @@ pub struct Plan {
pub members: Option<i64>,
pub blossom: bool,
pub livekit: bool,
pub stripe_price_id: Option<String>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, sqlx::FromRow)]
@@ -31,21 +31,8 @@ pub struct Tenant {
pub nwc_url: String,
pub nwc_error: Option<String>,
pub created_at: i64,
pub billing_anchor: Option<i64>,
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)]
@@ -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 sqlx::SqlitePool;
use crate::env::Env;
use crate::models::{Activity, LightningInvoice, Plan, Relay, Tenant};
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Plan, Relay, Tenant};
use crate::db::pool;
fn select_tenant(tail: &str) -> String {
format!("SELECT * FROM tenant {tail}")
@@ -16,161 +15,168 @@ fn select_activity(tail: &str) -> String {
format!("SELECT * FROM activity {tail}")
}
#[derive(Clone)]
pub struct Query {
pool: SqlitePool,
env: Env,
// Plans
pub fn list_plans() -> Vec<Plan> {
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 new(pool: SqlitePool, env: &Env) -> Self {
Self {
pool,
env: env.clone(),
}
}
// Plans
pub fn list_plans(&self) -> Vec<Plan> {
vec![
Plan {
id: "free".to_string(),
name: "Free".to_string(),
amount: 0,
members: Some(10),
blossom: false,
livekit: false,
stripe_price_id: None,
},
Plan {
id: "basic".to_string(),
name: "Basic".to_string(),
amount: 500,
members: Some(100),
blossom: true,
livekit: true,
stripe_price_id: Some(self.env.stripe_price_basic.clone()),
},
Plan {
id: "growth".to_string(),
name: "Growth".to_string(),
amount: 2500,
members: None,
blossom: true,
livekit: true,
stripe_price_id: Some(self.env.stripe_price_growth.clone()),
},
]
}
pub fn get_plan(&self, plan_id: &str) -> Option<Plan> {
self.list_plans().into_iter().find(|p| p.id == plan_id)
}
pub fn is_paid_plan(&self, plan_id: &str) -> bool {
self.get_plan(plan_id).is_some_and(|p| p.amount > 0)
}
// Tenants
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
let rows = sqlx::query_as::<_, Tenant>(&select_tenant(""))
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
.bind(pubkey)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub async fn get_tenant_by_stripe_customer_id(
&self,
stripe_customer_id: &str,
) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE stripe_customer_id = ?"))
.bind(stripe_customer_id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
// Relays
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(&select_relay(""))
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(&select_relay(
"WHERE synced = 0 OR TRIM(sync_error) != ''",
))
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant = ?"))
.bind(tenant_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
let row = sqlx::query_as::<_, Relay>(&select_relay("WHERE id = ?"))
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
// Invoices
pub async fn get_lightning_invoice(
&self,
stripe_invoice_id: &str,
) -> Result<Option<LightningInvoice>> {
let row = sqlx::query_as::<_, LightningInvoice>(
"SELECT * FROM lightning_invoice WHERE stripe_invoice_id = ?",
)
.bind(stripe_invoice_id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
// Activity
pub async fn list_activity_for_resource(&self, resource_id: &str) -> Result<Vec<Activity>> {
let rows = sqlx::query_as::<_, Activity>(&select_activity("WHERE resource_id = ? ORDER BY created_at DESC"))
.bind(resource_id)
.fetch_all(&self.pool)
.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)
}
pub fn get_plan(plan_id: &str) -> Option<Plan> {
list_plans().into_iter().find(|p| p.id == plan_id)
}
// Tenants
pub async fn list_tenants() -> Result<Vec<Tenant>> {
Ok(sqlx::query_as::<_, Tenant>(&select_tenant(""))
.fetch_all(pool())
.await?)
}
pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
Ok(sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
.bind(pubkey)
.fetch_optional(pool())
.await?)
}
// Relays
pub async fn list_relays() -> Result<Vec<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay(""))
.fetch_all(pool())
.await?)
}
pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
Ok(
sqlx::query_as::<_, Relay>(&select_relay("WHERE synced = 0 OR TRIM(sync_error) != ''"))
.fetch_all(pool())
.await?,
)
}
pub async fn list_relays_for_tenant(tenant_id: &str) -> Result<Vec<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant = ?"))
.bind(tenant_id)
.fetch_all(pool())
.await?)
}
pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE id = ?"))
.bind(id)
.fetch_optional(pool())
.await?)
}
// Invoices
pub async fn get_invoice(invoice_id: &str) -> Result<Option<Invoice>> {
Ok(sqlx::query_as::<_, Invoice>("SELECT * FROM invoice WHERE id = ?")
.bind(invoice_id)
.fetch_optional(pool())
.await?)
}
pub async fn list_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
Ok(sqlx::query_as::<_, Invoice>(
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC",
)
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
}
pub async fn get_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<InvoiceItem>> {
Ok(
sqlx::query_as::<_, InvoiceItem>("SELECT * FROM invoice_item WHERE invoice_id = ?")
.bind(invoice_id)
.fetch_all(pool())
.await?,
)
}
pub async fn get_bolt11(bolt11_id: &str) -> Result<Option<Bolt11>> {
Ok(sqlx::query_as::<_, Bolt11>("SELECT * FROM bolt11 WHERE id = ?")
.bind(bolt11_id)
.fetch_optional(pool())
.await?)
}
pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result<Option<Bolt11>> {
Ok(sqlx::query_as::<_, Bolt11>(
"SELECT * FROM bolt11 WHERE invoice_id = ? ORDER BY created_at DESC LIMIT 1",
)
.bind(invoice_id)
.fetch_optional(pool())
.await?)
}
// Activity
/// 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
/// caller reconciles off a precise marker rather than a timestamp watermark.
/// Ordered oldest-first so line items and proration apply in event order.
pub async fn list_billable_activity_for_tenant(tenant_pubkey: &str) -> Result<Vec<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE tenant = ?
AND billed_at IS NULL
AND activity_type IN (
'create_relay', 'update_relay', 'activate_relay',
'deactivate_relay', 'autogenerate_invoice'
)
ORDER BY created_at ASC",
))
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
}
pub async fn list_activity_for_resource(resource_id: &str) -> Result<Vec<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE resource_id = ? ORDER BY created_at DESC",
))
.bind(resource_id)
.fetch_all(pool())
.await?)
}
pub async fn get_latest_activity_for_resource_and_type(
resource_id: &str,
activity_type: &str,
) -> Result<Option<Activity>> {
Ok(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(pool())
.await?)
}
+15 -17
View File
@@ -5,11 +5,10 @@ use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use tokio::sync::Mutex;
use crate::env::Env;
use crate::env;
#[derive(Clone)]
pub struct Robot {
env: Env,
outbox_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 {
pub async fn new(env: &Env) -> Result<Self> {
pub async fn new() -> Result<Self> {
let robot = Self {
env: env.clone(),
outbox_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> {
let client = Client::new(self.env.keys.clone());
let client = Client::new(env::get().keys.clone());
for relay in relays {
client.add_relay(relay).await?;
}
@@ -46,24 +44,24 @@ impl Robot {
&self,
) -> Result<()> {
let mut metadata = Metadata::new();
if !self.env.robot_name.is_empty() {
metadata = metadata.name(&self.env.robot_name);
if !env::get().robot_name.is_empty() {
metadata = metadata.name(&env::get().robot_name);
}
if !self.env.robot_description.is_empty() {
metadata = metadata.about(&self.env.robot_description);
if !env::get().robot_description.is_empty() {
metadata = metadata.about(&env::get().robot_description);
}
if !self.env.robot_picture.is_empty() {
metadata = metadata.picture(Url::parse(&self.env.robot_picture)?);
if !env::get().robot_picture.is_empty() {
metadata = metadata.picture(Url::parse(&env::get().robot_picture)?);
}
let outbox_client = self.make_client(&self.env.robot_outbox_relays).await?;
let indexer_client = self.make_client(&self.env.robot_indexer_relays).await?;
let outbox_client = self.make_client(&env::get().robot_outbox_relays).await?;
let indexer_client = self.make_client(&env::get().robot_indexer_relays).await?;
outbox_client
.send_event_builder(EventBuilder::metadata(&metadata))
.await?;
let outbox_tags = self.env.robot_outbox_relays
let outbox_tags = env::get().robot_outbox_relays
.iter()
.map(|r| Tag::parse(["r", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?;
@@ -71,7 +69,7 @@ impl Robot {
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
.await?;
let messaging_tags = self.env.robot_messaging_relays
let messaging_tags = env::get().robot_messaging_relays
.iter()
.map(|r| Tag::parse(["relay", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?;
@@ -108,7 +106,7 @@ impl Robot {
let pubkey = PublicKey::parse(recipient)?;
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 mut relays = Vec::new();
@@ -128,7 +126,7 @@ impl Robot {
pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String> {
let pubkey = PublicKey::parse(pubkey).ok()?;
let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1);
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 event = events.into_iter().max_by_key(|e| e.created_at)?;
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 crate::api::{Api, AuthedPubkey};
use crate::query;
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(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
return Err(not_found("invoice not found"));
};
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(&invoice.customer)
let invoice = query::get_invoice(&id)
.await
.map_err(internal)?
else {
return Err(not_found("invoice not found"));
};
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
let invoice = api
.billing
.reconcile_invoice(&invoice)
.await
.map_err(internal)?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
ok(invoice)
}
pub async fn get_lightning_invoice(
pub async fn get_invoice_bolt11(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
Path(invoice_id): Path<String>,
) -> ApiResult {
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
return Err(not_found("invoice not found"));
};
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(&invoice.customer)
let invoice = query::get_invoice(&invoice_id)
.await
.map_err(internal)?
else {
return Err(not_found("invoice not found"));
};
.ok_or_else(|| 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
.reconcile_invoice(&invoice)
.ensure_and_reconcile_bolt11(&invoice_id)
.await
.map_err(internal)?;
let lightning_invoice = api
.billing
.ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency)
.await
.map_err(internal)?;
ok(serde_json::json!(lightning_invoice))
ok(serde_json::json!(bolt11))
}
-1
View File
@@ -2,5 +2,4 @@ pub mod identity;
pub mod invoices;
pub mod plans;
pub mod relays;
pub mod stripe;
pub mod tenants;
+5 -4
View File
@@ -3,14 +3,15 @@ use std::sync::Arc;
use axum::extract::{Path, State};
use crate::api::Api;
use crate::query;
use crate::web::{ApiResult, not_found, ok};
pub async fn list_plans(State(api): State<Arc<Api>>) -> ApiResult {
ok(api.query.list_plans())
pub async fn list_plans(State(_api): State<Arc<Api>>) -> ApiResult {
ok(query::list_plans())
}
pub async fn get_plan(State(api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
match api.query.get_plan(&id) {
pub async fn get_plan(State(_api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
match query::get_plan(&id) {
Some(plan) => ok(plan),
None => Err(not_found("plan not found")),
}
+51 -54
View File
@@ -9,6 +9,7 @@ use regex::Regex;
use serde::Deserialize;
use crate::api::{Api, AuthedPubkey};
use crate::{command, query};
use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
};
@@ -17,46 +18,13 @@ use crate::web::{
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(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult {
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)
}
@@ -78,9 +46,7 @@ pub async fn list_relay_activity(
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
let activity = api
.query
.list_activity_for_resource(&id)
let activity = query::list_activity_for_resource(&id)
.await
.map_err(internal)?;
ok(serde_json::json!({ "activity": activity }))
@@ -98,6 +64,23 @@ pub async fn list_relay_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(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
@@ -129,15 +112,31 @@ pub async fn create_relay(
..Default::default()
};
let relay = prepare_relay(&api, relay)?;
let relay = prepare_relay(relay)?;
api.command
.create_relay(&relay)
command::create_relay(&relay)
.await
.map_err(map_relay_write_error)?;
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(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
@@ -145,6 +144,7 @@ pub async fn update_relay(
Json(payload): Json<UpdateRelayRequest>,
) -> ApiResult {
let mut relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
let current_plan = relay.plan.clone();
@@ -187,17 +187,15 @@ pub async fn update_relay(
relay.push_enabled = v;
}
let relay = prepare_relay(&api, relay)?;
let relay = prepare_relay(relay)?;
let plan_changed = requested_plan
.as_deref()
.is_some_and(|requested| requested != current_plan);
if plan_changed {
let selected_plan = api
.query
.get_plan(&relay.plan)
.expect("validated plan must exist");
let selected_plan =
query::get_plan(&relay.plan).expect("validated plan must exist");
if let Some(limit) = selected_plan.members {
let current_members = fetch_relay_members(&api, &relay)
.await
@@ -214,10 +212,10 @@ pub async fn update_relay(
}
}
api.command
.update_relay(&relay)
command::update_relay(&relay)
.await
.map_err(map_relay_write_error)?;
ok(relay)
}
@@ -237,10 +235,10 @@ pub async fn deactivate_relay(
return Err(bad_request("relay-is-inactive", "relay is already inactive"));
}
api.command
.deactivate_relay(&relay)
command::deactivate_relay(&relay)
.await
.map_err(internal)?;
ok(())
}
@@ -260,7 +258,8 @@ pub async fn reactivate_relay(
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(())
}
@@ -279,15 +278,13 @@ const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"];
static SUBDOMAIN_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$").unwrap());
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)
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str()) {
return Err(unprocessable("invalid-subdomain", "subdomain is invalid"));
}
let plan = api
.query
.get_plan(&relay.plan)
let plan = query::get_plan(&relay.plan)
.ok_or_else(|| unprocessable("invalid-plan", "plan not found"))?;
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::{
Json,
extract::{Path, State},
extract::{Path, Query, State},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
use crate::api::{Api, AuthedPubkey};
use crate::models::Tenant;
use crate::web::{ApiResult, internal, map_unique_error, ok};
use crate::{command, env, query};
#[derive(Serialize)]
pub struct TenantResponse {
@@ -17,9 +18,8 @@ pub struct TenantResponse {
pub nwc_is_set: bool,
pub nwc_error: Option<String>,
pub created_at: i64,
pub billing_anchor: Option<i64>,
pub stripe_customer_id: String,
pub stripe_subscription_id: Option<String>,
pub past_due_at: Option<i64>,
}
impl From<Tenant> for TenantResponse {
@@ -29,39 +29,30 @@ impl From<Tenant> for TenantResponse {
pubkey: t.pubkey,
nwc_error: t.nwc_error,
created_at: t.created_at,
billing_anchor: t.billing_anchor,
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(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult {
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
.into_iter()
.map(TenantResponse::from)
.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(
State(api): State<Arc<Api>>,
AuthedPubkey(pubkey): AuthedPubkey,
) -> 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));
}
@@ -84,10 +75,10 @@ pub async fn create_tenant(
..Default::default()
};
match api.command.create_tenant(&tenant).await {
match command::create_tenant(&tenant).await {
Ok(()) => ok(TenantResponse::from(tenant)),
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(None) => Err(internal("tenant row missing after unique-constraint race")),
Err(e) => Err(internal(e)),
@@ -107,6 +98,11 @@ pub async fn get_tenant(
ok(TenantResponse::from(tenant))
}
#[derive(Deserialize)]
pub struct UpdateTenantRequest {
pub nwc_url: Option<String>,
}
pub async fn update_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
@@ -121,11 +117,11 @@ pub async fn update_tenant(
if nwc_url.is_empty() {
tenant.nwc_url = String::new();
} 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))
}
@@ -136,10 +132,47 @@ pub async fn list_tenant_relays(
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let relays = api
.query
.list_relays_for_tenant(&pubkey)
let relays = query::list_relays_for_tenant(&pubkey)
.await
.map_err(internal)?;
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 hmac::{Hmac, Mac};
use sha2::Sha256;
use std::collections::BTreeMap;
use crate::env::Env;
use crate::env;
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
#[derive(Clone)]
pub struct Stripe {
env: Env,
http: reqwest::Client,
}
impl Stripe {
pub fn new(env: &Env) -> Self {
pub fn new() -> Self {
Self {
env: env.clone(),
http: reqwest::Client::new(),
}
}
@@ -101,23 +31,17 @@ impl Stripe {
fn get(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.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 {
self.http
.post(format!("{STRIPE_API}{path}"))
.bearer_auth(&self.env.stripe_secret_key)
}
fn delete(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.delete(format!("{STRIPE_API}{path}"))
.bearer_auth(&self.env.stripe_secret_key)
.bearer_auth(&env::get().stripe_secret_key)
}
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");
for (i, part) in parts.iter().enumerate() {
if i > 0 {
@@ -146,153 +70,74 @@ impl Stripe {
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 ---
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
.get("/payment_methods")
.query(&[("customer", customer_id), ("type", "card")])
.send_json()
.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 ---
@@ -316,47 +161,13 @@ impl Stripe {
.map(str::to_string)
.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 {
async fn send_ok(self) -> Result<reqwest::Response>;
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 {
@@ -367,14 +178,6 @@ impl StripeRequest for reqwest::RequestBuilder {
async fn send_json(self) -> Result<serde_json::Value> {
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"
+1 -1
View File
@@ -33,7 +33,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const planById = new Map(plans().map((p) => [p.id, p]))
return (relays() ?? [])
.map((relay) => ({ relay, plan: planById.get(relay.plan) }))
.filter((entry) => entry.plan?.stripe_price_id)
.filter((entry) => entry.plan?.amount > 0)
})
async function loadBolt11() {
-1
View File
@@ -35,7 +35,6 @@ export type Plan = {
id: string
name: string
amount: number
stripe_price_id: string | null
members: number | null
blossom: boolean
livekit: boolean