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', ]); +}); - $subscription = $this->account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test'); +function firstMonthSubscription(Account $account): SubscriptionBuilder +{ + return $account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test'); +} - FirstMonthCheckoutDiscount::apply($subscription); +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 = firstMonthSubscription($this->account); + + 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 = $this->account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test'); + $subscription = firstMonthSubscription($this->account); - expect(fn () => FirstMonthCheckoutDiscount::apply($subscription)) + 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 = firstMonthSubscription($this->account); + + expect(fn () => FirstMonthCheckoutDiscount::apply($subscription, $this->account)) ->toThrow(RuntimeException::class); expect($subscription->couponId)->toBeNull();