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.
TierFeature enum
Section titled “TierFeature enum”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.
| Variant | DB column | ?from= slug | Label |
|---|---|---|---|
KnowledgeBase | knowledge_base_enabled | knowledge | Knowledge Base |
CustomAnalyzers | custom_analyzers_enabled | analyzers | Custom analyzers |
AutonomousPlans | autonomous_plans_enabled | plans | Autonomous plans |
Server-side enforcement
Section titled “Server-side enforcement”The service in src/mods/billing/services/check_tier_feature_service.rs provides two functions:
Single-feature check
Section titled “Single-feature check”pub async fn org_has_tier_feature( db: &DatabaseConnection, org_id: Uuid, feature: TierFeature,) -> Result<bool, AppError>Joins organization_subscription → subscription_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")?;Bulk flags (hot paths)
Section titled “Bulk flags (hot paths)”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,}Where gates are applied
Section titled “Where gates are applied”| Layer | Feature | Behavior on deny |
|---|---|---|
API (create_*) | KB, Analyzers, Plans | 403 Forbidden |
analyze_call_util | Analyzers | Early return (no-op) |
execute_plan_service | Plans | Returns Ok(()) silently — correct for background jobs with no HTTP boundary |
tool_registry_service | KB, Analyzers, Plans | Filters tools from the assistant’s available set via TierToolFlags |
Client-side enforcement
Section titled “Client-side enforcement”use_tier_feature hook
Section titled “use_tier_feature hook”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.
FeaturePaywall component
Section titled “FeaturePaywall component”src/ui/feature_paywall_ui.rs renders a conversion-focused card with per-feature visual identity, price preview, and a full-width upgrade CTA.
PaywallBullet type
Section titled “PaywallBullet type”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:
| Prop | Type | Description |
|---|---|---|
feature_name | String | Label shown in the eyebrow (e.g. “Knowledge Base”) |
headline | String | Semibold headline below the icon |
icon | Element | Lucide icon rendered in a 48×48 accent-tinted square |
accent_class | String | Tailwind utilities for icon tint (e.g. "bg-entity-call/10 text-entity-call") |
bullets | Vec<PaywallBullet> | Value propositions with per-bullet icons |
required_tier_name | String | Target tier name for CTA copy |
from_source | String | ?from= slug for billing deep-link |
price_label | Option<String> | Headline price (e.g. "$29") |
price_cadence | Option<String> | Cadence suffix (e.g. "/seat/mo") |
price_subtitle | Option<String> | Reassurance line (e.g. "billed monthly · cancel anytime") |
actionable | bool | true for owners (shows CTA), false for members (shows notice) |
on_upgrade | Option<EventHandler<()>> | Fires on CTA click; wired to use_tier_switch |
class | String | Additional CSS classes |
Card layout
Section titled “Card layout”The card stacks vertically:
- Header — accent icon square + uppercase eyebrow (
feature_name · required_tier_name) + headline - Bullets — each bullet in a flex row with its own accent-tinted icon square (24×24)
- 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 - Non-owner variant — replaces the price block with a soft notice: “Ask your organization owner to unlock {feature}.”
Per-feature visual identity
Section titled “Per-feature visual identity”FeaturePaywallCard in src/mods/billing/components/feature_paywall_card_component.rs wraps FeaturePaywall and supplies per-feature copy via paywall_copy():
| Feature | Icon | Accent | Bullet icons |
|---|---|---|---|
KnowledgeBase | BookOpen | bg-entity-call/10 text-entity-call | Upload, ShieldCheck, RefreshCw |
CustomAnalyzers | Activity | bg-entity-agent/10 text-entity-agent | Sparkles, Phone, Award |
AutonomousPlans | Workflow | bg-entity-plan/10 text-entity-plan | LayoutTemplate, Send, ShieldCheck |
Price preview
Section titled “Price preview”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 controlFeaturePaywall { 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.
In-app tier change
Section titled “In-app tier change”API endpoint
Section titled “API endpoint”POST /api/billing/subscription/tierAuth: 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) },}Service behavior
Section titled “Service behavior”update_subscription_tier in src/mods/billing/services/update_subscription_tier_service.rs:
- Looks up the org’s current subscription and the target tier by slug
- Returns
was_no_op: trueif already on the target tier (no Stripe call) - Fetches both tiers’ Stripe Prices in parallel (
futures::join) and resolves the Price matching the requestedbilling_interval - Rejects tiers without active Prices for the requested interval (not available for self-service)
- 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) - Calls
stripe::Subscription::updatewith proration:- Upgrade:
AlwaysInvoice— prorated charge hits immediately - Downgrade:
None— takes effect at cycle end
- Upgrade:
- Mirrors
subscription_tier_idon the local row immediately so the dashboard updates without waiting on thecustomer.subscription.updatedwebhook
Dashboard integration
Section titled “Dashboard integration”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.
Contextual billing banner
Section titled “Contextual billing banner”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= value | Banner 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.