From 177e7f8681272a2c0a5d8d9d3aee316e24568eba Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 14 May 2026 19:37:46 -0300 Subject: [PATCH] feat(signup): no-card 7-day trial on Starter plan New signups land on a 7-day generic trial (Cashier trial_ends_at) without a Stripe customer or subscription. Account is on Starter plan limits during the trial. After 7 days, EnsureAccountReady redirects to /subscribe per the existing flow. - CreateUser sets account.trial_ends_at and plan_id = Starter - EnsureAccountReady allows access when subscribed() OR onGenericTrial() - Account::isOnTrial() includes generic trial check Existing users unaffected: paying users have a subscription; never-paid users continue redirecting to /subscribe. --- app/Actions/User/CreateUser.php | 7 +++ .../Middleware/App/EnsureAccountReady.php | 11 +++- app/Models/Account.php | 11 +++- tests/Feature/Auth/TrialOnSignupTest.php | 63 +++++++++++++++++++ .../Middleware/TrialMiddlewareAccessTest.php | 62 ++++++++++++++++++ 5 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/Auth/TrialOnSignupTest.php create mode 100644 tests/Feature/Middleware/TrialMiddlewareAccessTest.php diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index ae81ae07..299c6de2 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -4,8 +4,10 @@ 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; @@ -21,9 +23,14 @@ public static function execute(array $data, array $utmParameters = []): User $user = DB::transaction(function () use ($data, $utmParameters): User { $isInviteRegistration = data_get($data, 'is_invite', false); + $starterPlanId = Plan::where('slug', Slug::Starter)->value('id'); + $trialEndsAt = now()->addDays(config('cashier.trial_days', 7)); + $account = Account::create([ 'name' => data_get($data, 'name')."'s Account", 'billing_email' => data_get($data, 'email'), + 'plan_id' => $starterPlanId, + 'trial_ends_at' => $trialEndsAt, ]); $user = User::create(array_merge([ diff --git a/app/Http/Middleware/App/EnsureAccountReady.php b/app/Http/Middleware/App/EnsureAccountReady.php index cdb8d4b9..f1dce5ff 100644 --- a/app/Http/Middleware/App/EnsureAccountReady.php +++ b/app/Http/Middleware/App/EnsureAccountReady.php @@ -24,8 +24,15 @@ public function handle(Request $request, Closure $next): Response $account = $user->account; - if (! config('trypost.self_hosted') && (! $account || ! $account->subscribed(Account::SUBSCRIPTION_NAME))) { - return redirect()->route('app.subscribe'); + if (! config('trypost.self_hosted')) { + $hasAccess = $account && ( + $account->subscribed(Account::SUBSCRIPTION_NAME) + || $account->onGenericTrial() + ); + + if (! $hasAccess) { + return redirect()->route('app.subscribe'); + } } if (! $user->workspaces()->exists()) { diff --git a/app/Models/Account.php b/app/Models/Account.php index b97058bd..50d49565 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -30,6 +30,11 @@ class Account extends Model 'name', 'billing_email', 'plan_id', + 'trial_ends_at', + ]; + + protected $casts = [ + 'trial_ends_at' => 'datetime', ]; public function forgetPlanFeatureCache(): void @@ -78,7 +83,11 @@ public function hasActiveSubscription(): bool public function isOnTrial(): bool { - return $this->subscription(self::SUBSCRIPTION_NAME)?->onTrial() ?? false; + if ($this->onGenericTrial()) { + return true; + } + + return (bool) $this->subscription(self::SUBSCRIPTION_NAME)?->onTrial(); } /** diff --git a/tests/Feature/Auth/TrialOnSignupTest.php b/tests/Feature/Auth/TrialOnSignupTest.php new file mode 100644 index 00000000..c3a7ebc2 --- /dev/null +++ b/tests/Feature/Auth/TrialOnSignupTest.php @@ -0,0 +1,63 @@ + false]); + $this->seed(PlanSeeder::class); +}); + +test('new signup gets a 7-day trial without card', function () { + Carbon::setTestNow('2026-05-14 12:00:00'); + + $user = CreateUser::execute([ + 'name' => 'Alice', + 'email' => 'alice@example.com', + 'password' => 'password123', + 'timezone' => 'UTC', + '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-22 12:00:00'); + 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-22 12:01:00'); + + expect($user->account->fresh()->isOnTrial())->toBeFalse(); +}); diff --git a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php new file mode 100644 index 00000000..1ecee913 --- /dev/null +++ b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php @@ -0,0 +1,62 @@ + false]); + $this->seed(PlanSeeder::class); +}); + +test('user on generic trial can access the app', function () { + Carbon::setTestNow('2026-05-14 12:00:00'); + + $user = CreateUser::execute([ + 'name' => 'Alice', + 'email' => 'alice@example.com', + 'password' => 'password123', + 'timezone' => 'UTC', + 'registration_ip' => '127.0.0.1', + ]); + + $workspace = Workspace::factory()->create([ + 'account_id' => $user->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(); +}); + +test('user whose trial expired is redirected to subscribe', 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', + ]); + + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Member->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + Carbon::setTestNow('2026-05-22 12:01:00'); + + $response = $this->actingAs($user->fresh())->get(route('app.accounts')); + + $response->assertRedirect(route('app.subscribe')); +});