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:
parent
a1fa897106
commit
4590fc5fd1
13 changed files with 543 additions and 191 deletions
17
.env.example
17
.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=
|
||||
|
|
|
|||
20
AGENTS.md
20
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
18
CLAUDE.md
18
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`.
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
100
app/Support/Billing/ConfigureSubscriptionCheckout.php
Normal file
100
app/Support/Billing/ConfigureSubscriptionCheckout.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
57
tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php
Normal file
57
tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php
Normal 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);
|
||||
});
|
||||
295
tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php
Normal file
295
tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in a new issue