Add Account Billing section with Stripe Customer Portal CTA.
Unit Tests / test (pull_request) Successful in 11s
Unit Tests / test (pull_request) Successful in 11s
Surface plan summary and invoice history on /account/, and redirect to Stripe's hosted portal for subscription management (#33).
This commit is contained in:
+1
-1
@@ -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 />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user