diff --git a/backend/src/api.rs b/backend/src/api.rs index e39de4b..f1b6720 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -14,7 +14,9 @@ use serde::{Deserialize, Serialize}; use crate::billing::Billing; use crate::command::Command; -use crate::models::{Relay, Tenant}; +use crate::models::{ + RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant, +}; use crate::query::Query; use axum::body::Bytes; @@ -122,7 +124,10 @@ impl Api { .route("/tenants/:pubkey/invoices", get(list_tenant_invoices)) .route("/invoices/:id", get(get_invoice)) .route("/invoices/:id/bolt11", get(get_invoice_bolt11)) - .route("/tenants/:pubkey/stripe/session", get(create_stripe_session)) + .route( + "/tenants/:pubkey/stripe/session", + get(create_stripe_session), + ) .route("/stripe/webhook", post(stripe_webhook)) .with_state(state) } @@ -232,7 +237,7 @@ impl Api { relay.schema = format!("{}_{}", relay.subdomain.replace('-', "_"), relay.id); } if relay.status.is_empty() { - relay.status = "active".to_string(); + relay.status = RELAY_STATUS_ACTIVE.to_string(); } relay.policy_public_join = parse_bool_default(relay.policy_public_join, 0); relay.policy_strip_signatures = parse_bool_default(relay.policy_strip_signatures, 0); @@ -393,13 +398,7 @@ async fn get_identity( }; } - Ok(ok( - StatusCode::OK, - IdentityResponse { - pubkey, - is_admin, - }, - )) + Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin })) } async fn get_plan(Path(id): Path) -> Response { @@ -489,14 +488,27 @@ async fn list_relay_activity( 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())), + 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.query.list_activity_for_relay(&id).await { - Ok(activity) => Ok(ok(StatusCode::OK, serde_json::json!({ "activity": activity }))), - Err(e) => Ok(err(StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string())), + Ok(activity) => Ok(ok( + StatusCode::OK, + serde_json::json!({ "activity": activity }), + )), + Err(e) => Ok(err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + &e.to_string(), + )), } } @@ -515,7 +527,7 @@ async fn create_relay( subdomain: payload.subdomain, plan: payload.plan, stripe_subscription_item_id: None, - status: "active".to_string(), + status: RELAY_STATUS_ACTIVE.to_string(), sync_error: String::new(), info_name: payload.info_name.unwrap_or_default(), info_icon: payload.info_icon.unwrap_or_default(), @@ -686,7 +698,7 @@ async fn deactivate_relay( state.api.require_admin_or_tenant(&auth, &relay.tenant)?; - if relay.status == "inactive" { + if relay.status == RELAY_STATUS_INACTIVE || relay.status == RELAY_STATUS_DELINQUENT { return Ok(err( StatusCode::BAD_REQUEST, "relay-is-inactive", @@ -725,7 +737,7 @@ async fn reactivate_relay( state.api.require_admin_or_tenant(&auth, &relay.tenant)?; - if relay.status == "active" { + if relay.status == RELAY_STATUS_ACTIVE { return Ok(err( StatusCode::BAD_REQUEST, "relay-is-active", @@ -773,7 +785,11 @@ async fn get_invoice( Path(id): Path, ) -> std::result::Result { let auth = state.api.extract_auth_pubkey(&headers)?; - let (invoice, tenant) = state.api.billing.get_invoice_with_tenant(&id).await + let (invoice, tenant) = state + .api + .billing + .get_invoice_with_tenant(&id) + .await .map_err(|e| ApiError::Internal(e.to_string()))?; state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?; @@ -786,7 +802,11 @@ async fn get_invoice_bolt11( Path(id): Path, ) -> std::result::Result { let auth = state.api.extract_auth_pubkey(&headers)?; - let (invoice, tenant) = state.api.billing.get_invoice_with_tenant(&id).await + let (invoice, tenant) = state + .api + .billing + .get_invoice_with_tenant(&id) + .await .map_err(|e| ApiError::Internal(e.to_string()))?; state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?; diff --git a/backend/src/billing.rs b/backend/src/billing.rs index c862eea..a859c3f 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -6,7 +6,9 @@ use nwc::prelude::{ use sha2::Sha256; use crate::command::Command; -use crate::models::Activity; +use crate::models::{ + Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, +}; use crate::query::Query; use crate::robot::Robot; @@ -55,8 +57,8 @@ impl Billing { let nwc_url = std::env::var("NWC_URL").unwrap_or_default(); let stripe_secret_key = std::env::var("STRIPE_SECRET_KEY").unwrap_or_default(); let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(); - let btc_quote_api_base = std::env::var("BTC_PRICE_API_BASE") - .unwrap_or_else(|_| COINBASE_SPOT_API.to_string()); + let btc_quote_api_base = + std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string()); Self { nwc_url, stripe_secret_key, @@ -127,7 +129,7 @@ impl Billing { } // Inactive relay: remove subscription item if exists, then clean up - if relay.status == "inactive" { + if relay.status == RELAY_STATUS_INACTIVE || relay.status == RELAY_STATUS_DELINQUENT { if let Some(ref item_id) = relay.stripe_subscription_item_id { self.stripe_delete_subscription_item(item_id).await?; self.command @@ -348,7 +350,7 @@ impl Billing { let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?; for relay in relays { - if relay.status == "inactive" && relay.plan != "free" { + if Self::should_reactivate_after_payment(&relay) { self.command.activate_relay(&relay).await?; } } @@ -402,8 +404,8 @@ impl Billing { let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?; for relay in relays { - if relay.status == "active" && relay.plan != "free" { - self.command.deactivate_relay(&relay).await?; + if relay.status == RELAY_STATUS_ACTIVE && relay.plan != "free" { + self.command.mark_relay_delinquent(&relay).await?; } } @@ -437,8 +439,8 @@ impl Billing { let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?; for relay in relays { - if relay.status == "active" && relay.plan != "free" { - self.command.deactivate_relay(&relay).await?; + if relay.status == RELAY_STATUS_ACTIVE && relay.plan != "free" { + self.command.mark_relay_delinquent(&relay).await?; } } @@ -722,6 +724,10 @@ impl Billing { fiat_minor_to_msats_from_quote(amount_due_minor, &normalized_currency, btc_price) } + fn should_reactivate_after_payment(relay: &Relay) -> bool { + relay.status == RELAY_STATUS_DELINQUENT && relay.plan != "free" + } + async fn fetch_btc_spot_price(&self, currency: &str) -> Result { fetch_btc_spot_price_from_base(&self.http, &self.btc_quote_api_base, currency).await } @@ -759,7 +765,9 @@ pub async fn fetch_btc_spot_price_from_base( .map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))?; if amount <= 0.0 { - return Err(anyhow!("invalid non-positive BTC spot quote for {currency}")); + return Err(anyhow!( + "invalid non-positive BTC spot quote for {currency}" + )); } Ok(amount) @@ -799,7 +807,34 @@ pub fn fiat_minor_to_msats_from_quote( #[cfg(test)] mod tests { - use super::fiat_minor_to_msats_from_quote; + use super::{Billing, fiat_minor_to_msats_from_quote}; + use crate::models::{ + RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, + }; + + fn relay_fixture(status: &str, plan: &str) -> Relay { + Relay { + id: "relay-1".to_string(), + tenant: "tenant-1".to_string(), + schema: "tenant_1".to_string(), + subdomain: "relay-1".to_string(), + plan: plan.to_string(), + stripe_subscription_item_id: None, + status: status.to_string(), + sync_error: String::new(), + info_name: String::new(), + info_icon: String::new(), + info_description: String::new(), + policy_public_join: 0, + policy_strip_signatures: 0, + groups_enabled: 1, + management_enabled: 1, + blossom_enabled: 1, + livekit_enabled: 1, + push_enabled: 1, + synced: 1, + } + } #[test] fn converts_usd_minor_units_with_quote() { @@ -814,4 +849,26 @@ mod tests { .expect("conversion should succeed"); assert_eq!(msats, 1_000_000); } + + #[test] + fn reactivates_only_delinquent_paid_relays_after_payment() { + let delinquent_paid = relay_fixture(RELAY_STATUS_DELINQUENT, "basic"); + assert!(Billing::should_reactivate_after_payment(&delinquent_paid)); + + let manually_inactive_paid = relay_fixture(RELAY_STATUS_INACTIVE, "basic"); + assert!(!Billing::should_reactivate_after_payment( + &manually_inactive_paid + )); + + let free_delinquent = relay_fixture(RELAY_STATUS_DELINQUENT, "free"); + assert!(!Billing::should_reactivate_after_payment(&free_delinquent)); + + let active_paid = relay_fixture(RELAY_STATUS_ACTIVE, "basic"); + assert!(!Billing::should_reactivate_after_payment(&active_paid)); + + let unknown_status_paid = relay_fixture("suspended", "basic"); + assert!(!Billing::should_reactivate_after_payment( + &unknown_status_paid + )); + } } diff --git a/backend/src/command.rs b/backend/src/command.rs index 72bd22c..ad23467 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -2,7 +2,9 @@ use anyhow::Result; use sqlx::{Sqlite, SqlitePool, Transaction}; use tokio::sync::broadcast; -use crate::models::{Activity, Relay, Tenant}; +use crate::models::{ + Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant, +}; #[derive(Clone)] pub struct Command { @@ -77,7 +79,8 @@ impl Command { .execute(&mut *tx) .await?; - let activity = Self::insert_activity(&mut tx, "create_tenant", "tenant", &tenant.pubkey).await?; + let activity = + Self::insert_activity(&mut tx, "create_tenant", "tenant", &tenant.pubkey).await?; tx.commit().await?; self.emit(activity); @@ -93,7 +96,8 @@ impl Command { .execute(&mut *tx) .await?; - let activity = Self::insert_activity(&mut tx, "update_tenant", "tenant", &tenant.pubkey).await?; + let activity = + Self::insert_activity(&mut tx, "update_tenant", "tenant", &tenant.pubkey).await?; tx.commit().await?; self.emit(activity); @@ -178,14 +182,30 @@ impl Command { } pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> { + self.set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay") + .await + } + + pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()> { + self.set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "deactivate_relay") + .await + } + + async fn set_relay_status( + &self, + relay_id: &str, + status: &str, + activity_type: &str, + ) -> Result<()> { let mut tx = self.pool.begin().await?; - sqlx::query("UPDATE relay SET status = 'inactive' WHERE id = ?") - .bind(&relay.id) + sqlx::query("UPDATE relay SET status = ? WHERE id = ?") + .bind(status) + .bind(relay_id) .execute(&mut *tx) .await?; - let activity = Self::insert_activity(&mut tx, "deactivate_relay", "relay", &relay.id).await?; + let activity = Self::insert_activity(&mut tx, activity_type, "relay", relay_id).await?; tx.commit().await?; self.emit(activity); @@ -193,18 +213,8 @@ impl Command { } pub async fn activate_relay(&self, relay: &Relay) -> Result<()> { - let mut tx = self.pool.begin().await?; - - sqlx::query("UPDATE relay SET status = 'active' WHERE id = ?") - .bind(&relay.id) - .execute(&mut *tx) - .await?; - - let activity = Self::insert_activity(&mut tx, "activate_relay", "relay", &relay.id).await?; - - tx.commit().await?; - self.emit(activity); - Ok(()) + self.set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay") + .await } pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> { @@ -216,7 +226,8 @@ impl Command { .execute(&mut *tx) .await?; - let activity = Self::insert_activity(&mut tx, "fail_relay_sync", "relay", &relay.id).await?; + let activity = + Self::insert_activity(&mut tx, "fail_relay_sync", "relay", &relay.id).await?; tx.commit().await?; self.emit(activity); @@ -231,7 +242,8 @@ impl Command { .execute(&mut *tx) .await?; - let activity = Self::insert_activity(&mut tx, "complete_relay_sync", "relay", relay_id).await?; + let activity = + Self::insert_activity(&mut tx, "complete_relay_sync", "relay", relay_id).await?; tx.commit().await?; self.emit(activity); @@ -246,7 +258,11 @@ impl Command { Ok(()) } - pub async fn set_relay_subscription_item(&self, relay_id: &str, stripe_subscription_item_id: &str) -> Result<()> { + pub async fn set_relay_subscription_item( + &self, + relay_id: &str, + stripe_subscription_item_id: &str, + ) -> Result<()> { sqlx::query("UPDATE relay SET stripe_subscription_item_id = ? WHERE id = ?") .bind(stripe_subscription_item_id) .bind(relay_id) @@ -255,7 +271,11 @@ impl Command { Ok(()) } - pub async fn set_tenant_subscription(&self, pubkey: &str, stripe_subscription_id: &str) -> Result<()> { + pub async fn set_tenant_subscription( + &self, + pubkey: &str, + stripe_subscription_id: &str, + ) -> Result<()> { sqlx::query("UPDATE tenant SET stripe_subscription_id = ? WHERE pubkey = ?") .bind(stripe_subscription_id) .bind(pubkey) diff --git a/backend/src/infra.rs b/backend/src/infra.rs index 8a96841..7cc5b04 100644 --- a/backend/src/infra.rs +++ b/backend/src/infra.rs @@ -2,7 +2,7 @@ use anyhow::Result; use nostr_sdk::prelude::*; use crate::command::Command; -use crate::models::Activity; +use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE}; use crate::query::Query; #[derive(Clone)] @@ -124,7 +124,8 @@ impl Infra { "host": host, "schema": relay.schema, "secret": secret, - "inactive": relay.status == "inactive", + "inactive": relay.status == RELAY_STATUS_INACTIVE + || relay.status == RELAY_STATUS_DELINQUENT, "info": { "name": relay.info_name, "icon": relay.info_icon, diff --git a/backend/src/models.rs b/backend/src/models.rs index 362861b..8e5cc5f 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -1,5 +1,9 @@ use serde::{Deserialize, Serialize}; +pub const RELAY_STATUS_ACTIVE: &str = "active"; +pub const RELAY_STATUS_INACTIVE: &str = "inactive"; +pub const RELAY_STATUS_DELINQUENT: &str = "delinquent"; + #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Activity { pub id: String, diff --git a/backend/src/query.rs b/backend/src/query.rs index b770084..b596b45 100644 --- a/backend/src/query.rs +++ b/backend/src/query.rs @@ -70,7 +70,7 @@ impl Query { pub async fn list_relays(&self) -> Result> { 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, status, sync_error, info_name, info_icon, info_description, policy_public_join, policy_strip_signatures, @@ -86,7 +86,7 @@ impl Query { pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result> { 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, status, sync_error, info_name, info_icon, info_description, policy_public_join, policy_strip_signatures, @@ -104,7 +104,7 @@ impl Query { pub async fn get_relay(&self, id: &str) -> Result> { let row = sqlx::query_as::<_, Relay>( - "SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id, + "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, @@ -119,7 +119,10 @@ impl Query { Ok(row) } - pub async fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result> { + pub async fn get_tenant_by_stripe_customer_id( + &self, + stripe_customer_id: &str, + ) -> Result> { let row = sqlx::query_as::<_, Tenant>( "SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at FROM tenant