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:
parent
17a091975d
commit
aacc4390a2
12 changed files with 8 additions and 119 deletions
|
|
@ -42,7 +42,6 @@ public static function execute(User $user, array $data): Workspace
|
|||
return $workspace;
|
||||
});
|
||||
|
||||
$user->account?->forgetPlanFeatureCache();
|
||||
$user->account?->syncWorkspaceQuantity();
|
||||
|
||||
return $workspace;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ public static function execute(User $user, Workspace $workspace): void
|
|||
|
||||
$workspace->delete();
|
||||
|
||||
$account?->forgetPlanFeatureCache();
|
||||
$account?->syncWorkspaceQuantity();
|
||||
|
||||
if (PostHogService::isEnabled()) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in a new issue