Compare commits

..

1 Commits

Author SHA1 Message Date
userAdityaa 41602e21c2 feat: display relay provisioning errors in UI 2026-04-20 18:08:38 +00:00
27 changed files with 189 additions and 1521 deletions
+3 -10
View File
@@ -74,20 +74,13 @@ Public exceptions:
- `GET /tenants` — list tenants (admin) - `GET /tenants` — list tenants (admin)
- `POST /tenants` — idempotently ensure a tenant row exists for the current auth pubkey (creates Stripe customer + tenant on first call, returns existing tenant otherwise) - `POST /tenants` — idempotently ensure a tenant row exists for the current auth pubkey (creates Stripe customer + tenant on first call, returns existing tenant otherwise)
- `GET /tenants/:pubkey` — get tenant (admin or same tenant) - `GET /tenants/:pubkey` — get tenant (admin or same tenant)
- `PUT /tenants/:pubkey` — update tenant `nwc_url` (admin or same tenant) - `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant)
- `GET /tenants/:pubkey/relays` — list tenant relays (admin or same tenant) - `GET /relays` — list relays (`?tenant=<pubkey>` allowed for admin only)
- `GET /relays` — list relays (admin)
- `POST /relays` — create relay (admin or relay tenant) - `POST /relays` — create relay (admin or relay tenant)
- `GET /relays/:id` — get relay (admin or relay tenant) - `GET /relays/:id` — get relay (admin or relay tenant)
- `GET /relays/:id/members` — list relay members from zooid (admin or relay tenant)
- `PUT /relays/:id` — update relay (admin or relay tenant) - `PUT /relays/:id` — update relay (admin or relay tenant)
- `GET /relays/:id/activity` — list relay activity (admin or relay tenant)
- `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant) - `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant)
- `POST /relays/:id/reactivate` — reactivate relay (admin or relay tenant) - `GET /invoices` — list invoices (`?tenant=<pubkey>` allowed for admin only)
- `GET /tenants/:pubkey/invoices` — list tenant invoices (admin or same tenant)
- `GET /invoices/:id` — get invoice (admin or same tenant)
- `GET /invoices/:id/bolt11` — get invoice bolt11 (admin or same tenant)
- `GET /tenants/:pubkey/stripe/session` — create Stripe customer portal session (admin or same tenant)
## API Auth Model ## API Auth Model
@@ -1,11 +0,0 @@
CREATE INDEX IF NOT EXISTS idx_tenant_stripe_customer_id
ON tenant (stripe_customer_id);
CREATE INDEX IF NOT EXISTS idx_relay_tenant_id
ON relay (tenant, id);
CREATE INDEX IF NOT EXISTS idx_relay_tenant_status_plan
ON relay (tenant, status, plan);
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);
@@ -1,11 +0,0 @@
CREATE TABLE IF NOT EXISTS invoice_nwc_payment (
invoice_id TEXT PRIMARY KEY,
tenant_pubkey TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'paid')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE INDEX IF NOT EXISTS idx_invoice_nwc_payment_tenant_pubkey
ON invoice_nwc_payment (tenant_pubkey);
+1 -11
View File
@@ -9,7 +9,6 @@ Members:
- `query: Query` - `query: Query`
- `command: Command` - `command: Command`
- `billing: Billing` - `billing: Billing`
- `infra: Infra`
Notes: Notes:
@@ -104,14 +103,6 @@ Notes:
- Authorizes admin or relay owner - Authorizes admin or relay owner
- Return `data` is a single relay struct from `query.get_relay` - Return `data` is a single relay struct from `query.get_relay`
## `async fn list_relay_members(...) -> Response`
- Serves `GET /relays/:id/members`
- Authorizes admin or relay owner
- For unsynced relays, returns an empty member list without calling zooid
- For synced relays, proxies the member list from zooid via `infra`
- Return `data` is `{ members }`
## `async fn create_relay(...) -> Response` ## `async fn create_relay(...) -> Response`
- Serves `POST /relays` - Serves `POST /relays`
@@ -126,7 +117,6 @@ Notes:
- Serves `PUT /relays/:id` - Serves `PUT /relays/:id`
- Authorizes admin or relay owner - Authorizes admin or relay owner
- Validates/prepares the relay data to be saved using `prepare_relay` - Validates/prepares the relay data to be saved using `prepare_relay`
- If the requested plan changes to a plan with a finite member limit and the current member count exceeds that limit, return a `422` with `code=member-limit-exceeded`
- Updates the given relay using `command.update_relay` - Updates the given relay using `command.update_relay`
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists` - If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
- Return `data` is a single relay struct. - Return `data` is a single relay struct.
@@ -142,7 +132,7 @@ Notes:
- Serves `POST /relays/:id/deactivate` - Serves `POST /relays/:id/deactivate`
- Authorizes admin or relay owner - Authorizes admin or relay owner
- If relay status is `inactive` or `delinquent`, return a `400` with `code=relay-is-inactive` - If relay is already inactive, return a `400` with `code=relay-is-inactive`
- Call `command.deactivate_relay` - Call `command.deactivate_relay`
- Return `data` is empty - Return `data` is empty
+7 -37
View File
@@ -26,15 +26,12 @@ Members:
Manages the Stripe subscription and subscription items for a relay's tenant. Only paid (non-free) relays interact with Stripe. Free-only tenants have no subscription. Must be idempotent. Manages the Stripe subscription and subscription items for a relay's tenant. Only paid (non-free) relays interact with Stripe. Free-only tenants have no subscription. Must be idempotent.
Stripe uses **pay-in-advance** by default: when a subscription is first created, Stripe immediately generates an open invoice for the current period. The `invoice.created` webhook fires shortly after and `handle_invoice_created` attempts payment.
- Fetch the relay and tenant associated with the `activity` - Fetch the relay and tenant associated with the `activity`
- **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then run downgrade proration validation by previewing the upcoming invoice and logging proration lines/amounts. Then check cleanup (below). Return early. - **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
- **If relay is `inactive` or `delinquent`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early. - **If relay is `inactive`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
- **If relay is `active` and on a paid plan**: - **If relay is `active` and on a paid plan**:
- **Ensure subscription exists**: If the tenant has no `stripe_subscription_id`, create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and the relay's price as the first item. Save the subscription ID via `command.set_tenant_subscription` and the item ID via `command.set_relay_subscription_item`. Return early. - **Ensure subscription exists**: If the tenant has no `stripe_subscription_id`, create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and the relay's price as the first item. Save the subscription ID via `command.set_tenant_subscription` and the item ID via `command.set_relay_subscription_item`. Return early.
- **Sync the subscription item**: If the tenant already has a subscription, create or update the relay's Stripe subscription item to the plan's `stripe_price_id` via the Stripe API, then call `command.set_relay_subscription_item`. - **Sync the subscription item**: If the tenant already has a subscription, create or update the relay's Stripe subscription item to the plan's `stripe_price_id` via the Stripe API, then call `command.set_relay_subscription_item`.
- **Downgrade validation**: when changing an existing subscription item, detect if the new plan amount is lower than the current one. If yes, preview the upcoming Stripe invoice and log proration line/amount details to validate expected credit/proration behavior.
- **Clean up empty subscription**: After any item deletion, check if the tenant has any remaining active paid relays. If none and the tenant has a `stripe_subscription_id`, cancel the Stripe subscription immediately and call `command.clear_tenant_subscription`. - **Clean up empty subscription**: After any item deletion, check if the tenant has any remaining active paid relays. If none and the tenant has a `stripe_subscription_id`, cancel the Stripe subscription immediately and call `command.clear_tenant_subscription`.
## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>` ## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>`
@@ -47,7 +44,6 @@ Stripe uses **pay-in-advance** by default: when a subscription is first created,
- `invoice.overdue` -> `self.handle_invoice_overdue` - `invoice.overdue` -> `self.handle_invoice_overdue`
- `customer.subscription.updated` -> `self.handle_subscription_updated` - `customer.subscription.updated` -> `self.handle_subscription_updated`
- `customer.subscription.deleted` -> `self.handle_subscription_deleted` - `customer.subscription.deleted` -> `self.handle_subscription_deleted`
- `payment_method.attached` -> `self.handle_payment_method_attached`
- Unknown event types are ignored (return Ok) - Unknown event types are ignored (return Ok)
## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<Value>` ## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<Value>`
@@ -70,37 +66,16 @@ Stripe uses **pay-in-advance** by default: when a subscription is first created,
- Creates a Stripe Customer Portal session for the given customer - Creates a Stripe Customer Portal session for the given customer
- Returns the portal session URL - Returns the portal session URL
## `pub async fn pay_outstanding_nwc_invoices(&self, tenant: &Tenant) -> Result<()>`
Called when a tenant first sets their NWC URL (via `PUT /tenants/:pubkey`). Attempts to pay any currently open invoices for the tenant using their NWC wallet, so that invoices created before NWC was configured are not left unpaid.
- If `tenant.nwc_url` is empty, return early.
- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe_list_invoices`.
- For each invoice with `status == "open"` and `amount_due > 0`:
- Attempt NWC payment via `nwc_pay_invoice`.
- On success: call `stripe_pay_invoice_out_of_band` and `command.clear_tenant_nwc_error`.
- On failure: call `command.set_tenant_nwc_error` and log the error; continue to the next invoice.
## `pub async fn pay_outstanding_card_invoices(&self, tenant: &Tenant) -> Result<()>`
Attempts Stripe-side collection for open invoices when the tenant has a card on file.
- If tenant has no card payment method, return early.
- List all Stripe invoices for `tenant.stripe_customer_id`.
- For each invoice with `status == "open"` and `amount_due > 0`:
- Call Stripe `POST /v1/invoices/:id/pay` to retry collection using the card on file.
- Log and continue on failures.
## `fn handle_invoice_created(&self, invoice: &Invoice)` ## `fn handle_invoice_created(&self, invoice: &Invoice)`
Attempts to pay a new subscription invoice. Because Stripe defaults to pay-in-advance, this webhook fires immediately when a subscription is created (i.e. when a paid relay is added or a plan is upgraded). Payment priority: Attempts to pay a new subscription invoice. Payment priority:
1. **NWC auto-pay**: If the tenant has a `nwc_url`: 1. **NWC auto-pay**: If the tenant has a `nwc_url`:
- Create a bolt11 Lightning invoice for the invoice amount using `self.nwc_url` (the receiving/system wallet) - Create a bolt11 Lightning invoice for the invoice amount using `self.nwc_url` (the receiving/system wallet)
- Pay the bolt11 invoice using the tenant's `nwc_url` (the spending/tenant wallet) - Pay the bolt11 invoice using the tenant's `nwc_url` (the spending/tenant wallet)
- If payment succeeds: call Stripe `POST /v1/invoices/:id/pay` with `paid_out_of_band: true`. Clear `nwc_error` via `command.clear_tenant_nwc_error`. - If payment succeeds: call Stripe `POST /v1/invoices/:id/pay` with `paid_out_of_band: true`. Clear `nwc_error` via `command.clear_tenant_nwc_error`.
- If payment fails: set `nwc_error` on tenant via `command.set_tenant_nwc_error`. Fall through to next option. - If payment fails: set `nwc_error` on tenant via `command.set_tenant_nwc_error`. Fall through to next option.
2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing here — Stripe will charge automatically for this invoice attempt. 2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing — Stripe will charge automatically.
3. **Manual payment**: If neither NWC nor card is available, send a DM via `robot.send_dm` notifying the tenant that payment is due with a link to the application for manual Lightning payment. 3. **Manual payment**: If neither NWC nor card is available, send a DM via `robot.send_dm` notifying the tenant that payment is due with a link to the application for manual Lightning payment.
Skip invoices with `amount_due` of 0. Skip invoices with `amount_due` of 0.
@@ -110,7 +85,7 @@ Skip invoices with `amount_due` of 0.
- Look up tenant by `stripe_customer_id` - Look up tenant by `stripe_customer_id`
- If tenant has `past_due_at` set: - If tenant has `past_due_at` set:
- Clear `past_due_at` via `command.clear_tenant_past_due` - Clear `past_due_at` via `command.clear_tenant_past_due`
- Find all `delinquent` relays on paid plans for the tenant (relays marked delinquent by the billing system due to non-payment) - Find all `inactive` relays for the tenant that were deactivated due to non-payment (i.e. relays on paid plans that are inactive)
- Reactivate each one via `command.activate_relay` - Reactivate each one via `command.activate_relay`
## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)` ## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)`
@@ -123,7 +98,7 @@ Skip invoices with `amount_due` of 0.
## `fn handle_invoice_overdue(&self, invoice: &Invoice)` ## `fn handle_invoice_overdue(&self, invoice: &Invoice)`
- Look up tenant by `stripe_customer_id` - Look up tenant by `stripe_customer_id`
- Mark all active relays on paid plans as delinquent via `command.mark_relay_delinquent` (sets status to `delinquent`, distinct from user-initiated `deactivate_relay`) - Deactivate all active relays on paid plans via `command.deactivate_relay`
- Send a DM via `robot.send_dm` notifying the tenant that their paid relays have been deactivated due to non-payment - Send a DM via `robot.send_dm` notifying the tenant that their paid relays have been deactivated due to non-payment
## `fn handle_subscription_updated(&self, subscription: &Subscription)` ## `fn handle_subscription_updated(&self, subscription: &Subscription)`
@@ -131,14 +106,9 @@ Skip invoices with `amount_due` of 0.
- Look up tenant by `stripe_customer_id` - Look up tenant by `stripe_customer_id`
- If subscription status is `canceled` or `unpaid`: - If subscription status is `canceled` or `unpaid`:
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription` - Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
- Mark all active paid relays as delinquent via `command.mark_relay_delinquent` - Deactivate all active paid relays for the tenant via `command.deactivate_relay`
## `fn handle_subscription_deleted(&self, subscription: &Subscription)` ## `fn handle_subscription_deleted(&self, subscription: &Subscription)`
- Look up tenant by `stripe_customer_id` - Look up tenant by `stripe_customer_id`
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription` - Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
## `fn handle_payment_method_attached(&self, stripe_customer_id: &str)`
- Look up tenant by `stripe_customer_id`
- Call `pay_outstanding_card_invoices` so invoices that were due before card setup are retried immediately
-8
View File
@@ -44,14 +44,6 @@ Notes:
- Sets relay status to `inactive` - Sets relay status to `inactive`
- Logs activity as `(deactivate_relay, relay_id)` - Logs activity as `(deactivate_relay, relay_id)`
- Used for user/admin-initiated deactivation only
## `pub fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `delinquent`
- Logs activity as `(deactivate_relay, relay_id)`
- Used exclusively by the billing system when a relay's subscription becomes past due
- `delinquent` relays are automatically reactivated via `activate_relay` when payment is received
## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>` ## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>`
+2 -5
View File
@@ -15,15 +15,12 @@ Members:
## `pub async fn start(self)` ## `pub async fn start(self)`
- Subscribes to `command.notify` - Subscribes to `command.notify`
- On startup, schedules delayed sync retries for relays whose `sync_error` is non-empty.
- Loops on `rx.recv()`, calling `handle_activity` for each received `Activity`. - Loops on `rx.recv()`, calling `handle_activity` for each received `Activity`.
## `async fn handle_activity(&self, activity: &Activity)` ## `async fn handle_activity(&self, activity: &Activity)`
- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report` immediately. - For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report`.
- For `fail_relay_sync`, schedules a delayed retry using exponential backoff based on consecutive failures for the relay. - All other activity types are ignored (e.g. `fail_relay_sync`, `complete_relay_sync`).
- Retry scheduling stops after the configured max attempts to avoid infinite retry loops.
- Other activity types are ignored (e.g. `complete_relay_sync`).
## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)` ## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)`
+1 -1
View File
@@ -69,7 +69,7 @@ A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid in
- `subdomain` - the relay's subdomain - `subdomain` - the relay's subdomain
- `plan` - the relay's plan - `plan` - the relay's plan
- `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans. - `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans.
- `status` - one of `active|inactive|delinquent`. Only `active` relays count toward billing. `delinquent` is set by the billing system when a relay's subscription becomes past due; `inactive` is set when a user or admin manually deactivates a relay. - `status` - `active|inactive`. Only `active` relays count toward billing.
- `synced` - whether the relay has been successfully synced to zooid at least once. - `synced` - whether the relay has been successfully synced to zooid at least once.
- `sync_error` - a string indicating any errors encountered when synchronizing. - `sync_error` - a string indicating any errors encountered when synchronizing.
- `info_name` - the relay's name - `info_name` - the relay's name
+54 -208
View File
@@ -1,6 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Result, anyhow}; use anyhow::anyhow;
use axum::{ use axum::{
Json, Router, Json, Router,
extract::{Path, State}, extract::{Path, State},
@@ -14,7 +14,6 @@ use serde::{Deserialize, Serialize};
use crate::billing::{Billing, InvoiceLookupError}; use crate::billing::{Billing, InvoiceLookupError};
use crate::command::Command; use crate::command::Command;
use crate::infra::Infra;
use crate::models::{ use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
}; };
@@ -28,7 +27,6 @@ pub struct Api {
query: Query, query: Query,
command: Command, command: Command,
billing: Billing, billing: Billing,
infra: Infra,
} }
async fn stripe_webhook( async fn stripe_webhook(
@@ -119,7 +117,7 @@ fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError {
} }
impl Api { impl Api {
pub fn new(query: Query, command: Command, billing: Billing, infra: Infra) -> Self { pub fn new(query: Query, command: Command, billing: Billing) -> Self {
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let admins = std::env::var("ADMINS") let admins = std::env::var("ADMINS")
.unwrap_or_default() .unwrap_or_default()
@@ -133,7 +131,6 @@ impl Api {
query, query,
command, command,
billing, billing,
infra,
} }
} }
@@ -151,7 +148,6 @@ impl Api {
.route("/tenants/:pubkey/relays", get(list_tenant_relays)) .route("/tenants/:pubkey/relays", get(list_tenant_relays))
.route("/relays", get(list_relays).post(create_relay)) .route("/relays", get(list_relays).post(create_relay))
.route("/relays/:id", get(get_relay).put(update_relay)) .route("/relays/:id", get(get_relay).put(update_relay))
.route("/relays/:id/members", get(list_relay_members))
.route("/relays/:id/activity", get(list_relay_activity)) .route("/relays/:id/activity", get(list_relay_activity))
.route("/relays/:id/deactivate", post(deactivate_relay)) .route("/relays/:id/deactivate", post(deactivate_relay))
.route("/relays/:id/reactivate", post(reactivate_relay)) .route("/relays/:id/reactivate", post(reactivate_relay))
@@ -255,24 +251,22 @@ impl Api {
} }
} }
async fn fetch_relay_members(&self, relay: &Relay) -> Result<Vec<String>> { fn prepare_relay(&self, mut relay: Relay) -> anyhow::Result<Relay> {
if relay.synced == 0 { if !relay
return Ok(Vec::new()); .subdomain
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(anyhow!("invalid-subdomain"));
} }
self.infra.list_relay_members(&relay.id).await let plan = Query::get_plan(&relay.plan).ok_or_else(|| anyhow!("invalid-plan"))?;
}
fn prepare_relay(&self, mut relay: Relay) -> std::result::Result<Relay, RelayValidationError> {
validate_subdomain_label(&relay.subdomain)?;
let plan = Query::get_plan(&relay.plan).ok_or(RelayValidationError::InvalidPlan)?;
if !plan.blossom && relay.blossom_enabled == 1 { if !plan.blossom && relay.blossom_enabled == 1 {
return Err(RelayValidationError::PremiumFeature); return Err(anyhow!("premium-feature"));
} }
if !plan.livekit && relay.livekit_enabled == 1 { if !plan.livekit && relay.livekit_enabled == 1 {
return Err(RelayValidationError::PremiumFeature); return Err(anyhow!("premium-feature"));
} }
if relay.schema.is_empty() { if relay.schema.is_empty() {
@@ -295,96 +289,6 @@ impl Api {
} }
} }
const SUBDOMAIN_LABEL_MAX_LEN: usize = 63;
const RESERVED_SUBDOMAIN_LABELS: [&str; 2] = ["api", "admin"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SubdomainValidationError {
Empty,
TooLong,
Reserved,
EdgeHyphen,
InvalidCharacters,
}
impl SubdomainValidationError {
fn code(self) -> &'static str {
match self {
Self::Empty => "subdomain-empty",
Self::TooLong => "subdomain-too-long",
Self::Reserved => "subdomain-reserved",
Self::EdgeHyphen => "subdomain-invalid-hyphen",
Self::InvalidCharacters => "subdomain-invalid-characters",
}
}
fn message(self) -> &'static str {
match self {
Self::Empty => "subdomain is required",
Self::TooLong => "subdomain must be 63 characters or fewer",
Self::Reserved => "subdomain is reserved",
Self::EdgeHyphen => "subdomain cannot start or end with a hyphen",
Self::InvalidCharacters => {
"subdomain may only contain lowercase letters, numbers, and hyphens"
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RelayValidationError {
InvalidPlan,
PremiumFeature,
Subdomain(SubdomainValidationError),
}
impl RelayValidationError {
fn code(self) -> &'static str {
match self {
Self::InvalidPlan => "invalid-plan",
Self::PremiumFeature => "premium-feature",
Self::Subdomain(reason) => reason.code(),
}
}
fn message(self) -> &'static str {
match self {
Self::InvalidPlan => "plan not found",
Self::PremiumFeature => "feature requires a paid plan",
Self::Subdomain(reason) => reason.message(),
}
}
}
impl From<SubdomainValidationError> for RelayValidationError {
fn from(value: SubdomainValidationError) -> Self {
Self::Subdomain(value)
}
}
fn validate_subdomain_label(subdomain: &str) -> std::result::Result<(), SubdomainValidationError> {
if subdomain.is_empty() {
return Err(SubdomainValidationError::Empty);
}
if subdomain.len() > SUBDOMAIN_LABEL_MAX_LEN {
return Err(SubdomainValidationError::TooLong);
}
if subdomain.starts_with('-') || subdomain.ends_with('-') {
return Err(SubdomainValidationError::EdgeHyphen);
}
if RESERVED_SUBDOMAIN_LABELS.contains(&subdomain) {
return Err(SubdomainValidationError::Reserved);
}
if !subdomain
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(SubdomainValidationError::InvalidCharacters);
}
Ok(())
}
fn ok<T: Serialize>(status: StatusCode, data: T) -> Response { fn ok<T: Serialize>(status: StatusCode, data: T) -> Response {
(status, Json(OkResponse { data, code: "ok" })).into_response() (status, Json(OkResponse { data, code: "ok" })).into_response()
} }
@@ -412,14 +316,6 @@ fn parse_bool_default(value: i64, default: i64) -> i64 {
} }
} }
fn relay_validation_error_response(error: RelayValidationError) -> Response {
err(
StatusCode::UNPROCESSABLE_ENTITY,
error.code(),
error.message(),
)
}
fn map_unique_error(err: &anyhow::Error) -> Option<&'static str> { fn map_unique_error(err: &anyhow::Error) -> Option<&'static str> {
let sqlx_err = err.downcast_ref::<sqlx::Error>()?; let sqlx_err = err.downcast_ref::<sqlx::Error>()?;
let sqlx::Error::Database(db_err) = sqlx_err else { let sqlx::Error::Database(db_err) = sqlx_err else {
@@ -681,40 +577,6 @@ async fn list_relay_activity(
} }
} }
async fn list_relay_members(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> std::result::Result<Response, ApiError> {
let auth = state.api.extract_auth_pubkey(&headers)?;
let relay = match state.api.query.get_relay(&id).await {
Ok(Some(r)) => r,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
};
state.api.require_admin_or_tenant(&auth, &relay.tenant)?;
match state.api.fetch_relay_members(&relay).await {
Ok(members) => Ok(ok(
StatusCode::OK,
serde_json::json!({ "members": members }),
)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
async fn create_relay( async fn create_relay(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -746,9 +608,27 @@ async fn create_relay(
}; };
relay = match state.api.prepare_relay(relay) { relay = match state.api.prepare_relay(relay) {
Err(e) if e.to_string() == "invalid-plan" => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-plan",
"plan not found",
));
}
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) if e.to_string() == "premium-feature" => {
return Ok(relay_validation_error_response(e)); return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"premium-feature",
"feature requires a paid plan",
));
}
Err(_) => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-relay",
"relay validation failed",
));
} }
}; };
@@ -794,13 +674,10 @@ async fn update_relay(
state.api.require_admin_or_tenant(&auth, &relay.tenant)?; state.api.require_admin_or_tenant(&auth, &relay.tenant)?;
let current_plan = relay.plan.clone();
let requested_plan = payload.plan.clone();
if let Some(v) = payload.subdomain { if let Some(v) = payload.subdomain {
relay.subdomain = v; relay.subdomain = v;
} }
if let Some(v) = requested_plan.clone() { if let Some(v) = payload.plan {
relay.plan = v; relay.plan = v;
} }
if let Some(v) = payload.info_name { if let Some(v) = payload.info_name {
@@ -835,44 +712,30 @@ async fn update_relay(
} }
relay = match state.api.prepare_relay(relay) { relay = match state.api.prepare_relay(relay) {
Err(e) if e.to_string() == "invalid-plan" => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-plan",
"plan not found",
));
}
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) if e.to_string() == "premium-feature" => {
return Ok(relay_validation_error_response(e)); return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"premium-feature",
"feature requires a paid plan",
));
}
Err(_) => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-relay",
"relay validation failed",
));
} }
}; };
let plan_changed = requested_plan
.as_deref()
.is_some_and(|requested| requested != current_plan);
if plan_changed {
let selected_plan = Query::get_plan(&relay.plan).expect("validated plan must exist");
if let Some(limit) = selected_plan.members {
let current_members = match state.api.fetch_relay_members(&relay).await {
Ok(members) => members.len() as i64,
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
};
if current_members > limit {
let message = format!(
"relay has {current_members} members, which exceeds the {} plan limit of {limit}",
selected_plan.name.to_lowercase()
);
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"member-limit-exceeded",
&message,
));
}
}
}
match state.api.command.update_relay(&relay).await { match state.api.command.update_relay(&relay).await {
Ok(()) => Ok(ok(StatusCode::OK, relay)), Ok(()) => Ok(ok(StatusCode::OK, relay)),
Err(e) => { Err(e) => {
@@ -1082,29 +945,12 @@ async fn update_tenant(
state.api.require_admin_or_tenant(&auth, &pubkey)?; state.api.require_admin_or_tenant(&auth, &pubkey)?;
let mut tenant = state.api.get_tenant_or_404(&pubkey).await?; let mut tenant = state.api.get_tenant_or_404(&pubkey).await?;
let nwc_previously_empty = tenant.nwc_url.is_empty();
if let Some(nwc_url) = payload.nwc_url { if let Some(nwc_url) = payload.nwc_url {
tenant.nwc_url = nwc_url; tenant.nwc_url = nwc_url;
} }
match state.api.command.update_tenant(&tenant).await { match state.api.command.update_tenant(&tenant).await {
Ok(()) => { Ok(()) => Ok(ok(StatusCode::OK, tenant)),
// When NWC is first connected, attempt to pay any outstanding open invoices.
if nwc_previously_empty && !tenant.nwc_url.is_empty() {
let billing = state.api.billing.clone();
let tenant_clone = tenant.clone();
tokio::spawn(async move {
if let Err(e) = billing.pay_outstanding_nwc_invoices(&tenant_clone).await {
tracing::error!(
error = %e,
pubkey = %tenant_clone.pubkey,
"pay_outstanding_nwc_invoices failed after NWC setup"
);
}
});
}
Ok(ok(StatusCode::OK, tenant))
}
Err(e) => Ok(err( Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"internal", "internal",
+37 -504
View File
@@ -17,9 +17,6 @@ type HmacSha256 = Hmac<Sha256>;
const STRIPE_API: &str = "https://api.stripe.com/v1"; const STRIPE_API: &str = "https://api.stripe.com/v1";
const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices"; const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices";
const WEBHOOK_TOLERANCE_SECS: i64 = 300; const WEBHOOK_TOLERANCE_SECS: i64 = 300;
const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.";
const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:";
const NWC_ERROR_DM_MAX_CHARS: usize = 240;
#[derive(Debug)] #[derive(Debug)]
pub enum InvoiceLookupError { pub enum InvoiceLookupError {
@@ -78,17 +75,12 @@ struct CoinbaseSpotPriceData {
amount: String, amount: String,
} }
enum NwcInvoicePaymentOutcome {
Paid,
Fallback(anyhow::Error),
Pending(anyhow::Error),
}
#[derive(Clone)] #[derive(Clone)]
pub struct Billing { pub struct Billing {
nwc_url: String, nwc_url: String,
stripe_secret_key: String, stripe_secret_key: String,
stripe_webhook_secret: String, stripe_webhook_secret: String,
btc_quote_api_base: String,
http: reqwest::Client, http: reqwest::Client,
query: Query, query: Query,
command: Command, command: Command,
@@ -106,10 +98,13 @@ impl Billing {
if stripe_webhook_secret.trim().is_empty() { if stripe_webhook_secret.trim().is_empty() {
panic!("missing STRIPE_WEBHOOK_SECRET environment variable"); panic!("missing STRIPE_WEBHOOK_SECRET environment variable");
} }
let btc_quote_api_base =
std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string());
Self { Self {
nwc_url, nwc_url,
stripe_secret_key, stripe_secret_key,
stripe_webhook_secret, stripe_webhook_secret,
btc_quote_api_base,
http: reqwest::Client::new(), http: reqwest::Client::new(),
query, query,
command, command,
@@ -120,10 +115,6 @@ impl Billing {
pub async fn start(self) { pub async fn start(self) {
let mut rx = self.command.notify.subscribe(); let mut rx = self.command.notify.subscribe();
if let Err(error) = self.reconcile_relay_subscriptions("startup").await {
tracing::error!(error = %error, "failed to reconcile relay billing state on startup");
}
loop { loop {
match rx.recv().await { match rx.recv().await {
Ok(activity) => { Ok(activity) => {
@@ -133,39 +124,12 @@ impl Billing {
} }
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "billing lagged"); tracing::warn!(missed = n, "billing lagged");
if let Err(error) = self.reconcile_relay_subscriptions("lagged").await {
tracing::error!(error = %error, "failed to reconcile relay billing state after lag");
}
} }
Err(tokio::sync::broadcast::error::RecvError::Closed) => break, Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
} }
} }
} }
async fn reconcile_relay_subscriptions(&self, source: &str) -> Result<()> {
let relays = self.query.list_relays().await?;
if relays.is_empty() {
return Ok(());
}
tracing::info!(source, relay_count = relays.len(), "reconciling relay billing state");
for relay in relays {
if let Err(error) = self.sync_relay_subscription_for_relay(&relay).await {
tracing::error!(
source,
relay = %relay.id,
error = %error,
"failed to reconcile relay billing state"
);
}
}
Ok(())
}
async fn handle_activity(&self, activity: &Activity) -> Result<()> { async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_billing_sync = matches!( let needs_billing_sync = matches!(
activity.activity_type.as_str(), activity.activity_type.as_str(),
@@ -189,10 +153,6 @@ impl Billing {
return Ok(()); return Ok(());
}; };
self.sync_relay_subscription_for_relay(&relay).await
}
async fn sync_relay_subscription_for_relay(&self, relay: &Relay) -> Result<()> {
let Some(tenant) = self.query.get_tenant(&relay.tenant).await? else { let Some(tenant) = self.query.get_tenant(&relay.tenant).await? else {
return Ok(()); return Ok(());
}; };
@@ -207,8 +167,6 @@ impl Billing {
self.command self.command
.delete_relay_subscription_item(&relay.id) .delete_relay_subscription_item(&relay.id)
.await?; .await?;
self.validate_downgrade_proration(&tenant, "free-plan-downgrade")
.await;
} }
self.cleanup_empty_subscription(&tenant.pubkey).await?; self.cleanup_empty_subscription(&tenant.pubkey).await?;
return Ok(()); return Ok(());
@@ -248,28 +206,8 @@ impl Billing {
// Sync the subscription item: create or update // Sync the subscription item: create or update
let subscription_id = tenant.stripe_subscription_id.as_ref().unwrap(); let subscription_id = tenant.stripe_subscription_id.as_ref().unwrap();
let item_id = if let Some(ref existing_item_id) = relay.stripe_subscription_item_id { let item_id = if let Some(ref existing_item_id) = relay.stripe_subscription_item_id {
let is_downgrade = self self.stripe_update_subscription_item(existing_item_id, stripe_price_id)
.is_subscription_item_downgrade(existing_item_id, plan.amount) .await?
.await
.unwrap_or_else(|error| {
tracing::warn!(
error = %error,
relay_id = %relay.id,
"failed to determine relay plan downgrade direction"
);
false
});
let updated_item_id = self
.stripe_update_subscription_item(existing_item_id, stripe_price_id)
.await?;
if is_downgrade {
self.validate_downgrade_proration(&tenant, "paid-plan-downgrade")
.await;
}
updated_item_id
} else { } else {
self.stripe_create_subscription_item(subscription_id, stripe_price_id) self.stripe_create_subscription_item(subscription_id, stripe_price_id)
.await? .await?
@@ -301,23 +239,6 @@ impl Billing {
Ok(()) Ok(())
} }
async fn existing_invoice_nwc_payment_outcome(
&self,
invoice_id: &str,
) -> Result<Option<NwcInvoicePaymentOutcome>> {
let state = self.query.get_invoice_nwc_payment_state(invoice_id).await?;
match state.as_deref() {
Some("paid") => Ok(Some(NwcInvoicePaymentOutcome::Paid)),
Some("pending") => Ok(Some(NwcInvoicePaymentOutcome::Pending(anyhow!(
"invoice {invoice_id} has a pending NWC reconciliation; refusing to create a new Lightning charge"
)))),
Some(other) => Err(anyhow!(
"unknown invoice_nwc_payment state '{other}' for invoice {invoice_id}"
)),
None => Ok(None),
}
}
pub async fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()> { pub async fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()> {
self.verify_webhook_signature(payload, signature)?; self.verify_webhook_signature(payload, signature)?;
@@ -354,10 +275,6 @@ impl Billing {
let customer = obj["customer"].as_str().unwrap_or_default(); let customer = obj["customer"].as_str().unwrap_or_default();
self.handle_subscription_deleted(customer).await?; self.handle_subscription_deleted(customer).await?;
} }
"payment_method.attached" => {
let customer = obj["customer"].as_str().unwrap_or_default();
self.handle_payment_method_attached(customer).await?;
}
_ => {} _ => {}
} }
@@ -417,54 +334,24 @@ impl Billing {
return Ok(()); return Ok(());
}; };
let mut nwc_error_for_dm: Option<String> = None;
// 1. NWC auto-pay: if the tenant has a nwc_url // 1. NWC auto-pay: if the tenant has a nwc_url
if !tenant.nwc_url.is_empty() { if !tenant.nwc_url.is_empty() {
match self match self
.nwc_pay_invoice( .nwc_pay_invoice(amount_due, currency, &tenant.nwc_url)
invoice_id, .await
&tenant.pubkey,
amount_due,
currency,
&tenant.nwc_url,
)
.await?
{ {
NwcInvoicePaymentOutcome::Paid => { Ok(()) => {
self.mark_invoice_paid_out_of_band_after_nwc(invoice_id, &tenant.pubkey) self.stripe_pay_invoice_out_of_band(invoice_id).await?;
.await?; self.command.clear_tenant_nwc_error(&tenant.pubkey).await?;
return Ok(()); return Ok(());
} }
NwcInvoicePaymentOutcome::Fallback(e) => { Err(e) => {
let error_msg = format!("{e}"); let error_msg = format!("{e}");
self.command self.command
.set_tenant_nwc_error(&tenant.pubkey, &error_msg) .set_tenant_nwc_error(&tenant.pubkey, &error_msg)
.await?; .await?;
tracing::warn!(
error = %e,
tenant_pubkey = %tenant.pubkey,
stripe_customer_id,
invoice_id,
"nwc auto-payment failed for invoice.created"
);
nwc_error_for_dm = summarize_nwc_error_for_dm(&error_msg);
// Fall through to next option // Fall through to next option
} }
NwcInvoicePaymentOutcome::Pending(e) => {
let error_msg = format!("{e}");
self.command
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
.await?;
tracing::error!(
error = %e,
tenant_pubkey = %tenant.pubkey,
stripe_customer_id,
invoice_id,
"nwc auto-payment requires reconciliation before retry"
);
return Err(e);
}
} }
} }
@@ -477,8 +364,12 @@ impl Billing {
} }
// 3. Manual payment: send a DM // 3. Manual payment: send a DM
let dm_message = manual_lightning_payment_dm(nwc_error_for_dm.as_deref()); self.robot
self.robot.send_dm(&tenant.pubkey, &dm_message).await?; .send_dm(
&tenant.pubkey,
"Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.",
)
.await?;
Ok(()) Ok(())
} }
@@ -601,103 +492,6 @@ impl Billing {
Ok(()) Ok(())
} }
async fn handle_payment_method_attached(&self, stripe_customer_id: &str) -> Result<()> {
if stripe_customer_id.is_empty() {
return Ok(());
}
let Some(tenant) = self
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
self.pay_outstanding_card_invoices(&tenant).await?;
Ok(())
}
async fn is_subscription_item_downgrade(
&self,
item_id: &str,
next_plan_amount: i64,
) -> Result<bool> {
let Some(current_price_id) = self.stripe_get_subscription_item_price_id(item_id).await?
else {
return Ok(false);
};
let Some(current_plan_amount) = Self::plan_amount_from_price_id(&current_price_id) else {
return Ok(false);
};
Ok(next_plan_amount < current_plan_amount)
}
fn plan_amount_from_price_id(price_id: &str) -> Option<i64> {
Query::list_plans().into_iter().find_map(|plan| {
if plan.stripe_price_id.as_deref() == Some(price_id) {
Some(plan.amount)
} else {
None
}
})
}
async fn validate_downgrade_proration(&self, tenant: &crate::models::Tenant, context: &str) {
match self
.stripe_preview_upcoming_invoice(
&tenant.stripe_customer_id,
tenant.stripe_subscription_id.as_deref(),
)
.await
{
Ok(upcoming) => {
let lines = upcoming["lines"]["data"]
.as_array()
.cloned()
.unwrap_or_default();
let proration_lines = lines
.iter()
.filter(|line| line["proration"].as_bool().unwrap_or(false))
.count();
let amount_due = upcoming["amount_due"]
.as_i64()
.unwrap_or_else(|| upcoming["total"].as_i64().unwrap_or(0));
let currency = upcoming["currency"].as_str().unwrap_or("usd");
let preview_id = upcoming["id"].as_str().unwrap_or_default();
tracing::info!(
tenant_pubkey = %tenant.pubkey,
stripe_customer_id = %tenant.stripe_customer_id,
context,
preview_id,
proration_lines,
amount_due,
currency,
"validated Stripe proration preview for downgrade"
);
if proration_lines == 0 {
tracing::warn!(
tenant_pubkey = %tenant.pubkey,
context,
"downgrade proration preview has no proration lines; verify in Stripe dashboard"
);
}
}
Err(error) => {
tracing::warn!(
error = %error,
tenant_pubkey = %tenant.pubkey,
context,
"failed to fetch downgrade proration preview"
);
}
}
}
// --- Public API helpers --- // --- Public API helpers ---
pub async fn get_invoice_with_tenant( pub async fn get_invoice_with_tenant(
@@ -721,13 +515,11 @@ impl Billing {
pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result<String> { pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result<String> {
let short_pubkey: String = tenant_pubkey.chars().take(12).collect(); let short_pubkey: String = tenant_pubkey.chars().take(12).collect();
let display_name = format!("Caravel tenant {short_pubkey}"); let display_name = format!("Caravel tenant {short_pubkey}");
let idempotency_key = self.idempotency_key(&["create_customer", tenant_pubkey]);
let resp = self let resp = self
.http .http
.post(format!("{STRIPE_API}/customers")) .post(format!("{STRIPE_API}/customers"))
.bearer_auth(&self.stripe_secret_key) .bearer_auth(&self.stripe_secret_key)
.header("Idempotency-Key", idempotency_key)
.form(&[ .form(&[
("name", display_name.as_str()), ("name", display_name.as_str()),
("metadata[tenant_pubkey]", tenant_pubkey), ("metadata[tenant_pubkey]", tenant_pubkey),
@@ -807,112 +599,6 @@ impl Billing {
Ok(invoice_response.invoice) Ok(invoice_response.invoice)
} }
pub async fn pay_outstanding_nwc_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> {
if tenant.nwc_url.is_empty() {
return Ok(());
}
let invoices = self
.stripe_list_invoices(&tenant.stripe_customer_id)
.await?;
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
for invoice in &invoices_arr {
let status = invoice["status"].as_str().unwrap_or_default();
let amount_due = invoice["amount_due"].as_i64().unwrap_or(0);
let invoice_id = invoice["id"].as_str().unwrap_or_default();
let currency = invoice["currency"].as_str().unwrap_or("usd");
if status != "open" || amount_due == 0 || invoice_id.is_empty() {
continue;
}
match self
.nwc_pay_invoice(
invoice_id,
&tenant.pubkey,
amount_due,
currency,
&tenant.nwc_url,
)
.await?
{
NwcInvoicePaymentOutcome::Paid => {
if let Err(e) = self
.mark_invoice_paid_out_of_band_after_nwc(invoice_id, &tenant.pubkey)
.await
{
tracing::error!(
error = %e,
invoice_id,
"failed to mark invoice paid out of band"
);
}
}
NwcInvoicePaymentOutcome::Fallback(e) => {
let error_msg = format!("{e}");
tracing::error!(
error = %e,
invoice_id,
"nwc payment failed for outstanding invoice"
);
let _ = self
.command
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
.await;
}
NwcInvoicePaymentOutcome::Pending(e) => {
let error_msg = format!("{e}");
tracing::error!(
error = %e,
invoice_id,
"outstanding invoice requires NWC reconciliation before retry"
);
let _ = self
.command
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
.await;
}
}
}
Ok(())
}
async fn pay_outstanding_card_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> {
if !self
.stripe_has_payment_method(&tenant.stripe_customer_id)
.await?
{
return Ok(());
}
let invoices = self
.stripe_list_invoices(&tenant.stripe_customer_id)
.await?;
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
for invoice in &invoices_arr {
let status = invoice["status"].as_str().unwrap_or_default();
let amount_due = invoice["amount_due"].as_i64().unwrap_or(0);
let invoice_id = invoice["id"].as_str().unwrap_or_default();
if status != "open" || amount_due == 0 || invoice_id.is_empty() {
continue;
}
if let Err(error) = self.stripe_pay_invoice(invoice_id).await {
tracing::error!(
error = %error,
invoice_id,
"failed to retry card payment for outstanding invoice"
);
}
}
Ok(())
}
pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String> { pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String> {
let resp = self let resp = self
.http .http
@@ -933,30 +619,15 @@ impl Billing {
// --- Stripe API helpers --- // --- Stripe API helpers ---
fn idempotency_key(&self, parts: &[&str]) -> String {
let mut mac = HmacSha256::new_from_slice(self.stripe_secret_key.as_bytes())
.expect("HMAC accepts any key length");
for (i, part) in parts.iter().enumerate() {
if i > 0 {
mac.update(b":");
}
mac.update(part.as_bytes());
}
hex::encode(mac.finalize().into_bytes())
}
async fn stripe_create_subscription( async fn stripe_create_subscription(
&self, &self,
customer_id: &str, customer_id: &str,
price_id: &str, price_id: &str,
) -> Result<(String, String)> { ) -> Result<(String, String)> {
let idempotency_key =
self.idempotency_key(&["create_subscription", customer_id, price_id]);
let resp = self let resp = self
.http .http
.post(format!("{STRIPE_API}/subscriptions")) .post(format!("{STRIPE_API}/subscriptions"))
.bearer_auth(&self.stripe_secret_key) .bearer_auth(&self.stripe_secret_key)
.header("Idempotency-Key", idempotency_key)
.form(&[ .form(&[
("customer", customer_id), ("customer", customer_id),
("collection_method", "charge_automatically"), ("collection_method", "charge_automatically"),
@@ -983,13 +654,10 @@ impl Billing {
subscription_id: &str, subscription_id: &str,
price_id: &str, price_id: &str,
) -> Result<String> { ) -> Result<String> {
let idempotency_key =
self.idempotency_key(&["create_subscription_item", subscription_id, price_id]);
let resp = self let resp = self
.http .http
.post(format!("{STRIPE_API}/subscription_items")) .post(format!("{STRIPE_API}/subscription_items"))
.bearer_auth(&self.stripe_secret_key) .bearer_auth(&self.stripe_secret_key)
.header("Idempotency-Key", idempotency_key)
.form(&[("subscription", subscription_id), ("price", price_id)]) .form(&[("subscription", subscription_id), ("price", price_id)])
.send() .send()
.await?; .await?;
@@ -1008,13 +676,10 @@ impl Billing {
item_id: &str, item_id: &str,
price_id: &str, price_id: &str,
) -> Result<String> { ) -> Result<String> {
let idempotency_key =
self.idempotency_key(&["update_subscription_item", item_id, price_id]);
let resp = self let resp = self
.http .http
.post(format!("{STRIPE_API}/subscription_items/{item_id}")) .post(format!("{STRIPE_API}/subscription_items/{item_id}"))
.bearer_auth(&self.stripe_secret_key) .bearer_auth(&self.stripe_secret_key)
.header("Idempotency-Key", idempotency_key)
.form(&[("price", price_id)]) .form(&[("price", price_id)])
.send() .send()
.await?; .await?;
@@ -1050,56 +715,10 @@ impl Billing {
Ok(()) Ok(())
} }
async fn stripe_pay_invoice(&self, invoice_id: &str) -> Result<()> {
let idempotency_key = self.idempotency_key(&["pay_invoice", invoice_id]);
self.http
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
.bearer_auth(&self.stripe_secret_key)
.header("Idempotency-Key", idempotency_key)
.send()
.await?
.error_for_status()?;
Ok(())
}
async fn stripe_get_subscription_item_price_id(&self, item_id: &str) -> Result<Option<String>> {
let resp = self
.http
.get(format!("{STRIPE_API}/subscription_items/{item_id}"))
.bearer_auth(&self.stripe_secret_key)
.send()
.await?;
let body: serde_json::Value = resp.error_for_status()?.json().await?;
Ok(body["price"]["id"].as_str().map(ToString::to_string))
}
async fn stripe_preview_upcoming_invoice(
&self,
customer_id: &str,
subscription_id: Option<&str>,
) -> Result<serde_json::Value> {
let mut req = self
.http
.get(format!("{STRIPE_API}/invoices/upcoming"))
.bearer_auth(&self.stripe_secret_key)
.query(&[("customer", customer_id)]);
if let Some(subscription_id) = subscription_id {
req = req.query(&[("subscription", subscription_id)]);
}
let body: serde_json::Value = req.send().await?.error_for_status()?.json().await?;
Ok(body)
}
async fn stripe_pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> { async fn stripe_pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> {
let idempotency_key = self.idempotency_key(&["pay_invoice_oob", invoice_id]);
self.http self.http
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay")) .post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
.bearer_auth(&self.stripe_secret_key) .bearer_auth(&self.stripe_secret_key)
.header("Idempotency-Key", idempotency_key)
.form(&[("paid_out_of_band", "true")]) .form(&[("paid_out_of_band", "true")])
.send() .send()
.await? .await?
@@ -1128,47 +747,19 @@ impl Billing {
// --- NWC helpers --- // --- NWC helpers ---
async fn mark_invoice_paid_out_of_band_after_nwc(
&self,
invoice_id: &str,
tenant_pubkey: &str,
) -> Result<()> {
self.stripe_pay_invoice_out_of_band(invoice_id).await?;
self.command.clear_tenant_nwc_error(tenant_pubkey).await?;
Ok(())
}
fn parse_nwc_uri(nwc_url: &str, role: &str) -> Result<NostrWalletConnectURI> {
nwc_url
.parse::<NostrWalletConnectURI>()
.map_err(|_| anyhow!("invalid {role} NWC URL"))
}
async fn nwc_pay_invoice( async fn nwc_pay_invoice(
&self, &self,
invoice_id: &str,
tenant_pubkey: &str,
amount_due_minor: i64, amount_due_minor: i64,
currency: &str, currency: &str,
tenant_nwc_url: &str, tenant_nwc_url: &str,
) -> Result<NwcInvoicePaymentOutcome> { ) -> Result<()> {
if let Some(existing_outcome) = self let amount_msats = self.fiat_minor_to_msats(amount_due_minor, currency).await?;
.existing_invoice_nwc_payment_outcome(invoice_id)
.await?
{
return Ok(existing_outcome);
}
let amount_msats = match self.fiat_minor_to_msats(amount_due_minor, currency).await {
Ok(amount_msats) => amount_msats,
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
};
// Create a bolt11 invoice using the system wallet (self.nwc_url) // Create a bolt11 invoice using the system wallet (self.nwc_url)
let system_uri = match Self::parse_nwc_uri(&self.nwc_url, "system") { let system_uri: NostrWalletConnectURI = self
Ok(system_uri) => system_uri, .nwc_url
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)), .parse()
}; .map_err(|_| anyhow!("invalid system NWC URL"))?;
let system_nwc = NWC::new(system_uri); let system_nwc = NWC::new(system_uri);
let make_req = MakeInvoiceRequest { let make_req = MakeInvoiceRequest {
@@ -1178,61 +769,29 @@ impl Billing {
expiry: None, expiry: None,
}; };
let invoice_response = system_nwc.make_invoice(make_req).await; let invoice_response = system_nwc
.make_invoice(make_req)
let invoice_response = match invoice_response { .await
Ok(invoice_response) => invoice_response, .map_err(|e| anyhow!("failed to create invoice: {e}"))?;
Err(error) => {
system_nwc.shutdown().await;
return Ok(NwcInvoicePaymentOutcome::Fallback(anyhow!(
"failed to create invoice: {error}"
)));
}
};
system_nwc.shutdown().await; system_nwc.shutdown().await;
// Pay the bolt11 invoice using the tenant's wallet // Pay the bolt11 invoice using the tenant's wallet
let tenant_uri = match Self::parse_nwc_uri(tenant_nwc_url, "tenant") { let tenant_uri: NostrWalletConnectURI = tenant_nwc_url
Ok(tenant_uri) => tenant_uri, .parse()
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)), .map_err(|_| anyhow!("invalid tenant NWC URL"))?;
};
if !self
.command
.insert_pending_invoice_nwc_payment(invoice_id, tenant_pubkey)
.await?
{
if let Some(existing_outcome) = self
.existing_invoice_nwc_payment_outcome(invoice_id)
.await?
{
return Ok(existing_outcome);
}
return Err(anyhow!(
"invoice_nwc_payment row missing after insert race for invoice {invoice_id}"
));
}
let tenant_nwc = NWC::new(tenant_uri); let tenant_nwc = NWC::new(tenant_uri);
let pay_req = NwcPayInvoiceRequest::new(invoice_response.invoice); let pay_req = NwcPayInvoiceRequest::new(invoice_response.invoice);
let pay_result = tenant_nwc.pay_invoice(pay_req).await; tenant_nwc
.pay_invoice(pay_req)
.await
.map_err(|e| anyhow!("failed to pay invoice: {e}"))?;
tenant_nwc.shutdown().await; tenant_nwc.shutdown().await;
match pay_result { Ok(())
Ok(_) => match self.command.mark_invoice_nwc_payment_paid(invoice_id).await {
Ok(()) => Ok(NwcInvoicePaymentOutcome::Paid),
Err(error) => Ok(NwcInvoicePaymentOutcome::Pending(anyhow!(
"invoice {invoice_id} was charged over NWC but failed to persist paid state: {error}"
))),
},
Err(error) => Ok(NwcInvoicePaymentOutcome::Pending(anyhow!(
"invoice {invoice_id} NWC payment attempt requires reconciliation: {error}"
))),
}
} }
async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result<u64> { async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result<u64> {
@@ -1246,7 +805,7 @@ impl Billing {
} }
async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> { async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> {
fetch_btc_spot_price_from_base(&self.http, COINBASE_SPOT_API, currency).await fetch_btc_spot_price_from_base(&self.http, &self.btc_quote_api_base, currency).await
} }
fn currency_minor_exponent(currency: &str) -> Result<u8> { fn currency_minor_exponent(currency: &str) -> Result<u8> {
@@ -1290,31 +849,6 @@ pub async fn fetch_btc_spot_price_from_base(
Ok(amount) Ok(amount)
} }
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)
}
fn manual_lightning_payment_dm(nwc_error: Option<&str>) -> String {
match nwc_error {
Some(error) if !error.is_empty() => {
format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{NWC_ERROR_DM_PREFIX} {error}")
}
_ => MANUAL_LIGHTNING_PAYMENT_DM.to_string(),
}
}
pub fn fiat_minor_to_msats_from_quote( pub fn fiat_minor_to_msats_from_quote(
amount_due_minor: i64, amount_due_minor: i64,
currency: &str, currency: &str,
@@ -1612,5 +1146,4 @@ mod tests {
assert_eq!(billing.stripe_secret_key, "sk_test_dummy"); assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy"); assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
} }
} }
+5 -45
View File
@@ -113,12 +113,12 @@ impl Command {
sqlx::query( sqlx::query(
"INSERT INTO relay ( "INSERT INTO relay (
id, tenant, schema, subdomain, plan, status, synced, sync_error, id, tenant, schema, subdomain, plan, status, sync_error,
info_name, info_icon, info_description, info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures, policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled, groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled livekit_enabled, push_enabled
) VALUES (?, ?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
) )
.bind(&relay.id) .bind(&relay.id)
.bind(&relay.tenant) .bind(&relay.tenant)
@@ -151,7 +151,7 @@ impl Command {
sqlx::query( sqlx::query(
"UPDATE relay "UPDATE relay
SET tenant = ?, schema = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?, synced = 0, SET tenant = ?, schema = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?,
info_name = ?, info_icon = ?, info_description = ?, info_name = ?, info_icon = ?, info_description = ?,
policy_public_join = ?, policy_strip_signatures = ?, policy_public_join = ?, policy_strip_signatures = ?,
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?, groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
@@ -203,7 +203,7 @@ impl Command {
) -> Result<()> { ) -> Result<()> {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?") sqlx::query("UPDATE relay SET status = ? WHERE id = ?")
.bind(status) .bind(status)
.bind(relay_id) .bind(relay_id)
.execute(&mut *tx) .execute(&mut *tx)
@@ -224,7 +224,7 @@ impl Command {
pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> { pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET synced = 0, sync_error = ? WHERE id = ?") sqlx::query("UPDATE relay SET sync_error = ? WHERE id = ?")
.bind(&sync_error) .bind(&sync_error)
.bind(&relay.id) .bind(&relay.id)
.execute(&mut *tx) .execute(&mut *tx)
@@ -313,46 +313,6 @@ impl Command {
Ok(()) Ok(())
} }
pub async fn insert_pending_invoice_nwc_payment(
&self,
invoice_id: &str,
tenant_pubkey: &str,
) -> Result<bool> {
let now = chrono::Utc::now().timestamp();
let result = sqlx::query(
"INSERT INTO invoice_nwc_payment (invoice_id, tenant_pubkey, state, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?)
ON CONFLICT(invoice_id) DO NOTHING",
)
.bind(invoice_id)
.bind(tenant_pubkey)
.bind(now)
.bind(now)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn mark_invoice_nwc_payment_paid(&self, invoice_id: &str) -> Result<()> {
let now = chrono::Utc::now().timestamp();
let result = sqlx::query(
"UPDATE invoice_nwc_payment
SET state = 'paid', updated_at = ?
WHERE invoice_id = ?",
)
.bind(now)
.bind(invoice_id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
anyhow::bail!("invoice_nwc_payment row missing for invoice_id: {invoice_id}");
}
Ok(())
}
pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()> { pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()> {
let now = chrono::Utc::now().timestamp(); let now = chrono::Utc::now().timestamp();
sqlx::query("UPDATE tenant SET past_due_at = ? WHERE pubkey = ?") sqlx::query("UPDATE tenant SET past_due_at = ? WHERE pubkey = ?")
+12 -196
View File
@@ -1,15 +1,10 @@
use anyhow::Result; use anyhow::Result;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use std::time::Duration;
use crate::command::Command; use crate::command::Command;
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay}; use crate::models::{Activity, Relay, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE};
use crate::query::Query; use crate::query::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)] #[derive(Clone)]
pub struct Infra { pub struct Infra {
api_url: String, api_url: String,
@@ -23,22 +18,14 @@ pub struct Infra {
} }
impl Infra { impl Infra {
pub fn new(query: Query, command: Command) -> Result<Self> { pub fn new(query: Query, command: Command) -> Self {
let api_url = std::env::var("ZOOID_API_URL").unwrap_or_default(); let api_url = std::env::var("ZOOID_API_URL").unwrap_or_default();
let relay_domain = std::env::var("RELAY_DOMAIN").unwrap_or_default(); let relay_domain = std::env::var("RELAY_DOMAIN").unwrap_or_default();
let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or_default(); let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or_default();
let livekit_api_key = std::env::var("LIVEKIT_API_KEY").unwrap_or_default(); let livekit_api_key = std::env::var("LIVEKIT_API_KEY").unwrap_or_default();
let livekit_api_secret = std::env::var("LIVEKIT_API_SECRET").unwrap_or_default(); let livekit_api_secret = std::env::var("LIVEKIT_API_SECRET").unwrap_or_default();
let api_secret = std::env::var("ZOOID_API_SECRET").unwrap_or_default(); let api_secret = std::env::var("ZOOID_API_SECRET").unwrap_or_default();
Self {
if api_url.trim().is_empty() {
anyhow::bail!("missing ZOOID_API_URL");
}
if api_secret.trim().is_empty() {
anyhow::bail!("missing ZOOID_API_SECRET");
}
Ok(Self {
api_url, api_url,
relay_domain, relay_domain,
livekit_url, livekit_url,
@@ -47,16 +34,12 @@ impl Infra {
api_secret, api_secret,
query, query,
command, command,
}) }
} }
pub async fn start(self) { pub async fn start(self) {
let mut rx = self.command.notify.subscribe(); let mut rx = self.command.notify.subscribe();
if let Err(error) = self.reconcile_relay_state("startup").await {
tracing::error!(error = %error, "failed to reconcile relay state on startup");
}
loop { loop {
match rx.recv().await { match rx.recv().await {
Ok(activity) => { Ok(activity) => {
@@ -66,10 +49,6 @@ impl Infra {
} }
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "infra lagged"); tracing::warn!(missed = n, "infra lagged");
if let Err(error) = self.reconcile_relay_state("lagged").await {
tracing::error!(error = %error, "failed to reconcile relay state after lag");
}
} }
Err(tokio::sync::broadcast::error::RecvError::Closed) => break, Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
} }
@@ -79,107 +58,18 @@ impl Infra {
async fn handle_activity(&self, activity: &Activity) -> Result<()> { async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_sync = should_sync_relay_activity(activity.activity_type.as_str()); let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
if !needs_sync || activity.resource_type != "relay" { if needs_sync {
return Ok(()); let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
} return Ok(());
};
if activity.activity_type == "fail_relay_sync" { let is_new = relay.synced == 0;
self.schedule_relay_sync_retry(&activity.resource_id, "activity") self.sync_and_report(&relay, is_new).await;
.await?;
return Ok(());
}
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
return Ok(());
};
let is_new = self.relay_sync_is_new(&relay).await?;
self.sync_and_report(&relay, is_new).await;
Ok(())
}
async fn reconcile_relay_state(&self, source: &str) -> Result<()> {
let relays = self.query.list_relays_pending_sync().await?;
if relays.is_empty() {
return Ok(());
}
tracing::info!(source, relay_count = relays.len(), "reconciling pending relay state");
for relay in relays {
if relay.sync_error.trim().is_empty() {
let is_new = self.relay_sync_is_new(&relay).await?;
self.sync_and_report(&relay, is_new).await;
} else {
self.schedule_relay_sync_retry(&relay.id, source).await?;
}
} }
Ok(()) Ok(())
} }
async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str) -> Result<()> {
let activities = self.query.list_activity_for_relay(relay_id).await?;
let consecutive_failures = consecutive_sync_failures(&activities);
let Some(delay) = relay_sync_retry_delay(consecutive_failures) else {
tracing::warn!(
relay = relay_id,
consecutive_failures,
max_attempts = RELAY_SYNC_RETRY_MAX_ATTEMPTS,
"relay sync retries exhausted; awaiting manual intervention"
);
return Ok(());
};
tracing::info!(
relay = relay_id,
source,
consecutive_failures,
delay_secs = delay.as_secs(),
"scheduled relay sync retry"
);
let relay_id = relay_id.to_string();
let infra = self.clone();
tokio::spawn(async move {
tokio::time::sleep(delay).await;
if let Err(e) = infra.retry_relay_sync(&relay_id).await {
tracing::error!(relay = %relay_id, error = %e, "relay sync retry task failed");
}
});
Ok(())
}
async fn retry_relay_sync(&self, relay_id: &str) -> Result<()> {
let Some(relay) = self.query.get_relay(relay_id).await? else {
return Ok(());
};
if relay.sync_error.trim().is_empty() {
tracing::debug!(relay = %relay.id, "skip relay sync retry; relay has no sync_error");
return Ok(());
}
let is_new = self.relay_sync_is_new(&relay).await?;
self.sync_and_report(&relay, is_new).await;
Ok(())
}
async fn relay_sync_is_new(&self, relay: &Relay) -> Result<bool> {
if relay.synced == 1 {
return Ok(false);
}
let has_completed_sync = self.query.relay_has_completed_sync(&relay.id).await?;
Ok(!has_completed_sync)
}
async fn sync_and_report(&self, relay: &Relay, is_new: bool) { async fn sync_and_report(&self, relay: &Relay, is_new: bool) {
match self.sync_relay(relay, is_new).await { match self.sync_relay(relay, is_new).await {
Ok(()) => { Ok(()) => {
@@ -206,28 +96,6 @@ impl Infra {
Ok(auth) Ok(auth)
} }
pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>> {
let client = reqwest::Client::new();
let base = self.api_url.trim_end_matches('/');
let url = format!("{base}/relay/{relay_id}/members");
let auth = self.nip98_auth(&url, HttpMethod::GET).await?;
let response = client
.get(&url)
.header("Authorization", auth)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("zooid members returned {status}: {body}");
}
let body = response.text().await?;
parse_relay_members_response(&body)
}
async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> { async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let base = self.api_url.trim_end_matches('/'); let base = self.api_url.trim_end_matches('/');
@@ -257,9 +125,7 @@ impl Infra {
); );
let url = format!("{}/relay/{}", base, relay.id); let url = format!("{}/relay/{}", base, relay.id);
let auth = self let auth = self.nip98_auth(&url, zooid_sync_http_method(is_new)).await?;
.nip98_auth(&url, zooid_sync_http_method(is_new))
.await?;
let request = if is_new { let request = if is_new {
client.post(&url) client.post(&url)
@@ -290,34 +156,6 @@ fn zooid_sync_http_method(is_new: bool) -> HttpMethod {
} }
} }
fn parse_relay_members_response(body: &str) -> Result<Vec<String>> {
let value: serde_json::Value = serde_json::from_str(body)?;
if let Some(members) = members_from_value(&value) {
return Ok(members);
}
if let Some(members) = value.get("members").and_then(members_from_value) {
return Ok(members);
}
if let Some(members) = value
.get("data")
.and_then(|data| data.get("members"))
.and_then(members_from_value)
{
return Ok(members);
}
anyhow::bail!("zooid members response missing members array")
}
fn members_from_value(value: &serde_json::Value) -> Option<Vec<String>> {
let values = value.as_array()?;
values
.iter()
.map(|value| value.as_str().map(ToString::to_string))
.collect()
}
fn relay_sync_body( fn relay_sync_body(
relay: &Relay, relay: &Relay,
host: String, host: String,
@@ -360,28 +198,6 @@ fn relay_sync_body(
fn should_sync_relay_activity(activity_type: &str) -> bool { fn should_sync_relay_activity(activity_type: &str) -> bool {
matches!( matches!(
activity_type, activity_type,
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync" "create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
) )
} }
fn consecutive_sync_failures(activities: &[Activity]) -> usize {
activities
.iter()
.take_while(|activity| activity.activity_type == "fail_relay_sync")
.count()
}
fn relay_sync_retry_delay(consecutive_failures: usize) -> Option<Duration> {
let retry_attempt = consecutive_failures.max(1);
if retry_attempt > RELAY_SYNC_RETRY_MAX_ATTEMPTS {
return None;
}
let exponent = (retry_attempt - 1).min(31);
let multiplier = 1u64 << exponent;
let delay_secs = RELAY_SYNC_RETRY_BASE_DELAY_SECS
.saturating_mul(multiplier)
.min(RELAY_SYNC_RETRY_MAX_DELAY_SECS);
Some(Duration::from_secs(delay_secs))
}
+2 -2
View File
@@ -33,8 +33,8 @@ async fn main() -> Result<()> {
let query = Query::new(pool.clone()); let query = Query::new(pool.clone());
let command = Command::new(pool); let command = Command::new(pool);
let billing = Billing::new(query.clone(), command.clone(), robot.clone()); let billing = Billing::new(query.clone(), command.clone(), robot.clone());
let infra = Infra::new(query.clone(), command.clone())?; let infra = Infra::new(query.clone(), command.clone());
let api = Api::new(query, command, billing.clone(), infra.clone()); let api = Api::new(query, command, billing.clone());
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("PORT") let port: u16 = std::env::var("PORT")
-43
View File
@@ -94,23 +94,6 @@ impl Query {
Ok(rows) Ok(rows)
} }
pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
WHERE synced = 0 OR TRIM(sync_error) != ''
ORDER BY id",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> { pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>( let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id, "SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
@@ -161,16 +144,6 @@ impl Query {
Ok(row) Ok(row)
} }
pub async fn get_invoice_nwc_payment_state(&self, invoice_id: &str) -> Result<Option<String>> {
let state = sqlx::query_scalar::<_, String>(
"SELECT state FROM invoice_nwc_payment WHERE invoice_id = ?",
)
.bind(invoice_id)
.fetch_optional(&self.pool)
.await?;
Ok(state)
}
pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool> { pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool> {
let plans = sqlx::query_scalar::<_, String>( let plans = sqlx::query_scalar::<_, String>(
"SELECT plan FROM relay WHERE tenant = ? AND status = 'active'", "SELECT plan FROM relay WHERE tenant = ? AND status = 'active'",
@@ -194,20 +167,4 @@ impl Query {
.await?; .await?;
Ok(rows) Ok(rows)
} }
pub async fn relay_has_completed_sync(&self, relay_id: &str) -> Result<bool> {
let found = sqlx::query_scalar::<_, i64>(
"SELECT 1
FROM activity
WHERE resource_type = 'relay'
AND resource_id = ?
AND activity_type = 'complete_relay_sync'
LIMIT 1",
)
.bind(relay_id)
.fetch_optional(&self.pool)
.await?;
Ok(found.is_some())
}
} }
-151
View File
@@ -1,151 +0,0 @@
import { For, Show, createEffect, createSignal } from "solid-js"
import Modal from "@/components/Modal"
type ConfirmDialogProps = {
open: boolean
title: string
description: string
/** Optional bullet points shown in a warning box below the description */
details?: string[]
confirmLabel: string
busyLabel?: string
busy?: boolean
tone?: "danger" | "primary"
onConfirm: () => void | Promise<void>
onClose: () => void
}
const TONE_STYLES: Record<NonNullable<ConfirmDialogProps["tone"]>, string> = {
danger: "bg-red-600 text-white hover:bg-red-700",
primary: "bg-blue-600 text-white hover:bg-blue-700",
}
const DETAIL_BOX_STYLES: Record<NonNullable<ConfirmDialogProps["tone"]>, string> = {
danger: "border-amber-200 bg-amber-50 text-amber-800",
primary: "border-blue-200 bg-blue-50 text-blue-800",
}
type ConfirmDialogSnapshot = {
title: string
description: string
details?: string[]
confirmLabel: string
busyLabel?: string
busy: boolean
tone: NonNullable<ConfirmDialogProps["tone"]>
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
const [snapshot, setSnapshot] = createSignal<ConfirmDialogSnapshot>({
title: props.title,
description: props.description,
details: props.details ? [...props.details] : undefined,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
})
createEffect(() => {
if (!props.open) return
setSnapshot({
title: props.title,
description: props.description,
details: props.details ? [...props.details] : undefined,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
})
})
const content = () => props.open
? {
title: props.title,
description: props.description,
details: props.details,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
}
: snapshot()
const tone = () => content().tone
const confirmText = () => content().busy ? (content().busyLabel ?? content().confirmLabel) : content().confirmLabel
function handleClose() {
if (props.busy) return
props.onClose()
}
function handleConfirm() {
if (props.busy) return
void props.onConfirm()
}
return (
<Modal
open={props.open}
onClose={handleClose}
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
panelClass="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
>
<div class="px-6 pt-6 pb-4 border-b border-gray-100">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">{content().title}</h2>
</div>
<button
type="button"
onClick={handleClose}
disabled={content().busy}
class="shrink-0 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="px-6 py-4">
<div class="space-y-3 text-left">
<p class="text-sm text-gray-600">{content().description}</p>
<Show when={content().details && content().details!.length > 0}>
<ul class={`w-full rounded-lg border px-4 py-3 space-y-1.5 ${DETAIL_BOX_STYLES[tone()]}`}>
<For each={content().details}>
{(item) => (
<li class="flex items-start gap-2 text-sm">
<span class="mt-0.5 shrink-0 select-none"></span>
<span>{item}</span>
</li>
)}
</For>
</ul>
</Show>
</div>
</div>
<div class="px-6 py-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={handleClose}
disabled={content().busy}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={content().busy}
class={`rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${TONE_STYLES[tone()]}`}
>
{confirmText()}
</button>
</div>
</Modal>
)
}
+14 -25
View File
@@ -3,6 +3,7 @@ import QRCode from "qrcode"
import Modal from "@/components/Modal" import Modal from "@/components/Modal"
import PaymentSetup from "@/components/PaymentSetup" import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, getInvoiceBolt11 } from "@/lib/api" import { getInvoice, getInvoiceBolt11 } from "@/lib/api"
import { tenantNeedsPaymentSetup } from "@/lib/hooks"
type PayStatus = "idle" | "loading" | "success" | "error" type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error" type Bolt11Status = "idle" | "loading" | "ready" | "error"
@@ -25,8 +26,8 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const [bolt11Error, setBolt11Error] = createSignal("") const [bolt11Error, setBolt11Error] = createSignal("")
const [payStatus, setPayStatus] = createSignal<PayStatus>("idle") const [payStatus, setPayStatus] = createSignal<PayStatus>("idle")
const [payError, setPayError] = createSignal("") const [payError, setPayError] = createSignal("")
const [showSetup, setShowSetup] = createSignal(false)
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false) const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [setupSaved, setSetupSaved] = createSignal(false)
async function loadBolt11() { async function loadBolt11() {
if (!props.invoice.id) return if (!props.invoice.id) return
@@ -62,6 +63,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const invoice = await getInvoice(props.invoice.id) const invoice = await getInvoice(props.invoice.id)
if (invoice.status === "paid") { if (invoice.status === "paid") {
setPayStatus("success") setPayStatus("success")
tenantNeedsPaymentSetup().then(needs => setShowSetup(needs)).catch(() => {})
} else { } else {
setPayStatus("error") setPayStatus("error")
setPayError("Payment not yet confirmed. Please try again after sending.") setPayError("Payment not yet confirmed. Please try again after sending.")
@@ -79,6 +81,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
setBolt11Error("") setBolt11Error("")
setBolt11("") setBolt11("")
setQrDataUrl("") setQrDataUrl("")
setShowSetup(false)
props.onClose() props.onClose()
} }
@@ -157,15 +160,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</button> </button>
</div> </div>
</Show> </Show>
<div class="text-center pt-1">
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="text-sm text-blue-600 hover:text-blue-700"
>
Set up payment method instead
</button>
</div>
</Show> </Show>
</div> </div>
} }
@@ -178,13 +172,15 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</div> </div>
<p class="text-sm font-medium text-gray-900">Payment confirmed!</p> <p class="text-sm font-medium text-gray-900">Payment confirmed!</p>
<p class="text-xs text-gray-500">Thank you. Your account is up to date.</p> <p class="text-xs text-gray-500">Thank you. Your account is up to date.</p>
<button <Show when={showSetup()}>
type="button" <button
onClick={() => setShowPaymentSetup(true)} type="button"
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700" onClick={() => setShowPaymentSetup(true)}
> class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
Set up automatic payments >
</button> Set up automatic payments
</button>
</Show>
</div> </div>
</Show> </Show>
</div> </div>
@@ -232,14 +228,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</Modal> </Modal>
<PaymentSetup <PaymentSetup
open={showPaymentSetup()} open={showPaymentSetup()}
onClose={() => { onClose={() => setShowPaymentSetup(false)}
setShowPaymentSetup(false)
if (setupSaved()) {
setSetupSaved(false)
props.onClose()
}
}}
onSaved={() => setSetupSaved(true)}
/> />
</> </>
) )
+2 -4
View File
@@ -9,7 +9,6 @@ type Tab = "nwc" | "card"
type PaymentSetupProps = { type PaymentSetupProps = {
open: boolean open: boolean
onClose: () => void onClose: () => void
onSaved?: () => void
} }
export default function PaymentSetup(props: PaymentSetupProps) { export default function PaymentSetup(props: PaymentSetupProps) {
@@ -28,7 +27,6 @@ export default function PaymentSetup(props: PaymentSetupProps) {
try { try {
await updateActiveTenant({ nwc_url: url }) await updateActiveTenant({ nwc_url: url })
setSaved(true) setSaved(true)
props.onSaved?.()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Failed to save wallet connection") setError(e instanceof Error ? e.message : "Failed to save wallet connection")
} finally { } finally {
@@ -66,7 +64,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div> <div>
<h2 class="text-lg font-semibold text-gray-900">Set Up Payments</h2> <h2 class="text-lg font-semibold text-gray-900">Set Up Payments</h2>
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay once invoices are issued for your relay.</p> <p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay for your relay.</p>
</div> </div>
<button <button
type="button" type="button"
@@ -146,7 +144,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
<line x1="1" y1="10" x2="23" y2="10" /> <line x1="1" y1="10" x2="23" y2="10" />
</svg> </svg>
</div> </div>
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing. If an invoice is currently due, we will retry collection after card setup.</p> <p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing.</p>
<button <button
type="button" type="button"
onClick={openPortal} onClick={openPortal}
+8 -62
View File
@@ -2,7 +2,6 @@ import { A } from "@solidjs/router"
import { Show, createEffect, createSignal, onCleanup } from "solid-js" import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import type { Relay, PlanId } from "@/lib/api" import type { Relay, PlanId } from "@/lib/api"
import menuDotsIcon from "@/assets/menu-dots-2.svg" import menuDotsIcon from "@/assets/menu-dots-2.svg"
import ConfirmDialog from "@/components/ConfirmDialog"
import Field from "@/components/Field" import Field from "@/components/Field"
import PricingTable from "@/components/PricingTable" import PricingTable from "@/components/PricingTable"
import ToggleButton from "@/components/ToggleButton" import ToggleButton from "@/components/ToggleButton"
@@ -52,8 +51,8 @@ type RelayDetailCardProps = {
currentMembers?: number currentMembers?: number
showTenant?: boolean showTenant?: boolean
editHref?: string editHref?: string
onDeactivate?: () => void | Promise<void> onDeactivate?: () => void
onReactivate?: () => void | Promise<void> onReactivate?: () => void
deactivating?: boolean deactivating?: boolean
reactivating?: boolean reactivating?: boolean
onTogglePublicJoin?: () => void onTogglePublicJoin?: () => void
@@ -77,7 +76,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
} }
const [menuOpen, setMenuOpen] = createSignal(false) const [menuOpen, setMenuOpen] = createSignal(false)
const [plan, setPlan] = createSignal<PlanId>(props.relay.plan) const [plan, setPlan] = createSignal<PlanId>(props.relay.plan)
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
let menuContainerRef: HTMLDivElement | undefined let menuContainerRef: HTMLDivElement | undefined
@@ -88,24 +86,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
} }
const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free" const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free"
const showPlanActions = () => props.showPlanActions ?? true const showPlanActions = () => props.showPlanActions ?? true
const actionBusy = () => pendingAction() === "deactivate" ? !!props.deactivating : pendingAction() === "reactivate" ? !!props.reactivating : false
const relayLabel = () => r().info_name || r().subdomain
const confirmTitle = () => pendingAction() === "deactivate" ? "Deactivate relay?" : "Reactivate relay?"
const confirmDescription = () => pendingAction() === "deactivate"
? `${relayLabel()} will be taken offline immediately.`
: `${relayLabel()} will come back online and start accepting connections.`
const confirmDetails = () => pendingAction() === "deactivate"
? [
"All client connections will be dropped immediately.",
"Members will be unable to read from or publish to the relay.",
"Scheduled and automated tasks (billing, syncing) will be paused.",
"All relay data, settings, and members are preserved, nothing is deleted.",
"You can reactivate at any time from this page.",
]
: undefined
const confirmLabel = () => pendingAction() === "deactivate" ? "Yes, deactivate" : "Yes, reactivate"
const confirmBusyLabel = () => pendingAction() === "deactivate" ? "Deactivating..." : "Reactivating..."
const confirmTone = () => pendingAction() === "deactivate" ? "danger" : "primary"
async function changePlan(plan: PlanId) { async function changePlan(plan: PlanId) {
setPlan(plan) setPlan(plan)
@@ -117,29 +97,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
} }
} }
function openActionDialog(action: "deactivate" | "reactivate") {
setMenuOpen(false)
setPendingAction(action)
}
function closeActionDialog() {
if (actionBusy()) return
setPendingAction(null)
}
async function confirmAction() {
const action = pendingAction()
if (!action) return
if (action === "deactivate") {
await props.onDeactivate?.()
} else {
await props.onReactivate?.()
}
setPendingAction(null)
}
createEffect(() => { createEffect(() => {
if (!menuOpen()) return if (!menuOpen()) return
@@ -171,7 +128,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 min-w-0"> <div class="flex items-start gap-4 min-w-0">
<Show when={r().info_icon}> <Show when={r().info_icon}>
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover shrink-0 border border-gray-200" /> <img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover flex-shrink-0 border border-gray-200" />
</Show> </Show>
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-3 flex-wrap"> <div class="flex items-center gap-3 flex-wrap">
@@ -191,7 +148,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</div> </div>
<Show when={props.editHref && (props.onDeactivate || props.onReactivate)}> <Show when={props.editHref && (props.onDeactivate || props.onReactivate)}>
<div class="relative shrink-0" ref={menuContainerRef}> <div class="relative flex-shrink-0" ref={menuContainerRef}>
<button <button
type="button" type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50" class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50"
@@ -220,7 +177,8 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
type="button" type="button"
class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50" class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
onClick={() => { onClick={() => {
openActionDialog("deactivate") setMenuOpen(false)
props.onDeactivate?.()
}} }}
disabled={props.deactivating} disabled={props.deactivating}
> >
@@ -232,7 +190,8 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
type="button" type="button"
class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50" class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50"
onClick={() => { onClick={() => {
openActionDialog("reactivate") setMenuOpen(false)
props.onReactivate?.()
}} }}
disabled={props.reactivating} disabled={props.reactivating}
> >
@@ -387,19 +346,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</Show> </Show>
</DetailSection> </DetailSection>
</Show> </Show>
<ConfirmDialog
open={pendingAction() !== null}
title={confirmTitle()}
description={confirmDescription()}
details={confirmDetails()}
confirmLabel={confirmLabel()}
busyLabel={confirmBusyLabel()}
busy={actionBusy()}
tone={confirmTone()}
onConfirm={confirmAction}
onClose={closeActionDialog}
/>
</div> </div>
) )
} }
-7
View File
@@ -1,7 +1,6 @@
import { createEffect, createMemo, createSignal, For } from "solid-js" import { createEffect, createMemo, createSignal, For } from "solid-js"
import type { Relay } from "@/lib/hooks" import type { Relay } from "@/lib/hooks"
import { slugify } from "@/lib/slugify" import { slugify } from "@/lib/slugify"
import { validateSubdomainLabel } from "@/lib/subdomain"
import { setToastMessage } from "@/components/Toast" import { setToastMessage } from "@/components/Toast"
import { plans } from "@/lib/state" import { plans } from "@/lib/state"
@@ -32,12 +31,6 @@ export default function RelayForm(props: RelayFormProps) {
return return
} }
const subdomainError = validateSubdomainLabel(subdomain())
if (subdomainError) {
setToastMessage(subdomainError)
return
}
setToastMessage("") setToastMessage("")
setSubmitting(true) setSubmitting(true)
-4
View File
@@ -241,10 +241,6 @@ export function getRelay(id: string) {
return callApi<undefined, Relay>("GET", `/relays/${id}`) return callApi<undefined, Relay>("GET", `/relays/${id}`)
} }
export function listRelayMembers(id: string) {
return callApi<undefined, { members: string[] }>("GET", `/relays/${id}/members`)
}
export function listRelayActivity(id: string) { export function listRelayActivity(id: string) {
return callApi<undefined, { activity: Activity[] }>("GET", `/relays/${id}/activity`) return callApi<undefined, { activity: Activity[] }>("GET", `/relays/${id}/activity`)
} }
+10 -9
View File
@@ -2,6 +2,7 @@ import { createEffect, createResource, createSignal, onCleanup } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile" import { getProfilePicture } from "applesauce-core/helpers/profile"
import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection" import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection"
import { includeMailboxes } from "applesauce-core/observable" import { includeMailboxes } from "applesauce-core/observable"
import { Relay as NostrRelay, RelayManagement } from "applesauce-relay"
import { map, of } from "rxjs" import { map, of } from "rxjs"
import { import {
createRelay, createRelay,
@@ -11,14 +12,12 @@ import {
getTenant, getTenant,
listRelayActivity, listRelayActivity,
listRelays, listRelays,
listTenantInvoices,
listTenantRelays, listTenantRelays,
listTenants, listTenants,
updateRelay, updateRelay,
updateTenant, updateTenant,
type Activity, type Activity,
type CreateRelayInput, type CreateRelayInput,
type Invoice,
type Relay, type Relay,
type Tenant, type Tenant,
type UpdateRelayInput, type UpdateRelayInput,
@@ -138,12 +137,14 @@ export async function tenantNeedsPaymentSetup(): Promise<boolean> {
return !tenant.nwc_url && !tenant.stripe_subscription_id return !tenant.nwc_url && !tenant.stripe_subscription_id
} }
export async function getLatestOpenInvoice(): Promise<Invoice | null> { export async function getRelayMembers(url: string) {
const invoices = await listTenantInvoices(account()!.pubkey) const management = new RelayManagement(new NostrRelay(url), account()!.signer)
const open = invoices
.filter(inv => inv.status === "open" && inv.amount_due > 0) try {
.sort((a, b) => b.period_start - a.period_start) return await management.listAllowedPubkeys()
return open[0] ?? null } catch {
return []
}
} }
export type { Activity, Invoice, Relay, Tenant } export type { Activity, Relay, Tenant }
-22
View File
@@ -1,22 +0,0 @@
const SUBDOMAIN_LABEL_MAX_LEN = 63
const RESERVED_SUBDOMAIN_LABELS = new Set(["api", "admin"])
export function validateSubdomainLabel(subdomain: string): string | null {
if (subdomain.length === 0) {
return "subdomain is required"
}
if (subdomain.length > SUBDOMAIN_LABEL_MAX_LEN) {
return "subdomain must be 63 characters or fewer"
}
if (subdomain.startsWith("-") || subdomain.endsWith("-")) {
return "subdomain cannot start or end with a hyphen"
}
if (RESERVED_SUBDOMAIN_LABELS.has(subdomain)) {
return "subdomain is reserved"
}
if (!/^[a-z0-9-]+$/.test(subdomain)) {
return "subdomain may only contain lowercase letters, numbers, and hyphens"
}
return null
}
+6 -6
View File
@@ -1,7 +1,7 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, type Relay } from "@/lib/hooks" import { updateRelayById, deactivateRelayById, reactivateRelayById, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
import { setToastMessage } from "@/components/Toast" import { setToastMessage } from "@/components/Toast"
import type { Invoice, PlanId } from "@/lib/api" import type { PlanId } from "@/lib/api"
function toBool(value: number | undefined, fallback: boolean): boolean { function toBool(value: number | undefined, fallback: boolean): boolean {
if (value === 0) return false if (value === 0) return false
@@ -30,7 +30,7 @@ export default function useRelayToggles(
{ refetch, mutate }: RelayActions, { refetch, mutate }: RelayActions,
) { ) {
const [busy, setBusy] = createSignal(false) const [busy, setBusy] = createSignal(false)
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>() const [needsPaymentSetup, setNeedsPaymentSetup] = createSignal(false)
async function updateRelay(next: Relay, previous: Relay) { async function updateRelay(next: Relay, previous: Relay) {
mutate(next) mutate(next)
@@ -101,8 +101,8 @@ export default function useRelayToggles(
} }
if (plan !== "free") { if (plan !== "free") {
const invoice = await getLatestOpenInvoice() const needs = await tenantNeedsPaymentSetup()
if (invoice) setPendingInvoice(invoice) if (needs) setNeedsPaymentSetup(true)
} }
} }
@@ -116,5 +116,5 @@ export default function useRelayToggles(
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"), onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
} }
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), toggles } return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup: () => setNeedsPaymentSetup(false), toggles }
} }
+1 -12
View File
@@ -1,12 +1,11 @@
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createResource, Show } from "solid-js" import { Show } from "solid-js"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import RelayDetailCard from "@/components/RelayDetailCard" import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState" import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading" import useMinLoading from "@/components/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed" import ActivityFeed from "@/components/ActivityFeed"
import { listRelayMembers } from "@/lib/api"
import { useRelay, useRelayActivity } from "@/lib/hooks" import { useRelay, useRelayActivity } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles" import useRelayToggles from "@/lib/useRelayToggles"
@@ -14,15 +13,6 @@ export default function AdminRelayDetail() {
const params = useParams() const params = useParams()
const relayId = () => params.id ?? "" const relayId = () => params.id ?? ""
const [relay, { refetch, mutate }] = useRelay(relayId) const [relay, { refetch, mutate }] = useRelay(relayId)
const [members] = createResource(relayId, async (id) => {
if (!id) return []
try {
return (await listRelayMembers(id)).members
} catch {
return []
}
})
const loading = useMinLoading(() => relay.loading && !relay()) const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId) const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, toggles } = useRelayToggles(relayId, relay, { refetch, mutate }) const { busy, handleDeactivate, handleReactivate, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
@@ -36,7 +26,6 @@ export default function AdminRelayDetail() {
<div class="space-y-6 mb-6"> <div class="space-y-6 mb-6">
<RelayDetailCard <RelayDetailCard
relay={r()} relay={r()}
currentMembers={members()?.length}
showTenant showTenant
editHref={`/admin/relays/${params.id}/edit`} editHref={`/admin/relays/${params.id}/edit`}
onDeactivate={handleDeactivate} onDeactivate={handleDeactivate}
+10 -108
View File
@@ -1,59 +1,27 @@
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createMemo, createResource, createSignal, Show } from "solid-js" import { createMemo, createResource, Show } from "solid-js"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup" import PaymentSetup from "@/components/PaymentSetup"
import RelayDetailCard from "@/components/RelayDetailCard" import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState" import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading" import useMinLoading from "@/components/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed" import ActivityFeed from "@/components/ActivityFeed"
import { listRelayMembers } from "@/lib/api" import { getRelayMembers, useRelay, useRelayActivity } from "@/lib/hooks"
import { getLatestOpenInvoice, useRelay, useRelayActivity, useTenant } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles" import useRelayToggles from "@/lib/useRelayToggles"
import { plans } from "@/lib/state"
export default function RelayDetail() { export default function RelayDetail() {
const params = useParams() const params = useParams()
const relayId = () => params.id ?? "" const relayId = () => params.id ?? ""
const [relay, { refetch, mutate }] = useRelay(relayId) const [relay, { refetch, mutate }] = useRelay(relayId)
const [members] = createResource(relayId, async (id) => { const relayUrl = createMemo(() => {
if (!id) return [] const subdomain = relay()?.subdomain
return subdomain ? `wss://${subdomain}.spaces.coracle.social` : undefined
try {
return (await listRelayMembers(id)).members
} catch {
return []
}
}) })
const [members] = createResource(relayUrl, getRelayMembers)
const loading = useMinLoading(() => relay.loading && !relay()) const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId) const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, toggles } = useRelayToggles(relayId, relay, { refetch, mutate }) const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
const [tenant, { refetch: refetchTenant }] = useTenant()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false)
const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false)
const isPaidRelay = createMemo(() => {
const r = relay()
if (!r) return false
const plan = plans().find(p => p.id === r.plan)
return !!(plan && plan.amount > 0)
})
const [openInvoice, { refetch: refetchOpenInvoice }] = createResource(
isPaidRelay,
async (paid) => paid ? getLatestOpenInvoice() : null
)
const showPaymentNudge = createMemo(() => {
if (paymentBannerDismissed()) return false
if (!isPaidRelay()) return false
const t = tenant()
if (!t) return false
return !t.nwc_url
})
return ( return (
<PageContainer> <PageContainer>
@@ -62,47 +30,9 @@ export default function RelayDetail() {
<Show when={!loading() && relay()}> <Show when={!loading() && relay()}>
{(r) => ( {(r) => (
<div class="space-y-6 mb-6"> <div class="space-y-6 mb-6">
<Show when={showPaymentNudge()}>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-amber-800">Payment setup recommended</p>
<p class="text-sm text-amber-700 mt-1">
This relay is on a paid plan. Invoices are due when your subscription starts. Set up NWC or Stripe for automatic payments, or pay open invoices via Lightning.
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<Show when={openInvoice()}>
<button
type="button"
onClick={() => setInvoiceDialogOpen(true)}
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
>
Pay invoice
</button>
</Show>
<button
type="button"
onClick={() => setPaymentSetupOpen(true)}
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
>
Set up payments
</button>
<button
type="button"
onClick={() => setPaymentBannerDismissed(true)}
aria-label="Dismiss"
class="text-amber-500 hover:text-amber-800 shrink-0"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Show>
<RelayDetailCard <RelayDetailCard
relay={r()} relay={r()}
currentMembers={members()?.length} currentMembers={members.length}
editHref={`/relays/${params.id}/edit`} editHref={`/relays/${params.id}/edit`}
onDeactivate={handleDeactivate} onDeactivate={handleDeactivate}
onReactivate={handleReactivate} onReactivate={handleReactivate}
@@ -115,37 +45,9 @@ export default function RelayDetail() {
</div> </div>
)} )}
</Show> </Show>
<Show when={pendingInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()}
open={true}
onClose={() => {
clearPendingInvoice()
void refetchTenant()
void refetchOpenInvoice()
}}
/>
)}
</Show>
<Show when={openInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()!}
open={invoiceDialogOpen()}
onClose={() => {
setInvoiceDialogOpen(false)
void refetchOpenInvoice()
}}
/>
)}
</Show>
<PaymentSetup <PaymentSetup
open={paymentSetupOpen()} open={needsPaymentSetup()}
onClose={() => { onClose={clearNeedsPaymentSetup}
setPaymentSetupOpen(false)
void refetchTenant()
}}
/> />
</PageContainer> </PageContainer>
) )
+2 -1
View File
@@ -1,6 +1,7 @@
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { Show } from "solid-js" import { Show } from "solid-js"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm" import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { slugify } from "@/lib/slugify"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import ResourceState from "@/components/ResourceState" import ResourceState from "@/components/ResourceState"
@@ -17,7 +18,7 @@ export default function RelayEdit(props: { basePath?: string; title?: string })
async function handleSubmit(values: RelayFormValues) { async function handleSubmit(values: RelayFormValues) {
await updateRelayById(relayId(), { await updateRelayById(relayId(), {
subdomain: values.subdomain, subdomain: slugify(values.subdomain),
info_name: values.info_name.trim(), info_name: values.info_name.trim(),
info_icon: values.info_icon.trim(), info_icon: values.info_icon.trim(),
info_description: values.info_description.trim(), info_description: values.info_description.trim(),
+12 -18
View File
@@ -1,15 +1,14 @@
import { createSignal, Show } from "solid-js" import { createSignal } from "solid-js"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog" import PaymentSetup from "@/components/PaymentSetup"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm" import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { createRelayForActiveTenant, getLatestOpenInvoice } from "@/lib/hooks" import { createRelayForActiveTenant, tenantNeedsPaymentSetup } from "@/lib/hooks"
import type { Invoice } from "@/lib/api"
export default function RelayNew() { export default function RelayNew() {
const navigate = useNavigate() const navigate = useNavigate()
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>() const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
let createdRelayId = "" let createdRelayId = ""
async function handleSubmit(values: RelayFormValues) { async function handleSubmit(values: RelayFormValues) {
@@ -17,9 +16,9 @@ export default function RelayNew() {
createdRelayId = relay.id createdRelayId = relay.id
if (values.plan !== "free") { if (values.plan !== "free") {
const invoice = await getLatestOpenInvoice() const needs = await tenantNeedsPaymentSetup()
if (invoice) { if (needs) {
setPendingInvoice(invoice) setShowPaymentSetup(true)
return return
} }
} }
@@ -28,7 +27,7 @@ export default function RelayNew() {
} }
function handleDialogClose() { function handleDialogClose() {
setPendingInvoice(undefined) setShowPaymentSetup(false)
navigate(`/relays/${createdRelayId}`) navigate(`/relays/${createdRelayId}`)
} }
@@ -42,15 +41,10 @@ export default function RelayNew() {
submitLabel="Create Relay" submitLabel="Create Relay"
submittingLabel="Creating..." submittingLabel="Creating..."
/> />
<Show when={pendingInvoice()}> <PaymentSetup
{(inv) => ( open={showPaymentSetup()}
<PaymentDialog onClose={handleDialogClose}
invoice={inv()} />
open={true}
onClose={handleDialogClose}
/>
)}
</Show>
</PageContainer> </PageContainer>
) )
} }