From c940826031b8842cb0dfbce2e21b8696c6ff4397 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 9 Jul 2026 15:18:00 -0300 Subject: [PATCH 1/4] Charge $1 for the first month instead of a free trial at checkout A $0 trial only runs a weak card authorization, so invalid cards slip through and only fail once the real charge fires days later. Applying a $11-off, duration=once Stripe coupon at checkout charges $1 for real on the first invoice instead, which validates the card immediately, then reverts to the full monthly price on the next invoice with no manual swap needed. --- .env.example | 5 ++- .../Billing/StartSubscriptionCheckout.php | 13 +++---- .../Billing/FirstMonthCheckoutDiscount.php | 26 +++++++++++++ config/cashier.php | 15 +++++++ .../FirstMonthCheckoutDiscountTest.php | 39 +++++++++++++++++++ 5 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 app/Support/Billing/FirstMonthCheckoutDiscount.php create mode 100644 tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php diff --git a/.env.example b/.env.example index 8104df93..4a631e7e 100644 --- a/.env.example +++ b/.env.example @@ -191,8 +191,11 @@ STRIPE_SECRET= STRIPE_WEBHOOK_SECRET= # Set to false to allow generic signup trial without requiring a card. REQUIRE_CARD_FOR_TRIAL=true -# Free trial length in days (Stripe Checkout shows one day fewer). +# Free trial length in days, used only for the no-card generic trial above. 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. +STRIPE_FIRST_MONTH_COUPON_ID= # Stripe Plan Price IDs (one per plan × interval). Used by PlanSeeder. STRIPE_WORKSPACE_MONTHLY= diff --git a/app/Actions/Billing/StartSubscriptionCheckout.php b/app/Actions/Billing/StartSubscriptionCheckout.php index 1515ce11..6a6798c7 100644 --- a/app/Actions/Billing/StartSubscriptionCheckout.php +++ b/app/Actions/Billing/StartSubscriptionCheckout.php @@ -5,6 +5,7 @@ namespace App\Actions\Billing; use App\Models\Account; +use App\Support\Billing\FirstMonthCheckoutDiscount; use Inertia\Inertia; use Symfony\Component\HttpFoundation\Response; @@ -12,8 +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; a trial is - * attached when the instance requires a card up front. + * 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. */ public function redirect(Account $account, string $priceId, string $cancelUrl): Response { @@ -23,12 +25,9 @@ public function redirect(Account $account, string $priceId, string $cancelUrl): ]); $subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId) - ->quantity(max(1, $account->workspaces()->count())) - ->allowPromotionCodes(); + ->quantity(max(1, $account->workspaces()->count())); - if ((bool) config('trypost.billing.require_card_for_trial', true)) { - $subscription->trialDays(config('cashier.trial_days')); - } + FirstMonthCheckoutDiscount::apply($subscription); $session = $subscription->checkout([ 'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}', diff --git a/app/Support/Billing/FirstMonthCheckoutDiscount.php b/app/Support/Billing/FirstMonthCheckoutDiscount.php new file mode 100644 index 00000000..9f69ff85 --- /dev/null +++ b/app/Support/Billing/FirstMonthCheckoutDiscount.php @@ -0,0 +1,26 @@ +withCoupon(config('cashier.first_month_coupon_id')); + } + + return $subscription->allowPromotionCodes(); + } +} diff --git a/config/cashier.php b/config/cashier.php index 44c30230..523c76b0 100644 --- a/config/cashier.php +++ b/config/cashier.php @@ -137,4 +137,19 @@ 'trial_days' => env('CASHIER_TRIAL_DAYS', 8), + /* + |-------------------------------------------------------------------------- + | 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. + | + */ + + 'first_month_coupon_id' => env('STRIPE_FIRST_MONTH_COUPON_ID'), + ]; diff --git a/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php b/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php new file mode 100644 index 00000000..b1dc7fd6 --- /dev/null +++ b/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php @@ -0,0 +1,39 @@ +account = Account::factory()->create(); +}); + +test('applies the first month coupon when a card is required for trial', function () { + config([ + 'trypost.billing.require_card_for_trial' => true, + 'cashier.first_month_coupon_id' => 'TRIAL1USD', + ]); + + $subscription = $this->account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test'); + + FirstMonthCheckoutDiscount::apply($subscription); + + expect($subscription->couponId)->toBe('TRIAL1USD') + ->and($subscription->promotionCodeId)->toBeNull() + ->and($subscription->allowPromotionCodes)->toBeFalse(); +}); + +test('allows promotion codes instead of a coupon when a card is not required for trial', function () { + config(['trypost.billing.require_card_for_trial' => false]); + + $subscription = $this->account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test'); + + FirstMonthCheckoutDiscount::apply($subscription); + + expect($subscription->allowPromotionCodes)->toBeTrue() + ->and($subscription->couponId)->toBeNull(); +}); From f36881cb6a02456f50f1802b8952a18b472b8d7d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 9 Jul 2026 15:37:25 -0300 Subject: [PATCH 2/4] Fail loud on a missing coupon and block unpaid extra workspaces Two hardening fixes for the paid first month: - FirstMonthCheckoutDiscount throws when the paid first month is enabled but STRIPE_FIRST_MONTH_COUPON_ID is unset, instead of silently charging every new customer the full price with no discount. - Guard workspace store() with the same active-subscription check create() already applies, so a direct POST can't bootstrap a second billable workspace and inflate checkout quantity past the fixed first-month coupon. --- .../Controllers/App/WorkspaceController.php | 32 +++++++++++++++---- .../Billing/FirstMonthCheckoutDiscount.php | 20 ++++++++++-- tests/Feature/WorkspaceControllerTest.php | 20 ++++++++++++ .../FirstMonthCheckoutDiscountTest.php | 14 ++++++++ 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/App/WorkspaceController.php b/app/Http/Controllers/App/WorkspaceController.php index d9d394ff..5b2326f2 100644 --- a/app/Http/Controllers/App/WorkspaceController.php +++ b/app/Http/Controllers/App/WorkspaceController.php @@ -15,6 +15,7 @@ use App\Http\Requests\App\Workspace\StoreWorkspaceRequest; use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest; use App\Http\Resources\App\WorkspaceMemberResource; +use App\Models\User; use App\Models\Workspace; use App\Services\Brand\LogoAttacher; use Illuminate\Http\JsonResponse; @@ -66,13 +67,8 @@ public function index(Request $request): Response public function create(Request $request): Response|RedirectResponse { - $user = $request->user(); - - if (! config('trypost.self_hosted') - && $user->ownedWorkspacesCount() > 0 - && ! $user->account?->hasActiveSubscription()) { - return redirect()->route('app.billing.index') - ->with('message', 'Subscribe to create more workspaces.'); + if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($request->user())) { + return $redirect; } return Inertia::render('workspaces/Create', [ @@ -83,6 +79,24 @@ 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. + */ + private function denyAdditionalWorkspaceWithoutSubscription(User $user): ?RedirectResponse + { + if (! config('trypost.self_hosted') + && $user->ownedWorkspacesCount() > 0 + && ! $user->account?->hasActiveSubscription()) { + return redirect()->route('app.billing.index') + ->with('message', 'Subscribe to create more workspaces.'); + } + + return null; + } + public function autofillBrand(AutofillBrandRequest $request, AutofillBrand $autofill): JsonResponse { try { @@ -98,6 +112,10 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher { $user = $request->user(); + if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($user)) { + return $redirect; + } + $validated = $request->validated(); $workspace = CreateWorkspace::execute($user, $validated); diff --git a/app/Support/Billing/FirstMonthCheckoutDiscount.php b/app/Support/Billing/FirstMonthCheckoutDiscount.php index 9f69ff85..064a27dd 100644 --- a/app/Support/Billing/FirstMonthCheckoutDiscount.php +++ b/app/Support/Billing/FirstMonthCheckoutDiscount.php @@ -5,6 +5,7 @@ namespace App\Support\Billing; use Laravel\Cashier\SubscriptionBuilder; +use RuntimeException; final class FirstMonthCheckoutDiscount { @@ -14,13 +15,26 @@ final class FirstMonthCheckoutDiscount * instead of a $0 trial authorization. Stripe rejects a Checkout Session * that sets both `discounts` and `allow_promotion_codes`, so accounts that * skip the paid first month keep the promotion-code field instead. + * + * @throws RuntimeException when the paid first month is enabled but no + * coupon is configured — failing loudly beats + * silently charging every new customer full price. */ public static function apply(SubscriptionBuilder $subscription): SubscriptionBuilder { - if ((bool) config('trypost.billing.require_card_for_trial', true)) { - return $subscription->withCoupon(config('cashier.first_month_coupon_id')); + if (! (bool) config('trypost.billing.require_card_for_trial', true)) { + return $subscription->allowPromotionCodes(); } - 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); } } diff --git a/tests/Feature/WorkspaceControllerTest.php b/tests/Feature/WorkspaceControllerTest.php index 796bef21..e1354dc5 100644 --- a/tests/Feature/WorkspaceControllerTest.php +++ b/tests/Feature/WorkspaceControllerTest.php @@ -677,6 +677,26 @@ expect(Workspace::where('account_id', $account->id)->count())->toBe(2); }); +test('store blocks a second workspace without an active subscription', function () { + config(['trypost.self_hosted' => false]); + + $account = Account::factory()->create(); + $user = User::factory()->create(['account_id' => $account->id]); + $account->update(['owner_id' => $user->id]); + + // The account already owns one workspace and has no subscription, so a + // direct POST must not bootstrap a second (billable) workspace. + $existing = Workspace::factory()->create(['account_id' => $account->id, 'user_id' => $user->id]); + $existing->members()->attach($user->id, ['role' => Role::Member->value]); + + $response = $this->actingAs($user)->post(route('app.workspaces.store'), [ + 'name' => 'Second Workspace', + ]); + + $response->assertRedirect(route('app.billing.index')); + expect(Workspace::where('account_id', $account->id)->count())->toBe(1); +}); + test('store attaches logo when logo_url is provided', function () { $account = Account::factory()->create(); $user = User::factory()->create(['account_id' => $account->id]); diff --git a/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php b/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php index b1dc7fd6..21738c2c 100644 --- a/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php +++ b/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php @@ -37,3 +37,17 @@ expect($subscription->allowPromotionCodes)->toBeTrue() ->and($subscription->couponId)->toBeNull(); }); + +test('throws instead of charging full price when the coupon is missing but a card is required', function () { + config([ + 'trypost.billing.require_card_for_trial' => true, + 'cashier.first_month_coupon_id' => '', + ]); + + $subscription = $this->account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test'); + + expect(fn () => FirstMonthCheckoutDiscount::apply($subscription)) + ->toThrow(RuntimeException::class); + + expect($subscription->couponId)->toBeNull(); +}); From 3dd4621f5771e21ba714cf36b433fb225ebd9b38 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 9 Jul 2026 15:51:50 -0300 Subject: [PATCH 3/4] Limit the $1 first month to a new customer's single workspace The fixed amount_off coupon only nets $1 for a quantity of one, and the offer is meant for genuinely new signups. A lapsed account that kept several workspaces and re-subscribes through onboarding would otherwise be charged N*price - amount_off (not $1), and a returning customer could whittle down to one workspace to claim the discount again. Apply the coupon only when the paid first month is enabled, the account bills a single workspace, and it has never subscribed before; every other checkout falls back to allowing promotion codes at the full price. --- .../Billing/StartSubscriptionCheckout.php | 2 +- .../Billing/FirstMonthCheckoutDiscount.php | 32 +++++++-- .../FirstMonthCheckoutDiscountTest.php | 69 +++++++++++++++---- 3 files changed, 81 insertions(+), 22 deletions(-) diff --git a/app/Actions/Billing/StartSubscriptionCheckout.php b/app/Actions/Billing/StartSubscriptionCheckout.php index 6a6798c7..949defc1 100644 --- a/app/Actions/Billing/StartSubscriptionCheckout.php +++ b/app/Actions/Billing/StartSubscriptionCheckout.php @@ -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); + FirstMonthCheckoutDiscount::apply($subscription, $account); $session = $subscription->checkout([ 'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}', diff --git a/app/Support/Billing/FirstMonthCheckoutDiscount.php b/app/Support/Billing/FirstMonthCheckoutDiscount.php index 064a27dd..aec286b7 100644 --- a/app/Support/Billing/FirstMonthCheckoutDiscount.php +++ b/app/Support/Billing/FirstMonthCheckoutDiscount.php @@ -4,6 +4,7 @@ namespace App\Support\Billing; +use App\Models\Account; use Laravel\Cashier\SubscriptionBuilder; use RuntimeException; @@ -13,16 +14,16 @@ 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 accounts that - * skip the paid first month keep the promotion-code field instead. + * 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 the paid first month is enabled but no - * coupon is configured — failing loudly beats - * silently charging every new customer full price. + * @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): SubscriptionBuilder + public static function apply(SubscriptionBuilder $subscription, Account $account): SubscriptionBuilder { - if (! (bool) config('trypost.billing.require_card_for_trial', true)) { + if (! self::qualifiesForPaidFirstMonth($account)) { return $subscription->allowPromotionCodes(); } @@ -37,4 +38,21 @@ public static function apply(SubscriptionBuilder $subscription): SubscriptionBui 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. + */ + 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()->exists(); + } } diff --git a/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php b/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php index 21738c2c..c7f3cd34 100644 --- a/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php +++ b/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php @@ -3,50 +3,91 @@ 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(); -}); -test('applies the first month coupon when a card is required for trial', function () { 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): void +{ + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_'.fake()->uuid(), + 'stripe_status' => 'canceled', + '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 = $this->account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test'); + $subscription = firstMonthSubscription($this->account); - FirstMonthCheckoutDiscount::apply($subscription); + FirstMonthCheckoutDiscount::apply($subscription, $this->account); expect($subscription->couponId)->toBe('TRIAL1USD') ->and($subscription->promotionCodeId)->toBeNull() ->and($subscription->allowPromotionCodes)->toBeFalse(); }); -test('allows promotion codes instead of a coupon when a card is not required for trial', function () { +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 = $this->account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test'); + $subscription = firstMonthSubscription($this->account); - FirstMonthCheckoutDiscount::apply($subscription); + FirstMonthCheckoutDiscount::apply($subscription, $this->account); expect($subscription->allowPromotionCodes)->toBeTrue() ->and($subscription->couponId)->toBeNull(); }); -test('throws instead of charging full price when the coupon is missing but a card is required', function () { - config([ - 'trypost.billing.require_card_for_trial' => true, - 'cashier.first_month_coupon_id' => '', - ]); +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 subscribed before', 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('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 = $this->account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test'); + $subscription = firstMonthSubscription($this->account); - expect(fn () => FirstMonthCheckoutDiscount::apply($subscription)) + expect(fn () => FirstMonthCheckoutDiscount::apply($subscription, $this->account)) ->toThrow(RuntimeException::class); expect($subscription->couponId)->toBeNull(); From 2c1cd4ec610095280a9ad579ef3671d0e0f94068 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 9 Jul 2026 16:03:25 -0300 Subject: [PATCH 4/4] Ignore never-started subscriptions when detecting a first-time customer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A raw subscriptions()->exists() check treated a leftover incomplete / incomplete_expired row — which Cashier persists when a first payment fails or a 3DS challenge is abandoned — as a prior subscription, so a genuinely new customer retrying after a failed first attempt lost the $1 coupon and was charged full price. Exclude those never-started statuses so only a subscription that actually started (active, canceled, etc.) marks the account as a returning customer. --- .../Billing/FirstMonthCheckoutDiscount.php | 12 ++++++++++-- .../Billing/FirstMonthCheckoutDiscountTest.php | 18 +++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/app/Support/Billing/FirstMonthCheckoutDiscount.php b/app/Support/Billing/FirstMonthCheckoutDiscount.php index aec286b7..2fba2131 100644 --- a/app/Support/Billing/FirstMonthCheckoutDiscount.php +++ b/app/Support/Billing/FirstMonthCheckoutDiscount.php @@ -7,6 +7,7 @@ use App\Models\Account; use Laravel\Cashier\SubscriptionBuilder; use RuntimeException; +use Stripe\Subscription as StripeSubscription; final class FirstMonthCheckoutDiscount { @@ -44,7 +45,9 @@ public static function apply(SubscriptionBuilder $subscription, Account $account * 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. + * 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 { @@ -53,6 +56,11 @@ private static function qualifiesForPaidFirstMonth(Account $account): bool } return $account->workspaces()->count() === 1 - && ! $account->subscriptions()->exists(); + && ! $account->subscriptions() + ->whereNotIn('stripe_status', [ + StripeSubscription::STATUS_INCOMPLETE, + StripeSubscription::STATUS_INCOMPLETE_EXPIRED, + ]) + ->exists(); } } diff --git a/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php b/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php index c7f3cd34..86d048ce 100644 --- a/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php +++ b/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php @@ -24,12 +24,12 @@ function firstMonthSubscription(Account $account): SubscriptionBuilder return $account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test'); } -function givePriorSubscription(Account $account): void +function givePriorSubscription(Account $account, string $status = 'canceled'): void { $account->subscriptions()->create([ 'type' => Account::SUBSCRIPTION_NAME, 'stripe_id' => 'sub_'.fake()->uuid(), - 'stripe_status' => 'canceled', + 'stripe_status' => $status, 'stripe_price' => 'price_monthly_test', ]); } @@ -69,7 +69,7 @@ function givePriorSubscription(Account $account): void ->and($subscription->couponId)->toBeNull(); }); -test('skips the coupon when the account has subscribed before', function () { +test('skips the coupon when the account has a prior canceled subscription', function () { Workspace::factory()->create(['account_id' => $this->account->id]); givePriorSubscription($this->account); @@ -81,6 +81,18 @@ function givePriorSubscription(Account $account): void ->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]);