fix(billing): normalize TeamUsage API response to prevent crash on navigation (#488)

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
This commit is contained in:
Cyrus Gray
2026-04-10 14:23:44 +05:30
committed by GitHub
parent ce5523c34f
commit f32c0d59f6
6 changed files with 143 additions and 24 deletions
+2
View File
@@ -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)
@@ -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));
}, []);
@@ -13,7 +13,7 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps)
{teamUsage && !isLoadingCredits && (
<span className="text-xs text-stone-400">
{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'}
</span>
)}
@@ -40,8 +40,8 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps)
</div>
<div className="mt-1 flex items-center justify-between">
<span className="text-[11px] text-stone-500">
5-hour cap: ${teamUsage.cycleLimit5hr.toFixed(2)} / $
{teamUsage.fiveHourCapUsd.toFixed(2)}
5-hour cap: ${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $
{(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)}
</span>
<span className="text-[11px] text-stone-500">
Cycle ends {new Date(teamUsage.cycleEndsAt).toLocaleDateString('en-US')}
+4 -4
View File
@@ -1290,8 +1290,8 @@ const Conversations = () => {
<div className="flex items-center justify-between gap-4">
<span className="text-stone-400">5-hour limit</span>
<span>
${teamUsage.cycleLimit5hr.toFixed(2)} / $
{teamUsage.fiveHourCapUsd.toFixed(2)}
${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $
{(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)}
{teamUsage.fiveHourResetsAt && (
<span className="text-stone-400 ml-1">
resets {formatResetTime(teamUsage.fiveHourResetsAt)}
@@ -1303,8 +1303,8 @@ const Conversations = () => {
<div className="flex items-center justify-between gap-4">
<span className="text-stone-400">Weekly limit</span>
<span>
${teamUsage.remainingUsd.toFixed(2)} / $
{teamUsage.cycleBudgetUsd.toFixed(2)} left
${(teamUsage.remainingUsd ?? 0).toFixed(2)} / $
{(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)} left
{teamUsage.cycleEndsAt && (
<span className="text-stone-400 ml-1">
resets {formatResetTime(teamUsage.cycleEndsAt)}
@@ -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');
});
});
+31 -1
View File
@@ -209,6 +209,35 @@ function normalizeCreditBalance(payload: unknown): CreditBalance {
};
}
export function normalizeTeamUsage(payload: unknown): TeamUsage {
const raw = (payload && typeof payload === 'object' ? payload : {}) as Record<string, unknown>;
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<TeamUsage> => {
return await callCoreCommand<TeamUsage>('openhuman.team_get_usage');
const result = await callCoreCommand<TeamUsage>('openhuman.team_get_usage');
return normalizeTeamUsage(result);
},
/**