From 52aae3fd6777bbe60a0d46ad61b1a1985679cb5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sat, 18 Jul 2026 14:45:56 +0200 Subject: [PATCH] feat: prompt free users to subscribe when enabling AI categorization (#696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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"): 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): 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). --- lang/es.json | 3 +- lang/fr.json | 3 +- resources/js/pages/settings/billing.test.tsx | 129 +++++++++++++++++++ resources/js/pages/settings/billing.tsx | 108 ++++++++++++++-- 4 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 resources/js/pages/settings/billing.test.tsx diff --git a/lang/es.json b/lang/es.json index 0ac4106a..d2e7d5b6 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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", diff --git a/lang/fr.json b/lang/fr.json index ee2b18d5..fed787bc 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -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", diff --git a/resources/js/pages/settings/billing.test.tsx b/resources/js/pages/settings/billing.test.tsx new file mode 100644 index 00000000..428475d8 --- /dev/null +++ b/resources/js/pages/settings/billing.test.tsx @@ -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}, + 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(); + + // 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(); + + 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(); + + // 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(); + }); +}); diff --git a/resources/js/pages/settings/billing.tsx b/resources/js/pages/settings/billing.tsx index f28b7685..278abcb8 100644 --- a/resources/js/pages/settings/billing.tsx +++ b/resources/js/pages/settings/billing.tsx @@ -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 ( + + + + + {__('AI categorization is a paid feature')} + + + {__( + 'Subscribe to a plan to let AI categorize your transactions automatically. You can enable AI right after subscribing.', + )} + + + + {/* 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. */} +
+ {planEntries.map(([key, plan]) => ( + setSelectedPlan(key)} + currency={currency} + locale={locale} + /> + ))} +
+ + + + + + + +
+
+ ); +} + 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({

- - {!hasProPlan && ( -

- {__( - 'You can give consent now, but AI categorization only runs while you have a paid plan.', - )} -

- )} ); @@ -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 ( @@ -505,7 +585,19 @@ export default function Billing() { setShowAiUpgrade(true)} /> + + {!hasProPlan && ( + + )}