From 6523922e75315f156cd034d49b9e98228b7da3ef Mon Sep 17 00:00:00 2001 From: Kofi Date: Thu, 25 Jun 2026 21:43:22 +0100 Subject: [PATCH 1/5] feat: Configure AI usage counter # Conflicts: # src/CloudServiceProvider.php # Conflicts: # src/CloudServiceProvider.php --- config/cloudplans.php | 3 + ..._201941_create_ai_usage_counters_table.php | 31 +++++ src/Models/AiUsageCounter.php | 44 ++++++ src/Support/CloudEntitlements.php | 131 ++++++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 database/migrations/2026_06_16_201941_create_ai_usage_counters_table.php create mode 100644 src/Models/AiUsageCounter.php create mode 100644 src/Support/CloudEntitlements.php diff --git a/config/cloudplans.php b/config/cloudplans.php index 6bf2f7d..fe1aeb9 100644 --- a/config/cloudplans.php +++ b/config/cloudplans.php @@ -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', @@ -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', @@ -97,6 +99,7 @@ ], 'yearly' => [ 'id' => 'yearly', + 'token_allowance' => (int) env('CLOUD_PLAN_YEARLY_TOKEN_ALLOWANCE', 50000), 'name' => 'Yearly', 'interval' => 'year', 'features' => [ diff --git a/database/migrations/2026_06_16_201941_create_ai_usage_counters_table.php b/database/migrations/2026_06_16_201941_create_ai_usage_counters_table.php new file mode 100644 index 0000000..40bb794 --- /dev/null +++ b/database/migrations/2026_06_16_201941_create_ai_usage_counters_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->timestamp('period_start'); + $table->integer('tokens_used'); + + $table->foreign('user_id')->references('id')->on('users'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ai_usage_counters'); + } +}; diff --git a/src/Models/AiUsageCounter.php b/src/Models/AiUsageCounter.php new file mode 100644 index 0000000..6354094 --- /dev/null +++ b/src/Models/AiUsageCounter.php @@ -0,0 +1,44 @@ + + */ + protected $fillable = [ + 'user_id', + 'period_start', + 'tokens_used', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'period_start' => 'datetime', + ]; + + /** + * Get the user that owns this usage counter. + */ + public function user(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class); + } +} diff --git a/src/Support/CloudEntitlements.php b/src/Support/CloudEntitlements.php new file mode 100644 index 0000000..e41f188 --- /dev/null +++ b/src/Support/CloudEntitlements.php @@ -0,0 +1,131 @@ +getConfigValue('subscription_plan'); + if (empty($planCode)) { + return INF; + } + + $plan = config("cloudplans.plans.{$planCode}"); + if (!$plan) { + return 0; + } + + $allowance = $plan['token_allowance'] ?? 0; + if ($allowance <= 0) { + return 0; + } + + $periodStart = Carbon::now()->startOfMonth(); + $used = AiUsageCounter::where('user_id', $owner->id) + ->where('period_start', $periodStart) + ->value('tokens_used') ?? 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 + { + if ($meter !== 'ai_tokens' || $amount <= 0 || !$owner) { + return; + } + + if (config('cloudplans.freemode_enabled', false)) { + return; + } + + $planCode = $owner->getConfigValue('subscription_plan'); + if (empty($planCode)) { + return; + } + + // Extract message ID from debug backtrace to ensure idempotency + $messageId = $this->getCurrentChatMessageId(); + if ($messageId === null) { + return; + } + + $cacheKey = "cloud_ai_consumed_message_{$messageId}"; + if (Cache::has($cacheKey)) { + return; + } + Cache::put($cacheKey, true, now()->addDays(30)); + + $periodStart = Carbon::now()->startOfMonth(); + + $counter = AiUsageCounter::where('user_id', $owner->id) + ->where('period_start', $periodStart) + ->first(); + + if ($counter) { + $counter->increment('tokens_used', $amount); + } else { + AiUsageCounter::create([ + 'user_id' => $owner->id, + 'period_start' => $periodStart, + 'tokens_used' => $amount, + ]); + } + } + + /** + * Find the chat message ID from the backtrace to key the idempotent increment. + */ + private function getCurrentChatMessageId(): ?int + { + foreach (debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT) as $frame) { + if (isset($frame['object']) && $frame['object'] instanceof \App\Jobs\ProcessChatMessageJob) { + return $frame['object']->assistantMessage->id; + } + } + return null; + } +} From 725a70f20b83234e538668bb7ef1406b58272285 Mon Sep 17 00:00:00 2001 From: Kofi Date: Sun, 28 Jun 2026 17:51:43 +0100 Subject: [PATCH 2/5] chore: Update Entitlements --- src/Support/CloudEntitlements.php | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/Support/CloudEntitlements.php b/src/Support/CloudEntitlements.php index e41f188..2173d10 100644 --- a/src/Support/CloudEntitlements.php +++ b/src/Support/CloudEntitlements.php @@ -45,9 +45,9 @@ public function remaining(?Model $owner, string $meter): int|float return 0; } - // Get owner's current plan - $planCode = $owner->getConfigValue('subscription_plan'); - if (empty($planCode)) { + // Get owner's current plan via Cashier + $planCode = $this->getPlanCode($owner); + if ($planCode === 'free') { return INF; } @@ -82,8 +82,8 @@ public function consume(?Model $owner, string $meter, int $amount): void return; } - $planCode = $owner->getConfigValue('subscription_plan'); - if (empty($planCode)) { + $planCode = $this->getPlanCode($owner); + if ($planCode === 'free') { return; } @@ -116,6 +116,26 @@ public function consume(?Model $owner, string $meter, int $amount): void } } + /** + * Get the active plan code for the owner via Cashier. + */ + private function getPlanCode(Model $owner): string + { + /** @var \Trakli\Cloud\Models\BillingCustomer|null $billingCustomer */ + $billingCustomer = \Trakli\Cloud\Models\BillingCustomer::where('user_id', $owner->getKey())->first(); + if ($billingCustomer) { + if ($billingCustomer->subscribed('monthly')) { + return 'monthly'; + } + if ($billingCustomer->subscribed('yearly')) { + return 'yearly'; + } + return 'free'; + } + + return 'free'; + } + /** * Find the chat message ID from the backtrace to key the idempotent increment. */ From 6c7a2efdc6e75a727a2ccd84c4b691167fa8a230 Mon Sep 17 00:00:00 2001 From: Kofi Date: Mon, 6 Jul 2026 21:21:33 +0100 Subject: [PATCH 3/5] feat: Make ai usage counter polymorphic --- ..._201941_create_ai_usage_counters_table.php | 3 +- src/Models/AiUsageCounter.php | 8 +++-- src/Support/CloudEntitlements.php | 34 ++++--------------- 3 files changed, 12 insertions(+), 33 deletions(-) diff --git a/database/migrations/2026_06_16_201941_create_ai_usage_counters_table.php b/database/migrations/2026_06_16_201941_create_ai_usage_counters_table.php index 40bb794..a16f6d1 100644 --- a/database/migrations/2026_06_16_201941_create_ai_usage_counters_table.php +++ b/database/migrations/2026_06_16_201941_create_ai_usage_counters_table.php @@ -12,11 +12,10 @@ public function up(): void { Schema::create('ai_usage_counters', function (Blueprint $table) { $table->id(); - $table->unsignedBigInteger('user_id'); + $table->morphs('owner'); $table->timestamp('period_start'); $table->integer('tokens_used'); - $table->foreign('user_id')->references('id')->on('users'); $table->timestamps(); }); } diff --git a/src/Models/AiUsageCounter.php b/src/Models/AiUsageCounter.php index 6354094..70ca5c0 100644 --- a/src/Models/AiUsageCounter.php +++ b/src/Models/AiUsageCounter.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; class AiUsageCounter extends Model { @@ -20,7 +21,8 @@ class AiUsageCounter extends Model * @var array */ protected $fillable = [ - 'user_id', + 'owner_id', + 'owner_type', 'period_start', 'tokens_used', ]; @@ -37,8 +39,8 @@ class AiUsageCounter extends Model /** * Get the user that owns this usage counter. */ - public function user(): BelongsTo + public function owner(): MorphTo { - return $this->belongsTo(\App\Models\User::class); + return $this->morphTo(); } } diff --git a/src/Support/CloudEntitlements.php b/src/Support/CloudEntitlements.php index 2173d10..5bb4315 100644 --- a/src/Support/CloudEntitlements.php +++ b/src/Support/CloudEntitlements.php @@ -62,7 +62,8 @@ public function remaining(?Model $owner, string $meter): int|float } $periodStart = Carbon::now()->startOfMonth(); - $used = AiUsageCounter::where('user_id', $owner->id) + $used = AiUsageCounter::where('owner_id', $owner->id) + ->where('owner_type', get_class($owner)) ->where('period_start', $periodStart) ->value('tokens_used') ?? 0; @@ -87,21 +88,10 @@ public function consume(?Model $owner, string $meter, int $amount): void return; } - // Extract message ID from debug backtrace to ensure idempotency - $messageId = $this->getCurrentChatMessageId(); - if ($messageId === null) { - return; - } - - $cacheKey = "cloud_ai_consumed_message_{$messageId}"; - if (Cache::has($cacheKey)) { - return; - } - Cache::put($cacheKey, true, now()->addDays(30)); - $periodStart = Carbon::now()->startOfMonth(); - $counter = AiUsageCounter::where('user_id', $owner->id) + $counter = AiUsageCounter::where('owner_id', $owner->id) + ->where('owner_type', get_class($owner)) ->where('period_start', $periodStart) ->first(); @@ -109,7 +99,8 @@ public function consume(?Model $owner, string $meter, int $amount): void $counter->increment('tokens_used', $amount); } else { AiUsageCounter::create([ - 'user_id' => $owner->id, + 'owner_id' => $owner->id, + 'owner_type' => get_class($owner), 'period_start' => $periodStart, 'tokens_used' => $amount, ]); @@ -135,17 +126,4 @@ private function getPlanCode(Model $owner): string return 'free'; } - - /** - * Find the chat message ID from the backtrace to key the idempotent increment. - */ - private function getCurrentChatMessageId(): ?int - { - foreach (debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT) as $frame) { - if (isset($frame['object']) && $frame['object'] instanceof \App\Jobs\ProcessChatMessageJob) { - return $frame['object']->assistantMessage->id; - } - } - return null; - } } From b89795fc34e41b277ee62a3d3cc895658ad190dc Mon Sep 17 00:00:00 2001 From: Kofi Date: Wed, 15 Jul 2026 22:47:03 +0100 Subject: [PATCH 4/5] refactor: Integrate eloquent-agent-metrics package for AI token tracking --- ..._201941_create_ai_usage_counters_table.php | 30 ------------ src/Models/AiUsageCounter.php | 46 ------------------- src/Support/CloudEntitlements.php | 37 +-------------- 3 files changed, 2 insertions(+), 111 deletions(-) delete mode 100644 database/migrations/2026_06_16_201941_create_ai_usage_counters_table.php delete mode 100644 src/Models/AiUsageCounter.php diff --git a/database/migrations/2026_06_16_201941_create_ai_usage_counters_table.php b/database/migrations/2026_06_16_201941_create_ai_usage_counters_table.php deleted file mode 100644 index a16f6d1..0000000 --- a/database/migrations/2026_06_16_201941_create_ai_usage_counters_table.php +++ /dev/null @@ -1,30 +0,0 @@ -id(); - $table->morphs('owner'); - $table->timestamp('period_start'); - $table->integer('tokens_used'); - - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('ai_usage_counters'); - } -}; diff --git a/src/Models/AiUsageCounter.php b/src/Models/AiUsageCounter.php deleted file mode 100644 index 70ca5c0..0000000 --- a/src/Models/AiUsageCounter.php +++ /dev/null @@ -1,46 +0,0 @@ - - */ - protected $fillable = [ - 'owner_id', - 'owner_type', - 'period_start', - 'tokens_used', - ]; - - /** - * The attributes that should be cast. - * - * @var array - */ - protected $casts = [ - 'period_start' => 'datetime', - ]; - - /** - * Get the user that owns this usage counter. - */ - public function owner(): MorphTo - { - return $this->morphTo(); - } -} diff --git a/src/Support/CloudEntitlements.php b/src/Support/CloudEntitlements.php index 5bb4315..daf4299 100644 --- a/src/Support/CloudEntitlements.php +++ b/src/Support/CloudEntitlements.php @@ -7,7 +7,6 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Carbon; -use Trakli\Cloud\Models\AiUsageCounter; class CloudEntitlements implements Entitlements { @@ -62,10 +61,7 @@ public function remaining(?Model $owner, string $meter): int|float } $periodStart = Carbon::now()->startOfMonth(); - $used = AiUsageCounter::where('owner_id', $owner->id) - ->where('owner_type', get_class($owner)) - ->where('period_start', $periodStart) - ->value('tokens_used') ?? 0; + $used = method_exists($owner, 'tokensUsed') ? $owner->tokensUsed($periodStart) : 0; return max(0, $allowance - $used); } @@ -75,36 +71,7 @@ public function remaining(?Model $owner, string $meter): int|float */ public function consume(?Model $owner, string $meter, int $amount): void { - if ($meter !== 'ai_tokens' || $amount <= 0 || !$owner) { - return; - } - - if (config('cloudplans.freemode_enabled', false)) { - return; - } - - $planCode = $this->getPlanCode($owner); - if ($planCode === 'free') { - return; - } - - $periodStart = Carbon::now()->startOfMonth(); - - $counter = AiUsageCounter::where('owner_id', $owner->id) - ->where('owner_type', get_class($owner)) - ->where('period_start', $periodStart) - ->first(); - - if ($counter) { - $counter->increment('tokens_used', $amount); - } else { - AiUsageCounter::create([ - 'owner_id' => $owner->id, - 'owner_type' => get_class($owner), - 'period_start' => $periodStart, - 'tokens_used' => $amount, - ]); - } + // Handled in trakli core via whilesmart/eloquent-agent-metrics } /** From 8c0b818808603efd4920c677cc520e261e4fd00e Mon Sep 17 00:00:00 2001 From: kofimokome Date: Mon, 3 Aug 2026 19:16:20 +0100 Subject: [PATCH 5/5] chore: Update algorithm to get current plan --- src/CloudServiceProvider.php | 3 +- src/Support/CloudEntitlements.php | 77 +++++++++++++++++++++++-------- 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/src/CloudServiceProvider.php b/src/CloudServiceProvider.php index 7dd18ff..f28899d 100644 --- a/src/CloudServiceProvider.php +++ b/src/CloudServiceProvider.php @@ -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 { @@ -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]); } diff --git a/src/Support/CloudEntitlements.php b/src/Support/CloudEntitlements.php index daf4299..86e44ae 100644 --- a/src/Support/CloudEntitlements.php +++ b/src/Support/CloudEntitlements.php @@ -2,11 +2,12 @@ namespace Trakli\Cloud\Support; -use App\Contracts\Entitlements; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Carbon; +use Whilesmart\Entitlements\Contracts\Entitlements; +use Whilesmart\Entitlements\Models\Subscription; +use Whilesmart\Entitlements\Support\AccessResult; + class CloudEntitlements implements Entitlements { @@ -44,24 +45,21 @@ public function remaining(?Model $owner, string $meter): int|float return 0; } - // Get owner's current plan via Cashier + // Get owner's current plan code $planCode = $this->getPlanCode($owner); - if ($planCode === 'free') { - return INF; - } $plan = config("cloudplans.plans.{$planCode}"); if (!$plan) { return 0; } - $allowance = $plan['token_allowance'] ?? 0; + $allowance = (int)($plan['token_allowance'] ?? 0); if ($allowance <= 0) { return 0; } $periodStart = Carbon::now()->startOfMonth(); - $used = method_exists($owner, 'tokensUsed') ? $owner->tokensUsed($periodStart) : 0; + $used = method_exists($owner, 'tokensUsed') ? (int)$owner->tokensUsed($periodStart) : 0; return max(0, $allowance - $used); } @@ -74,23 +72,62 @@ 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 via Cashier. + * 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 { - /** @var \Trakli\Cloud\Models\BillingCustomer|null $billingCustomer */ - $billingCustomer = \Trakli\Cloud\Models\BillingCustomer::where('user_id', $owner->getKey())->first(); - if ($billingCustomer) { - if ($billingCustomer->subscribed('monthly')) { - return 'monthly'; - } - if ($billingCustomer->subscribed('yearly')) { - return 'yearly'; - } - return 'free'; + $subscription = $this->activeSubscriptionFor($owner); + if ($subscription && $subscription->plan) { + return explode('-', $subscription->plan->key)[0]; // gets monthly from monthly-eu } + // 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 + if ($plan && in_array($feature, $plan['features'] ?? [], true)) { + return AccessResult::allow($feature); + } + + return AccessResult::deny($feature, 'not_in_plan'); + } } + + + +