diff --git a/backend/spec/api.md b/backend/spec/api.md index 20f055b..c2dc893 100644 --- a/backend/spec/api.md +++ b/backend/spec/api.md @@ -27,6 +27,19 @@ Notes: - Adds CORS middleware based on `origins` - Calls `axum::serve` with a listener +--- Plan routes + +## `async fn list_plans(...) -> Response` + +- Serves `GET /plans` +- Return `data` is a list of plan structs from `Repo::list_plans` + +## `async fn get_plan(...) -> Response` + +- Serves `GET /plans/:id` +- Return `data` is a single plan struct matching `id` +- If plan does not exist, return `404` with `code=not-found` + --- Tenant routes ## `async fn list_tenants(...) -> Response` @@ -100,6 +113,13 @@ Notes: - If user is a tenant, `tenant` query parameter is not ok; authenticated `pubkey` is used - Return `data` is a list of invoice structs from `repo.list_invoices` +## `async fn update_tenant_billing(...) -> Response` + +- Serves `PUT /tenants/:pubkey/billing` +- Authorizes admin or matching tenant +- Updates tenant billing NWC URL using `repo.update_tenant_nwc_url` +- Return `data` is the submitted billing payload + # Utility functions ## `extract_auth_pubkey(headers: &HeaderMap, method: &Method, uri: &Uri) -> Result` diff --git a/backend/spec/models.md b/backend/spec/models.md index b561840..d143311 100644 --- a/backend/spec/models.md +++ b/backend/spec/models.md @@ -27,6 +27,23 @@ Activity is an audit log of all actions performed by a user or a worker process. - `resource_type` is a string identifying the resource type being modified. - `resource_id` is a string identifying the resource id being modified. +# Plan + +A plan represents a rate charged for relays at a given feature/usage limit. Plans aren't saved to the database, but are simply hardcoded. However, they are exposed through the API so they can be used as a single source of truth. + +- `id` - the plan slug +- `name` - the plan name +- `sats` - the plan't cost per month +- `members` - the max number of members a relay can have before needing to upgrade. If empty, membership is not limited. +- `blossom` - whether blossom media hosting is available on this plan +- `livekit` - whether livekit audio/video calls are available on this plan + +There are three plans available: + +- `free` - 0 sats/mo, up to 10 members, no blossom/livekit +- `basic` - 10k sats/mo, up to 100 members, includes blossom/livekit +- `growth` - 50k sats/mo, unlimited members, includes blossom/livekit + # Tenant Tenants are customers of the service, identified by a nostr `pubkey`. Public metadata like name etc are pulled from the nostr network. They also have associated billing information. diff --git a/backend/spec/repo.md b/backend/spec/repo.md index e2a3d85..e3a9bb8 100644 --- a/backend/spec/repo.md +++ b/backend/spec/repo.md @@ -48,6 +48,11 @@ Notes: - Updates tenant `nwc_url` - Logs activity as `(update_tenant_nwc_url, tenant_id)` +## `pub fn list_plans() -> Vec` + +- Returns the hardcoded relay plans used by the system (`free`, `basic`, `growth`) +- This is the source of truth for plan metadata exposed via API + ## `pub fn list_relays(&self, tenant_id: Option<&str>) -> Result>` - Returns all matching relays @@ -120,3 +125,8 @@ Notes: ## `pub fn list_activity(&self, since: &i64, tenant: Option<&str>) -> Result>` - Returns all activity occuring after `since` matching `tenant` + +## `pub fn get_relay_plan_sats(&self, plan: &str) -> Result` + +- Returns the monthly sats amount for a given plan id +- Uses `list_plans()` data for consistent pricing logic across API and billing diff --git a/backend/src/api.rs b/backend/src/api.rs index f039dd2..ede24d4 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -76,6 +76,8 @@ impl Api { }; let app = Router::new() + .route("/plans", get(list_plans)) + .route("/plans/:id", get(get_plan)) .route("/tenants", get(list_tenants).post(create_tenant)) .route("/tenants/:pubkey", get(get_tenant)) .route("/tenants/:pubkey/billing", put(update_tenant_billing)) @@ -323,6 +325,36 @@ async fn list_tenants( } } +async fn list_plans( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, +) -> Response { + if let Err(e) = extract_auth_pubkey(&headers, &method, &uri, &state.api.host) { + return auth_fail_response(e); + } + + ok(StatusCode::OK, Repo::list_plans()) +} + +async fn get_plan( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, + Path(id): Path, +) -> Response { + if let Err(e) = extract_auth_pubkey(&headers, &method, &uri, &state.api.host) { + return auth_fail_response(e); + } + + match Repo::list_plans().into_iter().find(|p| p.id == id) { + Some(plan) => ok(StatusCode::OK, plan), + None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"), + } +} + async fn get_tenant( State(state): State, headers: HeaderMap, diff --git a/backend/src/billing.rs b/backend/src/billing.rs index cdda2af..78124c5 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -125,7 +125,7 @@ impl Billing { if hours <= 0 { continue; } - let plan_monthly = self.repo.get_relay_plan_amount_sats(&relay.plan).await?; + let plan_monthly = self.repo.get_relay_plan_sats(&relay.plan).await?; if plan_monthly <= 0 { continue; } @@ -228,7 +228,7 @@ impl Billing { Ok(()) } - async fn make_bolt11(&self, amount_sats: i64) -> Result { + async fn make_bolt11(&self, sats: i64) -> Result { if self.nwc_url.trim().is_empty() { anyhow::bail!("NWC_URL not configured") } @@ -236,7 +236,7 @@ impl Billing { let uri = nostr_sdk::nips::nip47::NostrWalletConnectURI::parse(&self.nwc_url)?; let req = nostr_sdk::nips::nip47::Request::make_invoice( nostr_sdk::nips::nip47::MakeInvoiceRequest { - amount: (amount_sats as u64) * 1_000, + amount: (sats as u64) * 1_000, description: Some("Caravel relay invoice".to_string()), description_hash: None, expiry: None, diff --git a/backend/src/models.rs b/backend/src/models.rs index 34c2e94..35709b5 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -10,6 +10,16 @@ pub struct Activity { pub resource_id: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Plan { + pub id: String, + pub name: String, + pub sats: i64, + pub members: Option, + pub blossom: bool, + pub livekit: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Tenant { pub pubkey: String, diff --git a/backend/src/repo.rs b/backend/src/repo.rs index 8173568..935585f 100644 --- a/backend/src/repo.rs +++ b/backend/src/repo.rs @@ -7,7 +7,7 @@ use sqlx::{ sqlite::{SqliteConnectOptions, SqlitePoolOptions}, }; -use crate::models::{Activity, Invoice, InvoiceItem, Relay, Tenant}; +use crate::models::{Activity, Invoice, InvoiceItem, Plan, Relay, Tenant}; #[derive(Clone)] pub struct Repo { @@ -502,15 +502,43 @@ impl Repo { Ok(count) } - pub async fn get_relay_plan_amount_sats(&self, plan: &str) -> Result { - let sats = match plan { - "free" => 0, - "basic" => 10_000, - "growth" => 50_000, - _ => 0, - }; + pub async fn get_relay_plan_sats(&self, plan: &str) -> Result { + let sats = Self::list_plans() + .into_iter() + .find(|p| p.id == plan) + .map(|p| p.sats) + .unwrap_or(0); Ok(sats) } + + pub fn list_plans() -> Vec { + vec![ + Plan { + id: "free".to_string(), + name: "Free".to_string(), + sats: 0, + members: Some(10), + blossom: false, + livekit: false, + }, + Plan { + id: "basic".to_string(), + name: "Basic".to_string(), + sats: 10_000, + members: Some(100), + blossom: true, + livekit: true, + }, + Plan { + id: "growth".to_string(), + name: "Growth".to_string(), + sats: 50_000, + members: None, + blossom: true, + livekit: true, + }, + ] + } } fn normalize_sqlite_url(url: &str) -> String {