Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
13 changes: 6 additions & 7 deletions app/Actions/Billing/StartSubscriptionCheckout.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@
namespace App\Actions\Billing;

use App\Models\Account;
use App\Support\Billing\FirstMonthCheckoutDiscount;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;

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
{
Expand All @@ -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}',
Expand Down
32 changes: 25 additions & 7 deletions app/Http/Controllers/App/WorkspaceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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', [
Expand All @@ -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 {
Expand All @@ -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);
Expand Down
66 changes: 66 additions & 0 deletions app/Support/Billing/FirstMonthCheckoutDiscount.php
Original file line number Diff line number Diff line change
@@ -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();
}
}
15 changes: 15 additions & 0 deletions config/cashier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),

];
20 changes: 20 additions & 0 deletions tests/Feature/WorkspaceControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
106 changes: 106 additions & 0 deletions tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php
Original file line number Diff line number Diff line change
@@ -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();
});