From 3611e882b4e1137d13770988fdbac9bf46ea7a6d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 21 May 2026 10:02:38 -0300 Subject: [PATCH] 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([