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.
This commit is contained in:
parent
368176cccb
commit
177e7f8681
5 changed files with 151 additions and 3 deletions
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
63
tests/Feature/Auth/TrialOnSignupTest.php
Normal file
63
tests/Feature/Auth/TrialOnSignupTest.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
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 () {
|
||||
config(['trypost.self_hosted' => 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();
|
||||
});
|
||||
62
tests/Feature/Middleware/TrialMiddlewareAccessTest.php
Normal file
62
tests/Feature/Middleware/TrialMiddlewareAccessTest.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\User\CreateUser;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Workspace;
|
||||
use Carbon\Carbon;
|
||||
use Database\Seeders\PlanSeeder;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.self_hosted' => 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'));
|
||||
});
|
||||
Loading…
Reference in a new issue