trypost/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php
Paulo Castellano 4590fc5fd1
Make Stripe Checkout configurable via billing env knobs (#252)
* Make Stripe Checkout configurable via billing env knobs

Replace the hard-required $1 first-month coupon with env-driven trial days,
optional coupon, and allow_promotion_codes so SaaS can switch checkout modes
without a code change.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix no-effect ReflectionClass import in checkout test

CI treats bare global use statements as ErrorException and aborts
loading the suite before any assertions run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Document checkout env knobs in AGENTS.md instead of .ai/

Remove the Boost record-rule .ai/rules folder and keep durable billing
checkout guidance in AGENTS.md / CLAUDE.md project-specific rules.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden checkout env knobs from review findings

Default allow_promotion_codes to false, grant Stripe trial only to
first-time subscribers, clarify the coupon/promo XOR error, and cover
negative XOR cases plus StartSubscriptionCheckout wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 21:25:32 -03:00

295 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Account;
use App\Models\Workspace;
use App\Support\Billing\ConfigureSubscriptionCheckout;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Cashier\SubscriptionBuilder;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->account = Account::factory()->create();
config([
'trypost.billing.require_card_for_trial' => true,
'cashier.trial_days' => 8,
'cashier.first_month_coupon_id' => '',
'cashier.allow_promotion_codes' => false,
]);
});
function checkoutSubscription(Account $account): SubscriptionBuilder
{
return $account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test');
}
function givePriorSubscription(Account $account, string $status = 'canceled'): void
{
$account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_'.fake()->uuid(),
'stripe_status' => $status,
'stripe_price' => 'price_monthly_test',
]);
}
function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon
{
$property = (new ReflectionClass($subscription))->getProperty('trialExpires');
return $property->getValue($subscription);
}
test('recipe A applies an eight-day trial without coupon or promotion codes', function () {
Workspace::factory()->create(['account_id' => $this->account->id]);
Carbon::setTestNow('2026-08-07 12:00:00');
$subscription = checkoutSubscription($this->account);
ConfigureSubscriptionCheckout::apply($subscription, $this->account);
expect($subscription->couponId)->toBeNull()
->and($subscription->allowPromotionCodes)->toBeFalse()
->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00');
});
test('recipe B applies the first-month coupon and skips trial days', function () {
config([
'cashier.first_month_coupon_id' => 'TRIAL1USD',
'cashier.allow_promotion_codes' => false,
'cashier.trial_days' => 8,
]);
Workspace::factory()->create(['account_id' => $this->account->id]);
$subscription = checkoutSubscription($this->account);
ConfigureSubscriptionCheckout::apply($subscription, $this->account);
expect($subscription->couponId)->toBe('TRIAL1USD')
->and($subscription->allowPromotionCodes)->toBeFalse()
->and(trialExpiresAt($subscription))->toBeNull();
});
test('recipe C applies trial days and allows promotion codes', function () {
config(['cashier.allow_promotion_codes' => true]);
Workspace::factory()->create(['account_id' => $this->account->id]);
Carbon::setTestNow('2026-08-07 12:00:00');
$subscription = checkoutSubscription($this->account);
ConfigureSubscriptionCheckout::apply($subscription, $this->account);
expect($subscription->couponId)->toBeNull()
->and($subscription->allowPromotionCodes)->toBeTrue()
->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00');
});
test('recipe D charges immediately with promotion codes and no trial', function () {
config([
'cashier.trial_days' => 0,
'cashier.allow_promotion_codes' => true,
]);
Workspace::factory()->create(['account_id' => $this->account->id]);
$subscription = checkoutSubscription($this->account);
ConfigureSubscriptionCheckout::apply($subscription, $this->account);
expect(trialExpiresAt($subscription))->toBeNull()
->and($subscription->couponId)->toBeNull()
->and($subscription->allowPromotionCodes)->toBeTrue();
});
test('throws when a qualifying coupon would combine with allow promotion codes', function () {
config([
'cashier.first_month_coupon_id' => 'TRIAL1USD',
'cashier.allow_promotion_codes' => true,
]);
Workspace::factory()->create(['account_id' => $this->account->id]);
$subscription = checkoutSubscription($this->account);
expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account))
->toThrow(RuntimeException::class, 'Cannot apply STRIPE_FIRST_MONTH_COUPON_ID while CASHIER_ALLOW_PROMOTION_CODES is enabled');
expect($subscription->couponId)->toBeNull()
->and(trialExpiresAt($subscription))->toBeNull();
});
test('does not throw when coupon and promo are both set but multi-workspace skips the coupon', function () {
config([
'cashier.first_month_coupon_id' => 'TRIAL1USD',
'cashier.allow_promotion_codes' => true,
]);
Workspace::factory()->count(2)->create(['account_id' => $this->account->id]);
Carbon::setTestNow('2026-08-07 12:00:00');
$subscription = checkoutSubscription($this->account);
expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account))
->not->toThrow(RuntimeException::class);
expect($subscription->couponId)->toBeNull()
->and($subscription->allowPromotionCodes)->toBeTrue()
->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00');
});
test('does not throw when coupon and promo are both set but a prior canceled subscription skips the coupon', function () {
config([
'cashier.first_month_coupon_id' => 'TRIAL1USD',
'cashier.allow_promotion_codes' => true,
]);
Workspace::factory()->create(['account_id' => $this->account->id]);
givePriorSubscription($this->account);
$subscription = checkoutSubscription($this->account);
expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account))
->not->toThrow(RuntimeException::class);
expect($subscription->couponId)->toBeNull()
->and($subscription->allowPromotionCodes)->toBeTrue()
->and(trialExpiresAt($subscription))->toBeNull();
});
test('empty coupon with card required does not throw and still applies trial', function () {
config([
'cashier.first_month_coupon_id' => '',
'cashier.allow_promotion_codes' => false,
]);
Workspace::factory()->create(['account_id' => $this->account->id]);
Carbon::setTestNow('2026-08-07 12:00:00');
$subscription = checkoutSubscription($this->account);
ConfigureSubscriptionCheckout::apply($subscription, $this->account);
expect($subscription->couponId)->toBeNull()
->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00');
});
test('skips the coupon and allows promotion codes when a card is not required', function () {
config([
'trypost.billing.require_card_for_trial' => false,
'cashier.first_month_coupon_id' => 'TRIAL1USD',
'cashier.allow_promotion_codes' => true,
]);
Workspace::factory()->create(['account_id' => $this->account->id]);
$subscription = checkoutSubscription($this->account);
expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account))
->not->toThrow(RuntimeException::class);
expect($subscription->allowPromotionCodes)->toBeTrue()
->and($subscription->couponId)->toBeNull()
->and(trialExpiresAt($subscription))->toBeNull();
});
test('skips the coupon when more than one workspace is billed and still applies trial for first-time', function () {
config(['cashier.first_month_coupon_id' => 'TRIAL1USD']);
Workspace::factory()->count(2)->create(['account_id' => $this->account->id]);
Carbon::setTestNow('2026-08-07 12:00:00');
$subscription = checkoutSubscription($this->account);
ConfigureSubscriptionCheckout::apply($subscription, $this->account);
expect($subscription->couponId)->toBeNull()
->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00');
});
test('skips coupon and trial when the account has a prior canceled subscription', function () {
config(['cashier.first_month_coupon_id' => 'TRIAL1USD']);
Workspace::factory()->create(['account_id' => $this->account->id]);
givePriorSubscription($this->account);
$subscription = checkoutSubscription($this->account);
ConfigureSubscriptionCheckout::apply($subscription, $this->account);
expect($subscription->couponId)->toBeNull()
->and(trialExpiresAt($subscription))->toBeNull()
->and($subscription->allowPromotionCodes)->toBeFalse();
});
test('still applies the coupon when the only prior subscription is incomplete_expired', function () {
config([
'cashier.first_month_coupon_id' => 'TRIAL1USD',
'cashier.allow_promotion_codes' => false,
]);
Workspace::factory()->create(['account_id' => $this->account->id]);
givePriorSubscription($this->account, 'incomplete_expired');
$subscription = checkoutSubscription($this->account);
ConfigureSubscriptionCheckout::apply($subscription, $this->account);
expect($subscription->couponId)->toBe('TRIAL1USD')
->and($subscription->allowPromotionCodes)->toBeFalse()
->and(trialExpiresAt($subscription))->toBeNull();
});
test('still applies the coupon when the only prior subscription is incomplete', function () {
config([
'cashier.first_month_coupon_id' => 'TRIAL1USD',
'cashier.allow_promotion_codes' => false,
]);
Workspace::factory()->create(['account_id' => $this->account->id]);
givePriorSubscription($this->account, 'incomplete');
$subscription = checkoutSubscription($this->account);
ConfigureSubscriptionCheckout::apply($subscription, $this->account);
expect($subscription->couponId)->toBe('TRIAL1USD')
->and(trialExpiresAt($subscription))->toBeNull();
});
test('clamps a one-day trial to Stripe Checkout minimum of two days', function () {
config(['cashier.trial_days' => 1]);
Workspace::factory()->create(['account_id' => $this->account->id]);
Carbon::setTestNow('2026-08-07 12:00:00');
$subscription = checkoutSubscription($this->account);
ConfigureSubscriptionCheckout::apply($subscription, $this->account);
expect(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-09 12:00:00');
});
test('skips trial days when trial_days is zero', function () {
config(['cashier.trial_days' => 0]);
Workspace::factory()->create(['account_id' => $this->account->id]);
$subscription = checkoutSubscription($this->account);
ConfigureSubscriptionCheckout::apply($subscription, $this->account);
expect(trialExpiresAt($subscription))->toBeNull()
->and($subscription->couponId)->toBeNull()
->and($subscription->allowPromotionCodes)->toBeFalse();
});
test('zero workspaces still get a first-time trial without a coupon', function () {
config(['cashier.first_month_coupon_id' => 'TRIAL1USD']);
Carbon::setTestNow('2026-08-07 12:00:00');
$subscription = checkoutSubscription($this->account);
ConfigureSubscriptionCheckout::apply($subscription, $this->account);
expect($subscription->couponId)->toBeNull()
->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00');
});