feat(paywall): require a plan when the user has accepted AI (#564)

## 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.
This commit is contained in:
Víctor Falcón 2026-06-19 16:18:49 +02:00 committed by GitHub
parent 57f8c93e28
commit 29d13ceed1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 52 additions and 2 deletions

View File

@ -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()) {

View File

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

View File

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