From 29d13ceed103860399b271ffed3cb17810112001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 19 Jun 2026 16:18:49 +0200 Subject: [PATCH] feat(paywall): require a plan when the user has accepted AI (#564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Forces users who have accepted AI consent to choose a plan, the same way connecting a bank already does. ## Why AI suggestions are a paid-plan feature (`PlanFeature::AiSuggestions`), and the onboarding notice tells the user *"AI suggestions are a Standard Plan feature. You'll choose a plan at the end of the onboarding."* But that was never enforced: `EnsureUserIsSubscribed` only gated on bank connections, never on AI consent. A user who accepted AI without connecting a bank saw the paywall once, got `paywall_seen_at` marked, and then fell through to **free** access — making the notice a false promise. ## Change - `EnsureUserIsSubscribed`: a non-Pro user with an **active AI consent** is now kept on the paywall on every request, exactly like a bank-connected user (added `&& ! $user->hasActiveAiConsent()` to the free-access branch). - `SubscriptionController::index`: `canUseFreePlan` is now `false` when the user has an active AI consent, so the paywall stops offering the free option to these users (keeps page and middleware consistent). - Revoking AI consent (`hasActiveAiConsent()` → false) restores free-plan access — a clean escape hatch. Onboarding itself is unaffected: the onboarding / AI-consent / rule-suggestion routes live in the `auth,verified` group **without** the `subscribed` middleware, so consenting mid-onboarding does not lock the user out of finishing. The paywall only kicks in afterward, when accessing the app — which is the intended behavior and matches the notice. ## Tests 4 new tests in `SubscriptionTest` (forced to paywall even after seeing it; `canUseFreePlan` false; subscribed users with consent still get access; revoking consent restores free access). Full `SubscriptionTest` green (38 passed). `pint` clean. ## ⚠️ Behavior change for existing users This applies retroactively. From prod, **34 users currently have an active AI consent**; any of them on the free plan today (no bank, `paywall_seen_at` set) will be redirected to the paywall after deploy and must subscribe or revoke consent. If we want to grandfather existing consenters and only enforce this going forward, that needs an extra condition (e.g. consent recorded after a cutoff date) — flag me and I'll add it. --- .../Controllers/SubscriptionController.php | 2 +- .../Middleware/EnsureUserIsSubscribed.php | 2 +- tests/Feature/SubscriptionTest.php | 50 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index 4ccf247d..88d588e1 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -25,7 +25,7 @@ class SubscriptionController extends Controller } $hasBankConnections = $user->bankingConnections()->exists(); - $canUseFreePlan = ! $hasBankConnections; + $canUseFreePlan = ! $hasBankConnections && ! $user->hasActiveAiConsent(); // Mark the paywall as seen so the middleware stops redirecting here. if ($canUseFreePlan && ! $user->hasSeenPaywall()) { diff --git a/app/Http/Middleware/EnsureUserIsSubscribed.php b/app/Http/Middleware/EnsureUserIsSubscribed.php index 7df9426c..e3d308ba 100644 --- a/app/Http/Middleware/EnsureUserIsSubscribed.php +++ b/app/Http/Middleware/EnsureUserIsSubscribed.php @@ -23,7 +23,7 @@ class EnsureUserIsSubscribed return $next($request); } - if ($user && ! $user->bankingConnections()->exists()) { + if ($user && ! $user->bankingConnections()->exists() && ! $user->hasActiveAiConsent()) { if (! $user->hasSeenPaywall()) { return redirect()->route('subscribe'); } diff --git a/tests/Feature/SubscriptionTest.php b/tests/Feature/SubscriptionTest.php index 6e920f13..3b41ad8a 100644 --- a/tests/Feature/SubscriptionTest.php +++ b/tests/Feature/SubscriptionTest.php @@ -407,6 +407,56 @@ test('paywall shows canUseFreePlan false when user has a bank connection', funct ); }); +test('users with active ai consent are forced to the paywall even after seeing it', function () { + $user = User::factory()->onboarded()->create(['paywall_seen_at' => now()]); + $user->recordAiConsent(); + + $this->actingAs($user); + + $this->get(route('dashboard'))->assertRedirect(route('subscribe')); + $this->get(route('accounts.list'))->assertRedirect(route('subscribe')); +}); + +test('paywall shows canUseFreePlan false when user has active ai consent', function () { + $user = User::factory()->onboarded()->create(); + $user->recordAiConsent(); + + $this->actingAs($user); + + $this->get(route('subscribe')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('subscription/paywall') + ->where('canUseFreePlan', false) + ); +}); + +test('subscribed users with active ai consent can access protected routes', function () { + $user = User::factory()->onboarded()->create(); + $user->recordAiConsent(); + + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_ai_consent_test123', + 'stripe_status' => 'active', + 'stripe_price' => 'price_test123', + ]); + + $this->actingAs($user); + + $this->get(route('dashboard'))->assertOk(); +}); + +test('revoking ai consent lets users fall back to the free plan', function () { + $user = User::factory()->onboarded()->create(['paywall_seen_at' => now()]); + $user->recordAiConsent(); + $user->revokeAiConsent(); + + $this->actingAs($user); + + $this->get(route('dashboard'))->assertOk(); +}); + test('taxRates returns configured stripe tax rate ids', function () { config(['subscriptions.tax_rates' => ['txr_test_1', 'txr_test_2']]);