= {
{{ $t('billing.subscribe.title') }}
- {{ trans('billing.subscribe.description', { days: String(trialDays) }) }}
+ {{ $t('billing.subscribe.description') }}
@@ -228,7 +227,7 @@ const planTones: Record = {
]"
@click="selectPlan(plan)"
>
- {{ trans('billing.subscribe.start_trial', { days: String(trialDays) }) }}
+ {{ $t('billing.subscribe.subscribe_cta') }}
diff --git a/tests/Feature/Auth/TrialOnSignupTest.php b/tests/Feature/Auth/TrialOnSignupTest.php
new file mode 100644
index 00000000..f47434a0
--- /dev/null
+++ b/tests/Feature/Auth/TrialOnSignupTest.php
@@ -0,0 +1,63 @@
+ 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();
+});
diff --git a/tests/Feature/BillingControllerTest.php b/tests/Feature/BillingControllerTest.php
index aaa09251..ebb212ad 100644
--- a/tests/Feature/BillingControllerTest.php
+++ b/tests/Feature/BillingControllerTest.php
@@ -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]);
diff --git a/tests/Feature/Listeners/StripeEventListenerTest.php b/tests/Feature/Listeners/StripeEventListenerTest.php
index 02df3a20..ced8ebe9 100644
--- a/tests/Feature/Listeners/StripeEventListenerTest.php
+++ b/tests/Feature/Listeners/StripeEventListenerTest.php
@@ -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
// ========================================
diff --git a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php
new file mode 100644
index 00000000..3c80d220
--- /dev/null
+++ b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php
@@ -0,0 +1,92 @@
+ 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();
+});
diff --git a/tests/Feature/WorkspaceBillingTest.php b/tests/Feature/WorkspaceBillingTest.php
index 1ad4427b..7eaf9142 100644
--- a/tests/Feature/WorkspaceBillingTest.php
+++ b/tests/Feature/WorkspaceBillingTest.php
@@ -107,7 +107,6 @@
$response->assertInertia(fn ($page) => $page
->component('billing/Subscribe', false)
->has('plans', $activePlanCount)
- ->has('trialDays')
);
});
diff --git a/tests/Unit/Models/AccountTest.php b/tests/Unit/Models/AccountTest.php
new file mode 100644
index 00000000..84147a8c
--- /dev/null
+++ b/tests/Unit/Models/AccountTest.php
@@ -0,0 +1,122 @@
+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();
+});