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:
Paulo Castellano 2026-07-09 16:07:49 -03:00 committed by GitHub
commit eee0a4a499
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 242 additions and 15 deletions

View file

@ -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=

View file

@ -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}',

View file

@ -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);

View 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();
}
}

View file

@ -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'),
];

View file

@ -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]);

View 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();
});