feat: billing plan fixture generation

This commit is contained in:
Maya
2025-11-29 15:27:58 -05:00
parent f37cdd282e
commit b6eb604570
9 changed files with 481 additions and 133 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -1,4 +1,5 @@
pub mod handlers;
pub mod plans;
pub mod service;
pub mod subscriber;
pub mod types;

View File

@@ -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<BillingPlan> {
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<BillingPlan> {
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
}

View File

@@ -102,15 +102,8 @@ impl BillingService {
pub async fn initialize_products(&self, plans: Vec<BillingPlan>) -> Result<(), Error> {
let mut created_plans = Vec::new();
let all_plans: Vec<BillingPlan> = 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());

View File

@@ -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()
})

250
backend/tests/fixtures.rs Normal file
View File

@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
let services: Vec<serde_json::Value> = 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<dyn std::error::Error>> {
use std::collections::HashMap;
let services = ServiceDefinitionRegistry::all_service_definitions();
// Group services by category
let mut by_category: HashMap<String, Vec<&Box<dyn ServiceDefinition>>> = 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<String> = 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("<table style=\"background-color: #1a1d29; border-collapse: collapse; width: 100%;\">\n");
markdown.push_str("<thead>\n");
markdown.push_str(
"<tr style=\"background-color: #1f2937; border-bottom: 2px solid #374151;\">\n",
);
markdown.push_str("<th width=\"60\" style=\"padding: 12px; text-align: center; color: #e5e7eb; font-weight: 600;\">Logo</th>\n");
markdown.push_str("<th width=\"200\" style=\"padding: 12px; text-align: left; color: #e5e7eb; font-weight: 600;\">Name</th>\n");
markdown.push_str("<th width=\"300\" style=\"padding: 12px; text-align: left; color: #e5e7eb; font-weight: 600;\">Description</th>\n");
markdown.push_str("<th style=\"padding: 12px; text-align: left; color: #e5e7eb; font-weight: 600;\">Discovery Pattern</th>\n");
markdown.push_str("</tr>\n");
markdown.push_str("</thead>\n");
markdown.push_str("<tbody>\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!(
"<img src=\"{}\" alt=\"{}\" width=\"32\" height=\"32\" />",
logo_url, name
)
} else {
"".to_string()
};
markdown.push_str("<tr style=\"border-bottom: 1px solid #374151;\">\n");
markdown.push_str(&format!(
"<td align=\"center\" style=\"padding: 12px; color: #d1d5db;\">{}</td>\n",
logo
));
markdown.push_str(&format!(
"<td style=\"padding: 12px; color: #f3f4f6; font-weight: 500;\">{}</td>\n",
name
));
markdown.push_str(&format!(
"<td style=\"padding: 12px; color: #d1d5db;\">{}</td>\n",
description
));
markdown.push_str(&format!("<td style=\"padding: 12px;\"><code style=\"background-color: #374151; color: #e5e7eb; padding: 2px 6px; border-radius: 3px; font-size: 0.875em;\">{}</code></td>\n", pattern));
markdown.push_str("</tr>\n");
}
markdown.push_str("</tbody>\n");
markdown.push_str("</table>\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<dyn std::error::Error>> {
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<TypeMetadata> = plans.iter().map(|p| p.to_metadata()).collect();
// Get all features metadata
let feature_metadata: Vec<TypeMetadata> = 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(())
}

View File

@@ -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<Service
.await
}
#[tokio::test]
async fn test_full_integration() {
// Start containers
let mut container_manager = ContainerManager::new();
container_manager
.start()
.expect("Failed to start containers");
let client = TestClient::new();
// Authenticate
let user = setup_authenticated_user(&client)
.await
.expect("Failed to authenticate user");
println!("✅ Authenticated as: {}", user.base.email);
// 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);
// Onboard
println!("\n=== Onboarding ===");
onboard(&client).await.expect("Failed to onboard");
println!("✅ Onboarded");
// 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);
// 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_fixtures().await;
}
println!("\n✅ All integration tests passed!");
println!(" ✓ User authenticated");
println!(" ✓ Network created");
println!(" ✓ Daemon registered");
println!(" ✓ Discovery completed");
println!(" ✓ Home Assistant discovered");
}
#[cfg(feature = "generate-fixtures")]
use netvisor::server::{
services::{definitions::ServiceDefinitionRegistry, r#impl::definitions::ServiceDefinition},
shared::types::metadata::TypeMetadata,
};
#[cfg(feature = "generate-fixtures")]
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");
}
#[cfg(feature = "generate-fixtures")]
async fn generate_db_fixture() -> Result<(), Box<dyn std::error::Error>> {
let output = std::process::Command::new("docker")
@@ -519,8 +608,7 @@ async fn generate_services_json() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
// #[cfg(feature = "generate-fixtures")]
#[tokio::test]
#[cfg(feature = "generate-fixtures")]
async fn generate_services_markdown() -> Result<(), Box<dyn std::error::Error>> {
use std::collections::HashMap;
@@ -607,81 +695,32 @@ async fn generate_services_markdown() -> Result<(), Box<dyn std::error::Error>>
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<dyn std::error::Error>> {
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<TypeMetadata> = 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<TypeMetadata> = 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(())
}

1
backend/tests/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod integration;