fix(billing): require card-backed trial again

Revert the no-card signup trial flow so access depends on a Stripe subscription trial started at checkout, preventing app access before a payment method is collected.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-05-21 09:49:44 -03:00
parent a4e1757535
commit 26c738c0ba
8 changed files with 29 additions and 107 deletions

View file

@ -4,10 +4,8 @@
namespace App\Actions\User;
use App\Enums\Plan\Slug;
use App\Jobs\PostHog\SyncUser;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use App\Services\PostHogService;
use Illuminate\Support\Facades\DB;
@ -26,8 +24,6 @@ public static function execute(array $data, array $utmParameters = []): User
$account = Account::create([
'name' => data_get($data, 'name')."'s Account",
'billing_email' => data_get($data, 'email'),
'plan_id' => Plan::where('slug', Slug::Starter)->value('id'),
'trial_ends_at' => now()->addDays(config('cashier.trial_days')),
]);
$user = User::create(array_merge([

View file

@ -30,6 +30,7 @@ public function subscribe(Request $request): Response|RedirectResponse
return Inertia::render('billing/Subscribe', [
'plans' => Plan::active()->orderBy('sort')->get(),
'trialDays' => config('cashier.trial_days'),
]);
}
@ -62,7 +63,8 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse|Redirect
]);
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
->allowPromotionCodes();
->allowPromotionCodes()
->trialDays(config('cashier.trial_days'));
$checkoutSession = $subscription->checkout([
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',

View file

@ -24,15 +24,8 @@ public function handle(Request $request, Closure $next): Response
$account = $user->account;
if (! config('trypost.self_hosted')) {
$hasAccess = $account && (
$account->subscribed(Account::SUBSCRIPTION_NAME)
|| $account->isOnTrial()
);
if (! $hasAccess) {
return redirect()->route('app.subscribe');
}
if (! config('trypost.self_hosted') && (! $account || ! $account->subscribed(Account::SUBSCRIPTION_NAME))) {
return redirect()->route('app.subscribe');
}
if (! $user->workspaces()->exists()) {

View file

@ -93,10 +93,6 @@ public function hasActiveSubscription(): bool
public function isOnTrial(): bool
{
if ($this->onGenericTrial()) {
return true;
}
return (bool) $this->subscription(self::SUBSCRIPTION_NAME)?->onTrial();
}
@ -104,11 +100,11 @@ public function activeTrialEndsAt(): ?CarbonInterface
{
$subscription = $this->subscription(self::SUBSCRIPTION_NAME);
return match (true) {
(bool) $subscription?->onTrial() => $subscription->trial_ends_at,
$this->onGenericTrial() => $this->trial_ends_at,
default => null,
};
if (! $subscription?->onTrial()) {
return null;
}
return $subscription->trial_ends_at;
}
/**

View file

@ -3,9 +3,6 @@
declare(strict_types=1);
use App\Actions\User\CreateUser;
use App\Enums\Plan\Slug;
use App\Models\Plan;
use Carbon\Carbon;
use Database\Seeders\PlanSeeder;
beforeEach(function () {
@ -13,9 +10,7 @@
$this->seed(PlanSeeder::class);
});
test('new signup gets a 7-day trial without card', function () {
Carbon::setTestNow('2026-05-14 12:00:00');
test('new signup does not create a trial before checkout', function () {
$user = CreateUser::execute([
'name' => 'Alice',
'email' => 'alice@example.com',
@ -24,40 +19,7 @@
'registration_ip' => '127.0.0.1',
]);
$starterPlan = Plan::where('slug', Slug::Starter)->firstOrFail();
expect($user->account->plan_id)->toBe($starterPlan->id);
expect($user->account->trial_ends_at?->toDateTimeString())->toBe('2026-05-21 12:00:00');
expect($user->account->plan_id)->toBeNull();
expect($user->account->trial_ends_at)->toBeNull();
expect($user->account->stripe_id)->toBeNull();
});
test('account during generic trial is recognized as on trial', function () {
Carbon::setTestNow('2026-05-14 12:00:00');
$user = CreateUser::execute([
'name' => 'Alice',
'email' => 'alice2@example.com',
'password' => 'password123',
'timezone' => 'UTC',
'registration_ip' => '127.0.0.1',
]);
expect($user->account->isOnTrial())->toBeTrue();
expect($user->account->onGenericTrial())->toBeTrue();
});
test('account whose generic trial expired is not on trial', function () {
Carbon::setTestNow('2026-05-14 12:00:00');
$user = CreateUser::execute([
'name' => 'Alice',
'email' => 'alice3@example.com',
'password' => 'password123',
'timezone' => 'UTC',
'registration_ip' => '127.0.0.1',
]);
Carbon::setTestNow('2026-05-21 12:01:00');
expect($user->account->fresh()->isOnTrial())->toBeFalse();
});

View file

@ -92,22 +92,6 @@
);
});
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]);
@ -148,14 +132,14 @@
);
});
test('subscribe page does not expose trialDays prop anymore', function () {
test('subscribe page exposes trialDays prop', 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')
->where('trialDays', config('cashier.trial_days'))
);
});

View file

@ -7,7 +7,6 @@
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use Carbon\Carbon;
use Database\Seeders\PlanSeeder;
beforeEach(function () {
@ -15,9 +14,7 @@
$this->seed(PlanSeeder::class);
});
test('user on generic trial can access the app', function () {
Carbon::setTestNow('2026-05-14 12:00:00');
test('user without subscription is redirected to subscribe', function () {
$user = CreateUser::execute([
'name' => 'Alice',
'email' => 'alice@example.com',
@ -35,12 +32,10 @@
$response = $this->actingAs($user->fresh())->get(route('app.accounts'));
$response->assertOk();
$response->assertRedirect(route('app.subscribe'));
});
test('user whose trial expired is redirected to subscribe', function () {
Carbon::setTestNow('2026-05-14 12:00:00');
test('user with active subscription can access the app', function () {
$user = CreateUser::execute([
'name' => 'Alice',
'email' => 'alice2@example.com',
@ -56,11 +51,16 @@
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
Carbon::setTestNow('2026-05-21 12:01:00');
$user->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
$response = $this->actingAs($user->fresh())->get(route('app.accounts'));
$response->assertRedirect(route('app.subscribe'));
$response->assertOk();
});
test('user on trialing subscription (legacy trial-with-card) can access the app', function () {

View file

@ -13,18 +13,8 @@
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(),
]);
test('isOnTrial ignores generic trial when there is no subscription', function () {
$account = Account::factory()->create(['trial_ends_at' => now()->addDays(7)]);
expect($account->isOnTrial())->toBeFalse();
});
@ -61,8 +51,7 @@
$endsAt = now()->addDays(7);
$account = Account::factory()->create(['trial_ends_at' => $endsAt]);
expect($account->activeTrialEndsAt()?->toDateTimeString())
->toBe($endsAt->toDateTimeString());
expect($account->activeTrialEndsAt())->toBeNull();
});
test('activeTrialEndsAt returns subscription date when only subscription trial is active', function () {
@ -83,7 +72,7 @@
->toBe($subscriptionEndsAt->toDateTimeString());
});
test('activeTrialEndsAt prefers subscription date over generic when both active', function () {
test('activeTrialEndsAt returns subscription date when both generic and subscription trials are present', function () {
$genericEndsAt = now()->addDays(7);
$subscriptionEndsAt = now()->addDays(14);