Merge pull request #38 from trypostit/feat/trial-without-card

feat(signup): no-card 7-day trial on Starter plan
This commit is contained in:
Paulo Castellano 2026-05-14 20:26:26 -03:00 committed by GitHub
commit a9e8efffea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 415 additions and 28 deletions

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;
@ -24,6 +26,8 @@ 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([

View file

@ -30,7 +30,6 @@ public function subscribe(Request $request): Response|RedirectResponse
return Inertia::render('billing/Subscribe', [
'plans' => Plan::active()->orderBy('sort')->get(),
'trialDays' => config('cashier.trial_days'),
]);
}
@ -63,8 +62,7 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse|Redirect
]);
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
->allowPromotionCodes()
->trialDays(config('cashier.trial_days'));
->allowPromotionCodes();
$checkoutSession = $subscription->checkout([
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',
@ -138,8 +136,8 @@ public function index(Request $request): Response|RedirectResponse
return Inertia::render('settings/account/Billing', [
'hasSubscription' => $account->subscribed(Account::SUBSCRIPTION_NAME),
'onTrial' => $subscription?->onTrial() ?? false,
'trialEndsAt' => $subscription?->trial_ends_at,
'onTrial' => $account->isOnTrial(),
'trialEndsAt' => $account->activeTrialEndsAt(),
'subscription' => $subscription?->only([
'stripe_status',
'ends_at',

View file

@ -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->isOnTrial()
);
if (! $hasAccess) {
return redirect()->route('app.subscribe');
}
}
if (! $user->workspaces()->exists()) {

View file

@ -61,7 +61,6 @@ public function share(Request $request): array
'selfHosted' => $isSelfHosted,
'googleAuthEnabled' => config('trypost.google_auth_enabled'),
'githubAuthEnabled' => config('trypost.github_auth_enabled'),
'trialDays' => config('cashier.trial_days'),
];
}
}

View file

@ -52,7 +52,10 @@ protected function handleSubscriptionCreated(Account $account, array $payload):
$previousPlan = $account->plan?->name;
if ($plan = $this->resolvePlanFromSubscriptionItems($payload, $account)) {
$account->update(['plan_id' => $plan->id]);
$account->update([
'plan_id' => $plan->id,
'trial_ends_at' => null,
]);
$account->forgetPlanFeatureCache();
}

View file

@ -9,6 +9,7 @@
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Models\Traits\HasUsage;
use Carbon\CarbonInterface;
use Database\Factories\AccountFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -30,6 +31,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 +84,22 @@ 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();
}
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,
};
}
/**

View file

@ -135,6 +135,6 @@
|
*/
'trial_days' => env('CASHIER_TRIAL_DAYS', 8),
'trial_days' => env('CASHIER_TRIAL_DAYS', 7),
];

View file

@ -26,8 +26,7 @@
'page_title' => 'Choose your plan',
'eyebrow' => 'Pricing',
'title' => 'Choose the right plan for you',
'description' => 'Start with a 7-day free trial. No charge until your trial ends.',
'trial_info' => '7-day free trial, then billed automatically',
'description' => 'Pick the plan that fits you. Billed monthly or annually.',
'monthly' => 'Monthly',
'yearly' => 'Yearly',
'per_month' => 'monthly',
@ -38,7 +37,7 @@
'everything_in' => 'Everything in :plan, plus:',
'save_months' => '2 months free',
'popular' => 'Most popular',
'start_trial' => 'Start 7-day free trial',
'subscribe_cta' => 'Subscribe',
'prices' => [
'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'],
'plus' => ['monthly' => '$29', 'yearly_per_month' => '$24', 'yearly' => '$290'],

View file

