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 (
+
+ );
+}
+
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.',
- )}
-