Fail loud on a missing coupon and block unpaid extra workspaces
Two hardening fixes for the paid first month: - FirstMonthCheckoutDiscount throws when the paid first month is enabled but STRIPE_FIRST_MONTH_COUPON_ID is unset, instead of silently charging every new customer the full price with no discount. - Guard workspace store() with the same active-subscription check create() already applies, so a direct POST can't bootstrap a second billable workspace and inflate checkout quantity past the fixed first-month coupon.
This commit is contained in:
parent
c940826031
commit
f36881cb6a
4 changed files with 76 additions and 10 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue