diff --git a/llm-fe/ANALYTICS.md b/llm-fe/ANALYTICS.md index 0c9067f..2536ead 100644 --- a/llm-fe/ANALYTICS.md +++ b/llm-fe/ANALYTICS.md @@ -20,7 +20,13 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta. | Conversation Created | `CONVERSATION_CREATED` | MessageContext | `{ conversationId }` | | Message Sent | `MESSAGE_SENT` | AsyncDashboard2 | `{ hasConversation, hasAttachment }` | | ToS Acknowledged | `TOS_ACKNOWLEDGED` | TermsOfService | — | -| Billing Portal Opened | `BILLING_PORTAL_OPENED` | BillingSection (Account) | — | +| Billing Portal Opened | `BILLING_PORTAL_OPENED` | BillingSection (Account) | `{ intent? }` | +| Subscription Upgrade Started | `SUBSCRIPTION_UPGRADE_STARTED` | BillingSection, UsageSummaryCard | `{ source, plan_slug? }` | +| Plan Change Started | `PLAN_CHANGE_STARTED` | BillingSection | `{ source, plan_slug? }` | +| Subscription Cancel Started | `SUBSCRIPTION_CANCEL_STARTED` | BillingSection | `{ source: 'portal' }` | +| Account Delete Started | `ACCOUNT_DELETE_STARTED` | DeleteAccountSection | — | +| Account Delete Success | `ACCOUNT_DELETE_SUCCESS` | DeleteAccountSection | — | +| Account Delete Failed | `ACCOUNT_DELETE_FAILED` | DeleteAccountSection | — | ## Identify diff --git a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.test.tsx b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.test.tsx index aa10c47..9978858 100644 --- a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.test.tsx +++ b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.test.tsx @@ -20,6 +20,9 @@ jest.mock('../../utils/analytics', () => ({ AnalyticsEvents: { BILLING_PORTAL_OPENED: 'Billing Portal Opened', CHECKOUT_STARTED: 'Checkout Started', + SUBSCRIPTION_UPGRADE_STARTED: 'Subscription Upgrade Started', + PLAN_CHANGE_STARTED: 'Plan Change Started', + SUBSCRIPTION_CANCEL_STARTED: 'Subscription Cancel Started', }, trackEvent: (...args: unknown[]) => mockTrackEvent(...args), })); @@ -52,30 +55,34 @@ const paidInvoice = { last_modified: '2026-07-01T12:00:00Z', }; -const foundersSubscription = { - plan: { - slug: 'founders', - name: 'Founders', - description: '', - price_cents: 1000, - currency: 'usd', - interval: 'month', - is_public: true, - is_selectable: true, - features: { - text_generation: true, - image_generation: true, - all_future_features: true, - }, - prompt_quota_per_window: 300, - prompt_window_hours: 6, - monthly_token_quota: null, - sort_order: 10, +const foundersPlan = { + slug: 'founders', + name: 'Founders', + description: '', + price_cents: 1000, + currency: 'usd', + interval: 'month', + is_public: true, + is_selectable: true, + features: { + text_generation: true, + image_generation: true, + all_future_features: true, }, + prompt_quota_per_window: 300, + prompt_window_hours: 6, + monthly_token_quota: null, + sort_order: 10, +}; + +const foundersSubscription = { + plan: foundersPlan, status: 'active', source: 'stripe', needs_checkout: false, stripe_subscription_id: 'sub_test', + cancel_at_period_end: false, + current_period_end: '2026-08-01T00:00:00Z', usage: { prompts_in_window: 2, prompt_quota: 300, @@ -92,12 +99,20 @@ const foundersSubscription = { }, }; +const complimentarySubscription = { + ...foundersSubscription, + source: 'backer', + stripe_subscription_id: '', +}; + const emptySubscription = { plan: null, status: 'none', source: 'none', needs_checkout: true, stripe_subscription_id: '', + cancel_at_period_end: false, + current_period_end: null, usage: { prompts_in_window: 0, prompt_quota: null, @@ -120,10 +135,12 @@ const mockFinanceGets = ({ invoices = [] as unknown[], payments = [] as unknown[], subscription = emptySubscription as SubscriptionMock, + plans = [foundersPlan] as unknown[], }: { invoices?: unknown[]; payments?: unknown[]; subscription?: SubscriptionMock; + plans?: unknown[]; } = {}) => { mockGet.mockImplementation((url: string) => { if (url === '/finance/invoices/') { @@ -135,6 +152,9 @@ const mockFinanceGets = ({ if (url === '/finance/subscription/') { return Promise.resolve({ data: subscription }); } + if (url === '/finance/plans/') { + return Promise.resolve({ data: plans }); + } return Promise.reject(new Error(`unexpected GET ${url}`)); }); }; @@ -171,7 +191,7 @@ describe('BillingSection', () => { }); }); - it('renders plan summary and invoice history from finance APIs', async () => { + it('renders plan summary and paid subscription management CTAs', async () => { mockFinanceGets({ invoices: [paidInvoice], subscription: foundersSubscription, @@ -179,7 +199,10 @@ describe('BillingSection', () => { renderBilling(); - expect(await screen.findByRole('button', { name: /Manage subscription/i })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /^Upgrade$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Change plan/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Cancel$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Manage subscription/i })).toBeInTheDocument(); expect(screen.getByText('Founders')).toBeInTheDocument(); expect(screen.getByText(/298 \/ 300/)).toBeInTheDocument(); expect(screen.getByText(/in — · out —/)).toBeInTheDocument(); @@ -189,10 +212,11 @@ describe('BillingSection', () => { ); }); - it('redirects to Stripe Customer Portal on manage billing', async () => { + it('opens Stripe portal for change plan when no alternate public plans', async () => { mockFinanceGets({ invoices: [paidInvoice], subscription: foundersSubscription, + plans: [foundersPlan], }); mockPost.mockResolvedValue({ data: { portal_url: 'https://billing.stripe.com/p/session/test' }, @@ -201,20 +225,67 @@ describe('BillingSection', () => { const user = userEvent.setup(); renderBilling(); - await screen.findByRole('button', { name: /Manage subscription/i }); - await user.click(screen.getByRole('button', { name: /Manage subscription/i })); + await screen.findByRole('button', { name: /Change plan/i }); + await user.click(screen.getByRole('button', { name: /Change plan/i })); await waitFor(() => { expect(mockPost).toHaveBeenCalledWith('/finance/portal/', { return_url: 'http://localhost/account/', }); }); - expect(mockTrackEvent).toHaveBeenCalledWith('Billing Portal Opened'); + expect(mockTrackEvent).toHaveBeenCalledWith('Plan Change Started', { + source: 'account_billing', + }); + expect(mockTrackEvent).toHaveBeenCalledWith('Billing Portal Opened', { + intent: 'change', + }); expect(assignMock).toHaveBeenCalledWith( 'https://billing.stripe.com/p/session/test' ); }); + it('confirms cancel then opens portal', async () => { + mockFinanceGets({ + invoices: [paidInvoice], + subscription: foundersSubscription, + }); + mockPost.mockResolvedValue({ + data: { portal_url: 'https://billing.stripe.com/p/session/cancel' }, + }); + + const user = userEvent.setup(); + renderBilling(); + + await screen.findByRole('button', { name: /^Cancel$/i }); + await user.click(screen.getByRole('button', { name: /^Cancel$/i })); + expect(await screen.findByTestId('cancel-confirm-modal')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /Continue to cancel/i })); + + await waitFor(() => { + expect(mockTrackEvent).toHaveBeenCalledWith('Subscription Cancel Started', { + source: 'portal', + }); + }); + expect(assignMock).toHaveBeenCalledWith( + 'https://billing.stripe.com/p/session/cancel' + ); + }); + + it('shows complimentary messaging without cancel', async () => { + mockFinanceGets({ + invoices: [], + subscription: complimentarySubscription, + }); + + renderBilling(); + + expect( + await screen.findByTestId('complimentary-billing-message') + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Cancel$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Upgrade$/i })).not.toBeInTheDocument(); + }); + it('shows empty unpaid state and starts checkout', async () => { mockFinanceGets(); mockPost.mockResolvedValue({ diff --git a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx index 11b1d5f..4212392 100644 --- a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx +++ b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx @@ -10,9 +10,13 @@ import { formatBillingDate, formatMoneyCents, formatTokenCount, + higherSelectablePlans, humanizeStatus, + isComplimentarySubscription, + otherSelectablePlans, pickPrimaryInvoice, SubscriptionMe, + SubscriptionPlanInfo, } from '../../utils/finance'; const GlassCard = styled.div` @@ -69,6 +73,14 @@ const BodyText = styled.p` line-height: 1.5; `; +const NoticeText = styled.p` + color: ${({ theme }) => theme.colors.text}; + opacity: 0.85; + margin: 1rem 0 0 0; + line-height: 1.5; + font-size: 0.95rem; +`; + const ErrorText = styled.p` color: #ff6b6b; margin: 0.75rem 0 0 0; @@ -116,6 +128,81 @@ const SecondaryButton = styled(StyledButton)` } `; +const DangerButton = styled(SecondaryButton)` + border-color: #ff6b6b66; + color: #ff6b6b; + + &:hover { + border-color: #ff6b6b; + } +`; + +const PlanList = styled.ul` + list-style: none; + margin: 1rem 0 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +`; + +const PlanOption = styled.li` + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 0.75rem; +`; + +const PlanMeta = styled.div` + flex: 1; + min-width: 200px; +`; + +const PlanName = styled.div` + font-weight: 600; + color: ${({ theme }) => theme.colors.text}; + margin-bottom: 0.25rem; +`; + +const PlanDesc = styled.div` + font-size: 0.9rem; + color: ${({ theme }) => theme.colors.text}; + opacity: 0.7; + line-height: 1.4; +`; + +const ModalBackdrop = styled.div` + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1.5rem; +`; + +const ModalCard = styled.div` + background: ${({ theme }) => theme.colors.cardBackground}; + backdrop-filter: blur(12px); + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 1rem; + padding: 1.75rem; + max-width: 480px; + width: 100%; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); +`; + +const ModalTitle = styled.h3` + margin: 0 0 0.75rem 0; + color: ${({ theme }) => theme.colors.text}; + font-size: 1.35rem; +`; + const StyledTable = styled.table` width: 100%; border-collapse: collapse; @@ -154,32 +241,42 @@ function apiErrorMessage(error: unknown, fallback: string): string { return axiosError.response?.data?.detail || axiosError.message || fallback; } +type PortalIntent = 'manage' | 'upgrade' | 'change' | 'cancel'; + const BillingSection = (): JSX.Element => { const [invoices, setInvoices] = useState([]); const [payments, setPayments] = useState([]); const [subscription, setSubscription] = useState(null); + const [plans, setPlans] = useState([]); const [loading, setLoading] = useState(true); const [listError, setListError] = useState(''); const [actionError, setActionError] = useState(''); const [portalLoading, setPortalLoading] = useState(false); - const [checkoutLoading, setCheckoutLoading] = useState(false); + const [checkoutLoadingSlug, setCheckoutLoadingSlug] = useState(null); + const [showPlanPicker, setShowPlanPicker] = useState(false); + const [planPickerMode, setPlanPickerMode] = useState<'upgrade' | 'change'>('change'); + const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); const loadBilling = useCallback(async () => { setLoading(true); setListError(''); try { - const [invoiceResponse, paymentResponse, subscriptionResponse] = await Promise.all([ - axiosInstance.get('/finance/invoices/'), - axiosInstance.get('/finance/payments/'), - axiosInstance.get('/finance/subscription/'), - ]); + const [invoiceResponse, paymentResponse, subscriptionResponse, plansResponse] = + await Promise.all([ + axiosInstance.get('/finance/invoices/'), + axiosInstance.get('/finance/payments/'), + axiosInstance.get('/finance/subscription/'), + axiosInstance.get('/finance/plans/'), + ]); setInvoices(Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []); setPayments(Array.isArray(paymentResponse.data) ? paymentResponse.data : []); setSubscription(subscriptionResponse.data || null); + setPlans(Array.isArray(plansResponse.data) ? plansResponse.data : []); } catch (error: unknown) { setInvoices([]); setPayments([]); setSubscription(null); + setPlans([]); setListError(apiErrorMessage(error, 'Could not load billing information.')); } finally { setLoading(false); @@ -192,6 +289,23 @@ const BillingSection = (): JSX.Element => { const primaryInvoice = useMemo(() => pickPrimaryInvoice(invoices), [invoices]); const hasPortalAccess = useMemo(() => canOpenBillingPortal(invoices), [invoices]); + const complimentary = useMemo( + () => isComplimentarySubscription(subscription, hasPortalAccess), + [subscription, hasPortalAccess] + ); + const upgradePlans = useMemo( + () => higherSelectablePlans(plans, subscription?.plan), + [plans, subscription?.plan] + ); + const changePlans = useMemo( + () => otherSelectablePlans(plans, subscription?.plan?.slug), + [plans, subscription?.plan?.slug] + ); + const periodEndLabel = useMemo(() => { + const end = + subscription?.current_period_end || primaryInvoice?.period_end || null; + return formatBillingDate(end); + }, [subscription?.current_period_end, primaryInvoice?.period_end]); const historyRows = useMemo(() => { if (invoices.length) { @@ -217,7 +331,7 @@ const BillingSection = (): JSX.Element => { })); }, [invoices, payments]); - const handleManageBilling = async () => { + const openPortal = async (intent: PortalIntent) => { setActionError(''); setPortalLoading(true); try { @@ -231,7 +345,7 @@ const BillingSection = (): JSX.Element => { setActionError('Billing portal could not be opened. Try again.'); return; } - trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED); + trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED, { intent }); window.location.assign(portalUrl); } catch (error: unknown) { setActionError( @@ -242,15 +356,22 @@ const BillingSection = (): JSX.Element => { } }; - const handleStartCheckout = async () => { + const handleStartCheckout = async (planSlug?: string, source = 'account_billing') => { setActionError(''); - setCheckoutLoading(true); + setCheckoutLoadingSlug(planSlug || '__default__'); try { const { success_url, cancel_url } = checkoutReturnUrls(); - trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'account_billing' }); + trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { + source, + ...(planSlug ? { plan_slug: planSlug } : {}), + }); const response = await axiosInstance.post<{ checkout_url: string }>( '/finance/checkout/', - { success_url, cancel_url } + { + success_url, + cancel_url, + ...(planSlug ? { plan_slug: planSlug } : {}), + } ); const checkoutUrl = response.data?.checkout_url; if (!checkoutUrl) { @@ -261,10 +382,59 @@ const BillingSection = (): JSX.Element => { } catch (error: unknown) { setActionError(apiErrorMessage(error, 'Could not start checkout. Try again.')); } finally { - setCheckoutLoading(false); + setCheckoutLoadingSlug(null); } }; + const handleUpgradeClick = () => { + trackEvent(AnalyticsEvents.SUBSCRIPTION_UPGRADE_STARTED, { + source: 'account_billing', + }); + if (upgradePlans.length > 0) { + setPlanPickerMode('upgrade'); + setShowPlanPicker(true); + return; + } + void openPortal('upgrade'); + }; + + const handleChangePlanClick = () => { + trackEvent(AnalyticsEvents.PLAN_CHANGE_STARTED, { source: 'account_billing' }); + if (changePlans.length > 0) { + setPlanPickerMode('change'); + setShowPlanPicker(true); + return; + } + void openPortal('change'); + }; + + const handleConfirmCancel = async () => { + trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'portal' }); + setCancelConfirmOpen(false); + await openPortal('cancel'); + }; + + const pickerPlans = planPickerMode === 'upgrade' ? upgradePlans : changePlans; + + const selectPlanFromPicker = async (plan: SubscriptionPlanInfo) => { + if (planPickerMode === 'upgrade') { + trackEvent(AnalyticsEvents.SUBSCRIPTION_UPGRADE_STARTED, { + source: 'plan_picker', + plan_slug: plan.slug, + }); + // New higher tier via Checkout when selectable; portal otherwise. + await handleStartCheckout(plan.slug, 'account_upgrade'); + return; + } + trackEvent(AnalyticsEvents.PLAN_CHANGE_STARTED, { + source: 'plan_picker', + plan_slug: plan.slug, + }); + // Existing subscribers change plans in the Stripe portal (proration / PCI). + setShowPlanPicker(false); + await openPortal('change'); + }; + return ( <> @@ -331,13 +501,19 @@ const BillingSection = (): JSX.Element => { ) : null} - {primaryInvoice ? ( - - Period end - - {formatBillingDate(primaryInvoice.period_end)} - - + + Period end + {periodEndLabel} + + {subscription?.cancel_at_period_end ? ( + + Cancellation scheduled. You keep access until {periodEndLabel}. + + ) : null} + {subscription?.status === 'canceled' ? ( + + Subscription canceled. Renew via Complete payment when you are ready. + ) : null} ) : ( @@ -349,25 +525,52 @@ const BillingSection = (): JSX.Element => { {!loading && !listError && ( - {hasPortalAccess ? ( - - {portalLoading ? 'Opening…' : 'Manage subscription'} - - ) : subscription?.needs_checkout === false ? ( - - Complimentary access — no payment required. + {hasPortalAccess && !complimentary ? ( + <> + + Upgrade + + + Change plan + + {!subscription?.cancel_at_period_end && + subscription?.status !== 'canceled' ? ( + setCancelConfirmOpen(true)} + disabled={portalLoading} + > + Cancel + + ) : null} + openPortal('manage')} + disabled={portalLoading} + > + {portalLoading ? 'Opening…' : 'Manage subscription'} + + + ) : complimentary ? ( + + Complimentary access — no payment required. Plan changes and + cancellation are not available for this account. ) : ( handleStartCheckout()} + disabled={Boolean(checkoutLoadingSlug)} > - {checkoutLoading ? 'Starting…' : 'Complete payment'} + {checkoutLoadingSlug ? 'Starting…' : 'Complete payment'} )} @@ -375,6 +578,52 @@ const BillingSection = (): JSX.Element => { )} + + {showPlanPicker && pickerPlans.length > 0 ? ( +
+ + {planPickerMode === 'upgrade' + ? 'Choose a higher plan. Checkout opens securely in Stripe.' + : 'Select another plan, then confirm the change in the Stripe customer portal (price and quotas update there).'} + + + {pickerPlans.map((plan) => ( + + + {plan.name} + + {formatMoneyCents(plan.price_cents, plan.currency)} + {plan.interval ? ` / ${plan.interval}` : ''} + {plan.description ? ` — ${plan.description}` : ''} + {` · ${plan.prompt_quota_per_window} prompts / ${plan.prompt_window_hours}h`} + + + selectPlanFromPicker(plan)} + disabled={ + portalLoading || + checkoutLoadingSlug === plan.slug || + checkoutLoadingSlug === '__default__' + } + > + {checkoutLoadingSlug === plan.slug + ? 'Starting…' + : planPickerMode === 'upgrade' + ? 'Upgrade' + : 'Select'} + + + ))} + + + setShowPlanPicker(false)}> + Close + + +
+ ) : null} + {actionError ? {actionError} : null}
@@ -421,6 +670,44 @@ const BillingSection = (): JSX.Element => { )} + + {cancelConfirmOpen ? ( + setCancelConfirmOpen(false)} + data-testid="cancel-confirm-modal" + > + event.stopPropagation()} + > + Cancel subscription? + + You will finish canceling in the Stripe customer portal. Access typically + continues until the end of the current billing period + {periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}. + + + + {portalLoading ? 'Opening…' : 'Continue to cancel'} + + setCancelConfirmOpen(false)} + disabled={portalLoading} + > + Keep subscription + + + + + ) : null} ); }; diff --git a/llm-fe/src/llm-fe/components/DeleteAccountSection/DeleteAccountSection.test.tsx b/llm-fe/src/llm-fe/components/DeleteAccountSection/DeleteAccountSection.test.tsx new file mode 100644 index 0000000..89f94fa --- /dev/null +++ b/llm-fe/src/llm-fe/components/DeleteAccountSection/DeleteAccountSection.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { ThemeProvider } from 'styled-components'; +import { AccountContext } from '../../contexts/AccountContext'; +import { AuthContext } from '../../contexts/AuthContext'; +import DeleteAccountSection from './DeleteAccountSection'; + +const mockDelete = jest.fn(); +const mockPost = jest.fn(); +const mockClearTokens = jest.fn(); +const mockGetRefreshToken = jest.fn(); +const mockApplyAccessToken = jest.fn(); +const mockNavigate = jest.fn(); +const mockSetAccount = jest.fn(); +const mockSetAuthentication = jest.fn(); +const mockTrackEvent = jest.fn(); + +jest.mock('../../../axiosApi', () => ({ + axiosInstance: { + delete: (...args: unknown[]) => mockDelete(...args), + post: (...args: unknown[]) => mockPost(...args), + }, + applyAccessToken: (...args: unknown[]) => mockApplyAccessToken(...args), +})); + +jest.mock('../../auth/tokenStorage', () => ({ + clearTokens: (...args: unknown[]) => mockClearTokens(...args), + getRefreshToken: (...args: unknown[]) => mockGetRefreshToken(...args), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +jest.mock('../../utils/analytics', () => ({ + AnalyticsEvents: { + ACCOUNT_DELETE_STARTED: 'Account Delete Started', + ACCOUNT_DELETE_SUCCESS: 'Account Delete Success', + ACCOUNT_DELETE_FAILED: 'Account Delete Failed', + }, + trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +})); + +const theme = { + main: '#4a90e2', + darkMode: true, + colors: { + text: '#ffffff', + cardBackground: 'rgba(0,0,0,0.4)', + cardBorder: 'rgba(255,255,255,0.1)', + }, +}; + +const renderSection = () => + render( + + + {}, + loading: false, + }} + > + + + + + + + ); + +describe('DeleteAccountSection', () => { + beforeEach(() => { + mockDelete.mockReset(); + mockPost.mockReset(); + mockClearTokens.mockReset(); + mockGetRefreshToken.mockReset(); + mockApplyAccessToken.mockReset(); + mockNavigate.mockReset(); + mockSetAccount.mockReset(); + mockSetAuthentication.mockReset(); + mockTrackEvent.mockReset(); + mockGetRefreshToken.mockReturnValue('refresh-token'); + mockClearTokens.mockResolvedValue(undefined); + mockDelete.mockResolvedValue({ data: { deleted: true } }); + mockPost.mockResolvedValue({}); + }); + + it('requires email confirmation then deletes and signs out', async () => { + const user = userEvent.setup(); + renderSection(); + + await user.click(screen.getByRole('button', { name: /Delete my account/i })); + expect(await screen.findByTestId('delete-account-modal')).toBeInTheDocument(); + + await user.type(screen.getByLabelText(/Confirm email/i), 'user@example.com'); + await user.click(screen.getByRole('button', { name: /^Delete account$/i })); + + await waitFor(() => { + expect(mockDelete).toHaveBeenCalledWith('/user/', { + data: { refresh_token: 'refresh-token' }, + }); + }); + expect(mockTrackEvent).toHaveBeenCalledWith('Account Delete Started'); + expect(mockTrackEvent).toHaveBeenCalledWith('Account Delete Success'); + expect(mockClearTokens).toHaveBeenCalled(); + expect(mockApplyAccessToken).toHaveBeenCalledWith(null); + expect(mockSetAuthentication).toHaveBeenCalledWith(false); + expect(mockNavigate).toHaveBeenCalledWith('/signin/'); + }); +}); diff --git a/llm-fe/src/llm-fe/components/DeleteAccountSection/DeleteAccountSection.tsx b/llm-fe/src/llm-fe/components/DeleteAccountSection/DeleteAccountSection.tsx new file mode 100644 index 0000000..6e4b85f --- /dev/null +++ b/llm-fe/src/llm-fe/components/DeleteAccountSection/DeleteAccountSection.tsx @@ -0,0 +1,250 @@ +import React, { useContext, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; +import { applyAccessToken, axiosInstance } from '../../../axiosApi'; +import { clearTokens, getRefreshToken } from '../../auth/tokenStorage'; +import { AccountContext } from '../../contexts/AccountContext'; +import { AuthContext } from '../../contexts/AuthContext'; +import { AnalyticsEvents, trackEvent } from '../../utils/analytics'; + +const GlassCard = styled.div` + background: ${({ theme }) => theme.colors.cardBackground}; + backdrop-filter: blur(10px); + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 1rem; + padding: 2rem; + width: 100%; + max-width: 1000px; + margin-bottom: 2rem; + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); +`; + +const CardTitle = styled.h2` + font-size: 1.8rem; + margin-bottom: 1rem; + color: ${({ theme }) => theme.colors.text}; + border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; + padding-bottom: 1rem; +`; + +const BodyText = styled.p` + color: ${({ theme }) => theme.colors.text}; + opacity: 0.75; + margin: 0 0 1rem 0; + line-height: 1.5; +`; + +const ErrorText = styled.p` + color: #ff6b6b; + margin: 0.75rem 0 0 0; + font-size: 0.95rem; +`; + +const ButtonRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +`; + +const DangerButton = styled.button` + background: transparent; + border: 1px solid #ff6b6b; + border-radius: 0.5rem; + color: #ff6b6b; + padding: 0.8rem 1.5rem; + font-weight: 600; + cursor: pointer; + + &:hover { + background: #ff6b6b22; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +const SecondaryButton = styled.button` + background: transparent; + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 0.5rem; + color: ${({ theme }) => theme.colors.text}; + padding: 0.8rem 1.5rem; + font-weight: 600; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +const ConfirmInput = styled.input` + width: 100%; + max-width: 420px; + background: ${({ theme }) => + theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'}; + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 0.5rem; + padding: 0.8rem; + color: ${({ theme }) => theme.colors.text}; + font-size: 1rem; + outline: none; + margin-top: 0.5rem; + + &:focus { + border-color: #ff6b6b; + } +`; + +const ModalBackdrop = styled.div` + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1.5rem; +`; + +const ModalCard = styled.div` + background: ${({ theme }) => theme.colors.cardBackground}; + backdrop-filter: blur(12px); + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 1rem; + padding: 1.75rem; + max-width: 520px; + width: 100%; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); +`; + +const ModalTitle = styled.h3` + margin: 0 0 0.75rem 0; + color: ${({ theme }) => theme.colors.text}; + font-size: 1.35rem; +`; + +function apiErrorMessage(error: unknown, fallback: string): string { + const axiosError = error as { + response?: { data?: { detail?: string } }; + message?: string; + }; + return axiosError.response?.data?.detail || axiosError.message || fallback; +} + +const DeleteAccountSection = (): JSX.Element => { + const { account, setAccount } = useContext(AccountContext); + const { setAuthentication } = useContext(AuthContext); + const navigate = useNavigate(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmEmail, setConfirmEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const email = account?.email || ''; + + const closeModal = () => { + if (loading) return; + setConfirmOpen(false); + setConfirmEmail(''); + setError(''); + }; + + const handleDelete = async () => { + if (!email || confirmEmail.trim().toLowerCase() !== email.toLowerCase()) { + setError('Type your account email exactly to confirm.'); + return; + } + + setLoading(true); + setError(''); + trackEvent(AnalyticsEvents.ACCOUNT_DELETE_STARTED); + + try { + const refreshToken = getRefreshToken(); + await axiosInstance.delete('/user/', { + data: refreshToken ? { refresh_token: refreshToken } : {}, + }); + trackEvent(AnalyticsEvents.ACCOUNT_DELETE_SUCCESS); + try { + if (refreshToken) { + await axiosInstance.post('blacklist/', { refresh_token: refreshToken }); + } + } catch { + // Account already deleted; local cleanup is enough. + } + await clearTokens(); + applyAccessToken(null); + setAuthentication(false); + setAccount(undefined); + navigate('/signin/'); + } catch (err: unknown) { + trackEvent(AnalyticsEvents.ACCOUNT_DELETE_FAILED); + setError(apiErrorMessage(err, 'Could not delete account. Try again.')); + setLoading(false); + } + }; + + return ( + <> + + Delete account + + Permanently deactivate your account. Conversations are hidden and you will + be signed out. This cannot be undone from the app. + + setConfirmOpen(true)}> + Delete my account + + + + {confirmOpen ? ( + + event.stopPropagation()} + > + Delete your account? + + Type {email || 'your email'} to confirm. You will lose + access immediately after deletion. + + setConfirmEmail(event.target.value)} + disabled={loading} + aria-label="Confirm email" + /> + {error ? {error} : null} + + + {loading ? 'Deleting…' : 'Delete account'} + + + Keep account + + + + + ) : null} + + ); +}; + +export default DeleteAccountSection; diff --git a/llm-fe/src/llm-fe/components/UsageSummaryCard/UsageSummaryCard.tsx b/llm-fe/src/llm-fe/components/UsageSummaryCard/UsageSummaryCard.tsx index 703838c..b0274dc 100644 --- a/llm-fe/src/llm-fe/components/UsageSummaryCard/UsageSummaryCard.tsx +++ b/llm-fe/src/llm-fe/components/UsageSummaryCard/UsageSummaryCard.tsx @@ -1,11 +1,15 @@ import React, { useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; import { axiosInstance } from '../../../axiosApi'; +import { AnalyticsEvents, trackEvent } from '../../utils/analytics'; import { + canOpenBillingPortal, formatBillingDate, formatTokenCount, + isComplimentarySubscription, SubscriptionMe, } from '../../utils/finance'; +import type { FinanceInvoice } from '../../utils/finance'; const GlassCard = styled.div` background: ${({ theme }) => theme.colors.cardBackground}; @@ -106,6 +110,27 @@ const UpgradeHint = styled.p` line-height: 1.45; `; +const UpgradeButton = styled.button` + margin-top: 1rem; + background: ${({ theme }) => theme.main}; + border: none; + border-radius: 0.5rem; + color: #fff; + padding: 0.7rem 1.25rem; + font-weight: 600; + cursor: pointer; + + &:hover { + transform: translateY(-1px); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } +`; + function clampPct(used: number, quota: number): number { if (quota <= 0) return 0; return Math.min(100, Math.round((used / quota) * 1000) / 10); @@ -141,15 +166,26 @@ const UsageMeter = ({ label, meta, usedLabel, pct }: MeterProps): JSX.Element => const UsageSummaryCard = (): JSX.Element => { const [subscription, setSubscription] = useState(null); + const [hasPortalAccess, setHasPortalAccess] = useState(false); const [loading, setLoading] = useState(true); + const [upgradeLoading, setUpgradeLoading] = useState(false); const load = useCallback(async () => { setLoading(true); try { - const response = await axiosInstance.get('/finance/subscription/'); - setSubscription(response.data || null); + const [subscriptionResponse, invoiceResponse] = await Promise.all([ + axiosInstance.get('/finance/subscription/'), + axiosInstance.get('/finance/invoices/'), + ]); + setSubscription(subscriptionResponse.data || null); + setHasPortalAccess( + canOpenBillingPortal( + Array.isArray(invoiceResponse.data) ? invoiceResponse.data : [] + ) + ); } catch { setSubscription(null); + setHasPortalAccess(false); } finally { setLoading(false); } @@ -174,6 +210,39 @@ const UsageSummaryCard = (): JSX.Element => { ? 0 : null; + const complimentary = isComplimentarySubscription(subscription, hasPortalAccess); + const showUpgrade = Boolean(subscription?.plan) && hasPortalAccess && !complimentary; + + const handleUpgrade = async () => { + trackEvent(AnalyticsEvents.SUBSCRIPTION_UPGRADE_STARTED, { + source: 'usage_card', + }); + const billing = document.querySelector('[data-testid="billing-section"]'); + if (billing) { + billing.scrollIntoView({ behavior: 'smooth', block: 'start' }); + const buttons = Array.from(billing.querySelectorAll('button')); + const upgrade = buttons.find((btn) => + /upgrade/i.test(btn.textContent || '') + ); + upgrade?.focus(); + return; + } + setUpgradeLoading(true); + try { + const returnUrl = `${window.location.origin}/account/`; + const response = await axiosInstance.post<{ portal_url: string }>( + '/finance/portal/', + { return_url: returnUrl } + ); + if (response.data?.portal_url) { + trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED, { intent: 'upgrade' }); + window.location.assign(response.data.portal_url); + } + } finally { + setUpgradeLoading(false); + } + }; + return ( Usage limit @@ -224,9 +293,27 @@ const UsageSummaryCard = (): JSX.Element => { /> )} - - Need more capacity? Manage or upgrade your plan in Billing below. - + {showUpgrade ? ( + <> + + Need more capacity? Upgrade your plan in Billing, or open upgrade + below. + + + {upgradeLoading ? 'Opening…' : 'Upgrade'} + + + ) : complimentary ? ( + Complimentary access — usage limits follow your granted plan. + ) : ( + + Need more capacity? Manage or upgrade your plan in Billing below. + + )} )} diff --git a/llm-fe/src/llm-fe/pages/Account2/Account2.tsx b/llm-fe/src/llm-fe/pages/Account2/Account2.tsx index 790b418..accb82f 100644 --- a/llm-fe/src/llm-fe/pages/Account2/Account2.tsx +++ b/llm-fe/src/llm-fe/pages/Account2/Account2.tsx @@ -11,6 +11,7 @@ import styled from "styled-components"; import ThemeSettingsCard from "../../components/ThemeSettingsCard/ThemeSettingsCard"; import UsageSummaryCard from "../../components/UsageSummaryCard/UsageSummaryCard"; import BillingSection from "../../components/BillingSection/BillingSection"; +import DeleteAccountSection from "../../components/DeleteAccountSection/DeleteAccountSection"; // Styled Components const PageContainer = styled.div` @@ -419,6 +420,7 @@ const AccountPage = (): JSX.Element => {

Account and prompt information will be available soon

)} + ) } diff --git a/llm-fe/src/llm-fe/utils/analytics.ts b/llm-fe/src/llm-fe/utils/analytics.ts index dacfe81..ef7abbd 100644 --- a/llm-fe/src/llm-fe/utils/analytics.ts +++ b/llm-fe/src/llm-fe/utils/analytics.ts @@ -20,7 +20,11 @@ const queue: QueuedCall[] = []; * | Conversation Created | New chat id assigned over WS | * | Message Sent | User submits prompt (no content) | * | ToS Acknowledged | POST acknowledge_tos succeeds | - * | Billing Portal Opened | When #33 portal CTA ships | + * | Billing Portal Opened | Account manage / portal CTAs | + * | Subscription Upgrade Started | Upgrade intent (#75) | + * | Plan Change Started | Change-plan intent (#75) | + * | Subscription Cancel Started | Cancel intent (#75) | + * | Account Delete Started / Success / Failed | Self-delete (#34 companion) | */ export const AnalyticsEvents = { LOGIN_SUCCESS: 'Login Success', @@ -36,6 +40,12 @@ export const AnalyticsEvents = { MESSAGE_SENT: 'Message Sent', TOS_ACKNOWLEDGED: 'ToS Acknowledged', BILLING_PORTAL_OPENED: 'Billing Portal Opened', + SUBSCRIPTION_UPGRADE_STARTED: 'Subscription Upgrade Started', + PLAN_CHANGE_STARTED: 'Plan Change Started', + SUBSCRIPTION_CANCEL_STARTED: 'Subscription Cancel Started', + ACCOUNT_DELETE_STARTED: 'Account Delete Started', + ACCOUNT_DELETE_SUCCESS: 'Account Delete Success', + ACCOUNT_DELETE_FAILED: 'Account Delete Failed', } as const; export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents]; diff --git a/llm-fe/src/llm-fe/utils/finance.ts b/llm-fe/src/llm-fe/utils/finance.ts index d712fb3..98f9e06 100644 --- a/llm-fe/src/llm-fe/utils/finance.ts +++ b/llm-fe/src/llm-fe/utils/finance.ts @@ -74,9 +74,44 @@ export type SubscriptionMe = { source: string; needs_checkout: boolean; stripe_subscription_id: string; + cancel_at_period_end?: boolean; + current_period_end?: string | null; usage: SubscriptionUsage; }; +export type SubscriptionSource = 'none' | 'stripe' | 'backer' | 'admin' | string; + +/** Complimentary / admin-granted access — no Stripe cancel/change. */ +export function isComplimentarySubscription( + subscription: Pick | null | undefined, + hasPortalAccess: boolean +): boolean { + if (!subscription) return false; + if (subscription.source === 'backer' || subscription.source === 'admin') return true; + return !hasPortalAccess && subscription.needs_checkout === false; +} + +/** Higher-priced selectable plans relative to the current plan. */ +export function higherSelectablePlans( + plans: SubscriptionPlanInfo[], + current: SubscriptionPlanInfo | null | undefined +): SubscriptionPlanInfo[] { + const selectable = plans.filter((p) => p.is_selectable); + if (!current) return selectable; + return selectable.filter( + (p) => + p.slug !== current.slug && + (p.price_cents > current.price_cents || p.sort_order > current.sort_order) + ); +} + +export function otherSelectablePlans( + plans: SubscriptionPlanInfo[], + currentSlug: string | null | undefined +): SubscriptionPlanInfo[] { + return plans.filter((p) => p.is_selectable && p.slug !== currentSlug); +} + /** Display provider-reported token counts; never invent 0 for missing usage. */ export function formatTokenCount(value: number | null | undefined): string { if (value === null || value === undefined) return '—';