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",
|
"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.",
|
"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",
|
"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.",
|
"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 enabled": "Categorización con IA activada",
|
||||||
"AI categorization disabled": "Categorización con IA desactivada",
|
"AI categorization disabled": "Categorización con IA desactivada",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@
|
||||||
"AI Categorization": "Catégorisation par IA",
|
"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.",
|
"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",
|
"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.",
|
"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 enabled": "Catégorisation par IA activée",
|
||||||
"AI categorization disabled": "Catégorisation par IA désactivé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 { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import AppLayout from '@/layouts/app-layout';
|
import AppLayout from '@/layouts/app-layout';
|
||||||
import SettingsLayout from '@/layouts/settings/layout';
|
import SettingsLayout from '@/layouts/settings/layout';
|
||||||
import { cn } from '@/lib/utils';
|
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({
|
function AiConsentSection({
|
||||||
initialConsent,
|
initialConsent,
|
||||||
hasProPlan,
|
hasProPlan,
|
||||||
|
onUpgradeNeeded,
|
||||||
}: {
|
}: {
|
||||||
initialConsent: boolean;
|
initialConsent: boolean;
|
||||||
hasProPlan: boolean;
|
hasProPlan: boolean;
|
||||||
|
onUpgradeNeeded: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [consented, setConsented] = useState(initialConsent);
|
const [consented, setConsented] = useState(initialConsent);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const handleToggle = async (checked: boolean) => {
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
|
|
@ -457,14 +544,6 @@ function AiConsentSection({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -478,6 +557,7 @@ export default function Billing() {
|
||||||
const hasProPlan = auth?.hasProPlan ?? false;
|
const hasProPlan = auth?.hasProPlan ?? false;
|
||||||
const planEntries = Object.entries(pricing.plans);
|
const planEntries = Object.entries(pricing.plans);
|
||||||
const defaultPlan = pricing.plans[pricing.defaultPlan];
|
const defaultPlan = pricing.plans[pricing.defaultPlan];
|
||||||
|
const [showAiUpgrade, setShowAiUpgrade] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout breadcrumbs={breadcrumbs}>
|
<AppLayout breadcrumbs={breadcrumbs}>
|
||||||
|
|
@ -505,7 +585,19 @@ export default function Billing() {
|
||||||
<AiConsentSection
|
<AiConsentSection
|
||||||
initialConsent={hasAiConsent}
|
initialConsent={hasAiConsent}
|
||||||
hasProPlan={hasProPlan}
|
hasProPlan={hasProPlan}
|
||||||
|
onUpgradeNeeded={() => setShowAiUpgrade(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!hasProPlan && (
|
||||||
|
<AiUpgradeDialog
|
||||||
|
open={showAiUpgrade}
|
||||||
|
onOpenChange={setShowAiUpgrade}
|
||||||
|
planEntries={planEntries}
|
||||||
|
defaultPlan={pricing.defaultPlan}
|
||||||
|
currency={pricing.currency}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue