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.
This commit is contained in:
parent
d6c40d425c
commit
c940826031
5 changed files with 90 additions and 8 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
26
app/Support/Billing/FirstMonthCheckoutDiscount.php
Normal file
26
app/Support/Billing/FirstMonthCheckoutDiscount.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Billing;
|
||||
|
||||
use Laravel\Cashier\SubscriptionBuilder;
|
||||
|
||||
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.
|
||||
*/
|
||||
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'));
|
||||
}
|
||||
|
||||
return $subscription->allowPromotionCodes();
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Support\Billing\FirstMonthCheckoutDiscount;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
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');
|
||||
|
||||
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();
|
||||
});
|
||||
Loading…
Reference in a new issue