From 266a03b2c2a9650980e44b00916315de30e33f5b Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Fri, 10 Apr 2026 14:21:12 +0530 Subject: [PATCH] fix(billing): normalize TeamUsage API response to prevent crash on navigation getTeamUsage() returned raw backend JSON without normalization, so undefined/null numeric fields caused .toFixed() TypeErrors that crashed the billing page. Add normalizeTeamUsage() (matching the existing normalizeCreditBalance pattern), defensive ?? 0 guards on .toFixed() call sites, and switch BillingPanel to Promise.allSettled for partial rendering on API failure. Closes #482 --- .claude/memory.md | 2 + .../settings/panels/BillingPanel.tsx | 43 +++++++---- .../panels/billing/InferenceBudget.tsx | 6 +- app/src/pages/Conversations.tsx | 8 +- .../services/api/__tests__/creditsApi.test.ts | 76 ++++++++++++++++++- app/src/services/api/creditsApi.ts | 32 +++++++- 6 files changed, 143 insertions(+), 24 deletions(-) diff --git a/.claude/memory.md b/.claude/memory.md index 554f7e579f..d0cb6f9818 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -87,6 +87,8 @@ Quick reference for anyone starting with Claude on this project. Updated by the - **Banner dismiss state uses localStorage** (prefix `openhuman:upsell:`), not Redux — consistent with CLAUDE.md exception for ephemeral UI state. - **Phased rollout** — Phase 1 = banners + limit modal + hook. Phase 2 = onboarding upsell + analytics. Phase 3 = remote config + A/B testing. - **"5-hour" label stragglers in Conversations.tsx** — `LimitPill` label and its hover tooltip still say "5h" / "5-hour". Commit 8c52236's "10-hour" terminology refactor missed those two spots. +- **`getTeamUsage()` now normalizes via `normalizeTeamUsage()`** — Added in issue #482. The Rust sidecar passes backend JSON through opaquely (`src/openhuman/team/ops.rs`), so the TS client must normalize field names and types. Pattern matches existing `normalizeCreditBalance()` in the same file. Any new billing API that returns raw backend data should follow the same normalize-at-the-client pattern. +- **Two separate `TeamUsage` types exist** — `creditsApi.ts:24` (billing: cycle budget, limits) and `types/team.ts:11` (team model: daily token limit). Different import paths, no collision, but confusing. ## Settings & Skills Reorganization (Issue #396) diff --git a/app/src/components/settings/panels/BillingPanel.tsx b/app/src/components/settings/panels/BillingPanel.tsx index ec8a98f55d..d0d06295b3 100644 --- a/app/src/components/settings/panels/BillingPanel.tsx +++ b/app/src/components/settings/panels/BillingPanel.tsx @@ -88,21 +88,34 @@ const BillingPanel = () => { // Fetch current plan, credits balance, and team usage on mount useEffect(() => { setIsLoadingCredits(true); - Promise.all([billingApi.getCurrentPlan(), creditsApi.getBalance(), creditsApi.getTeamUsage()]) - .then(([plan, balance, usage]) => { - log( - '[load] plan=%s active=%s weeklyBudget=%s', - plan.plan, - plan.hasActiveSubscription, - plan.weeklyBudgetUsd - ); - setCurrentPlan(plan); - setCreditBalance(balance); - setTeamUsage(usage); - }) - .catch(error => { - log('[load] failed: %O', error); - console.error(error); + Promise.allSettled([ + billingApi.getCurrentPlan(), + creditsApi.getBalance(), + creditsApi.getTeamUsage(), + ]) + .then(([planResult, balanceResult, usageResult]) => { + if (planResult.status === 'fulfilled') { + const plan = planResult.value; + log( + '[load] plan=%s active=%s weeklyBudget=%s', + plan.plan, + plan.hasActiveSubscription, + plan.weeklyBudgetUsd + ); + setCurrentPlan(plan); + } else { + log('[load] getCurrentPlan failed: %O', planResult.reason); + } + if (balanceResult.status === 'fulfilled') { + setCreditBalance(balanceResult.value); + } else { + log('[load] getBalance failed: %O', balanceResult.reason); + } + if (usageResult.status === 'fulfilled') { + setTeamUsage(usageResult.value); + } else { + log('[load] getTeamUsage failed: %O', usageResult.reason); + } }) .finally(() => setIsLoadingCredits(false)); }, []); diff --git a/app/src/components/settings/panels/billing/InferenceBudget.tsx b/app/src/components/settings/panels/billing/InferenceBudget.tsx index 0074e01e7f..da67d2fb6b 100644 --- a/app/src/components/settings/panels/billing/InferenceBudget.tsx +++ b/app/src/components/settings/panels/billing/InferenceBudget.tsx @@ -13,7 +13,7 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps) {teamUsage && !isLoadingCredits && ( {teamUsage.cycleBudgetUsd > 0 - ? `$${teamUsage.remainingUsd.toFixed(2)} / $${teamUsage.cycleBudgetUsd.toFixed(2)} remaining` + ? `$${(teamUsage.remainingUsd ?? 0).toFixed(2)} / $${(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)} remaining` : 'No recurring plan budget'} )} @@ -40,8 +40,8 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps)
- 5-hour cap: ${teamUsage.cycleLimit5hr.toFixed(2)} / $ - {teamUsage.fiveHourCapUsd.toFixed(2)} + 5-hour cap: ${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $ + {(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)} Cycle ends {new Date(teamUsage.cycleEndsAt).toLocaleDateString('en-US')} diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 9c6ef6cbfc..3d48f9bacf 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -1290,8 +1290,8 @@ const Conversations = () => {
5-hour limit - ${teamUsage.cycleLimit5hr.toFixed(2)} / $ - {teamUsage.fiveHourCapUsd.toFixed(2)} + ${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $ + {(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)} {teamUsage.fiveHourResetsAt && ( — resets {formatResetTime(teamUsage.fiveHourResetsAt)} @@ -1303,8 +1303,8 @@ const Conversations = () => {
Weekly limit - ${teamUsage.remainingUsd.toFixed(2)} / $ - {teamUsage.cycleBudgetUsd.toFixed(2)} left + ${(teamUsage.remainingUsd ?? 0).toFixed(2)} / $ + {(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)} left {teamUsage.cycleEndsAt && ( — resets {formatResetTime(teamUsage.cycleEndsAt)} diff --git a/app/src/services/api/__tests__/creditsApi.test.ts b/app/src/services/api/__tests__/creditsApi.test.ts index 9fbe7c8758..8a39add22c 100644 --- a/app/src/services/api/__tests__/creditsApi.test.ts +++ b/app/src/services/api/__tests__/creditsApi.test.ts @@ -6,7 +6,7 @@ vi.mock('../../coreCommandClient', () => ({ callCoreCommand: (...args: unknown[]) => mockCallCoreCommand(...args), })); -const { creditsApi, normalizeCouponRedeemResult, normalizeRedeemedCoupon } = +const { creditsApi, normalizeCouponRedeemResult, normalizeRedeemedCoupon, normalizeTeamUsage } = await import('../creditsApi'); describe('creditsApi coupon helpers', () => { @@ -103,3 +103,77 @@ describe('creditsApi coupon helpers', () => { expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_get_coupons'); }); }); + +describe('normalizeTeamUsage', () => { + it('passes through well-formed camelCase fields', () => { + const input = { + remainingUsd: 12.5, + cycleBudgetUsd: 25, + cycleLimit5hr: 3.2, + cycleLimit7day: 18, + fiveHourCapUsd: 5, + fiveHourResetsAt: '2026-04-09T18:00:00Z', + cycleStartDate: '2026-04-07T00:00:00Z', + cycleEndsAt: '2026-04-14T00:00:00Z', + bypassCycleLimit: false, + }; + expect(normalizeTeamUsage(input)).toEqual(input); + }); + + it('maps snake_case backend fields to camelCase', () => { + const result = normalizeTeamUsage({ + remaining_usd: 10, + cycle_budget_usd: 20, + five_hour_spend_usd: 2.5, + cycle_limit_7day: 15, + five_hour_cap_usd: 5, + five_hour_resets_at: '2026-04-09T18:00:00Z', + cycle_start_date: '2026-04-07T00:00:00Z', + cycle_ends_at: '2026-04-14T00:00:00Z', + bypass_cycle_limit: true, + }); + expect(result.remainingUsd).toBe(10); + expect(result.cycleBudgetUsd).toBe(20); + expect(result.cycleLimit5hr).toBe(2.5); + expect(result.cycleLimit7day).toBe(15); + expect(result.fiveHourCapUsd).toBe(5); + expect(result.fiveHourResetsAt).toBe('2026-04-09T18:00:00Z'); + expect(result.bypassCycleLimit).toBe(true); + }); + + it('maps legacy fiveHourSpendUsd to cycleLimit5hr', () => { + const result = normalizeTeamUsage({ fiveHourSpendUsd: 4.0 }); + expect(result.cycleLimit5hr).toBe(4.0); + }); + + it('returns safe defaults for empty object', () => { + const result = normalizeTeamUsage({}); + expect(result.remainingUsd).toBe(0); + expect(result.cycleBudgetUsd).toBe(0); + expect(result.cycleLimit5hr).toBe(0); + expect(result.cycleLimit7day).toBe(0); + expect(result.fiveHourCapUsd).toBe(0); + expect(result.fiveHourResetsAt).toBeNull(); + expect(result.bypassCycleLimit).toBe(false); + expect(typeof result.cycleStartDate).toBe('string'); + expect(typeof result.cycleEndsAt).toBe('string'); + }); + + it('does not crash on null or undefined input', () => { + expect(() => normalizeTeamUsage(null)).not.toThrow(); + expect(() => normalizeTeamUsage(undefined)).not.toThrow(); + const result = normalizeTeamUsage(null); + expect(result.remainingUsd).toBe(0); + expect(result.cycleLimit5hr).toBe(0); + }); + + it('getTeamUsage normalizes the RPC response', async () => { + mockCallCoreCommand.mockResolvedValueOnce({ remaining_usd: 8, cycle_budget_usd: 25 }); + + const result = await creditsApi.getTeamUsage(); + expect(result.remainingUsd).toBe(8); + expect(result.cycleBudgetUsd).toBe(25); + expect(result.cycleLimit5hr).toBe(0); + expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.team_get_usage'); + }); +}); diff --git a/app/src/services/api/creditsApi.ts b/app/src/services/api/creditsApi.ts index e0b1ac8c5e..8d7342f438 100644 --- a/app/src/services/api/creditsApi.ts +++ b/app/src/services/api/creditsApi.ts @@ -209,6 +209,35 @@ function normalizeCreditBalance(payload: unknown): CreditBalance { }; } +export function normalizeTeamUsage(payload: unknown): TeamUsage { + const raw = (payload && typeof payload === 'object' ? payload : {}) as Record; + return { + remainingUsd: normalizeUsd(raw.remainingUsd ?? raw.remaining_usd), + cycleBudgetUsd: normalizeUsd(raw.cycleBudgetUsd ?? raw.cycle_budget_usd), + cycleLimit5hr: normalizeUsd( + raw.cycleLimit5hr ?? raw.fiveHourSpendUsd ?? raw.five_hour_spend_usd + ), + cycleLimit7day: normalizeUsd(raw.cycleLimit7day ?? raw.cycle_limit_7day), + fiveHourCapUsd: normalizeUsd(raw.fiveHourCapUsd ?? raw.five_hour_cap_usd), + fiveHourResetsAt: asStringOrNull(raw.fiveHourResetsAt ?? raw.five_hour_resets_at), + cycleStartDate: + typeof raw.cycleStartDate === 'string' + ? raw.cycleStartDate + : typeof raw.cycle_start_date === 'string' + ? raw.cycle_start_date + : new Date().toISOString(), + cycleEndsAt: + typeof raw.cycleEndsAt === 'string' + ? raw.cycleEndsAt + : typeof raw.cycle_ends_at === 'string' + ? raw.cycle_ends_at + : new Date().toISOString(), + bypassCycleLimit: Boolean( + raw.bypassCycleLimit ?? raw.bypassRateLimit ?? raw.bypass_cycle_limit + ), + }; +} + /** * Credits API endpoints */ @@ -227,7 +256,8 @@ export const creditsApi = { * GET /teams/me/usage */ getTeamUsage: async (): Promise => { - return await callCoreCommand('openhuman.team_get_usage'); + const result = await callCoreCommand('openhuman.team_get_usage'); + return normalizeTeamUsage(result); }, /**