From b6eb604570604751fb4f9f5294f1ba6b7c8aad64 Mon Sep 17 00:00:00 2001 From: Maya Date: Sat, 29 Nov 2025 15:27:58 -0500 Subject: [PATCH] feat: billing plan fixture generation --- .github/workflows/release.yml | 3 +- backend/src/bin/server.rs | 43 +--- backend/src/server/billing/mod.rs | 1 + backend/src/server/billing/plans.rs | 71 +++++++ backend/src/server/billing/service.rs | 12 +- backend/src/server/billing/types/base.rs | 36 +++- backend/tests/fixtures.rs | 250 +++++++++++++++++++++++ backend/tests/integration.rs | 197 +++++++++++------- backend/tests/mod.rs | 1 + 9 files changed, 481 insertions(+), 133 deletions(-) create mode 100644 backend/src/server/billing/plans.rs create mode 100644 backend/tests/fixtures.rs create mode 100644 backend/tests/mod.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84ae6456..6b859474 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,6 +83,7 @@ jobs: mv backend/src/tests/daemon_config-next.json backend/src/tests/daemon_config.json mv ui/static/services-next.json ui/static/services.json mv docs/SERVICES-NEXT.md docs/SERVICES.md + mv ui/static/billing-plans-next.json ui/static/billing-plans.json echo "✅ Updated test fixtures" - name: Commit and push fixture updates @@ -94,7 +95,7 @@ jobs: git add backend/Cargo.toml backend/Cargo.lock ui/package.json ui/package-lock.json # Stage fixture updates - git add backend/src/tests/netvisor.sql backend/src/tests/daemon_config.json ui/static/services.json docs/SERVICES.md + git add backend/src/tests/netvisor.sql backend/src/tests/daemon_config.json ui/static/services.json docs/SERVICES.md ui/static/billing-plans.json if git diff --staged --quiet; then echo "No fixture changes to commit" diff --git a/backend/src/bin/server.rs b/backend/src/bin/server.rs index ba15836d..7de24301 100644 --- a/backend/src/bin/server.rs +++ b/backend/src/bin/server.rs @@ -12,7 +12,7 @@ use netvisor::server::{ auth::AuthenticatedEntity, logging::request_logging_middleware, rate_limit::rate_limit_middleware, }, - billing::types::base::{BillingPlan, BillingRate, PlanConfig}, + billing::plans::get_all_plans, config::{AppState, ServerCli, ServerConfig}, organizations::r#impl::base::{Organization, OrganizationBase}, shared::{ @@ -207,46 +207,7 @@ async fn main() -> anyhow::Result<()> { let all_users = user_service.get_all(EntityFilter::unfiltered()).await?; if let Some(billing_service) = billing_service { - billing_service - .initialize_products(vec![ - BillingPlan::Starter(PlanConfig { - base_cents: 1499, - rate: BillingRate::Month, - trial_days: 0, - 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, - 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?; + billing_service.initialize_products(get_all_plans()).await?; } // First load - populate user and org diff --git a/backend/src/server/billing/mod.rs b/backend/src/server/billing/mod.rs index 21e9499f..fb2d4519 100644 --- a/backend/src/server/billing/mod.rs +++ b/backend/src/server/billing/mod.rs @@ -1,4 +1,5 @@ pub mod handlers; +pub mod plans; pub mod service; pub mod subscriber; pub mod types; diff --git a/backend/src/server/billing/plans.rs b/backend/src/server/billing/plans.rs new file mode 100644 index 00000000..11a9f2f2 --- /dev/null +++ b/backend/src/server/billing/plans.rs @@ -0,0 +1,71 @@ +use super::types::base::{BillingPlan, BillingRate, PlanConfig}; + +/// Returns the canonical list of billing plans for NetVisor. +/// This is the single source of truth for plan definitions. +fn get_default_plans() -> Vec { + vec![ + BillingPlan::Starter(PlanConfig { + base_cents: 1499, + rate: BillingRate::Month, + trial_days: 0, + 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, + 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), + }), + ] +} + +fn get_enterprise_plan() -> BillingPlan { + BillingPlan::Enterprise(PlanConfig { + base_cents: 0, + rate: BillingRate::Month, + trial_days: 14, + seat_cents: None, + network_cents: None, + included_seats: None, + included_networks: None, + }) +} + +/// Returns both monthly and yearly versions of all plans. +/// Yearly plans get a 20% discount. +pub fn get_all_plans() -> Vec { + let monthly_plans = get_default_plans(); + let mut all_plans = monthly_plans.clone(); + all_plans.push(get_enterprise_plan()); + + // Add yearly versions with 20% discount + for plan in monthly_plans { + all_plans.push(plan.to_yearly(0.20)); + } + + all_plans +} diff --git a/backend/src/server/billing/service.rs b/backend/src/server/billing/service.rs index 70806585..c005b987 100644 --- a/backend/src/server/billing/service.rs +++ b/backend/src/server/billing/service.rs @@ -102,15 +102,8 @@ 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 = all_plans.len(), + plan_count = plans.len(), "Initializing Stripe products and prices" ); @@ -157,7 +150,7 @@ impl BillingService { } }; - for plan in all_plans { + for plan in plans { // Check if product exists, create if not let product_id = plan.stripe_product_id(); let product = match RetrieveProduct::new(product_id.clone()) @@ -731,6 +724,7 @@ impl BillingService { } BillingPlan::Team { .. } => {} BillingPlan::Business { .. } => {} + BillingPlan::Enterprise { .. } => {} } organization.base.plan_status = Some(sub.status.to_string()); diff --git a/backend/src/server/billing/types/base.rs b/backend/src/server/billing/types/base.rs index 3394215f..7b706b11 100644 --- a/backend/src/server/billing/types/base.rs +++ b/backend/src/server/billing/types/base.rs @@ -27,6 +27,7 @@ pub enum BillingPlan { Pro(PlanConfig), Team(PlanConfig), Business(PlanConfig), + Enterprise(PlanConfig), } impl PartialEq for BillingPlan { @@ -128,6 +129,7 @@ impl BillingPlan { "pro" => Some(Self::Pro(plan_config)), "team" => Some(Self::Team(plan_config)), "business" => Some(Self::Business(plan_config)), + "enterprise" => Some(Self::Enterprise(plan_config)), _ => None, } } @@ -139,6 +141,7 @@ impl BillingPlan { BillingPlan::Pro(plan_config) => *plan_config, BillingPlan::Team(plan_config) => *plan_config, BillingPlan::Business(plan_config) => *plan_config, + BillingPlan::Enterprise(plan_config) => *plan_config, } } @@ -149,11 +152,12 @@ impl BillingPlan { BillingPlan::Pro(plan_config) => *plan_config = config, BillingPlan::Team(plan_config) => *plan_config = config, BillingPlan::Business(plan_config) => *plan_config = config, + BillingPlan::Enterprise(plan_config) => *plan_config = config, } } pub fn is_commercial(&self) -> bool { - matches!(self, BillingPlan::Team(_) | BillingPlan::Business(_)) + matches!(self, BillingPlan::Team(_) | BillingPlan::Business(_) | BillingPlan::Enterprise(_)) } pub fn stripe_product_id(&self) -> String { @@ -238,6 +242,15 @@ impl BillingPlan { audit_logs: true, remove_powered_by: true, }, + BillingPlan::Enterprise { .. } => BillingPlanFeatures { + share_views: true, + onboarding_call: true, + dedicated_support_channel: true, + commercial_license: true, + api_access: true, + audit_logs: true, + remove_powered_by: true, + }, } } } @@ -302,7 +315,8 @@ impl EntityMetadataProvider for BillingPlan { BillingPlan::Starter { .. } => "ThumbsUp", BillingPlan::Pro { .. } => "Zap", BillingPlan::Team { .. } => "Users", - BillingPlan::Business { .. } => "Building", + BillingPlan::Business { .. } => "Briefcase", + BillingPlan::Enterprise { .. } => "Building", } } @@ -312,7 +326,8 @@ impl EntityMetadataProvider for BillingPlan { BillingPlan::Starter { .. } => "blue", BillingPlan::Pro { .. } => "yellow", BillingPlan::Team { .. } => "orange", - BillingPlan::Business { .. } => "gray", + BillingPlan::Business { .. } => "brown", + BillingPlan::Enterprise { .. } => "gray", } } } @@ -325,6 +340,7 @@ impl TypeMetadataProvider for BillingPlan { BillingPlan::Pro { .. } => "Pro", BillingPlan::Team { .. } => "Team", BillingPlan::Business { .. } => "Business", + BillingPlan::Enterprise { .. } => "Enterprise", } } @@ -341,11 +357,25 @@ impl TypeMetadataProvider for BillingPlan { BillingPlan::Business { .. } => { "Manage multi-site and multi-customer documentation with advanced features" } + BillingPlan::Enterprise { .. } => { + "Deploy NetVisor with enterprise-grade features and functionality" + } } } fn metadata(&self) -> serde_json::Value { + let config = self.config(); + serde_json::json!({ + // Pricing information + "base_cents": config.base_cents, + "rate": config.rate, + "trial_days": config.trial_days, + "seat_cents": config.seat_cents, + "network_cents": config.network_cents, + "included_seats": config.included_seats, + "included_networks": config.included_networks, + // Feature flags and metadata "features": self.features(), "is_commercial": self.is_commercial() }) diff --git a/backend/tests/fixtures.rs b/backend/tests/fixtures.rs new file mode 100644 index 00000000..2e8f19fe --- /dev/null +++ b/backend/tests/fixtures.rs @@ -0,0 +1,250 @@ +use netvisor::server::{ + services::{definitions::ServiceDefinitionRegistry, r#impl::definitions::ServiceDefinition}, + shared::types::metadata::TypeMetadata, +}; + +pub async fn generate_fixtures() { + generate_db_fixture() + .await + .expect("Failed to generate db fixture"); + + generate_daemon_config_fixture() + .await + .expect("Failed to generate daemon config fixture"); + + generate_services_json() + .await + .expect("Failed to generate services json"); + + generate_services_markdown() + .await + .expect("Failed to generate services markdown"); + + generate_billing_plans_json() + .await + .expect("Failed to generate billing and features json"); + + println!("✅ Generated test fixtures"); +} + +async fn generate_db_fixture() -> Result<(), Box> { + let output = std::process::Command::new("docker") + .args([ + "exec", + "netvisor-postgres-dev-1", + "pg_dump", + "-U", + "postgres", + "-d", + "netvisor", + "--clean", + "--if-exists", + ]) + .output()?; + + if !output.status.success() { + return Err(format!( + "pg_dump failed: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + + let fixture_path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/tests/netvisor-next.sql"); + std::fs::write(&fixture_path, output.stdout)?; + + println!("✅ Generated netvisor-next.sql from test data"); + Ok(()) +} + +async fn generate_daemon_config_fixture() -> Result<(), Box> { + // First, find the config file location in the container + let find_output = std::process::Command::new("docker") + .args([ + "exec", + "netvisor-daemon-1", + "find", + "/root/.config", + "-name", + "config.json", + "-type", + "f", + ]) + .output()?; + + if !find_output.status.success() { + return Err(format!( + "Failed to find daemon config: {}", + String::from_utf8_lossy(&find_output.stderr) + ) + .into()); + } + + let config_path = String::from_utf8_lossy(&find_output.stdout) + .trim() + .to_string(); + + if config_path.is_empty() { + return Err("No config.json found in container".into()); + } + + println!("Found daemon config at: {}", config_path); + + // Now read the config file + let output = std::process::Command::new("docker") + .args(["exec", "netvisor-daemon-1", "cat", &config_path]) + .output()?; + + if !output.status.success() { + return Err(format!( + "Failed to read daemon config: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + + let fixture_path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/tests/daemon_config-next.json"); + std::fs::write(&fixture_path, output.stdout)?; + + println!("✅ Generated daemon_config-next.json from test daemon"); + Ok(()) +} + +async fn generate_services_json() -> Result<(), Box> { + let services: Vec = ServiceDefinitionRegistry::all_service_definitions() + .iter() + .map(|s| { + serde_json::json!({ + "logo_url": s.logo_url(), + "name": s.name(), + "description": s.description(), + "discovery_pattern": s.discovery_pattern().to_string(), + "category": s.category() + }) + }) + .collect(); + + // Write JSON file + let json_string = serde_json::to_string_pretty(&services)?; + let json_path = std::path::Path::new("../ui/static/services-next.json"); + tokio::fs::write(json_path, json_string).await?; + + Ok(()) +} + +async fn generate_services_markdown() -> Result<(), Box> { + use std::collections::HashMap; + + let services = ServiceDefinitionRegistry::all_service_definitions(); + + // Group services by category + let mut by_category: HashMap>> = HashMap::new(); + for service in &services { + let category = service.category().to_string(); + by_category.entry(category).or_default().push(service); + } + + // Sort categories for consistent output + let mut categories: Vec = by_category.keys().cloned().collect(); + categories.sort(); + + let mut markdown = String::from("# NetVisor Service Definitions\n\n"); + markdown.push_str("This document lists all services that NetVisor can automatically discover and identify.\n\n"); + + for category in categories { + let services = by_category.get(&category).unwrap(); + + // Add category header + markdown.push_str(&format!("## {}\n\n", category)); + + // Use HTML table with dark theme styling + markdown.push_str("\n"); + markdown.push_str("\n"); + markdown.push_str( + "\n", + ); + markdown.push_str("\n"); + markdown.push_str("\n"); + markdown.push_str("\n"); + markdown.push_str("\n"); + markdown.push_str("\n"); + markdown.push_str("\n"); + markdown.push_str("\n"); + + // Sort services by name within category + let mut sorted_services = services.clone(); + sorted_services.sort_by_key(|s| s.name()); + + for service in sorted_services { + let logo_url = service.logo_url(); + let name = service.name(); + let description = service.description(); + let pattern = service.discovery_pattern().to_string(); + + // Format logo + let logo = if !logo_url.is_empty() { + format!( + "\"{}\"", + logo_url, name + ) + } else { + "—".to_string() + }; + + markdown.push_str("\n"); + markdown.push_str(&format!( + "\n", + logo + )); + markdown.push_str(&format!( + "\n", + name + )); + markdown.push_str(&format!( + "\n", + description + )); + markdown.push_str(&format!("\n", pattern)); + markdown.push_str("\n"); + } + + markdown.push_str("\n"); + markdown.push_str("
LogoNameDescriptionDiscovery Pattern
{}{}{}{}
\n\n"); + } + + let md_path = std::path::Path::new("../docs/SERVICES-NEXT.md"); + tokio::fs::write(md_path, markdown).await?; + + Ok(()) +} + +async fn generate_billing_plans_json() -> Result<(), Box> { + use netvisor::server::billing::plans::get_all_plans; + use netvisor::server::billing::types::features::Feature; + use netvisor::server::shared::types::metadata::MetadataProvider; + use strum::IntoEnumIterator; + + // Get all plans (monthly + yearly) + let plans = get_all_plans(); + + // Convert to metadata format (same as API returns) + let plan_metadata: Vec = plans.iter().map(|p| p.to_metadata()).collect(); + + // Get all features metadata + let feature_metadata: Vec = Feature::iter().map(|f| f.to_metadata()).collect(); + + // Combine into a single structure + let fixture = serde_json::json!({ + "billing_plans": plan_metadata, + "features": feature_metadata, + }); + + let json_string = serde_json::to_string_pretty(&fixture)?; + let path = std::path::Path::new("../ui/static/billing-plans-next.json"); + tokio::fs::write(path, json_string).await?; + + println!("✅ Generated billing-plans-next.json"); + Ok(()) +} diff --git a/backend/tests/integration.rs b/backend/tests/integration.rs index 97c9159e..435f2c23 100644 --- a/backend/tests/integration.rs +++ b/backend/tests/integration.rs @@ -5,14 +5,9 @@ use netvisor::server::daemons::r#impl::base::Daemon; use netvisor::server::discovery::r#impl::types::DiscoveryType; use netvisor::server::networks::r#impl::Network; use netvisor::server::organizations::r#impl::base::Organization; -use netvisor::server::services::definitions::ServiceDefinitionRegistry; -#[cfg(feature = "generate-fixtures")] -use netvisor::server::services::definitions::ServiceDefinitionRegistry; + use netvisor::server::services::definitions::home_assistant::HomeAssistant; use netvisor::server::services::r#impl::base::Service; -use netvisor::server::services::r#impl::definitions::ServiceDefinition; -#[cfg(feature = "generate-fixtures")] -use netvisor::server::services::r#impl::definitions::ServiceDefinition; use netvisor::server::shared::handlers::factory::OnboardingRequest; use netvisor::server::shared::types::api::ApiResponse; use netvisor::server::shared::types::metadata::HasId; @@ -409,6 +404,100 @@ async fn verify_home_assistant_discovered(client: &TestClient) -> Result Result<(), Box> { let output = std::process::Command::new("docker") @@ -519,8 +608,7 @@ async fn generate_services_json() -> Result<(), Box> { Ok(()) } -// #[cfg(feature = "generate-fixtures")] -#[tokio::test] +#[cfg(feature = "generate-fixtures")] async fn generate_services_markdown() -> Result<(), Box> { use std::collections::HashMap; @@ -607,81 +695,32 @@ async fn generate_services_markdown() -> Result<(), Box> Ok(()) } -#[tokio::test] -async fn test_full_integration() { - // Start containers - let mut container_manager = ContainerManager::new(); - container_manager - .start() - .expect("Failed to start containers"); +#[cfg(feature = "generate-fixtures")] +async fn generate_billing_plans_json() -> Result<(), Box> { + use netvisor::server::billing::plans::get_all_plans; + use netvisor::server::billing::types::features::Feature; + use netvisor::server::shared::types::metadata::MetadataProvider; + use strum::IntoEnumIterator; - let client = TestClient::new(); + // Get all plans (monthly + yearly) + let plans = get_all_plans(); - // Authenticate - let user = setup_authenticated_user(&client) - .await - .expect("Failed to authenticate user"); - println!("✅ Authenticated as: {}", user.base.email); + // Convert to metadata format (same as API returns) + let plan_metadata: Vec = plans.iter().map(|p| p.to_metadata()).collect(); - // Wait for organization - println!("\n=== Waiting for Organization ==="); - let organization = wait_for_organization(&client) - .await - .expect("Failed to find organization"); - println!("✅ Organization: {}", organization.base.name); + // Get all features metadata + let feature_metadata: Vec = Feature::iter().map(|f| f.to_metadata()).collect(); - // Onboard - println!("\n=== Onboarding ==="); - onboard(&client).await.expect("Failed to onboard"); - println!("✅ Onboarded"); + // Combine into a single structure + let fixture = serde_json::json!({ + "billing_plans": plan_metadata, + "features": feature_metadata, + }); - // Wait for network - println!("\n=== Waiting for Network ==="); - let network = wait_for_network(&client) - .await - .expect("Failed to find network"); - println!("✅ Network: {}", network.base.name); + let json_string = serde_json::to_string_pretty(&fixture)?; + let path = std::path::Path::new("../ui/static/billing-plans-next.json"); + tokio::fs::write(path, json_string).await?; - // Wait for daemon - println!("\n=== Waiting for Daemon ==="); - let daemon = wait_for_daemon(&client) - .await - .expect("Failed to find daemon"); - println!("✅ Daemon registered: {}", daemon.id); - - // Run discovery - run_discovery(&client).await.expect("Discovery failed"); - - // Verify service discovered - let _service = verify_home_assistant_discovered(&client) - .await - .expect("Failed to find Home Assistant"); - - #[cfg(feature = "generate-fixtures")] - { - generate_db_fixture() - .await - .expect("Failed to generate db fixture"); - - generate_daemon_config_fixture() - .await - .expect("Failed to generate daemon config fixture"); - - generate_services_json() - .await - .expect("Failed to generate services json"); - - generate_services_markdown() - .await - .expect("Failed to generate services markdown"); - - println!("✅ Generated test fixtures"); - } - - println!("\n✅ All integration tests passed!"); - println!(" ✓ User authenticated"); - println!(" ✓ Network created"); - println!(" ✓ Daemon registered"); - println!(" ✓ Discovery completed"); - println!(" ✓ Home Assistant discovered"); + println!("✅ Generated billing-plans-next.json"); + Ok(()) } diff --git a/backend/tests/mod.rs b/backend/tests/mod.rs new file mode 100644 index 00000000..5155b774 --- /dev/null +++ b/backend/tests/mod.rs @@ -0,0 +1 @@ +pub mod integration;