diff --git a/.env.example b/.env.example index 66b17435..725a39c1 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/AGENTS.md b/AGENTS.md index db8230fd..28a6a28e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,3 +201,23 @@ # Inertia + Vue - IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns. + +# 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. diff --git a/CLAUDE.md b/CLAUDE.md index ff38c076..98fdb636 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/app/Actions/Billing/StartSubscriptionCheckout.php b/app/Actions/Billing/StartSubscriptionCheckout.php index 949defc1..7991ee9b 100644 --- a/app/Actions/Billing/StartSubscriptionCheckout.php +++ b/app/Actions/Billing/StartSubscriptionCheckout.php @@ -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}', diff --git a/app/Http/Controllers/App/WorkspaceController.php b/app/Http/Controllers/App/WorkspaceController.php index 4e5ff212..ecc1c6e4 100644 --- a/app/Http/Controllers/App/WorkspaceController.php +++ b/app/Http/Controllers/App/WorkspaceController.php @@ -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 { diff --git a/app/Support/Billing/ConfigureSubscriptionCheckout.php b/app/Support/Billing/ConfigureSubscriptionCheckout.php new file mode 100644 index 00000000..bcec132c --- /dev/null +++ b/app/Support/Billing/ConfigureSubscriptionCheckout.php @@ -0,0 +1,100 @@ + 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(); + } +} diff --git a/app/Support/Billing/FirstMonthCheckoutDiscount.php b/app/Support/Billing/FirstMonthCheckoutDiscount.php deleted file mode 100644 index 2fba2131..00000000 --- a/app/Support/Billing/FirstMonthCheckoutDiscount.php +++ /dev/null @@ -1,66 +0,0 @@ -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(); - } -} diff --git a/config/cashier.php b/config/cashier.php index 523c76b0..8b4ed78d 100644 --- a/config/cashier.php +++ b/config/cashier.php @@ -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), + ]; diff --git a/config/trypost.php b/config/trypost.php index 08cd5054..acb68a9a 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -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 | */ diff --git a/tests/Feature/Billing/TrialLengthTest.php b/tests/Feature/Billing/TrialLengthTest.php index cbd3a0fd..7a61dc5e 100644 --- a/tests/Feature/Billing/TrialLengthTest.php +++ b/tests/Feature/Billing/TrialLengthTest.php @@ -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]); diff --git a/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php b/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php new file mode 100644 index 00000000..2deb46b7 --- /dev/null +++ b/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php @@ -0,0 +1,57 @@ + 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); +}); diff --git a/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php b/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php new file mode 100644 index 00000000..3c6b9ad6 --- /dev/null +++ b/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php @@ -0,0 +1,295 @@ +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'); +}); diff --git a/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php b/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php deleted file mode 100644 index 86d048ce..00000000 --- a/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php +++ /dev/null @@ -1,106 +0,0 @@ -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(); -});