Add subscription upgrade/change/cancel UX and account self-delete (#75) (#76)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 1m55s

## Summary
- Closes [#75](#75)
- Companion for [chat_backend#34](ai_ml_operations/chat_backend#34) (self-delete API)
- Account Billing: Upgrade / Change plan / Cancel CTAs (Stripe portal–first), plan picker when multiple public selectable plans exist, cancel confirmation with period-end messaging, complimentary users get no fake cancel
- Usage card Upgrade scrolls/focuses Billing; analytics for upgrade/change/cancel intents
- Danger Zone: email-confirmed `DELETE /user/` then logout → `/signin/`

## Test plan
- [ ] `npm test -- --watchAll=false --testPathPattern='BillingSection|DeleteAccountSection'`
- [ ] Paid Stripe user: Upgrade / Change plan / Cancel → portal (or checkout for higher selectable plan)
- [ ] Cancel confirm shows period-end access copy; scheduled cancel notice when `cancel_at_period_end`
- [ ] Backer/admin: complimentary message, no Cancel/Upgrade
- [ ] Delete account: confirm email → soft-delete → signed out at `/signin/`Reviewed-on: #76
This commit was merged in pull request #76.
This commit is contained in:
2026-08-01 12:24:10 -07:00
parent 5a847b64cc
commit 19698658b1
9 changed files with 936 additions and 66 deletions
+7 -1
View File
@@ -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
@@ -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({
@@ -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<FinanceInvoice[]>([]);
const [payments, setPayments] = useState<FinancePayment[]>([]);
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
const [plans, setPlans] = useState<SubscriptionPlanInfo[]>([]);
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<string | null>(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<FinanceInvoice[]>('/finance/invoices/'),
axiosInstance.get<FinancePayment[]>('/finance/payments/'),
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
]);
const [invoiceResponse, paymentResponse, subscriptionResponse, plansResponse] =
await Promise.all([
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'),
axiosInstance.get<FinancePayment[]>('/finance/payments/'),
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
axiosInstance.get<SubscriptionPlanInfo[]>('/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 (
<>
<GlassCard data-testid="billing-section">
@@ -331,13 +501,19 @@ const BillingSection = (): JSX.Element => {
</SettingRow>
</>
) : null}
{primaryInvoice ? (
<SettingRow>
<SettingLabel>Period end</SettingLabel>
<SettingValue>
{formatBillingDate(primaryInvoice.period_end)}
</SettingValue>
</SettingRow>
<SettingRow>
<SettingLabel>Period end</SettingLabel>
<SettingValue>{periodEndLabel}</SettingValue>
</SettingRow>
{subscription?.cancel_at_period_end ? (
<NoticeText data-testid="cancel-scheduled-notice">
Cancellation scheduled. You keep access until {periodEndLabel}.
</NoticeText>
) : null}
{subscription?.status === 'canceled' ? (
<NoticeText>
Subscription canceled. Renew via Complete payment when you are ready.
</NoticeText>
) : null}
</>
) : (
@@ -349,25 +525,52 @@ const BillingSection = (): JSX.Element => {
{!loading && !listError && (
<ButtonRow>
{hasPortalAccess ? (
<StyledButton
type="button"
onClick={handleManageBilling}
disabled={portalLoading}
>
{portalLoading ? 'Opening…' : 'Manage subscription'}
</StyledButton>
) : subscription?.needs_checkout === false ? (
<BodyText style={{ margin: 0 }}>
Complimentary access no payment required.
{hasPortalAccess && !complimentary ? (
<>
<StyledButton
type="button"
onClick={handleUpgradeClick}
disabled={portalLoading || Boolean(checkoutLoadingSlug)}
>
Upgrade
</StyledButton>
<SecondaryButton
type="button"
onClick={handleChangePlanClick}
disabled={portalLoading || Boolean(checkoutLoadingSlug)}
>
Change plan
</SecondaryButton>
{!subscription?.cancel_at_period_end &&
subscription?.status !== 'canceled' ? (
<DangerButton
type="button"
onClick={() => setCancelConfirmOpen(true)}
disabled={portalLoading}
>
Cancel
</DangerButton>
) : null}
<SecondaryButton
type="button"
onClick={() => openPortal('manage')}
disabled={portalLoading}
>
{portalLoading ? 'Opening…' : 'Manage subscription'}
</SecondaryButton>
</>
) : complimentary ? (
<BodyText style={{ margin: 0 }} data-testid="complimentary-billing-message">
Complimentary access no payment required. Plan changes and
cancellation are not available for this account.
</BodyText>
) : (
<StyledButton
type="button"
onClick={handleStartCheckout}
disabled={checkoutLoading}
onClick={() => handleStartCheckout()}
disabled={Boolean(checkoutLoadingSlug)}
>
{checkoutLoading ? 'Starting…' : 'Complete payment'}
{checkoutLoadingSlug ? 'Starting…' : 'Complete payment'}
</StyledButton>
)}
<SecondaryButton type="button" onClick={loadBilling} disabled={loading}>
@@ -375,6 +578,52 @@ const BillingSection = (): JSX.Element => {
</SecondaryButton>
</ButtonRow>
)}
{showPlanPicker && pickerPlans.length > 0 ? (
<div data-testid="plan-picker">
<BodyText style={{ marginTop: '1.25rem', marginBottom: 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).'}
</BodyText>
<PlanList>
{pickerPlans.map((plan) => (
<PlanOption key={plan.slug}>
<PlanMeta>
<PlanName>{plan.name}</PlanName>
<PlanDesc>
{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`}
</PlanDesc>
</PlanMeta>
<StyledButton
type="button"
onClick={() => selectPlanFromPicker(plan)}
disabled={
portalLoading ||
checkoutLoadingSlug === plan.slug ||
checkoutLoadingSlug === '__default__'
}
>
{checkoutLoadingSlug === plan.slug
? 'Starting…'
: planPickerMode === 'upgrade'
? 'Upgrade'
: 'Select'}
</StyledButton>
</PlanOption>
))}
</PlanList>
<ButtonRow>
<SecondaryButton type="button" onClick={() => setShowPlanPicker(false)}>
Close
</SecondaryButton>
</ButtonRow>
</div>
) : null}
{actionError ? <ErrorText role="alert">{actionError}</ErrorText> : null}
</GlassCard>
@@ -421,6 +670,44 @@ const BillingSection = (): JSX.Element => {
</div>
)}
</GlassCard>
{cancelConfirmOpen ? (
<ModalBackdrop
role="presentation"
onClick={() => setCancelConfirmOpen(false)}
data-testid="cancel-confirm-modal"
>
<ModalCard
role="dialog"
aria-modal="true"
aria-labelledby="cancel-subscription-title"
onClick={(event) => event.stopPropagation()}
>
<ModalTitle id="cancel-subscription-title">Cancel subscription?</ModalTitle>
<BodyText>
You will finish canceling in the Stripe customer portal. Access typically
continues until the end of the current billing period
{periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.
</BodyText>
<ButtonRow>
<DangerButton
type="button"
onClick={handleConfirmCancel}
disabled={portalLoading}
>
{portalLoading ? 'Opening…' : 'Continue to cancel'}
</DangerButton>
<SecondaryButton
type="button"
onClick={() => setCancelConfirmOpen(false)}
disabled={portalLoading}
>
Keep subscription
</SecondaryButton>
</ButtonRow>
</ModalCard>
</ModalBackdrop>
) : null}
</>
);
};
@@ -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(
<MemoryRouter>
<ThemeProvider theme={theme}>
<AuthContext.Provider
value={{
authenticated: true,
setAuthentication: mockSetAuthentication,
needsNewPassword: false,
setNeedsNewPassword: () => {},
loading: false,
}}
>
<AccountContext.Provider
value={{
account: { email: 'user@example.com' } as never,
setAccount: mockSetAccount,
}}
>
<DeleteAccountSection />
</AccountContext.Provider>
</AuthContext.Provider>
</ThemeProvider>
</MemoryRouter>
);
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/');
});
});
@@ -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 (
<>
<GlassCard data-testid="delete-account-section">
<CardTitle>Delete account</CardTitle>
<BodyText>
Permanently deactivate your account. Conversations are hidden and you will
be signed out. This cannot be undone from the app.
</BodyText>
<DangerButton type="button" onClick={() => setConfirmOpen(true)}>
Delete my account
</DangerButton>
</GlassCard>
{confirmOpen ? (
<ModalBackdrop
role="presentation"
onClick={closeModal}
data-testid="delete-account-modal"
>
<ModalCard
role="dialog"
aria-modal="true"
aria-labelledby="delete-account-title"
onClick={(event) => event.stopPropagation()}
>
<ModalTitle id="delete-account-title">Delete your account?</ModalTitle>
<BodyText>
Type <strong>{email || 'your email'}</strong> to confirm. You will lose
access immediately after deletion.
</BodyText>
<ConfirmInput
type="email"
autoComplete="off"
placeholder="Confirm email"
value={confirmEmail}
onChange={(event) => setConfirmEmail(event.target.value)}
disabled={loading}
aria-label="Confirm email"
/>
{error ? <ErrorText role="alert">{error}</ErrorText> : null}
<ButtonRow>
<DangerButton
type="button"
onClick={handleDelete}
disabled={loading || !confirmEmail}
>
{loading ? 'Deleting…' : 'Delete account'}
</DangerButton>
<SecondaryButton type="button" onClick={closeModal} disabled={loading}>
Keep account
</SecondaryButton>
</ButtonRow>
</ModalCard>
</ModalBackdrop>
) : null}
</>
);
};
export default DeleteAccountSection;
@@ -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<SubscriptionMe | null>(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<SubscriptionMe>('/finance/subscription/');
setSubscription(response.data || null);
const [subscriptionResponse, invoiceResponse] = await Promise.all([
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
axiosInstance.get<FinanceInvoice[]>('/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 (
<GlassCard data-testid="usage-summary-card">
<CardTitle>Usage limit</CardTitle>
@@ -224,9 +293,27 @@ const UsageSummaryCard = (): JSX.Element => {
/>
)}
<UpgradeHint>
Need more capacity? Manage or upgrade your plan in Billing below.
</UpgradeHint>
{showUpgrade ? (
<>
<UpgradeHint>
Need more capacity? Upgrade your plan in Billing, or open upgrade
below.
</UpgradeHint>
<UpgradeButton
type="button"
onClick={handleUpgrade}
disabled={upgradeLoading}
>
{upgradeLoading ? 'Opening…' : 'Upgrade'}
</UpgradeButton>
</>
) : complimentary ? (
<UpgradeHint>Complimentary access usage limits follow your granted plan.</UpgradeHint>
) : (
<UpgradeHint>
Need more capacity? Manage or upgrade your plan in Billing below.
</UpgradeHint>
)}
</>
)}
</GlassCard>
@@ -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 => {
<p style={{ color: 'rgba(255,255,255,0.7)' }}>Account and prompt information will be available soon</p>
</GlassCard>
)}
<DeleteAccountSection />
</>
)
}
+11 -1
View File
@@ -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];
+35
View File
@@ -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<SubscriptionMe, 'source' | 'needs_checkout'> | 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 '—';