From 0514ce677b4ad5669d8a3519df06434e5236808f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 14 May 2026 20:23:35 -0300 Subject: [PATCH] feat(billing): clear trial_ends_at on subscription created + add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StripeEventListener::handleSubscriptionCreated nulls account.trial_ends_at when a Stripe subscription is created. Prevents 'Trial' badge from lingering for users who convert mid-generic-trial to paid. - Drop unused trialDays global Inertia prop (no frontend consumers after /subscribe redesign). Tests added (10 new, 0 regressions, 1533 total): - AccountTest: isOnTrial + activeTrialEndsAt across 4 scenarios (no trial, generic only, subscription only, both — subscription wins) - StripeEventListenerTest: subscription created clears generic trial - TrialMiddlewareAccessTest: trialing-with-card subscription passes - BillingControllerTest: index exposes onTrial/trialEndsAt for the 3 trial states (generic-only, subscription-only, paying); subscribe page no longer exposes trialDays prop --- .../Middleware/App/HandleInertiaRequests.php | 1 - app/Listeners/StripeEventListener.php | 5 +- tests/Feature/BillingControllerTest.php | 68 +++++++++- .../Listeners/StripeEventListenerTest.php | 17 +++ .../Middleware/TrialMiddlewareAccessTest.php | 30 +++++ tests/Feature/WorkspaceBillingTest.php | 1 - tests/Unit/Models/AccountTest.php | 122 ++++++++++++++++++ 7 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Models/AccountTest.php diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index f17aa60e..b266b584 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -61,7 +61,6 @@ public function share(Request $request): array 'selfHosted' => $isSelfHosted, 'googleAuthEnabled' => config('trypost.google_auth_enabled'), 'githubAuthEnabled' => config('trypost.github_auth_enabled'), - 'trialDays' => config('cashier.trial_days'), ]; } } diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index 550d11f3..4a701c0b 100644 --- a/app/Listeners/StripeEventListener.php +++ b/app/Listeners/StripeEventListener.php @@ -52,7 +52,10 @@ protected function handleSubscriptionCreated(Account $account, array $payload): $previousPlan = $account->plan?->name; if ($plan = $this->resolvePlanFromSubscriptionItems($payload, $account)) { - $account->update(['plan_id' => $plan->id]); + $account->update([ + 'plan_id' => $plan->id, + 'trial_ends_at' => null, + ]); $account->forgetPlanFeatureCache(); } diff --git a/tests/Feature/BillingControllerTest.php b/tests/Feature/BillingControllerTest.php index aaa09251..ebb212ad 100644 --- a/tests/Feature/BillingControllerTest.php +++ b/tests/Feature/BillingControllerTest.php @@ -38,7 +38,6 @@ $response->assertInertia(fn ($page) => $page ->component('billing/Subscribe', false) ->has('plans') - ->has('trialDays') ); }); @@ -93,6 +92,73 @@ ); }); +test('billing index exposes onTrial=true and trialEndsAt for generic-trial-only account', function () { + config(['trypost.self_hosted' => false]); + + $endsAt = now()->addDays(7)->startOfSecond(); + $this->account->update(['trial_ends_at' => $endsAt]); + + $response = $this->actingAs($this->user->fresh())->get(route('app.billing.index')); + + $response->assertInertia(fn ($page) => $page + ->component('settings/account/Billing', false) + ->where('hasSubscription', false) + ->where('onTrial', true) + ->where('trialEndsAt', $endsAt->toIso8601ZuluString('microsecond')) + ); +}); + +test('billing index exposes onTrial=true and trialEndsAt for subscription-trial account', function () { + config(['trypost.self_hosted' => false]); + + $subscriptionEndsAt = now()->addDays(5)->startOfSecond(); + $this->account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'trialing', + 'stripe_price' => 'price_123', + 'trial_ends_at' => $subscriptionEndsAt, + ]); + + $response = $this->actingAs($this->user->fresh())->get(route('app.billing.index')); + + $response->assertInertia(fn ($page) => $page + ->where('hasSubscription', true) + ->where('onTrial', true) + ->where('trialEndsAt', $subscriptionEndsAt->toIso8601ZuluString('microsecond')) + ); +}); + +test('billing index exposes onTrial=false and trialEndsAt=null for paying subscribed user', function () { + config(['trypost.self_hosted' => false]); + + $this->account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_123', + ]); + + $response = $this->actingAs($this->user->fresh())->get(route('app.billing.index')); + + $response->assertInertia(fn ($page) => $page + ->where('hasSubscription', true) + ->where('onTrial', false) + ->where('trialEndsAt', null) + ); +}); + +test('subscribe page does not expose trialDays prop anymore', function () { + config(['trypost.self_hosted' => false]); + + $response = $this->actingAs($this->user)->get(route('app.subscribe')); + + $response->assertInertia(fn ($page) => $page + ->component('billing/Subscribe', false) + ->missing('trialDays') + ); +}); + test('billing index redirects to calendar in self hosted mode', function () { config(['trypost.self_hosted' => true]); diff --git a/tests/Feature/Listeners/StripeEventListenerTest.php b/tests/Feature/Listeners/StripeEventListenerTest.php index 02df3a20..ced8ebe9 100644 --- a/tests/Feature/Listeners/StripeEventListenerTest.php +++ b/tests/Feature/Listeners/StripeEventListenerTest.php @@ -39,6 +39,23 @@ expect(true)->toBeTrue(); }); +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([ + 'type' => 'customer.subscription.created', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'id' => 'sub_123', + 'status' => 'active', + 'items' => ['data' => [['price' => ['id' => $starter->stripe_monthly_price_id]]]], + ]], + ])); + + expect($this->account->fresh()->trial_ends_at)->toBeNull(); +}); + // ======================================== // customer.subscription.updated // ======================================== diff --git a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php index 2f3f2b27..3c80d220 100644 --- a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php +++ b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php @@ -4,6 +4,8 @@ use App\Actions\User\CreateUser; use App\Enums\UserWorkspace\Role; +use App\Models\Account; +use App\Models\User; use App\Models\Workspace; use Carbon\Carbon; use Database\Seeders\PlanSeeder; @@ -60,3 +62,31 @@ $response->assertRedirect(route('app.subscribe')); }); + +test('user on trialing subscription (legacy trial-with-card) can access the app', function () { + $account = Account::factory()->create([ + 'trial_ends_at' => null, + 'stripe_id' => 'cus_test_'.fake()->uuid(), + ]); + $user = User::factory()->create(['account_id' => $account->id]); + $account->update(['owner_id' => $user->id]); + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'trialing', + 'stripe_price' => 'price_123', + 'trial_ends_at' => now()->addDays(5), + ]); + + $workspace = Workspace::factory()->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Member->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $response = $this->actingAs($user->fresh())->get(route('app.accounts')); + + $response->assertOk(); +}); diff --git a/tests/Feature/WorkspaceBillingTest.php b/tests/Feature/WorkspaceBillingTest.php index 1ad4427b..7eaf9142 100644 --- a/tests/Feature/WorkspaceBillingTest.php +++ b/tests/Feature/WorkspaceBillingTest.php @@ -107,7 +107,6 @@ $response->assertInertia(fn ($page) => $page ->component('billing/Subscribe', false) ->has('plans', $activePlanCount) - ->has('trialDays') ); }); diff --git a/tests/Unit/Models/AccountTest.php b/tests/Unit/Models/AccountTest.php new file mode 100644 index 00000000..84147a8c --- /dev/null +++ b/tests/Unit/Models/AccountTest.php @@ -0,0 +1,122 @@ +seed(PlanSeeder::class); + Carbon::setTestNow('2026-05-14 12:00:00'); +}); + +test('isOnTrial returns true for account on generic trial', function () { + $account = Account::factory()->create([ + 'trial_ends_at' => now()->addDays(7), + ]); + + expect($account->isOnTrial())->toBeTrue(); +}); + +test('isOnTrial returns false when generic trial has expired and there is no subscription', function () { + $account = Account::factory()->create([ + 'trial_ends_at' => now()->subDay(), + ]); + + expect($account->isOnTrial())->toBeFalse(); +}); + +test('isOnTrial returns false for account without trial or subscription', function () { + $account = Account::factory()->create(['trial_ends_at' => null]); + + expect($account->isOnTrial())->toBeFalse(); +}); + +test('isOnTrial returns true via subscription trial when account has no generic trial', function () { + $account = Account::factory()->create([ + 'trial_ends_at' => null, + 'stripe_id' => 'cus_test_'.fake()->uuid(), + ]); + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'trialing', + 'stripe_price' => 'price_123', + 'trial_ends_at' => now()->addDays(5), + ]); + + expect($account->isOnTrial())->toBeTrue(); +}); + +test('activeTrialEndsAt returns null when not on any trial', function () { + $account = Account::factory()->create(['trial_ends_at' => null]); + + expect($account->activeTrialEndsAt())->toBeNull(); +}); + +test('activeTrialEndsAt returns generic trial date when only generic is active', function () { + $endsAt = now()->addDays(7); + $account = Account::factory()->create(['trial_ends_at' => $endsAt]); + + expect($account->activeTrialEndsAt()?->toDateTimeString()) + ->toBe($endsAt->toDateTimeString()); +}); + +test('activeTrialEndsAt returns subscription date when only subscription trial is active', function () { + $subscriptionEndsAt = now()->addDays(5); + $account = Account::factory()->create([ + 'trial_ends_at' => null, + 'stripe_id' => 'cus_test_'.fake()->uuid(), + ]); + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'trialing', + 'stripe_price' => 'price_123', + 'trial_ends_at' => $subscriptionEndsAt, + ]); + + expect($account->activeTrialEndsAt()?->toDateTimeString()) + ->toBe($subscriptionEndsAt->toDateTimeString()); +}); + +test('activeTrialEndsAt prefers subscription date over generic when both active', function () { + $genericEndsAt = now()->addDays(7); + $subscriptionEndsAt = now()->addDays(14); + + $account = Account::factory()->create([ + 'trial_ends_at' => $genericEndsAt, + 'stripe_id' => 'cus_test_'.fake()->uuid(), + ]); + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'trialing', + 'stripe_price' => 'price_123', + 'trial_ends_at' => $subscriptionEndsAt, + ]); + + expect($account->activeTrialEndsAt()?->toDateTimeString()) + ->toBe($subscriptionEndsAt->toDateTimeString()); +}); + +test('activeTrialEndsAt returns null for paying customer post-trial', function () { + $account = Account::factory()->create([ + 'trial_ends_at' => null, + 'stripe_id' => 'cus_test_'.fake()->uuid(), + 'plan_id' => Plan::where('slug', Slug::Starter)->value('id'), + ]); + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_123', + 'trial_ends_at' => null, + ]); + + expect($account->activeTrialEndsAt())->toBeNull(); + expect($account->isOnTrial())->toBeFalse(); +});