Skip to content
Open
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
3 changes: 3 additions & 0 deletions config/cloudplans.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
'free' => [
'id' => 'free',
'name' => 'Free',
'token_allowance' => (int) env('CLOUD_PLAN_FREE_TOKEN_ALLOWANCE', 0),
'interval' => 'lifetime',
'features' => [
'Up to 3 wallets',
Expand All @@ -83,6 +84,7 @@
'monthly' => [
'id' => 'monthly',
'name' => 'Monthly',
'token_allowance' => (int) env('CLOUD_PLAN_MONTHLY_TOKEN_ALLOWANCE', 50000),
'interval' => 'month',
'features' => [
'Unlimited categories and wallets',
Expand All @@ -97,6 +99,7 @@
],
'yearly' => [
'id' => 'yearly',
'token_allowance' => (int) env('CLOUD_PLAN_YEARLY_TOKEN_ALLOWANCE', 50000),
'name' => 'Yearly',
'interval' => 'year',
'features' => [
Expand Down
3 changes: 2 additions & 1 deletion src/CloudServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Trakli\Cloud\Console\SyncPlansCommand;
use Whilesmart\Entitlements\Contracts\Entitlements;

class CloudServiceProvider extends ServiceProvider
{
Expand All @@ -31,7 +32,7 @@ public function boot(): void
if (blank(config('entitlements-cashier.default_plan'))) {
config(['entitlements-cashier.default_plan' => 'free']);
}

$this->app->singleton(Entitlements::class, \Trakli\Cloud\Support\CloudEntitlements::class);
if ($this->app->runningInConsole()) {
$this->commands([SyncPlansCommand::class]);
}
Expand Down
133 changes: 133 additions & 0 deletions src/Support/CloudEntitlements.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

namespace Trakli\Cloud\Support;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Whilesmart\Entitlements\Contracts\Entitlements;
use Whilesmart\Entitlements\Models\Subscription;
use Whilesmart\Entitlements\Support\AccessResult;


class CloudEntitlements implements Entitlements
{
/**
* Determine if the owner is allowed to use a given feature.
*/
public function allows(?Model $owner, string $feature): bool

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The allows method is currently hardcoded to return true, which bypasses all plan-based feature restrictions. This should delegate to the check method to ensure that entitlements are properly enforced across the application.

Suggested change
public function allows(?Model $owner, string $feature): bool
public function allows(?Model $owner, string $feature): bool
{
return $this->check($owner, $feature)->allowed();
}

{
return true;
}

/**
* Get the limit for a given key.
*/
public function limit(?Model $owner, string $key): ?int
{
return null;
}

/**
* Get the remaining token allowance for the current period.
*/
public function remaining(?Model $owner, string $meter): int|float
{
if ($meter !== 'ai_tokens') {
return INF;
}

// If in freemode, return unlimited tokens
if (config('cloudplans.freemode_enabled', false)) {
return INF;
}

if (!$owner) {
return 0;
}

// Get owner's current plan code
$planCode = $this->getPlanCode($owner);

$plan = config("cloudplans.plans.{$planCode}");
if (!$plan) {
return 0;
}

$allowance = (int)($plan['token_allowance'] ?? 0);
if ($allowance <= 0) {
return 0;
}

$periodStart = Carbon::now()->startOfMonth();
$used = method_exists($owner, 'tokensUsed') ? (int)$owner->tokensUsed($periodStart) : 0;

return max(0, $allowance - $used);
}

/**
* Consume a given amount of tokens for the current period.
*/
public function consume(?Model $owner, string $meter, int $amount): void
{
// Handled in trakli core via whilesmart/eloquent-agent-metrics
}

private function subscriptionModel(): string
{
return config('entitlements.models.subscription', Subscription::class);
}

private function activeSubscriptionFor(Model $owner): ?Subscription
{
return $this->subscriptionModel()::query()
->where('owner_type', $owner->getMorphClass())
->where('owner_id', $owner->getKey())
->active()
->latest('id')
->first();
}

/**
* Get the active plan code for the owner.
*
* The entitlement_plans table keys are suffixed with the region
* (e.g. monthly-us), while cloudplans.php is keyed by base plan
* (free, monthly, yearly). Strip the suffix before config lookup.
*/
private function getPlanCode(Model $owner): string
{
$subscription = $this->activeSubscriptionFor($owner);
if ($subscription && $subscription->plan) {
return explode('-', $subscription->plan->key)[0]; // gets monthly from monthly-eu

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Determining the plan code by splitting the key string is brittle. If a plan key doesn't follow the exact prefix-suffix format (e.g., a three-part key), this logic might return an incorrect value. Consider using a more robust mapping or Laravel's Str::before helper.

Suggested change
return explode('-', $subscription->plan->key)[0]; // gets monthly from monthly-eu
return (string) \Illuminate\Support\Str::before($subscription->plan->key, '-');

}

// Assume user is on the free plan
return 'free';
}

public function check(?Model $owner, string $feature): AccessResult
{
// If in freemode or feature is unconditionally allowed
if (config('cloudplans.freemode_enabled', false)) {
return AccessResult::allow($feature);
}

if (! $owner) {
return AccessResult::deny($feature, 'no_owner');
}

$planCode = $this->getPlanCode($owner);
$plan = config("cloudplans.plans.{$planCode}");

// Check if the feature is listed in the plan's features or permissions

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The features array in config/cloudplans.php contains human-readable strings for the UI (e.g., 'Up to 3 wallets'). Checking for a programmatic feature key (like 'ai_chat') against this array will always fail. You should introduce a machine-readable permissions array in the config or map feature keys to these strings.

Suggested change
// Check if the feature is listed in the plan's features or permissions
// Suggested: Use a dedicated permissions array for programmatic keys
$permissions = $plan['permissions'] ?? [];
if ($plan && in_array($feature, $permissions, true)) {
return AccessResult::allow($feature);
}

if ($plan && in_array($feature, $plan['features'] ?? [], true)) {
return AccessResult::allow($feature);
}

return AccessResult::deny($feature, 'not_in_plan');
}
}