feat: prompt free users to subscribe when enabling AI categorization

On Settings > Manage Plan, a free user who ticked 'Allow AI categorization'
silently recorded consent. That flipped hasActiveAiConsent() to true, which
made the EnsureUserIsSubscribed middleware hard-redirect them to the paywall on
the next navigation and removed the 'Continue for free' escape hatch — locking
them out of the app with no explanation.

Now, for a free user, ticking the box opens a contextual dialog explaining AI is
a paid feature and offering Monthly/Annual plans that start Stripe checkout. No
consent is recorded, so nothing locks them out; dismissing the dialog leaves the
app fully usable. Pro users keep the direct-consent behavior, and a free user
who already has consent can still untick to revoke and escape the lock.

Add vitest coverage for the free (modal, no POST), pro (direct consent) and
revoke paths, and drop the now-inaccurate 'you can give consent now' note.
This commit is contained in:
Víctor Falcón 2026-07-18 12:53:56 +02:00
parent 5621e90879
commit f422bb4ce4
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>