Subscription Tier System Documentation
Overview
The subscription tier system manages user subscriptions with feature-based access control. Users get assigned to tiers by purchasing products, and each tier grants access to specific features and limits.
Tiers are sold through any of four billing sources — Stripe, PayPal, the
Apple App Store, or Google Play (the mobile apps' in-app purchases). Each
subscription order item stores its source in odi_payment_source, and a
user has at most one active source at a time (see
Mobile Billing for the source model and exclusivity
rules). All four drive the same grant path:
TierBilling::handleProductPurchase() → SubscriptionTier::addUser() on
purchase/renewal, and TierBilling::handleSubscriptionExpired() →
SubscriptionTier::removeUserFromAllTiers() on expiry, cancellation, or
refund.
How It Works
1. Creating Subscription Tiers (Admin)
Navigate: Products → Subscription Tiers → Create New Tier
Configure the tier:
- Tier Name: Internal identifier (e.g., "basic", "premium", "pro")
- Display Name: What users see (e.g., "Basic Plan", "Premium Member")
- Tier Level: Numeric hierarchy - higher numbers = better tiers (e.g., 10, 20, 30)
- Description: Rich text shown to users describing the tier
- Features: Configure limits and access for this tier (see below)
- Active: Toggle to enable/disable the tier
Tier Name: premium
Display Name: Premium Plan
Tier Level: 20
Description: Full access with up to 10 devices
Features:
- Maximum Devices: 10
- Custom Rules: Enabled
- Advanced Filters: Enabled2. Configuring Tier Features
Features are automatically discovered from JSON definition files:
Plugin Features: Each plugin defines its features in /plugins/{plugin}/tier_features.json
Example: /plugins/controld/tier_features.json
{
"max_devices": {
"type": "integer",
"label": "Maximum Devices",
"description": "Maximum number of devices allowed for this tier",
"default": 1,
"min": 0,
"max": 999
}
}When you edit a tier, the admin UI automatically shows all available features from all plugins. Simply set the values for each tier.
Note: Plugin features are automatically prefixed with the plugin name (e.g., max_devices becomes controld_max_devices).
The mailbox plugin's mailbox_fleet_slot (boolean) and
mailbox_fleet_max_domains (integer) gate the hosted relay fleet
(plugins/mailbox/docs/overview.md § Hosted relay fleet): the fleet service
validates mailbox_fleet_slot against the enrolling customer's tier at
enrollment and on a periodic re-check, with a grace window before a lapsed
slot is suspended.
Core Features: Core (non-plugin) features live in /includes/core_tier_features.json
and are not prefixed. The member Drive defines four:
| Key | Type | Meaning |
|---|---|---|
drive_storage_bytes | integer | Total Drive storage per member. 0 disables uploads. |
drive_max_file_bytes | integer | Largest single file a member may upload. 0 disables uploads. |
drive_share_links | boolean | Whether the member may mint public share links. |
drive_versioning_depth | integer | Prior versions kept per file. 0 keeps only the current version. |
SubscriptionTier::getUserFeature($user_id, $key, $default),
which returns the default for a tierless member — so all four fail closed (no
storage, no links, no history) until a tier is configured.3. Linking Tiers to Products
Navigate: Products → Edit Product
Assign the tier:
- Select a subscription tier from the dropdown
- Save the product
4. User Assignment
Automatic Assignment (Purchase):
- User purchases a product with an assigned tier
- User is removed from their current tier (if any)
- User is assigned to the new tier
- If the new tier is lower level than current, assignment is skipped (upgrade-only)
- Navigate to Subscription Tiers → View Members
- Add/remove users manually
- Useful for promotions, migrations, or support cases
5. Viewing Tier Members
Navigate: Products → Subscription Tiers → View Members
See:
- All users in the tier
- When they were assigned
- How they got the tier (purchase, manual, etc.)
- Current subscription status
6. Admin Settings
Six settings control subscription management behaviors (Products → Settings):
- subscription_downgrades_enabled - Allow users to downgrade to lower tiers
- subscription_downgrade_timing - When downgrades take effect (
immediateorend_of_period) - subscription_cancellation_enabled - Allow users to cancel subscriptions
- subscription_cancellation_timing - When cancellations take effect (
immediateorend_of_period) - subscription_reactivation_enabled - Allow users to reactivate cancelled subscriptions
- subscription_cancellation_prorate - Issue refunds for immediate cancellations
User Experience
Users manage their subscriptions from a dedicated subscription management page:
View Current Tier:
- See their current subscription tier and features
- View billing information and renewal date
- Always available
- See higher tiers and their features
- Purchase immediately with prorated billing
- Only if
subscription_downgrades_enabled = true - Takes effect based on
subscription_downgrade_timingsetting
- Only if
subscription_cancellation_enabled = true - Timing and refunds controlled by settings
- Only if
subscription_reactivation_enabled = true - Available for cancelled subscriptions before they expire
Native App Read Access
The iOS member app's Subscriptions screen
(ios/joinery-kit/Sources/JoineryMemberKit/SubscriptionsScreen.swift) reads
the same subscription data through POST /api/v1/action/subscription_summary
(session key). The payload returns active and cancelled subscriptions
(order-item id, product/tier name, price, period, status, renewal-or-end
date, can_cancel), the current tier from the user record, and a
payment_source marker (stripe / paypal / app_store / play_store /
none) so the client knows which management affordances to show.
The screen is read-only plus Cancel, which calls the existing
orders_recurring_action — the same action the web page's cancel button
uses. Upgrade, downgrade, reactivate, and billing management are routed by
source: web-billed subscriptions open the existing web pages in the app's
authenticated webview ("Change Plan" / "Manage Billing"), while store-billed
subscriptions (app_store / play_store) deep-link to the store's own
subscription management and report can_cancel: false. Selling inside the
apps is the billing kits' job — see Mobile Billing.
Developer Reference
Key Files
Models:
/data/subscription_tiers_class.php-SubscriptionTierandMultiSubscriptionTier
/adm/admin_subscription_tiers.php- List tiers (under Users in the admin menu)/adm/admin_subscription_tier_edit.php- Create/edit tier/adm/admin_subscription_tier_members.php- View members
plugins/store/includes/TierBilling.php- Purchase-driven tier grants, upgrade options, renewal/expiryplugins/store/views/profile/change-tier.php+plugins/store/logic/change_tier_logic.php- Self-serve plan change UI
/includes/core_tier_features.json- Core features/plugins/{plugin}/tier_features.json- Plugin features
Core/Billing Split (the TierBilling seam)
Tiers themselves are core: the model, tier levels, group membership (grp_category = 'subscription_tier'), feature flags, and content gating all work with no store present. What sells tiers is the store plugin: purchase-driven tier assignment, upgrades, renewals, and expiry live behind TierBilling in plugins/store/includes/.
Where a gate prompt sends a user to upgrade is controlled by the core tier_upgrade_url setting:
- Set — the prompt links there (point it at any external or custom purchase flow).
- Empty (default), store active — the prompt offers the store's tier-granting products via
TierBilling::getUpgradeOptions(). - Empty, store inactive — the prompt falls back to a contact-us message.
TierBilling without a store-active guard; everything core needs from billing flows through that setting and the gate prompt component.Database Structure
Main table: sbt_subscription_tiers
- Links to groups system via
sbt_grp_group_id - Features stored as JSONB in
sbt_features - Integrates with products via
pro_sbt_subscription_tier_id
Common Code Patterns
Check user's feature value:
// Get max devices for user (default to 1 if no tier)
$max_devices = SubscriptionTier::getUserFeature($user_id, 'controld_max_devices', 1);
// Check boolean feature
$has_premium = SubscriptionTier::getUserFeature($user_id, 'controld_advanced_filters', false);
// Use in logic
if ($device_count >= $max_devices) {
return "You've reached your device limit. Upgrade to add more.";
}Check minimum tier level:
// Require tier level 20 or higher
if (!SubscriptionTier::UserHasMinimumTier($user_id, 20)) {
header('Location: /upgrade-required');
exit;
}
// Or use helper that redirects automatically:
SubscriptionTier::requireMinimumTier($user_id, 20, '/change-subscription');Get user's tier:
$tier = SubscriptionTier::GetUserTier($user_id);
if ($tier) {
echo "Your plan: " . $tier->get('sbt_display_name');
echo "Tier level: " . $tier->get('sbt_tier_level');
}Get available upgrades:
$upgrades = SubscriptionTier::getUpgradeOptions($user_id);
foreach ($upgrades as $option) {
$tier = $option['tier'];
$products = $option['products'];
// Display upgrade cards
}Creating Plugin Features
Create /plugins/{yourplugin}/tier_features.json:
{
"your_feature_name": {
"type": "integer",
"label": "Feature Display Name",
"description": "Help text for admins",
"default": 1,
"min": 0,
"max": 100
},
"another_feature": {
"type": "boolean",
"label": "Enable Premium Feature",
"description": "Allow access to premium features",
"default": false
}
}Feature types:
integer- Numeric limits (shows number input with optional min/max)boolean- True/false flags (shows checkbox)string- Text values (shows text input)
Rolling Out a New Feature to Existing Tiers
Adding a tier-gated feature is three steps. Skipping any of them silently breaks the feature for the wrong audience.
- Schema — declare the key in
tier_features.json(or coresettings.json-equivalent). - Code — gate the UI/logic via
$tier->getFeature('feature_key', $default)orSubscriptionTier::getUserFeature(...). - Per-tier values — explicitly set the flag on every existing tier row in
sbt_subscription_tiers.sbt_featuresvia the admin Subscription Tiers UI (/admin/admin_subscription_tier_edit?id=N).
default in the schema is not a per-tier default — it's a fallback used when a tier row has no entry for the key. So if you ship a boolean feature with default: false and forget step 3, every paying tier silently gets false until an admin manually toggles it on. The feature looks broken from the user's side, but the only "bug" is missing per-tier values.For brand-new tiers, the same rule applies — when creating a tier, set every relevant feature explicitly rather than relying on schema defaults.
Important: MultiGroup Filter Keys
When querying for groups, use the correct option keys:
// ❌ WRONG - Uses column names directly
$groups = new MultiGroup([
'grp_name' => 'Basic Plan',
'grp_category' => 'subscription_tier'
]);
// ✅ CORRECT - Uses MultiGroup option keys
$groups = new MultiGroup([
'group_name' => 'Basic Plan',
'category' => 'subscription_tier'
]);MultiGroup option keys:
group_name→grp_namecategory→grp_categorygroup_id→grp_group_iduser_id→grp_usr_user_iddeleted→grp_delete_time(bool)
/data/[table]_class.php to see which option keys each Multi class accepts.Automatic Integration
The system automatically:
- Creates a group for each tier in
grp_groupswithgrp_category = 'subscription_tier' - Assigns users to tier groups when they purchase products
- Removes users from old tiers when upgrading
- Tracks all changes in the
change_trackingtable - Prevents downgrades via purchase (upgrade-only)
Troubleshooting
"Tier already exists" error when creating new tier:
- Check you're using correct MultiGroup option keys (see above)
- Verify no existing tier has that name
- Check JSON file exists and has valid syntax
- Ensure file is readable by web server
- Verify product has
pro_sbt_subscription_tier_idset - Check error logs for assignment failures
- Always provide default value in
getUserFeature() - Verify feature key matches JSON definition
- Check user has a tier assigned
false) for paying customers:
- The per-tier value was never set on
sbt_subscription_tiers.sbt_featuresfor that tier — the schemadefaultis filling in. - Open
/admin/admin_subscription_tier_edit?id=Nfor each affected tier and toggle the feature on. See "Rolling Out a New Feature to Existing Tiers" above.
Tier Gating (Content Access Control)
Tier gating restricts access to any entity based on the viewer's subscription tier. When a user lacks the required tier, they see a prompt to subscribe or upgrade.
How It Works
Each gatable entity has a {prefix}_tier_min_level field. When set to a tier level value (e.g., 10, 20, 30), only users at that tier or higher can see the full content. Admins (permission 5+) always bypass the gate.
Making an Entity Tier-Gatable
- Add the field to
$field_specifications:
'{prefix}_tier_min_level' => array('type'=>'int4', 'is_nullable'=>true),- For early access support (optional), also add:
'{prefix}_tier_public_after_hours' => array('type'=>'int4', 'is_nullable'=>true),- Call
authenticate_tier()in the view:
$session = SessionControl::get_instance();
$access = $entity->authenticate_tier($session);
if ($access['allowed']) {
// Render full content
} else {
require_once(PathHelper::getIncludePath('includes/tier_gate_prompt.php'));
render_tier_gate_prompt($access);
}- For entities with an
is_viewable($session)visibility gate (files, videos), add inside that method:
if ($this->get('{prefix}_tier_min_level')) {
$tier_access = $this->authenticate_tier($session);
if (!$tier_access['allowed']) return false;
}- Add a tier dropdown to the admin edit page:
require_once(PathHelper::getIncludePath('data/subscription_tiers_class.php'));
$tier_options = ['' => 'No tier required'];
$all_tiers = MultiSubscriptionTier::GetAllActive();
foreach ($all_tiers as $tier) {
$tier_options[$tier->get('sbt_tier_level')] = $tier->get('sbt_display_name') . ' (Level ' . $tier->get('sbt_tier_level') . ')';
}
$formwriter->dropinput('{prefix}_tier_min_level', 'Minimum Tier Required', [
'options' => $tier_options
]);- Register the entity so the tier admin page's gated-content summary counts it. Core entity types register in
TierGatedContentRegistry::registerCoreDefaults()(bottom ofincludes/TierGatedContentRegistry.php); a plugin's types register from itsserve.php:
TierGatedContentRegistry::register('Events', 'evt_events', 'evt_tier_min_level', 'evt_delete_time');Gate Prompt Component
includes/tier_gate_prompt.php provides two functions:
render_tier_gate_prompt($access, $options)— Renders the paywall prompt. Pass['preview_html' => $html]in options to show a preview before the gate.get_tier_gate_preview_html($body_text)— Returns truncated preview HTML based on thetier_gate_preview_lengthsetting.
authenticate_tier() Return Value
Returns an array with:
allowed(bool) — Whether access is grantedreason(string|null) —'not_logged_in'or'tier_too_low'required_level(int|null) — The tier level neededuser_level(int|null) — The user's current tier levelrequired_tier(SubscriptionTier|null) — The required tier objectupgrade_options(array) — Available upgrade products
Multi-Class Filtering
Use max_visible_tier_level in Multi class queries to filter by user's tier:
$user_tier_level = 0;
$tier = SubscriptionTier::GetUserTier($session->get_user_id());
if ($tier) $user_tier_level = $tier->get('sbt_tier_level');
$posts = new MultiPost(['published' => true, 'deleted' => false, 'max_visible_tier_level' => $user_tier_level]);Supported on: MultiPost, MultiEvent, MultiProduct.
Interaction with Other Access Controls
Tier gating is additive. The precedence is: soft delete > published state > visibility > permission level > group membership > tier requirement. Tier is always the last check.
Site Settings
tier_gate_preview_length— Characters of body text to show before the paywall (0 = no preview)tier_gate_hide_from_listings— Hide gated content from listings for users who lack the tier (RSS feeds always hide gated items)
Plugin Developer Usage
Plugin developers can use tier gating in plugin views by calling authenticate_tier() on any entity that has the {prefix}_tier_min_level field. The gate prompt component renders correctly in any theme context.
Related Documentation
/specs/subscription_tiers.md- Full specification and implementation detailsCLAUDE.md- System architecture and patterns