From 26c738c0baac0d4253b7dee4cd040b0309b813ce Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 21 May 2026 09:49:44 -0300 Subject: [PATCH 1/5] 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 --- app/Actions/User/CreateUser.php | 4 -- .../Controllers/App/BillingController.php | 4 +- .../Middleware/App/EnsureAccountReady.php | 11 +---- app/Models/Account.php | 14 +++--- tests/Feature/Auth/TrialOnSignupTest.php | 44 ++----------------- tests/Feature/BillingControllerTest.php | 20 +-------- .../Middleware/TrialMiddlewareAccessTest.php | 20 ++++----- tests/Unit/Models/AccountTest.php | 19 ++------ 8 files changed, 29 insertions(+), 107 deletions(-) diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index e7001e12..ae81ae07 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -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([ diff --git a/app/Http/Controllers/App/BillingController.php b/app/Http/Controllers/App/BillingController.php index 2bbb3ad4..92e00744 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -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}', diff --git a/app/Http/Middleware/App/EnsureAccountReady.php b/app/Http/Middleware/App/EnsureAccountReady.php index 4123ee71..cdb8d4b9 100644 --- a/app/Http/Middleware/App/EnsureAccountReady.php +++ b/app/Http/Middleware/App/EnsureAccountReady.php @@ -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()) { diff --git a/app/Models/Account.php b/app/Models/Account.php index 632b9682..13716450 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -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; } /** diff --git a/tests/Feature/Auth/TrialOnSignupTest.php b/tests/Feature/Auth/TrialOnSignupTest.php index f47434a0..e3d9066f 100644 --- a/tests/Feature/Auth/TrialOnSignupTest.php +++ b/tests/Feature/Auth/TrialOnSignupTest.php @@ -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(); -}); diff --git a/tests/Feature/BillingControllerTest.php b/tests/Feature/BillingControllerTest.php index ebb212ad..efe90d7b 100644 --- a/tests/Feature/BillingControllerTest.php +++ b/tests/Feature/BillingControllerTest.php @@ -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')) ); }); diff --git a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php index 3c80d220..1723f806 100644 --- a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php +++ b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php @@ -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 () { diff --git a/tests/Unit/Models/AccountTest.php b/tests/Unit/Models/AccountTest.php index 84147a8c..a62a867c 100644 --- a/tests/Unit/Models/AccountTest.php +++ b/tests/Unit/Models/AccountTest.php @@ -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); From a8f999f45f8ca0934a4b3deb1b249764e44a09ff Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 21 May 2026 09:57:46 -0300 Subject: [PATCH 2/5] chore(billing): surface trial copy and rename signup test Show the checkout-based trial duration again on the subscribe page and rename the signup billing test file so it matches the current no-trial-at-signup behavior. Co-authored-by: Cursor --- lang/en/billing.php | 2 ++ lang/es/billing.php | 2 ++ lang/pt-BR/billing.php | 2 ++ resources/js/pages/billing/Subscribe.vue | 8 ++++++-- ...ialOnSignupTest.php => SignupRequiresCheckoutTest.php} | 0 5 files changed, 12 insertions(+), 2 deletions(-) rename tests/Feature/Auth/{TrialOnSignupTest.php => SignupRequiresCheckoutTest.php} (100%) 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..2b1c4cc6 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; }>(); 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,7 @@ const planTones: Record = { ]" @click="selectPlan(plan)" > - {{ $t('billing.subscribe.subscribe_cta') }} + {{ trans('billing.subscribe.start_trial', { days: String(trialDays) }) }} diff --git a/tests/Feature/Auth/TrialOnSignupTest.php b/tests/Feature/Auth/SignupRequiresCheckoutTest.php similarity index 100% rename from tests/Feature/Auth/TrialOnSignupTest.php rename to tests/Feature/Auth/SignupRequiresCheckoutTest.php From 3611e882b4e1137d13770988fdbac9bf46ea7a6d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 21 May 2026 10:02:38 -0300 Subject: [PATCH 3/5] feat(billing): make trial card requirement configurable Add a trypost config toggle to switch between card-required checkout trials and no-card signup trials, and wire signup, checkout, access gating, UI copy, and tests to both modes. Co-authored-by: Cursor --- .env.example | 4 ++++ app/Actions/User/CreateUser.php | 15 +++++++++--- .../Controllers/App/BillingController.php | 11 ++++++--- .../Middleware/App/EnsureAccountReady.php | 12 ++++++++-- app/Models/Account.php | 8 +++++++ config/trypost.php | 15 ++++++++++++ resources/js/pages/billing/Subscribe.vue | 11 ++++++--- .../Auth/SignupRequiresCheckoutTest.php | 16 +++++++++++++ tests/Feature/BillingControllerTest.php | 16 +++++++++++++ .../Middleware/TrialMiddlewareAccessTest.php | 24 +++++++++++++++++++ tests/Unit/Models/AccountTest.php | 19 +++++++++++++++ 11 files changed, 140 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 02edf84c..e6ae6d38 100644 --- a/.env.example +++ b/.env.example @@ -168,6 +168,10 @@ AI_AUDIO_PROVIDER=elevenlabs # Stripe (Cashier — billing) # ============================================ # Required when SELF_HOSTED=false. Get keys at https://dashboard.stripe.com/apikeys +# Trial behavior: +# - true => require card at checkout to start trial +# - false => grant a signup trial without card +BILLING_REQUIRE_CARD_FOR_TRIAL=true STRIPE_KEY= STRIPE_SECRET= STRIPE_WEBHOOK_SECRET= diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index ae81ae07..5f01e207 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; @@ -20,11 +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'), - ]); + ]; + + 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 92e00744..3edfbd03 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -28,9 +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' => config('cashier.trial_days'), + 'trialDays' => $requiresCardForTrial ? config('cashier.trial_days') : null, ]); } @@ -63,8 +65,11 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse|Redirect ]); $subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId) - ->allowPromotionCodes() - ->trialDays(config('cashier.trial_days')); + ->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}', diff --git a/app/Http/Middleware/App/EnsureAccountReady.php b/app/Http/Middleware/App/EnsureAccountReady.php index cdb8d4b9..20a0900f 100644 --- a/app/Http/Middleware/App/EnsureAccountReady.php +++ b/app/Http/Middleware/App/EnsureAccountReady.php @@ -24,8 +24,16 @@ 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')) { + $requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true); + $hasAccess = $account && ( + $account->subscribed(Account::SUBSCRIPTION_NAME) + || (! $requiresCardForTrial && $account->isOnTrial()) + ); + + if (! $hasAccess) { + return redirect()->route('app.subscribe'); + } } if (! $user->workspaces()->exists()) { diff --git a/app/Models/Account.php b/app/Models/Account.php index 13716450..f4c04c99 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -93,6 +93,10 @@ public function hasActiveSubscription(): bool public function isOnTrial(): bool { + if (! (bool) config('trypost.billing.require_card_for_trial', true) && $this->onGenericTrial()) { + return true; + } + return (bool) $this->subscription(self::SUBSCRIPTION_NAME)?->onTrial(); } @@ -101,6 +105,10 @@ public function activeTrialEndsAt(): ?CarbonInterface $subscription = $this->subscription(self::SUBSCRIPTION_NAME); if (! $subscription?->onTrial()) { + if (! (bool) config('trypost.billing.require_card_for_trial', true) && $this->onGenericTrial()) { + return $this->trial_ends_at; + } + return null; } diff --git a/config/trypost.php b/config/trypost.php index 45f1d93d..a7209d9f 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' => env('BILLING_REQUIRE_CARD_FOR_TRIAL', true), + ], + /* |-------------------------------------------------------------------------- | Media Size Limits diff --git a/resources/js/pages/billing/Subscribe.vue b/resources/js/pages/billing/Subscribe.vue index 2b1c4cc6..c25d9dee 100644 --- a/resources/js/pages/billing/Subscribe.vue +++ b/resources/js/pages/billing/Subscribe.vue @@ -29,7 +29,7 @@ interface Highlight { const { plans, trialDays } = defineProps<{ plans: Plan[]; - trialDays: number; + trialDays: number | null; }>(); const isYearly = ref(true); @@ -122,7 +122,7 @@ const planTones: Record = {

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

