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:
parent
169ebf22a5
commit
b399aaaa0d
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue