feat(subscriptions): add configurable trial period to paid plans (#324)

## Summary

Adds a 15-day trial to the monthly and yearly plans. Configurable per
plan (or disabled) via config.

## Changes

- `config/subscriptions.php` — new `trial_days` key per plan (defaults:
monthly=15, yearly=15). Env overrides: `STRIPE_PRO_MONTHLY_TRIAL_DAYS`,
`STRIPE_PRO_YEARLY_TRIAL_DAYS`. Set to `0` to disable.
- `SubscriptionController::checkout` — applies `trialDays()` on the
Cashier subscription builder when `trial_days > 0`.
- Tests — assert `trial_days` surfaced in pricing props; assert
`trialDays(15)` applied on checkout; assert skipped when `0`.

## Notes

Stripe Checkout enforces a **minimum 2-day trial**. Values of `1` will
fail at Stripe. `0` disables cleanly.

## Test plan

```
php artisan test --compact tests/Feature/SubscriptionTest.php
```
This commit is contained in:
Víctor Falcón 2026-04-24 13:07:46 +01:00 committed by GitHub
parent 169ebf22a5
commit b399aaaa0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 77 additions and 6 deletions

View File

@ -77,13 +77,19 @@ class SubscriptionController extends Controller
$priceId = $this->resolvePriceIdByLookupKey($plan['stripe_lookup_key']);
return $request->user()
$subscriptionBuilder = $request->user()
->newSubscription('default', $priceId)
->allowPromotionCodes()
->checkout([
'success_url' => route('subscribe.success'),
'cancel_url' => route('subscribe.cancel'),
]);
->allowPromotionCodes();
$trialDays = (int) ($plan['trial_days'] ?? 0);
if ($trialDays > 0) {
$subscriptionBuilder->trialDays($trialDays);
}
return $subscriptionBuilder->checkout([
'success_url' => route('subscribe.success'),
'cancel_url' => route('subscribe.cancel'),
]);
}
/**

View File

@ -52,6 +52,7 @@ return [
'original_price' => null,
'stripe_lookup_key' => env('STRIPE_PRO_MONTHLY_LOOKUP_KEY', 'whisper_pro_monthly'),
'billing_period' => 'month',
'trial_days' => (int) env('STRIPE_PRO_MONTHLY_TRIAL_DAYS', 15),
'features' => [
'Unlimited accounts',
'Unlimited transactions',
@ -68,6 +69,7 @@ return [
'original_price' => 47.88,
'stripe_lookup_key' => env('STRIPE_PRO_YEARLY_LOOKUP_KEY', 'whisper_pro_yearly'),
'billing_period' => 'year',
'trial_days' => (int) env('STRIPE_PRO_YEARLY_TRIAL_DAYS', 15),
'features' => [
'Unlimited accounts',
'Unlimited transactions',

View File

@ -8,6 +8,9 @@ use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Cache;
use Laravel\Cashier\Checkout;
use Laravel\Cashier\SubscriptionBuilder;
beforeEach(function () {
config([
@ -196,6 +199,7 @@ test('pricing config includes all plan details', function () {
->where('original_price', null)
->has('stripe_lookup_key')
->where('billing_period', 'month')
->where('trial_days', 15)
->has('features')
)
->has('pricing.plans.yearly', fn ($plan) => $plan
@ -204,6 +208,7 @@ test('pricing config includes all plan details', function () {
->where('original_price', 47.88)
->has('stripe_lookup_key')
->where('billing_period', 'year')
->where('trial_days', 15)
->has('features')
)
->has('pricing.promo', fn ($promo) => $promo
@ -298,3 +303,61 @@ test('billing portal skips stripe customer creation when user already has a stri
$this->get(route('settings.billing.portal'))->assertRedirect();
});
test('checkout applies configured trial days to the subscription builder', function () {
config([
'subscriptions.plans.monthly.trial_days' => 15,
'subscriptions.plans.monthly.stripe_lookup_key' => 'test_monthly_lookup',
]);
Cache::put('stripe_price_id:test_monthly_lookup', 'price_test_monthly', now()->addHour());
$checkout = Mockery::mock(Checkout::class);
$checkout->shouldReceive('toResponse')->andReturn(new RedirectResponse('https://stripe.test/session'));
$builder = Mockery::mock(SubscriptionBuilder::class);
$builder->shouldReceive('allowPromotionCodes')->once()->andReturnSelf();
$builder->shouldReceive('trialDays')->once()->with(15)->andReturnSelf();
$builder->shouldReceive('checkout')->once()->andReturn($checkout);
$user = Mockery::mock(User::class)->shouldIgnoreMissing();
$user->shouldReceive('hasVerifiedEmail')->andReturn(true);
$user->shouldReceive('hasProPlan')->andReturn(false);
$user->shouldReceive('newSubscription')
->once()
->with('default', 'price_test_monthly')
->andReturn($builder);
$this->withoutMiddleware(HandleInertiaRequests::class);
$this->actingAs($user);
$this->get(route('subscribe.checkout', ['plan' => 'monthly']))->assertRedirect();
});
test('checkout skips trial days when configured to zero', function () {
config([
'subscriptions.plans.monthly.trial_days' => 0,
'subscriptions.plans.monthly.stripe_lookup_key' => 'test_monthly_lookup',
]);
Cache::put('stripe_price_id:test_monthly_lookup', 'price_test_monthly', now()->addHour());
$checkout = Mockery::mock(Checkout::class);
$checkout->shouldReceive('toResponse')->andReturn(new RedirectResponse('https://stripe.test/session'));
$builder = Mockery::mock(SubscriptionBuilder::class);
$builder->shouldReceive('allowPromotionCodes')->once()->andReturnSelf();
$builder->shouldNotReceive('trialDays');
$builder->shouldReceive('checkout')->once()->andReturn($checkout);
$user = Mockery::mock(User::class)->shouldIgnoreMissing();
$user->shouldReceive('hasVerifiedEmail')->andReturn(true);
$user->shouldReceive('hasProPlan')->andReturn(false);
$user->shouldReceive('newSubscription')
->once()
->with('default', 'price_test_monthly')
->andReturn($builder);
$this->withoutMiddleware(HandleInertiaRequests::class);
$this->actingAs($user);
$this->get(route('subscribe.checkout', ['plan' => 'monthly']))->assertRedirect();
});