Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<Entitlement>`.
* Updated documentation to reflect enum contract and configuration changes.


### v1.3.0-beta.1 (2025-10-27)
* * *

Expand Down
2 changes: 1 addition & 1 deletion DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions config/cashier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
],
];
3 changes: 1 addition & 2 deletions src/CashierServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace Chargebee\Cashier;

use BackedEnum;
use Chargebee\Cashier\Console\FeatureEnumCommand;
use Chargebee\Cashier\Console\WebhookCommand;
use Chargebee\Cashier\Contracts\EntitlementAccessVerifier;
Expand Down Expand Up @@ -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);

Expand Down
52 changes: 41 additions & 11 deletions src/Concerns/HasEntitlements.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<FeatureEnumContract> $features
* @param Collection<Feature> $featureModels
* @return Collection<FeatureEnumContract>|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<FeatureEnumContract> $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);
}
}
6 changes: 6 additions & 0 deletions src/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@ class Constants
{
public const REQUIRED_FEATURES_KEY = 'chargebee.required_features';
}

enum EntitlementErrorCode
{
case MISSING_FEATURE_IN_DB;
case ACCESS_DENIED;
}
21 changes: 16 additions & 5 deletions src/Contracts/EntitlementAccessVerifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Feature> $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;
}
4 changes: 3 additions & 1 deletion src/Contracts/FeatureEnumContract.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace Chargebee\Cashier\Contracts;

interface FeatureEnumContract
use BackedEnum;

interface FeatureEnumContract extends BackedEnum
{
/**
* Returns the chargebee id of the feature.
Expand Down
3 changes: 1 addition & 2 deletions src/Entitlement.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down
17 changes: 10 additions & 7 deletions src/Http/Middleware/UserEntitlementCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,26 +31,28 @@ public function handle(Request $request, Closure $next)

// 2) Or from route macro (closure routes)
if (! $features) {
/** @var null|array<FeatureEnumContract&BackedEnum> $fromAction */
/** @var null|array<FeatureEnumContract> $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<FeatureEnumContract&BackedEnum>
* @return array<FeatureEnumContract>
*/
private function featuresFromAttributes($route): array
{
Expand Down
8 changes: 4 additions & 4 deletions src/Subscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -1025,21 +1025,21 @@ public function asChargebeeSubscription(): ChargebeeSubscription
/**
* Get entitlements
*
* @return \Chargebee\Cashier\Entitlement[]
* @return \Illuminate\Support\Collection<Entitlement>
*/
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 {
$response = $chargebee->subscriptionEntitlement()->subscriptionEntitlementsForSubscription($this->chargebee_id, $options);
$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;
}
Expand Down
35 changes: 28 additions & 7 deletions src/Support/DefaultEntitlementAccessVerifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Feature> $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<Entitlement> $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,
Expand All @@ -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.');
}
}
}
Loading