Compare commits
No commits in common. "feat/entitlement-exception-hierarchy" and "dev" have entirely different histories.
feat/entit
...
dev
7 changed files with 13 additions and 506 deletions
|
|
@ -7,23 +7,7 @@ namespace Core\Tenant\Exceptions;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base exception for entitlement-related errors.
|
* Exception thrown when an entitlement check fails.
|
||||||
*
|
|
||||||
* 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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
<?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,10 +5,6 @@ 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;
|
||||||
|
|
@ -20,6 +16,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -284,63 +281,6 @@ 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.
|
||||||
*
|
*
|
||||||
|
|
@ -458,61 +398,6 @@ 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.
|
||||||
*
|
*
|
||||||
|
|
@ -705,18 +590,14 @@ 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 PackageNotFoundException If the package code does not exist
|
* @throws ModelNotFoundException 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)->first();
|
$package = Package::where('code', $packageCode)->firstOrFail();
|
||||||
|
|
||||||
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) {
|
||||||
|
|
@ -1855,7 +1736,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 PackageNotFoundException If the package code does not exist
|
* @throws ModelNotFoundException If the package code does not exist
|
||||||
*
|
*
|
||||||
* @see self::provisionPackage() For workspace-level package provisioning
|
* @see self::provisionPackage() For workspace-level package provisioning
|
||||||
*/
|
*/
|
||||||
|
|
@ -1864,11 +1745,7 @@ class EntitlementService
|
||||||
string $packageCode,
|
string $packageCode,
|
||||||
array $options = []
|
array $options = []
|
||||||
): NamespacePackage {
|
): NamespacePackage {
|
||||||
$package = Package::where('code', $packageCode)->first();
|
$package = Package::where('code', $packageCode)->firstOrFail();
|
||||||
|
|
||||||
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,20 +166,6 @@ 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:
|
||||||
|
|
@ -197,20 +183,6 @@ 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:
|
||||||
|
|
@ -397,69 +369,6 @@ $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
|
||||||
|
|
@ -474,23 +383,13 @@ if (!$workspace->can('social.accounts')->isAllowed()) {
|
||||||
throw new \Exception('Limit exceeded');
|
throw new \Exception('Limit exceeded');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Good: Check before action (non-throwing)
|
// Good: Check before action
|
||||||
$result = $workspace->can('social.accounts');
|
$result = $workspace->can('social.accounts');
|
||||||
if ($result->isDenied()) {
|
if ($result->isDenied()) {
|
||||||
throw new LimitExceededException(
|
throw new EntitlementException($result->reason);
|
||||||
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
|
||||||
|
|
@ -498,11 +397,12 @@ $workspace->recordUsage('social.accounts');
|
||||||
For atomic check-and-record:
|
For atomic check-and-record:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
use Core\Tenant\Exceptions\LimitExceededException;
|
DB::transaction(function () use ($workspace, $user) {
|
||||||
|
$result = $workspace->can('ai.credits', 10);
|
||||||
|
|
||||||
DB::transaction(function () use ($workspace, $user, $entitlements) {
|
if ($result->isDenied()) {
|
||||||
// canOrFail throws LimitExceededException if limit reached
|
throw new EntitlementException($result->reason);
|
||||||
$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