Add subscription upgrade/change/cancel UX and account self-delete (#75)
Unit Tests / test (pull_request) Successful in 11s
Unit Tests / test (pull_request) Successful in 11s
Surface Upgrade, Change plan, and Cancel on Account billing (portal-first), plan picker when multiple public plans exist, complimentary-safe messaging, and Danger Zone self-delete against DELETE /user/ (backend #34).
This commit is contained in:
+7
-1
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 '—';
|
||||
|
||||
Reference in New Issue
Block a user