From 21e45b4847987a2940afc1439d7432ab60b070da Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 22 Jun 2026 15:31:32 -0300 Subject: [PATCH] chore: remove legacy plan tiers (starter/plus/pro/max) All customers were migrated to the single Workspace plan and the old plan rows were deleted in production, so drop the now-dead legacy tiers from the code: reduce the Plan Slug enum to Workspace only and default the PlanFactory to it. Rework StripeEventListenerTest around the single Workspace plan (its monthly and yearly price ids still exercise plan-by-price mapping, trial clearing, deletion, and previous-plan propagation), and point the remaining tests that referenced the starter/pro slugs at the seeded Workspace plan. --- app/Enums/Plan/Slug.php | 8 - database/factories/PlanFactory.php | 4 +- .../Jobs/PostHog/SyncAccountUsageTest.php | 2 +- tests/Feature/Jobs/PostHog/SyncUserTest.php | 2 +- .../Feature/Jobs/PostHog/TrackBillingTest.php | 2 +- .../Listeners/StripeEventListenerTest.php | 242 ++++++------------ tests/Feature/PlanTest.php | 10 +- tests/Unit/Models/AccountTest.php | 2 +- tests/Unit/PostHogServiceTest.php | 2 +- 9 files changed, 87 insertions(+), 187 deletions(-) diff --git a/app/Enums/Plan/Slug.php b/app/Enums/Plan/Slug.php index 7b056c48..b750b4bf 100644 --- a/app/Enums/Plan/Slug.php +++ b/app/Enums/Plan/Slug.php @@ -6,19 +6,11 @@ enum Slug: string { - case Starter = 'starter'; - case Plus = 'plus'; - case Pro = 'pro'; - case Max = 'max'; case Workspace = 'workspace'; public function label(): string { return match ($this) { - self::Starter => 'Starter', - self::Plus => 'Plus', - self::Pro => 'Pro', - self::Max => 'Max', self::Workspace => 'Workspace', }; } diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php index 522130c1..fd3d562f 100644 --- a/database/factories/PlanFactory.php +++ b/database/factories/PlanFactory.php @@ -21,8 +21,8 @@ class PlanFactory extends Factory public function definition(): array { return [ - 'slug' => fake()->randomElement(Slug::cases()), - 'name' => fake()->word(), + 'slug' => Slug::Workspace, + 'name' => 'Workspace', 'stripe_monthly_price_id' => null, 'stripe_yearly_price_id' => null, 'monthly_credits_limit' => 2500, diff --git a/tests/Feature/Jobs/PostHog/SyncAccountUsageTest.php b/tests/Feature/Jobs/PostHog/SyncAccountUsageTest.php index 77d4c209..e17dca94 100644 --- a/tests/Feature/Jobs/PostHog/SyncAccountUsageTest.php +++ b/tests/Feature/Jobs/PostHog/SyncAccountUsageTest.php @@ -19,7 +19,7 @@ config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); $this->account = Account::factory()->create([ - 'plan_id' => Plan::query()->where('slug', 'starter')->first()?->id, + 'plan_id' => Plan::query()->where('slug', 'workspace')->first()?->id, ]); $this->user = User::factory()->create(['account_id' => $this->account->id]); $this->account->update(['owner_id' => $this->user->id]); diff --git a/tests/Feature/Jobs/PostHog/SyncUserTest.php b/tests/Feature/Jobs/PostHog/SyncUserTest.php index 37af8157..9e5dc252 100644 --- a/tests/Feature/Jobs/PostHog/SyncUserTest.php +++ b/tests/Feature/Jobs/PostHog/SyncUserTest.php @@ -17,7 +17,7 @@ config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); $this->account = Account::factory()->create([ - 'plan_id' => Plan::query()->where('slug', 'starter')->first()?->id, + 'plan_id' => Plan::query()->where('slug', 'workspace')->first()?->id, ]); $this->user = User::factory()->create(['account_id' => $this->account->id]); $this->account->update(['owner_id' => $this->user->id]); diff --git a/tests/Feature/Jobs/PostHog/TrackBillingTest.php b/tests/Feature/Jobs/PostHog/TrackBillingTest.php index 5a6d0ecb..75c1e4b0 100644 --- a/tests/Feature/Jobs/PostHog/TrackBillingTest.php +++ b/tests/Feature/Jobs/PostHog/TrackBillingTest.php @@ -18,7 +18,7 @@ config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); $this->account = Account::factory()->create([ - 'plan_id' => Plan::query()->where('slug', 'starter')->first()?->id, + 'plan_id' => Plan::query()->where('slug', 'workspace')->first()?->id, ]); $this->user = User::factory()->create(['account_id' => $this->account->id]); $this->account->update(['owner_id' => $this->user->id]); diff --git a/tests/Feature/Listeners/StripeEventListenerTest.php b/tests/Feature/Listeners/StripeEventListenerTest.php index a3f71f5c..bcfb4028 100644 --- a/tests/Feature/Listeners/StripeEventListenerTest.php +++ b/tests/Feature/Listeners/StripeEventListenerTest.php @@ -15,17 +15,12 @@ beforeEach(function () { config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); - Plan::factory()->create([ - 'slug' => Slug::Starter, - 'name' => 'Starter', - 'stripe_monthly_price_id' => 'price_starter_monthly', - 'stripe_yearly_price_id' => 'price_starter_yearly', - ]); - Plan::factory()->create([ - 'slug' => Slug::Pro, - 'name' => 'Pro', - 'stripe_monthly_price_id' => 'price_pro_monthly', - 'stripe_yearly_price_id' => 'price_pro_yearly', + // Use the seeded Workspace plan; set deterministic price ids so the + // assertions don't depend on `.env.testing`. + $this->plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); + $this->plan->update([ + 'stripe_monthly_price_id' => 'price_workspace_monthly', + 'stripe_yearly_price_id' => 'price_workspace_yearly', ]); $this->account = Account::factory()->create(['stripe_id' => 'cus_test123']); @@ -51,7 +46,6 @@ }); test('subscription created clears the generic trial_ends_at on the account', function () { - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); $this->account->update(['trial_ends_at' => now()->addDays(3)]); $this->listener->handle(new WebhookReceived([ @@ -60,7 +54,7 @@ 'customer' => 'cus_test123', 'id' => 'sub_123', 'status' => 'active', - 'items' => ['data' => [['price' => ['id' => $starter->stripe_monthly_price_id]]]], + 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], ]], ])); @@ -184,8 +178,7 @@ }); test('subscription deleted dispatches TrackBilling with subscription.cancelled event', function () { - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); - $this->account->update(['plan_id' => $starter->id]); + $this->account->update(['plan_id' => $this->plan->id]); Bus::fake([TrackBilling::class]); @@ -236,116 +229,81 @@ }); // ======================================== -// Plan sync — domain logic +// Plan mapping by Stripe price id // ======================================== -// -// Tests below override the seeded plans' Stripe price ids with deterministic -// values so the assertions don't depend on `.env.testing` having -// `STRIPE_*_MONTHLY/YEARLY` set. -beforeEach(function () { - Plan::query()->where('slug', 'starter')->update([ - 'stripe_monthly_price_id' => 'price_starter_monthly', - 'stripe_yearly_price_id' => 'price_starter_yearly', - ]); - Plan::query()->where('slug', 'pro')->update([ - 'stripe_monthly_price_id' => 'price_pro_monthly', - 'stripe_yearly_price_id' => 'price_pro_yearly', - ]); -}); - -test('subscription updated swaps account plan_id when price matches a different plan', function () { - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); - $pro = Plan::query()->where('slug', 'pro')->firstOrFail(); - - $this->account->update(['plan_id' => $starter->id]); - - $this->listener->handle(new WebhookReceived([ - 'type' => 'customer.subscription.updated', - 'data' => ['object' => [ - 'customer' => 'cus_test123', - 'items' => ['data' => [ - ['price' => ['id' => 'price_pro_monthly']], - ]], - ]], - ])); - - expect($this->account->fresh()->plan_id)->toBe($pro->id); -}); - -test('subscription updated leaves plan_id alone when price already matches current plan', function () { - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); - $this->account->update(['plan_id' => $starter->id]); - - $this->listener->handle(new WebhookReceived([ - 'type' => 'customer.subscription.updated', - 'data' => ['object' => [ - 'customer' => 'cus_test123', - 'items' => ['data' => [ - ['price' => ['id' => 'price_starter_monthly']], - ]], - ]], - ])); - - expect($this->account->fresh()->plan_id)->toBe($starter->id); -}); - -test('subscription updated ignores unknown price ids without erroring', function () { - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); - $this->account->update(['plan_id' => $starter->id]); - - $this->listener->handle(new WebhookReceived([ - 'type' => 'customer.subscription.updated', - 'data' => ['object' => [ - 'customer' => 'cus_test123', - 'items' => ['data' => [ - ['price' => ['id' => 'price_unknown_xyz']], - ]], - ]], - ])); - - expect($this->account->fresh()->plan_id)->toBe($starter->id); -}); - -test('subscription updated matches yearly price ids too', function () { - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); - $pro = Plan::query()->where('slug', 'pro')->firstOrFail(); - - $this->account->update(['plan_id' => $starter->id]); - - $this->listener->handle(new WebhookReceived([ - 'type' => 'customer.subscription.updated', - 'data' => ['object' => [ - 'customer' => 'cus_test123', - 'items' => ['data' => [ - ['price' => ['id' => 'price_pro_yearly']], - ]], - ]], - ])); - - expect($this->account->fresh()->plan_id)->toBe($pro->id); -}); - -test('subscription created syncs plan from price ids on first activation', function () { - $pro = Plan::query()->where('slug', 'pro')->firstOrFail(); +test('subscription created syncs the plan from the price id on first activation', function () { $this->account->update(['plan_id' => null]); $this->listener->handle(new WebhookReceived([ 'type' => 'customer.subscription.created', 'data' => ['object' => [ 'customer' => 'cus_test123', - 'items' => ['data' => [ - ['price' => ['id' => 'price_pro_monthly']], - ]], + 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], ]], ])); - expect($this->account->fresh()->plan_id)->toBe($pro->id); + expect($this->account->fresh()->plan_id)->toBe($this->plan->id); +}); + +test('subscription updated maps the plan by its monthly price id', function () { + $this->account->update(['plan_id' => null]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBe($this->plan->id); +}); + +test('subscription updated maps the plan by its yearly price id too', function () { + $this->account->update(['plan_id' => null]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'items' => ['data' => [['price' => ['id' => 'price_workspace_yearly']]]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBe($this->plan->id); +}); + +test('subscription updated leaves plan_id alone when the price already matches', function () { + $this->account->update(['plan_id' => $this->plan->id]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBe($this->plan->id); +}); + +test('subscription updated ignores unknown price ids without erroring', function () { + $this->account->update(['plan_id' => $this->plan->id]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'items' => ['data' => [['price' => ['id' => 'price_unknown_xyz']]]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBe($this->plan->id); }); test('subscription deleted clears the account plan_id', function () { - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); - $this->account->update(['plan_id' => $starter->id]); + $this->account->update(['plan_id' => $this->plan->id]); $this->listener->handle(new WebhookReceived([ 'type' => 'customer.subscription.deleted', @@ -377,8 +335,7 @@ // ======================================== test('subscription updated forwards the previous plan name to TrackBilling', function () { - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); - $this->account->update(['plan_id' => $starter->id]); + $this->account->update(['plan_id' => $this->plan->id]); Bus::fake([TrackBilling::class]); @@ -386,19 +343,18 @@ 'type' => 'customer.subscription.updated', 'data' => ['object' => [ 'customer' => 'cus_test123', - 'items' => ['data' => [['price' => ['id' => 'price_pro_monthly']]]], + 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], ]], ])); Bus::assertDispatched( TrackBilling::class, - fn ($job) => $job->event === BillingEvent::Updated && $job->previousPlan === $starter->name, + fn ($job) => $job->event === BillingEvent::Updated && $job->previousPlan === $this->plan->name, ); }); test('subscription deleted forwards the previous plan name to TrackBilling', function () { - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); - $this->account->update(['plan_id' => $starter->id]); + $this->account->update(['plan_id' => $this->plan->id]); Bus::fake([TrackBilling::class]); @@ -409,7 +365,7 @@ Bus::assertDispatched( TrackBilling::class, - fn ($job) => $job->event === BillingEvent::Cancelled && $job->previousPlan === $starter->name, + fn ($job) => $job->event === BillingEvent::Cancelled && $job->previousPlan === $this->plan->name, ); }); @@ -422,7 +378,7 @@ 'type' => 'customer.subscription.created', 'data' => ['object' => [ 'customer' => 'cus_test123', - 'items' => ['data' => [['price' => ['id' => 'price_starter_monthly']]]], + 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], ]], ])); @@ -431,51 +387,3 @@ fn ($job) => $job->event === BillingEvent::Created && $job->previousPlan === null, ); }); - -// ======================================== -// Plan mapping by Stripe price id -// ======================================== - -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]); - - $this->listener->handle(new WebhookReceived([ - 'type' => 'customer.subscription.updated', - 'data' => ['object' => [ - 'customer' => 'cus_test123', - 'items' => ['data' => [['price' => ['id' => 'price_pro_monthly']]]], - ]], - ])); - - expect($this->account->fresh()->plan_id)->toBe($pro->id); -}); - -test('subscription updated maps an archived legacy plan by price id', function () { - $pro = Plan::query()->where('slug', 'pro')->firstOrFail(); - $pro->update(['is_archived' => true]); - - $this->listener->handle(new WebhookReceived([ - 'type' => 'customer.subscription.updated', - 'data' => ['object' => [ - 'customer' => 'cus_test123', - 'items' => ['data' => [['price' => ['id' => 'price_pro_monthly']]]], - ]], - ])); - - expect($this->account->fresh()->plan_id)->toBe($pro->id); -}); - -test('subscription deleted clears the plan_id', function () { - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); - $this->account->update(['plan_id' => $starter->id]); - - $this->listener->handle(new WebhookReceived([ - 'type' => 'customer.subscription.deleted', - 'data' => ['object' => ['customer' => 'cus_test123']], - ])); - - expect($this->account->fresh()->plan_id)->toBeNull(); -}); diff --git a/tests/Feature/PlanTest.php b/tests/Feature/PlanTest.php index c58c8de8..032d2645 100644 --- a/tests/Feature/PlanTest.php +++ b/tests/Feature/PlanTest.php @@ -6,16 +6,16 @@ use App\Models\Plan; test('plan can be created with factory', function () { - Plan::where('slug', Slug::Pro)->delete(); + Plan::where('slug', Slug::Workspace)->delete(); $plan = Plan::factory()->create([ - 'slug' => Slug::Pro, - 'name' => 'Pro', + 'slug' => Slug::Workspace, + 'name' => 'Workspace', ]); expect($plan)->toBeInstanceOf(Plan::class) - ->and($plan->slug)->toBe(Slug::Pro) - ->and($plan->name)->toBe('Pro') + ->and($plan->slug)->toBe(Slug::Workspace) + ->and($plan->name)->toBe('Workspace') ->and($plan->is_archived)->toBeFalse(); }); diff --git a/tests/Unit/Models/AccountTest.php b/tests/Unit/Models/AccountTest.php index 09288f69..a0ae0782 100644 --- a/tests/Unit/Models/AccountTest.php +++ b/tests/Unit/Models/AccountTest.php @@ -174,7 +174,7 @@ $account = Account::factory()->create([ 'trial_ends_at' => null, 'stripe_id' => 'cus_test_'.fake()->uuid(), - 'plan_id' => Plan::where('slug', Slug::Starter)->value('id'), + 'plan_id' => Plan::where('slug', Slug::Workspace)->value('id'), ]); $account->subscriptions()->create([ 'type' => Account::SUBSCRIPTION_NAME, diff --git a/tests/Unit/PostHogServiceTest.php b/tests/Unit/PostHogServiceTest.php index 8e128043..85393d53 100644 --- a/tests/Unit/PostHogServiceTest.php +++ b/tests/Unit/PostHogServiceTest.php @@ -86,7 +86,7 @@ Queue::fake(); config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); - $plan = Plan::query()->where('slug', 'starter')->first(); + $plan = Plan::query()->where('slug', 'workspace')->first(); $account = Account::factory()->create(['plan_id' => $plan?->id]); $service = new PostHogService;