From b399aaaa0dcafc27f9f9665209f9aceecf0b70e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 24 Apr 2026 13:07:46 +0100 Subject: [PATCH] feat(subscriptions): add configurable trial period to paid plans (#324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ``` --- .../Controllers/SubscriptionController.php | 18 ++++-- config/subscriptions.php | 2 + tests/Feature/SubscriptionTest.php | 63 +++++++++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index b68f0982..d8372a8f 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -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'), + ]); } /** diff --git a/config/subscriptions.php b/config/subscriptions.php index d05ef109..da46dcba 100644 --- a/config/subscriptions.php +++ b/config/subscriptions.php @@ -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', diff --git a/tests/Feature/SubscriptionTest.php b/tests/Feature/SubscriptionTest.php index 16f71af2..4a402553 100644 --- a/tests/Feature/SubscriptionTest.php +++ b/tests/Feature/SubscriptionTest.php @@ -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(); +});