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')); +});