From 2c1cd4ec610095280a9ad579ef3671d0e0f94068 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 9 Jul 2026 16:03:25 -0300 Subject: [PATCH] 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]);