diff --git a/app/Http/Controllers/App/WorkspaceController.php b/app/Http/Controllers/App/WorkspaceController.php index d9d394ff..5b2326f2 100644 --- a/app/Http/Controllers/App/WorkspaceController.php +++ b/app/Http/Controllers/App/WorkspaceController.php @@ -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); diff --git a/app/Support/Billing/FirstMonthCheckoutDiscount.php b/app/Support/Billing/FirstMonthCheckoutDiscount.php index 9f69ff85..064a27dd 100644 --- a/app/Support/Billing/FirstMonthCheckoutDiscount.php +++ b/app/Support/Billing/FirstMonthCheckoutDiscount.php @@ -5,6 +5,7 @@ namespace App\Support\Billing; use Laravel\Cashier\SubscriptionBuilder; +use RuntimeException; final class FirstMonthCheckoutDiscount { @@ -14,13 +15,26 @@ final class FirstMonthCheckoutDiscount * 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. + * + * @throws RuntimeException when the paid first month is enabled but no + * coupon is configured — failing loudly beats + * silently charging every new customer full price. */ public static function apply(SubscriptionBuilder $subscription): SubscriptionBuilder { - if ((bool) config('trypost.billing.require_card_for_trial', true)) { - return $subscription->withCoupon(config('cashier.first_month_coupon_id')); + if (! (bool) config('trypost.billing.require_card_for_trial', true)) { + return $subscription->allowPromotionCodes(); } - 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); } } diff --git a/tests/Feature/WorkspaceControllerTest.php b/tests/Feature/WorkspaceControllerTest.php index 796bef21..e1354dc5 100644 --- a/tests/Feature/WorkspaceControllerTest.php +++ b/tests/Feature/WorkspaceControllerTest.php @@ -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]); diff --git a/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php b/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php index b1dc7fd6..21738c2c 100644 --- a/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php +++ b/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php @@ -37,3 +37,17 @@ 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' => '', + ]); + + $subscription = $this->account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test'); + + expect(fn () => FirstMonthCheckoutDiscount::apply($subscription)) + ->toThrow(RuntimeException::class); + + expect($subscription->couponId)->toBeNull(); +});