Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
40 changes: 40 additions & 0 deletions backend/migrations/20251125001342_billing-updates.sql
Original file line number Diff line number Diff line change
@@ -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;
53 changes: 34 additions & 19 deletions backend/src/bin/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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?;
}
Expand Down
150 changes: 127 additions & 23 deletions backend/src/server/auth/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>) -> 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<T: FeatureCheck> {
pub permissions: UserOrgPermissions,
pub plan: BillingPlan,
Expand Down Expand Up @@ -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
}
}
2 changes: 1 addition & 1 deletion backend/src/server/billing/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub fn create_router() -> Router<Arc<AppState>> {
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))
}

Expand Down
1 change: 1 addition & 0 deletions backend/src/server/billing/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod handlers;
pub mod service;
pub mod subscriber;
pub mod types;
Loading
Loading