trypost/app/Support/Billing/ConfigureSubscriptionCheckout.php
Paulo Castellano 4590fc5fd1
Make Stripe Checkout configurable via billing env knobs (#252)
* Make Stripe Checkout configurable via billing env knobs

Replace the hard-required $1 first-month coupon with env-driven trial days,
optional coupon, and allow_promotion_codes so SaaS can switch checkout modes
without a code change.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix no-effect ReflectionClass import in checkout test

CI treats bare global use statements as ErrorException and aborts
loading the suite before any assertions run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Document checkout env knobs in AGENTS.md instead of .ai/

Remove the Boost record-rule .ai/rules folder and keep durable billing
checkout guidance in AGENTS.md / CLAUDE.md project-specific rules.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden checkout env knobs from review findings

Default allow_promotion_codes to false, grant Stripe trial only to
first-time subscribers, clarify the coupon/promo XOR error, and cover
negative XOR cases plus StartSubscriptionCheckout wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 21:25:32 -03:00

100 lines
3.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Billing;
use App\Models\Account;
use Laravel\Cashier\SubscriptionBuilder;
use RuntimeException;
use Stripe\Subscription as StripeSubscription;
final class ConfigureSubscriptionCheckout
{
/**
* Stripe Checkout rejects subscription trials shorter than 48 hours.
*/
public const MIN_CHECKOUT_TRIAL_DAYS = 2;
/**
* Apply env-driven checkout options to a subscription builder.
*
* Precedence when REQUIRE_CARD_FOR_TRIAL is enabled:
* 1. Qualifying first-month coupon → withCoupon, no trialDays (card charge now).
* 2. Else first-time customer + CASHIER_TRIAL_DAYS > 0 → trialDays (clamped to ≥ 2).
* 3. Else plain checkout (immediate full price) — including re-subscribers.
*
* CASHIER_ALLOW_PROMOTION_CODES enables the Checkout promo field only when no
* coupon is applied — Stripe rejects discounts + allow_promotion_codes together.
*
* @throws RuntimeException when a coupon would be applied while
* allow_promotion_codes is also enabled.
*/
public static function apply(SubscriptionBuilder $subscription, Account $account): SubscriptionBuilder
{
if (self::shouldApplyFirstMonthCoupon($account)) {
if ((bool) config('cashier.allow_promotion_codes', false)) {
throw new RuntimeException(
'Cannot apply STRIPE_FIRST_MONTH_COUPON_ID while CASHIER_ALLOW_PROMOTION_CODES is enabled: '
.'Stripe Checkout rejects discounts and allow_promotion_codes on the same session.'
);
}
return $subscription->withCoupon((string) config('cashier.first_month_coupon_id'));
}
if (
(bool) config('trypost.billing.require_card_for_trial', true)
&& self::isFirstTimeSubscriber($account)
) {
$trialDays = (int) config('cashier.trial_days');
if ($trialDays > 0) {
$subscription->trialDays(max(self::MIN_CHECKOUT_TRIAL_DAYS, $trialDays));
}
}
if ((bool) config('cashier.allow_promotion_codes', false)) {
$subscription->allowPromotionCodes();
}
return $subscription;
}
/**
* Fixed amount_off first-month coupons only fit a new customer checking out a
* single workspace. A subscription that never left incomplete never became
* real, so a retry after a failed first attempt still qualifies; any started
* subscription (even canceled) does not.
*/
private static function shouldApplyFirstMonthCoupon(Account $account): bool
{
if (! (bool) config('trypost.billing.require_card_for_trial', true)) {
return false;
}
$couponId = config('cashier.first_month_coupon_id');
if (! is_string($couponId) || $couponId === '') {
return false;
}
return $account->workspaces()->count() === 1
&& self::isFirstTimeSubscriber($account);
}
/**
* True when the account has never had a real Stripe subscription. Rows that
* stayed in incomplete / incomplete_expired never became billable, so a
* retry after a failed first Checkout still counts as first-time.
*/
private static function isFirstTimeSubscriber(Account $account): bool
{
return ! $account->subscriptions()
->whereNotIn('stripe_status', [
StripeSubscription::STATUS_INCOMPLETE,
StripeSubscription::STATUS_INCOMPLETE_EXPIRED,
])
->exists();
}
}