Compare commits

...

5 Commits

21 changed files with 939 additions and 136 deletions
+10 -3
View File
@@ -74,13 +74,20 @@ Public exceptions:
- `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)
- `GET /tenants/:pubkey` — get tenant (admin or same tenant)
- `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant)
- `GET /relays` — list relays (`?tenant=<pubkey>` allowed for admin only)
- `PUT /tenants/:pubkey` — update tenant `nwc_url` (admin or same tenant)
- `GET /tenants/:pubkey/relays` — list tenant relays (admin or same tenant)
- `GET /relays` — list relays (admin)
- `POST /relays` — create 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)
- `GET /relays/:id/activity` — list relay activity (admin or relay tenant)
- `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant)
- `GET /invoices` — list invoices (`?tenant=<pubkey>` allowed for admin only)
- `POST /relays/:id/reactivate` — reactivate relay (admin or relay tenant)
- `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
@@ -0,0 +1,11 @@
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);
+10
View File
@@ -9,6 +9,7 @@ Members:
- `query: Query`
- `command: Command`
- `billing: Billing`
- `infra: Infra`
Notes:
@@ -103,6 +104,14 @@ Notes:
- Authorizes admin or relay owner
- 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`
- Serves `POST /relays`
@@ -117,6 +126,7 @@ Notes:
- Serves `PUT /relays/:id`
- Authorizes admin or relay owner
- 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`
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
- Return `data` is a single relay struct.
+33 -3
View File
@@ -26,12 +26,15 @@ 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.
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`
- **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 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 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 `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.
- **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`.
## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>`
@@ -44,6 +47,7 @@ Manages the Stripe subscription and subscription items for a relay's tenant. Onl
- `invoice.overdue` -> `self.handle_invoice_overdue`
- `customer.subscription.updated` -> `self.handle_subscription_updated`
- `customer.subscription.deleted` -> `self.handle_subscription_deleted`
- `payment_method.attached` -> `self.handle_payment_method_attached`
- Unknown event types are ignored (return Ok)
## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<Value>`
@@ -66,16 +70,37 @@ Manages the Stripe subscription and subscription items for a relay's tenant. Onl
- Creates a Stripe Customer Portal session for the given customer
- 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)`
Attempts to pay a new subscription invoice. Payment priority:
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:
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)
- 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 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 — Stripe will charge automatically.
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.
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.
@@ -112,3 +137,8 @@ Skip invoices with `amount_due` of 0.
- Look up tenant by `stripe_customer_id`
- 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
+5 -2
View File
@@ -15,12 +15,15 @@ Members:
## `pub async fn start(self)`
- 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`.
## `async fn handle_activity(&self, activity: &Activity)`
- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report`.
- All other activity types are ignored (e.g. `fail_relay_sync`, `complete_relay_sync`).
- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report` immediately.
- For `fail_relay_sync`, schedules a delayed retry using exponential backoff based on consecutive failures for the relay.
- 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)`
+208 -54
View File
@@ -1,6 +1,6 @@
use std::sync::Arc;
use anyhow::anyhow;
use anyhow::{Result, anyhow};
use axum::{
Json, Router,
extract::{Path, State},
@@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize};
use crate::billing::{Billing, InvoiceLookupError};
use crate::command::Command;
use crate::infra::Infra;
use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
};
@@ -27,6 +28,7 @@ pub struct Api {
query: Query,
command: Command,
billing: Billing,
infra: Infra,
}
async fn stripe_webhook(
@@ -117,7 +119,7 @@ fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError {
}
impl Api {
pub fn new(query: Query, command: Command, billing: Billing) -> Self {
pub fn new(query: Query, command: Command, billing: Billing, infra: Infra) -> Self {
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let admins = std::env::var("ADMINS")
.unwrap_or_default()
@@ -131,6 +133,7 @@ impl Api {
query,
command,
billing,
infra,
}
}
@@ -148,6 +151,7 @@ impl Api {
.route("/tenants/:pubkey/relays", get(list_tenant_relays))
.route("/relays", get(list_relays).post(create_relay))
.route("/relays/:id", get(get_relay).put(update_relay))
.route("/relays/:id/members", get(list_relay_members))
.route("/relays/:id/activity", get(list_relay_activity))
.route("/relays/:id/deactivate", post(deactivate_relay))
.route("/relays/:id/reactivate", post(reactivate_relay))
@@ -251,22 +255,24 @@ impl Api {
}
}
fn prepare_relay(&self, mut relay: Relay) -> anyhow::Result<Relay> {
if !relay
.subdomain
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(anyhow!("invalid-subdomain"));
async fn fetch_relay_members(&self, relay: &Relay) -> Result<Vec<String>> {
if relay.synced == 0 {
return Ok(Vec::new());
}
let plan = Query::get_plan(&relay.plan).ok_or_else(|| anyhow!("invalid-plan"))?;
self.infra.list_relay_members(&relay.id).await
}
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 {
return Err(anyhow!("premium-feature"));
return Err(RelayValidationError::PremiumFeature);
}
if !plan.livekit && relay.livekit_enabled == 1 {
return Err(anyhow!("premium-feature"));
return Err(RelayValidationError::PremiumFeature);
}
if relay.schema.is_empty() {
@@ -289,6 +295,96 @@ 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 {
(status, Json(OkResponse { data, code: "ok" })).into_response()
}
@@ -316,6 +412,14 @@ 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> {
let sqlx_err = err.downcast_ref::<sqlx::Error>()?;
let sqlx::Error::Database(db_err) = sqlx_err else {
@@ -577,6 +681,40 @@ 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(
State(state): State<AppState>,
headers: HeaderMap,
@@ -608,27 +746,9 @@ async fn create_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,
Err(e) if e.to_string() == "premium-feature" => {
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",
));
Err(e) => {
return Ok(relay_validation_error_response(e));
}
};
@@ -674,10 +794,13 @@ async fn update_relay(
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 {
relay.subdomain = v;
}
if let Some(v) = payload.plan {
if let Some(v) = requested_plan.clone() {
relay.plan = v;
}
if let Some(v) = payload.info_name {
@@ -712,30 +835,44 @@ async fn update_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,
Err(e) if e.to_string() == "premium-feature" => {
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",
));
Err(e) => {
return Ok(relay_validation_error_response(e));
}
};
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 {
Ok(()) => Ok(ok(StatusCode::OK, relay)),
Err(e) => {
@@ -945,12 +1082,29 @@ async fn update_tenant(
state.api.require_admin_or_tenant(&auth, &pubkey)?;
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 {
tenant.nwc_url = nwc_url;
}
match state.api.command.update_tenant(&tenant).await {
Ok(()) => Ok(ok(StatusCode::OK, tenant)),
Ok(()) => {
// 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(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
+254 -2
View File
@@ -167,6 +167,8 @@ impl Billing {
self.command
.delete_relay_subscription_item(&relay.id)
.await?;
self.validate_downgrade_proration(&tenant, "free-plan-downgrade")
.await;
}
self.cleanup_empty_subscription(&tenant.pubkey).await?;
return Ok(());
@@ -206,8 +208,28 @@ impl Billing {
// Sync the subscription item: create or update
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 {
self.stripe_update_subscription_item(existing_item_id, stripe_price_id)
.await?
let is_downgrade = self
.is_subscription_item_downgrade(existing_item_id, plan.amount)
.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 {
self.stripe_create_subscription_item(subscription_id, stripe_price_id)
.await?
@@ -275,6 +297,10 @@ impl Billing {
let customer = obj["customer"].as_str().unwrap_or_default();
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?;
}
_ => {}
}
@@ -492,6 +518,103 @@ impl Billing {
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 ---
pub async fn get_invoice_with_tenant(
@@ -599,6 +722,93 @@ impl Billing {
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(amount_due, currency, &tenant.nwc_url)
.await
{
Ok(()) => {
if let Err(e) = self.stripe_pay_invoice_out_of_band(invoice_id).await {
tracing::error!(
error = %e,
invoice_id,
"failed to mark invoice paid out of band"
);
} else {
let _ = self.command.clear_tenant_nwc_error(&tenant.pubkey).await;
}
}
Err(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;
}
}
}
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> {
let resp = self
.http
@@ -715,6 +925,48 @@ impl Billing {
Ok(())
}
async fn stripe_pay_invoice(&self, invoice_id: &str) -> Result<()> {
self.http
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
.bearer_auth(&self.stripe_secret_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<()> {
self.http
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
+173 -13
View File
@@ -1,10 +1,15 @@
use anyhow::Result;
use nostr_sdk::prelude::*;
use std::time::Duration;
use crate::command::Command;
use crate::models::{Activity, Relay, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE};
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
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)]
pub struct Infra {
api_url: String,
@@ -18,14 +23,22 @@ pub struct Infra {
}
impl Infra {
pub fn new(query: Query, command: Command) -> Self {
pub fn new(query: Query, command: Command) -> Result<Self> {
let api_url = std::env::var("ZOOID_API_URL").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_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 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,
relay_domain,
livekit_url,
@@ -34,12 +47,16 @@ impl Infra {
api_secret,
query,
command,
}
})
}
pub async fn start(self) {
let mut rx = self.command.notify.subscribe();
if let Err(e) = self.schedule_startup_retries().await {
tracing::error!(error = %e, "failed to schedule relay sync retries on startup");
}
loop {
match rx.recv().await {
Ok(activity) => {
@@ -58,15 +75,84 @@ impl Infra {
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
if needs_sync {
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
return Ok(());
};
let is_new = relay.synced == 0;
self.sync_and_report(&relay, is_new).await;
if !needs_sync || activity.resource_type != "relay" {
return Ok(());
}
if activity.activity_type == "fail_relay_sync" {
self.schedule_relay_sync_retry(&activity.resource_id, "activity")
.await?;
return Ok(());
}
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
return Ok(());
};
let is_new = relay.synced == 0;
self.sync_and_report(&relay, is_new).await;
Ok(())
}
async fn schedule_startup_retries(&self) -> Result<()> {
let relays = self.query.list_relays_with_sync_error().await?;
for relay in relays {
self.schedule_relay_sync_retry(&relay.id, "startup").await?;
}
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 = relay.synced == 0;
self.sync_and_report(&relay, is_new).await;
Ok(())
}
@@ -96,6 +182,28 @@ impl Infra {
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<()> {
let client = reqwest::Client::new();
let base = self.api_url.trim_end_matches('/');
@@ -125,7 +233,9 @@ impl Infra {
);
let url = format!("{}/relay/{}", base, relay.id);
let auth = self.nip98_auth(&url, zooid_sync_http_method(is_new)).await?;
let auth = self
.nip98_auth(&url, zooid_sync_http_method(is_new))
.await?;
let request = if is_new {
client.post(&url)
@@ -156,6 +266,34 @@ 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(
relay: &Relay,
host: String,
@@ -198,6 +336,28 @@ fn relay_sync_body(
fn should_sync_relay_activity(activity_type: &str) -> bool {
matches!(
activity_type,
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync"
)
}
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 command = Command::new(pool);
let billing = Billing::new(query.clone(), command.clone(), robot.clone());
let infra = Infra::new(query.clone(), command.clone());
let api = Api::new(query, command, billing.clone());
let infra = Infra::new(query.clone(), command.clone())?;
let api = Api::new(query, command, billing.clone(), infra.clone());
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("PORT")
+17
View File
@@ -94,6 +94,23 @@ impl Query {
Ok(rows)
}
pub async fn list_relays_with_sync_error(&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 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>> {
let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
+25 -14
View File
@@ -3,7 +3,6 @@ import QRCode from "qrcode"
import Modal from "@/components/Modal"
import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, getInvoiceBolt11 } from "@/lib/api"
import { tenantNeedsPaymentSetup } from "@/lib/hooks"
type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
@@ -26,8 +25,8 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const [bolt11Error, setBolt11Error] = createSignal("")
const [payStatus, setPayStatus] = createSignal<PayStatus>("idle")
const [payError, setPayError] = createSignal("")
const [showSetup, setShowSetup] = createSignal(false)
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [setupSaved, setSetupSaved] = createSignal(false)
async function loadBolt11() {
if (!props.invoice.id) return
@@ -63,7 +62,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const invoice = await getInvoice(props.invoice.id)
if (invoice.status === "paid") {
setPayStatus("success")
tenantNeedsPaymentSetup().then(needs => setShowSetup(needs)).catch(() => {})
} else {
setPayStatus("error")
setPayError("Payment not yet confirmed. Please try again after sending.")
@@ -81,7 +79,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
setBolt11Error("")
setBolt11("")
setQrDataUrl("")
setShowSetup(false)
props.onClose()
}
@@ -160,6 +157,15 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</button>
</div>
</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>
</div>
}
@@ -172,15 +178,13 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</div>
<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>
<Show when={showSetup()}>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Set up automatic payments
</button>
</Show>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Set up automatic payments
</button>
</div>
</Show>
</div>
@@ -228,7 +232,14 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</Modal>
<PaymentSetup
open={showPaymentSetup()}
onClose={() => setShowPaymentSetup(false)}
onClose={() => {
setShowPaymentSetup(false)
if (setupSaved()) {
setSetupSaved(false)
props.onClose()
}
}}
onSaved={() => setSetupSaved(true)}
/>
</>
)
+4 -2
View File
@@ -9,6 +9,7 @@ type Tab = "nwc" | "card"
type PaymentSetupProps = {
open: boolean
onClose: () => void
onSaved?: () => void
}
export default function PaymentSetup(props: PaymentSetupProps) {
@@ -27,6 +28,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
try {
await updateActiveTenant({ nwc_url: url })
setSaved(true)
props.onSaved?.()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save wallet connection")
} finally {
@@ -64,7 +66,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
<div class="flex items-start justify-between gap-3">
<div>
<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 for your relay.</p>
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay once invoices are issued for your relay.</p>
</div>
<button
type="button"
@@ -144,7 +146,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing.</p>
<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>
<button
type="button"
onClick={openPortal}
+7
View File
@@ -1,6 +1,7 @@
import { createEffect, createMemo, createSignal, For } from "solid-js"
import type { Relay } from "@/lib/hooks"
import { slugify } from "@/lib/slugify"
import { validateSubdomainLabel } from "@/lib/subdomain"
import { setToastMessage } from "@/components/Toast"
import { plans } from "@/lib/state"
@@ -31,6 +32,12 @@ export default function RelayForm(props: RelayFormProps) {
return
}
const subdomainError = validateSubdomainLabel(subdomain())
if (subdomainError) {
setToastMessage(subdomainError)
return
}
setToastMessage("")
setSubmitting(true)
+4
View File
@@ -241,6 +241,10 @@ export function getRelay(id: string) {
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) {
return callApi<undefined, { activity: Activity[] }>("GET", `/relays/${id}/activity`)
}
+9 -10
View File
@@ -2,7 +2,6 @@ import { createEffect, createResource, createSignal, onCleanup } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection"
import { includeMailboxes } from "applesauce-core/observable"
import { Relay as NostrRelay, RelayManagement } from "applesauce-relay"
import { map, of } from "rxjs"
import {
createRelay,
@@ -12,12 +11,14 @@ import {
getTenant,
listRelayActivity,
listRelays,
listTenantInvoices,
listTenantRelays,
listTenants,
updateRelay,
updateTenant,
type Activity,
type CreateRelayInput,
type Invoice,
type Relay,
type Tenant,
type UpdateRelayInput,
@@ -137,14 +138,12 @@ export async function tenantNeedsPaymentSetup(): Promise<boolean> {
return !tenant.nwc_url && !tenant.stripe_subscription_id
}
export async function getRelayMembers(url: string) {
const management = new RelayManagement(new NostrRelay(url), account()!.signer)
try {
return await management.listAllowedPubkeys()
} catch {
return []
}
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
const invoices = await listTenantInvoices(account()!.pubkey)
const open = invoices
.filter(inv => inv.status === "open" && inv.amount_due > 0)
.sort((a, b) => b.period_start - a.period_start)
return open[0] ?? null
}
export type { Activity, Relay, Tenant }
export type { Activity, Invoice, Relay, Tenant }
+22
View File
@@ -0,0 +1,22 @@
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 { updateRelayById, deactivateRelayById, reactivateRelayById, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, type Relay } from "@/lib/hooks"
import { setToastMessage } from "@/components/Toast"
import type { PlanId } from "@/lib/api"
import type { Invoice, PlanId } from "@/lib/api"
function toBool(value: number | undefined, fallback: boolean): boolean {
if (value === 0) return false
@@ -30,7 +30,7 @@ export default function useRelayToggles(
{ refetch, mutate }: RelayActions,
) {
const [busy, setBusy] = createSignal(false)
const [needsPaymentSetup, setNeedsPaymentSetup] = createSignal(false)
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
async function updateRelay(next: Relay, previous: Relay) {
mutate(next)
@@ -101,8 +101,8 @@ export default function useRelayToggles(
}
if (plan !== "free") {
const needs = await tenantNeedsPaymentSetup()
if (needs) setNeedsPaymentSetup(true)
const invoice = await getLatestOpenInvoice()
if (invoice) setPendingInvoice(invoice)
}
}
@@ -116,5 +116,5 @@ export default function useRelayToggles(
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
}
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup: () => setNeedsPaymentSetup(false), toggles }
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), toggles }
}
+12 -1
View File
@@ -1,11 +1,12 @@
import { useParams } from "@solidjs/router"
import { Show } from "solid-js"
import { createResource, Show } from "solid-js"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed"
import { listRelayMembers } from "@/lib/api"
import { useRelay, useRelayActivity } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles"
@@ -13,6 +14,15 @@ export default function AdminRelayDetail() {
const params = useParams()
const relayId = () => params.id ?? ""
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 [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
@@ -26,6 +36,7 @@ export default function AdminRelayDetail() {
<div class="space-y-6 mb-6">
<RelayDetailCard
relay={r()}
currentMembers={members()?.length}
showTenant
editHref={`/admin/relays/${params.id}/edit`}
onDeactivate={handleDeactivate}
+108 -10
View File
@@ -1,27 +1,59 @@
import { useParams } from "@solidjs/router"
import { createMemo, createResource, Show } from "solid-js"
import { createMemo, createResource, createSignal, Show } from "solid-js"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed"
import { getRelayMembers, useRelay, useRelayActivity } from "@/lib/hooks"
import { listRelayMembers } from "@/lib/api"
import { getLatestOpenInvoice, useRelay, useRelayActivity, useTenant } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles"
import { plans } from "@/lib/state"
export default function RelayDetail() {
const params = useParams()
const relayId = () => params.id ?? ""
const [relay, { refetch, mutate }] = useRelay(relayId)
const relayUrl = createMemo(() => {
const subdomain = relay()?.subdomain
return subdomain ? `wss://${subdomain}.spaces.coracle.social` : undefined
const [members] = createResource(relayId, async (id) => {
if (!id) return []
try {
return (await listRelayMembers(id)).members
} catch {
return []
}
})
const [members] = createResource(relayUrl, getRelayMembers)
const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, 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 (
<PageContainer>
@@ -30,9 +62,47 @@ export default function RelayDetail() {
<Show when={!loading() && relay()}>
{(r) => (
<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
relay={r()}
currentMembers={members.length}
currentMembers={members()?.length}
editHref={`/relays/${params.id}/edit`}
onDeactivate={handleDeactivate}
onReactivate={handleReactivate}
@@ -45,9 +115,37 @@ export default function RelayDetail() {
</div>
)}
</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
open={needsPaymentSetup()}
onClose={clearNeedsPaymentSetup}
open={paymentSetupOpen()}
onClose={() => {
setPaymentSetupOpen(false)
void refetchTenant()
}}
/>
</PageContainer>
)
+1 -2
View File
@@ -1,7 +1,6 @@
import { useNavigate, useParams } from "@solidjs/router"
import { Show } from "solid-js"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { slugify } from "@/lib/slugify"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import ResourceState from "@/components/ResourceState"
@@ -18,7 +17,7 @@ export default function RelayEdit(props: { basePath?: string; title?: string })
async function handleSubmit(values: RelayFormValues) {
await updateRelayById(relayId(), {
subdomain: slugify(values.subdomain),
subdomain: values.subdomain,
info_name: values.info_name.trim(),
info_icon: values.info_icon.trim(),
info_description: values.info_description.trim(),
+18 -12
View File
@@ -1,14 +1,15 @@
import { createSignal } from "solid-js"
import { createSignal, Show } from "solid-js"
import { useNavigate } from "@solidjs/router"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import PaymentSetup from "@/components/PaymentSetup"
import PaymentDialog from "@/components/PaymentDialog"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { createRelayForActiveTenant, tenantNeedsPaymentSetup } from "@/lib/hooks"
import { createRelayForActiveTenant, getLatestOpenInvoice } from "@/lib/hooks"
import type { Invoice } from "@/lib/api"
export default function RelayNew() {
const navigate = useNavigate()
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
let createdRelayId = ""
async function handleSubmit(values: RelayFormValues) {
@@ -16,9 +17,9 @@ export default function RelayNew() {
createdRelayId = relay.id
if (values.plan !== "free") {
const needs = await tenantNeedsPaymentSetup()
if (needs) {
setShowPaymentSetup(true)
const invoice = await getLatestOpenInvoice()
if (invoice) {
setPendingInvoice(invoice)
return
}
}
@@ -27,7 +28,7 @@ export default function RelayNew() {
}
function handleDialogClose() {
setShowPaymentSetup(false)
setPendingInvoice(undefined)
navigate(`/relays/${createdRelayId}`)
}
@@ -41,10 +42,15 @@ export default function RelayNew() {
submitLabel="Create Relay"
submittingLabel="Creating..."
/>
<PaymentSetup
open={showPaymentSetup()}
onClose={handleDialogClose}
/>
<Show when={pendingInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()}
open={true}
onClose={handleDialogClose}
/>
)}
</Show>
</PageContainer>
)
}