@ -26,8 +26,7 @@
'page_title' => 'Elige tu plan',
'eyebrow' => 'Precios',
'title' => 'Elige el plan ideal para ti',
'description' => 'Comienza con 7 días gratis. Sin cargo hasta que termine tu prueba.',
'trial_info' => 'Prueba gratuita de 7 días, luego se cobra automáticamente',
'description' => 'Elige el plan que te queda. Facturación mensual o anual.',
'monthly' => 'Mensual',
'yearly' => 'Anual',
'per_month' => 'mensual',
@ -38,7 +37,7 @@
'everything_in' => 'Todo lo de :plan, más:',
'save_months' => '2 meses gratis',
'popular' => 'Más popular',
'start_trial' => 'Comenzar prueba de 7 días',
'subscribe_cta' => 'Suscribirse',
'prices' => [
'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'],
'plus' => ['monthly' => '$29', 'yearly_per_month' => '$24', 'yearly' => '$290'],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -26,8 +26,7 @@
'page_title' => 'Escolha seu plano',
'eyebrow' => 'Preços',
'title' => 'Escolha o plano ideal pra você',
'description' => 'Comece com 7 dias grátis. Sem cobrança até o fim do seu teste.',
'trial_info' => '7 dias grátis, depois cobrança automática',
'description' => 'Escolha o plano que combina com você. Cobrança mensal ou anual.',
'monthly' => 'Mensal',
'yearly' => 'Anual',
'per_month' => 'mensal',
@ -38,7 +37,7 @@
'everything_in' => 'Tudo do :plan, mais:',
'save_months' => '2 meses grátis',
'popular' => 'Mais popular',
'start_trial' => 'Iniciar teste de 7 dias',
'subscribe_cta' => 'Assinar',
'prices' => [
'starter' => ['monthly' => 'R$ 95', 'yearly_per_month' => 'R$ 79', 'yearly' => 'R$ 950'],
'plus' => ['monthly' => 'R$ 145', 'yearly_per_month' => 'R$ 121', 'yearly' => 'R$ 1450'],

View file

@ -29,7 +29,6 @@ interface Highlight {
defineProps<{
plans: Plan[];
trialDays: number;
}>();
const isYearly = ref(true);
@ -120,7 +119,7 @@ const planTones: Record<PlanSlug, string> = {
{{ $t('billing.subscribe.title') }}
</h1>
<p class="mx-auto max-w-2xl text-balance text-base text-muted-foreground sm:text-lg">
{{ trans('billing.subscribe.description', { days: String(trialDays) }) }}
{{ $t('billing.subscribe.description') }}
</p>
</div>
</div>
@ -228,7 +227,7 @@ const planTones: Record<PlanSlug, string> = {
]"
@click="selectPlan(plan)"
>
{{ trans('billing.subscribe.start_trial', { days: String(trialDays) }) }}
{{ $t('billing.subscribe.subscribe_cta') }}
<IconArrowRight class="size-4" />
</button>

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

View file

@ -38,7 +38,6 @@
$response->assertInertia(fn ($page) => $page
->component('billing/Subscribe', false)
->has('plans')
->has('trialDays')
);
});
@ -93,6 +92,73 @@
);
});
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]);
$subscriptionEndsAt = now()->addDays(5)->startOfSecond();
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'trialing',
'stripe_price' => 'price_123',
'trial_ends_at' => $subscriptionEndsAt,
]);
$response = $this->actingAs($this->user->fresh())->get(route('app.billing.index'));
$response->assertInertia(fn ($page) => $page
->where('hasSubscription', true)
->where('onTrial', true)
->where('trialEndsAt', $subscriptionEndsAt->toIso8601ZuluString('microsecond'))
);
});
test('billing index exposes onTrial=false and trialEndsAt=null for paying subscribed user', function () {
config(['trypost.self_hosted' => false]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
$response = $this->actingAs($this->user->fresh())->get(route('app.billing.index'));
$response->assertInertia(fn ($page) => $page
->where('hasSubscription', true)
->where('onTrial', false)
->where('trialEndsAt', null)
);
});
test('subscribe page does not expose trialDays prop anymore', 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')
);
});
test('billing index redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);

View file

@ -39,6 +39,23 @@
expect(true)->toBeTrue();
});
test('subscription created clears the generic trial_ends_at on the account', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$this->account->update(['trial_ends_at' => now()->addDays(3)]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => [
'customer' => 'cus_test123',
'id' => 'sub_123',
'status' => 'active',
'items' => ['data' => [['price' => ['id' => $starter->stripe_monthly_price_id]]]],
]],
]));
expect($this->account->fresh()->trial_ends_at)->toBeNull();
});
// ========================================
// customer.subscription.updated
// ========================================

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
use App\Actions\User\CreateUser;
use App\Enums\UserWorkspace\Role;
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]);
$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-21 12:01:00');
$response = $this->actingAs($user->fresh())->get(route('app.accounts'));
$response->assertRedirect(route('app.subscribe'));
});
test('user on trialing subscription (legacy trial-with-card) can access the app', function () {
$account = Account::factory()->create([
'trial_ends_at' => null,
'stripe_id' => 'cus_test_'.fake()->uuid(),
]);
$user = User::factory()->create(['account_id' => $account->id]);
$account->update(['owner_id' => $user->id]);
$account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'trialing',
'stripe_price' => 'price_123',
'trial_ends_at' => now()->addDays(5),
]);
$workspace = Workspace::factory()->create([
'account_id' => $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

@ -107,7 +107,6 @@
$response->assertInertia(fn ($page) => $page
->component('billing/Subscribe', false)
->has('plans', $activePlanCount)
->has('trialDays')
);
});

