From 48e18ebd99f29ef1b5d4d35d7cb681ca642fd106 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Fri, 31 Jul 2026 06:21:54 -0500 Subject: [PATCH] Surface plan quotas and token usage in chat and billing UI Companion to backend #16/#17/#36: show tokens on messages, plan/quota on Account billing and chat usage bar, skip checkout for Backers. --- .../BillingSection/BillingSection.test.tsx | 115 ++++++++++++++---- .../BillingSection/BillingSection.tsx | 84 ++++++++++--- .../ConversationDetailCard.test.tsx | 63 ++++++++++ .../ConversationDetailCard.tsx | 34 ++++-- .../UsageSummaryBar/UsageSummaryBar.tsx | 70 +++++++++++ llm-fe/src/llm-fe/contexts/MessageContext.tsx | 3 + llm-fe/src/llm-fe/data.ts | 13 +- .../pages/AsyncDashboard2/AsyncDashboard2.tsx | 6 + llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx | 9 +- llm-fe/src/llm-fe/utils/finance.test.ts | 17 ++- llm-fe/src/llm-fe/utils/finance.ts | 52 ++++++++ 11 files changed, 413 insertions(+), 53 deletions(-) create mode 100644 llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx create mode 100644 llm-fe/src/llm-fe/components/UsageSummaryBar/UsageSummaryBar.tsx diff --git a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.test.tsx b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.test.tsx index bcc3539..465ee61 100644 --- a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.test.tsx +++ b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.test.tsx @@ -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( @@ -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' } }, diff --git a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx index 35208b1..11b1d5f 100644 --- a/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx +++ b/llm-fe/src/llm-fe/components/BillingSection/BillingSection.tsx @@ -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([]); const [payments, setPayments] = useState([]); + const [subscription, setSubscription] = useState(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('/finance/invoices/'), axiosInstance.get('/finance/payments/'), + axiosInstance.get('/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,34 +273,72 @@ const BillingSection = (): JSX.Element => { Loading billing information… ) : listError ? ( {listError} - ) : primaryInvoice ? ( + ) : subscription?.plan || primaryInvoice ? ( <> Plan - {primaryInvoice.description || 'Chat Subscription'} + {subscription?.plan?.name || + 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)} + {humanizeStatus(subscription?.status || primaryInvoice?.status || 'none')} + {subscription?.plan ? ( + + Price + + {formatMoneyCents( + subscription.plan.price_cents, + subscription.plan.currency + )} + {subscription.plan.interval ? ` / ${subscription.plan.interval}` : ''} + + + ) : primaryInvoice ? ( + + Amount + + {formatMoneyCents( + primaryInvoice.amount_paid || primaryInvoice.amount_due, + primaryInvoice.currency + )} + {primaryInvoice.period_end ? ' / period' : ''} + + + ) : null} + {subscription?.usage ? ( + <> + + Prompts remaining + + {subscription.usage.prompts_remaining === null + ? '—' + : `${subscription.usage.prompts_remaining} / ${subscription.usage.prompt_quota} (${subscription.usage.window_hours}h)`} + + + + Tokens this period + + in {formatTokenCount(subscription.usage.tokens_in_period)} · out{' '} + {formatTokenCount(subscription.usage.tokens_out_period)} + + + + ) : null} + {primaryInvoice ? ( + + Period end + + {formatBillingDate(primaryInvoice.period_end)} + + + ) : null} ) : ( @@ -313,6 +357,10 @@ const BillingSection = (): JSX.Element => { > {portalLoading ? 'Opening…' : 'Manage subscription'} + ) : subscription?.needs_checkout === false ? ( + + Complimentary access — no payment required. + ) : ( + render( + + + + ); + +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(); + }); +}); diff --git a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx index 4da4092..7129ab9 100644 --- a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx +++ b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx @@ -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); @@ -48,7 +50,7 @@ const Bubble = styled.div<{ $isUser: boolean }>` font-family: 'Fira Code', monospace; font-size: 0.9em; } - + & a { color: #a0c4ff; text-decoration: underline; @@ -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; @@ -76,7 +87,7 @@ const LoadingDot = styled.div` &:nth-child(1) { animation-delay: -0.32s; } &:nth-child(2) { animation-delay: -0.16s; } - + @keyframes bounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } @@ -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 ( @@ -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 ( @@ -178,6 +193,11 @@ const ConversationDetailCard = ({ {contentToAdd} + {showTokens ? ( + + Tokens in {formatTokenCount(tokens_in)} · out {formatTokenCount(tokens_out)} + + ) : null} ); }; diff --git a/llm-fe/src/llm-fe/components/UsageSummaryBar/UsageSummaryBar.tsx b/llm-fe/src/llm-fe/components/UsageSummaryBar/UsageSummaryBar.tsx new file mode 100644 index 0000000..a97d45c --- /dev/null +++ b/llm-fe/src/llm-fe/components/UsageSummaryBar/UsageSummaryBar.tsx @@ -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(null); + + const load = useCallback(async () => { + try { + const response = await axiosInstance.get('/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 ( + + + {subscription.plan.name} + + + Prompts left ({usage.window_hours}h): {promptsLabel} + + + Period tokens in {formatTokenCount(usage.tokens_in_period)} · out{' '} + {formatTokenCount(usage.tokens_out_period)} + + + ); +}; + +export default UsageSummaryBar; diff --git a/llm-fe/src/llm-fe/contexts/MessageContext.tsx b/llm-fe/src/llm-fe/contexts/MessageContext.tsx index de83c48..bceb53c 100644 --- a/llm-fe/src/llm-fe/contexts/MessageContext.tsx +++ b/llm-fe/src/llm-fe/contexts/MessageContext.tsx @@ -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) { diff --git a/llm-fe/src/llm-fe/data.ts b/llm-fe/src/llm-fe/data.ts index e50873d..8f61fe3 100644 --- a/llm-fe/src/llm-fe/data.ts +++ b/llm-fe/src/llm-fe/data.ts @@ -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; } } diff --git a/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx b/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx index 1375940..09116fb 100644 --- a/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx +++ b/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx @@ -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 => { + {conversationDetails.length > 0 ? ( conversationDetails.map((convo_detail, index) => @@ -523,12 +525,16 @@ const AsyncDashboardInner = (): JSX.Element => { ) : ( ) diff --git a/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx b/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx index 0eb6155..16db37b 100644 --- a/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx +++ b/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx @@ -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(''); @@ -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, diff --git a/llm-fe/src/llm-fe/utils/finance.test.ts b/llm-fe/src/llm-fe/utils/finance.test.ts index 8617760..6fb1927 100644 --- a/llm-fe/src/llm-fe/utils/finance.test.ts +++ b/llm-fe/src/llm-fe/utils/finance.test.ts @@ -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); }); }); diff --git a/llm-fe/src/llm-fe/utils/finance.ts b/llm-fe/src/llm-fe/utils/finance.ts index 44e97eb..d712fb3 100644 --- a/llm-fe/src/llm-fe/utils/finance.ts +++ b/llm-fe/src/llm-fe/utils/finance.ts @@ -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 { -- 2.54.0