Compare commits

..

No commits in common. "feat/entitlement-exception-hierarchy" and "dev" have entirely different histories.

7 changed files with 13 additions and 506 deletions

View file

@ -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
{ {

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

@ -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);