Skip to content
Merged
4 changes: 4 additions & 0 deletions app/Actions/User/CreateUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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([
Expand Down
8 changes: 3 additions & 5 deletions app/Http/Controllers/App/BillingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]);
}

Expand Down Expand Up @@ -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}',
Expand Down Expand Up @@ -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',
Expand Down
11 changes: 9 additions & 2 deletions app/Http/Middleware/App/EnsureAccountReady.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
1 change: 0 additions & 1 deletion app/Http/Middleware/App/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
];
}
}
5 changes: 4 additions & 1 deletion app/Listeners/StripeEventListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
23 changes: 22 additions & 1 deletion app/Models/Account.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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,
};
}

/**
Expand Down
2 changes: 1 addition & 1 deletion config/cashier.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,6 @@
|
*/

'trial_days' => env('CASHIER_TRIAL_DAYS', 8),
'trial_days' => env('CASHIER_TRIAL_DAYS', 7),

];
5 changes: 2 additions & 3 deletions lang/en/billing.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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'],
Expand Down
5 changes: 2 additions & 3 deletions lang/es/billing.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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'],
Expand Down
2 changes: 1 addition & 1 deletion lang/php_en.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion lang/php_es.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion lang/php_pt-BR.json

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions lang/pt-BR/billing.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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'],
Expand Down
5 changes: 2 additions & 3 deletions resources/js/pages/billing/Subscribe.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ interface Highlight {

defineProps<{
plans: Plan[];
trialDays: number;
}>();

const isYearly = ref(true);
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>

Expand Down
63 changes: 63 additions & 0 deletions tests/Feature/Auth/TrialOnSignupTest.php
Original file line number Diff line number Diff line change
@@ -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();
});
68 changes: 67 additions & 1 deletion tests/Feature/BillingControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
$response->assertInertia(fn ($page) => $page
->component('billing/Subscribe', false)
->has('plans')
->has('trialDays')
);
});

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

Expand Down
17 changes: 17 additions & 0 deletions tests/Feature/Listeners/StripeEventListenerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ========================================
Expand Down
Loading
Loading