Ignore never-started subscriptions when detecting a first-time customer

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.
This commit is contained in:
Paulo Castellano 2026-07-09 16:03:25 -03:00
parent 3dd4621f57
commit 2c1cd4ec61
2 changed files with 25 additions and 5 deletions

View file

@ -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();
}
}

View file

@ -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]);