Limit the $1 first month to a new customer's single workspace
The fixed amount_off coupon only nets $1 for a quantity of one, and the offer is meant for genuinely new signups. A lapsed account that kept several workspaces and re-subscribes through onboarding would otherwise be charged N*price - amount_off (not $1), and a returning customer could whittle down to one workspace to claim the discount again. Apply the coupon only when the paid first month is enabled, the account bills a single workspace, and it has never subscribed before; every other checkout falls back to allowing promotion codes at the full price.
This commit is contained in:
parent
f36881cb6a
commit
3dd4621f57
3 changed files with 81 additions and 22 deletions
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue