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.
This commit is contained in:
Paulo Castellano 2026-06-22 09:55:03 -03:00
parent 17a091975d
commit aacc4390a2
12 changed files with 8 additions and 119 deletions

View file

@ -42,7 +42,6 @@ public static function execute(User $user, array $data): Workspace
return $workspace;
});
$user->account?->forgetPlanFeatureCache();
$user->account?->syncWorkspaceQuantity();
return $workspace;

View file

@ -20,7 +20,6 @@ public static function execute(User $user, Workspace $workspace): void
$workspace->delete();
$account?->forgetPlanFeatureCache();
$account?->syncWorkspaceQuantity();
if (PostHogService::isEnabled()) {

View file

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Features;
use App\Models\Account;
use App\Support\BillingCycle;
class MonthlyCreditsLimit
{
public string $name = 'monthly-credits-limit';
public function resolve(Account $scope): int
{
return BillingCycle::for($scope)->creditAllotment();
}
}

View file

@ -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'));

View file

@ -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(),
],
]);
}

View file

@ -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);
}

View file

@ -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');

View file

@ -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(),
];
}

View file

@ -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);

View file

@ -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();
});

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
use App\Enums\Plan\Slug;
use App\Features\MonthlyCreditsLimit;
use App\Models\Account;
use App\Models\Plan;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
use Laravel\Pennant\Feature;
test('forgetPlanFeatureCache drops the cached monthly credits feature', function () {
$plan = Plan::where('slug', Slug::Workspace)->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);
});

View file

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
use App\Enums\Plan\Slug;
use App\Features\MonthlyCreditsLimit;
use App\Models\Account;
use App\Models\Plan;
use App\Models\Workspace;
test('resolves to the billing cycle credit allotment', function () {
$plan = Plan::where('slug', Slug::Workspace)->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);
});