feat(billing): clear trial_ends_at on subscription created + add tests

- 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
This commit is contained in:
Paulo Castellano 2026-05-14 20:23:35 -03:00
parent c29198caef
commit 0514ce677b
7 changed files with 240 additions and 4 deletions

View file

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

View file

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

View file

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

View file

@ -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
// ========================================

View file

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

View file

@ -107,7 +107,6 @@
$response->assertInertia(fn ($page) => $page
->component('billing/Subscribe', false)
->has('plans', $activePlanCount)
->has('trialDays')
);
});

View file

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
use App\Enums\Plan\Slug;
use App\Models\Account;
use App\Models\Plan;
use Carbon\Carbon;
use Database\Seeders\PlanSeeder;
beforeEach(function () {
$this->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();
});