mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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')}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user