diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index e7001e12..5f01e207 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -22,13 +22,18 @@ public static function execute(array $data, array $utmParameters = []): User { $user = DB::transaction(function () use ($data, $utmParameters): User { $isInviteRegistration = data_get($data, 'is_invite', false); - - $account = Account::create([ + $requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true); + $accountAttributes = [ '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')), - ]); + ]; + + if (! $requiresCardForTrial) { + $accountAttributes['plan_id'] = Plan::where('slug', Slug::Starter)->value('id'); + $accountAttributes['trial_ends_at'] = now()->addDays(config('cashier.trial_days', 7)); + } + + $account = Account::create($accountAttributes); $user = User::create(array_merge([ 'name' => data_get($data, 'name'), diff --git a/app/Http/Controllers/App/BillingController.php b/app/Http/Controllers/App/BillingController.php index 2bbb3ad4..3edfbd03 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -28,8 +28,11 @@ public function subscribe(Request $request): Response|RedirectResponse return redirect()->route('app.billing.index'); } + $requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true); + return Inertia::render('billing/Subscribe', [ 'plans' => Plan::active()->orderBy('sort')->get(), + 'trialDays' => $requiresCardForTrial ? config('cashier.trial_days') : null, ]); } @@ -64,6 +67,10 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse|Redirect $subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId) ->allowPromotionCodes(); + if ((bool) config('trypost.billing.require_card_for_trial', true)) { + $subscription->trialDays(config('cashier.trial_days')); + } + $checkoutSession = $subscription->checkout([ 'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}', 'cancel_url' => route('app.subscribe'), diff --git a/app/Http/Middleware/App/EnsureAccountReady.php b/app/Http/Middleware/App/EnsureAccountReady.php index 4123ee71..20a0900f 100644 --- a/app/Http/Middleware/App/EnsureAccountReady.php +++ b/app/Http/Middleware/App/EnsureAccountReady.php @@ -25,9 +25,10 @@ public function handle(Request $request, Closure $next): Response $account = $user->account; if (! config('trypost.self_hosted')) { + $requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true); $hasAccess = $account && ( $account->subscribed(Account::SUBSCRIPTION_NAME) - || $account->isOnTrial() + || (! $requiresCardForTrial && $account->isOnTrial()) ); if (! $hasAccess) { diff --git a/app/Models/Account.php b/app/Models/Account.php index 632b9682..f4c04c99 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -93,7 +93,7 @@ public function hasActiveSubscription(): bool public function isOnTrial(): bool { - if ($this->onGenericTrial()) { + if (! (bool) config('trypost.billing.require_card_for_trial', true) && $this->onGenericTrial()) { return true; } @@ -104,11 +104,15 @@ 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()) { + if (! (bool) config('trypost.billing.require_card_for_trial', true) && $this->onGenericTrial()) { + return $this->trial_ends_at; + } + + return null; + } + + return $subscription->trial_ends_at; } /** diff --git a/config/cashier.php b/config/cashier.php index ed1ff73b..44c30230 100644 --- a/config/cashier.php +++ b/config/cashier.php @@ -135,6 +135,6 @@ | */ - 'trial_days' => env('CASHIER_TRIAL_DAYS', 7), + 'trial_days' => env('CASHIER_TRIAL_DAYS', 8), ]; diff --git a/config/trypost.php b/config/trypost.php index 45f1d93d..5b20081b 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -16,6 +16,21 @@ 'self_hosted' => env('SELF_HOSTED', true), + /* + |-------------------------------------------------------------------------- + | Billing + |-------------------------------------------------------------------------- + | + | Control trial behavior for SaaS billing: + | - true: require card at checkout to start trial (Stripe trialing) + | - false: grant generic trial at signup without card + | + */ + + 'billing' => [ + 'require_card_for_trial' => true, + ], + /* |-------------------------------------------------------------------------- | Media Size Limits diff --git a/lang/en/billing.php b/lang/en/billing.php index da52b3d2..eac2f116 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -27,6 +27,7 @@ 'eyebrow' => 'Pricing', 'title' => 'Choose the right plan for you', 'description' => 'Pick the plan that fits you. Billed monthly or annually.', + 'trial_info' => ':days-day free trial, then billed automatically', 'monthly' => 'Monthly', 'yearly' => 'Yearly', 'per_month' => 'monthly', @@ -37,6 +38,7 @@ 'everything_in' => 'Everything in :plan, plus:', 'save_months' => '2 months free', 'popular' => 'Most popular', + 'start_trial' => 'Start :days-day free trial', 'subscribe_cta' => 'Subscribe', 'prices' => [ 'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'], diff --git a/lang/es/billing.php b/lang/es/billing.php index 99714107..f077718d 100644 --- a/lang/es/billing.php +++ b/lang/es/billing.php @@ -27,6 +27,7 @@ 'eyebrow' => 'Precios', 'title' => 'Elige el plan ideal para ti', 'description' => 'Elige el plan que te queda. Facturación mensual o anual.', + 'trial_info' => 'Prueba gratuita de :days días, luego se cobra automáticamente', 'monthly' => 'Mensual', 'yearly' => 'Anual', 'per_month' => 'mensual', @@ -37,6 +38,7 @@ 'everything_in' => 'Todo lo de :plan, más:', 'save_months' => '2 meses gratis', 'popular' => 'Más popular', + 'start_trial' => 'Comenzar prueba de :days días', 'subscribe_cta' => 'Suscribirse', 'prices' => [ 'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'], diff --git a/lang/pt-BR/billing.php b/lang/pt-BR/billing.php index f392cc44..6a74b23d 100644 --- a/lang/pt-BR/billing.php +++ b/lang/pt-BR/billing.php @@ -27,6 +27,7 @@ 'eyebrow' => 'Preços', 'title' => 'Escolha o plano ideal pra você', 'description' => 'Escolha o plano que combina com você. Cobrança mensal ou anual.', + 'trial_info' => ':days dias grátis, depois cobrança automática', 'monthly' => 'Mensal', 'yearly' => 'Anual', 'per_month' => 'mensal', @@ -37,6 +38,7 @@ 'everything_in' => 'Tudo do :plan, mais:', 'save_months' => '2 meses grátis', 'popular' => 'Mais popular', + 'start_trial' => 'Iniciar teste de :days dias', 'subscribe_cta' => 'Assinar', 'prices' => [ 'starter' => ['monthly' => 'R$ 95', 'yearly_per_month' => 'R$ 79', 'yearly' => 'R$ 950'], diff --git a/resources/js/pages/billing/Subscribe.vue b/resources/js/pages/billing/Subscribe.vue index 12b1d366..c25d9dee 100644 --- a/resources/js/pages/billing/Subscribe.vue +++ b/resources/js/pages/billing/Subscribe.vue @@ -27,8 +27,9 @@ interface Highlight { tooltip?: string; } -defineProps<{ +const { plans, trialDays } = defineProps<{ plans: Plan[]; + trialDays: number | null; }>(); const isYearly = ref(true); @@ -121,6 +122,9 @@ const planTones: Record = {

{{ $t('billing.subscribe.description') }}

+

+ {{ trans('billing.subscribe.trial_info', { days: String(trialDays) }) }} +

@@ -227,7 +231,12 @@ const planTones: Record = { ]" @click="selectPlan(plan)" > - {{ $t('billing.subscribe.subscribe_cta') }} + + diff --git a/tests/Feature/Auth/SignupRequiresCheckoutTest.php b/tests/Feature/Auth/SignupRequiresCheckoutTest.php new file mode 100644 index 00000000..005c0e02 --- /dev/null +++ b/tests/Feature/Auth/SignupRequiresCheckoutTest.php @@ -0,0 +1,41 @@ + false]); + config(['trypost.billing.require_card_for_trial' => true]); + $this->seed(PlanSeeder::class); +}); + +test('new signup does not create a trial before checkout', function () { + $user = CreateUser::execute([ + 'name' => 'Alice', + 'email' => 'alice@example.com', + 'password' => 'password123', + 'timezone' => 'UTC', + 'registration_ip' => '127.0.0.1', + ]); + + expect($user->account->plan_id)->toBeNull(); + expect($user->account->trial_ends_at)->toBeNull(); + expect($user->account->stripe_id)->toBeNull(); +}); + +test('new signup creates generic trial when card is not required', function () { + config(['trypost.billing.require_card_for_trial' => false]); + + $user = CreateUser::execute([ + 'name' => 'Alice', + 'email' => 'alice+nocard@example.com', + 'password' => 'password123', + 'timezone' => 'UTC', + 'registration_ip' => '127.0.0.1', + ]); + + expect($user->account->plan_id)->not->toBeNull(); + expect($user->account->trial_ends_at)->not->toBeNull(); +}); diff --git a/tests/Feature/Auth/TrialOnSignupTest.php b/tests/Feature/Auth/TrialOnSignupTest.php deleted file mode 100644 index f47434a0..00000000 --- a/tests/Feature/Auth/TrialOnSignupTest.php +++ /dev/null @@ -1,63 +0,0 @@ - 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-21 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-21 12:01:00'); - - expect($user->account->fresh()->isOnTrial())->toBeFalse(); -}); diff --git a/tests/Feature/BillingControllerTest.php b/tests/Feature/BillingControllerTest.php index ebb212ad..55fafc56 100644 --- a/tests/Feature/BillingControllerTest.php +++ b/tests/Feature/BillingControllerTest.php @@ -9,6 +9,8 @@ use App\Models\Workspace; beforeEach(function () { + config(['trypost.billing.require_card_for_trial' => true]); + $this->account = Account::factory()->create(); $this->user = User::factory()->create([ 'account_id' => $this->account->id, @@ -92,22 +94,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 +134,28 @@ ); }); -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')) + ); +}); + +test('subscribe page exposes null trialDays when card is not required', function () { + config([ + 'trypost.self_hosted' => false, + 'trypost.billing.require_card_for_trial' => false, + ]); + + $response = $this->actingAs($this->user)->get(route('app.subscribe')); + + $response->assertInertia(fn ($page) => $page + ->component('billing/Subscribe', false) + ->where('trialDays', null) ); }); diff --git a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php index 3c80d220..3bc6cf7b 100644 --- a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php +++ b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php @@ -7,17 +7,15 @@ use App\Models\Account; use App\Models\User; use App\Models\Workspace; -use Carbon\Carbon; use Database\Seeders\PlanSeeder; beforeEach(function () { config(['trypost.self_hosted' => false]); + config(['trypost.billing.require_card_for_trial' => true]); $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 +33,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 +52,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 () { @@ -90,3 +91,26 @@ $response->assertOk(); }); + +test('user on generic trial can access the app when card is not required', function () { + config(['trypost.billing.require_card_for_trial' => false]); + + $user = CreateUser::execute([ + 'name' => 'Alice', + 'email' => 'alice-generic@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(); +}); diff --git a/tests/Unit/Models/AccountTest.php b/tests/Unit/Models/AccountTest.php index 84147a8c..4d4e416c 100644 --- a/tests/Unit/Models/AccountTest.php +++ b/tests/Unit/Models/AccountTest.php @@ -9,26 +9,25 @@ use Database\Seeders\PlanSeeder; beforeEach(function () { + config(['trypost.billing.require_card_for_trial' => true]); $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(), - ]); +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(); }); +test('isOnTrial includes generic trial when card is not required', function () { + config(['trypost.billing.require_card_for_trial' => false]); + + $account = Account::factory()->create(['trial_ends_at' => now()->addDays(7)]); + + expect($account->isOnTrial())->toBeTrue(); +}); + test('isOnTrial returns false for account without trial or subscription', function () { $account = Account::factory()->create(['trial_ends_at' => null]); @@ -61,6 +60,15 @@ $endsAt = now()->addDays(7); $account = Account::factory()->create(['trial_ends_at' => $endsAt]); + expect($account->activeTrialEndsAt())->toBeNull(); +}); + +test('activeTrialEndsAt returns generic trial date when card is not required', function () { + config(['trypost.billing.require_card_for_trial' => false]); + + $endsAt = now()->addDays(7); + $account = Account::factory()->create(['trial_ends_at' => $endsAt]); + expect($account->activeTrialEndsAt()?->toDateTimeString()) ->toBe($endsAt->toDateTimeString()); }); @@ -83,7 +91,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);