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 <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-05-21 10:02:38 -03:00
parent a8f999f45f
commit 3611e882b4
11 changed files with 140 additions and 11 deletions

View file

@ -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=

View file

@ -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'),

View file

@ -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}',

View file

@ -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()) {

View file

@ -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;
}

View file

@ -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

View file

@ -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<PlanSlug, string> = {
<p class="mx-auto max-w-2xl text-balance text-base text-muted-foreground sm:text-lg">
{{ $t('billing.subscribe.description') }}
</p>
<p class="text-sm font-semibold text-foreground/70">
<p v-if="trialDays !== null" class="text-sm font-semibold text-foreground/70">
{{ trans('billing.subscribe.trial_info', { days: String(trialDays) }) }}
</p>
</div>
@ -231,7 +231,12 @@ const planTones: Record<PlanSlug, string> = {
]"
@click="selectPlan(plan)"
>
{{ trans('billing.subscribe.start_trial', { days: String(trialDays) }) }}
<template v-if="trialDays !== null">
{{ trans('billing.subscribe.start_trial', { days: String(trialDays) }) }}
</template>
<template v-else>
{{ $t('billing.subscribe.subscribe_cta') }}
</template>
<IconArrowRight class="size-4" />
</button>

View file

@ -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();
});

View file

@ -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]);

View file

@ -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();
});

View file

@ -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([