refactor: explicit Pennant cache reset on plan_id change
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.
This commit is contained in:
parent
b91bad7e6f
commit
f72a97f676
5 changed files with 130 additions and 19 deletions
|
|
@ -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]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, mixed> $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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue