From b2f44e1ebfa704347d38d1ebe46c2f5f964f480d Mon Sep 17 00:00:00 2001 From: Maya Date: Tue, 25 Nov 2025 22:49:41 -0500 Subject: [PATCH] feat: per network and user billing --- backend/Cargo.lock | 10 + backend/Cargo.toml | 1 + .../20251125001342_billing-updates.sql | 40 ++ backend/src/bin/server.rs | 53 ++- backend/src/server/auth/middleware.rs | 150 ++++++- backend/src/server/billing/handlers.rs | 2 +- backend/src/server/billing/mod.rs | 1 + backend/src/server/billing/service.rs | 378 +++++++++++++--- backend/src/server/billing/subscriber.rs | 91 ++++ backend/src/server/billing/types/base.rs | 273 ++++++++---- backend/src/server/billing/types/features.rs | 60 ++- backend/src/server/networks/handlers.rs | 29 +- backend/src/server/networks/service.rs | 2 +- backend/src/server/organizations/handlers.rs | 9 +- backend/src/server/organizations/service.rs | 19 + backend/src/server/shared/events/bus.rs | 49 ++- backend/src/server/shared/events/types.rs | 20 +- backend/src/server/shared/services/factory.rs | 5 + backend/src/server/users/impl/permissions.rs | 7 +- .../features/billing/BillingPlanForm.svelte | 408 ++++++++++++------ .../billing/BillingSettingsModal.svelte | 116 ++++- .../lib/features/billing/ToggleGroup.svelte | 36 ++ ui/src/lib/features/billing/types.ts | 12 +- ui/src/lib/features/organizations/types.ts | 51 +-- ui/src/lib/shared/stores/metadata.ts | 3 + ui/src/lib/shared/utils/navigation.ts | 11 +- ui/src/routes/billing/+page.svelte | 8 +- 27 files changed, 1398 insertions(+), 446 deletions(-) create mode 100644 backend/migrations/20251125001342_billing-updates.sql create mode 100644 backend/src/server/billing/subscriber.rs create mode 100644 ui/src/lib/features/billing/ToggleGroup.svelte diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 994796d0..d0d29894 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2467,6 +2467,15 @@ dependencies = [ "serde", ] +[[package]] +name = "json_value_merge" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c724b60f15eb3213be053eca7e4c6186e7a86fbd74760984b5178ab00beaf6" +dependencies = [ + "serde_json", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2962,6 +2971,7 @@ dependencies = [ "inventory", "ipgen", "itertools 0.14.0", + "json_value_merge", "lazy_static", "lettre", "libc", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 33404527..03427f79 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -157,6 +157,7 @@ nanoid = "0.4.0" serde_with = "3.15.1" lettre = { version = "0.11.19", default-features = false, features = ["smtp-transport", "builder", "tokio1", "tokio1-rustls", "ring", "webpki-roots"] } html2text = "0.16.4" +json_value_merge = "2.0.1" # === Platform-specific Dependencies === [target.'cfg(target_os = "linux")'.dependencies] diff --git a/backend/migrations/20251125001342_billing-updates.sql b/backend/migrations/20251125001342_billing-updates.sql new file mode 100644 index 00000000..a39fe8cb --- /dev/null +++ b/backend/migrations/20251125001342_billing-updates.sql @@ -0,0 +1,40 @@ +-- Migration: Restructure BillingPlan to use PlanConfig tuple variant +-- Transforms existing plan JSONB from: +-- {"type": "Team", "price": {"cents": 14900, "rate": "Month"}, "trial_days": 14} +-- To: +-- {"type": "Team", "config": {"base_cents": 14900, "rate": "Month", ...}} + +UPDATE organizations +SET plan = jsonb_build_object( + 'type', plan->>'type', + 'config', jsonb_build_object( + 'base_cents', (plan->'price'->>'cents')::integer, + 'rate', plan->'price'->>'rate', + 'trial_days', (plan->>'trial_days')::integer, + 'seat_cents', CASE + WHEN plan->>'type' IN ('Team', 'Enterprise') THEN 1000 + ELSE NULL + END, + 'network_cents', CASE + WHEN plan->>'type' IN ('Pro', 'Team') THEN 500 + ELSE NULL + END, + 'included_seats', CASE plan->>'type' + WHEN 'Community' THEN NULL + WHEN 'Starter' THEN 1 + WHEN 'Pro' THEN 1 + WHEN 'Team' THEN 5 + WHEN 'Enterprise' THEN 25 + ELSE 1 + END, + 'included_networks', CASE plan->>'type' + WHEN 'Community' THEN NULL + WHEN 'Starter' THEN 1 + WHEN 'Pro' THEN 3 + WHEN 'Team' THEN 10 + WHEN 'Enterprise' THEN NULL + ELSE 1 + END + ) +) +WHERE plan IS NOT NULL; \ No newline at end of file diff --git a/backend/src/bin/server.rs b/backend/src/bin/server.rs index b1f817ae..2a1c7c3e 100644 --- a/backend/src/bin/server.rs +++ b/backend/src/bin/server.rs @@ -7,7 +7,7 @@ use axum::{ use clap::Parser; use netvisor::server::{ auth::middleware::AuthenticatedEntity, - billing::types::base::{BillingPlan, BillingRate, Price}, + billing::types::base::{BillingPlan, BillingRate, PlanConfig}, config::{AppState, CliArgs, ServerConfig}, organizations::r#impl::base::{Organization, OrganizationBase}, shared::{ @@ -290,27 +290,42 @@ async fn main() -> anyhow::Result<()> { if let Some(billing_service) = billing_service { billing_service .initialize_products(vec![ - BillingPlan::Starter { - price: Price { - cents: 1499, - rate: BillingRate::Month, - }, + BillingPlan::Starter(PlanConfig { + base_cents: 1499, + rate: BillingRate::Month, trial_days: 0, - }, - BillingPlan::Pro { - price: Price { - cents: 2499, - rate: BillingRate::Month, - }, + seat_cents: None, + network_cents: None, + included_seats: Some(1), + included_networks: Some(1), + }), + BillingPlan::Pro(PlanConfig { + base_cents: 2499, + rate: BillingRate::Month, trial_days: 7, - }, - BillingPlan::Team { - price: Price { - cents: 9999, - rate: BillingRate::Month, - }, + seat_cents: None, + network_cents: None, + included_seats: Some(1), + included_networks: Some(3), + }), + BillingPlan::Team(PlanConfig { + base_cents: 7999, + rate: BillingRate::Month, trial_days: 7, - }, + seat_cents: Some(1000), + network_cents: Some(800), + included_seats: Some(5), + included_networks: Some(5), + }), + BillingPlan::Business(PlanConfig { + base_cents: 14999, + rate: BillingRate::Month, + trial_days: 14, + seat_cents: Some(800), + network_cents: Some(500), + included_seats: Some(10), + included_networks: Some(25), + }), ]) .await?; } diff --git a/backend/src/server/auth/middleware.rs b/backend/src/server/auth/middleware.rs index d5a0dbe1..3d7fb68b 100644 --- a/backend/src/server/auth/middleware.rs +++ b/backend/src/server/auth/middleware.rs @@ -7,6 +7,7 @@ use crate::server::{ shared::{services::traits::CrudService, storage::filter::EntityFilter, types::api::ApiError}, users::r#impl::{base::User, permissions::UserOrgPermissions}, }; +use async_trait::async_trait; use axum::{ extract::FromRequestParts, http::request::Parts, @@ -450,13 +451,37 @@ where } } -/// Trait for defining feature requirements -pub trait FeatureCheck: Send + Sync { - fn check(&self, plan: BillingPlan) -> bool; - fn error_message(&self) -> &'static str; +/// Context available for feature/quota checks +pub struct FeatureCheckContext<'a> { + pub organization: &'a Organization, + pub plan: BillingPlan, + pub app_state: &'a AppState, +} + +pub enum FeatureCheckResult { + Allowed, + Denied { message: String }, } -/// Extractor that checks organization plan features using a trait +impl FeatureCheckResult { + pub fn denied(msg: impl Into) -> Self { + Self::Denied { + message: msg.into(), + } + } + + pub fn is_allowed(&self) -> bool { + matches!(self, Self::Allowed) + } +} + +#[async_trait] +pub trait FeatureCheck: Send + Sync + Default { + async fn check(&self, ctx: &FeatureCheckContext<'_>) -> FeatureCheckResult; +} + +// ============ Extractor ============ + pub struct RequireFeature { pub permissions: UserOrgPermissions, pub plan: BillingPlan, @@ -488,35 +513,114 @@ where .map_err(|_| AuthError(ApiError::internal_error("Failed to load organization")))? .ok_or_else(|| AuthError(ApiError::forbidden("Organization not found")))?; - let plan = - organization.base.plan.as_ref().ok_or_else(|| { - AuthError(ApiError::forbidden("Organization has no billing plan")) - })?; + let plan = organization.base.plan.unwrap_or_default(); + + let ctx = FeatureCheckContext { + organization: &organization, + plan, + app_state, + }; let checker = T::default(); - if !checker.check(*plan) { - return Err(AuthError(ApiError::forbidden(checker.error_message()))); + match checker.check(&ctx).await { + FeatureCheckResult::Allowed => Ok(RequireFeature { + permissions, + plan, + organization, + _phantom: std::marker::PhantomData, + }), + FeatureCheckResult::Denied { message } => Err(AuthError(ApiError::forbidden(&message))), } - - Ok(RequireFeature { - permissions, - plan: *plan, - organization, - _phantom: std::marker::PhantomData, - }) } } -// Concrete feature checkers +// ============ Concrete Checkers ============ + #[derive(Default)] pub struct InviteUsersFeature; +#[async_trait] impl FeatureCheck for InviteUsersFeature { - fn check(&self, plan: BillingPlan) -> bool { - plan.features().team_members || plan.features().share_views + async fn check(&self, ctx: &FeatureCheckContext<'_>) -> FeatureCheckResult { + let features = ctx.plan.features(); + + if !features.share_views { + return FeatureCheckResult::denied( + "Your plan does not include team collaboration features", + ); + } + + // Check seat quota if there's a limit and user doesn't have a plan that lets them buy more seats + if let Some(max_seats) = ctx.plan.config().included_seats + && ctx.plan.config().seat_cents.is_none() + { + let org_filter = EntityFilter::unfiltered().organization_id(&ctx.organization.id); + + let current_members = ctx + .app_state + .services + .user_service + .get_all(org_filter) + .await + .unwrap_or_default() + .iter() + .filter(|u| u.base.permissions.counts_towards_seats()) + .count(); + + let pending_invites = ctx + .app_state + .services + .organization_service + .get_org_invites(&ctx.organization.id) + .await + .unwrap_or_default() + .iter() + .filter(|i| i.permissions.counts_towards_seats()) + .count(); + + let total_seats_used = current_members + pending_invites; + + if total_seats_used >= max_seats as usize { + return FeatureCheckResult::denied(format!( + "Seat limit reached ({}/{}). Upgrade your plan for more seats, or delete any unused pending invites.", + total_seats_used, max_seats + )); + } + } + + FeatureCheckResult::Allowed } +} + +#[derive(Default)] +pub struct CreateNetworkFeature; + +#[async_trait] +impl FeatureCheck for CreateNetworkFeature { + async fn check(&self, ctx: &FeatureCheckContext<'_>) -> FeatureCheckResult { + // Check networks quota if there's a limit and user doesn't have a plan that lets them buy more networks + if let Some(max_networks) = ctx.plan.config().included_networks + && ctx.plan.config().network_cents.is_none() + { + let org_filter = EntityFilter::unfiltered().organization_id(&ctx.organization.id); + + let current_networks = ctx + .app_state + .services + .network_service + .get_all(org_filter) + .await + .map(|o| o.len()) + .unwrap_or(0); + + if current_networks >= max_networks as usize { + return FeatureCheckResult::denied(format!( + "Network limit reached ({}/{}). Upgrade your plan for more networks.", + current_networks, max_networks + )); + } + } - fn error_message(&self) -> &'static str { - "Your organization plan does not include Team Member or Share Views features" + FeatureCheckResult::Allowed } } diff --git a/backend/src/server/billing/handlers.rs b/backend/src/server/billing/handlers.rs index 525c8ec0..d0ed5e9d 100644 --- a/backend/src/server/billing/handlers.rs +++ b/backend/src/server/billing/handlers.rs @@ -15,7 +15,7 @@ pub fn create_router() -> Router> { Router::new() .route("/plans", get(get_billing_plans)) .route("/checkout", post(create_checkout_session)) - .route("/webhook", post(handle_webhook)) + .route("/webhooks", post(handle_webhook)) .route("/portal", post(create_portal_session)) } diff --git a/backend/src/server/billing/mod.rs b/backend/src/server/billing/mod.rs index 1aa16b74..21e9499f 100644 --- a/backend/src/server/billing/mod.rs +++ b/backend/src/server/billing/mod.rs @@ -1,3 +1,4 @@ pub mod handlers; pub mod service; +pub mod subscriber; pub mod types; diff --git a/backend/src/server/billing/service.rs b/backend/src/server/billing/service.rs index 1bb47208..ea78408a 100644 --- a/backend/src/server/billing/service.rs +++ b/backend/src/server/billing/service.rs @@ -1,6 +1,8 @@ use crate::server::auth::middleware::AuthenticatedEntity; use crate::server::billing::types::base::BillingPlan; +use crate::server::billing::types::features::Feature; use crate::server::networks::service::NetworkService; +use crate::server::organizations::r#impl::base::Organization; use crate::server::organizations::service::OrganizationService; use crate::server::shared::services::traits::CrudService; use crate::server::shared::storage::filter::EntityFilter; @@ -13,6 +15,11 @@ use std::sync::Arc; use std::sync::OnceLock; use stripe::Client; use stripe_billing::billing_portal_session::CreateBillingPortalSession; +use stripe_billing::subscription::ListSubscription; +use stripe_billing::subscription::ListSubscriptionStatus; +use stripe_billing::subscription::UpdateSubscription; +use stripe_billing::subscription::UpdateSubscriptionItems; +use stripe_billing::subscription::UpdateSubscriptionProrationBehavior; use stripe_billing::{Subscription, SubscriptionStatus}; use stripe_checkout::checkout_session::CreateCheckoutSessionCustomerUpdate; use stripe_checkout::checkout_session::CreateCheckoutSessionCustomerUpdateAddress; @@ -30,6 +37,7 @@ use stripe_product::Price; use stripe_product::price::CreatePriceRecurring; use stripe_product::price::SearchPrice; use stripe_product::price::{CreatePrice, CreatePriceRecurringUsageType}; +use stripe_product::product::Features; use stripe_product::product::{CreateProduct, RetrieveProduct}; use stripe_webhook::{EventObject, Webhook}; use uuid::Uuid; @@ -42,6 +50,11 @@ pub struct BillingService { pub plans: OnceLock>, } +const SEAT_PRODUCT_ID: &str = "extra_seats"; +const SEAT_PRODUCT_NAME: &str = "Extra Seats"; +const NETWORK_PRODUCT_ID: &str = "extra_networks"; +const NETWORK_PRODUCT_NAME: &str = "Extra Networks"; + impl BillingService { pub fn new( stripe_secret: String, @@ -82,12 +95,62 @@ impl BillingService { pub async fn initialize_products(&self, plans: Vec) -> Result<(), Error> { let mut created_plans = Vec::new(); + let all_plans: Vec = plans + .clone() + .iter() + .map(|p| p.to_yearly(0.20)) + .chain(plans) + .collect(); + tracing::info!( - plan_count = plans.len(), + plan_count = all_plans.len(), "Initializing Stripe products and prices" ); - for plan in plans { + // Create seat and network products + let seat_product = match RetrieveProduct::new(SEAT_PRODUCT_ID) + .send(&self.stripe) + .await + { + Ok(p) => { + tracing::info!("Product {} already exists", p.id); + p + } + Err(_) => { + // Create product + let create_product = CreateProduct::new(SEAT_PRODUCT_NAME) + .id(SEAT_PRODUCT_ID) + .description("Additional seats over what's included in the base plan"); + + let product = create_product.send(&self.stripe).await?; + + tracing::info!("Created product: {}", SEAT_PRODUCT_NAME); + product + } + }; + + let network_product = match RetrieveProduct::new(NETWORK_PRODUCT_ID) + .send(&self.stripe) + .await + { + Ok(p) => { + tracing::info!("Product {} already exists", p.id); + p + } + Err(_) => { + // Create product + let create_product = CreateProduct::new(NETWORK_PRODUCT_NAME) + .id(NETWORK_PRODUCT_ID) + .description("Additional networks over what's included in the base plan"); + + let product = create_product.send(&self.stripe).await?; + + tracing::info!("Created product: {}", NETWORK_PRODUCT_NAME); + product + } + }; + + for plan in all_plans { // Check if product exists, create if not let product_id = plan.stripe_product_id(); let product = match RetrieveProduct::new(product_id.clone()) @@ -99,9 +162,15 @@ impl BillingService { p } Err(_) => { + let features: Vec = plan.features().into(); + + let features: Vec = + features.iter().map(|f| Features::new(f.name())).collect(); + // Create product let create_product = CreateProduct::new(plan.name()) .id(product_id) + .marketing_features(features) .description(plan.description()); let product = create_product.send(&self.stripe).await?; @@ -111,8 +180,9 @@ impl BillingService { } }; + // Create base price match self - .get_price_from_lookup_key(plan.stripe_price_lookup_key()) + .get_price_from_lookup_key(plan.stripe_base_price_lookup_key()) .await? { Some(p) => { @@ -120,24 +190,92 @@ impl BillingService { } None => { // Create price - let create_price = CreatePrice::new(stripe_types::Currency::USD) - .lookup_key(plan.stripe_price_lookup_key()) - .product(product.id) - .unit_amount(plan.price().cents) + let create_base_price = CreatePrice::new(stripe_types::Currency::USD) + .lookup_key(plan.stripe_base_price_lookup_key()) + .product(product.id.clone()) + .unit_amount(plan.config().base_cents) .recurring(CreatePriceRecurring { - interval: plan.price().stripe_recurring_interval(), + interval: plan.config().rate.stripe_recurring_interval(), interval_count: Some(1), - trial_period_days: Some(plan.trial_days()), + trial_period_days: Some(plan.config().trial_days), meter: None, usage_type: Some(CreatePriceRecurringUsageType::Licensed), }); - let price = create_price.send(&self.stripe).await?; + let price = create_base_price.send(&self.stripe).await?; tracing::info!("Created price: {}", price.id); } }; + // Create seat prices + if let (Some(seat_lookup_key), Some(seat_cents)) = ( + plan.stripe_seat_addon_price_lookup_key(), + plan.config().seat_cents, + ) { + // Create seat addon price + match self + .get_price_from_lookup_key(seat_lookup_key.clone()) + .await? + { + Some(p) => { + tracing::info!("Price {} already exists", p.id); + } + None => { + // Create price + let create_seat_price = CreatePrice::new(stripe_types::Currency::USD) + .lookup_key(seat_lookup_key) + .product(seat_product.id.clone()) + .unit_amount(seat_cents) + .recurring(CreatePriceRecurring { + interval: plan.config().rate.stripe_recurring_interval(), + interval_count: Some(1), + trial_period_days: Some(plan.config().trial_days), + meter: None, + usage_type: Some(CreatePriceRecurringUsageType::Licensed), + }); + + let price = create_seat_price.send(&self.stripe).await?; + + tracing::info!("Created price: {}", price.id); + } + }; + } + + // Create network prices + if let (Some(network_lookup_key), Some(network_cents)) = ( + plan.stripe_network_addon_price_lookup_key(), + plan.config().network_cents, + ) { + // Create network addon price + match self + .get_price_from_lookup_key(network_lookup_key.clone()) + .await? + { + Some(p) => { + tracing::info!("Price {} already exists", p.id); + } + None => { + // Create price + let create_network_price = CreatePrice::new(stripe_types::Currency::USD) + .lookup_key(network_lookup_key) + .product(network_product.id.clone()) + .unit_amount(network_cents) + .recurring(CreatePriceRecurring { + interval: plan.config().rate.stripe_recurring_interval(), + interval_count: Some(1), + trial_period_days: Some(plan.config().trial_days), + meter: None, + usage_type: Some(CreatePriceRecurringUsageType::Licensed), + }); + + let price = create_network_price.send(&self.stripe).await?; + + tracing::info!("Created price: {}", price.id); + } + }; + } + created_plans.push(plan) } @@ -165,17 +303,10 @@ impl BillingService { .get_or_create_customer(organization_id, authentication) .await?; - tracing::info!( - organization_id = %organization_id, - plan = %plan.name(), - customer_id = %customer_id, - "Creating checkout session" - ); - - let price = self - .get_price_from_lookup_key(plan.stripe_price_lookup_key()) + let base_price = self + .get_price_from_lookup_key(plan.stripe_base_price_lookup_key()) .await? - .ok_or_else(|| anyhow!("Could not find price for selected plan"))?; + .ok_or_else(|| anyhow!("Could not find base price for selected plan"))?; let create_checkout_session = CreateCheckoutSession::new() .customer(customer_id) @@ -185,7 +316,7 @@ impl BillingService { .billing_address_collection(CheckoutSessionBillingAddressCollection::Auto) .customer_update(CreateCheckoutSessionCustomerUpdate { name: Some(CreateCheckoutSessionCustomerUpdateName::Auto), - address: if plan.is_business_plan() { + address: if plan.is_commercial() { Some(CreateCheckoutSessionCustomerUpdateAddress::Auto) } else { None @@ -193,10 +324,10 @@ impl BillingService { shipping: None, }) .tax_id_collection(CreateCheckoutSessionTaxIdCollection::new( - plan.is_business_plan(), + plan.is_commercial(), )) .line_items(vec![CreateCheckoutSessionLineItems { - price: Some(price.id.to_string()), + price: Some(base_price.id.to_string()), quantity: Some(1), adjustable_quantity: None, price_data: None, @@ -230,6 +361,149 @@ impl BillingService { Ok(session) } + pub async fn update_addon_prices( + &self, + organization: Organization, + network_count: u64, + seat_count: u64, + ) -> Result<(), Error> { + tracing::info!( + organization_id = %organization.id, + network_count = %network_count, + seat_count = %seat_count, + "Updating addon prices" + ); + + let plan = organization.base.plan.ok_or_else(|| { + anyhow!( + "Organization {} doesn't have a billing plan", + organization.base.name + ) + })?; + let customer_id = organization.base.stripe_customer_id.ok_or_else(|| { + anyhow!( + "Organization {} doesn't have a Stripe customer ID", + organization.base.name + ) + })?; + + let extra_networks = if let Some(included_networks) = plan.config().included_networks { + network_count.saturating_sub(included_networks) + } else { + 0 + }; + + let extra_seats = if let Some(included_seats) = plan.config().included_seats { + seat_count.saturating_sub(included_seats) + } else { + 0 + }; + + let org_subscriptions = ListSubscription::new() + .customer(customer_id) + .status(ListSubscriptionStatus::Active) + .send(&self.stripe) + .await?; + + let subscription = org_subscriptions + .data + .first() + .ok_or_else(|| anyhow!("No active subscription found"))?; + + // Build items array - need to update quantities on existing items + let mut items_to_update = vec![]; + + // Track what we found + let mut found_seat_item = false; + let mut found_network_item = false; + + // Find existing subscription items by price lookup key + for item in &subscription.items.data { + let price_id = &item.price.id; + + // Check if this is a seat addon item + if let Some(seat_lookup) = plan.stripe_seat_addon_price_lookup_key() + && let Some(seat_price) = self.get_price_from_lookup_key(seat_lookup).await? + && price_id == &seat_price.id + { + found_seat_item = true; + items_to_update.push(UpdateSubscriptionItems { + id: Some(item.id.to_string()), + price: Some(price_id.to_string()), + quantity: Some(extra_seats), + deleted: if extra_seats == 0 { Some(true) } else { None }, + ..Default::default() + }); + continue; + } + + // Check if this is a network addon item + if let Some(network_lookup) = plan.stripe_network_addon_price_lookup_key() + && let Some(network_price) = self.get_price_from_lookup_key(network_lookup).await? + && price_id == &network_price.id + { + found_network_item = true; + items_to_update.push(UpdateSubscriptionItems { + id: Some(item.id.to_string()), + price: Some(price_id.to_string()), + quantity: Some(extra_networks), + deleted: if extra_networks == 0 { + Some(true) + } else { + None + }, + ..Default::default() + }); + continue; + } + } + + // Add new seat item if needed + if !found_seat_item + && extra_seats > 0 + && let Some(seat_lookup) = plan.stripe_seat_addon_price_lookup_key() + && let Some(seat_price) = self.get_price_from_lookup_key(seat_lookup).await? + { + items_to_update.push(UpdateSubscriptionItems { + price: Some(seat_price.id.to_string()), + quantity: Some(extra_seats), + ..Default::default() + }); + } + + // Add new network item if needed + if !found_network_item + && extra_networks > 0 + && let Some(network_lookup) = plan.stripe_network_addon_price_lookup_key() + && let Some(network_price) = self.get_price_from_lookup_key(network_lookup).await? + { + items_to_update.push(UpdateSubscriptionItems { + price: Some(network_price.id.to_string()), + quantity: Some(extra_networks), + ..Default::default() + }); + } + + // Update the subscription if there are changes + if !items_to_update.is_empty() { + UpdateSubscription::new(&subscription.id) + .items(items_to_update) + .proration_behavior(UpdateSubscriptionProrationBehavior::CreateProrations) + .send(&self.stripe) + .await?; + + tracing::info!( + organization_id = %organization.id, + subscription_id = %subscription.id, + extra_seats = ?extra_seats, + extra_networks = ?extra_networks, + "Updated subscription addon quantities" + ); + } + + Ok(()) + } + /// Get existing customer or create new one async fn get_or_create_customer( &self, @@ -324,6 +598,13 @@ impl BillingService { } async fn handle_subscription_update(&self, sub: Subscription) -> Result<(), Error> { + tracing::debug!( + subscription_id = %sub.id, + subscription_status = ?sub.status, + metadata = ?sub.metadata, + "Processing subscription update" + ); + let org_id = sub .metadata .get("organization_id") @@ -353,32 +634,33 @@ impl BillingService { .ok_or_else(|| anyhow!("Could not find organization to update subscriptions status"))?; // Update enabled features to match new plan - if let Some(max_networks) = plan.features().max_networks { - let networks = self - .network_service - .get_all(EntityFilter::unfiltered().organization_id(&org_id)) - .await?; - let keep_ids = networks - .iter() - .take(max_networks) - .map(|n| n.id) - .collect::>(); - - for network in networks { - if !keep_ids.contains(&network.id) { - self.network_service - .delete(&network.id, AuthenticatedEntity::System) - .await?; - tracing::info!( - organization_id = %org_id, - network_id = %network.id, - "Deleted network due to plan downgrade" - ); - } - } - } + // if let Some(included_networks) = plan.config().included_networks { + // let networks = self + // .network_service + // .get_all(EntityFilter::unfiltered().organization_id(&org_id)) + // .await?; + // let keep_ids = networks + // .iter() + // .take(included_networks) + // .map(|n| n.id) + // .collect::>(); + + // for network in networks { + // if !keep_ids.contains(&network.id) { + // self.network_service + // .delete(&network.id, AuthenticatedEntity::System) + // .await?; + // tracing::info!( + // organization_id = %org_id, + // network_id = %network.id, + // "Deleted network due to plan downgrade" + // ); + // } + // } + // } match plan { + BillingPlan::Community { .. } => {} BillingPlan::Starter { .. } => { let mut users = self .user_service @@ -408,7 +690,7 @@ impl BillingService { } } BillingPlan::Team { .. } => {} - BillingPlan::Community { .. } => {} + BillingPlan::Business { .. } => {} } organization.base.plan_status = Some(sub.status.to_string()); diff --git a/backend/src/server/billing/subscriber.rs b/backend/src/server/billing/subscriber.rs new file mode 100644 index 00000000..176255db --- /dev/null +++ b/backend/src/server/billing/subscriber.rs @@ -0,0 +1,91 @@ +use std::collections::HashMap; + +use anyhow::Error; +use async_trait::async_trait; + +use crate::server::{ + billing::service::BillingService, + shared::{ + entities::{Entity, EntityDiscriminants}, + events::{ + bus::{EventFilter, EventSubscriber}, + types::{EntityOperation, Event}, + }, + services::traits::CrudService, + storage::filter::EntityFilter, + }, +}; + +#[async_trait] +impl EventSubscriber for BillingService { + fn event_filter(&self) -> EventFilter { + EventFilter::entity_only(HashMap::from([ + ( + EntityDiscriminants::Network, + Some(vec![EntityOperation::Created, EntityOperation::Deleted]), + ), + ( + EntityDiscriminants::User, + Some(vec![EntityOperation::Created, EntityOperation::Deleted]), + ), + ])) + } + + async fn handle_events(&self, events: Vec) -> Result<(), Error> { + if events.is_empty() { + return Ok(()); + } + + for event in events { + if let Event::Entity(e) = event + && let Some(org_id) = if let Some(org_id) = e.organization_id { + Some(org_id) + } else if let Some(network_id) = e.network_id { + self.network_service + .get_by_id(&network_id) + .await? + .map(|n| n.base.organization_id) + } else { + None + } + && let Some(org) = self.organization_service.get_by_id(&org_id).await? + { + match e.entity_type { + Entity::Network(_) | Entity::User(_) => { + let filter = EntityFilter::unfiltered().organization_id(&org_id); + + let network_count = + self.network_service.get_all(filter.clone()).await?.len(); + + let seat_count = self + .user_service + .get_all(filter) + .await? + .iter() + .filter(|u| u.base.permissions.counts_towards_seats()) + .count(); + + // When user has just been created org won't yet have a billing plan + if org.base.plan.is_none() { + continue; + } + + self.update_addon_prices(org, network_count as u64, seat_count as u64) + .await?; + } + _ => (), + } + } + } + + Ok(()) + } + + fn debounce_window_ms(&self) -> u64 { + 50 // Small window to batch multiple subnet deletions + } + + fn name(&self) -> &str { + "billing_quota_update" + } +} diff --git a/backend/src/server/billing/types/base.rs b/backend/src/server/billing/types/base.rs index c18605d1..6ff69135 100644 --- a/backend/src/server/billing/types/base.rs +++ b/backend/src/server/billing/types/base.rs @@ -1,6 +1,8 @@ -use crate::server::shared::types::metadata::{EntityMetadataProvider, HasId, TypeMetadataProvider}; +use crate::server::{ + billing::types::features::Feature, + shared::types::metadata::{EntityMetadataProvider, HasId, TypeMetadataProvider}, +}; use serde::{Deserialize, Serialize}; -use std::fmt::Display; use std::hash::Hash; use stripe_product::price::CreatePriceRecurringInterval; use strum::{Display, EnumDiscriminants, EnumIter, IntoStaticStr}; @@ -19,69 +21,76 @@ use strum::{Display, EnumDiscriminants, EnumIter, IntoStaticStr}; )] #[serde(tag = "type")] pub enum BillingPlan { - Community { price: Price, trial_days: u32 }, - Starter { price: Price, trial_days: u32 }, - Pro { price: Price, trial_days: u32 }, - Team { price: Price, trial_days: u32 }, + Community(PlanConfig), + Starter(PlanConfig), + Pro(PlanConfig), + Team(PlanConfig), + Business(PlanConfig), } impl PartialEq for BillingPlan { fn eq(&self, other: &Self) -> bool { - self.price() == other.price() && self.trial_days() == other.trial_days() + self.config() == other.config() } } impl Hash for BillingPlan { fn hash(&self, state: &mut H) { - self.price().hash(state); - self.trial_days().hash(state); + self.config().hash(state); } } impl Default for BillingPlan { fn default() -> Self { - BillingPlan::Community { - price: Price { - cents: 0, - rate: BillingRate::Month, - }, + BillingPlan::Community(PlanConfig { + base_cents: 0, + rate: BillingRate::Month, trial_days: 0, - } + seat_cents: None, + network_cents: None, + included_networks: None, + included_seats: None, + }) } } -#[derive(Debug, Clone, Serialize, Deserialize, Default, Copy, Eq)] -pub struct Price { - pub cents: i64, - pub rate: BillingRate, -} +impl BillingPlan { + pub fn to_yearly(&self, discount: f32) -> Self { + let mut yearly_config = self.config(); + yearly_config.rate = BillingRate::Year; -impl Hash for Price { - fn hash(&self, state: &mut H) { - self.cents.hash(state); - self.rate.hash(state); - } -} + // Round to nearest dollar (100 cents) + yearly_config.base_cents = + Self::round_to_dollar(yearly_config.base_cents as f32 * 12.0 * (1.0 - discount)); + yearly_config.seat_cents = yearly_config + .seat_cents + .map(|c| Self::round_to_dollar(c as f32 * 12.0 * (1.0 - discount))); + yearly_config.network_cents = yearly_config + .network_cents + .map(|c| Self::round_to_dollar(c as f32 * 12.0 * (1.0 - discount))); -impl PartialEq for Price { - fn eq(&self, other: &Self) -> bool { - self.cents == other.cents && self.rate == other.rate + let mut yearly_plan = *self; + yearly_plan.set_config(yearly_config); + yearly_plan } -} - -impl Price { - pub fn stripe_recurring_interval(&self) -> CreatePriceRecurringInterval { - match self.rate { - BillingRate::Month => CreatePriceRecurringInterval::Month, - BillingRate::Year => CreatePriceRecurringInterval::Year, - } + fn round_to_dollar(cents: f32) -> i64 { + ((cents / 100.0).round() * 100.0) as i64 } } -impl Display for Price { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} per {}", self.cents, self.rate) - } +#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq, Default, Hash)] +pub struct PlanConfig { + pub base_cents: i64, + pub rate: BillingRate, + pub trial_days: u32, + + // None = can't pay for more + pub seat_cents: Option, + pub network_cents: Option, + + // None = unlimited + pub included_seats: Option, + pub included_networks: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Display, Default, Copy, PartialEq, Eq, Hash)] @@ -91,103 +100,194 @@ pub enum BillingRate { Year, } +impl BillingRate { + pub fn stripe_recurring_interval(&self) -> CreatePriceRecurringInterval { + match self { + BillingRate::Month => CreatePriceRecurringInterval::Month, + BillingRate::Year => CreatePriceRecurringInterval::Year, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BillingPlanFeatures { - pub max_networks: Option, - // pub api_access: bool, - pub team_members: bool, pub share_views: bool, + pub remove_powered_by: bool, + pub audit_logs: bool, + pub api_access: bool, pub onboarding_call: bool, pub dedicated_support_channel: bool, pub commercial_license: bool, } impl BillingPlan { - pub fn from_id(id: &str, price: Price, trial_days: u32) -> Option { + pub fn from_id(id: &str, plan_config: PlanConfig) -> Option { match id { - "starter" => Some(Self::Starter { price, trial_days }), - "pro" => Some(Self::Pro { price, trial_days }), - "team" => Some(Self::Team { price, trial_days }), + "starter" => Some(Self::Starter(plan_config)), + "pro" => Some(Self::Pro(plan_config)), + "team" => Some(Self::Team(plan_config)), + "business" => Some(Self::Business(plan_config)), _ => None, } } - pub fn is_business_plan(&self) -> bool { - matches!(self, BillingPlan::Team { .. }) + pub fn config(&self) -> PlanConfig { + match self { + BillingPlan::Community(plan_config) => *plan_config, + BillingPlan::Starter(plan_config) => *plan_config, + BillingPlan::Pro(plan_config) => *plan_config, + BillingPlan::Team(plan_config) => *plan_config, + BillingPlan::Business(plan_config) => *plan_config, + } + } + + pub fn set_config(&mut self, config: PlanConfig) { + match self { + BillingPlan::Community(plan_config) => *plan_config = config, + BillingPlan::Starter(plan_config) => *plan_config = config, + BillingPlan::Pro(plan_config) => *plan_config = config, + BillingPlan::Team(plan_config) => *plan_config = config, + BillingPlan::Business(plan_config) => *plan_config = config, + } + } + + pub fn is_commercial(&self) -> bool { + matches!(self, BillingPlan::Team(_) | BillingPlan::Business(_)) } pub fn stripe_product_id(&self) -> String { self.to_string().to_lowercase() } - pub fn stripe_price_lookup_key(&self) -> String { + pub fn stripe_base_price_lookup_key(&self) -> String { format!( - "{}_{}_monthly", + "{}_{}_{}", self.stripe_product_id(), - self.price().to_string().replace(" ", "_").replace(".", "_") + self.config().base_cents, + self.config().rate ) } - pub fn price(&self) -> Price { - match self { - BillingPlan::Community { price, .. } => *price, - BillingPlan::Starter { price, .. } => *price, - BillingPlan::Pro { price, .. } => *price, - BillingPlan::Team { price, .. } => *price, - } + pub fn stripe_seat_addon_price_lookup_key(&self) -> Option { + self.config().seat_cents.map(|c| { + format!( + "{}_seats_{}_{}", + self.stripe_product_id(), + c, + self.config().rate + ) + }) } - pub fn trial_days(&self) -> u32 { - match self { - BillingPlan::Community { trial_days, .. } => *trial_days, - BillingPlan::Starter { trial_days, .. } => *trial_days, - BillingPlan::Pro { trial_days, .. } => *trial_days, - BillingPlan::Team { trial_days, .. } => *trial_days, - } + pub fn stripe_network_addon_price_lookup_key(&self) -> Option { + self.config().network_cents.map(|c| { + format!( + "{}_networks_{}_{}", + self.stripe_product_id(), + c, + self.config().rate + ) + }) } pub fn features(&self) -> BillingPlanFeatures { match self { - Self::Community { .. } => BillingPlanFeatures { - max_networks: None, - // api_access: true, - team_members: true, + BillingPlan::Community { .. } => BillingPlanFeatures { share_views: true, onboarding_call: true, dedicated_support_channel: true, + api_access: true, + audit_logs: true, commercial_license: false, + remove_powered_by: false, }, - Self::Starter { .. } => BillingPlanFeatures { - max_networks: Some(1), - // api_access: false, - team_members: false, + BillingPlan::Starter { .. } => BillingPlanFeatures { share_views: false, onboarding_call: false, dedicated_support_channel: false, commercial_license: false, + api_access: false, + audit_logs: false, + remove_powered_by: false, }, - Self::Pro { .. } => BillingPlanFeatures { - max_networks: Some(3), - // api_access: false), - team_members: false, + BillingPlan::Pro { .. } => BillingPlanFeatures { share_views: true, onboarding_call: false, dedicated_support_channel: false, commercial_license: false, + api_access: false, + audit_logs: false, + remove_powered_by: false, }, - Self::Team { .. } => BillingPlanFeatures { - max_networks: None, - // api_access: true, - team_members: true, + BillingPlan::Team { .. } => BillingPlanFeatures { share_views: true, onboarding_call: true, dedicated_support_channel: true, commercial_license: true, + api_access: false, + audit_logs: false, + remove_powered_by: true, + }, + BillingPlan::Business { .. } => BillingPlanFeatures { + share_views: true, + onboarding_call: true, + dedicated_support_channel: true, + commercial_license: true, + api_access: true, + audit_logs: true, + remove_powered_by: true, }, } } } +#[allow(clippy::from_over_into)] +impl Into> for BillingPlanFeatures { + fn into(self) -> Vec { + let mut features = vec![]; + + let BillingPlanFeatures { + share_views, + onboarding_call, + dedicated_support_channel, + commercial_license, + api_access, + audit_logs, + remove_powered_by, + } = self; + + if share_views { + features.push(Feature::ShareViews) + } + + if onboarding_call { + features.push(Feature::OnboardingCall) + } + + if dedicated_support_channel { + features.push(Feature::DedicatedSupportChannel) + } + + if commercial_license { + features.push(Feature::CommercialLicense) + } + + if api_access { + features.push(Feature::ApiAccess); + } + + if audit_logs { + features.push(Feature::AuditLogs) + } + + if remove_powered_by { + features.push(Feature::RemovePoweredBy) + } + + features + } +} + impl HasId for BillingPlan { fn id(&self) -> &'static str { self.into() @@ -201,6 +301,7 @@ impl EntityMetadataProvider for BillingPlan { BillingPlan::Starter { .. } => "ThumbsUp", BillingPlan::Pro { .. } => "Zap", BillingPlan::Team { .. } => "Users", + BillingPlan::Business { .. } => "Building", } } @@ -210,6 +311,7 @@ impl EntityMetadataProvider for BillingPlan { BillingPlan::Starter { .. } => "blue", BillingPlan::Pro { .. } => "yellow", BillingPlan::Team { .. } => "orange", + BillingPlan::Business { .. } => "gray", } } } @@ -221,6 +323,7 @@ impl TypeMetadataProvider for BillingPlan { BillingPlan::Starter { .. } => "Starter", BillingPlan::Pro { .. } => "Pro", BillingPlan::Team { .. } => "Team", + BillingPlan::Business { .. } => "Business", } } @@ -234,12 +337,16 @@ impl TypeMetadataProvider for BillingPlan { BillingPlan::Team { .. } => { "Collaborate on infrastructure documentation with your team" } + BillingPlan::Business { .. } => { + "Manage multi-site and multi-customer documentation with advanced features" + } } } fn metadata(&self) -> serde_json::Value { serde_json::json!({ "features": self.features(), + "is_commercial": self.is_commercial() }) } } diff --git a/backend/src/server/billing/types/features.rs b/backend/src/server/billing/types/features.rs index a7562193..387a951d 100644 --- a/backend/src/server/billing/types/features.rs +++ b/backend/src/server/billing/types/features.rs @@ -9,25 +9,26 @@ use strum::IntoStaticStr; #[derive(Debug, Clone, Serialize, Deserialize, EnumIter, IntoStaticStr, Display, Default)] pub enum Feature { - MaxNetworks, #[default] - TeamMembers, ShareViews, OnboardingCall, DedicatedSupportChannel, CommercialLicense, + AuditLogs, + ApiAccess, + RemovePoweredBy, } impl HasId for Feature { fn id(&self) -> &'static str { match self { - Feature::MaxNetworks => "max_networks", - // Feature::ApiAccess => "API Access", - Feature::TeamMembers => "team_members", + Feature::ApiAccess => "api_access", + Feature::AuditLogs => "audit_logs", Feature::ShareViews => "share_views", Feature::OnboardingCall => "onboarding_call", Feature::DedicatedSupportChannel => "dedicated_support_channel", Feature::CommercialLicense => "commercial_license", + Feature::RemovePoweredBy => "remove_powered_by", } } } @@ -43,23 +44,35 @@ impl EntityMetadataProvider for Feature { } impl TypeMetadataProvider for Feature { + fn category(&self) -> &'static str { + match self { + Feature::OnboardingCall + | Feature::DedicatedSupportChannel + | Feature::CommercialLicense => "Support & Licensing", + _ => "Features", + } + } + fn name(&self) -> &'static str { match self { - Feature::MaxNetworks => "Max Networks", - // Feature::ApiAccess => "API Access", - Feature::TeamMembers => "Team Members", + Feature::AuditLogs => "Audit Logs", + Feature::ApiAccess => "API Access", Feature::ShareViews => "Share Views", Feature::OnboardingCall => "Onboarding Call", Feature::DedicatedSupportChannel => "Dedicated Discord Channel", Feature::CommercialLicense => "Commercial License", + Feature::RemovePoweredBy => "Remove 'Powered By'", } } fn description(&self) -> &'static str { match self { - Feature::MaxNetworks => "How many networks your organization can create", - // Feature::ApiAccess => "Access NetVisor APIs programmatically to bring your data into other applications", - Feature::TeamMembers => "Collaborate on networks with team members and customers", + Feature::AuditLogs => { + "Comprehensive logs of all access and data modification actions performed in NetVisor" + } + Feature::ApiAccess => { + "Access NetVisor APIs programmatically to bring your data into other applications" + } Feature::ShareViews => "Share live network diagrams with others", Feature::OnboardingCall => { "30 minute onboarding call to ensure you're getting the most out of NetVisor" @@ -68,14 +81,20 @@ impl TypeMetadataProvider for Feature { "A dedicated discord channel for support and questions" } Feature::CommercialLicense => "Use NetVisor under a commercial license", + Feature::RemovePoweredBy => { + "Remove 'Powered By NetVisor' in bottom right corner of visualization" + } } } fn metadata(&self) -> serde_json::Value { - let use_null_as_unlimited = matches!(self, Feature::MaxNetworks); + let is_coming_soon = matches!( + self, + Feature::ApiAccess | Feature::AuditLogs | Feature::RemovePoweredBy + ); serde_json::json!({ - "use_null_as_unlimited": use_null_as_unlimited + "is_coming_soon": is_coming_soon }) } } @@ -99,33 +118,34 @@ mod tests { .as_object() .expect("Features should be an object"); - let billing_plan_keys: HashSet<&str> = features_map.keys().map(|s| s.as_str()).collect(); + let billing_plan_features: HashSet<&str> = + features_map.keys().map(|s| s.as_str()).collect(); // Check that every Feature ID exists in BillingPlanFeatures for feature_id in &feature_ids { assert!( - billing_plan_keys.contains(feature_id), + billing_plan_features.contains(feature_id), "Feature ID '{}' does not exist in BillingPlanFeatures", feature_id ); } // Check that every BillingPlanFeatures field has a corresponding Feature - for key in &billing_plan_keys { + for feature in &billing_plan_features { assert!( - feature_ids.contains(key), + feature_ids.contains(feature), "BillingPlanFeatures field '{}' does not have a corresponding Feature variant", - key + feature ); } // Verify they have the same count assert_eq!( feature_ids.len(), - billing_plan_keys.len(), + billing_plan_features.len(), "Feature enum has {} variants but BillingPlanFeatures has {} fields", feature_ids.len(), - billing_plan_keys.len() + billing_plan_features.len() ); } } diff --git a/backend/src/server/networks/handlers.rs b/backend/src/server/networks/handlers.rs index d3fb1924..f482241a 100644 --- a/backend/src/server/networks/handlers.rs +++ b/backend/src/server/networks/handlers.rs @@ -1,4 +1,6 @@ -use crate::server::auth::middleware::{AuthenticatedUser, RequireAdmin}; +use crate::server::auth::middleware::{ + AuthenticatedUser, CreateNetworkFeature, RequireAdmin, RequireFeature, +}; use crate::server::shared::handlers::traits::{ BulkDeleteResponse, CrudHandlers, bulk_delete_handler, delete_handler, get_by_id_handler, update_handler, @@ -13,7 +15,6 @@ use crate::server::{ types::api::{ApiResponse, ApiResult}, }, }; -use anyhow::anyhow; use axum::extract::Path; use axum::{ Router, @@ -37,6 +38,7 @@ pub fn create_router() -> Router> { pub async fn create_handler( State(state): State>, RequireAdmin(user): RequireAdmin, + RequireFeature { .. }: RequireFeature, Json(request): Json, ) -> ApiResult>> { if let Err(err) = request.validate() { @@ -46,29 +48,6 @@ pub async fn create_handler( ))); } - let organization = state - .services - .organization_service - .get_by_id(&user.organization_id) - .await? - .ok_or_else(|| anyhow!("Failed to get organization for user {}", user.user_id))?; - - let networks = state - .services - .network_service - .get_all(EntityFilter::unfiltered().organization_id(&organization.id)) - .await?; - - if let Some(plan) = organization.base.plan - && let Some(max_networks) = plan.features().max_networks - && networks.len() >= max_networks - { - return Err(ApiError::forbidden(&format!( - "Current plan ({}) only allows for {} network(s). Please upgrade for additional networks.", - plan, max_networks - ))); - } - let service = Network::get_service(&state); let created = service .create(request, user.into()) diff --git a/backend/src/server/networks/service.rs b/backend/src/server/networks/service.rs index 1146cf4f..cbe33fa0 100644 --- a/backend/src/server/networks/service.rs +++ b/backend/src/server/networks/service.rs @@ -36,7 +36,7 @@ impl EventBusService for NetworkService { None } fn get_organization_id(&self, entity: &Network) -> Option { - Some(entity.id) + Some(entity.base.organization_id) } } diff --git a/backend/src/server/organizations/handlers.rs b/backend/src/server/organizations/handlers.rs index 2fcf6c79..b2b58e04 100644 --- a/backend/src/server/organizations/handlers.rs +++ b/backend/src/server/organizations/handlers.rs @@ -57,10 +57,13 @@ async fn create_invite( RequireFeature { plan, .. }: RequireFeature, Json(request): Json, ) -> ApiResult>> { - // We know they have either team_members or share_views enabled - if !plan.features().team_members && request.permissions > UserOrgPermissions::Visualizer { + if let Some(s) = plan.config().included_seats + && s == 1 + && plan.features().share_views + && request.permissions > UserOrgPermissions::Visualizer + { return Err(ApiError::forbidden( - "You can only create Visualizer invites on your current plan. Please upgrade to a Team plan to add Members and Admins.", + "You can only invite users with Visualizer permissions on your plan. Please upgrade to invite Members, Admins, and Owners.", )); } diff --git a/backend/src/server/organizations/service.rs b/backend/src/server/organizations/service.rs index ccce5d08..42e49685 100644 --- a/backend/src/server/organizations/service.rs +++ b/backend/src/server/organizations/service.rs @@ -203,6 +203,25 @@ impl OrganizationService { Ok(()) } + /// Revoke a specific invite + pub async fn get_org_invites(&self, organization_id: &Uuid) -> Result, Error> { + let invites = self.invites.read().await; + + let org_invites: Vec = invites + .iter() + .filter_map(|(_, invite)| { + if invite.organization_id == *organization_id { + Some(invite) + } else { + None + } + }) + .cloned() + .collect(); + + Ok(org_invites) + } + /// List all active invites for an organization pub async fn list_invites(&self, organization_id: &Uuid) -> Vec { let invites = self.invites.read().await; diff --git a/backend/src/server/shared/events/bus.rs b/backend/src/server/shared/events/bus.rs index 52bc6429..ab54c0e8 100644 --- a/backend/src/server/shared/events/bus.rs +++ b/backend/src/server/shared/events/bus.rs @@ -23,6 +23,8 @@ pub trait EventSubscriber: Send + Sync { async fn handle_events(&self, events: Vec) -> Result<()>; /// Optional: debounce window in milliseconds (default: 0 = no batching) + /// NOTE: Batching is global per-subscriber; per-org grouping happens in handle_events. + /// If we add more batching subscribers, consider moving grouping upstream to EventBus. fn debounce_window_ms(&self) -> u64 { 0 } @@ -100,14 +102,6 @@ impl EventFilter { } fn matches_auth(&self, event: &AuthEvent) -> bool { - // Check network filter (using organization_id for auth events) - if let Some(networks) = &self.network_ids - && let Some(org_id) = event.organization_id - && !networks.contains(&org_id) - { - return false; - } - // Check auth operation filter if let Some(auth_operations) = &self.auth_operations { return auth_operations.contains(&event.operation); @@ -170,10 +164,45 @@ impl SubscriberState { return; } + // Count events per org before processing + let mut events_per_org: HashMap, usize> = HashMap::new(); + for event in &events { + let org_id = match event { + Event::Entity(e) => e.network_id, + Event::Auth(e) => e.organization_id, + }; + *events_per_org.entry(org_id).or_default() += 1; + } + + let batch_start = std::time::Instant::now(); + let result = subscriber.handle_events(events.clone()).await; + let batch_duration = batch_start.elapsed(); + + // ============================================================================= + // EVENT BATCH TELEMETRY SIGNALS + // ============================================================================= + // Use these metrics to determine if per-org batching is needed: + // + // | Metric | Signal | + // |-------------------------------|-------------------------------------| + // | org_count consistently > 1 | Batches are mixing tenants | + // | events_per_org skewed | Noisy tenant problem | + // | duration_ms grows w/ org_count| Head-of-line blocking matters | + // | Per-network duration variance | Some tenants slower than others | + // | Errors correlate with orgs | Tenant-specific edge cases | + // + // If multiple signals fire, refactor batching to group by org_id upstream. + // See: https://github.com//issues/XXX (or link to design doc) + // ============================================================================= + tracing::debug!( subscriber = %subscriber.name(), - event_count = events.len(), - "Subscriber processing event batch" + batch_size = events_per_org.values().sum::(), + org_count = events_per_org.len(), + events_per_org = ?events_per_org, + duration_ms = batch_duration.as_millis(), + success = result.is_ok(), + "Event batch processed" ); if let Err(e) = subscriber.handle_events(events).await { diff --git a/backend/src/server/shared/events/types.rs b/backend/src/server/shared/events/types.rs index 723fb691..f0bda320 100644 --- a/backend/src/server/shared/events/types.rs +++ b/backend/src/server/shared/events/types.rs @@ -19,6 +19,20 @@ impl Event { } } + pub fn org_id(&self) -> Option { + match self { + Event::Auth(a) => a.organization_id, + Event::Entity(e) => e.organization_id, + } + } + + pub fn network_id(&self) -> Option { + match self { + Event::Auth(_) => None, + Event::Entity(e) => e.network_id, + } + } + pub fn log(&self) { match self { Event::Entity(event) => { @@ -52,8 +66,8 @@ impl Event { .unwrap_or("unknown".to_string()); let org_id_str = event .organization_id - .map(|n| n.to_string()) - .unwrap_or("N/A".to_string()); + .map(|u| u.to_string()) + .unwrap_or("None".to_string()); tracing::info!( ip = %event.ip_address, @@ -176,7 +190,7 @@ pub struct EntityEvent { pub id: Uuid, pub entity_type: Entity, pub entity_id: Uuid, - pub network_id: Option, // Some entities might belong to an org, not a network + pub network_id: Option, // Some entities might belong to an org, not a network (ie users) pub organization_id: Option, // Some entities might belong to a network, not an org pub operation: EntityOperation, pub timestamp: DateTime, diff --git a/backend/src/server/shared/services/factory.rs b/backend/src/server/shared/services/factory.rs index 842d7b9f..30a4e73d 100644 --- a/backend/src/server/shared/services/factory.rs +++ b/backend/src/server/shared/services/factory.rs @@ -167,6 +167,7 @@ impl ServiceFactory { None }); + // Register services that implement event bus subscriber event_bus .register_subscriber(topology_service.clone()) .await; @@ -175,6 +176,10 @@ impl ServiceFactory { event_bus.register_subscriber(host_service.clone()).await; + if let Some(billing_service) = billing_service.clone() { + event_bus.register_subscriber(billing_service).await; + } + Ok(Self { user_service, auth_service, diff --git a/backend/src/server/users/impl/permissions.rs b/backend/src/server/users/impl/permissions.rs index e8167ee1..7d357edd 100644 --- a/backend/src/server/users/impl/permissions.rs +++ b/backend/src/server/users/impl/permissions.rs @@ -32,6 +32,10 @@ impl UserOrgPermissions { pub fn as_str(&self) -> &'static str { self.into() } + + pub fn counts_towards_seats(&self) -> bool { + *self >= UserOrgPermissions::Member + } } impl FromStr for UserOrgPermissions { @@ -130,7 +134,8 @@ impl TypeMetadataProvider for UserOrgPermissions { matches!(self, UserOrgPermissions::Owner | UserOrgPermissions::Admin); serde_json::json!({ "can_manage": can_manage, - "network_permissions": network_permissions + "network_permissions": network_permissions, + "counts_towards_seats": self.counts_towards_seats() }) } } diff --git a/ui/src/lib/features/billing/BillingPlanForm.svelte b/ui/src/lib/features/billing/BillingPlanForm.svelte index 33de2046..4220fea4 100644 --- a/ui/src/lib/features/billing/BillingPlanForm.svelte +++ b/ui/src/lib/features/billing/BillingPlanForm.svelte @@ -1,15 +1,70 @@
- -
-
+ +
+ +
Open source on GitHub
+ + + (planFilter = value as PlanFilter)} + /> + + + (billingPeriod = value as BillingPeriod)} + />
-
-
- - - - - - - - - {#each $currentPlans as plan (plan.type)} - {@const description = billingPlans.getDescription(plan.type)} - {@const IconComponent = billingPlans.getIconComponent(plan.type)} - {@const colorHelper = billingPlans.getColorHelper(plan.type)} -
-
- -
-
- -
-
+
+ + + + + + + + {#each filteredPlans as plan (plan.type)} + {@const description = billingPlans.getDescription(plan.type)} + {@const IconComponent = billingPlans.getIconComponent(plan.type)} + {@const colorHelper = billingPlans.getColorHelper(plan.type)} + - {/each} + + + {/each} + + + + + + + + {#each filteredPlans as plan (plan.type)} + + {/each} + + + + + + {#each filteredPlans as plan (plan.type)} + + {/each} + + + + {#each [...groupedFeatures.entries()] as [category, categoryFeatures] (category)} + + + - - - - - {#each sortedFeatureKeys as featureKey (featureKey)} - {@const featureDescription = features.getDescription(featureKey)} - - - + - - - {#each $currentPlans as plan (plan.type)} - {@const value = getFeatureValue(plan.type, featureKey)} - - {/each} - - {/each} - - - - - - - - - - {#each $currentPlans as plan (plan.type)} - + + {#each filteredPlans as plan (plan.type)} + {@const value = getFeatureValue(plan.type, featureKey)} + + {/each} + {/each} - - -
+
+ +
+
+ +
+
+ {billingPlans.getName(plan.type)} -
+
+
- -
-
{formatPrice(plan)}
- {#if plan.trial_days > 0} -
- {plan.trial_days}-day free trial -
- {/if} -
+ +
+
{formatBasePricing(plan)}
+ {#if plan.trial_days > 0} +
+ {plan.trial_days}-day free trial +
+ {/if} +
- -
- {#if description} -
- {description} -
- {/if} -
+ +
+ {#if description} +
+ {description} +
+ {/if}
-
+
Seats
+
+
+ + {plan.included_seats === null ? 'Unlimited' : plan.included_seats} + + {#if plan.seat_cents} + + {formatSeatAddonPricing(plan)} for additional seats + + {/if} +
+
+
Networks
+
+
+ + {plan.included_networks === null ? 'Unlimited' : plan.included_networks} + + {#if plan.network_cents} + + {formatNetworkAddonPricing(plan)} for additional networks + + {/if} +
+
+ +
-
- {features.getName(featureKey)} -
- {#if featureDescription} -
- {featureDescription} + + {#if !collapsedCategories[category]} + {#each categoryFeatures as featureKey (featureKey)} + {@const featureDescription = features.getDescription(featureKey)} + {@const comingSoon = isComingSoon(featureKey)} +
+
+ {features.getName(featureKey)}
- {/if} -
- {#if typeof value === 'boolean'} - {#if value} - - {:else} - - {/if} - {:else if value === null} - - {:else} - {value} + {#if featureDescription} +
+ {featureDescription} +
{/if}
- - + {#if comingSoon && value} + + {:else if typeof value === 'boolean'} + {#if value} + + {:else} + + {/if} + {:else if value === null} + + {:else} + {value} + {/if} +
+ {/if} + {/each} + +
+
+
+
+
+
+ {#each filteredPlans as plan (plan.type)} +
+ +
+ {/each} +
diff --git a/ui/src/lib/features/billing/BillingSettingsModal.svelte b/ui/src/lib/features/billing/BillingSettingsModal.svelte index 5cc710bd..3158a8a9 100644 --- a/ui/src/lib/features/billing/BillingSettingsModal.svelte +++ b/ui/src/lib/features/billing/BillingSettingsModal.svelte @@ -3,14 +3,36 @@ import ModalHeaderIcon from '$lib/shared/components/layout/ModalHeaderIcon.svelte'; import { CreditCard, CheckCircle, AlertCircle } from 'lucide-svelte'; import { organization, getOrganization } from '$lib/features/organizations/store'; - import { isBillingPlanActive } from '$lib/features/organizations/types'; + import { isBillingPlanActive } from '../organizations/types'; import { currentUser } from '$lib/features/auth/store'; - import { billingPlans } from '$lib/shared/stores/metadata'; + import { billingPlans, permissions } from '$lib/shared/stores/metadata'; import { openCustomerPortal } from './store'; import InfoCard from '$lib/shared/components/data/InfoCard.svelte'; + import { users } from '../users/store'; + import { networks } from '../networks/store'; let { isOpen = $bindable(false), onClose }: { isOpen: boolean; onClose: () => void } = $props(); + let org = $derived($organization); + + let seatCount = $derived( + $users.filter((u) => permissions.getMetadata(u.permissions).counts_towards_seats).length + ); + let networkCount = $derived($networks.length); + + let extraSeats = $derived.by(() => { + if (!org?.plan?.included_seats) return 0; + return Math.max(seatCount - org.plan.included_seats, 0); + }); + + let extraNetworks = $derived.by(() => { + if (!org?.plan?.included_networks) return 0; + return Math.max(networkCount - org.plan.included_networks, 0); + }); + + let extraSeatsCents = $derived(extraSeats * (org?.plan?.seat_cents || 0)); + let extraNetworksCents = $derived(extraNetworks * (org?.plan?.network_cents || 0)); + // Force Svelte to track organization reactivity $effect(() => { void $organization; @@ -27,7 +49,6 @@ await getOrganization(); } - let org = $derived($organization); let planActive = $derived(org ? isBillingPlanActive(org) : false); function formatPlanStatus(status: string): string { @@ -91,27 +112,86 @@
-
-
-
-

- {billingPlans.getName(org.plan?.type || null)} -

- {#if org.plan && org.plan.trial_days > 0 && org.plan_status === 'trialing'} -

- Includes {org.plan.trial_days}-day free trial +

+ {#if org.plan} + +
+
+

+ {billingPlans.getName(org.plan.type || null)}

- {/if} -
- {#if org.plan} + {#if org.plan.trial_days > 0 && org.plan_status === 'trialing'} +

+ Includes {org.plan.trial_days}-day free trial +

+ {/if} +

- ${org.plan.price.cents / 100} + ${org.plan.base_cents / 100}

-

per {org.plan.price.rate}

+

per {org.plan.rate}

+
+
+ + + {#if org.plan.included_seats !== null} +
+
+
+

Seats

+

+ {seatCount} total ({org.plan.included_seats} included + {#if extraSeats > 0} + + {extraSeats} extra @ ${org.plan.seat_cents + ? org.plan.seat_cents / 100 + : 0} each + {/if}) +

+
+ {#if extraSeatsCents > 0} +
+

+ +${extraSeatsCents / 100} +

+

per {org.plan.rate}

+
+ {:else} +

Included

+ {/if} +
{/if} -
+ + + {#if org.plan.included_networks !== null} +
+
+
+

Networks

+

+ {networkCount} total ({org.plan.included_networks} included + {#if extraNetworks > 0} + + {extraNetworks} extra @ ${org.plan.network_cents + ? org.plan.network_cents / 100 + : 0} each + {/if}) +

+
+ {#if extraNetworksCents > 0} +
+

+ +${extraNetworksCents / 100} +

+

per {org.plan.rate}

+
+ {:else} +

Included

+ {/if} +
+
+ {/if} + {/if} {#if org.plan_status === 'trialing'}
+ interface ToggleOption { + value: string; + label: string; + badge?: string; + } + + interface Props { + options: ToggleOption[]; + selected: string; + onchange: (value: string) => void; + } + + let { options, selected, onchange }: Props = $props(); + + +
+ {#each options as option (option.value)} + + {/each} +
diff --git a/ui/src/lib/features/billing/types.ts b/ui/src/lib/features/billing/types.ts index 63b79f60..faf78944 100644 --- a/ui/src/lib/features/billing/types.ts +++ b/ui/src/lib/features/billing/types.ts @@ -1,10 +1,12 @@ export interface BillingPlan { - price: { - cents: number; - rate: string; - }; + base_cents: number; + seat_cents: number | null; + included_seats: number | null; + network_cents: number | null; + included_networks: number | null; + rate: string; trial_days: number; - type: string; + type: 'Starter' | 'Pro' | 'Team' | 'Enterprise'; } export function formatPrice(cents: number, rate: string): string { diff --git a/ui/src/lib/features/organizations/types.ts b/ui/src/lib/features/organizations/types.ts index a2584e52..67064368 100644 --- a/ui/src/lib/features/organizations/types.ts +++ b/ui/src/lib/features/organizations/types.ts @@ -1,3 +1,4 @@ +import type { BillingPlan } from '../billing/types'; import type { UserOrgPermissions } from '../users/types'; export interface Organization { @@ -11,52 +12,6 @@ export interface Organization { is_onboarded: boolean; } -export function isBillingPlanActive(organization: Organization) { - return organization.plan_status == 'active' || organization.plan_status == 'trialing'; -} - -type BillingPlan = - | HomelabStarterBillingPlan - | HomelabProBillingPlan - | TeamBillingPlan - | CommunityBillingPlan; - -export interface HomelabStarterBillingPlan { - type: 'HomelabStarter'; - price: { - cents: number; - rate: string; - }; - trial_days: number; -} - -export interface HomelabProBillingPlan { - type: 'HomelabPro'; - price: { - cents: number; - rate: string; - }; - trial_days: number; -} - -export interface TeamBillingPlan { - type: 'Team'; - price: { - cents: number; - rate: string; - }; - trial_days: number; -} - -export interface CommunityBillingPlan { - type: 'Community'; - price: { - cents: number; - rate: string; - }; - trial_days: number; -} - export interface CreateInviteRequest { expiration_hours: number | null; permissions: UserOrgPermissions; @@ -72,3 +27,7 @@ export interface OrganizationInvite { created_by: string; organization_id: string; } + +export function isBillingPlanActive(organization: Organization) { + return organization.plan_status == 'active' || organization.plan_status == 'trialing'; +} diff --git a/ui/src/lib/shared/stores/metadata.ts b/ui/src/lib/shared/stores/metadata.ts index 1caf8b16..5cec299a 100644 --- a/ui/src/lib/shared/stores/metadata.ts +++ b/ui/src/lib/shared/stores/metadata.ts @@ -47,6 +47,7 @@ export interface BillingPlanMetadata { onboarding_call: boolean; dedicated_support_channel: boolean; }; + is_commercial: boolean; } export interface ServicedDefinitionMetadata { @@ -59,6 +60,7 @@ export interface ServicedDefinitionMetadata { export interface PermissionsMetadata { can_manage: string[]; network_permissions: boolean; + counts_towards_seats: boolean; } export interface SubnetTypeMetadata { @@ -80,6 +82,7 @@ export interface GroupTypeMetadata {} export interface FeatureMetadata { use_null_as_unlimited: boolean; + is_coming_soon: boolean; } export interface PortTypeMetadata { diff --git a/ui/src/lib/shared/utils/navigation.ts b/ui/src/lib/shared/utils/navigation.ts index f1fc15bb..b246faad 100644 --- a/ui/src/lib/shared/utils/navigation.ts +++ b/ui/src/lib/shared/utils/navigation.ts @@ -4,7 +4,6 @@ import { get } from 'svelte/store'; import { organization } from '$lib/features/organizations/store'; import { config } from '$lib/shared/stores/config'; import { isBillingPlanActive } from '$lib/features/organizations/types'; -import { currentUser } from '$lib/features/auth/store'; /** * Determines the correct route for an authenticated user based on their state @@ -12,20 +11,14 @@ import { currentUser } from '$lib/features/auth/store'; export function getRoute(): string { const $organization = get(organization); const $config = get(config); - const $currentUser = get(currentUser); if (!$organization) { - return resolve('/'); + return resolve('/auth'); } // Check onboarding first - if (!$organization.is_onboarded && $currentUser?.permissions === 'Owner') { - return resolve('/onboarding'); - } - - // If not onboarded and not owner, they're stuck (shouldn't happen normally) if (!$organization.is_onboarded) { - return resolve('/'); + return resolve('/onboarding'); } // Check billing if enabled diff --git a/ui/src/routes/billing/+page.svelte b/ui/src/routes/billing/+page.svelte index 9c522943..87b3fd37 100644 --- a/ui/src/routes/billing/+page.svelte +++ b/ui/src/routes/billing/+page.svelte @@ -13,7 +13,7 @@ {#if $loading} {:else} -
+
-
- +
+
+ +