refactor: create EntitlementException hierarchy with subtypes

Create exception subclasses for fine-grained error handling:
- LimitExceededException: feature usage limit exceeded
- PackageNotFoundException: package code not found during provisioning
- FeatureNotFoundException: feature code not found during checks
- PackageSuspendedException: workspace packages suspended

Update EntitlementService:
- Add canOrFail() and canForNamespaceOrFail() throwing variants
- Replace firstOrFail() with explicit PackageNotFoundException in provisioning
- Import new exception types, remove unused ModelNotFoundException

Update docs/entitlements.md with Exception Hierarchy section, API reference
entries for new methods, and updated Best Practices examples.

Fixes #19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-03-24 13:30:48 +00:00
parent c51e4310b1
commit 2601392b8d
No known key found for this signature in database
GPG key ID: AF404715446AEB41
7 changed files with 506 additions and 13 deletions

View file

@ -7,7 +7,23 @@ namespace Core\Tenant\Exceptions;
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
{

View 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());
}
}

View 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());
}
}

View 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());
}
}

View 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());
}
}

View file

@ -5,6 +5,10 @@ declare(strict_types=1);
namespace Core\Tenant\Services;
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\EntitlementLog;
use Core\Tenant\Models\Feature;
@ -16,7 +20,6 @@ use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Models\WorkspacePackage;
use Illuminate\Cache\TaggableStore;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Collection;
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.
*
@ -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.
*
@ -590,14 +705,18 @@ class EntitlementService
* - `metadata`: Additional data to store with the package
* @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(
Workspace $workspace,
string $packageCode,
array $options = []
): 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
if ($package->is_base_package) {
@ -1736,7 +1855,7 @@ class EntitlementService
* - `metadata`: Additional data to store with the package
* @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
*/
@ -1745,7 +1864,11 @@ class EntitlementService
string $packageCode,
array $options = []
): 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
if ($package->is_base_package) {

View file

@ -166,6 +166,20 @@ public function can(
- `isNearLimit(): bool` (>80%)
- `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()
Check entitlement for a namespace with cascade:
@ -183,6 +197,20 @@ Cascade order:
2. Workspace pool (if `namespace->workspace_id` set)
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()
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
### Check Before Action
@ -383,13 +474,23 @@ if (!$workspace->can('social.accounts')->isAllowed()) {
throw new \Exception('Limit exceeded');
}
// Good: Check before action
// Good: Check before action (non-throwing)
$result = $workspace->can('social.accounts');
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([...]);
$workspace->recordUsage('social.accounts');
// Good: Check before action (throwing)
$entitlements->canOrFail($workspace, 'social.accounts');
$account = SocialAccount::create([...]);
$workspace->recordUsage('social.accounts');
```
### Use Transactions
@ -397,12 +498,11 @@ $workspace->recordUsage('social.accounts');
For atomic check-and-record:
```php
DB::transaction(function () use ($workspace, $user) {
$result = $workspace->can('ai.credits', 10);
use Core\Tenant\Exceptions\LimitExceededException;
if ($result->isDenied()) {
throw new EntitlementException($result->reason);
}
DB::transaction(function () use ($workspace, $user, $entitlements) {
// canOrFail throws LimitExceededException if limit reached
$entitlements->canOrFail($workspace, 'ai.credits', quantity: 10);
// Perform AI generation
$output = $aiService->generate($prompt);