From 5db57f7f269185fe66ffba568b028260b1dda8dc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 11 Sep 2026 10:49:56 +0300 Subject: [PATCH 1/3] feat: move billing management to web dashboard Co-authored-by: Medulla --- .../rewards/RewardsCouponSection.tsx | 283 ------------------ .../components/rewards/RewardsRedeemTab.tsx | 9 - .../__tests__/RewardsCouponSection.test.tsx | 140 --------- .../settings/panels/BillingPanel.test.tsx | 230 +++++--------- .../settings/panels/BillingPanel.tsx | 181 +++++++---- .../panels/__tests__/InferenceBudget.test.tsx | 16 +- .../panels/billing/InferenceBudget.tsx | 13 +- app/src/config/__tests__/navConfig.test.ts | 10 +- app/src/config/navConfig.ts | 6 +- app/src/pages/Rewards.tsx | 46 ++- app/src/pages/__tests__/Rewards.test.tsx | 9 +- .../services/api/__tests__/billingApi.test.ts | 18 ++ app/src/services/api/billingApi.ts | 6 + app/src/types/api.ts | 6 + gitbooks/features/billing-and-usage.md | 4 +- gitbooks/features/rewards-and-referrals.md | 15 +- src/core/jsonrpc_tests.rs | 15 +- src/openhuman/hosted/billing/README.md | 7 +- src/openhuman/hosted/billing/ops.rs | 5 + src/openhuman/hosted/billing/schemas.rs | 22 ++ src/openhuman/hosted/billing/schemas_tests.rs | 17 +- 21 files changed, 344 insertions(+), 714 deletions(-) delete mode 100644 app/src/components/rewards/RewardsCouponSection.tsx delete mode 100644 app/src/components/rewards/RewardsRedeemTab.tsx delete mode 100644 app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx diff --git a/app/src/components/rewards/RewardsCouponSection.tsx b/app/src/components/rewards/RewardsCouponSection.tsx deleted file mode 100644 index 07005c611c..0000000000 --- a/app/src/components/rewards/RewardsCouponSection.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import createDebug from 'debug'; -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { useUser } from '../../hooks/useUser'; -import { useT } from '../../lib/i18n/I18nContext'; -import { useCoreState } from '../../providers/CoreStateProvider'; -import { type CreditBalance, creditsApi, type RedeemedCoupon } from '../../services/api/creditsApi'; -import { Button, TextField } from '../ui'; - -const log = createDebug('openhuman:rewards-coupons'); - -function formatUsd(amount: number): string { - return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); -} - -function formatDateTime(value: string | null, pendingLabel: string): string { - if (!value) return pendingLabel; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? pendingLabel : date.toLocaleString(); -} - -function redemptionStatusClass(coupon: RedeemedCoupon): string { - if (coupon.fulfilled) return 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300'; - if (coupon.activationType === 'CONDITIONAL') - return 'bg-amber-50 dark:bg-amber-500/10 text-amber-800 dark:text-amber-200'; - return 'bg-surface-subtle text-content-secondary'; -} - -const RewardsCouponSection = () => { - const { t } = useT(); - const { snapshot } = useCoreState(); - const { refetch } = useUser(); - const token = snapshot.sessionToken; - - const [couponCode, setCouponCode] = useState(''); - const [creditBalance, setCreditBalance] = useState(null); - const [redeemedCoupons, setRedeemedCoupons] = useState([]); - const [loading, setLoading] = useState(false); - const [submitLoading, setSubmitLoading] = useState(false); - const [loadError, setLoadError] = useState(null); - const [submitError, setSubmitError] = useState(null); - const [submitSuccess, setSubmitSuccess] = useState(null); - const latestRequestIdRef = useRef(0); - - const loadCouponState = useCallback(async () => { - if (!token) { - latestRequestIdRef.current += 1; - setCreditBalance(null); - setRedeemedCoupons([]); - setLoadError(null); - setLoading(false); - return; - } - - latestRequestIdRef.current += 1; - const requestId = latestRequestIdRef.current; - setLoading(true); - setLoadError(null); - - try { - log('[load] fetching balance and coupon history'); - const [balance, coupons] = await Promise.all([ - creditsApi.getBalance(), - creditsApi.getUserCoupons(), - ]); - - if (requestId !== latestRequestIdRef.current) return; - - log('[load] loaded balance=%O coupons=%d', balance, coupons.length); - setCreditBalance(balance); - setRedeemedCoupons(coupons); - } catch (error) { - if (requestId !== latestRequestIdRef.current) return; - const message = - error && typeof error === 'object' && 'error' in error - ? String((error as { error: unknown }).error) - : 'Could not load reward codes right now.'; - log('[load] failed: %s', message); - setLoadError(message); - } finally { - if (requestId === latestRequestIdRef.current) { - setLoading(false); - } - } - }, [token]); - - useEffect(() => { - void loadCouponState(); - }, [loadCouponState]); - - const handleRedeem = async () => { - const code = couponCode.trim(); - if (!code || submitLoading) return; - - setSubmitLoading(true); - setSubmitError(null); - setSubmitSuccess(null); - - try { - log('[redeem] submitting code=%s', code); - const result = await creditsApi.redeemCoupon(code); - const successMsg = result.pending - ? t('rewards.coupon.redeemAccepted') - .replace('{code}', result.couponCode) - .replace('{amount}', formatUsd(result.amountUsd)) - : t('rewards.coupon.redeemSuccess') - .replace('{code}', result.couponCode) - .replace('{amount}', formatUsd(result.amountUsd)); - setSubmitSuccess(successMsg); - setCouponCode(''); - - const refreshResults = await Promise.allSettled([loadCouponState(), refetch()]); - const refreshFailures = refreshResults.filter( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ); - if (refreshFailures.length > 0) { - log('[redeem] refresh failed count=%d', refreshFailures.length); - } - - log( - '[redeem] completed code=%s pending=%s amount=%s', - result.couponCode, - result.pending, - result.amountUsd - ); - } catch (error) { - const message = - error && typeof error === 'object' && 'error' in error - ? String((error as { error: unknown }).error) - : 'Could not apply that reward code.'; - log('[redeem] failed: %s', message); - setSubmitError(message); - } finally { - setSubmitLoading(false); - } - }; - - if (!token) { - return null; - } - - return ( - <> -
-
-
-
- {t('rewards.coupon.promoCredits')} -
-
- {creditBalance ? formatUsd(creditBalance.promotionBalanceUsd) : loading ? '…' : '—'} -
-
-
-
- {t('rewards.coupon.redeemedCodes')} -
-
{redeemedCoupons.length}
-
-
- -
-
- { - setCouponCode(event.target.value.toUpperCase()); - if (submitError) setSubmitError(null); - if (submitSuccess) setSubmitSuccess(null); - }} - onKeyDown={event => { - if (event.key === 'Enter') { - void handleRedeem(); - } - }} - placeholder={t('rewards.coupon.placeholder')} - disabled={submitLoading} - mono - className="flex-1" - /> - -
- {submitSuccess ? ( -
- {submitSuccess} -
- ) : null} - {submitError ? ( -
- {submitError} -
- ) : null} - {loadError ? ( -
- {loadError} - -
- ) : null} -
-
-
-
-
-

