From f72a97f6766548da66c938de96ef6c4be1172ec5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 7 May 2026 10:59:48 -0300 Subject: [PATCH] refactor: explicit Pennant cache reset on plan_id change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the implicit Account::booted() observer with an explicit Account::forgetPlanFeatureCache() method called from each plan_id mutation site (StripeEventListener x3, BillingController x2). Self-hosted installs naturally never reach any of these callsites — Stripe webhooks do not fire and the billing controllers redirect to /calendar before any plan mutation happens — so the Pennant flush is now guaranteed to be a cloud-only operation. Adds integration coverage proving the full chain webhook -> plan_id update -> Pennant flush -> next Feature::value resolves against the new plan limit. --- .../Controllers/App/BillingController.php | 8 ++ app/Listeners/StripeEventListener.php | 15 +++- app/Models/Account.php | 25 +++--- .../Listeners/StripeEventListenerTest.php | 80 +++++++++++++++++++ tests/Feature/Models/AccountModelTest.php | 21 +++-- 5 files changed, 130 insertions(+), 19 deletions(-) diff --git a/app/Http/Controllers/App/BillingController.php b/app/Http/Controllers/App/BillingController.php index 8d346b94..3338e4bb 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -72,6 +72,10 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse|Redirect $account->update(['plan_id' => $plan->id]); + if ($account->wasChanged('plan_id')) { + $account->forgetPlanFeatureCache(); + } + return Inertia::location($checkoutSession->url); } @@ -164,6 +168,10 @@ public function swap(Request $request, Plan $plan): RedirectResponse $subscription->swap($priceId); $account->update(['plan_id' => $plan->id]); + if ($account->wasChanged('plan_id')) { + $account->forgetPlanFeatureCache(); + } + return redirect()->route('app.billing.index') ->with('flash.success', __('billing.flash.plan_changed', ['plan' => $plan->name])); } diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index 5937f0f4..02f361f8 100644 --- a/app/Listeners/StripeEventListener.php +++ b/app/Listeners/StripeEventListener.php @@ -53,6 +53,10 @@ protected function handleSubscriptionCreated(Account $account, array $payload): if ($plan = $this->resolvePlanFromSubscriptionItems($payload, $account)) { $account->update(['plan_id' => $plan->id]); + + if ($account->wasChanged('plan_id')) { + $account->forgetPlanFeatureCache(); + } } SubscriptionCreated::dispatch($account); @@ -64,8 +68,7 @@ protected function handleSubscriptionCreated(Account $account, array $payload): * Plan changes coming from Stripe (billing portal, dashboard, or our own * `BillingController@swap`) re-arrive here. Re-resolving the plan from * the price ids guarantees the local state matches Stripe even when a - * change happens out-of-band. Pennant feature caches are forgotten - * automatically via Account::booted() when `plan_id` changes. + * change happens out-of-band. * * @param array $payload */ @@ -75,6 +78,10 @@ protected function handleSubscriptionUpdated(Account $account, array $payload): if ($plan = $this->resolvePlanFromSubscriptionItems($payload, $account)) { $account->update(['plan_id' => $plan->id]); + + if ($account->wasChanged('plan_id')) { + $account->forgetPlanFeatureCache(); + } } $this->trackPlanChange($account, BillingEvent::Updated, $previousPlan, $payload); @@ -94,6 +101,10 @@ protected function handleSubscriptionDeleted(Account $account, array $payload): $account->update(['plan_id' => null]); + if ($account->wasChanged('plan_id')) { + $account->forgetPlanFeatureCache(); + } + $this->trackPlanChange($account, BillingEvent::Cancelled, $previousPlan, $payload); } diff --git a/app/Models/Account.php b/app/Models/Account.php index df81576d..56edf84f 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -32,18 +32,21 @@ class Account extends Model 'plan_id', ]; - protected static function booted(): void + /** + * Drop the cached Pennant feature values for the plan-scoped limits so + * the next read resolves against the current plan. Call this right after + * any `plan_id` change. Centralised here (instead of as a model hook) so + * self-hosted installs — where Stripe webhooks never fire and the billing + * controllers redirect away — never trigger this path. + */ + public function forgetPlanFeatureCache(): void { - static::updated(function (Account $account): void { - if ($account->wasChanged('plan_id')) { - Feature::for($account)->forget([ - WorkspaceLimit::class, - SocialAccountLimit::class, - MemberLimit::class, - MonthlyCreditsLimit::class, - ]); - } - }); + Feature::for($this)->forget([ + WorkspaceLimit::class, + SocialAccountLimit::class, + MemberLimit::class, + MonthlyCreditsLimit::class, + ]); } public function owner(): BelongsTo diff --git a/tests/Feature/Listeners/StripeEventListenerTest.php b/tests/Feature/Listeners/StripeEventListenerTest.php index aab99c10..fba305d0 100644 --- a/tests/Feature/Listeners/StripeEventListenerTest.php +++ b/tests/Feature/Listeners/StripeEventListenerTest.php @@ -4,15 +4,18 @@ use App\Enums\PostHog\BillingEvent; use App\Events\SubscriptionCreated; +use App\Features\WorkspaceLimit; 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 Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Events\WebhookReceived; +use Laravel\Pennant\Feature; beforeEach(function () { $this->account = Account::factory()->create(['stripe_id' => 'cus_test123']); @@ -394,3 +397,80 @@ fn ($job) => $job->event === BillingEvent::Created && $job->previousPlan === null, ); }); + +// ======================================== +// Pennant feature cache invalidation +// ======================================== + +test('subscription updated flushes the pennant cache when the plan changes', 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 against the starter limit. + expect(Feature::for($this->account)->value(WorkspaceLimit::class)) + ->toBe($starter->workspace_limit); + + expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count()) + ->toBeGreaterThan(0); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'items' => ['data' => [['price' => ['id' => 'price_pro_monthly']]]], + ]], + ])); + + expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count()) + ->toBe(0); + + expect(Feature::for($this->account->fresh())->value(WorkspaceLimit::class)) + ->toBe($pro->workspace_limit); +}); + +test('subscription deleted flushes the pennant cache', function () { + $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); + $this->account->update(['plan_id' => $starter->id]); + + Feature::for($this->account)->value(WorkspaceLimit::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); +}); + +test('subscription updated with same plan does not flush the pennant cache', function () { + $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); + $this->account->update(['plan_id' => $starter->id]); + + Feature::for($this->account)->value(WorkspaceLimit::class); + + $cachedRow = DB::table('features') + ->where('scope', 'account|'.$this->account->id) + ->first(); + + expect($cachedRow)->not->toBeNull(); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'items' => ['data' => [['price' => ['id' => 'price_starter_monthly']]]], + ]], + ])); + + $stillCachedRow = DB::table('features') + ->where('scope', 'account|'.$this->account->id) + ->first(); + + expect($stillCachedRow->id)->toBe($cachedRow->id); +}); diff --git a/tests/Feature/Models/AccountModelTest.php b/tests/Feature/Models/AccountModelTest.php index ec8113b8..100751df 100644 --- a/tests/Feature/Models/AccountModelTest.php +++ b/tests/Feature/Models/AccountModelTest.php @@ -11,27 +11,36 @@ use Illuminate\Support\Facades\DB; use Laravel\Pennant\Feature; -test('booted hook flushes pennant cache when plan_id changes', function () { +test('forgetPlanFeatureCache drops the cached plan-scoped features', function () { $starter = Plan::where('slug', 'starter')->first(); $plus = Plan::where('slug', 'plus')->first(); $account = Account::factory()->create(['plan_id' => $starter->id]); - expect(Feature::for($account)->value(WorkspaceLimit::class))->toBe($starter->workspace_limit); - expect(Feature::for($account)->value(SocialAccountLimit::class))->toBe($starter->social_account_limit); - expect(Feature::for($account)->value(MemberLimit::class))->toBe($starter->member_limit); - expect(Feature::for($account)->value(MonthlyCreditsLimit::class))->toBe($starter->monthly_credits_limit); + // Prime the Pennant cache against the starter plan. + Feature::for($account)->value(WorkspaceLimit::class); + Feature::for($account)->value(SocialAccountLimit::class); + Feature::for($account)->value(MemberLimit::class); + Feature::for($account)->value(MonthlyCreditsLimit::class); + expect(DB::table('features')->where('scope', 'account|'.$account->id)->count()) + ->toBe(4); + + // Move the account to a plan with different limits and forget the cache. $account->update(['plan_id' => $plus->id]); + $account->forgetPlanFeatureCache(); $account->load('plan'); + expect(DB::table('features')->where('scope', 'account|'.$account->id)->count()) + ->toBe(0); + expect(Feature::for($account)->value(WorkspaceLimit::class))->toBe($plus->workspace_limit); expect(Feature::for($account)->value(SocialAccountLimit::class))->toBe($plus->social_account_limit); expect(Feature::for($account)->value(MemberLimit::class))->toBe($plus->member_limit); expect(Feature::for($account)->value(MonthlyCreditsLimit::class))->toBe($plus->monthly_credits_limit); }); -test('booted hook does not flush pennant when other fields change', function () { +test('updating non-plan fields does not flush the pennant cache', function () { $plan = Plan::where('slug', 'plus')->first(); $account = Account::factory()->create(['plan_id' => $plan->id]);