-

+

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

@@ -231,7 +231,12 @@ const planTones: Record = { ]" @click="selectPlan(plan)" > - {{ trans('billing.subscribe.start_trial', { days: String(trialDays) }) }} + + diff --git a/tests/Feature/Auth/SignupRequiresCheckoutTest.php b/tests/Feature/Auth/SignupRequiresCheckoutTest.php index e3d9066f..005c0e02 100644 --- a/tests/Feature/Auth/SignupRequiresCheckoutTest.php +++ b/tests/Feature/Auth/SignupRequiresCheckoutTest.php @@ -7,6 +7,7 @@ beforeEach(function () { config(['trypost.self_hosted' => false]); + config(['trypost.billing.require_card_for_trial' => true]); $this->seed(PlanSeeder::class); }); @@ -23,3 +24,18 @@ 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/BillingControllerTest.php b/tests/Feature/BillingControllerTest.php index efe90d7b..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, @@ -143,6 +145,20 @@ ); }); +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) + ); +}); + test('billing index redirects to calendar in self hosted mode', function () { config(['trypost.self_hosted' => true]); diff --git a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php index 1723f806..3bc6cf7b 100644 --- a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php +++ b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php @@ -11,6 +11,7 @@ beforeEach(function () { config(['trypost.self_hosted' => false]); + config(['trypost.billing.require_card_for_trial' => true]); $this->seed(PlanSeeder::class); }); @@ -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 a62a867c..4d4e416c 100644 --- a/tests/Unit/Models/AccountTest.php +++ b/tests/Unit/Models/AccountTest.php @@ -9,6 +9,7 @@ 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'); }); @@ -19,6 +20,14 @@ 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]); @@ -54,6 +63,16 @@ 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()); +}); + test('activeTrialEndsAt returns subscription date when only subscription trial is active', function () { $subscriptionEndsAt = now()->addDays(5); $account = Account::factory()->create([ From e8fcbfa230643258b48142c7ed16c009ce20643a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 21 May 2026 10:09:20 -0300 Subject: [PATCH 4/5] refactor(billing): make trial toggle fixed in trypost config Keep the trial card requirement switch as a plain boolean in config/trypost.php so changing the app flow is a single in-repo config edit without env wiring. Co-authored-by: Cursor --- .env.example | 4 ---- config/trypost.php | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.env.example b/.env.example index e6ae6d38..02edf84c 100644 --- a/.env.example +++ b/.env.example @@ -168,10 +168,6 @@ AI_AUDIO_PROVIDER=elevenlabs # Stripe (Cashier — billing) # ============================================ # Required when SELF_HOSTED=false. Get keys at https://dashboard.stripe.com/apikeys -# Trial behavior: -# - true => require card at checkout to start trial -# - false => grant a signup trial without card -BILLING_REQUIRE_CARD_FOR_TRIAL=true STRIPE_KEY= STRIPE_SECRET= STRIPE_WEBHOOK_SECRET= diff --git a/config/trypost.php b/config/trypost.php index a7209d9f..5b20081b 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -28,7 +28,7 @@ */ 'billing' => [ - 'require_card_for_trial' => env('BILLING_REQUIRE_CARD_FOR_TRIAL', true), + 'require_card_for_trial' => true, ], /* From 2fff7f66f3cb4c1038ac4f5760c2c7e751fde46a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 21 May 2026 20:04:16 -0300 Subject: [PATCH 5/5] fix(billing): update default trial days to 8 in cashier config Increase the default trial duration from 7 to 8 days in the cashier configuration to better align with user feedback and improve trial experience. --- config/cashier.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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), ];