Merge pull request #176 from trypostit/feat/paid-first-month-checkout
Charge $1 for the first month instead of a free trial
This commit is contained in:
commit
eee0a4a499
7 changed files with 242 additions and 15 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, $account);
|
||||
|
||||
$session = $subscription->checkout([
|
||||
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
|
||||
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
|
||||
use App\Http\Resources\App\WorkspaceMemberResource;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Brand\LogoAttacher;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
@ -66,13 +67,8 @@ public function index(Request $request): Response
|
|||
|
||||
public function create(Request $request): Response|RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! config('trypost.self_hosted')
|
||||
&& $user->ownedWorkspacesCount() > 0
|
||||
&& ! $user->account?->hasActiveSubscription()) {
|
||||
return redirect()->route('app.billing.index')
|
||||
->with('message', 'Subscribe to create more workspaces.');
|
||||
if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($request->user())) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
return Inertia::render('workspaces/Create', [
|
||||
|
|
@ -83,6 +79,24 @@ public function create(Request $request): Response|RedirectResponse
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Block creating a paid additional workspace without an active subscription.
|
||||
* Guards both the form (`create`) and the write (`store`) so a direct POST
|
||||
* can't bootstrap a second billable workspace — which would also inflate the
|
||||
* checkout quantity past the fixed first-month coupon.
|
||||
*/
|
||||
private function denyAdditionalWorkspaceWithoutSubscription(User $user): ?RedirectResponse
|
||||
{
|
||||
if (! config('trypost.self_hosted')
|
||||
&& $user->ownedWorkspacesCount() > 0
|
||||
&& ! $user->account?->hasActiveSubscription()) {
|
||||
return redirect()->route('app.billing.index')
|
||||
->with('message', 'Subscribe to create more workspaces.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function autofillBrand(AutofillBrandRequest $request, AutofillBrand $autofill): JsonResponse
|
||||
{
|
||||
try {
|
||||
|
|
@ -98,6 +112,10 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher
|
|||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($user)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
$workspace = CreateWorkspace::execute($user, $validated);
|
||||
|
|
|
|||
66
app/Support/Billing/FirstMonthCheckoutDiscount.php
Normal file
66
app/Support/Billing/FirstMonthCheckoutDiscount.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?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 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 checkouts that
|
||||
* don't qualify for the paid first month keep the promotion-code field.
|
||||
*
|
||||
* @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, Account $account): SubscriptionBuilder
|
||||
{
|
||||
if (! self::qualifiesForPaidFirstMonth($account)) {
|
||||
return $subscription->allowPromotionCodes();
|
||||
}
|
||||
|
||||
$couponId = config('cashier.first_month_coupon_id');
|
||||
|
||||
if (! is_string($couponId) || $couponId === '') {
|
||||
throw new RuntimeException(
|
||||
'STRIPE_FIRST_MONTH_COUPON_ID must be set when REQUIRE_CARD_FOR_TRIAL is enabled, '
|
||||
.'otherwise checkout would charge the full price instead of the $1 first month.'
|
||||
);
|
||||
}
|
||||
|
||||
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. A subscription that never left `incomplete` never
|
||||
* became real, so a new customer retrying after a failed first attempt
|
||||
* still qualifies; any started subscription (even canceled) does not.
|
||||
*/
|
||||
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()
|
||||
->whereNotIn('stripe_status', [
|
||||
StripeSubscription::STATUS_INCOMPLETE,
|
||||
StripeSubscription::STATUS_INCOMPLETE_EXPIRED,
|
||||
])
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -677,6 +677,26 @@
|
|||
expect(Workspace::where('account_id', $account->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('store blocks a second workspace without an active subscription', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$account = Account::factory()->create();
|
||||
$user = User::factory()->create(['account_id' => $account->id]);
|
||||
$account->update(['owner_id' => $user->id]);
|
||||
|
||||
// The account already owns one workspace and has no subscription, so a
|
||||
// direct POST must not bootstrap a second (billable) workspace.
|
||||
$existing = Workspace::factory()->create(['account_id' => $account->id, 'user_id' => $user->id]);
|
||||
$existing->members()->attach($user->id, ['role' => Role::Member->value]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('app.workspaces.store'), [
|
||||
'name' => 'Second Workspace',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('app.billing.index'));
|
||||
expect(Workspace::where('account_id', $account->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('store attaches logo when logo_url is provided', function () {
|
||||
$account = Account::factory()->create();
|
||||
$user = User::factory()->create(['account_id' => $account->id]);
|
||||
|
|
|
|||
106
tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php
Normal file
106
tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
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();
|
||||
|
||||
config([
|
||||
'trypost.billing.require_card_for_trial' => true,
|
||||
'cashier.first_month_coupon_id' => 'TRIAL1USD',
|
||||
]);
|
||||
});
|
||||
|
||||
function firstMonthSubscription(Account $account): SubscriptionBuilder
|
||||
{
|
||||
return $account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test');
|
||||
}
|
||||
|
||||
function givePriorSubscription(Account $account, string $status = 'canceled'): void
|
||||
{
|
||||
$account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_'.fake()->uuid(),
|
||||
'stripe_status' => $status,
|
||||
'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('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 = firstMonthSubscription($this->account);
|
||||
|
||||
FirstMonthCheckoutDiscount::apply($subscription, $this->account);
|
||||
|
||||
expect($subscription->allowPromotionCodes)->toBeTrue()
|
||||
->and($subscription->couponId)->toBeNull();
|
||||
});
|
||||
|
||||
test('skips the coupon when more than one workspace is billed', function () {
|
||||
Workspace::factory()->count(2)->create(['account_id' => $this->account->id]);
|
||||
|
||||
$subscription = firstMonthSubscription($this->account);
|
||||
|
||||
FirstMonthCheckoutDiscount::apply($subscription, $this->account);
|
||||
|
||||
expect($subscription->allowPromotionCodes)->toBeTrue()
|
||||
->and($subscription->couponId)->toBeNull();
|
||||
});
|
||||
|
||||
test('skips the coupon when the account has a prior canceled subscription', 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('still applies the coupon when the only prior subscription never left incomplete', function () {
|
||||
Workspace::factory()->create(['account_id' => $this->account->id]);
|
||||
givePriorSubscription($this->account, 'incomplete_expired');
|
||||
|
||||
$subscription = firstMonthSubscription($this->account);
|
||||
|
||||
FirstMonthCheckoutDiscount::apply($subscription, $this->account);
|
||||
|
||||
expect($subscription->couponId)->toBe('TRIAL1USD')
|
||||
->and($subscription->allowPromotionCodes)->toBeFalse();
|
||||
});
|
||||
|
||||
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