From c940826031b8842cb0dfbce2e21b8696c6ff4397 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 9 Jul 2026 15:18:00 -0300 Subject: [PATCH] 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(); +});