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>
This commit is contained in:
Paulo Castellano 2026-08-07 20:25:32 -04:00 committed by GitHub
parent a1fa897106
commit 4590fc5fd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 543 additions and 191 deletions

View file

@ -196,13 +196,22 @@ AI_AUDIO_PROVIDER=elevenlabs
STRIPE_KEY=
STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
# Set to false to allow generic signup trial without requiring a card.
# SaaS default (recipe A): 8-day Stripe trial with card, no coupon, no promo field.
# REQUIRE_CARD_FOR_TRIAL=true forces Checkout before app access (no generic trial).
REQUIRE_CARD_FOR_TRIAL=true
# Free trial length in days, used only for the no-card generic trial above.
# Trial length in days. Card-required Checkout uses trialDays for first-time
# subscribers when no first-month coupon is applied; re-subscribers skip trial.
# No-card mode uses this for accounts.trial_ends_at. 0 = off.
CASHIER_TRIAL_DAYS=8
# Stripe Coupon ID (amount_off, duration=once) so the first invoice at
# checkout comes out to $1 instead of the full monthly price.
# Optional Stripe Coupon ID (amount_off, duration=once). When set for a qualifying
# first-time single-workspace checkout, applies the coupon and SKIPS trialDays
# (e.g. TRIAL1USD for a $1 first month). Empty = trial mode above.
# XOR with CASHIER_ALLOW_PROMOTION_CODES only when the coupon would apply —
# Stripe forbids both on one session (ConfigureSubscriptionCheckout throws).
STRIPE_FIRST_MONTH_COUPON_ID=
# Show Stripe Checkout promotion-code field when no coupon is applied.
# Defaults to false (recipe A). Must be false when a first-month coupon applies.
CASHIER_ALLOW_PROMOTION_CODES=false
# Stripe Plan Price IDs (one per plan × interval). Used by PlanSeeder.
STRIPE_WORKSPACE_MONTHLY=

View file

@ -201,3 +201,23 @@ # Inertia + Vue
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
</laravel-boost-guidelines>
# Project-Specific Rules
## Stripe Checkout (env knobs)
Checkout options are configured only via env — do not hardcode trial/coupon/promo behavior in controllers. All of it goes through `App\Support\Billing\ConfigureSubscriptionCheckout` (called from `StartSubscriptionCheckout`).
| Env | Config | Default | Effect |
| --- | --- | --- | --- |
| `REQUIRE_CARD_FOR_TRIAL` | `trypost.billing.require_card_for_trial` | `true` | `true`: app access only after Stripe Checkout (no generic signup trial). `false`: generic `accounts.trial_ends_at` trial without a card |
| `CASHIER_TRIAL_DAYS` | `cashier.trial_days` | `8` | Card-required Checkout: `trialDays(N)` for **first-time** subscribers when no first-month coupon is applied (`0` = off). Re-subscribers skip trial. No-card mode: length of the generic signup trial |
| `STRIPE_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_id` | empty | Optional. When set for a qualifying first-time single-workspace checkout, applies `withCoupon` and **skips** trial. Empty = trial mode |
| `CASHIER_ALLOW_PROMOTION_CODES` | `cashier.allow_promotion_codes` | `false` | When `true` and no coupon is applied, show the Checkout promo-code field |
Standing constraints:
- Stripe rejects `discounts` (coupon) and `allow_promotion_codes` on the same session — if both would apply, `ConfigureSubscriptionCheckout` must throw (fail loud). Never “prefer one silently.” Envs may both be set when the account does **not** qualify for the coupon (no throw).
- A set first-month coupon wins over trial (`trialDays` is skipped for that checkout).
- Empty coupon + card required + first-time must use `trialDays` — do **not** reintroduce a required-coupon throw.
- Coupon qualification stays: card required, exactly one workspace, no prior real subscription (`incomplete` / `incomplete_expired` still qualify).
- Prefer documenting durable billing decisions here (and in `CLAUDE.md`) — do **not** create a `.ai/` rules folder for this project.

View file

