diff --git a/Exceptions/EntitlementException.php b/Exceptions/EntitlementException.php index db1a00d..e63d423 100644 --- a/Exceptions/EntitlementException.php +++ b/Exceptions/EntitlementException.php @@ -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 { diff --git a/Exceptions/FeatureNotFoundException.php b/Exceptions/FeatureNotFoundException.php new file mode 100644 index 0000000..d8b8c5e --- /dev/null +++ b/Exceptions/FeatureNotFoundException.php @@ -0,0 +1,53 @@ +expectsJson()) { + return response()->json([ + 'message' => $this->getMessage(), + 'error' => 'feature_not_found', + 'feature_code' => $this->featureCode, + ], $this->getCode()); + } + + return redirect()->back() + ->with('error', $this->getMessage()); + } +} diff --git a/Exceptions/LimitExceededException.php b/Exceptions/LimitExceededException.php new file mode 100644 index 0000000..877074e --- /dev/null +++ b/Exceptions/LimitExceededException.php @@ -0,0 +1,77 @@ +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()); + } +} diff --git a/Exceptions/PackageNotFoundException.php b/Exceptions/PackageNotFoundException.php new file mode 100644 index 0000000..7828d46 --- /dev/null +++ b/Exceptions/PackageNotFoundException.php @@ -0,0 +1,61 @@ +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()); + } +} diff --git a/Exceptions/PackageSuspendedException.php b/Exceptions/PackageSuspendedException.php new file mode 100644 index 0000000..4d8e08b --- /dev/null +++ b/Exceptions/PackageSuspendedException.php @@ -0,0 +1,63 @@ +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()); + } +} diff --git a/Services/EntitlementService.php b/Services/EntitlementService.php index 894f232..dffe274 100644 --- a/Services/EntitlementService.php +++ b/Services/EntitlementService.php @@ -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) { diff --git a/docs/entitlements.md b/docs/entitlements.md index ec72523..6346ac5 100644 --- a/docs/entitlements.md +++ b/docs/entitlements.md @@ -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);