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:
parent
89174af4e6
commit
52aae3fd67
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue