diff --git a/CHANGELOG.md b/CHANGELOG.md index de5244f..89a4ec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +### v1.3.0-beta.2 (2025-10-30) + +--- + +* Refactored entitlements to use `FeatureEnumContract` (now extends `BackedEnum`) and simplified method signatures across routes, middleware, and attributes. +* Switched entitlements config to env-driven options (`CASHIER_ENTITLEMENTS_*`). +* Fixed entitlement cache serialization (store as array, restore via `Entitlement::fromArray`). +* Added hard failure (`HttpException(500)`) when required features are missing in DB. +* Updated `Subscription::getEntitlements()` to return `Collection`. +* Updated documentation to reflect enum contract and configuration changes. + + ### v1.3.0-beta.1 (2025-10-27) * * * diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 688fe10..b155ab5 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -3101,7 +3101,7 @@ enum CustomFeature: string implements FeatureEnumContract { } ``` -As long as your enum implementation satisfies the `FeatureEnumContract&BackedEnum` type, you will +As long as your enum implementation satisfies the `FeatureEnumContract` type, you will be able to pass the feature to the main API methods like `$user->hasAccess()`; Additionally, these features and the required metadata will need to be populated in the DB. diff --git a/config/cashier.php b/config/cashier.php index 9a2826b..01c8b30 100644 --- a/config/cashier.php +++ b/config/cashier.php @@ -98,16 +98,16 @@ 'entitlements' => [ // Enable Chargebee Entitlements for Cashier - 'enabled' => true, + 'enabled' => env('CASHIER_ENTITLEMENTS_ENABLED', false), // The class that will be used to check if the user has access to the feature. // If this is not provided, the default implementation expects fallback_access // to be provided. - 'access_verifier' => \Chargebee\Cashier\Support\DefaultEntitlementAccessVerifier::class, + 'access_verifier' => env('CASHIER_ENTITLEMENTS_ACCESS_VERIFIER'), - // Map of FeatureID => boolean which is used as a fallback to determine if + // array of FeatureID => boolean which is used as a fallback to determine if // the user has access to the feature. This is used only if a access_check // class is not provided. - 'feature_defaults' => [], + 'feature_defaults' => env('CASHIER_ENTITLEMENTS_FEATURE_DEFAULTS'), ], ]; diff --git a/src/CashierServiceProvider.php b/src/CashierServiceProvider.php index a0ae8ba..c2dbdc4 100644 --- a/src/CashierServiceProvider.php +++ b/src/CashierServiceProvider.php @@ -2,7 +2,6 @@ namespace Chargebee\Cashier; -use BackedEnum; use Chargebee\Cashier\Console\FeatureEnumCommand; use Chargebee\Cashier\Console\WebhookCommand; use Chargebee\Cashier\Contracts\EntitlementAccessVerifier; @@ -153,7 +152,7 @@ protected function enableEntitlements(): void // Initialise the route macro, which binds the middleware to the route // and reads the required features from the route action. - \Illuminate\Routing\Route::macro('requiresEntitlement', function (FeatureEnumContract&BackedEnum ...$features) { + \Illuminate\Routing\Route::macro('requiresEntitlement', function (FeatureEnumContract ...$features) { /** @var \Illuminate\Routing\Route $this */ $this->middleware(UserEntitlementCheck::class); diff --git a/src/Concerns/HasEntitlements.php b/src/Concerns/HasEntitlements.php index b09b261..0374bdc 100644 --- a/src/Concerns/HasEntitlements.php +++ b/src/Concerns/HasEntitlements.php @@ -2,13 +2,14 @@ namespace Chargebee\Cashier\Concerns; -use BackedEnum; use Chargebee\Cashier\Contracts\EntitlementAccessVerifier; use Chargebee\Cashier\Contracts\FeatureEnumContract; use Chargebee\Cashier\Entitlement; +use Chargebee\Cashier\EntitlementErrorCode; use Chargebee\Cashier\Feature; use Chargebee\Cashier\Subscription; use Illuminate\Contracts\Cache\Repository as CacheRepository; +use Illuminate\Http\Request; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; @@ -82,32 +83,61 @@ public function ensureEntitlements(): void $cachedEntitlements = $cacheStore->get($cacheKey); if ($cachedEntitlements) { Log::debug('Got entitlements from cache: ', ['cachedEntitlements' => $cachedEntitlements]); - // Convert the cached entitlements to an array of Entitlement objects $this->entitlements = collect($cachedEntitlements)->map(fn ($entitlement) => Entitlement::fromArray($entitlement)); } else { $entitlements = $this->getEntitlements(); Log::debug('Got entitlements from API: ', ['entitlements' => $entitlements]); $cacheExpirySeconds = config('session.lifetime', 120) * 60; - $cacheStore->put($cacheKey, $entitlements, $cacheExpirySeconds); + $cacheStore->put($cacheKey, $entitlements->toArray(), $cacheExpirySeconds); } } + /** + * Check if the given features are missing in the database + * + * @param Collection $features + * @param Collection $featureModels + * @return Collection|null + */ + protected function checkMissingFeatures(Collection $features, Collection $featureModels): ?Collection + { + $missingFeatureIds = $features->reject(function ($enum) use ($featureModels) { + return $featureModels->contains(function ($model) use ($enum) { + return $enum->id() === $model->chargebee_id; + }); + }); + if ($missingFeatureIds->count() > 0) { + return $missingFeatureIds; + } + + return null; + } + /** * Check if the user has the given entitlement * - * @param FeatureEnumContract&BackedEnum ...$features + * @param FeatureEnumContract|array $features + * @param ?Request $request * @return bool */ - public function hasAccess(FeatureEnumContract&BackedEnum ...$features): bool + public function hasAccess($features, ?Request $request = null): bool { - $featureModels = Feature::whereIn('chargebee_id', $features)->get(); $feats = collect($features); - if ($featureModels->count() != $feats->count()) { - Log::warning(<<<'EOF' - Some features were not found in the database. Please run `php artisan cashier:generate-feature-enum` to sync. - EOF, ['missingFeatures' => $feats->diff($featureModels)->implode(', ')]); + $featureModels = Feature::whereIn('chargebee_id', $features)->get(); + $request = $request ?? request(); + $entitlementAccessVerifier = app(EntitlementAccessVerifier::class); + + $missingFeatures = $this->checkMissingFeatures($feats, $featureModels); + if ($missingFeatures && $missingFeatures->count() > 0) { + Log::error(<<<'EOF' + Feature(s) missing in database. Run `php artisan cashier:generate-feature-enum` to sync. + EOF, ['missingFeatures' => $missingFeatures]); + + $entitlementAccessVerifier::handleError($request, EntitlementErrorCode::MISSING_FEATURE_IN_DB, $missingFeatures); + + return false; } - return app(EntitlementAccessVerifier::class)::hasAccessToFeatures($this, $featureModels); + return $entitlementAccessVerifier::hasAccessToFeatures($request, $featureModels); } } diff --git a/src/Constants.php b/src/Constants.php index c26ee8e..a93e5ff 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -6,3 +6,9 @@ class Constants { public const REQUIRED_FEATURES_KEY = 'chargebee.required_features'; } + +enum EntitlementErrorCode +{ + case MISSING_FEATURE_IN_DB; + case ACCESS_DENIED; +} diff --git a/src/Contracts/EntitlementAccessVerifier.php b/src/Contracts/EntitlementAccessVerifier.php index f26fda4..63a0c0a 100644 --- a/src/Contracts/EntitlementAccessVerifier.php +++ b/src/Contracts/EntitlementAccessVerifier.php @@ -2,16 +2,16 @@ namespace Chargebee\Cashier\Contracts; -use Chargebee\Cashier\Concerns\HasEntitlements; +use Chargebee\Cashier\EntitlementErrorCode; use Chargebee\Cashier\Feature; -use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Http\Request; use Illuminate\Support\Collection; interface EntitlementAccessVerifier { /** * For the given user, decide if feature is accessible to them. The entitlements - * for the user are accessible via $user->getEntitlements(). The implementation in the + * for the user are accessible via $request->user()->getEntitlements(). The implementation in the * app will need to consider variour factors like feature type, value, levels, etc. * * If multiple features are defined on the route, those are passed as an array to this method. @@ -20,9 +20,20 @@ interface EntitlementAccessVerifier * If you also track usage of these features in your app, apply the required logic to verify * if the usage is within the entitled limits. * - * @param Authenticatable&HasEntitlements $user The user to check access for + * @param Request $request * @param Collection $features * @return bool */ - public static function hasAccessToFeatures($user, Collection $features): bool; + public static function hasAccessToFeatures(Request $request, Collection $features): bool; + + /** + * When hasAccessToFeatures returns false, this method will be called to handle the access denied case. + * You can throw an exception, return a response, or redirect to a different page. + * + * @param Request $request + * @param EntitlementErrorCode $error + * @param mixed $data + * @return void + */ + public static function handleError(Request $request, EntitlementErrorCode $error, mixed $data = null): void; } diff --git a/src/Contracts/FeatureEnumContract.php b/src/Contracts/FeatureEnumContract.php index 2fc30c8..467f83b 100644 --- a/src/Contracts/FeatureEnumContract.php +++ b/src/Contracts/FeatureEnumContract.php @@ -2,7 +2,9 @@ namespace Chargebee\Cashier\Contracts; -interface FeatureEnumContract +use BackedEnum; + +interface FeatureEnumContract extends BackedEnum { /** * Returns the chargebee id of the feature. diff --git a/src/Entitlement.php b/src/Entitlement.php index bbc1cec..8e58dfe 100644 --- a/src/Entitlement.php +++ b/src/Entitlement.php @@ -2,7 +2,6 @@ namespace Chargebee\Cashier; -use BackedEnum; use Chargebee\Cashier\Contracts\FeatureEnumContract; use Chargebee\Resources\SubscriptionEntitlement\SubscriptionEntitlement as ChargebeeSubscriptionEntitlement; use Illuminate\Contracts\Support\Arrayable; @@ -48,7 +47,7 @@ public function feature(): Feature * * @return bool */ - public function providesFeature(FeatureEnumContract&BackedEnum $feature): bool + public function providesFeature(FeatureEnumContract $feature): bool { return $this->entitlement->feature_id === $feature->id(); } diff --git a/src/Http/Middleware/UserEntitlementCheck.php b/src/Http/Middleware/UserEntitlementCheck.php index 7c5c424..4340fa3 100644 --- a/src/Http/Middleware/UserEntitlementCheck.php +++ b/src/Http/Middleware/UserEntitlementCheck.php @@ -2,10 +2,11 @@ namespace Chargebee\Cashier\Http\Middleware; -use BackedEnum; use Chargebee\Cashier\Concerns\HasEntitlements; use Chargebee\Cashier\Constants; +use Chargebee\Cashier\Contracts\EntitlementAccessVerifier; use Chargebee\Cashier\Contracts\FeatureEnumContract; +use Chargebee\Cashier\EntitlementErrorCode; use Chargebee\Cashier\Support\RequiresEntitlement; use Closure; use Illuminate\Contracts\Auth\Authenticatable; @@ -30,26 +31,28 @@ public function handle(Request $request, Closure $next) // 2) Or from route macro (closure routes) if (! $features) { - /** @var null|array $fromAction */ + /** @var null|array $fromAction */ $fromAction = $route->getAction(Constants::REQUIRED_FEATURES_KEY) ?? null; if ($fromAction) { $features = $fromAction; } } if ($features) { - $hasAccess = $user->hasAccess(...$features); + $request->attributes->set(Constants::REQUIRED_FEATURES_KEY, $features); + $hasAccess = $user->hasAccess($features, $request); + if (! $hasAccess) { - throw new HttpException(403, 'You are not authorized to access this resource.'); - } + $entitlementAccessVerifier = app(EntitlementAccessVerifier::class); - $request->attributes->set(Constants::REQUIRED_FEATURES_KEY, $features); + return $entitlementAccessVerifier::handleError($request, EntitlementErrorCode::ACCESS_DENIED); + } } return $next($request); } /** - * @return array + * @return array */ private function featuresFromAttributes($route): array { diff --git a/src/Subscription.php b/src/Subscription.php index 2e95141..899c761 100644 --- a/src/Subscription.php +++ b/src/Subscription.php @@ -1025,13 +1025,13 @@ public function asChargebeeSubscription(): ChargebeeSubscription /** * Get entitlements * - * @return \Chargebee\Cashier\Entitlement[] + * @return \Illuminate\Support\Collection */ - public function getEntitlements(): array + public function getEntitlements(): Collection { Log::debug('Getting entitlements for subscription '.$this->chargebee_id); $chargebee = Cashier::chargebee(); - $entitlements = []; + $entitlements = Collection::make(); $options = []; do { @@ -1039,7 +1039,7 @@ public function getEntitlements(): array $entitlementsResponse = collect($response->list)->map(function ($entitlement) { return new Entitlement($entitlement->subscription_entitlement); }); - array_push($entitlements, ...$entitlementsResponse->toArray()); + $entitlements->push(...$entitlementsResponse); if ($response->next_offset) { $options['offset'] = $response->next_offset; } diff --git a/src/Support/DefaultEntitlementAccessVerifier.php b/src/Support/DefaultEntitlementAccessVerifier.php index 4f176b1..da5b852 100644 --- a/src/Support/DefaultEntitlementAccessVerifier.php +++ b/src/Support/DefaultEntitlementAccessVerifier.php @@ -2,37 +2,40 @@ namespace Chargebee\Cashier\Support; -use Chargebee\Cashier\Concerns\HasEntitlements; use Chargebee\Cashier\Contracts\EntitlementAccessVerifier; use Chargebee\Cashier\Entitlement; +use Chargebee\Cashier\EntitlementErrorCode; use Chargebee\Cashier\Feature; use Chargebee\Resources\Feature\Enums\Type as FeatureType; -use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Http\Request; use Illuminate\Support\Collection; +use Symfony\Component\HttpKernel\Exception\HttpException; final class DefaultEntitlementAccessVerifier implements EntitlementAccessVerifier { /** * Return true if the entitlements collectively provide all requested features. * - * @param Authenticatable&HasEntitlements $user The user to check access for + * @param Request $request * @param Collection $features * @return bool */ - public static function hasAccessToFeatures($user, Collection $features): bool + public static function hasAccessToFeatures(Request $request, Collection $features): bool { - $entitlements = $user->getEntitlements(); + /** @var Collection $entitlements */ + $entitlements = $request->user()?->getEntitlements() ?? collect(); $featureDefaults = config('cashier.entitlements.feature_defaults', []); // Every feature must be provided by at least one entitlement (AND logic) return $features->every(function ($feature) use ($entitlements, $featureDefaults) { return $entitlements->contains(function ($entitlement) use ($feature, $featureDefaults) { - // For the default implementation, we can only check SWITCH feature types. - // For the others, check if there is a fallback value configured. + // If the entitlement does not match the feature being checked, bail. if ($entitlement->feature_id !== $feature->chargebee_id) { return false; } + // For the default implementation, we can only check SWITCH feature types. + // For the others, check if there is a fallback value configured in the config file. $hasAccess = match (FeatureType::tryFromValue(strtolower($entitlement->feature_type))) { FeatureType::SWITCH => $entitlement->value, default => $featureDefaults[$feature->chargebee_id] ?? false, @@ -43,4 +46,22 @@ public static function hasAccessToFeatures($user, Collection $features): bool }); }); } + + /** + * Throw a 403 error when access is denied. + * + * @param Request $request + * @param EntitlementErrorCode $error + * @param mixed $data + * @return void + */ + public static function handleError(Request $request, EntitlementErrorCode $error, mixed $data = null): void + { + switch ($error) { + case EntitlementErrorCode::MISSING_FEATURE_IN_DB: + throw new HttpException(500, 'Error verifying your access to this resource.'); + case EntitlementErrorCode::ACCESS_DENIED: + throw new HttpException(403, 'You are not authorized to access this resource.'); + } + } } diff --git a/src/Support/RequiresEntitlement.php b/src/Support/RequiresEntitlement.php index cb0c58d..3ade122 100644 --- a/src/Support/RequiresEntitlement.php +++ b/src/Support/RequiresEntitlement.php @@ -3,21 +3,20 @@ namespace Chargebee\Cashier\Support; use Attribute; -use BackedEnum; use Chargebee\Cashier\Contracts\FeatureEnumContract; #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] final class RequiresEntitlement { /** - * @var list + * @var list */ public array $features; /** - * @param FeatureEnumContract&BackedEnum ...$features + * @param FeatureEnumContract ...$features */ - public function __construct(FeatureEnumContract&BackedEnum ...$features) + public function __construct(FeatureEnumContract ...$features) { $this->features = $features; } diff --git a/tests/Feature/FeatureEnumCommandTest.php b/tests/Feature/FeatureEnumCommandTest.php index 7602cc3..569ab0d 100644 --- a/tests/Feature/FeatureEnumCommandTest.php +++ b/tests/Feature/FeatureEnumCommandTest.php @@ -50,13 +50,22 @@ public function test_generate_feature_enum_should_create_enum_file_with_cases_an $this->assertNotNull($capturedPath); $this->assertNotNull($capturedPhp); - $expectedPhp = " 'feature_priority_support', ); } -}"; + + public function id(): string + { + return $this->value; + } + + /** + * @param array $featureIds + * @return array + */ + public static function fromArray(array $featureIds): array + { + return array_map(fn (string $featureId) => self::from($featureId), $featureIds); + } +} +PHP; // Verify the file path $expectedPath = base_path('app/Models/FeaturesMap.php'); @@ -124,7 +148,7 @@ public function test_should_use_default_options_when_none_provided(): void $this->artisan('cashier:generate-feature-enum', ['--force' => true]) ->assertExitCode(0); - $expectedPath = base_path('app/Models/FeaturesMap.php'); + $expectedPath = base_path('app/Models/Feature.php'); $this->assertEquals($expectedPath, $capturedPath); } @@ -150,13 +174,22 @@ public function test_should_handle_custom_class_and_namespace(): void ])->assertExitCode(0); $expectedPath = base_path('app/Enums/CustomFeatures.php'); - $expectedPhp = " 'feature_priority_support', ); } -}"; + + public function id(): string + { + return $this->value; + } + + /** + * @param array $featureIds + * @return array + */ + public static function fromArray(array $featureIds): array + { + return array_map(fn (string $featureId) => self::from($featureId), $featureIds); + } +} +PHP; $this->assertEquals($expectedPath, $capturedPath); $this->assertEquals($expectedPhp, $capturedPhp); } @@ -210,7 +258,7 @@ public function test_should_handle_path_with_trailing_slash(): void '--force' => true, ])->assertExitCode(0); - $expectedPath = base_path('app/Models/FeaturesMap.php'); + $expectedPath = base_path('app/Models/Feature.php'); $this->assertEquals($expectedPath, $capturedPath); } @@ -228,13 +276,22 @@ public function test_should_skip_features_with_invalid_names(): void $this->artisan('cashier:generate-feature-enum', ['--force' => true]) ->assertExitCode(0); - $expectedPhp = " 'feature_free_trial', ); } -}"; + + public function id(): string + { + return $this->value; + } + + /** + * @param array $featureIds + * @return array + */ + public static function fromArray(array $featureIds): array + { + return array_map(fn (string $featureId) => self::from($featureId), $featureIds); + } +} +PHP; // Should only contain the valid feature - $this->assertStringContainsString($expectedPhp, $capturedPhp); + $this->assertEquals($expectedPhp, $capturedPhp); } public function test_should_escape_special_characters_in_values(): void @@ -262,13 +334,22 @@ public function test_should_escape_special_characters_in_values(): void }); $this->artisan('cashier:generate-feature-enum', ['--force' => true]) ->assertExitCode(0); - $expectedPhp = " 'feature_priority_support', ); } -}"; - $this->assertEquals($capturedPhp, $expectedPhp); + + public function id(): string + { + return $this->value; + } + + /** + * @param array $featureIds + * @return array + */ + public static function fromArray(array $featureIds): array + { + return array_map(fn (string $featureId) => self::from($featureId), $featureIds); } } +PHP; + $this->assertEquals($capturedPhp, $expectedPhp); + } +} \ No newline at end of file