466 lines
9.9 KiB
Markdown
466 lines
9.9 KiB
Markdown
|
|
---
|
||
|
|
title: Entitlements
|
||
|
|
description: Guide to the entitlement system for feature access and usage limits
|
||
|
|
updated: 2026-01-29
|
||
|
|
---
|
||
|
|
|
||
|
|
# Entitlement System
|
||
|
|
|
||
|
|
The entitlement system controls feature access, usage limits, and billing integration for workspaces and namespaces.
|
||
|
|
|
||
|
|
## Quick Start
|
||
|
|
|
||
|
|
### Check Feature Access
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Core\Tenant\Services\EntitlementService;
|
||
|
|
|
||
|
|
$entitlements = app(EntitlementService::class);
|
||
|
|
|
||
|
|
// Check if workspace can use a feature
|
||
|
|
$result = $entitlements->can($workspace, 'ai.credits', quantity: 5);
|
||
|
|
|
||
|
|
if ($result->isAllowed()) {
|
||
|
|
// Perform action
|
||
|
|
$entitlements->recordUsage($workspace, 'ai.credits', quantity: 5, user: $user);
|
||
|
|
} else {
|
||
|
|
// Handle denial
|
||
|
|
return response()->json([
|
||
|
|
'error' => $result->reason,
|
||
|
|
'limit' => $result->limit,
|
||
|
|
'used' => $result->used,
|
||
|
|
], 403);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Via Workspace Model
|
||
|
|
|
||
|
|
```php
|
||
|
|
$result = $workspace->can('social.accounts');
|
||
|
|
|
||
|
|
if ($result->isAllowed()) {
|
||
|
|
$workspace->recordUsage('social.accounts');
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Concepts
|
||
|
|
|
||
|
|
### Features
|
||
|
|
|
||
|
|
Features are defined in the `entitlement_features` table:
|
||
|
|
|
||
|
|
| Field | Description |
|
||
|
|
|-------|-------------|
|
||
|
|
| `code` | Unique identifier (e.g., `ai.credits`, `social.accounts`) |
|
||
|
|
| `type` | `boolean`, `limit`, or `unlimited` |
|
||
|
|
| `reset_type` | `none`, `monthly`, or `rolling` |
|
||
|
|
| `rolling_window_days` | Days for rolling window |
|
||
|
|
| `parent_feature_id` | For hierarchical features (pool sharing) |
|
||
|
|
|
||
|
|
**Feature Types:**
|
||
|
|
|
||
|
|
| Type | Behaviour |
|
||
|
|
|------|-----------|
|
||
|
|
| `boolean` | Binary on/off access |
|
||
|
|
| `limit` | Numeric limit with usage tracking |
|
||
|
|
| `unlimited` | Feature enabled with no limits |
|
||
|
|
|
||
|
|
**Reset Types:**
|
||
|
|
|
||
|
|
| Type | Behaviour |
|
||
|
|
|------|-----------|
|
||
|
|
| `none` | Usage accumulates forever |
|
||
|
|
| `monthly` | Resets at billing cycle start |
|
||
|
|
| `rolling` | Rolling window (e.g., last 30 days) |
|
||
|
|
|
||
|
|
### Packages
|
||
|
|
|
||
|
|
Packages bundle features with specific limits:
|
||
|
|
|
||
|
|
```php
|
||
|
|
// Example package definition
|
||
|
|
$package = Package::create([
|
||
|
|
'code' => 'creator',
|
||
|
|
'name' => 'Creator Plan',
|
||
|
|
'is_base_package' => true,
|
||
|
|
'monthly_price' => 19.99,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Attach features
|
||
|
|
$package->features()->attach($aiCreditsFeature->id, ['limit_value' => 100]);
|
||
|
|
$package->features()->attach($socialAccountsFeature->id, ['limit_value' => 5]);
|
||
|
|
```
|
||
|
|
|
||
|
|
### Workspace Packages
|
||
|
|
|
||
|
|
Packages are provisioned to workspaces:
|
||
|
|
|
||
|
|
```php
|
||
|
|
$workspacePackage = $entitlements->provisionPackage($workspace, 'creator', [
|
||
|
|
'source' => EntitlementLog::SOURCE_BLESTA,
|
||
|
|
'billing_cycle_anchor' => now(),
|
||
|
|
'blesta_service_id' => 'srv_12345',
|
||
|
|
]);
|
||
|
|
```
|
||
|
|
|
||
|
|
**Statuses:**
|
||
|
|
- `active` - Package is in use
|
||
|
|
- `suspended` - Temporarily disabled (e.g., payment failed)
|
||
|
|
- `cancelled` - Permanently ended
|
||
|
|
- `expired` - Past expiry date
|
||
|
|
|
||
|
|
### Boosts
|
||
|
|
|
||
|
|
Boosts provide temporary limit increases:
|
||
|
|
|
||
|
|
```php
|
||
|
|
$boost = $entitlements->provisionBoost($workspace, 'ai.credits', [
|
||
|
|
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
|
||
|
|
'limit_value' => 50,
|
||
|
|
'duration_type' => Boost::DURATION_CYCLE_BOUND,
|
||
|
|
]);
|
||
|
|
```
|
||
|
|
|
||
|
|
**Boost Types:**
|
||
|
|
|
||
|
|
| Type | Effect |
|
||
|
|
|------|--------|
|
||
|
|
| `add_limit` | Adds to package limit |
|
||
|
|
| `enable` | Enables boolean feature |
|
||
|
|
| `unlimited` | Makes feature unlimited |
|
||
|
|
|
||
|
|
**Duration Types:**
|
||
|
|
|
||
|
|
| Type | Expiry |
|
||
|
|
|------|--------|
|
||
|
|
| `cycle_bound` | Expires at billing cycle end |
|
||
|
|
| `duration` | Expires after set `expires_at` |
|
||
|
|
| `permanent` | Never expires |
|
||
|
|
|
||
|
|
## API Reference
|
||
|
|
|
||
|
|
### EntitlementService
|
||
|
|
|
||
|
|
#### can()
|
||
|
|
|
||
|
|
Check if a workspace can use a feature:
|
||
|
|
|
||
|
|
```php
|
||
|
|
public function can(
|
||
|
|
Workspace $workspace,
|
||
|
|
string $featureCode,
|
||
|
|
int $quantity = 1
|
||
|
|
): EntitlementResult
|
||
|
|
```
|
||
|
|
|
||
|
|
**Returns `EntitlementResult` with:**
|
||
|
|
- `isAllowed(): bool`
|
||
|
|
- `isDenied(): bool`
|
||
|
|
- `isUnlimited(): bool`
|
||
|
|
- `limit: ?int`
|
||
|
|
- `used: int`
|
||
|
|
- `remaining: ?int`
|
||
|
|
- `reason: ?string`
|
||
|
|
- `featureCode: string`
|
||
|
|
- `getUsagePercentage(): ?float`
|
||
|
|
- `isNearLimit(): bool` (>80%)
|
||
|
|
- `isAtLimit(): bool` (100%)
|
||
|
|
|
||
|
|
#### canForNamespace()
|
||
|
|
|
||
|
|
Check entitlement for a namespace with cascade:
|
||
|
|
|
||
|
|
```php
|
||
|
|
public function canForNamespace(
|
||
|
|
Namespace_ $namespace,
|
||
|
|
string $featureCode,
|
||
|
|
int $quantity = 1
|
||
|
|
): EntitlementResult
|
||
|
|
```
|
||
|
|
|
||
|
|
Cascade order:
|
||
|
|
1. Namespace-level packages
|
||
|
|
2. Workspace pool (if `namespace->workspace_id` set)
|
||
|
|
3. User tier (if namespace owned by user)
|
||
|
|
|
||
|
|
#### recordUsage()
|
||
|
|
|
||
|
|
Record feature usage:
|
||
|
|
|
||
|
|
```php
|
||
|
|
public function recordUsage(
|
||
|
|
Workspace $workspace,
|
||
|
|
string $featureCode,
|
||
|
|
int $quantity = 1,
|
||
|
|
?User $user = null,
|
||
|
|
?array $metadata = null
|
||
|
|
): UsageRecord
|
||
|
|
```
|
||
|
|
|
||
|
|
#### provisionPackage()
|
||
|
|
|
||
|
|
Assign a package to a workspace:
|
||
|
|
|
||
|
|
```php
|
||
|
|
public function provisionPackage(
|
||
|
|
Workspace $workspace,
|
||
|
|
string $packageCode,
|
||
|
|
array $options = []
|
||
|
|
): WorkspacePackage
|
||
|
|
```
|
||
|
|
|
||
|
|
**Options:**
|
||
|
|
- `source` - `system`, `blesta`, `admin`, `user`
|
||
|
|
- `billing_cycle_anchor` - Start of billing cycle
|
||
|
|
- `expires_at` - Package expiry date
|
||
|
|
- `blesta_service_id` - External billing reference
|
||
|
|
- `metadata` - Additional data
|
||
|
|
|
||
|
|
#### provisionBoost()
|
||
|
|
|
||
|
|
Add a temporary boost:
|
||
|
|
|
||
|
|
```php
|
||
|
|
public function provisionBoost(
|
||
|
|
Workspace $workspace,
|
||
|
|
string $featureCode,
|
||
|
|
array $options = []
|
||
|
|
): Boost
|
||
|
|
```
|
||
|
|
|
||
|
|
**Options:**
|
||
|
|
- `boost_type` - `add_limit`, `enable`, `unlimited`
|
||
|
|
- `duration_type` - `cycle_bound`, `duration`, `permanent`
|
||
|
|
- `limit_value` - Amount to add (for `add_limit`)
|
||
|
|
- `expires_at` - Expiry date (for `duration`)
|
||
|
|
|
||
|
|
#### suspendWorkspace() / reactivateWorkspace()
|
||
|
|
|
||
|
|
Manage workspace package status:
|
||
|
|
|
||
|
|
```php
|
||
|
|
$entitlements->suspendWorkspace($workspace, 'blesta');
|
||
|
|
$entitlements->reactivateWorkspace($workspace, 'admin');
|
||
|
|
```
|
||
|
|
|
||
|
|
#### getUsageSummary()
|
||
|
|
|
||
|
|
Get all feature usage for a workspace:
|
||
|
|
|
||
|
|
```php
|
||
|
|
$summary = $entitlements->getUsageSummary($workspace);
|
||
|
|
|
||
|
|
// Returns Collection grouped by category:
|
||
|
|
// [
|
||
|
|
// 'ai' => [
|
||
|
|
// ['code' => 'ai.credits', 'limit' => 100, 'used' => 50, ...],
|
||
|
|
// ],
|
||
|
|
// 'social' => [
|
||
|
|
// ['code' => 'social.accounts', 'limit' => 5, 'used' => 3, ...],
|
||
|
|
// ],
|
||
|
|
// ]
|
||
|
|
```
|
||
|
|
|
||
|
|
## Namespace-Level Entitlements
|
||
|
|
|
||
|
|
For products that operate at namespace level:
|
||
|
|
|
||
|
|
```php
|
||
|
|
$result = $entitlements->canForNamespace($namespace, 'bio.pages');
|
||
|
|
|
||
|
|
if ($result->isAllowed()) {
|
||
|
|
$entitlements->recordNamespaceUsage($namespace, 'bio.pages', user: $user);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Provision namespace-specific package
|
||
|
|
$entitlements->provisionNamespacePackage($namespace, 'bio-pro');
|
||
|
|
```
|
||
|
|
|
||
|
|
## Usage Alerts
|
||
|
|
|
||
|
|
The `UsageAlertService` monitors usage and sends notifications:
|
||
|
|
|
||
|
|
```php
|
||
|
|
// Check single workspace
|
||
|
|
$alerts = app(UsageAlertService::class)->checkWorkspace($workspace);
|
||
|
|
|
||
|
|
// Check all workspaces (scheduled command)
|
||
|
|
php artisan tenant:check-usage-alerts
|
||
|
|
```
|
||
|
|
|
||
|
|
**Alert Thresholds:**
|
||
|
|
- 80% - Warning
|
||
|
|
- 90% - Critical
|
||
|
|
- 100% - Limit reached
|
||
|
|
|
||
|
|
**Notification Channels:**
|
||
|
|
- Email to workspace owner
|
||
|
|
- Webhook events (`limit_warning`, `limit_reached`)
|
||
|
|
|
||
|
|
## Billing Integration
|
||
|
|
|
||
|
|
### Blesta API
|
||
|
|
|
||
|
|
External endpoints for billing system integration:
|
||
|
|
|
||
|
|
```
|
||
|
|
POST /api/entitlements - Provision package
|
||
|
|
POST /api/entitlements/{id}/suspend - Suspend
|
||
|
|
POST /api/entitlements/{id}/unsuspend - Reactivate
|
||
|
|
POST /api/entitlements/{id}/cancel - Cancel
|
||
|
|
POST /api/entitlements/{id}/renew - Renew
|
||
|
|
GET /api/entitlements/{id} - Get details
|
||
|
|
```
|
||
|
|
|
||
|
|
### Cross-App API
|
||
|
|
|
||
|
|
For other Host UK services to check entitlements:
|
||
|
|
|
||
|
|
```
|
||
|
|
GET /api/entitlements/check - Check feature access
|
||
|
|
POST /api/entitlements/usage - Record usage
|
||
|
|
GET /api/entitlements/summary - Get usage summary
|
||
|
|
```
|
||
|
|
|
||
|
|
## Webhooks
|
||
|
|
|
||
|
|
Subscribe to entitlement events:
|
||
|
|
|
||
|
|
```php
|
||
|
|
$webhookService = app(EntitlementWebhookService::class);
|
||
|
|
|
||
|
|
$webhook = $webhookService->register($workspace,
|
||
|
|
name: 'Usage Alerts',
|
||
|
|
url: 'https://api.example.com/hooks/entitlements',
|
||
|
|
events: ['limit_warning', 'limit_reached']
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
**Available Events:**
|
||
|
|
- `limit_warning` - 80%/90% threshold
|
||
|
|
- `limit_reached` - 100% threshold
|
||
|
|
- `package_changed` - Package add/change/remove
|
||
|
|
- `boost_activated` - New boost
|
||
|
|
- `boost_expired` - Boost expired
|
||
|
|
|
||
|
|
**Payload Format:**
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"event": "limit_warning",
|
||
|
|
"data": {
|
||
|
|
"workspace_id": 123,
|
||
|
|
"feature_code": "ai.credits",
|
||
|
|
"threshold": 80,
|
||
|
|
"used": 80,
|
||
|
|
"limit": 100
|
||
|
|
},
|
||
|
|
"timestamp": "2026-01-29T12:00:00Z"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Verification:**
|
||
|
|
|
||
|
|
```php
|
||
|
|
$isValid = $webhookService->verifySignature(
|
||
|
|
$payload,
|
||
|
|
$request->header('X-Signature'),
|
||
|
|
$webhook->secret
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
## Best Practices
|
||
|
|
|
||
|
|
### Check Before Action
|
||
|
|
|
||
|
|
Always check entitlements before performing the action:
|
||
|
|
|
||
|
|
```php
|
||
|
|
// Bad: Check after action
|
||
|
|
$account = SocialAccount::create([...]);
|
||
|
|
if (!$workspace->can('social.accounts')->isAllowed()) {
|
||
|
|
$account->delete();
|
||
|
|
throw new \Exception('Limit exceeded');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Good: Check before action
|
||
|
|
$result = $workspace->can('social.accounts');
|
||
|
|
if ($result->isDenied()) {
|
||
|
|
throw new EntitlementException($result->reason);
|
||
|
|
}
|
||
|
|
$account = SocialAccount::create([...]);
|
||
|
|
$workspace->recordUsage('social.accounts');
|
||
|
|
```
|
||
|
|
|
||
|
|
### Use Transactions
|
||
|
|
|
||
|
|
For atomic check-and-record:
|
||
|
|
|
||
|
|
```php
|
||
|
|
DB::transaction(function () use ($workspace, $user) {
|
||
|
|
$result = $workspace->can('ai.credits', 10);
|
||
|
|
|
||
|
|
if ($result->isDenied()) {
|
||
|
|
throw new EntitlementException($result->reason);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Perform AI generation
|
||
|
|
$output = $aiService->generate($prompt);
|
||
|
|
|
||
|
|
// Record usage
|
||
|
|
$workspace->recordUsage('ai.credits', 10, $user, [
|
||
|
|
'model' => 'claude-3',
|
||
|
|
'tokens' => 1500,
|
||
|
|
]);
|
||
|
|
|
||
|
|
return $output;
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Cache Considerations
|
||
|
|
|
||
|
|
Entitlement checks are cached for 5 minutes. For real-time accuracy:
|
||
|
|
|
||
|
|
```php
|
||
|
|
// Force cache refresh
|
||
|
|
$entitlements->invalidateCache($workspace);
|
||
|
|
$result = $entitlements->can($workspace, 'feature');
|
||
|
|
```
|
||
|
|
|
||
|
|
### Feature Code Conventions
|
||
|
|
|
||
|
|
Use dot notation for feature codes:
|
||
|
|
|
||
|
|
```
|
||
|
|
service.feature
|
||
|
|
service.feature.subfeature
|
||
|
|
```
|
||
|
|
|
||
|
|
Examples:
|
||
|
|
- `ai.credits`
|
||
|
|
- `social.accounts`
|
||
|
|
- `social.posts.scheduled`
|
||
|
|
- `bio.pages`
|
||
|
|
- `analytics.websites`
|
||
|
|
|
||
|
|
### Hierarchical Features
|
||
|
|
|
||
|
|
For shared pools, use parent features:
|
||
|
|
|
||
|
|
```php
|
||
|
|
// Parent feature (pool)
|
||
|
|
$aiCredits = Feature::create([
|
||
|
|
'code' => 'ai.credits',
|
||
|
|
'type' => Feature::TYPE_LIMIT,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Child feature (uses parent pool)
|
||
|
|
$aiGeneration = Feature::create([
|
||
|
|
'code' => 'ai.generation',
|
||
|
|
'parent_feature_id' => $aiCredits->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Both check against ai.credits pool
|
||
|
|
$workspace->can('ai.generation'); // Uses ai.credits limit
|
||
|
|
```
|