View file

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
use App\Enums\Plan\Slug;
use App\Models\Account;
use App\Models\Plan;
use Carbon\Carbon;
use Database\Seeders\PlanSeeder;
beforeEach(function () {
$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(),
]);
expect($account->isOnTrial())->toBeFalse();
});
test('isOnTrial returns false for account without trial or subscription', function () {
$account = Account::factory()->create(['trial_ends_at' => null]);
expect($account->isOnTrial())->toBeFalse();
});
test('isOnTrial returns true via subscription trial when account has no generic trial', function () {
$account = Account::factory()->create([
'trial_ends_at' => null,
'stripe_id' => 'cus_test_'.fake()->uuid(),
]);
$account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'trialing',
'stripe_price' => 'price_123',
'trial_ends_at' => now()->addDays(5),
]);
expect($account->isOnTrial())->toBeTrue();
});
test('activeTrialEndsAt returns null when not on any trial', function () {
$account = Account::factory()->create(['trial_ends_at' => null]);
expect($account->activeTrialEndsAt())->toBeNull();
});
test('activeTrialEndsAt returns generic trial date when only generic is active', function () {
$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([
'trial_ends_at' => null,
'stripe_id' => 'cus_test_'.fake()->uuid(),
]);
$account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'trialing',
'stripe_price' => 'price_123',
'trial_ends_at' => $subscriptionEndsAt,
]);
expect($account->activeTrialEndsAt()?->toDateTimeString())
->toBe($subscriptionEndsAt->toDateTimeString());
});
test('activeTrialEndsAt prefers subscription date over generic when both active', function () {
$genericEndsAt = now()->addDays(7);
$subscriptionEndsAt = now()->addDays(14);
$account = Account::factory()->create([
'trial_ends_at' => $genericEndsAt,
'stripe_id' => 'cus_test_'.fake()->uuid(),
]);
$account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'trialing',
'stripe_price' => 'price_123',
'trial_ends_at' => $subscriptionEndsAt,
]);
expect($account->activeTrialEndsAt()?->toDateTimeString())
->toBe($subscriptionEndsAt->toDateTimeString());
});
test('activeTrialEndsAt returns null for paying customer post-trial', function () {
$account = Account::factory()->create([
'trial_ends_at' => null,
'stripe_id' => 'cus_test_'.fake()->uuid(),
'plan_id' => Plan::where('slug', Slug::Starter)->value('id'),
]);
$account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
'trial_ends_at' => null,
]);
expect($account->activeTrialEndsAt())->toBeNull();
expect($account->isOnTrial())->toBeFalse();
});