Show plan quotas and token usage in chat + billing UI (#72)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 10s
Deploy Beta / deploy-beta (push) Failing after 1m50s

## Summary
Frontend companion to backend [#16](ai_ml_operations/chat_backend#16) / [#17](ai_ml_operations/chat_backend#17) / [#36](ai_ml_operations/chat_backend#36).

- **Token display (#16)**: assistant messages show `Tokens in … · out …`; missing usage renders as `—` (never fabricated 0).
- **Plan + quota UI (#17/#36)**: Account `BillingSection` loads `GET /finance/subscription/` (plan name, prompt remaining, period tokens). Chat dashboard `UsageSummaryBar` shows the same snapshot.
- **Backer path**: Sign-up skips Stripe checkout when `needs_checkout` is false.

Requires backend PR on `feature/plans-quotas-token-usage`.

## Test plan
- [ ] With subscribed user: usage bar + billing show plan and prompt remaining
- [ ] Assistant messages with null tokens show `—`; known counts render numbers
- [ ] Backer signup does not redirect to Stripe
- [ ] Founders signup still starts checkout
- [ ] Jest: `BillingSection`, `ConversationDetailCard`, `finance` helpers passReviewed-on: #72
This commit was merged in pull request #72.
This commit is contained in:
2026-07-31 04:24:14 -07:00
parent 6a6e911ef8
commit e89ccbbeca
11 changed files with 413 additions and 53 deletions
@@ -52,6 +52,87 @@ 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,
},
status: 'active',
source: 'stripe',
needs_checkout: false,
stripe_subscription_id: 'sub_test',
usage: {
prompts_in_window: 2,
prompt_quota: 300,
prompts_remaining: 298,
window_hours: 6,
tokens_in_period: null,
tokens_out_period: null,
tokens_total_period: null,
turns_missing_token_usage: 0,
monthly_token_quota: null,
tokens_remaining: null,
period_start: null,
period_end: null,
},
};
const emptySubscription = {
plan: null,
status: 'none',
source: 'none',
needs_checkout: true,
stripe_subscription_id: '',
usage: {
prompts_in_window: 0,
prompt_quota: null,
prompts_remaining: null,
window_hours: 6,
tokens_in_period: null,
tokens_out_period: null,
tokens_total_period: null,
turns_missing_token_usage: 0,
monthly_token_quota: null,
tokens_remaining: null,
period_start: null,
period_end: null,
},
};
const mockFinanceGets = ({
invoices = [] as unknown[],
payments = [] as unknown[],
subscription = emptySubscription,
} = {}) => {
mockGet.mockImplementation((url: string) => {
if (url === '/finance/invoices/') {
return Promise.resolve({ data: invoices });
}
if (url === '/finance/payments/') {
return Promise.resolve({ data: payments });
}
if (url === '/finance/subscription/') {
return Promise.resolve({ data: subscription });
}
return Promise.reject(new Error(`unexpected GET ${url}`));
});
};
const renderBilling = () =>
render(
<ThemeProvider theme={theme}>
@@ -85,21 +166,17 @@ describe('BillingSection', () => {
});
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}`));
mockFinanceGets({
invoices: [paidInvoice],
subscription: foundersSubscription,
});
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('Founders')).toBeInTheDocument();
expect(screen.getByText(/298 \/ 300/)).toBeInTheDocument();
expect(screen.getByText(/in — · out —/)).toBeInTheDocument();
expect(screen.getByText('View')).toHaveAttribute(
'href',
'https://invoice.stripe.com/i/test'
@@ -107,11 +184,9 @@ describe('BillingSection', () => {
});
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: [] });
mockFinanceGets({
invoices: [paidInvoice],
subscription: foundersSubscription,
});
mockPost.mockResolvedValue({
data: { portal_url: 'https://billing.stripe.com/p/session/test' },
@@ -135,7 +210,7 @@ describe('BillingSection', () => {
});
it('shows empty unpaid state and starts checkout', async () => {
mockGet.mockResolvedValue({ data: [] });
mockFinanceGets();
mockPost.mockResolvedValue({
data: { checkout_url: 'https://checkout.stripe.com/c/pay/cs_test' },
});
@@ -176,11 +251,9 @@ describe('BillingSection', () => {
});
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: [] });
mockFinanceGets({
invoices: [paidInvoice],
subscription: foundersSubscription,
});
mockPost.mockRejectedValue({
response: { data: { detail: 'No Stripe customer found' } },
@@ -9,8 +9,10 @@ import {
FinancePayment,
formatBillingDate,
formatMoneyCents,
formatTokenCount,
humanizeStatus,
pickPrimaryInvoice,
SubscriptionMe,
} from '../../utils/finance';
const GlassCard = styled.div`
@@ -155,6 +157,7 @@ function apiErrorMessage(error: unknown, fallback: string): string {
const BillingSection = (): JSX.Element => {
const [invoices, setInvoices] = useState<FinanceInvoice[]>([]);
const [payments, setPayments] = useState<FinancePayment[]>([]);
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
const [loading, setLoading] = useState(true);
const [listError, setListError] = useState('');
const [actionError, setActionError] = useState('');
@@ -165,15 +168,18 @@ const BillingSection = (): JSX.Element => {
setLoading(true);
setListError('');
try {
const [invoiceResponse, paymentResponse] = await Promise.all([
const [invoiceResponse, paymentResponse, subscriptionResponse] = await Promise.all([
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'),
axiosInstance.get<FinancePayment[]>('/finance/payments/'),
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
]);
setInvoices(Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []);
setPayments(Array.isArray(paymentResponse.data) ? paymentResponse.data : []);
setSubscription(subscriptionResponse.data || null);
} catch (error: unknown) {
setInvoices([]);
setPayments([]);
setSubscription(null);
setListError(apiErrorMessage(error, 'Could not load billing information.'));
} finally {
setLoading(false);
@@ -267,18 +273,34 @@ const BillingSection = (): JSX.Element => {
<BodyText>Loading billing information</BodyText>
) : listError ? (
<ErrorText role="alert">{listError}</ErrorText>
) : primaryInvoice ? (
) : subscription?.plan || primaryInvoice ? (
<>
<SettingRow>
<SettingLabel>Plan</SettingLabel>
<SettingValue>
{primaryInvoice.description || 'Chat Subscription'}
{subscription?.plan?.name ||
primaryInvoice?.description ||
'Chat Subscription'}
</SettingValue>
</SettingRow>
<SettingRow>
<SettingLabel>Status</SettingLabel>
<SettingValue>{humanizeStatus(primaryInvoice.status)}</SettingValue>
<SettingValue>
{humanizeStatus(subscription?.status || primaryInvoice?.status || 'none')}
</SettingValue>
</SettingRow>
{subscription?.plan ? (
<SettingRow>
<SettingLabel>Price</SettingLabel>
<SettingValue>
{formatMoneyCents(
subscription.plan.price_cents,
subscription.plan.currency
)}
{subscription.plan.interval ? ` / ${subscription.plan.interval}` : ''}
</SettingValue>
</SettingRow>
) : primaryInvoice ? (
<SettingRow>
<SettingLabel>Amount</SettingLabel>
<SettingValue>
@@ -289,12 +311,34 @@ const BillingSection = (): JSX.Element => {
{primaryInvoice.period_end ? ' / period' : ''}
</SettingValue>
</SettingRow>
) : null}
{subscription?.usage ? (
<>
<SettingRow>
<SettingLabel>Prompts remaining</SettingLabel>
<SettingValue>
{subscription.usage.prompts_remaining === null
? '—'
: `${subscription.usage.prompts_remaining} / ${subscription.usage.prompt_quota} (${subscription.usage.window_hours}h)`}
</SettingValue>
</SettingRow>
<SettingRow>
<SettingLabel>Tokens this period</SettingLabel>
<SettingValue>
in {formatTokenCount(subscription.usage.tokens_in_period)} · out{' '}
{formatTokenCount(subscription.usage.tokens_out_period)}
</SettingValue>
</SettingRow>
</>
) : null}
{primaryInvoice ? (
<SettingRow>
<SettingLabel>Period end</SettingLabel>
<SettingValue>
{formatBillingDate(primaryInvoice.period_end)}
</SettingValue>
</SettingRow>
) : null}
</>
) : (
<BodyText>
@@ -313,6 +357,10 @@ const BillingSection = (): JSX.Element => {
>
{portalLoading ? 'Opening…' : 'Manage subscription'}
</StyledButton>
) : subscription?.needs_checkout === false ? (
<BodyText style={{ margin: 0 }}>
Complimentary access no payment required.
</BodyText>
) : (
<StyledButton
type="button"
@@ -0,0 +1,63 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import ConversationDetailCard from './ConversationDetailCard';
const theme = {
main: '#336699',
focus: '#224466',
darkMode: true,
colors: {
text: '#ffffff',
cardBackground: 'rgba(0,0,0,0.3)',
cardBorder: 'rgba(255,255,255,0.1)',
},
};
const renderCard = (props: {
message: string;
user_created: boolean;
tokens_in?: number | null;
tokens_out?: number | null;
}) =>
render(
<ThemeProvider theme={theme as never}>
<ConversationDetailCard {...props} />
</ThemeProvider>
);
describe('ConversationDetailCard token usage', () => {
it('shows em dash when assistant tokens are missing', () => {
renderCard({
message: 'Hello from the model',
user_created: false,
tokens_in: null,
tokens_out: null,
});
expect(screen.getByTestId('message-token-usage')).toHaveTextContent(
'Tokens in — · out —'
);
});
it('shows reported token counts for assistant messages', () => {
renderCard({
message: 'Hello from the model',
user_created: false,
tokens_in: 12,
tokens_out: 34,
});
expect(screen.getByTestId('message-token-usage')).toHaveTextContent(
'Tokens in 12 · out 34'
);
});
it('hides token meta on user messages', () => {
renderCard({
message: 'Hi',
user_created: true,
tokens_in: 1,
tokens_out: 2,
});
expect(screen.queryByTestId('message-token-usage')).not.toBeInTheDocument();
});
});
@@ -1,6 +1,7 @@
import React from "react";
import Markdown from "markdown-to-jsx";
import styled, { keyframes } from "styled-components";
import { formatTokenCount } from "../../utils/finance";
const fadeIn = keyframes`
from { opacity: 0; transform: translateY(10px); }
@@ -9,7 +10,8 @@ const fadeIn = keyframes`
const MessageContainer = styled.div<{ $isUser: boolean }>`
display: flex;
justify-content: ${(props) => (props.$isUser ? "flex-end" : "flex-start")};
flex-direction: column;
align-items: ${(props) => (props.$isUser ? "flex-end" : "flex-start")};
margin-bottom: 1.5rem;
width: 100%;
animation: ${fadeIn} 0.3s ease-out;
@@ -26,8 +28,8 @@ const Bubble = styled.div<{ $isUser: boolean }>`
? `linear-gradient(135deg, ${props.theme.main} 0%, ${props.theme.focus} 100%)`
: props.theme.darkMode
? "rgba(255, 255, 255, 0.1)"
: "rgba(0, 0, 0, 0.7)"}; // Dark background for AI in light mode as requested
color: #fff; // Text stays white as background is always dark/colored
: "rgba(0, 0, 0, 0.7)"};
color: #fff;
backdrop-filter: blur(10px);
border: 1px solid ${(props) => props.theme.darkMode ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)"};
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
@@ -66,6 +68,15 @@ const Bubble = styled.div<{ $isUser: boolean }>`
}
`;
const TokenMeta = styled.div<{ $isUser: boolean }>`
margin-top: 0.35rem;
max-width: 80%;
font-size: 0.75rem;
opacity: 0.65;
color: ${({ theme }) => theme.colors.text};
text-align: ${(props) => (props.$isUser ? "right" : "left")};
`;
const LoadingDot = styled.div`
width: 8px;
height: 8px;
@@ -93,9 +104,10 @@ const LoadingContainer = styled.div`
type ConversationDetailCardProps = {
message: string;
user_created: boolean;
tokens_in?: number | null;
tokens_out?: number | null;
};
// Custom component for rendering plots
const MyPlot = ({ format, image }: { format: string; image: string }) => {
const imageSrc = `data:image/${format};base64,${image}`;
return (
@@ -107,7 +119,6 @@ const MyPlot = ({ format, image }: { format: string; image: string }) => {
);
};
// Custom component for rendering errors
const MyError = ({ content }: { content: string }) => {
return (
<span style={{ color: "#ff6b6b", fontWeight: "bold", display: "block", marginTop: "0.5rem" }}>
@@ -119,6 +130,8 @@ const MyError = ({ content }: { content: string }) => {
const ConversationDetailCard = ({
message,
user_created,
tokens_in = null,
tokens_out = null,
}: ConversationDetailCardProps): JSX.Element => {
if (message.length === 0) {
return (
@@ -158,6 +171,8 @@ const ConversationDetailCard = ({
}
} catch { }
const showTokens = !user_created;
return (
<MessageContainer $isUser={user_created}>
<Bubble $isUser={user_created}>
@@ -178,6 +193,11 @@ const ConversationDetailCard = ({
{contentToAdd}
</Markdown>
</Bubble>
{showTokens ? (
<TokenMeta $isUser={user_created} data-testid="message-token-usage">
Tokens in {formatTokenCount(tokens_in)} · out {formatTokenCount(tokens_out)}
</TokenMeta>
) : null}
</MessageContainer>
);
};
@@ -0,0 +1,70 @@
import React, { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi';
import {
formatTokenCount,
SubscriptionMe,
} from '../../utils/finance';
const Bar = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
align-items: center;
padding: 0.65rem 1rem;
margin-bottom: 0.75rem;
border-radius: 0.75rem;
background: ${({ theme }) => theme.colors.cardBackground};
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
color: ${({ theme }) => theme.colors.text};
font-size: 0.85rem;
opacity: 0.95;
`;
const Chip = styled.span`
white-space: nowrap;
`;
const UsageSummaryBar = (): JSX.Element | null => {
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
const load = useCallback(async () => {
try {
const response = await axiosInstance.get<SubscriptionMe>('/finance/subscription/');
setSubscription(response.data || null);
} catch {
setSubscription(null);
}
}, []);
useEffect(() => {
load();
}, [load]);
if (!subscription?.plan) {
return null;
}
const usage = subscription.usage;
const promptsLabel =
usage.prompts_remaining === null || usage.prompt_quota === null
? '—'
: `${usage.prompts_remaining}/${usage.prompt_quota}`;
return (
<Bar data-testid="usage-summary-bar">
<Chip>
<strong>{subscription.plan.name}</strong>
</Chip>
<Chip>
Prompts left ({usage.window_hours}h): {promptsLabel}
</Chip>
<Chip>
Period tokens in {formatTokenCount(usage.tokens_in_period)} · out{' '}
{formatTokenCount(usage.tokens_out_period)}
</Chip>
</Bar>
);
};
export default UsageSummaryBar;
@@ -72,9 +72,12 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
const tempConversations: ConversationPrompt[] = data.map(
(item) =>
new ConversationPrompt({
id: item.id,
message: item.message,
user_created: item.user_created,
created_timestamp: item.created_timestamp,
tokens_in: item.tokens_in ?? null,
tokens_out: item.tokens_out ?? null,
}),
)
if (tempConversations.length === 1) {
+12 -1
View File
@@ -4,6 +4,8 @@ export interface ConversationPromptType {
message: string,
user_created: boolean,
created_timestamp: Date,
tokens_in?: number | null,
tokens_out?: number | null,
}
export class ConversationPrompt{
@@ -11,7 +13,8 @@ export class ConversationPrompt{
message: string = '';
user_created: boolean = false;
created_timestamp: Date = new Date();
// TODO: add a date time stamp
tokens_in: number | null = null;
tokens_out: number | null = null;
constructor(initializer?: any){
if(!initializer) return;
@@ -19,6 +22,8 @@ export class ConversationPrompt{
if (initializer.message) this.message = initializer.message;
if (initializer.user_created) this.user_created = initializer.user_created;
if (initializer.created_timestamp) this.created_timestamp = initializer.created_timestamp;
if (initializer.tokens_in !== undefined) this.tokens_in = initializer.tokens_in;
if (initializer.tokens_out !== undefined) this.tokens_out = initializer.tokens_out;
}
}
@@ -54,6 +59,8 @@ export interface ConversationType {
id: number;
title: string;
conversationDetail: ConversationPrompt[];
tokens_in?: number | null;
tokens_out?: number | null;
}
export class Conversation {
@@ -61,6 +68,8 @@ export class Conversation {
title: string ='';
conversationDetail: ConversationPrompt[] = [];
account: Account | undefined;
tokens_in: number | null = null;
tokens_out: number | null = null;
constructor(initializer?: any){
if(!initializer) return;
@@ -68,6 +77,8 @@ export class Conversation {
if (initializer.title) this.title = initializer.title;
if (initializer.conversationDetail) this.conversationDetail = initializer.conversationDetail;
if (initializer.account) this.account = initializer.account;
if (initializer.tokens_in !== undefined) this.tokens_in = initializer.tokens_in;
if (initializer.tokens_out !== undefined) this.tokens_out = initializer.tokens_out;
}
}
@@ -16,6 +16,7 @@ import { MessageContext } from "../../contexts/MessageContext";
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground";
import Header2 from "../../components/Header2/Header2";
import UsageSummaryBar from "../../components/UsageSummaryBar/UsageSummaryBar";
import { AnalyticsEvents, trackEvent } from "../../utils/analytics";
// Styled Components
@@ -516,6 +517,7 @@ const AsyncDashboardInner = (): JSX.Element => {
</Sidebar>
<MainContent>
<UsageSummaryBar />
<ChatArea>
{conversationDetails.length > 0 ? (
conversationDetails.map((convo_detail, index) =>
@@ -523,12 +525,16 @@ const AsyncDashboardInner = (): JSX.Element => {
<ConversationDetailCard
message={convo_detail.message}
user_created={convo_detail.user_created}
tokens_in={convo_detail.tokens_in}
tokens_out={convo_detail.tokens_out}
key={convo_detail.id || index}
/>
) : (
<ConversationDetailCard
message={stateMessage}
user_created={convo_detail.user_created}
tokens_in={convo_detail.tokens_in}
tokens_out={convo_detail.tokens_out}
key={convo_detail.id || index}
/>
)
+8 -1
View File
@@ -3,7 +3,7 @@ import React, { useContext, useEffect, useState } from 'react';
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
import { setTokens } from '../../auth/tokenStorage';
import { AuthContext } from '../../contexts/AuthContext';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { AccountContext } from '../../contexts/AccountContext';
import { AxiosResponse } from 'axios';
import { Account, AccountType } from '../../data';
@@ -179,6 +179,7 @@ function checkoutReturnUrls(): { success_url: string; cancel_url: string } {
}
const SignUp = (): JSX.Element => {
const navigate = useNavigate();
const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
const { setAccount } = useContext(AccountContext);
const [errorMessage, setErrorMessage] = useState<string>('');
@@ -250,6 +251,12 @@ const SignUp = (): JSX.Element => {
identifyAccount(account);
const { success_url, cancel_url } = checkoutReturnUrls();
const needsCheckout = registerResponse.data?.needs_checkout !== false;
if (!needsCheckout) {
navigate('/');
return;
}
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup' });
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
success_url,
+12 -5
View File
@@ -1,6 +1,7 @@
import {
canOpenBillingPortal,
formatMoneyCents,
formatTokenCount,
humanizeStatus,
pickPrimaryInvoice,
} from './finance';
@@ -30,6 +31,13 @@ describe('finance helpers', () => {
expect(formatMoneyCents(1000, 'usd')).toMatch(/10/);
});
it('shows em dash for missing token usage', () => {
expect(formatTokenCount(null)).toBe('—');
expect(formatTokenCount(undefined)).toBe('—');
expect(formatTokenCount(0)).toBe('0');
expect(formatTokenCount(1200)).toMatch(/1/);
});
it('humanizes status labels', () => {
expect(humanizeStatus('past_due')).toBe('Past Due');
});
@@ -41,14 +49,13 @@ describe('finance helpers', () => {
id: 2,
status: 'paid',
stripe_subscription_id: 'sub_1',
description: 'Active Plan',
}),
];
expect(pickPrimaryInvoice(invoices)?.description).toBe('Active Plan');
expect(canOpenBillingPortal(invoices)).toBe(true);
expect(pickPrimaryInvoice(invoices)?.id).toBe(2);
});
it('denies portal when no paid or subscription invoice', () => {
expect(canOpenBillingPortal([baseInvoice()])).toBe(false);
it('detects portal access from paid or subscribed invoices', () => {
expect(canOpenBillingPortal([baseInvoice({ status: 'open' })])).toBe(false);
expect(canOpenBillingPortal([baseInvoice({ status: 'paid' })])).toBe(true);
});
});
+52
View File
@@ -31,6 +31,58 @@ export type FinancePayment = {
last_modified: string;
};
export type PlanFeatures = {
text_generation: boolean;
image_generation: boolean;
all_future_features: boolean;
};
export type SubscriptionPlanInfo = {
slug: string;
name: string;
description: string;
price_cents: number;
currency: string;
interval: string;
is_public: boolean;
is_selectable: boolean;
features: PlanFeatures;
prompt_quota_per_window: number;
prompt_window_hours: number;
monthly_token_quota: number | null;
sort_order: number;
};
export type SubscriptionUsage = {
prompts_in_window: number;
prompt_quota: number | null;
prompts_remaining: number | null;
window_hours: number;
tokens_in_period: number | null;
tokens_out_period: number | null;
tokens_total_period: number | null;
turns_missing_token_usage: number;
monthly_token_quota: number | null;
tokens_remaining: number | null;
period_start: string | null;
period_end: string | null;
};
export type SubscriptionMe = {
plan: SubscriptionPlanInfo | null;
status: string;
source: string;
needs_checkout: boolean;
stripe_subscription_id: string;
usage: SubscriptionUsage;
};
/** 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 '—';
return value.toLocaleString();
}
export function formatMoneyCents(amountCents: number, currency: string): string {
const code = (currency || 'usd').toUpperCase();
try {