Skip to content

Tier Feature Gating

Tier feature gating controls access to premium features (Knowledge Base, Custom Analyzers, Autonomous Plans) based on the organization’s subscription tier. Enforcement runs on both server and client, with a shared TierFeature enum as the single source of truth.

Defined in src/shared/types/tier_feature_type.rs, this enum is available on both server and WASM targets.

pub enum TierFeature {
KnowledgeBase, // subscription_tier.knowledge_base_enabled
CustomAnalyzers, // subscription_tier.custom_analyzers_enabled
AutonomousPlans, // subscription_tier.autonomous_plans_enabled
}

Each variant maps to a boolean *_enabled column on the subscription_tier table and to a ?from= URL slug used by the billing page’s contextual banner.

VariantDB column?from= slugLabel
KnowledgeBaseknowledge_base_enabledknowledgeKnowledge Base
CustomAnalyzerscustom_analyzers_enabledanalyzersCustom analyzers
AutonomousPlansautonomous_plans_enabledplansAutonomous plans

The service in src/mods/billing/services/check_tier_feature_service.rs provides two functions:

pub async fn org_has_tier_feature(
db: &DatabaseConnection,
org_id: Uuid,
feature: TierFeature,
) -> Result<bool, AppError>

Joins organization_subscriptionsubscription_tier and returns the relevant *_enabled column. Returns false if no subscription exists.

Use this in API handlers via the or_forbidden chain:

org_has_tier_feature(&db, session.organization.id, TierFeature::KnowledgeBase)
.await?
.or_forbidden("Knowledge Base is not included in your current plan")?;
pub async fn load_org_tier_flags(
db: &DatabaseConnection,
org_id: Uuid,
) -> Result<OrgTierFlags, AppError>

Returns all three flags in a single query. Used by the assistant tool registry to filter tier-gated tools without three round-trips:

pub struct OrgTierFlags {
pub knowledge_base_enabled: bool,
pub custom_analyzers_enabled: bool,
pub autonomous_plans_enabled: bool,
}
LayerFeatureBehavior on deny
API (create_*)KB, Analyzers, Plans403 Forbidden
analyze_call_utilAnalyzersEarly return (no-op)
execute_plan_servicePlansReturns Ok(()) silently — correct for background jobs with no HTTP boundary
tool_registry_serviceKB, Analyzers, PlansFilters tools from the assistant’s available set via TierToolFlags

src/mods/billing/hooks/use_tier_feature.rs resolves a TierFeatureGate from the cached subscription and tier list APIs:

pub struct TierFeatureGate {
pub enabled: bool,
pub required_tier_slug: String, // e.g. "pro"
pub required_tier_name: String, // e.g. "Pro"
}
pub fn use_tier_feature(feature: TierFeature) -> TierFeatureResource;

The hook fetches the org’s subscription and tier list in parallel, then derives whether the feature is available. required_tier_name resolves to the lowest-priced monthly tier that includes the feature — used for paywall CTA copy. Tiers without any wired Stripe Prices sort last, so an unwired Pro never beats a wired Enterprise.

src/ui/feature_paywall_ui.rs renders a conversion-focused card with per-feature visual identity, price preview, and a full-width upgrade CTA.

Each bullet carries its own icon instead of a generic dot:

pub struct PaywallBullet {
pub icon: Element, // Lucide icon (size ~14)
pub text: String, // Outcome-focused copy
}

FeaturePaywallProps accepts these fields:

PropTypeDescription
feature_nameStringLabel shown in the eyebrow (e.g. “Knowledge Base”)
headlineStringSemibold headline below the icon
iconElementLucide icon rendered in a 48×48 accent-tinted square
accent_classStringTailwind utilities for icon tint (e.g. "bg-entity-call/10 text-entity-call")
bulletsVec<PaywallBullet>Value propositions with per-bullet icons
required_tier_nameStringTarget tier name for CTA copy
from_sourceString?from= slug for billing deep-link
price_labelOption<String>Headline price (e.g. "$29")
price_cadenceOption<String>Cadence suffix (e.g. "/seat/mo")
price_subtitleOption<String>Reassurance line (e.g. "billed monthly · cancel anytime")
actionablebooltrue for owners (shows CTA), false for members (shows notice)
on_upgradeOption<EventHandler<()>>Fires on CTA click; wired to use_tier_switch
classStringAdditional CSS classes

The card stacks vertically:

  1. Header — accent icon square + uppercase eyebrow (feature_name · required_tier_name) + headline
  2. Bullets — each bullet in a flex row with its own accent-tinted icon square (24×24)
  3. Price block (separated by border-t) — large price numeral + cadence, subtitle, full-width CTA button (ButtonSize::Lg, 44px tap target), optional “See all plans →” link
  4. Non-owner variant — replaces the price block with a soft notice: “Ask your organization owner to unlock {feature}.”