@ -226,6 +226,24 @@ ## System AI (always allowed, never metered)
- It MUST NOT deduct anything: NEVER call `RecordAiUsage` (or otherwise consume the account's credits) for brand analysis. Cost is the platform's, not the user's.
- Any future "system" AI helper (runs as part of the platform, not on behalf of a workspace's metered quota) follows the same rule: ungated and unmetered.
## Stripe Checkout (env knobs)
Checkout options are configured only via env — do not hardcode trial/coupon/promo behavior in controllers. All of it goes through `App\Support\Billing\ConfigureSubscriptionCheckout` (called from `StartSubscriptionCheckout`).
| Env | Config | Default | Effect |
| --- | --- | --- | --- |
| `REQUIRE_CARD_FOR_TRIAL` | `trypost.billing.require_card_for_trial` | `true` | `true`: app access only after Stripe Checkout (no generic signup trial). `false`: generic `accounts.trial_ends_at` trial without a card |
| `CASHIER_TRIAL_DAYS` | `cashier.trial_days` | `8` | Card-required Checkout: `trialDays(N)` for **first-time** subscribers when no first-month coupon is applied (`0` = off). Re-subscribers skip trial. No-card mode: length of the generic signup trial |
| `STRIPE_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_id` | empty | Optional. When set for a qualifying first-time single-workspace checkout, applies `withCoupon` and **skips** trial. Empty = trial mode |
| `CASHIER_ALLOW_PROMOTION_CODES` | `cashier.allow_promotion_codes` | `false` | When `true` and no coupon is applied, show the Checkout promo-code field |
Standing constraints:
- Stripe rejects `discounts` (coupon) and `allow_promotion_codes` on the same session — if both would apply, `ConfigureSubscriptionCheckout` must throw (fail loud). Never “prefer one silently.” Envs may both be set when the account does **not** qualify for the coupon (no throw).
- A set first-month coupon wins over trial (`trialDays` is skipped for that checkout).
- Empty coupon + card required + first-time must use `trialDays` — do **not** reintroduce a required-coupon throw.
- Coupon qualification stays: card required, exactly one workspace, no prior real subscription (`incomplete` / `incomplete_expired` still qualify).
- Prefer documenting durable billing decisions here (and in `AGENTS.md`) — do **not** create a `.ai/` rules folder for this project.
## Icons (@tabler/icons-vue)
- This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`.

View file

@ -5,7 +5,7 @@
namespace App\Actions\Billing;
use App\Models\Account;
use App\Support\Billing\FirstMonthCheckoutDiscount;
use App\Support\Billing\ConfigureSubscriptionCheckout;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
@ -13,9 +13,9 @@ class StartSubscriptionCheckout
{
/**
* Create a Stripe Checkout session for the given price and return an Inertia
* redirect to it. Quantity tracks the account's workspace count; the first
* month's coupon is applied when the instance requires a card up front, so
* the first invoice charges $1 instead of running a $0 trial authorization.
* redirect to it. Quantity tracks the account's workspace count. Trial days,
* optional first-month coupon, and promotion codes come from cashier /
* trypost billing env config via ConfigureSubscriptionCheckout.
*/
public function redirect(Account $account, string $priceId, string $cancelUrl): Response
{
@ -27,7 +27,7 @@ public function redirect(Account $account, string $priceId, string $cancelUrl):
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
->quantity(max(1, $account->workspaces()->count()));
FirstMonthCheckoutDiscount::apply($subscription, $account);
ConfigureSubscriptionCheckout::apply($subscription, $account);
$session = $subscription->checkout([
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',

View file

@ -86,7 +86,7 @@ public function create(Request $request): Response|RedirectResponse
* Block creating a paid additional workspace without an active subscription.
* Guards both the form (`create`) and the write (`store`) so a direct POST
* can't bootstrap a second billable workspace which would also inflate the
* checkout quantity past the fixed first-month coupon.
* checkout quantity before the first subscription exists.
*/
private function denyAdditionalWorkspaceWithoutSubscription(User $user): ?RedirectResponse
{

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Support\Billing;
use App\Models\Account;
use Laravel\Cashier\SubscriptionBuilder;
use RuntimeException;
use Stripe\Subscription as StripeSubscription;
final class ConfigureSubscriptionCheckout
{
/**
* Stripe Checkout rejects subscription trials shorter than 48 hours.
*/
public const MIN_CHECKOUT_TRIAL_DAYS = 2;
/**
* Apply env-driven checkout options to a subscription builder.
*
* Precedence when REQUIRE_CARD_FOR_TRIAL is enabled:
* 1. Qualifying first-month coupon withCoupon, no trialDays (card charge now).
* 2. Else first-time customer + CASHIER_TRIAL_DAYS > 0 trialDays (clamped to 2).
* 3. Else plain checkout (immediate full price) including re-subscribers.
*
* CASHIER_ALLOW_PROMOTION_CODES enables the Checkout promo field only when no
* coupon is applied Stripe rejects discounts + allow_promotion_codes together.
*
* @throws RuntimeException when a coupon would be applied while
* allow_promotion_codes is also enabled.
*/
public static function apply(SubscriptionBuilder $subscription, Account $account): SubscriptionBuilder
{
if (self::shouldApplyFirstMonthCoupon($account)) {
if ((bool) config('cashier.allow_promotion_codes', false)) {
throw new RuntimeException(
'Cannot apply STRIPE_FIRST_MONTH_COUPON_ID while CASHIER_ALLOW_PROMOTION_CODES is enabled: '
.'Stripe Checkout rejects discounts and allow_promotion_codes on the same session.'
);
}
return $subscription->withCoupon((string) config('cashier.first_month_coupon_id'));
}
if (
(bool) config('trypost.billing.require_card_for_trial', true)
&& self::isFirstTimeSubscriber($account)
) {
$trialDays = (int) config('cashier.trial_days');
if ($trialDays > 0) {
$subscription->trialDays(max(self::MIN_CHECKOUT_TRIAL_DAYS, $trialDays));
}
}
if ((bool) config('cashier.allow_promotion_codes', false)) {
$subscription->allowPromotionCodes();
}
return $subscription;
}
/**
* Fixed amount_off first-month coupons only fit a new customer checking out a
* single workspace. A subscription that never left incomplete never became
* real, so a retry after a failed first attempt still qualifies; any started
* subscription (even canceled) does not.
*/
private static function shouldApplyFirstMonthCoupon(Account $account): bool
{
if (! (bool) config('trypost.billing.require_card_for_trial', true)) {
return false;
}
$couponId = config('cashier.first_month_coupon_id');
if (! is_string($couponId) || $couponId === '') {
return false;
}
return $account->workspaces()->count() === 1
&& self::isFirstTimeSubscriber($account);
}
/**
* True when the account has never had a real Stripe subscription. Rows that
* stayed in incomplete / incomplete_expired never became billable, so a
* retry after a failed first Checkout still counts as first-time.
*/
private static function isFirstTimeSubscriber(Account $account): bool
{
return ! $account->subscriptions()
->whereNotIn('stripe_status', [
StripeSubscription::STATUS_INCOMPLETE,
StripeSubscription::STATUS_INCOMPLETE_EXPIRED,
])
->exists();
}
}

View file

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Billing;
use App\Models\Account;
use Laravel\Cashier\SubscriptionBuilder;
use RuntimeException;
use Stripe\Subscription as StripeSubscription;
final class FirstMonthCheckoutDiscount
{
/**
* Configure a subscription checkout to charge $1 for the first invoice via
* a `duration: once` Stripe coupon, so a real charge validates the card
* instead of a $0 trial authorization. Stripe rejects a Checkout Session
* that sets both `discounts` and `allow_promotion_codes`, so checkouts that
* don't qualify for the paid first month keep the promotion-code field.
*
* @throws RuntimeException when a qualifying checkout has the paid first
* month enabled but no coupon configured failing
* loudly beats silently charging full price.
*/
public static function apply(SubscriptionBuilder $subscription, Account $account): SubscriptionBuilder
{
if (! self::qualifiesForPaidFirstMonth($account)) {
return $subscription->allowPromotionCodes();
}
$couponId = config('cashier.first_month_coupon_id');
if (! is_string($couponId) || $couponId === '') {
throw new RuntimeException(
'STRIPE_FIRST_MONTH_COUPON_ID must be set when REQUIRE_CARD_FOR_TRIAL is enabled, '
.'otherwise checkout would charge the full price instead of the $1 first month.'
);
}
return $subscription->withCoupon($couponId);
}
/**
* The fixed-amount first-month coupon only applies to a genuinely new
* customer checking out a single workspace: the fixed `amount_off` is only
* correct for a quantity of one, and the $1 offer is for first-time signups
* not a returning account re-subscribing with workspaces it kept from a
* lapsed subscription. A subscription that never left `incomplete` never
* became real, so a new customer retrying after a failed first attempt
* still qualifies; any started subscription (even canceled) does not.
*/
private static function qualifiesForPaidFirstMonth(Account $account): bool
{
if (! (bool) config('trypost.billing.require_card_for_trial', true)) {
return false;
}
return $account->workspaces()->count() === 1
&& ! $account->subscriptions()
->whereNotIn('stripe_status', [
StripeSubscription::STATUS_INCOMPLETE,
StripeSubscription::STATUS_INCOMPLETE_EXPIRED,
])
->exists();
}
}

View file

@ -131,7 +131,12 @@
| Trial Period
|--------------------------------------------------------------------------
|
| The number of days for the trial period. Set to 0 to disable trials.
| Days of trial. With REQUIRE_CARD_FOR_TRIAL=true and no first-month coupon
| applied, first-time checkouts get Stripe trialDays (status trialing).
| Re-subscribers (any prior real subscription) skip trial. With
| REQUIRE_CARD_FOR_TRIAL=false, it is the generic signup trial length on
| accounts.trial_ends_at. Set to 0 to disable trials. Stripe Checkout
| rejects trials shorter than 48 hours values of 1 are clamped to 2.
|
*/
@ -142,14 +147,29 @@
| Paid First Month Coupon
|--------------------------------------------------------------------------
|
| Stripe Coupon ID applied at checkout so the first invoice comes out to
| $1 instead of the full monthly price a real charge validates the
| card up front instead of a $0 trial authorization. Must be an
| `amount_off` coupon with `duration: once`, so it discounts only the
| first invoice and the full price bills automatically afterward.
| Optional Stripe Coupon ID (amount_off, duration=once). When set and the
| account qualifies (card required, single workspace, first-time), checkout
| applies withCoupon and skips trialDays so the first invoice validates the
| card. Empty = no coupon; card-required checkouts use trial_days instead.
| Cannot be combined with allow_promotion_codes on the same checkout.
|
*/
'first_month_coupon_id' => env('STRIPE_FIRST_MONTH_COUPON_ID'),
/*
|--------------------------------------------------------------------------
| Allow Promotion Codes
|--------------------------------------------------------------------------
|
| When true and no first-month coupon is applied on the session, Stripe
| Checkout shows the redeemable promotion-code field. Defaults to false
| (SaaS recipe A). Stripe rejects a session that sets both discounts
| (coupon) and allow_promotion_codes ConfigureSubscriptionCheckout
| throws if both would apply on the same checkout.
|
*/
'allow_promotion_codes' => (bool) env('CASHIER_ALLOW_PROMOTION_CODES', false),
];

View file

@ -37,9 +37,10 @@
| Billing
|--------------------------------------------------------------------------
|
| Control trial behavior for SaaS billing:
| - true: require card at checkout to start trial (Stripe trialing)
| - false: grant generic trial at signup without card
| Control whether signup requires a card before app access:
| - true: no generic trial at signup; access only after Stripe Checkout
| (trialDays and/or first-month coupon come from cashier.* env knobs)
| - false: grant generic trial at signup without a card
|
*/

View file

@ -8,6 +8,10 @@
expect(config('cashier.trial_days'))->toBe(8);
});
test('allow_promotion_codes defaults to false for saas recipe A', function () {
expect(config('cashier.allow_promotion_codes'))->toBeFalse();
});
test('signup grants a trial of cashier.trial_days in no-card mode', function () {
config(['trypost.billing.require_card_for_trial' => false]);

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Actions\Billing\StartSubscriptionCheckout;
use App\Models\Account;
use App\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Cashier\SubscriptionBuilder;
use Mockery\MockInterface;
use Symfony\Component\HttpFoundation\Response;
uses(RefreshDatabase::class);
test('redirect applies checkout configuration before opening the stripe session', function () {
config([
'trypost.billing.require_card_for_trial' => true,
'cashier.trial_days' => 8,
'cashier.first_month_coupon_id' => '',
'cashier.allow_promotion_codes' => false,
]);
$account = Account::factory()->create();
Workspace::factory()->create(['account_id' => $account->id]);
$cancelUrl = route('app.welcome');
$builder = Mockery::mock(SubscriptionBuilder::class);
$builder->shouldReceive('quantity')->once()->with(1)->andReturnSelf();
$builder->shouldReceive('trialDays')->once()->with(8)->andReturnSelf();
$builder->shouldReceive('withCoupon')->never();
$builder->shouldReceive('allowPromotionCodes')->never();
$builder->shouldReceive('checkout')
->once()
->with([
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => $cancelUrl,
])
->andReturn((object) ['url' => 'https://checkout.stripe.test/session']);
/** @var Account&MockInterface $accountMock */
$accountMock = Mockery::mock($account)->makePartial();
$accountMock->shouldReceive('createOrGetStripeCustomer')->once()->andReturnNull();
$accountMock->shouldReceive('newSubscription')
->once()
->with(Account::SUBSCRIPTION_NAME, 'price_monthly_test')
->andReturn($builder);
$response = app(StartSubscriptionCheckout::class)->redirect(
$accountMock,
'price_monthly_test',
$cancelUrl,
);
expect($response->headers->get('Location'))->toBe('https://checkout.stripe.test/session')
->and($response->getStatusCode())->toBe(Response::HTTP_FOUND);
});

View file

@ -0,0 +1,295 @@
<?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');
});

View file

@ -1,106 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Account;
use App\Models\Workspace;
use App\Support\Billing\FirstMonthCheckoutDiscount;
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.first_month_coupon_id' => 'TRIAL1USD',
]);
});
function firstMonthSubscription(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',
]);
}
test('applies the first month coupon for a first-time single-workspace checkout', function () {
Workspace::factory()->create(['account_id' => $this->account->id]);
$subscription = firstMonthSubscription($this->account);
FirstMonthCheckoutDiscount::apply($subscription, $this->account);
expect($subscription->couponId)->toBe('TRIAL1USD')
->and($subscription->promotionCodeId)->toBeNull()
->and($subscription->allowPromotionCodes)->toBeFalse();
});
test('skips the coupon and allows promotion codes when a card is not required', function () {
config(['trypost.billing.require_card_for_trial' => false]);
Workspace::factory()->create(['account_id' => $this->account->id]);
$subscription = firstMonthSubscription($this->account);
FirstMonthCheckoutDiscount::apply($subscription, $this->account);
expect($subscription->allowPromotionCodes)->toBeTrue()
->and($subscription->couponId)->toBeNull();
});
test('skips the coupon when more than one workspace is billed', function () {
Workspace::factory()->count(2)->create(['account_id' => $this->account->id]);
$subscription = firstMonthSubscription($this->account);
FirstMonthCheckoutDiscount::apply($subscription, $this->account);
expect($subscription->allowPromotionCodes)->toBeTrue()
->and($subscription->couponId)->toBeNull();
});
test('skips the coupon when the account has a prior canceled subscription', function () {
Workspace::factory()->create(['account_id' => $this->account->id]);
givePriorSubscription($this->account);
$subscription = firstMonthSubscription($this->account);
FirstMonthCheckoutDiscount::apply($subscription, $this->account);
expect($subscription->allowPromotionCodes)->toBeTrue()
->and($subscription->couponId)->toBeNull();
});
test('still applies the coupon when the only prior subscription never left incomplete', function () {
Workspace::factory()->create(['account_id' => $this->account->id]);
givePriorSubscription($this->account, 'incomplete_expired');
$subscription = firstMonthSubscription($this->account);
FirstMonthCheckoutDiscount::apply($subscription, $this->account);
expect($subscription->couponId)->toBe('TRIAL1USD')
->and($subscription->allowPromotionCodes)->toBeFalse();
});
test('throws instead of charging full price when a qualifying checkout has no coupon', function () {
config(['cashier.first_month_coupon_id' => '']);
Workspace::factory()->create(['account_id' => $this->account->id]);
$subscription = firstMonthSubscription($this->account);
expect(fn () => FirstMonthCheckoutDiscount::apply($subscription, $this->account))
->toThrow(RuntimeException::class);
expect($subscription->couponId)->toBeNull();
});