feat: prompt free users to subscribe when enabling AI categorization (#696)

## Problem

On **Settings › Manage Plan**, a free (non-subscribed) user who ticked
**"Allow AI categorization"** silently recorded AI consent (`POST
/ai/consent`). That flipped `hasActiveAiConsent()` to `true`, which
caused two things on the **next navigation / refresh**:

1. `EnsureUserIsSubscribed` hard-redirected them to `/subscribe`, on
every gated page.
2. The paywall's `canUseFreePlan` (`!hasBankConnections &&
!hasActiveAiConsent`) became `false`, so the **"Continue for free"**
escape hatch disappeared.

Net effect: enabling AI locked a free user out of the app behind the
paywall, with no explanation and no way back.

## Fix

For a **free user**, ticking the box no longer records consent. Instead
it opens a contextual dialog that explains AI is a paid feature and
offers **Monthly / Annual** plans that start Stripe checkout. Because no
consent is recorded:

- Nothing locks them out — dismissing the dialog ("Maybe later") leaves
the app fully usable.
- **Pro users** keep the existing direct-consent behavior.
- A free user who somehow already has consent can still **untick to
revoke** and escape the lock.

The now-inaccurate "you can give consent now…" note was removed (a free
user can no longer give consent from here).

## Tests

- New `resources/js/pages/settings/billing.test.tsx` covers three paths:
free user → modal opens & **no consent POSTed**; pro user → consent
recorded directly; free user with existing consent → untick **revokes**.

## Demo

**Before** — enabling AI silently locks the free user behind the paywall
(no "Continue for free"):

<!-- 📎 PLACEHOLDER: drag in ~/Downloads/free-user-ai-paywall.mp4 -->


https://github.com/user-attachments/assets/a72790a4-2b2d-4af1-9f8f-4a16f821eaef



**After** — enabling AI shows a contextual subscribe modal; dismissing
it keeps the app working (no lock):

<!-- 📎 PLACEHOLDER: drag in ~/Downloads/free-user-ai-new-behavior.mp4
-->


https://github.com/user-attachments/assets/4a1c76d6-6c4c-4e94-baea-99e7c302b3df



> Note: the "after" clip ends at the modal + "no lock" proof rather than
crossing into Stripe Checkout, because this local dev env has a
pre-existing Stripe misconfig (`No such tax rate 'txr_…'`) that 500s on
`/subscribe/checkout` — unrelated to this change (the dialog's Upgrade
button reuses the same `checkout.url()` as the existing on-page Upgrade
button).
This commit is contained in:
Víctor Falcón 2026-07-18 14:45:56 +02:00 committed by GitHub
parent 89174af4e6
commit 52aae3fd67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 233 additions and 10 deletions

View File

@ -18,7 +18,8 @@
"AI Categorization": "Categorización con IA",
"Let AI suggest categories for your transactions automatically.": "Deja que la IA sugiera categorías para tus transacciones automáticamente.",
"Allow AI categorization": "Permitir categorización con IA",
"You can give consent now, but AI categorization only runs while you have a paid plan.": "Puedes dar tu consentimiento ahora, pero la categorización con IA solo funciona mientras tengas un plan de pago.",
"AI categorization is a paid feature": "La categorización con IA es una función de pago",
"Subscribe to a plan to let AI categorize your transactions automatically. You can enable AI right after subscribing.": "Suscríbete a un plan para que la IA categorice tus transacciones automáticamente. Podrás activar la IA justo después de suscribirte.",
"With your permission, we send merchant names from your transactions to our AI provider so it can suggest categories. You can revoke this at any time.": "Con tu permiso, enviamos los nombres de los comercios de tus transacciones a nuestro proveedor de IA para que pueda sugerir categorías. Puedes revocarlo en cualquier momento.",
"AI categorization enabled": "Categorización con IA activada",
"AI categorization disabled": "Categorización con IA desactivada",

View File

@ -15,7 +15,8 @@
"AI Categorization": "Catégorisation par IA",
"Let AI suggest categories for your transactions automatically.": "Laissez l'IA suggérer automatiquement des catégories pour vos transactions.",
"Allow AI categorization": "Autoriser la catégorisation par IA",
"You can give consent now, but AI categorization only runs while you have a paid plan.": "Vous pouvez donner votre consentement maintenant, mais la catégorisation par IA ne fonctionne que tant que vous avez un forfait payant.",
"AI categorization is a paid feature": "La catégorisation par IA est une fonctionnalité payante",
"Subscribe to a plan to let AI categorize your transactions automatically. You can enable AI right after subscribing.": "Abonnez-vous à un forfait pour laisser l'IA catégoriser automatiquement vos transactions. Vous pourrez activer l'IA juste après votre abonnement.",
"With your permission, we send merchant names from your transactions to our AI provider so it can suggest categories. You can revoke this at any time.": "Avec votre permission, nous envoyons les noms des commerçants de vos transactions à notre fournisseur d'IA afin qu'il puisse suggérer des catégories. Vous pouvez révoquer cela à tout moment.",
"AI categorization enabled": "Catégorisation par IA activée",
"AI categorization disabled": "Catégorisation par IA désactivée",

View File

@ -0,0 +1,129 @@
import { PricingConfig } from '@/types/pricing';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import Billing from './billing';
const mocks = vi.hoisted(() => ({
axiosPost: vi.fn(() => Promise.resolve({ data: {} })),
axiosDelete: vi.fn(() => Promise.resolve({ data: {} })),
state: { hasProPlan: false, hasAiConsent: false },
}));
vi.mock('axios', () => ({
default: {
post: mocks.axiosPost,
delete: mocks.axiosDelete,
get: vi.fn(),
isAxiosError: () => false,
},
}));
const pricing: PricingConfig = {
plans: {
monthly: {
name: 'Monthly',
price: 3.99,
original_price: null,
stripe_lookup_key: 'monthly',
billing_period: 'month',
features: [],
},
yearly: {
name: 'Annual',
price: 23.88,
original_price: 47.88,
stripe_lookup_key: 'yearly',
billing_period: 'year',
features: [],
},
},
defaultPlan: 'yearly',
bestValuePlan: 'yearly',
promo: { enabled: false, code: '', description: '', badge: '' },
currency: 'EUR',
};
vi.mock('@inertiajs/react', () => ({
Head: ({ title }: { title: string }) => <title>{title}</title>,
router: { post: vi.fn(), visit: vi.fn() },
usePage: () => ({
props: {
auth: {
isDemoAccount: false,
hasProPlan: mocks.state.hasProPlan,
},
pricing,
locale: 'en',
hasAiConsent: mocks.state.hasAiConsent,
},
}),
}));
vi.mock('@/layouts/app-layout', () => ({
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('@/layouts/settings/layout', () => ({
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
function aiCheckbox(): HTMLElement {
return screen.getByRole('checkbox', { name: /Allow AI categorization/i });
}
describe('Billing AI categorization toggle', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.state.hasProPlan = false;
mocks.state.hasAiConsent = false;
});
it('prompts a free user to subscribe instead of recording consent', async () => {
render(<Billing />);
// Capture the element up front: once the dialog opens, Radix marks the
// background as aria-hidden, so getByRole can no longer see it.
const checkbox = aiCheckbox();
fireEvent.click(checkbox);
expect(
await screen.findByText('AI categorization is a paid feature'),
).toBeInTheDocument();
// The consent must NOT be recorded — that is what would lock the free
// user behind the paywall on the next navigation.
expect(mocks.axiosPost).not.toHaveBeenCalled();
expect(checkbox).not.toBeChecked();
});
it('records consent directly for a subscribed user', async () => {
mocks.state.hasProPlan = true;
render(<Billing />);
fireEvent.click(aiCheckbox());
await waitFor(() => expect(mocks.axiosPost).toHaveBeenCalledTimes(1));
expect(
screen.queryByText('AI categorization is a paid feature'),
).not.toBeInTheDocument();
});
it('lets a free user with existing consent revoke it (escape the lock)', async () => {
mocks.state.hasAiConsent = true;
render(<Billing />);
// Unchecking is not gated — it must revoke so a previously locked free
// user can get out from behind the paywall.
fireEvent.click(aiCheckbox());
await waitFor(() => expect(mocks.axiosDelete).toHaveBeenCalledTimes(1));
expect(mocks.axiosPost).not.toHaveBeenCalled();
expect(
screen.queryByText('AI categorization is a paid feature'),
).not.toBeInTheDocument();
});
});

View File

@ -3,6 +3,14 @@ import { ProBadge } from '@/components/pro-badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { cn } from '@/lib/utils';
@ -389,17 +397,96 @@ function SubscribedSection({
);
}
function AiUpgradeDialog({
open,
onOpenChange,
planEntries,
defaultPlan,
currency,
locale,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
planEntries: [string, Plan][];
defaultPlan: string;
currency: string;
locale: string;
}) {
const [selectedPlan, setSelectedPlan] = useState(defaultPlan);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{__('AI categorization is a paid feature')}
</DialogTitle>
<DialogDescription>
{__(
'Subscribe to a plan to let AI categorize your transactions automatically. You can enable AI right after subscribing.',
)}
</DialogDescription>
</DialogHeader>
{/* ponytail: plan cards + checkout button mirror UpgradeSection;
PlanCard is the shared unit not worth a further abstraction
for two call sites. Extract a PlanPicker only if paywall.tsx's
third copy is unified too. */}
<div className="flex gap-3">
{planEntries.map(([key, plan]) => (
<PlanCard
key={key}
planKey={key}
plan={plan}
isSelected={key === selectedPlan}
onSelect={() => setSelectedPlan(key)}
currency={currency}
locale={locale}
/>
))}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{__('Maybe later')}
</Button>
<a href={checkout.url({ query: { plan: selectedPlan } })}>
<Button className="w-full bg-emerald-600 hover:bg-emerald-700 dark:bg-emerald-600 dark:hover:bg-emerald-700">
<ZapIcon className="size-4" />
{__('Upgrade to Standard Plan')}
</Button>
</a>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function AiConsentSection({
initialConsent,
hasProPlan,
onUpgradeNeeded,
}: {
initialConsent: boolean;
hasProPlan: boolean;
onUpgradeNeeded: () => void;
}) {
const [consented, setConsented] = useState(initialConsent);
const [saving, setSaving] = useState(false);
const handleToggle = async (checked: boolean) => {
// Free users can't enable AI directly — it needs a paid plan. Prompt
// them to subscribe instead of recording consent (which would silently
// lock them behind the paywall on the next navigation).
if (checked && !hasProPlan) {
onUpgradeNeeded();
return;
}
setSaving(true);
try {
if (checked) {
@ -457,14 +544,6 @@ function AiConsentSection({
</p>
</div>
</label>
{!hasProPlan && (
<p className="mt-3 border-t pt-3 text-sm text-amber-600 dark:text-amber-400">
{__(
'You can give consent now, but AI categorization only runs while you have a paid plan.',
)}
</p>
)}
</div>
</div>
);
@ -478,6 +557,7 @@ export default function Billing() {
const hasProPlan = auth?.hasProPlan ?? false;
const planEntries = Object.entries(pricing.plans);
const defaultPlan = pricing.plans[pricing.defaultPlan];
const [showAiUpgrade, setShowAiUpgrade] = useState(false);
return (
<AppLayout breadcrumbs={breadcrumbs}>
@ -505,7 +585,19 @@ export default function Billing() {
<AiConsentSection
initialConsent={hasAiConsent}
hasProPlan={hasProPlan}
onUpgradeNeeded={() => setShowAiUpgrade(true)}
/>
{!hasProPlan && (
<AiUpgradeDialog
open={showAiUpgrade}
onOpenChange={setShowAiUpgrade}
planEntries={planEntries}
defaultPlan={pricing.defaultPlan}
currency={pricing.currency}
locale={locale}
/>
)}
</div>
</SettingsLayout>
</AppLayout>