FeaturePaywallCard in src/mods/billing/components/feature_paywall_card_component.rs wraps FeaturePaywall and supplies per-feature copy via paywall_copy():

FeatureIconAccentBullet icons
KnowledgeBaseBookOpenbg-entity-call/10 text-entity-callUpload, ShieldCheck, RefreshCw
CustomAnalyzersActivitybg-entity-agent/10 text-entity-agentSparkles, Phone, Award
AutonomousPlansWorkflowbg-entity-plan/10 text-entity-planLayoutTemplate, Send, ShieldCheck

FeaturePaywallCard derives price from the gate’s Stripe-sourced monthly TierPrice:

let has_required_tier = !gate.required_tier_slug.is_empty();
let monthly_price = gate.prices.iter()
.find(|p| p.interval == BillingInterval::Month);
let price_label = monthly_price.map(|p| format_price_cents(p.unit_amount_cents));
let price_cadence = monthly_price.map(|_| "/seat/mo".to_string());

Tiers without any active Stripe Prices show “Contact sales” with disabled CTAs instead of $0.00.

// Convenience wrapper (recommended)
FeaturePaywallCard {
feature: TierFeature::KnowledgeBase,
gate: gate.clone(),
}
// Direct usage with full control
FeaturePaywall {
feature_name: "Knowledge Base",
headline: "Train your AI on your own documents.",
icon: rsx! { BookOpen { size: 24 } },
accent_class: "bg-entity-call/10 text-entity-call",
bullets: vec![
PaywallBullet {
icon: rsx! { Upload { size: 14 } },
text: "Upload reference docs and call scripts".to_string(),
},
],
required_tier_name: "Pro",
from_source: "knowledge",
price_label: Some("$29".to_string()),
price_cadence: Some("/seat/mo".to_string()),
actionable: session.is_owner,
on_upgrade: Some(switch.open),
}

The paywall is wired into 9 views: KB, Analyzer, and Plan list, create, and details views, plus plan templates. Enforcement is forward-only — existing records stay in the database but the view renders the paywall instead.

POST /api/billing/subscription/tier

Auth: Owner-only. Body: target_tier_slug: String, billing_interval: BillingInterval.

Response:

pub enum TierChangeResult {
Updated {
tier_slug: String,
tier_name: String,
was_no_op: bool, // true if already on this tier
},
RedirectToCheckout {
url: String, // Stripe Checkout URL (no payment method on file)
},
}

update_subscription_tier in src/mods/billing/services/update_subscription_tier_service.rs:

  1. Looks up the org’s current subscription and the target tier by slug
  2. Returns was_no_op: true if already on the target tier (no Stripe call)
  3. Fetches both tiers’ Stripe Prices in parallel (futures::join) and resolves the Price matching the requested billing_interval
  4. Rejects tiers without active Prices for the requested interval (not available for self-service)
  5. Determines upgrade vs. downgrade by comparing same-interval unit_amount_cents. Falls back to Downgrade when the current tier’s Stripe fetch fails (safer than an unsolicited proration)
  6. Calls stripe::Subscription::update with proration:
    • Upgrade: AlwaysInvoice — prorated charge hits immediately
    • Downgrade: None — takes effect at cycle end
  7. Mirrors subscription_tier_id on the local row immediately so the dashboard updates without waiting on the customer.subscription.updated webhook

The billing section (billing_section_component.rs) renders a TierGrid with per-tier “Switch to {tier}” CTAs, each backed by a ConfirmDialog.

Recommended badge: Context-aware via ?from= query param. Strictly upward — never recommends a downgrade. Suppressed on the top tier or when the current tier already includes the feature.

Layout order: Free orgs see the TierGrid above CurrentPlanCard; paid orgs keep the existing order. Auto-scrolls to TierGrid on ?from= deep-links.

BillingContextBanner in src/mods/billing/components/billing_context_banner_component.rs reads the ?from= query parameter from window.location and renders feature-specific copy:

?from= valueBanner copy
knowledge”Knowledge Base is part of {tier}. Upgrade below to unlock it.”
analyzers”Custom analyzers are part of {tier}. Upgrade below to start using them.”
plans”Autonomous plans are part of {tier}. Upgrade below to enable them.”
team-members”You’re at seat capacity. Open Manage billing on your current plan to add more seats.”

Banner copy uses the lowest-feature-tier name (separate from the Recommended badge logic) so a Pro org arriving via ?from=knowledge doesn’t read “part of Enterprise.” The banner is suppressed when stale (user already has the feature) and dismissible per-render.