From 312990a6fb5bbbc7d0b8a5440729d636c8971688 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Fri, 31 Jul 2026 05:48:10 -0500 Subject: [PATCH] Add Account Billing section with Stripe Customer Portal CTA. Surface plan summary and invoice history on /account/, and redirect to Stripe's hosted portal for subscription management (#33). --- llm-fe/ANALYTICS.md | 2 +- .../BillingSection/BillingSection.test.tsx | 198 +++++++++ .../BillingSection/BillingSection.tsx | 380 ++++++++++++++++++ llm-fe/src/llm-fe/pages/Account2/Account2.tsx | 2 + llm-fe/src/llm-fe/utils/finance.test.ts | 54 +++ llm-fe/src/llm-fe/utils/finance.ts | 87 ++++ 6 files changed, 722 insertions(+), 1 deletion(-) create mode 100644 llm-fe/src/llm-fe/components/BillingSection/BillingSection.test.tsx create mode 100644 llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx create mode 100644 llm-fe/src/llm-fe/utils/finance.test.ts create mode 100644 llm-fe/src/llm-fe/utils/finance.ts diff --git a/llm-fe/ANALYTICS.md b/llm-fe/ANALYTICS.md index 786b2b6..0c9067f 100644 --- a/llm-fe/ANALYTICS.md +++ b/llm-fe/ANALYTICS.md @@ -20,7 +20,7 @@ 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` | *(pending #33 Account portal CTA)* | — | +| Billing Portal Opened | `BILLING_PORTAL_OPENED` | BillingSection (Account) | — | ## 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 new file mode 100644 index 0000000..bcc3539 --- /dev/null +++ b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.test.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from 'styled-components'; +import BillingSection from './BillingSection'; + +const mockGet = jest.fn(); +const mockPost = jest.fn(); +const assignMock = jest.fn(); +const mockTrackEvent = jest.fn(); + +jest.mock('../../../axiosApi', () => ({ + axiosInstance: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + }, +})); + +jest.mock('../../utils/analytics', () => ({ + AnalyticsEvents: { + BILLING_PORTAL_OPENED: 'Billing Portal Opened', + CHECKOUT_STARTED: 'Checkout Started', + }, + 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 paidInvoice = { + id: 1, + provider: 'stripe', + status: 'paid', + currency: 'usd', + amount_due: 1000, + amount_paid: 1000, + period_start: '2026-07-01T00:00:00Z', + period_end: '2026-08-01T00:00:00Z', + stripe_invoice_id: 'in_test', + stripe_checkout_session_id: 'cs_test', + stripe_subscription_id: 'sub_test', + hosted_invoice_url: 'https://invoice.stripe.com/i/test', + description: 'Chat Subscription', + created: '2026-07-01T12:00:00Z', + last_modified: '2026-07-01T12:00:00Z', +}; + +const renderBilling = () => + render( + + + + ); + +describe('BillingSection', () => { + const originalLocation = window.location; + + beforeEach(() => { + mockGet.mockReset(); + mockPost.mockReset(); + assignMock.mockReset(); + mockTrackEvent.mockReset(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + assign: assignMock, + origin: 'http://localhost', + }, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + }); + + it('renders plan summary and invoice history from finance APIs', async () => { + mockGet.mockImplementation((url: string) => { + if (url === '/finance/invoices/') { + return Promise.resolve({ data: [paidInvoice] }); + } + if (url === '/finance/payments/') { + return Promise.resolve({ data: [] }); + } + return Promise.reject(new Error(`unexpected GET ${url}`)); + }); + + renderBilling(); + + expect(await screen.findByRole('button', { name: /Manage subscription/i })).toBeInTheDocument(); + expect(screen.getAllByText('Chat Subscription').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Paid').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('View')).toHaveAttribute( + 'href', + 'https://invoice.stripe.com/i/test' + ); + }); + + it('redirects to Stripe Customer Portal on manage billing', async () => { + mockGet.mockImplementation((url: string) => { + if (url === '/finance/invoices/') { + return Promise.resolve({ data: [paidInvoice] }); + } + return Promise.resolve({ data: [] }); + }); + mockPost.mockResolvedValue({ + data: { portal_url: 'https://billing.stripe.com/p/session/test' }, + }); + + const user = userEvent.setup(); + renderBilling(); + + await screen.findByRole('button', { name: /Manage subscription/i }); + await user.click(screen.getByRole('button', { name: /Manage subscription/i })); + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith('/finance/portal/', { + return_url: 'http://localhost/account/', + }); + }); + expect(mockTrackEvent).toHaveBeenCalledWith('Billing Portal Opened'); + expect(assignMock).toHaveBeenCalledWith( + 'https://billing.stripe.com/p/session/test' + ); + }); + + it('shows empty unpaid state and starts checkout', async () => { + mockGet.mockResolvedValue({ data: [] }); + mockPost.mockResolvedValue({ + data: { checkout_url: 'https://checkout.stripe.com/c/pay/cs_test' }, + }); + + const user = userEvent.setup(); + renderBilling(); + + expect( + await screen.findByText(/No active subscription yet/i) + ).toBeInTheDocument(); + expect(screen.getByText(/No invoices or payments yet/i)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /Complete payment/i })); + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith( + '/finance/checkout/', + expect.objectContaining({ + success_url: expect.stringContaining('/billing/success'), + cancel_url: expect.stringContaining('/billing/cancel'), + }) + ); + }); + expect(assignMock).toHaveBeenCalledWith( + 'https://checkout.stripe.com/c/pay/cs_test' + ); + }); + + it('shows visible error when finance list fails', async () => { + mockGet.mockRejectedValue({ + response: { data: { detail: 'Finance unavailable' } }, + }); + + renderBilling(); + + expect(await screen.findAllByRole('alert')).not.toHaveLength(0); + expect(screen.getAllByText('Finance unavailable').length).toBeGreaterThan(0); + }); + + it('shows visible error when portal open fails', async () => { + mockGet.mockImplementation((url: string) => { + if (url === '/finance/invoices/') { + return Promise.resolve({ data: [paidInvoice] }); + } + return Promise.resolve({ data: [] }); + }); + mockPost.mockRejectedValue({ + response: { data: { detail: 'No Stripe customer found' } }, + }); + + const user = userEvent.setup(); + renderBilling(); + + await screen.findByRole('button', { name: /Manage subscription/i }); + await user.click(screen.getByRole('button', { name: /Manage subscription/i })); + + expect(await screen.findByText('No Stripe customer found')).toBeInTheDocument(); + expect(assignMock).not.toHaveBeenCalled(); + }); +}); diff --git a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx new file mode 100644 index 0000000..35208b1 --- /dev/null +++ b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx @@ -0,0 +1,380 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { axiosInstance } from '../../../axiosApi'; +import { AnalyticsEvents, trackEvent } from '../../utils/analytics'; +import { + canOpenBillingPortal, + checkoutReturnUrls, + FinanceInvoice, + FinancePayment, + formatBillingDate, + formatMoneyCents, + humanizeStatus, + pickPrimaryInvoice, +} from '../../utils/finance'; + +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: 1.5rem; + color: ${({ theme }) => theme.colors.text}; + border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; + padding-bottom: 1rem; +`; + +const SettingRow = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 1rem 0; + border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; + + &:last-child { + border-bottom: none; + } +`; + +const SettingLabel = styled.span` + font-size: 1.1rem; + color: ${({ theme }) => theme.colors.text}; + font-weight: 500; + opacity: 0.75; +`; + +const SettingValue = styled.span` + font-size: 1.1rem; + color: ${({ theme }) => theme.colors.text}; + font-weight: 600; + text-align: right; +`; + +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: 1.25rem; +`; + +const StyledButton = styled.button` + background: ${({ theme }) => theme.main}; + border: none; + border-radius: 0.5rem; + color: #fff; + padding: 0.8rem 1.5rem; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px ${({ theme }) => theme.main}66; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; + } +`; + +const SecondaryButton = styled(StyledButton)` + background: transparent; + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + color: ${({ theme }) => theme.colors.text}; + + &:hover { + box-shadow: none; + border-color: ${({ theme }) => theme.main}; + } +`; + +const StyledTable = styled.table` + width: 100%; + border-collapse: collapse; + margin-top: 1rem; +`; + +const Th = styled.th` + text-align: left; + padding: 1rem; + border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; + color: ${({ theme }) => theme.colors.text}; + opacity: 0.7; + font-weight: 600; +`; + +const Td = styled.td` + padding: 1rem; + border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; + color: ${({ theme }) => theme.colors.text}; +`; + +const InvoiceLink = styled.a` + color: ${({ theme }) => theme.main}; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +`; + +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 BillingSection = (): JSX.Element => { + const [invoices, setInvoices] = useState([]); + const [payments, setPayments] = 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 loadBilling = useCallback(async () => { + setLoading(true); + setListError(''); + try { + const [invoiceResponse, paymentResponse] = await Promise.all([ + axiosInstance.get('/finance/invoices/'), + axiosInstance.get('/finance/payments/'), + ]); + setInvoices(Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []); + setPayments(Array.isArray(paymentResponse.data) ? paymentResponse.data : []); + } catch (error: unknown) { + setInvoices([]); + setPayments([]); + setListError(apiErrorMessage(error, 'Could not load billing information.')); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadBilling(); + }, [loadBilling]); + + const primaryInvoice = useMemo(() => pickPrimaryInvoice(invoices), [invoices]); + const hasPortalAccess = useMemo(() => canOpenBillingPortal(invoices), [invoices]); + + const historyRows = useMemo(() => { + if (invoices.length) { + return invoices.map((invoice) => ({ + key: `invoice-${invoice.id}`, + date: invoice.created, + description: invoice.description || 'Invoice', + amount: formatMoneyCents( + invoice.amount_paid || invoice.amount_due, + invoice.currency + ), + status: humanizeStatus(invoice.status), + url: invoice.hosted_invoice_url || '', + })); + } + return payments.map((payment) => ({ + key: `payment-${payment.id}`, + date: payment.paid_at || payment.created, + description: 'Payment', + amount: formatMoneyCents(payment.amount, payment.currency), + status: humanizeStatus(payment.status), + url: '', + })); + }, [invoices, payments]); + + const handleManageBilling = async () => { + setActionError(''); + setPortalLoading(true); + try { + const returnUrl = `${window.location.origin}/account/`; + const response = await axiosInstance.post<{ portal_url: string }>( + '/finance/portal/', + { return_url: returnUrl } + ); + const portalUrl = response.data?.portal_url; + if (!portalUrl) { + setActionError('Billing portal could not be opened. Try again.'); + return; + } + trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED); + window.location.assign(portalUrl); + } catch (error: unknown) { + setActionError( + apiErrorMessage(error, 'Could not open the billing portal. Try again.') + ); + } finally { + setPortalLoading(false); + } + }; + + const handleStartCheckout = async () => { + setActionError(''); + setCheckoutLoading(true); + try { + const { success_url, cancel_url } = checkoutReturnUrls(); + trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'account_billing' }); + const response = await axiosInstance.post<{ checkout_url: string }>( + '/finance/checkout/', + { success_url, cancel_url } + ); + const checkoutUrl = response.data?.checkout_url; + if (!checkoutUrl) { + setActionError('Checkout could not be started. Try again.'); + return; + } + window.location.assign(checkoutUrl); + } catch (error: unknown) { + setActionError(apiErrorMessage(error, 'Could not start checkout. Try again.')); + } finally { + setCheckoutLoading(false); + } + }; + + return ( + <> + + Billing + {loading ? ( + Loading billing information… + ) : listError ? ( + {listError} + ) : primaryInvoice ? ( + <> + + Plan + + {primaryInvoice.description || 'Chat Subscription'} + + + + Status + {humanizeStatus(primaryInvoice.status)} + + + Amount + + {formatMoneyCents( + primaryInvoice.amount_paid || primaryInvoice.amount_due, + primaryInvoice.currency + )} + {primaryInvoice.period_end ? ' / period' : ''} + + + + Period end + + {formatBillingDate(primaryInvoice.period_end)} + + + + ) : ( + + No active subscription yet. Complete payment to unlock billing management + and invoice history. + + )} + + {!loading && !listError && ( + + {hasPortalAccess ? ( + + {portalLoading ? 'Opening…' : 'Manage subscription'} + + ) : ( + + {checkoutLoading ? 'Starting…' : 'Complete payment'} + + )} + + Refresh + + + )} + {actionError ? {actionError} : null} + + + + Billing history + {loading ? ( + Loading history… + ) : listError ? ( + {listError} + ) : historyRows.length === 0 ? ( + No invoices or payments yet. + ) : ( +
+ + + + Date + Description + Amount + Status + Invoice + + + + {historyRows.map((row) => ( + + {formatBillingDate(row.date)} + {row.description} + {row.amount} + {row.status} + + {row.url ? ( + + View + + ) : ( + '—' + )} + + + ))} + + +
+ )} +
+ + ); +}; + +export default BillingSection; diff --git a/llm-fe/src/llm-fe/pages/Account2/Account2.tsx b/llm-fe/src/llm-fe/pages/Account2/Account2.tsx index 087d36f..585d82f 100644 --- a/llm-fe/src/llm-fe/pages/Account2/Account2.tsx +++ b/llm-fe/src/llm-fe/pages/Account2/Account2.tsx @@ -9,6 +9,7 @@ import Header2 from "../../components/Header2/Header2"; import ParticleBackground from "../../components/ParticleBackground/ParticleBackground"; import styled from "styled-components"; import ThemeSettingsCard from "../../components/ThemeSettingsCard/ThemeSettingsCard"; +import BillingSection from "../../components/BillingSection/BillingSection"; // Styled Components const PageContainer = styled.div` @@ -407,6 +408,7 @@ const AccountPage = (): JSX.Element => { return ( <> + {account?.is_company_manager ? ( ) : ( diff --git a/llm-fe/src/llm-fe/utils/finance.test.ts b/llm-fe/src/llm-fe/utils/finance.test.ts new file mode 100644 index 0000000..8617760 --- /dev/null +++ b/llm-fe/src/llm-fe/utils/finance.test.ts @@ -0,0 +1,54 @@ +import { + canOpenBillingPortal, + formatMoneyCents, + humanizeStatus, + pickPrimaryInvoice, +} from './finance'; +import type { FinanceInvoice } from './finance'; + +const baseInvoice = (overrides: Partial = {}): FinanceInvoice => ({ + id: 1, + provider: 'stripe', + status: 'open', + currency: 'usd', + amount_due: 1000, + amount_paid: 0, + period_start: null, + period_end: null, + stripe_invoice_id: null, + stripe_checkout_session_id: 'cs_1', + stripe_subscription_id: null, + hosted_invoice_url: '', + description: 'Chat Subscription', + created: '2026-07-01T00:00:00Z', + last_modified: '2026-07-01T00:00:00Z', + ...overrides, +}); + +describe('finance helpers', () => { + it('formats money in cents', () => { + expect(formatMoneyCents(1000, 'usd')).toMatch(/10/); + }); + + it('humanizes status labels', () => { + expect(humanizeStatus('past_due')).toBe('Past Due'); + }); + + it('prefers subscription-backed invoices for plan summary', () => { + const invoices = [ + baseInvoice({ id: 1, status: 'open' }), + baseInvoice({ + id: 2, + status: 'paid', + stripe_subscription_id: 'sub_1', + description: 'Active Plan', + }), + ]; + expect(pickPrimaryInvoice(invoices)?.description).toBe('Active Plan'); + expect(canOpenBillingPortal(invoices)).toBe(true); + }); + + it('denies portal when no paid or subscription invoice', () => { + expect(canOpenBillingPortal([baseInvoice()])).toBe(false); + }); +}); diff --git a/llm-fe/src/llm-fe/utils/finance.ts b/llm-fe/src/llm-fe/utils/finance.ts new file mode 100644 index 0000000..44e97eb --- /dev/null +++ b/llm-fe/src/llm-fe/utils/finance.ts @@ -0,0 +1,87 @@ +export type FinanceInvoice = { + id: number; + provider: string; + status: string; + currency: string; + amount_due: number; + amount_paid: number; + period_start: string | null; + period_end: string | null; + stripe_invoice_id: string | null; + stripe_checkout_session_id: string | null; + stripe_subscription_id: string | null; + hosted_invoice_url: string; + description: string; + created: string; + last_modified: string; +}; + +export type FinancePayment = { + id: number; + invoice: number | null; + provider: string; + status: string; + currency: string; + amount: number; + stripe_payment_intent_id: string | null; + stripe_charge_id: string | null; + paid_at: string | null; + failure_message: string; + created: string; + last_modified: string; +}; + +export function formatMoneyCents(amountCents: number, currency: string): string { + const code = (currency || 'usd').toUpperCase(); + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: code, + }).format(amountCents / 100); + } catch { + return `${(amountCents / 100).toFixed(2)} ${code}`; + } +} + +export function formatBillingDate(value: string | null | undefined): string { + if (!value) return '—'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +export function humanizeStatus(status: string): string { + if (!status) return 'Unknown'; + return status + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +/** Prefer paid / subscription-backed invoices for the plan summary. */ +export function pickPrimaryInvoice(invoices: FinanceInvoice[]): FinanceInvoice | null { + if (!invoices.length) return null; + const withSub = invoices.find((inv) => Boolean(inv.stripe_subscription_id)); + if (withSub) return withSub; + const paid = invoices.find((inv) => inv.status === 'paid'); + if (paid) return paid; + return invoices[0]; +} + +export function canOpenBillingPortal(invoices: FinanceInvoice[]): boolean { + return invoices.some( + (inv) => Boolean(inv.stripe_subscription_id) || inv.status === 'paid' + ); +} + +export function checkoutReturnUrls(): { success_url: string; cancel_url: string } { + const origin = window.location.origin; + return { + success_url: `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${origin}/billing/cancel`, + }; +} -- 2.54.0