From aacc4390a275bbcbf76d309959c2ece3611b9622 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 22 Jun 2026 09:55:03 -0300 Subject: [PATCH] refactor(billing): replace MonthlyCreditsLimit Pennant feature with BillingCycle Credit allotment is now derived directly from BillingCycle::for($account)->creditAllotment() instead of a cached Pennant feature, removing the dynamic cache-invalidation footgun (forgetPlanFeatureCache) that had to be called from every subscription/workspace mutation. --- app/Actions/Workspace/CreateWorkspace.php | 1 - app/Actions/Workspace/DeleteWorkspace.php | 1 - app/Features/MonthlyCreditsLimit.php | 18 ----------- .../Controllers/App/BillingController.php | 1 - .../App/Settings/UsageController.php | 4 +-- app/Listeners/StripeEventListener.php | 3 -- app/Models/Account.php | 9 ------ app/Models/Traits/HasUsage.php | 10 +++---- .../Billing/WorkspaceQuantitySyncTest.php | 2 -- .../Listeners/StripeEventListenerTest.php | 24 ++------------- tests/Feature/Models/AccountModelTest.php | 30 ------------------- .../Unit/Features/MonthlyCreditsLimitTest.php | 24 --------------- 12 files changed, 8 insertions(+), 119 deletions(-) delete mode 100644 app/Features/MonthlyCreditsLimit.php delete mode 100644 tests/Feature/Models/AccountModelTest.php delete mode 100644 tests/Unit/Features/MonthlyCreditsLimitTest.php diff --git a/app/Actions/Workspace/CreateWorkspace.php b/app/Actions/Workspace/CreateWorkspace.php index d591ba8c..4c42fe67 100644 --- a/app/Actions/Workspace/CreateWorkspace.php +++ b/app/Actions/Workspace/CreateWorkspace.php @@ -42,7 +42,6 @@ public static function execute(User $user, array $data): Workspace return $workspace; }); - $user->account?->forgetPlanFeatureCache(); $user->account?->syncWorkspaceQuantity(); return $workspace; diff --git a/app/Actions/Workspace/DeleteWorkspace.php b/app/Actions/Workspace/DeleteWorkspace.php index aa889ecd..e43e8cbe 100644 --- a/app/Actions/Workspace/DeleteWorkspace.php +++ b/app/Actions/Workspace/DeleteWorkspace.php @@ -20,7 +20,6 @@ public static function execute(User $user, Workspace $workspace): void $workspace->delete(); - $account?->forgetPlanFeatureCache(); $account?->syncWorkspaceQuantity(); if (PostHogService::isEnabled()) { diff --git a/app/Features/MonthlyCreditsLimit.php b/app/Features/MonthlyCreditsLimit.php deleted file mode 100644 index b4dc719c..00000000 --- a/app/Features/MonthlyCreditsLimit.php +++ /dev/null @@ -1,18 +0,0 @@ -creditAllotment(); - } -} diff --git a/app/Http/Controllers/App/BillingController.php b/app/Http/Controllers/App/BillingController.php index fae503e8..d085906f 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -143,7 +143,6 @@ public function swapToYearly(Request $request): RedirectResponse } $subscription->swap($yearlyPriceId); - $account->forgetPlanFeatureCache(); return redirect()->route('app.billing.index') ->with('flash.success', __('billing.flash.switched_to_yearly')); diff --git a/app/Http/Controllers/App/Settings/UsageController.php b/app/Http/Controllers/App/Settings/UsageController.php index 13e240e7..7940ef58 100644 --- a/app/Http/Controllers/App/Settings/UsageController.php +++ b/app/Http/Controllers/App/Settings/UsageController.php @@ -4,14 +4,12 @@ namespace App\Http\Controllers\App\Settings; -use App\Features\MonthlyCreditsLimit; use App\Http\Controllers\App\Controller; use App\Support\BillingCycle; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; -use Laravel\Pennant\Feature; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class UsageController extends Controller @@ -39,7 +37,7 @@ public function index(Request $request): Response|RedirectResponse 'socialAccountCount' => $totalSocialAccounts, 'memberCount' => $totalMembers, 'creditsUsed' => BillingCycle::for($account)->usedCredits(), - 'monthlyCreditsLimit' => Feature::for($account)->value(MonthlyCreditsLimit::class), + 'monthlyCreditsLimit' => BillingCycle::for($account)->creditAllotment(), ], ]); } diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index 4a701c0b..14e0cd19 100644 --- a/app/Listeners/StripeEventListener.php +++ b/app/Listeners/StripeEventListener.php @@ -56,7 +56,6 @@ protected function handleSubscriptionCreated(Account $account, array $payload): 'plan_id' => $plan->id, 'trial_ends_at' => null, ]); - $account->forgetPlanFeatureCache(); } $this->trackPlanChange($account, BillingEvent::Created, $previousPlan, $payload); @@ -71,7 +70,6 @@ protected function handleSubscriptionUpdated(Account $account, array $payload): if ($plan = $this->resolvePlanFromSubscriptionItems($payload, $account)) { $account->update(['plan_id' => $plan->id]); - $account->forgetPlanFeatureCache(); } $this->trackPlanChange($account, BillingEvent::Updated, $previousPlan, $payload); @@ -89,7 +87,6 @@ protected function handleSubscriptionDeleted(Account $account, array $payload): $previousPlan = $account->plan?->name; $account->update(['plan_id' => null]); - $account->forgetPlanFeatureCache(); $this->trackPlanChange($account, BillingEvent::Cancelled, $previousPlan, $payload); } diff --git a/app/Models/Account.php b/app/Models/Account.php index ce5454ba..b8e31b00 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -4,7 +4,6 @@ namespace App\Models; -use App\Features\MonthlyCreditsLimit; use App\Models\Traits\HasUsage; use Carbon\CarbonInterface; use Database\Factories\AccountFactory; @@ -15,7 +14,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Billable; -use Laravel\Pennant\Feature; use Throwable; class Account extends Model @@ -46,13 +44,6 @@ public static function postsCountCacheKey(string $accountId): string 'trial_ends_at' => 'datetime', ]; - public function forgetPlanFeatureCache(): void - { - Feature::for($this)->forget([ - MonthlyCreditsLimit::class, - ]); - } - public function owner(): BelongsTo { return $this->belongsTo(User::class, 'owner_id'); diff --git a/app/Models/Traits/HasUsage.php b/app/Models/Traits/HasUsage.php index 0ae06502..f37b2ff2 100644 --- a/app/Models/Traits/HasUsage.php +++ b/app/Models/Traits/HasUsage.php @@ -4,20 +4,18 @@ namespace App\Models\Traits; -use App\Features\MonthlyCreditsLimit; use App\Models\Account; use App\Models\Invite; use App\Models\Post; use App\Support\BillingCycle; use Illuminate\Support\Facades\Cache; -use Laravel\Pennant\Feature; /** * Provides account-level usage counts and plan-resolved feature limits. * - * The `featureLimits()` call resolves the Pennant credit feature for the account, - * which writes to the features cache table on first access. The cache is invalidated - * explicitly via `Account::forgetPlanFeatureCache()` on plan and workspace changes. + * `featureLimits()` resolves the account's per-cycle credit allotment directly + * from BillingCycle, computed fresh from the plan, workspace count, and billing + * interval — no caching, so there is nothing to invalidate. */ trait HasUsage { @@ -56,7 +54,7 @@ public function usage(): array public function featureLimits(): array { return [ - 'monthlyCreditsLimit' => Feature::for($this)->value(MonthlyCreditsLimit::class), + 'monthlyCreditsLimit' => BillingCycle::for($this)->creditAllotment(), ]; } diff --git a/tests/Feature/Billing/WorkspaceQuantitySyncTest.php b/tests/Feature/Billing/WorkspaceQuantitySyncTest.php index 1113ae2b..8f2bae64 100644 --- a/tests/Feature/Billing/WorkspaceQuantitySyncTest.php +++ b/tests/Feature/Billing/WorkspaceQuantitySyncTest.php @@ -54,7 +54,6 @@ $user = User::factory()->create(); $account = mock(Account::class)->makePartial(); - $account->shouldReceive('forgetPlanFeatureCache')->once(); $account->shouldReceive('syncWorkspaceQuantity')->once(); $user->setRelation('account', $account); @@ -69,7 +68,6 @@ ]); $account = mock(Account::class)->makePartial(); - $account->shouldReceive('forgetPlanFeatureCache')->once(); $account->shouldReceive('syncWorkspaceQuantity')->once(); $workspace->setRelation('account', $account); diff --git a/tests/Feature/Listeners/StripeEventListenerTest.php b/tests/Feature/Listeners/StripeEventListenerTest.php index f3ebf327..74ffc0ff 100644 --- a/tests/Feature/Listeners/StripeEventListenerTest.php +++ b/tests/Feature/Listeners/StripeEventListenerTest.php @@ -4,16 +4,13 @@ use App\Enums\Plan\Slug; use App\Enums\PostHog\BillingEvent; -use App\Features\MonthlyCreditsLimit; use App\Jobs\PostHog\TrackBilling; use App\Listeners\StripeEventListener; use App\Models\Account; use App\Models\Plan; use App\Models\User; use Illuminate\Support\Facades\Bus; -use Illuminate\Support\Facades\DB; use Laravel\Cashier\Events\WebhookReceived; -use Laravel\Pennant\Feature; beforeEach(function () { config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); @@ -439,18 +436,12 @@ // Pennant feature cache invalidation // ======================================== -test('subscription updated flushes the pennant cache when the plan changes', function () { +test('subscription updated maps the plan by price id', function () { $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); $pro = Plan::query()->where('slug', 'pro')->firstOrFail(); $this->account->update(['plan_id' => $starter->id]); - // Prime the Pennant cache. - Feature::for($this->account)->value(MonthlyCreditsLimit::class); - - expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count()) - ->toBeGreaterThan(0); - $this->listener->handle(new WebhookReceived([ 'type' => 'customer.subscription.updated', 'data' => ['object' => [ @@ -459,9 +450,6 @@ ]], ])); - expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count()) - ->toBe(0); - expect($this->account->fresh()->plan_id)->toBe($pro->id); }); @@ -480,20 +468,14 @@ expect($this->account->fresh()->plan_id)->toBe($pro->id); }); -test('subscription deleted flushes the pennant cache', function () { +test('subscription deleted clears the plan_id', function () { $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); $this->account->update(['plan_id' => $starter->id]); - Feature::for($this->account)->value(MonthlyCreditsLimit::class); - - expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count()) - ->toBeGreaterThan(0); - $this->listener->handle(new WebhookReceived([ 'type' => 'customer.subscription.deleted', 'data' => ['object' => ['customer' => 'cus_test123']], ])); - expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count()) - ->toBe(0); + expect($this->account->fresh()->plan_id)->toBeNull(); }); diff --git a/tests/Feature/Models/AccountModelTest.php b/tests/Feature/Models/AccountModelTest.php deleted file mode 100644 index 04457dc4..00000000 --- a/tests/Feature/Models/AccountModelTest.php +++ /dev/null @@ -1,30 +0,0 @@ -first(); - - $account = Account::factory()->create(['plan_id' => $plan->id]); - Workspace::factory()->create(['account_id' => $account->id]); - - Feature::for($account)->value(MonthlyCreditsLimit::class); - - expect(DB::table('features')->where('scope', 'account|'.$account->id)->count()) - ->toBe(1); - - $account->forgetPlanFeatureCache(); - - expect(DB::table('features')->where('scope', 'account|'.$account->id)->count()) - ->toBe(0); - - expect(Feature::for($account)->value(MonthlyCreditsLimit::class))->toBe(2500); -}); diff --git a/tests/Unit/Features/MonthlyCreditsLimitTest.php b/tests/Unit/Features/MonthlyCreditsLimitTest.php deleted file mode 100644 index 261ba4fd..00000000 --- a/tests/Unit/Features/MonthlyCreditsLimitTest.php +++ /dev/null @@ -1,24 +0,0 @@ -first(); - $account = Account::factory()->create(['plan_id' => $plan->id, 'trial_ends_at' => null]); - Workspace::factory()->count(2)->create(['account_id' => $account->id]); - - expect((new MonthlyCreditsLimit)->resolve($account))->toBe(5000); -}); - -test('resolves to zero when the account has no workspaces', function () { - $plan = Plan::where('slug', Slug::Workspace)->first(); - $account = Account::factory()->create(['plan_id' => $plan->id, 'trial_ends_at' => null]); - - expect((new MonthlyCreditsLimit)->resolve($account))->toBe(0); -});