Add Account Billing section + Stripe Customer Portal CTA (#33) (#71)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 1m58s

## Summary
- Closes [#33](#33)
- Add Billing glass cards on `/account/` (`Account2`): current plan summary + billing history from `GET /finance/invoices/` (and payments fallback)
- **Manage subscription** CTA → `POST /finance/portal/` → redirect to Stripe hosted Customer Portal (return URL `/account/`)
- Empty / unpaid users get **Complete payment** → Checkout instead of a dead portal button
- Visible errors for list/portal failures; fire `BILLING_PORTAL_OPENED`
- Depends on backend portal endpoint: [chat_backend#35](ai_ml_operations/chat_backend#35)

## Test plan
- [ ] `npm test -- --watchAll=false --testPathPattern='(BillingSection|finance\\.test)'`
- [ ] Signed-in user with paid invoices: plan + history render; Manage subscription redirects to portal URL
- [ ] User with no invoices: empty copy + Complete payment starts Checkout
- [ ] Portal/list API errors show alert text (no silent fail)
- [ ] Return from Stripe portal lands on `/account/`
- [ ] No card fields collected in-appReviewed-on: #71
This commit was merged in pull request #71.
This commit is contained in:
2026-07-31 03:54:07 -07:00
parent 553d526862
commit 6a6e911ef8
6 changed files with 722 additions and 1 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta.
| Conversation Created | `CONVERSATION_CREATED` | MessageContext | `{ conversationId }` | | Conversation Created | `CONVERSATION_CREATED` | MessageContext | `{ conversationId }` |
| Message Sent | `MESSAGE_SENT` | AsyncDashboard2 | `{ hasConversation, hasAttachment }` | | Message Sent | `MESSAGE_SENT` | AsyncDashboard2 | `{ hasConversation, hasAttachment }` |
| ToS Acknowledged | `TOS_ACKNOWLEDGED` | TermsOfService | — | | 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 ## Identify
@@ -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(
<ThemeProvider theme={theme}>
<BillingSection />
</ThemeProvider>
);
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();
});
});
@@ -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<FinanceInvoice[]>([]);
const [payments, setPayments] = useState<FinancePayment[]>([]);
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<FinanceInvoice[]>('/finance/invoices/'),
axiosInstance.get<FinancePayment[]>('/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 (
<>
<GlassCard data-testid="billing-section">
<CardTitle>Billing</CardTitle>
{loading ? (
<BodyText>Loading billing information</BodyText>
) : listError ? (
<ErrorText role="alert">{listError}</ErrorText>
) : primaryInvoice ? (
<>
<SettingRow>
<SettingLabel>Plan</SettingLabel>
<SettingValue>
{primaryInvoice.description || 'Chat Subscription'}
</SettingValue>
</SettingRow>
<SettingRow>
<SettingLabel>Status</SettingLabel>
<SettingValue>{humanizeStatus(primaryInvoice.status)}</SettingValue>
</SettingRow>
<SettingRow>
<SettingLabel>Amount</SettingLabel>
<SettingValue>
{formatMoneyCents(
primaryInvoice.amount_paid || primaryInvoice.amount_due,
primaryInvoice.currency
)}
{primaryInvoice.period_end ? ' / period' : ''}
</SettingValue>
</SettingRow>
<SettingRow>
<SettingLabel>Period end</SettingLabel>
<SettingValue>
{formatBillingDate(primaryInvoice.period_end)}
</SettingValue>
</SettingRow>
</>
) : (
<BodyText>
No active subscription yet. Complete payment to unlock billing management
and invoice history.
</BodyText>
)}
{!loading && !listError && (
<ButtonRow>
{hasPortalAccess ? (
<StyledButton
type="button"
onClick={handleManageBilling}
disabled={portalLoading}
>
{portalLoading ? 'Opening…' : 'Manage subscription'}
</StyledButton>
) : (
<StyledButton
type="button"
onClick={handleStartCheckout}
disabled={checkoutLoading}
>
{checkoutLoading ? 'Starting…' : 'Complete payment'}
</StyledButton>
)}
<SecondaryButton type="button" onClick={loadBilling} disabled={loading}>
Refresh
</SecondaryButton>
</ButtonRow>
)}
{actionError ? <ErrorText role="alert">{actionError}</ErrorText> : null}
</GlassCard>
<GlassCard data-testid="billing-history">
<CardTitle>Billing history</CardTitle>
{loading ? (
<BodyText>Loading history</BodyText>
) : listError ? (
<ErrorText role="alert">{listError}</ErrorText>
) : historyRows.length === 0 ? (
<BodyText>No invoices or payments yet.</BodyText>
) : (
<div style={{ overflowX: 'auto' }}>
<StyledTable>
<thead>
<tr>
<Th>Date</Th>
<Th>Description</Th>
<Th>Amount</Th>
<Th>Status</Th>
<Th>Invoice</Th>
</tr>
</thead>
<tbody>
{historyRows.map((row) => (
<tr key={row.key}>
<Td>{formatBillingDate(row.date)}</Td>
<Td>{row.description}</Td>
<Td>{row.amount}</Td>
<Td>{row.status}</Td>
<Td>
{row.url ? (
<InvoiceLink href={row.url} target="_blank" rel="noreferrer">
View
</InvoiceLink>
) : (
'—'
)}
</Td>
</tr>
))}
</tbody>
</StyledTable>
</div>
)}
</GlassCard>
</>
);
};
export default BillingSection;
@@ -9,6 +9,7 @@ import Header2 from "../../components/Header2/Header2";
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground"; import ParticleBackground from "../../components/ParticleBackground/ParticleBackground";
import styled from "styled-components"; import styled from "styled-components";
import ThemeSettingsCard from "../../components/ThemeSettingsCard/ThemeSettingsCard"; import ThemeSettingsCard from "../../components/ThemeSettingsCard/ThemeSettingsCard";
import BillingSection from "../../components/BillingSection/BillingSection";
// Styled Components // Styled Components
const PageContainer = styled.div` const PageContainer = styled.div`
@@ -407,6 +408,7 @@ const AccountPage = (): JSX.Element => {
return ( return (
<> <>
<ThemeSettingsCard /> <ThemeSettingsCard />
<BillingSection />
{account?.is_company_manager ? ( {account?.is_company_manager ? (
<CompanyManagerCard /> <CompanyManagerCard />
) : ( ) : (
+54
View File
@@ -0,0 +1,54 @@
import {
canOpenBillingPortal,
formatMoneyCents,
humanizeStatus,
pickPrimaryInvoice,
} from './finance';
import type { FinanceInvoice } from './finance';
const baseInvoice = (overrides: Partial<FinanceInvoice> = {}): 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);
});
});
+87
View File
@@ -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`,
};
}