Compare commits
1 commit
dev
...
feat/entit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2601392b8d |
7 changed files with 506 additions and 13 deletions
|
|
@ -7,7 +7,23 @@ namespace Core\Tenant\Exceptions;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exception thrown when an entitlement check fails.
|
* Base exception for entitlement-related errors.
|
||||||
|
*
|
||||||
|
* This is the root of the entitlement exception hierarchy. Consumers can catch
|
||||||
|
* this class to handle all entitlement errors, or catch specific subtypes for
|
||||||
|
* fine-grained error handling.
|
||||||
|
*
|
||||||
|
* Exception hierarchy:
|
||||||
|
* - EntitlementException (base)
|
||||||
|
* - LimitExceededException -- feature usage limit has been exceeded
|
||||||
|
* - PackageNotFoundException -- referenced package code does not exist
|
||||||
|
* - FeatureNotFoundException -- referenced feature code does not exist
|
||||||
|
* - PackageSuspendedException -- workspace packages are suspended
|
||||||
|
*
|
||||||
|
* @see LimitExceededException
|
||||||
|
* @see PackageNotFoundException
|
||||||
|
* @see FeatureNotFoundException
|
||||||
|
* @see PackageSuspendedException
|
||||||
*/
|
*/
|
||||||
class EntitlementException extends Exception
|
class EntitlementException extends Exception
|
||||||
{
|
{
|
||||||
|
|
|
||||||
53
Exceptions/FeatureNotFoundException.php
Normal file
53
Exceptions/FeatureNotFoundException.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Tenant\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when a referenced feature does not exist.
|
||||||
|
*
|
||||||
|
* This exception is thrown when an entitlement check references a feature
|
||||||
|
* code that is not defined in the features table.
|
||||||
|
*
|
||||||
|
* @see EntitlementException Base exception class
|
||||||
|
*/
|
||||||
|
class FeatureNotFoundException extends EntitlementException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message = 'The requested feature was not found.',
|
||||||
|
?string $featureCode = null,
|
||||||
|
int $code = 404,
|
||||||
|
?\Throwable $previous = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message, $featureCode, $code, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create exception for a specific feature code.
|
||||||
|
*/
|
||||||
|
public static function forCode(string $featureCode): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
message: "Feature '{$featureCode}' does not exist.",
|
||||||
|
featureCode: $featureCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the exception as an HTTP response.
|
||||||
|
*/
|
||||||
|
public function render($request)
|
||||||
|
{
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $this->getMessage(),
|
||||||
|
'error' => 'feature_not_found',
|
||||||
|
'feature_code' => $this->featureCode,
|
||||||
|
], $this->getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back()
|
||||||
|
->with('error', $this->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
77
Exceptions/LimitExceededException.php
Normal file
77
Exceptions/LimitExceededException.php
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Tenant\Exceptions;
|
||||||
|
|
||||||
|
use Core\Tenant\Services\EntitlementResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when a feature usage limit has been exceeded.
|
||||||
|
*
|
||||||
|
* This exception indicates that the workspace or namespace has consumed
|
||||||
|
* all available capacity for a limit-based feature (e.g., pages, API calls).
|
||||||
|
*
|
||||||
|
* @see EntitlementException Base exception class
|
||||||
|
*/
|
||||||
|
class LimitExceededException extends EntitlementException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message = 'You have reached your limit for this feature.',
|
||||||
|
?string $featureCode = null,
|
||||||
|
public readonly ?int $limit = null,
|
||||||
|
public readonly ?int $used = null,
|
||||||
|
int $code = 403,
|
||||||
|
?\Throwable $previous = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message, $featureCode, $code, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from an EntitlementResult.
|
||||||
|
*/
|
||||||
|
public static function fromResult(EntitlementResult $result): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
message: $result->getMessage() ?? 'You have reached your limit for this feature.',
|
||||||
|
featureCode: $result->featureCode,
|
||||||
|
limit: $result->limit,
|
||||||
|
used: $result->used,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the feature limit that was exceeded.
|
||||||
|
*/
|
||||||
|
public function getLimit(): ?int
|
||||||
|
{
|
||||||
|
return $this->limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current usage count.
|
||||||
|
*/
|
||||||
|
public function getUsed(): ?int
|
||||||
|
{
|
||||||
|
return $this->used;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the exception as an HTTP response.
|
||||||
|
*/
|
||||||
|
public function render($request)
|
||||||
|
{
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $this->getMessage(),
|
||||||
|
'error' => 'limit_exceeded',
|
||||||
|
'feature_code' => $this->featureCode,
|
||||||
|
'limit' => $this->limit,
|
||||||
|
'used' => $this->used,
|
||||||
|
], $this->getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back()
|
||||||
|
->with('error', $this->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
61
Exceptions/PackageNotFoundException.php
Normal file
61
Exceptions/PackageNotFoundException.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Tenant\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when a referenced package does not exist.
|
||||||
|
*
|
||||||
|
* This exception is thrown during provisioning when the requested package
|
||||||
|
* code cannot be found in the packages table.
|
||||||
|
*
|
||||||
|
* @see EntitlementException Base exception class
|
||||||
|
*/
|
||||||
|
class PackageNotFoundException extends EntitlementException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message = 'The requested package was not found.',
|
||||||
|
public readonly ?string $packageCode = null,
|
||||||
|
int $code = 404,
|
||||||
|
?\Throwable $previous = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message, featureCode: null, code: $code, previous: $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create exception for a specific package code.
|
||||||
|
*/
|
||||||
|
public static function forCode(string $packageCode): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
message: "Package '{$packageCode}' not found.",
|
||||||
|
packageCode: $packageCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the package code that was not found.
|
||||||
|
*/
|
||||||
|
public function getPackageCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->packageCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the exception as an HTTP response.
|
||||||
|
*/
|
||||||
|
public function render($request)
|
||||||
|
{
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $this->getMessage(),
|
||||||
|
'error' => 'package_not_found',
|
||||||
|
'package_code' => $this->packageCode,
|
||||||
|
], $this->getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back()
|
||||||
|
->with('error', $this->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
63
Exceptions/PackageSuspendedException.php
Normal file
63
Exceptions/PackageSuspendedException.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Tenant\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when an operation targets a suspended package.
|
||||||
|
*
|
||||||
|
* This exception indicates that the workspace's packages have been suspended
|
||||||
|
* (e.g., for non-payment or policy violation), preventing feature access.
|
||||||
|
*
|
||||||
|
* @see EntitlementException Base exception class
|
||||||
|
*/
|
||||||
|
class PackageSuspendedException extends EntitlementException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message = 'Your package has been suspended.',
|
||||||
|
?string $featureCode = null,
|
||||||
|
public readonly ?int $workspaceId = null,
|
||||||
|
int $code = 403,
|
||||||
|
?\Throwable $previous = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message, $featureCode, $code, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create exception for a specific workspace.
|
||||||
|
*/
|
||||||
|
public static function forWorkspace(int $workspaceId, ?string $featureCode = null): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
message: 'Your package has been suspended. Please contact support or update your billing details.',
|
||||||
|
featureCode: $featureCode,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the workspace ID whose packages are suspended.
|
||||||
|
*/
|
||||||
|
public function getWorkspaceId(): ?int
|
||||||
|
{
|
||||||
|
return $this->workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the exception as an HTTP response.
|
||||||
|
*/
|
||||||
|
public function render($request)
|
||||||
|
{
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $this->getMessage(),
|
||||||
|
'error' => 'package_suspended',
|
||||||
|
'feature_code' => $this->featureCode,
|
||||||
|
], $this->getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back()
|
||||||
|
->with('error', $this->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,10 @@ declare(strict_types=1);
|
||||||
namespace Core\Tenant\Services;
|
namespace Core\Tenant\Services;
|
||||||
|
|
||||||
use Core\Tenant\Events\EntitlementCacheInvalidated;
|
use Core\Tenant\Events\EntitlementCacheInvalidated;
|
||||||
|
use Core\Tenant\Exceptions\EntitlementException;
|
||||||
|
use Core\Tenant\Exceptions\FeatureNotFoundException;
|
||||||
|
use Core\Tenant\Exceptions\LimitExceededException;
|
||||||
|
use Core\Tenant\Exceptions\PackageNotFoundException;
|
||||||
use Core\Tenant\Models\Boost;
|
use Core\Tenant\Models\Boost;
|
||||||
use Core\Tenant\Models\EntitlementLog;
|
use Core\Tenant\Models\EntitlementLog;
|
||||||
use Core\Tenant\Models\Feature;
|
use Core\Tenant\Models\Feature;
|
||||||
|
|
@ -16,7 +20,6 @@ use Core\Tenant\Models\User;
|
||||||
use Core\Tenant\Models\Workspace;
|
use Core\Tenant\Models\Workspace;
|
||||||
use Core\Tenant\Models\WorkspacePackage;
|
use Core\Tenant\Models\WorkspacePackage;
|
||||||
use Illuminate\Cache\TaggableStore;
|
use Illuminate\Cache\TaggableStore;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
|
@ -281,6 +284,63 @@ class EntitlementService
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a workspace can use a feature, throwing on failure.
|
||||||
|
*
|
||||||
|
* This is the throwing variant of `can()`. It returns the EntitlementResult
|
||||||
|
* on success but throws a specific exception subtype on failure, allowing
|
||||||
|
* consumers to distinguish failure modes via catch blocks.
|
||||||
|
*
|
||||||
|
* ## Example Usage
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* use Core\Tenant\Exceptions\LimitExceededException;
|
||||||
|
* use Core\Tenant\Exceptions\FeatureNotFoundException;
|
||||||
|
*
|
||||||
|
* try {
|
||||||
|
* $result = $entitlementService->canOrFail($workspace, 'pages');
|
||||||
|
* $page = $workspace->pages()->create($data);
|
||||||
|
* $entitlementService->recordUsage($workspace, 'pages', 1, $user);
|
||||||
|
* } catch (LimitExceededException $e) {
|
||||||
|
* return response()->json(['error' => $e->getMessage(), 'limit' => $e->getLimit()], 403);
|
||||||
|
* } catch (FeatureNotFoundException $e) {
|
||||||
|
* return response()->json(['error' => 'Unknown feature'], 404);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param Workspace $workspace The workspace to check entitlements for
|
||||||
|
* @param string $featureCode The feature code to check
|
||||||
|
* @param int $quantity The quantity being requested (default: 1)
|
||||||
|
* @return EntitlementResult The successful entitlement result
|
||||||
|
*
|
||||||
|
* @throws FeatureNotFoundException If the feature code does not exist
|
||||||
|
* @throws LimitExceededException If the feature limit has been exceeded
|
||||||
|
* @throws EntitlementException If the feature is not included in any package
|
||||||
|
*/
|
||||||
|
public function canOrFail(Workspace $workspace, string $featureCode, int $quantity = 1): EntitlementResult
|
||||||
|
{
|
||||||
|
$feature = $this->getFeature($featureCode);
|
||||||
|
|
||||||
|
if (! $feature) {
|
||||||
|
throw FeatureNotFoundException::forCode($featureCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->can($workspace, $featureCode, $quantity);
|
||||||
|
|
||||||
|
if ($result->isDenied()) {
|
||||||
|
if ($result->limit !== null && $result->used !== null) {
|
||||||
|
throw LimitExceededException::fromResult($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new EntitlementException(
|
||||||
|
message: $result->getMessage() ?? 'Entitlement check failed.',
|
||||||
|
featureCode: $featureCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a namespace can use a feature.
|
* Check if a namespace can use a feature.
|
||||||
*
|
*
|
||||||
|
|
@ -398,6 +458,61 @@ class EntitlementService
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a namespace can use a feature, throwing on failure.
|
||||||
|
*
|
||||||
|
* This is the throwing variant of `canForNamespace()`. It returns the
|
||||||
|
* EntitlementResult on success but throws a specific exception subtype
|
||||||
|
* on failure.
|
||||||
|
*
|
||||||
|
* ## Example Usage
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* use Core\Tenant\Exceptions\LimitExceededException;
|
||||||
|
* use Core\Tenant\Exceptions\FeatureNotFoundException;
|
||||||
|
*
|
||||||
|
* try {
|
||||||
|
* $result = $entitlementService->canForNamespaceOrFail($namespace, 'links');
|
||||||
|
* $link = $namespace->links()->create($data);
|
||||||
|
* $entitlementService->recordNamespaceUsage($namespace, 'links');
|
||||||
|
* } catch (LimitExceededException $e) {
|
||||||
|
* return response()->json(['error' => $e->getMessage()], 403);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param Namespace_ $namespace The namespace to check entitlements for
|
||||||
|
* @param string $featureCode The feature code to check
|
||||||
|
* @param int $quantity The quantity being requested (default: 1)
|
||||||
|
* @return EntitlementResult The successful entitlement result
|
||||||
|
*
|
||||||
|
* @throws FeatureNotFoundException If the feature code does not exist
|
||||||
|
* @throws LimitExceededException If the feature limit has been exceeded
|
||||||
|
* @throws EntitlementException If the feature is not included in any package
|
||||||
|
*/
|
||||||
|
public function canForNamespaceOrFail(Namespace_ $namespace, string $featureCode, int $quantity = 1): EntitlementResult
|
||||||
|
{
|
||||||
|
$feature = $this->getFeature($featureCode);
|
||||||
|
|
||||||
|
if (! $feature) {
|
||||||
|
throw FeatureNotFoundException::forCode($featureCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->canForNamespace($namespace, $featureCode, $quantity);
|
||||||
|
|
||||||
|
if ($result->isDenied()) {
|
||||||
|
if ($result->limit !== null && $result->used !== null) {
|
||||||
|
throw LimitExceededException::fromResult($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new EntitlementException(
|
||||||
|
message: $result->getMessage() ?? 'Entitlement check failed.',
|
||||||
|
featureCode: $featureCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record usage of a feature for a namespace.
|
* Record usage of a feature for a namespace.
|
||||||
*
|
*
|
||||||
|
|
@ -590,14 +705,18 @@ class EntitlementService
|
||||||
* - `metadata`: Additional data to store with the package
|
* - `metadata`: Additional data to store with the package
|
||||||
* @return WorkspacePackage The created workspace package record
|
* @return WorkspacePackage The created workspace package record
|
||||||
*
|
*
|
||||||
* @throws ModelNotFoundException If the package code does not exist
|
* @throws PackageNotFoundException If the package code does not exist
|
||||||
*/
|
*/
|
||||||
public function provisionPackage(
|
public function provisionPackage(
|
||||||
Workspace $workspace,
|
Workspace $workspace,
|
||||||
string $packageCode,
|
string $packageCode,
|
||||||
array $options = []
|
array $options = []
|
||||||
): WorkspacePackage {
|
): WorkspacePackage {
|
||||||
$package = Package::where('code', $packageCode)->firstOrFail();
|
$package = Package::where('code', $packageCode)->first();
|
||||||
|
|
||||||
|
if (! $package) {
|
||||||
|
throw PackageNotFoundException::forCode($packageCode);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is a base package and workspace already has one
|
// Check if this is a base package and workspace already has one
|
||||||
if ($package->is_base_package) {
|
if ($package->is_base_package) {
|
||||||
|
|
@ -1736,7 +1855,7 @@ class EntitlementService
|
||||||
* - `metadata`: Additional data to store with the package
|
* - `metadata`: Additional data to store with the package
|
||||||
* @return NamespacePackage The created namespace package record
|
* @return NamespacePackage The created namespace package record
|
||||||
*
|
*
|
||||||
* @throws ModelNotFoundException If the package code does not exist
|
* @throws PackageNotFoundException If the package code does not exist
|
||||||
*
|
*
|
||||||
* @see self::provisionPackage() For workspace-level package provisioning
|
* @see self::provisionPackage() For workspace-level package provisioning
|
||||||
*/
|
*/
|
||||||
|
|
@ -1745,7 +1864,11 @@ class EntitlementService
|
||||||
string $packageCode,
|
string $packageCode,
|
||||||
array $options = []
|
array $options = []
|
||||||
): NamespacePackage {
|
): NamespacePackage {
|
||||||
$package = Package::where('code', $packageCode)->firstOrFail();
|
$package = Package::where('code', $packageCode)->first();
|
||||||
|
|
||||||
|
if (! $package) {
|
||||||
|
throw PackageNotFoundException::forCode($packageCode);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is a base package and namespace already has one
|
// Check if this is a base package and namespace already has one
|
||||||
if ($package->is_base_package) {
|
if ($package->is_base_package) {
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,20 @@ public function can(
|
||||||
- `isNearLimit(): bool` (>80%)
|
- `isNearLimit(): bool` (>80%)
|
||||||
- `isAtLimit(): bool` (100%)
|
- `isAtLimit(): bool` (100%)
|
||||||
|
|
||||||
|
#### canOrFail()
|
||||||
|
|
||||||
|
Check if a workspace can use a feature, throwing on failure:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function canOrFail(
|
||||||
|
Workspace $workspace,
|
||||||
|
string $featureCode,
|
||||||
|
int $quantity = 1
|
||||||
|
): EntitlementResult
|
||||||
|
|
||||||
|
// Throws: FeatureNotFoundException, LimitExceededException, EntitlementException
|
||||||
|
```
|
||||||
|
|
||||||
#### canForNamespace()
|
#### canForNamespace()
|
||||||
|
|
||||||
Check entitlement for a namespace with cascade:
|
Check entitlement for a namespace with cascade:
|
||||||
|
|
@ -183,6 +197,20 @@ Cascade order:
|
||||||
2. Workspace pool (if `namespace->workspace_id` set)
|
2. Workspace pool (if `namespace->workspace_id` set)
|
||||||
3. User tier (if namespace owned by user)
|
3. User tier (if namespace owned by user)
|
||||||
|
|
||||||
|
#### canForNamespaceOrFail()
|
||||||
|
|
||||||
|
Check namespace entitlement with cascade, throwing on failure:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function canForNamespaceOrFail(
|
||||||
|
Namespace_ $namespace,
|
||||||
|
string $featureCode,
|
||||||
|
int $quantity = 1
|
||||||
|
): EntitlementResult
|
||||||
|
|
||||||
|
// Throws: FeatureNotFoundException, LimitExceededException, EntitlementException
|
||||||
|
```
|
||||||
|
|
||||||
#### recordUsage()
|
#### recordUsage()
|
||||||
|
|
||||||
Record feature usage:
|
Record feature usage:
|
||||||
|
|
@ -369,6 +397,69 @@ $isValid = $webhookService->verifySignature(
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Exception Hierarchy
|
||||||
|
|
||||||
|
The entitlement system provides a hierarchy of exceptions for fine-grained error handling:
|
||||||
|
|
||||||
|
```
|
||||||
|
EntitlementException (base)
|
||||||
|
├── LimitExceededException -- feature usage limit exceeded
|
||||||
|
├── PackageNotFoundException -- referenced package code does not exist
|
||||||
|
├── FeatureNotFoundException -- referenced feature code does not exist
|
||||||
|
└── PackageSuspendedException -- workspace packages are suspended
|
||||||
|
```
|
||||||
|
|
||||||
|
All exception classes are in the `Core\Tenant\Exceptions` namespace.
|
||||||
|
|
||||||
|
### Catching Specific Exceptions
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Tenant\Exceptions\EntitlementException;
|
||||||
|
use Core\Tenant\Exceptions\LimitExceededException;
|
||||||
|
use Core\Tenant\Exceptions\FeatureNotFoundException;
|
||||||
|
use Core\Tenant\Exceptions\PackageNotFoundException;
|
||||||
|
use Core\Tenant\Exceptions\PackageSuspendedException;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $entitlements->canOrFail($workspace, 'pages');
|
||||||
|
$page = $workspace->pages()->create($data);
|
||||||
|
$entitlements->recordUsage($workspace, 'pages', 1, $user);
|
||||||
|
} catch (LimitExceededException $e) {
|
||||||
|
// Feature limit exceeded -- $e->getLimit(), $e->getUsed()
|
||||||
|
return response()->json(['error' => $e->getMessage()], 403);
|
||||||
|
} catch (FeatureNotFoundException $e) {
|
||||||
|
// Feature code does not exist -- $e->getFeatureCode()
|
||||||
|
return response()->json(['error' => 'Unknown feature'], 404);
|
||||||
|
} catch (EntitlementException $e) {
|
||||||
|
// Catch-all for any entitlement error
|
||||||
|
return response()->json(['error' => $e->getMessage()], $e->getCode());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### canOrFail() / canForNamespaceOrFail()
|
||||||
|
|
||||||
|
The `canOrFail()` and `canForNamespaceOrFail()` methods are throwing variants of `can()` and `canForNamespace()`. They return `EntitlementResult` on success and throw specific exception subtypes on failure:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Returns EntitlementResult on success, throws on failure
|
||||||
|
$result = $entitlements->canOrFail($workspace, 'ai.credits', quantity: 5);
|
||||||
|
|
||||||
|
// Namespace variant with cascade
|
||||||
|
$result = $entitlements->canForNamespaceOrFail($namespace, 'links');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provisioning Exceptions
|
||||||
|
|
||||||
|
Package provisioning throws `PackageNotFoundException` when the package code is invalid:
|
||||||
|
|
||||||
|
```php
|
||||||
|
try {
|
||||||
|
$entitlements->provisionPackage($workspace, 'unknown-package');
|
||||||
|
} catch (PackageNotFoundException $e) {
|
||||||
|
// $e->getPackageCode() returns the invalid code
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
### Check Before Action
|
### Check Before Action
|
||||||
|
|
@ -383,13 +474,23 @@ if (!$workspace->can('social.accounts')->isAllowed()) {
|
||||||
throw new \Exception('Limit exceeded');
|
throw new \Exception('Limit exceeded');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Good: Check before action
|
// Good: Check before action (non-throwing)
|
||||||
$result = $workspace->can('social.accounts');
|
$result = $workspace->can('social.accounts');
|
||||||
if ($result->isDenied()) {
|
if ($result->isDenied()) {
|
||||||
throw new EntitlementException($result->reason);
|
throw new LimitExceededException(
|
||||||
|
message: $result->reason,
|
||||||
|
featureCode: 'social.accounts',
|
||||||
|
limit: $result->limit,
|
||||||
|
used: $result->used,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
$account = SocialAccount::create([...]);
|
$account = SocialAccount::create([...]);
|
||||||
$workspace->recordUsage('social.accounts');
|
$workspace->recordUsage('social.accounts');
|
||||||
|
|
||||||
|
// Good: Check before action (throwing)
|
||||||
|
$entitlements->canOrFail($workspace, 'social.accounts');
|
||||||
|
$account = SocialAccount::create([...]);
|
||||||
|
$workspace->recordUsage('social.accounts');
|
||||||
```
|
```
|
||||||
|
|
||||||
### Use Transactions
|
### Use Transactions
|
||||||
|
|
@ -397,12 +498,11 @@ $workspace->recordUsage('social.accounts');
|
||||||
For atomic check-and-record:
|
For atomic check-and-record:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
DB::transaction(function () use ($workspace, $user) {
|
use Core\Tenant\Exceptions\LimitExceededException;
|
||||||
$result = $workspace->can('ai.credits', 10);
|
|
||||||
|
|
||||||
if ($result->isDenied()) {
|
DB::transaction(function () use ($workspace, $user, $entitlements) {
|
||||||
throw new EntitlementException($result->reason);
|
// canOrFail throws LimitExceededException if limit reached
|
||||||
}
|
$entitlements->canOrFail($workspace, 'ai.credits', quantity: 10);
|
||||||
|
|
||||||
// Perform AI generation
|
// Perform AI generation
|
||||||
$output = $aiService->generate($prompt);
|
$output = $aiService->generate($prompt);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue