512 lines
14 KiB
Markdown
512 lines
14 KiB
Markdown
# RFC: Entitlements and Feature System
|
|
|
|
**Status:** Implemented
|
|
**Created:** 2026-01-15
|
|
**Authors:** Host UK Engineering
|
|
|
|
---
|
|
|
|
## Abstract
|
|
|
|
The Entitlement System controls feature access, usage limits, and tier gating across all Host services. It answers one question: "Can this workspace do this action?"
|
|
|
|
Workspaces subscribe to **Packages** that bundle **Features**. Features are either boolean flags (access gates) or numeric limits (usage caps). **Boosts** provide temporary or permanent additions to base limits. Usage is tracked, cached, and enforced in real-time.
|
|
|
|
The system integrates with Commerce for subscription lifecycle and exposes an API for cross-service entitlement checks.
|
|
|
|
---
|
|
|
|
## Core Model
|
|
|
|
### Entity Relationships
|
|
|
|
```
|
|
┌──────────────────────────────────────────────────────────────────┐
|
|
│ │
|
|
│ Workspace ───┬─── WorkspacePackage ─── Package ─── Features │
|
|
│ │ │
|
|
│ ├─── Boosts (temporary limit additions) │
|
|
│ │ │
|
|
│ ├─── UsageRecords (consumption tracking) │
|
|
│ │ │
|
|
│ └─── EntitlementLogs (audit trail) │
|
|
│ │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Workspace
|
|
|
|
The tenant unit. All entitlement checks happen against a workspace, not a user. Users belong to workspaces; workspaces own entitlements.
|
|
|
|
```php
|
|
// Check if workspace can use a feature
|
|
$workspace->can('social.accounts', quantity: 3);
|
|
|
|
// Record usage
|
|
$workspace->recordUsage('ai.credits', quantity: 10);
|
|
|
|
// Get usage summary
|
|
$workspace->getUsageSummary();
|
|
```
|
|
|
|
### Package
|
|
|
|
A bundle of features with defined limits. Two types:
|
|
|
|
| Type | Behaviour |
|
|
|------|-----------|
|
|
| **Base Package** | Only one active per workspace. Upgrading replaces the previous base package. |
|
|
| **Add-on Package** | Stackable. Multiple can be active simultaneously. Limits accumulate. |
|
|
|
|
**Database:** `entitlement_packages`
|
|
|
|
```php
|
|
// Package fields
|
|
'code' // Unique identifier (e.g., 'social-creator')
|
|
'name' // Display name
|
|
'is_base_package' // true = only one allowed
|
|
'is_stackable' // true = limits add to base
|
|
'monthly_price' // Pricing
|
|
'yearly_price'
|
|
'stripe_price_id_monthly'
|
|
'stripe_price_id_yearly'
|
|
```
|
|
|
|
### Feature
|
|
|
|
A capability or limit that can be granted. Three types:
|
|
|
|
| Type | Behaviour | Example |
|
|
|------|-----------|---------|
|
|
| **Boolean** | On/off access gate | `tier.apollo`, `host.social` |
|
|
| **Limit** | Numeric cap on usage | `social.accounts` (5), `ai.credits` (100) |
|
|
| **Unlimited** | No cap (special limit value) | Agency tier social posts |
|
|
|
|
**Database:** `entitlement_features`
|
|
|
|
```php
|
|
// Feature fields
|
|
'code' // Unique identifier (e.g., 'social.accounts')
|
|
'name' // Display name
|
|
'type' // boolean, limit, unlimited
|
|
'reset_type' // none, monthly, rolling
|
|
'rolling_window_days' // For rolling reset (e.g., 30)
|
|
'parent_feature_id' // For global pools (see Storage Pool below)
|
|
```
|
|
|
|
#### Reset Types
|
|
|
|
| Reset Type | Behaviour |
|
|
|------------|-----------|
|
|
| **None** | Usage accumulates forever (e.g., account limits) |
|
|
| **Monthly** | Resets at billing cycle start |
|
|
| **Rolling** | Rolling window (e.g., last 30 days) |
|
|
|
|
#### Hierarchical Features (Global Pools)
|
|
|
|
Child features share a parent's limit pool. Used for storage allocation across services:
|
|
|
|
```
|
|
host.storage.total (1000 MB)
|
|
├── host.cdn (draws from parent pool)
|
|
├── bio.cdn (draws from parent pool)
|
|
└── social.cdn (draws from parent pool)
|
|
```
|
|
|
|
### WorkspacePackage
|
|
|
|
The pivot linking workspaces to packages. Tracks subscription state.
|
|
|
|
**Database:** `entitlement_workspace_packages`
|
|
|
|
```php
|
|
// Status constants
|
|
STATUS_ACTIVE // Package in effect
|
|
STATUS_SUSPENDED // Temporarily disabled (e.g., payment failure)
|
|
STATUS_CANCELLED // Marked for removal
|
|
STATUS_EXPIRED // Past expiry date
|
|
|
|
// Key fields
|
|
'starts_at' // When package becomes active
|
|
'expires_at' // When package ends
|
|
'billing_cycle_anchor' // For monthly reset calculations
|
|
'blesta_service_id' // External billing system reference
|
|
```
|
|
|
|
### Boost
|
|
|
|
Temporary or permanent additions to feature limits. Use cases:
|
|
- One-time credit top-ups
|
|
- Promotional extras
|
|
- Cycle-bound bonuses that expire at billing renewal
|
|
|
|
**Database:** `entitlement_boosts`
|
|
|
|
```php
|
|
// Boost types
|
|
BOOST_TYPE_ADD_LIMIT // Add to existing limit
|
|
BOOST_TYPE_ENABLE // Enable a boolean feature
|
|
BOOST_TYPE_UNLIMITED // Grant unlimited access
|
|
|
|
// Duration types
|
|
DURATION_CYCLE_BOUND // Expires at billing cycle end
|
|
DURATION_DURATION // Expires after set time
|
|
DURATION_PERMANENT // Never expires
|
|
|
|
// Key fields
|
|
'limit_value' // Amount to add
|
|
'consumed_quantity' // How much has been used
|
|
'status' // active, exhausted, expired, cancelled
|
|
```
|
|
|
|
---
|
|
|
|
## How Checking Works
|
|
|
|
### The `can()` Method
|
|
|
|
All access checks flow through `EntitlementService::can()`.
|
|
|
|
```php
|
|
public function can(Workspace $workspace, string $featureCode, int $quantity = 1): EntitlementResult
|
|
```
|
|
|
|
**Algorithm:**
|
|
|
|
```
|
|
1. Look up feature by code
|
|
2. If feature has parent, use parent's code for pool lookup
|
|
3. Sum limits from all active packages + boosts
|
|
4. If any source grants unlimited → return allowed (unlimited)
|
|
5. Get current usage (respecting reset type)
|
|
6. If usage + quantity > limit → deny
|
|
7. Otherwise → allow
|
|
```
|
|
|
|
**Example:**
|
|
|
|
```php
|
|
// Check before creating social account
|
|
$result = $workspace->can('social.accounts');
|
|
|
|
if ($result->isDenied()) {
|
|
throw new EntitlementException($result->getMessage());
|
|
}
|
|
|
|
// Proceed with creation...
|
|
|
|
// Record the usage
|
|
$workspace->recordUsage('social.accounts');
|
|
```
|
|
|
|
### EntitlementResult
|
|
|
|
The return value from `can()`. Provides all context needed for UI feedback.
|
|
|
|
```php
|
|
$result = $workspace->can('ai.credits', quantity: 10);
|
|
|
|
$result->isAllowed(); // bool
|
|
$result->isDenied(); // bool
|
|
$result->isUnlimited(); // bool
|
|
$result->getMessage(); // Denial reason
|
|
|
|
$result->limit; // Total limit (100)
|
|
$result->used; // Current usage (75)
|
|
$result->remaining; // Remaining (25)
|
|
$result->getUsagePercentage(); // 75.0
|
|
$result->isNearLimit(); // true if > 80%
|
|
```
|
|
|
|
### Caching
|
|
|
|
Limits and usage are cached for 5 minutes to avoid repeated database queries.
|
|
|
|
```php
|
|
// Cache keys
|
|
"entitlement:{workspace_id}:limit:{feature_code}"
|
|
"entitlement:{workspace_id}:usage:{feature_code}"
|
|
```
|
|
|
|
Cache is invalidated when:
|
|
- Package is provisioned, suspended, cancelled, or reactivated
|
|
- Boost is provisioned or expires
|
|
- Usage is recorded
|
|
|
|
---
|
|
|
|
## Usage Tracking
|
|
|
|
### Recording Usage
|
|
|
|
After a gated action succeeds, record the consumption:
|
|
|
|
```php
|
|
$workspace->recordUsage(
|
|
featureCode: 'ai.credits',
|
|
quantity: 10,
|
|
user: $user, // Optional: who triggered it
|
|
metadata: [ // Optional: context
|
|
'model' => 'claude-3',
|
|
'tokens' => 1500,
|
|
]
|
|
);
|
|
```
|
|
|
|
**Database:** `entitlement_usage_records`
|
|
|
|
### Usage Calculation
|
|
|
|
Usage is calculated based on the feature's reset type:
|
|
|
|
| Reset Type | Query |
|
|
|------------|-------|
|
|
| None | All records ever |
|
|
| Monthly | Records since billing cycle start |
|
|
| Rolling | Records in last N days |
|
|
|
|
```php
|
|
// Monthly: Get current cycle start from primary package
|
|
$cycleStart = $workspace->workspacePackages()
|
|
->whereHas('package', fn($q) => $q->where('is_base_package', true))
|
|
->first()
|
|
->getCurrentCycleStart();
|
|
|
|
UsageRecord::getTotalUsage($workspaceId, $featureCode, $cycleStart);
|
|
|
|
// Rolling: Last 30 days
|
|
UsageRecord::getRollingUsage($workspaceId, $featureCode, days: 30);
|
|
```
|
|
|
|
### Usage Summary
|
|
|
|
For dashboards, get all features with their current state:
|
|
|
|
```php
|
|
$summary = $workspace->getUsageSummary();
|
|
|
|
// Returns Collection grouped by category:
|
|
[
|
|
'social' => [
|
|
['code' => 'social.accounts', 'limit' => 5, 'used' => 3, ...],
|
|
['code' => 'social.posts.scheduled', 'limit' => 100, 'used' => 45, ...],
|
|
],
|
|
'ai' => [
|
|
['code' => 'ai.credits', 'limit' => 100, 'used' => 75, ...],
|
|
],
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
## Integration Points
|
|
|
|
### Commerce Integration
|
|
|
|
Subscriptions from Commerce automatically provision/revoke entitlement packages.
|
|
|
|
**Event Flow:**
|
|
|
|
```
|
|
SubscriptionCreated → ProvisionSocialHostSubscription listener
|
|
→ EntitlementService::provisionPackage()
|
|
|
|
SubscriptionCancelled → Revoke package (immediate or at period end)
|
|
|
|
SubscriptionRenewed → Update expires_at
|
|
→ Expire cycle-bound boosts
|
|
→ Reset monthly usage (via cycle anchor)
|
|
```
|
|
|
|
**Plan Changes:**
|
|
|
|
```php
|
|
$subscriptionService->changePlan(
|
|
$subscription,
|
|
$newPackage,
|
|
prorate: true, // Calculate credit/charge
|
|
immediate: true // Apply now vs. period end
|
|
);
|
|
```
|
|
|
|
### External Billing (Blesta)
|
|
|
|
The API supports external billing systems via webhook-style endpoints:
|
|
|
|
```
|
|
POST /api/v1/entitlements → Provision package
|
|
POST /api/v1/entitlements/{id}/suspend
|
|
POST /api/v1/entitlements/{id}/unsuspend
|
|
POST /api/v1/entitlements/{id}/cancel
|
|
POST /api/v1/entitlements/{id}/renew
|
|
GET /api/v1/entitlements/{id} → Get status
|
|
```
|
|
|
|
### Cross-Service API
|
|
|
|
External services (BioHost, etc.) check entitlements via API:
|
|
|
|
```
|
|
GET /api/v1/entitlements/check
|
|
?email=user@example.com
|
|
&feature=bio.pages
|
|
&quantity=1
|
|
|
|
POST /api/v1/entitlements/usage
|
|
{ email, feature, quantity, metadata }
|
|
|
|
GET /api/v1/entitlements/summary
|
|
GET /api/v1/entitlements/summary/{workspace}
|
|
```
|
|
|
|
---
|
|
|
|
## Feature Categories
|
|
|
|
Features are organised by category for display grouping:
|
|
|
|
| Category | Features |
|
|
|----------|----------|
|
|
| **tier** | `tier.apollo`, `tier.hades`, `tier.nyx`, `tier.stygian` |
|
|
| **service** | `host.social`, `host.bio`, `host.analytics`, `host.trust` |
|
|
| **social** | `social.accounts`, `social.posts.scheduled`, `social.workspaces` |
|
|
| **ai** | `ai.credits`, `ai.providers.claude`, `ai.providers.gemini` |
|
|
| **biolink** | `bio.pages`, `bio.shortlinks`, `bio.domains` |
|
|
| **analytics** | `analytics.sites`, `analytics.pageviews` |
|
|
| **storage** | `host.storage.total`, `host.cdn`, `bio.cdn`, `social.cdn` |
|
|
| **team** | `team.members` |
|
|
| **api** | `api.requests` |
|
|
| **support** | `support.mailboxes`, `support.agents`, `support.conversations` |
|
|
| **tools** | `tool.url_shortener`, `tool.qr_generator`, `tool.dns_lookup` |
|
|
|
|
---
|
|
|
|
## Audit Logging
|
|
|
|
All entitlement changes are logged for compliance and debugging.
|
|
|
|
**Database:** `entitlement_logs`
|
|
|
|
```php
|
|
// Log actions
|
|
ACTION_PACKAGE_PROVISIONED
|
|
ACTION_PACKAGE_SUSPENDED
|
|
ACTION_PACKAGE_CANCELLED
|
|
ACTION_PACKAGE_REACTIVATED
|
|
ACTION_PACKAGE_RENEWED
|
|
ACTION_PACKAGE_EXPIRED
|
|
ACTION_BOOST_PROVISIONED
|
|
ACTION_BOOST_CONSUMED
|
|
ACTION_BOOST_EXHAUSTED
|
|
ACTION_BOOST_EXPIRED
|
|
ACTION_BOOST_CANCELLED
|
|
ACTION_USAGE_RECORDED
|
|
ACTION_USAGE_DENIED
|
|
|
|
// Log sources
|
|
SOURCE_BLESTA // External billing
|
|
SOURCE_COMMERCE // Internal commerce
|
|
SOURCE_ADMIN // Manual admin action
|
|
SOURCE_SYSTEM // Automated (e.g., expiry)
|
|
SOURCE_API // API call
|
|
```
|
|
|
|
---
|
|
|
|
## Implementation Files
|
|
|
|
### Models
|
|
- `app/Mod/Tenant/Models/Feature.php`
|
|
- `app/Mod/Tenant/Models/Package.php`
|
|
- `app/Mod/Tenant/Models/WorkspacePackage.php`
|
|
- `app/Mod/Tenant/Models/Boost.php`
|
|
- `app/Mod/Tenant/Models/UsageRecord.php`
|
|
- `app/Mod/Tenant/Models/EntitlementLog.php`
|
|
|
|
### Services
|
|
- `app/Mod/Tenant/Services/EntitlementService.php` - Core logic
|
|
- `app/Mod/Tenant/Services/EntitlementResult.php` - Result DTO
|
|
|
|
### API
|
|
- `app/Mod/Api/Controllers/EntitlementApiController.php`
|
|
|
|
### Commerce Integration
|
|
- `app/Mod/Commerce/Listeners/ProvisionSocialHostSubscription.php`
|
|
- `app/Mod/Commerce/Services/SubscriptionService.php`
|
|
|
|
### Database
|
|
- `entitlement_features` - Feature definitions
|
|
- `entitlement_packages` - Package definitions
|
|
- `entitlement_package_features` - Package/feature pivot with limits
|
|
- `entitlement_workspace_packages` - Workspace subscriptions
|
|
- `entitlement_boosts` - Temporary additions
|
|
- `entitlement_usage_records` - Consumption tracking
|
|
- `entitlement_logs` - Audit trail
|
|
|
|
### Seeders
|
|
- `app/Mod/Tenant/Database/Seeders/FeatureSeeder.php`
|
|
|
|
### Tests
|
|
- `app/Mod/Tenant/Tests/Feature/EntitlementServiceTest.php`
|
|
- `app/Mod/Tenant/Tests/Feature/EntitlementApiTest.php`
|
|
|
|
---
|
|
|
|
## Usage Examples
|
|
|
|
### Basic Access Check
|
|
|
|
```php
|
|
// In controller or service
|
|
$result = $workspace->can('social.accounts');
|
|
|
|
if ($result->isDenied()) {
|
|
return back()->with('error', $result->getMessage());
|
|
}
|
|
|
|
// Perform action...
|
|
$workspace->recordUsage('social.accounts');
|
|
```
|
|
|
|
### With Quantity
|
|
|
|
```php
|
|
// Before bulk import
|
|
$result = $workspace->can('social.posts.scheduled', quantity: 50);
|
|
|
|
if ($result->isDenied()) {
|
|
return "Cannot schedule {$quantity} posts. " .
|
|
"Remaining: {$result->remaining}";
|
|
}
|
|
```
|
|
|
|
### Tier Check
|
|
|
|
```php
|
|
// Gate premium features
|
|
if ($workspace->isApollo()) {
|
|
// Show Apollo-tier features
|
|
}
|
|
|
|
// Or directly
|
|
if ($workspace->can('tier.apollo')->isAllowed()) {
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### Usage Dashboard Data
|
|
|
|
```php
|
|
// For billing/usage page
|
|
$summary = $workspace->getUsageSummary();
|
|
$packages = $entitlements->getActivePackages($workspace);
|
|
$boosts = $entitlements->getActiveBoosts($workspace);
|
|
```
|
|
|
|
---
|
|
|
|
## Version History
|
|
|
|
| Version | Date | Changes |
|
|
|---------|------|---------|
|
|
| 1.0 | 2026-01-15 | Initial RFC |
|