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.
This commit is contained in:
Paulo Castellano 2026-06-22 15:31:32 -03:00
parent 11319cb45a
commit 21e45b4847
9 changed files with 87 additions and 187 deletions

View file

@ -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',
};
}

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,

View file

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