feat(billing): clear trial_ends_at on subscription created + add tests
- StripeEventListener::handleSubscriptionCreated nulls account.trial_ends_at when a Stripe subscription is created. Prevents 'Trial' badge from lingering for users who convert mid-generic-trial to paid. - Drop unused trialDays global Inertia prop (no frontend consumers after /subscribe redesign). Tests added (10 new, 0 regressions, 1533 total): - AccountTest: isOnTrial + activeTrialEndsAt across 4 scenarios (no trial, generic only, subscription only, both — subscription wins) - StripeEventListenerTest: subscription created clears generic trial - TrialMiddlewareAccessTest: trialing-with-card subscription passes - BillingControllerTest: index exposes onTrial/trialEndsAt for the 3 trial states (generic-only, subscription-only, paying); subscribe page no longer exposes trialDays prop
This commit is contained in:
parent
c29198caef
commit
0514ce677b
7 changed files with 240 additions and 4 deletions
|
|
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
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;
|
||||
|
|
@ -60,3 +62,31 @@
|
|||
|
||||
$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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -107,7 +107,6 @@
|
|||
$response->assertInertia(fn ($page) => $page
|
||||
->component('billing/Subscribe', false)
|
||||
->has('plans', $activePlanCount)
|
||||
->has('trialDays')
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
122
tests/Unit/Models/AccountTest.php
Normal file
122
tests/Unit/Models/AccountTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in a new issue