- {t('rewards.coupon.recentRedemptions')} -

- -
- - {loading && redeemedCoupons.length === 0 ? ( -

{t('rewards.coupon.loadingHistory')}

- ) : null} - - {redeemedCoupons.length === 0 && !loading && !loadError ? ( -

- {t('rewards.coupon.noCodes')} -

- ) : redeemedCoupons.length > 0 ? ( -
- - - - - - - - - - - {redeemedCoupons.map(coupon => ( - - - - - - - ))} - -
{t('rewards.coupon.colCode')}{t('rewards.coupon.colReward')}{t('rewards.coupon.colStatus')}{t('rewards.coupon.colRedeemed')}
{coupon.code} - {formatUsd(coupon.amountUsd)} - - - {coupon.fulfilled - ? t('rewards.coupon.statusApplied') - : coupon.activationType === 'CONDITIONAL' - ? t('rewards.coupon.statusPendingAction') - : t('rewards.coupon.statusRedeemed')} - - - {formatDateTime(coupon.redeemedAt, t('rewards.coupon.pending'))} -
-
- ) : null} -
-
- - ); -}; - -export default RewardsCouponSection; diff --git a/app/src/components/rewards/RewardsRedeemTab.tsx b/app/src/components/rewards/RewardsRedeemTab.tsx deleted file mode 100644 index f59b732ac1..0000000000 --- a/app/src/components/rewards/RewardsRedeemTab.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import RewardsCouponSection from './RewardsCouponSection'; - -export default function RewardsRedeemTab() { - return ( - <> - - - ); -} diff --git a/app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx b/app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx deleted file mode 100644 index 684badbeb4..0000000000 --- a/app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import RewardsCouponSection from '../RewardsCouponSection'; - -const mocks = vi.hoisted(() => ({ - mockUseCoreState: vi.fn(), - mockUseUser: vi.fn(), - mockCreditsApi: { getBalance: vi.fn(), getUserCoupons: vi.fn(), redeemCoupon: vi.fn() }, -})); - -vi.mock('../../../providers/CoreStateProvider', () => ({ - useCoreState: () => mocks.mockUseCoreState(), -})); - -vi.mock('../../../hooks/useUser', () => ({ useUser: () => mocks.mockUseUser() })); - -vi.mock('../../../services/api/creditsApi', () => ({ creditsApi: mocks.mockCreditsApi })); - -describe('RewardsCouponSection', () => { - const refetch = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - mocks.mockUseCoreState.mockReturnValue({ snapshot: { sessionToken: 'test-token' } }); - mocks.mockUseUser.mockReturnValue({ refetch }); - }); - - it('loads balances and refreshes history after a successful redemption', async () => { - mocks.mockCreditsApi.getBalance - .mockResolvedValueOnce({ promotionBalanceUsd: 3, teamTopupUsd: 1 }) - .mockResolvedValueOnce({ promotionBalanceUsd: 8, teamTopupUsd: 1 }); - mocks.mockCreditsApi.getUserCoupons - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([ - { - code: 'APRL-2026', - amountUsd: 5, - redeemedAt: '2026-04-09T19:00:00.000Z', - activationType: 'IMMEDIATE', - activationCondition: null, - fulfilled: true, - fulfilledAt: '2026-04-09T19:00:01.000Z', - }, - ]); - mocks.mockCreditsApi.redeemCoupon.mockResolvedValueOnce({ - couponCode: 'APRL-2026', - amountUsd: 5, - pending: false, - }); - - render(); - - expect(await screen.findByText('$3.00')).toBeInTheDocument(); - expect(screen.getByText('No reward codes redeemed yet.')).toBeInTheDocument(); - - fireEvent.change(screen.getByPlaceholderText('Coupon code'), { - target: { value: 'aprl-2026' }, - }); - fireEvent.click(screen.getByRole('button', { name: 'Redeem Code' })); - - expect( - await screen.findByText('APRL-2026 redeemed. $5.00 was added to your credits.') - ).toBeInTheDocument(); - - await waitFor(() => { - expect(screen.getByText('$8.00')).toBeInTheDocument(); - }); - expect(screen.getByText('APRL-2026')).toBeInTheDocument(); - expect(screen.getByText('Applied')).toBeInTheDocument(); - expect(refetch).toHaveBeenCalledTimes(1); - }); - - it('shows backend redemption errors without clearing the existing state', async () => { - mocks.mockCreditsApi.getBalance.mockResolvedValue({ promotionBalanceUsd: 3, teamTopupUsd: 0 }); - mocks.mockCreditsApi.getUserCoupons.mockResolvedValue([]); - mocks.mockCreditsApi.redeemCoupon.mockRejectedValueOnce({ - error: 'This coupon has already been used.', - }); - - render(); - - expect(await screen.findByText('$3.00')).toBeInTheDocument(); - - fireEvent.change(screen.getByPlaceholderText('Coupon code'), { - target: { value: 'used-code' }, - }); - fireEvent.click(screen.getByRole('button', { name: 'Redeem Code' })); - - expect(await screen.findByText('This coupon has already been used.')).toBeInTheDocument(); - expect(mocks.mockCreditsApi.getBalance).toHaveBeenCalledTimes(1); - expect(refetch).not.toHaveBeenCalled(); - }); - - it('shows pending coupon copy and keeps the current balance until the reward is fulfilled', async () => { - mocks.mockCreditsApi.getBalance - .mockResolvedValueOnce({ promotionBalanceUsd: 3, teamTopupUsd: 0 }) - .mockResolvedValueOnce({ promotionBalanceUsd: 3, teamTopupUsd: 0 }); - mocks.mockCreditsApi.getUserCoupons - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([ - { - code: 'APRL-2026', - amountUsd: 5, - redeemedAt: '2026-04-09T19:00:00.000Z', - activationType: 'CONDITIONAL', - activationCondition: 'SUBSCRIBE_PAID_PLAN', - fulfilled: false, - fulfilledAt: null, - }, - ]); - mocks.mockCreditsApi.redeemCoupon.mockResolvedValueOnce({ - couponCode: 'APRL-2026', - amountUsd: 5, - pending: true, - }); - - render(); - - expect(await screen.findByText('$3.00')).toBeInTheDocument(); - - fireEvent.change(screen.getByPlaceholderText('Coupon code'), { - target: { value: 'aprl-2026' }, - }); - fireEvent.click(screen.getByRole('button', { name: 'Redeem Code' })); - - expect( - await screen.findByText( - 'APRL-2026 accepted. $5.00 will unlock after the required action is completed.' - ) - ).toBeInTheDocument(); - - await waitFor(() => { - expect(screen.getAllByText('$3.00')).toHaveLength(1); - }); - expect(screen.getByText('APRL-2026')).toBeInTheDocument(); - expect(screen.getByText('Pending action')).toBeInTheDocument(); - expect(refetch).toHaveBeenCalledTimes(1); - }); -}); diff --git a/app/src/components/settings/panels/BillingPanel.test.tsx b/app/src/components/settings/panels/BillingPanel.test.tsx index 9f8bfbc265..ed861267de 100644 --- a/app/src/components/settings/panels/BillingPanel.test.tsx +++ b/app/src/components/settings/panels/BillingPanel.test.tsx @@ -4,6 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import BillingPanel from './BillingPanel'; const navigateBack = vi.fn(); +const openUrlMock = vi.fn(); +const getSummaryMock = vi.fn(); +const getTeamUsageMock = vi.fn(); vi.mock('../hooks/useSettingsNavigation', () => ({ useSettingsNavigation: () => ({ @@ -14,185 +17,116 @@ vi.mock('../hooks/useSettingsNavigation', () => ({ }), })); -const openUrlMock = vi.fn(); vi.mock('../../../utils/openUrl', () => ({ openUrl: (url: string) => openUrlMock(url) })); - -const getCurrentPlanMock = vi.fn(); -const purchasePlanMock = vi.fn(); -const createCoinbaseChargeMock = vi.fn(); - vi.mock('../../../services/api/billingApi', () => ({ - billingApi: { - getCurrentPlan: (...args: unknown[]) => getCurrentPlanMock(...args), - purchasePlan: (...args: unknown[]) => purchasePlanMock(...args), - createCoinbaseCharge: (...args: unknown[]) => createCoinbaseChargeMock(...args), - }, + billingApi: { getSummary: (...args: unknown[]) => getSummaryMock(...args) }, +})); +vi.mock('../../../services/api/creditsApi', () => ({ + creditsApi: { getTeamUsage: (...args: unknown[]) => getTeamUsageMock(...args) }, })); +const summary = { + credits: { promotionBalanceUsd: 4.5, teamTopupUsd: 10, totalUsd: 14.5 }, + plan: { + plan: 'PRO', + hasActiveSubscription: true, + planExpiry: '2026-12-01T00:00:00.000Z', + subscription: null, + monthlyBudgetUsd: 100, + weeklyBudgetUsd: 25, + }, + links: { + topUpUrl: 'https://staging.tinyhumans.ai/dashboard?tab=billing', + manageUrl: 'https://staging.tinyhumans.ai/dashboard?tab=plans', + apiKeysUrl: 'https://staging.tinyhumans.ai/dashboard?tab=api-keys', + }, +}; + +const usage = { + remainingUsd: 39.5, + cycleBudgetUsd: 25, + cycleSpentUsd: 6, + cycleStartDate: '2026-09-07T00:00:00.000Z', + cycleEndsAt: '2026-09-14T00:00:00.000Z', + plan: { + plan: 'PRO', + name: 'Pro', + marginPercent: 10, + payAsYouGoMarginPercent: 100, + discountVsPayAsYouGoPercent: 90, + }, + insights: { + period: { startDate: '2026-09-07', endDate: '2026-09-14' }, + totals: { + inferenceUsd: 5, + integrationsUsd: 1, + totalUsd: 6, + inferenceCalls: 20, + integrationCalls: 2, + }, + dailySeries: [], + topModels: [], + topIntegrations: [], + }, +}; + describe('', () => { beforeEach(() => { vi.clearAllMocks(); openUrlMock.mockResolvedValue(undefined); - getCurrentPlanMock.mockResolvedValue({ - plan: 'FREE', - hasActiveSubscription: false, - planExpiry: null, - subscription: null, - monthlyBudgetUsd: 0, - weeklyBudgetUsd: 0, - }); - purchasePlanMock.mockResolvedValue({ - checkoutUrl: 'https://checkout.stripe.com/test', - sessionId: 'test-session', - }); - createCoinbaseChargeMock.mockResolvedValue({ - gatewayTransactionId: 'test-gw', - hostedUrl: 'https://commerce.coinbase.com/test', - status: 'NEW', - expiresAt: '2026-01-01T00:00:00Z', - }); + getSummaryMock.mockResolvedValue(summary); + getTeamUsageMock.mockResolvedValue(usage); }); - it('renders the plan selector and the dashboard button without auto-opening the browser', async () => { + it('shows plan, balances, cycle spend, and total funds remaining', async () => { render(); - // SubscriptionPlans renders its own title; billing frequency selection is - // back in-app so users can change their plan without leaving the desktop app. - expect(screen.getByText('Choose a Plan')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Open billing dashboard' })).toBeInTheDocument(); - - // getCurrentPlan is called on mount but must not trigger a browser open. - await waitFor(() => expect(getCurrentPlanMock).toHaveBeenCalledTimes(1)); - expect(openUrlMock).not.toHaveBeenCalled(); - }); - - it('loads the current plan tier on mount and passes it to SubscriptionPlans', async () => { - getCurrentPlanMock.mockResolvedValue({ - plan: 'BASIC', - hasActiveSubscription: true, - planExpiry: null, - subscription: null, - monthlyBudgetUsd: 20, - weeklyBudgetUsd: 10, - }); - - render(); - - await waitFor(() => expect(getCurrentPlanMock).toHaveBeenCalledTimes(1)); - // With BASIC as current tier the BASIC card shows the "Current plan" badge. - expect(await screen.findByText('Current plan')).toBeInTheDocument(); + await waitFor(() => expect(getSummaryMock).toHaveBeenCalledTimes(1)); + expect(getTeamUsageMock).toHaveBeenCalledTimes(1); + expect(screen.getByText('PRO')).toBeInTheDocument(); + expect(screen.getByText('$39.50')).toBeInTheDocument(); + expect(screen.getByText('$4.50')).toBeInTheDocument(); + expect(screen.getByText('$10.00')).toBeInTheDocument(); + expect(screen.getByText(/Spent \$6\.00 this cycle/i)).toBeInTheDocument(); }); - it('upgrade with card payment calls purchasePlan and opens the checkout URL', async () => { + it('uses backend-provided dashboard URLs for billing actions', async () => { render(); + await screen.findByText('$39.50'); - await waitFor(() => expect(getCurrentPlanMock).toHaveBeenCalledTimes(1)); - - // Both BASIC and PRO show upgrade buttons when current tier is FREE. - const upgradeButtons = screen.getAllByRole('button', { name: 'Upgrade' }); - fireEvent.click(upgradeButtons[0]); + fireEvent.click(screen.getByRole('button', { name: 'Top Up Credits' })); + fireEvent.click(screen.getByRole('button', { name: 'Open billing dashboard' })); - await waitFor(() => expect(purchasePlanMock).toHaveBeenCalledTimes(1)); - expect(purchasePlanMock).toHaveBeenCalledWith('BASIC_MONTHLY'); - await waitFor(() => - expect(openUrlMock).toHaveBeenCalledWith('https://checkout.stripe.com/test') - ); + expect(openUrlMock).toHaveBeenNthCalledWith(1, summary.links.topUpUrl); + expect(openUrlMock).toHaveBeenNthCalledWith(2, summary.links.manageUrl); }); - // The reason this PR exists: the interval toggle must reach `purchasePlan`. - // The monthly case above passes on the DEFAULT interval, so it stays green - // even if `buildPlanId(tier, billingInterval)` is hardcoded back to - // 'monthly' — i.e. even with the bug in #5865 fully restored. This is the - // case that fails when that happens. - it('upgrade after selecting Annual sends the yearly plan id', async () => { + it('keeps usable cycle details visible when the aggregate summary fails', async () => { + getSummaryMock.mockRejectedValue(new Error('Summary unavailable')); render(); - await waitFor(() => expect(getCurrentPlanMock).toHaveBeenCalledTimes(1)); - - fireEvent.click(screen.getByRole('button', { name: 'Annual' })); - - const upgradeButtons = await screen.findAllByRole('button', { name: 'Upgrade' }); - fireEvent.click(upgradeButtons[0]); - await waitFor(() => expect(purchasePlanMock).toHaveBeenCalledTimes(1)); - expect(purchasePlanMock).toHaveBeenCalledWith('BASIC_YEARLY'); + expect(await screen.findByText('Summary unavailable')).toBeInTheDocument(); + expect(screen.getByText('$39.50')).toBeInTheDocument(); + expect(screen.getByText(/Spent \$6\.00 this cycle/i)).toBeInTheDocument(); }); - // The crypto branch of `handleUpgrade` had no test at all: the mock was - // declared and stubbed but never asserted on, so the whole branch was - // unexecuted. Also pins the interval coupling from the Codex P1 — selecting - // crypto forces `annual`, so the price on screen matches the charge. - it('upgrade with crypto creates a Coinbase charge and opens the hosted URL', async () => { - render(); - await waitFor(() => expect(getCurrentPlanMock).toHaveBeenCalledTimes(1)); - - fireEvent.click(screen.getByRole('switch')); - - const upgradeButtons = await screen.findAllByRole('button', { name: 'Upgrade' }); - fireEvent.click(upgradeButtons[0]); - - await waitFor(() => expect(createCoinbaseChargeMock).toHaveBeenCalledTimes(1)); - expect(createCoinbaseChargeMock).toHaveBeenCalledWith('BASIC'); - // Crypto must never go through the Stripe path. - expect(purchasePlanMock).not.toHaveBeenCalled(); - await waitFor(() => - expect(openUrlMock).toHaveBeenCalledWith('https://commerce.coinbase.com/test') - ); - // Selecting crypto switches the interval to annual, so the monthly - // button is disabled and the displayed price cannot disagree with the - // charge that was created. - expect(screen.getByRole('button', { name: 'Monthly' })).toBeDisabled(); - }); - - it('opens the billing dashboard when the user clicks the secondary button', async () => { + it('keeps plan and balances visible when cycle usage fails', async () => { + getTeamUsageMock.mockRejectedValue(new Error('Usage unavailable')); render(); - fireEvent.click(screen.getByRole('button', { name: 'Open billing dashboard' })); - await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(1)); - expect(openUrlMock).toHaveBeenLastCalledWith('https://tinyhumans.ai/dashboard'); + expect(await screen.findByText('Usage unavailable')).toBeInTheDocument(); + expect(screen.getByText('PRO')).toBeInTheDocument(); + expect(screen.queryByText('$14.50')).not.toBeInTheDocument(); + expect(screen.getAllByText('n/a').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText(/Unable to load usage data/i)).toBeInTheDocument(); }); - it('invokes the navigation back handler from both the header and the inline button', async () => { + it('invokes the navigation back handler from both back buttons', async () => { render(); + await screen.findByText('$39.50'); - // The SettingsHeader back button (aria-label "Back") and the inline - // "Back to settings" button both route through navigateBack. fireEvent.click(screen.getByRole('button', { name: 'Back' })); fireEvent.click(screen.getByRole('button', { name: 'Back to settings' })); expect(navigateBack).toHaveBeenCalledTimes(2); }); - - it('shows an error message when getCurrentPlan rejects', async () => { - getCurrentPlanMock.mockRejectedValue(new Error('Network error')); - - render(); - - await waitFor(() => expect(screen.getByText('Network error')).toBeInTheDocument()); - }); - - it('shows an error message when purchasePlan rejects', async () => { - purchasePlanMock.mockRejectedValue(new Error('Payment failed')); - - render(); - await waitFor(() => expect(getCurrentPlanMock).toHaveBeenCalledTimes(1)); - - const upgradeButtons = screen.getAllByRole('button', { name: 'Upgrade' }); - fireEvent.click(upgradeButtons[0]); - - await waitFor(() => expect(screen.getByText('Payment failed')).toBeInTheDocument()); - }); - - it('shows an error when purchasePlan returns no checkout URL', async () => { - purchasePlanMock.mockResolvedValue({ checkoutUrl: null, sessionId: 'test-session' }); - - render(); - await waitFor(() => expect(getCurrentPlanMock).toHaveBeenCalledTimes(1)); - - const upgradeButtons = screen.getAllByRole('button', { name: 'Upgrade' }); - fireEvent.click(upgradeButtons[0]); - - await waitFor(() => - expect(screen.getByText('Checkout session did not return a redirect URL')).toBeInTheDocument() - ); - expect(openUrlMock).not.toHaveBeenCalled(); - }); }); diff --git a/app/src/components/settings/panels/BillingPanel.tsx b/app/src/components/settings/panels/BillingPanel.tsx index 25c18c253b..91cc480e8b 100644 --- a/app/src/components/settings/panels/BillingPanel.tsx +++ b/app/src/components/settings/panels/BillingPanel.tsx @@ -1,92 +1,138 @@ +import createDebug from 'debug'; import { useEffect, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { billingApi } from '../../../services/api/billingApi'; -import type { PlanTier } from '../../../types/api'; +import { creditsApi, type TeamUsage } from '../../../services/api/creditsApi'; +import type { BillingSummaryData } from '../../../types/api'; import { BILLING_DASHBOARD_URL } from '../../../utils/links'; import { openUrl } from '../../../utils/openUrl'; import Button from '../../ui/Button'; import { SettingsStatusLine } from '../controls'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; import SettingsPanel from '../layout/SettingsPanel'; -import SubscriptionPlans from './billing/SubscriptionPlans'; -import { buildPlanId } from './billingHelpers'; +import InferenceBudget from './billing/InferenceBudget'; + +const log = createDebug('openhuman:billing:panel'); +const formatUsd = (amount: number): string => `$${amount.toFixed(2)}`; const BillingPanel = () => { const { t } = useT(); const { navigateBack } = useSettingsNavigation(); - const [currentTier, setCurrentTier] = useState('FREE'); - const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly'); - const [paymentMethod, setPaymentMethod] = useState<'card' | 'crypto'>('card'); - const [isPurchasing, setIsPurchasing] = useState(false); - const [purchasingTier, setPurchasingTier] = useState(null); + const [summary, setSummary] = useState(null); + const [teamUsage, setTeamUsage] = useState(null); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [planLoading, setPlanLoading] = useState(true); - const [planKnown, setPlanKnown] = useState(false); - const paymentConfirmed = false; useEffect(() => { - billingApi - .getCurrentPlan() - .then(data => { - setCurrentTier(data.plan); - setPlanKnown(true); - }) - .catch(err => setError(err instanceof Error ? err.message : String(err))) - .finally(() => setPlanLoading(false)); - }, []); + let cancelled = false; + + const load = async () => { + log('loading billing summary and cycle usage'); + const [summaryResult, usageResult] = await Promise.allSettled([ + billingApi.getSummary(), + creditsApi.getTeamUsage(), + ]); + if (cancelled) return; + + if (summaryResult.status === 'fulfilled') { + setSummary(summaryResult.value); + } else { + log('summary load failed error=%s', String(summaryResult.reason)); + } - const handleSetPaymentMethod = (method: 'card' | 'crypto') => { - setPaymentMethod(method); - if (method === 'crypto') setBillingInterval('annual'); - }; - - const handleUpgrade = async (tier: PlanTier): Promise => { - setError(null); - setIsPurchasing(true); - setPurchasingTier(tier); - try { - if (paymentMethod === 'crypto') { - const charge = await billingApi.createCoinbaseCharge(tier); - await openUrl(charge.hostedUrl); + if (usageResult.status === 'fulfilled') { + setTeamUsage(usageResult.value); } else { - const session = await billingApi.purchasePlan(buildPlanId(tier, billingInterval)); - if (session.checkoutUrl) { - await openUrl(session.checkoutUrl); - } else { - throw new Error('Checkout session did not return a redirect URL'); - } + log('usage load failed error=%s', String(usageResult.reason)); + } + + const failure = + summaryResult.status === 'rejected' + ? summaryResult.reason + : usageResult.status === 'rejected' + ? usageResult.reason + : null; + if (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setIsPurchasing(false); - setPurchasingTier(null); - } - }; + setLoading(false); + log('billing state applied summary=%s usage=%s', summaryResult.status, usageResult.status); + }; + + void load(); + return () => { + cancelled = true; + }; + }, []); + + // Only /teams/me/usage includes the remaining subscription-cycle allowance. + // The summary's totalUsd is wallet-only (promotion + top-up), so it is not a + // safe fallback for the account's true available balance. + const availableUsd = teamUsage?.remainingUsd; + const topUpUrl = summary?.links.topUpUrl ?? `${BILLING_DASHBOARD_URL}?tab=billing`; + const manageUrl = summary?.links.manageUrl ?? BILLING_DASHBOARD_URL; return ( - - + + +
+
+

+ {t('settings.billing.movedToWeb')} +

+

{t('settings.billing.movedToWebDesc')}

+
+ +
+ + + + +
+
+ +
- +