diff --git a/src/features/quota/hooks/useQuotaActions.ts b/src/features/quota/hooks/useQuotaActions.ts index 7be118c3..3a63319d 100644 --- a/src/features/quota/hooks/useQuotaActions.ts +++ b/src/features/quota/hooks/useQuotaActions.ts @@ -14,6 +14,7 @@ import { import type { AuthFileItem } from '@/types'; import { getStatusFromError } from '@/utils/quota'; import { getQuotaCacheKey } from '@/utils/quota/identity'; +import { enrichQuotaInBackground } from '../quotaEnrichment'; import { getQuotaMap, getQuotaSetter, type QuotaAdapter, type QuotaCardState } from '../providers'; const getQuotaState = (adapter: QuotaAdapter, file: AuthFileItem): QuotaCardState | undefined => @@ -42,10 +43,12 @@ export function useQuotaActions(disableControls: boolean) { try { const data = await adapter.fetchQuota(file, t); commitIfQuotaCacheCurrent(cacheGeneration, () => { + const successState = adapter.buildSuccessState(data); setQuota((prev) => ({ ...prev, - [cacheKey]: adapter.buildSuccessState(data), + [cacheKey]: successState, })); + void enrichQuotaInBackground(adapter, file, data, successState, t); showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success'); }); } catch (err: unknown) { diff --git a/src/features/quota/hooks/useQuotaBatchLoader.ts b/src/features/quota/hooks/useQuotaBatchLoader.ts index 2efd2268..f9538b16 100644 --- a/src/features/quota/hooks/useQuotaBatchLoader.ts +++ b/src/features/quota/hooks/useQuotaBatchLoader.ts @@ -14,7 +14,8 @@ import { captureQuotaCacheGeneration, commitIfQuotaCacheCurrent } from '@/stores import { getStatusFromError } from '@/utils/quota'; import { getQuotaCacheKey } from '@/utils/quota/identity'; import type { QuotaFileEntry } from '../logic'; -import { QUOTA_ADAPTERS, getQuotaSetter } from '../providers'; +import { QUOTA_ADAPTERS, getQuotaSetter, type QuotaCardState } from '../providers'; +import { enrichQuotaInBackground } from '../quotaEnrichment'; import type { QuotaProviderType } from '../providers/types'; interface BatchFetchResult { @@ -85,6 +86,7 @@ export function useQuotaBatchLoader() { if (requestId !== requestIdRef.current) return; + const committedStates = new Map(); setQuota((prev) => { const nextState = { ...prev }; results.forEach((result) => { @@ -98,12 +100,19 @@ export function useQuotaBatchLoader() { result.error || t('common.unknown_error'), result.errorStatus ); + committedStates.set(result.cacheKey, nextState[result.cacheKey]); }, result.name ); }); return nextState; }); + results.forEach((result, index) => { + const state = committedStates.get(result.cacheKey); + if (result.status === 'success' && state) { + void enrichQuotaInBackground(adapter, entries[index].file, result.data, state, t); + } + }); }) ); } finally { diff --git a/src/features/quota/providers/index.ts b/src/features/quota/providers/index.ts index 796915a7..f8121210 100644 --- a/src/features/quota/providers/index.ts +++ b/src/features/quota/providers/index.ts @@ -38,6 +38,7 @@ export interface QuotaAdapter { i18nPrefix: string; filterFn: (file: AuthFileItem) => boolean; fetchQuota: (file: AuthFileItem, t: TFunction) => Promise; + enrichQuota?: (file: AuthFileItem, data: unknown, t: TFunction) => Promise; resetQuota?: (file: AuthFileItem, t: TFunction) => Promise; canResetQuota?: (quota: QuotaCardState) => boolean; storeSelector: (state: QuotaStore) => Record; diff --git a/src/features/quota/providers/types.ts b/src/features/quota/providers/types.ts index 5f9eb36b..6962137a 100644 --- a/src/features/quota/providers/types.ts +++ b/src/features/quota/providers/types.ts @@ -46,6 +46,8 @@ export interface QuotaProviderData { i18nPrefix: string; filterFn: (file: AuthFileItem) => boolean; fetchQuota: (file: AuthFileItem, t: TFunction) => Promise; + /** Optional details loaded only after the primary quota has been committed. */ + enrichQuota?: (file: AuthFileItem, data: TData, t: TFunction) => Promise; resetQuota?: (file: AuthFileItem, t: TFunction) => Promise; canResetQuota?: (quota: TState) => boolean; storeSelector: (state: QuotaStore) => Record; diff --git a/src/features/quota/providers/xai/XaiQuotaBody.tsx b/src/features/quota/providers/xai/XaiQuotaBody.tsx index 4f5c98f0..20bc3096 100644 --- a/src/features/quota/providers/xai/XaiQuotaBody.tsx +++ b/src/features/quota/providers/xai/XaiQuotaBody.tsx @@ -51,6 +51,15 @@ const formatXaiPercent = (value: number | null): string => { const XAI_SUPERGROK_LIMIT_CENTS = 15_000; const XAI_SUPERGROK_HEAVY_LIMIT_CENTS = 150_000; +const planValueClass = ( + tier: XaiBillingSummary['planTier'], + classes: QuotaBodyProps['classes'] +) => { + if (tier === 'elite') return classes.elitePlanValue; + if (tier === 'premium') return classes.premiumPlanValue; + return classes.codexPlanValue; +}; + const resolveXaiPlan = ( monthlyLimitCents: number | null ): { labelKey: string; premium: boolean } | null => { @@ -85,7 +94,15 @@ export function XaiQuotaBody({ quota, classes }: QuotaBodyProps) <>
{t('xai_quota.plan_label')} - {t('xai_quota.plan_paid')} + + {billing.planLabel ?? t('xai_quota.plan_paid')} +
{t('xai_quota.paid_health')}
@@ -138,13 +155,45 @@ export function XaiQuotaBody({ quota, classes }: QuotaBodyProps) Boolean(billing.billingPeriodEnd)) && !(hasWeeklyData && billing.monthlyLimitCents === 0 && billing.usedCents === 0); + const subscriptionLabel = billing.planLabel ?? (plan ? t(`xai_quota.${plan.labelKey}`) : null); + const subscriptionClass = billing.planLabel + ? planValueClass(billing.planTier, classes) + : plan?.premium + ? classes.premiumPlanValue + : classes.codexPlanValue; + const headerReset = buildResetDisplay( + null, + hasWeeklyData ? billing.resetAtMs : parseIsoToMs(billing.billingPeriodEnd), + now, + locale + ); + return ( <> - {plan && ( + {(subscriptionLabel || headerReset) && (
- {t('xai_quota.plan_label')} - - {t(`xai_quota.${plan.labelKey}`)} + {subscriptionLabel && ( + + {t('xai_quota.plan_label')} + {subscriptionLabel} + + )} + {headerReset && ( + + {t('xai_quota.resets_label')} + {headerReset.absolute} + {headerReset.relative && ( + {headerReset.relative} + )} + + )} +
+ )} + {typeof billing.prepaidBalanceCents === 'number' && billing.prepaidBalanceCents > 0 && ( +
+ {t('xai_quota.prepaid')} + + {formatUsdFromCents(billing.prepaidBalanceCents)}
)} diff --git a/src/features/quota/providers/xai/data.ts b/src/features/quota/providers/xai/data.ts index aff5a472..23513bdd 100644 --- a/src/features/quota/providers/xai/data.ts +++ b/src/features/quota/providers/xai/data.ts @@ -12,6 +12,8 @@ import { XAI_API_REQUEST_HEADERS, XAI_BILLING_MONTHLY_URL, XAI_BILLING_WEEKLY_URL, + XAI_SETTINGS_URL, + XAI_USER_URL, XAI_PAID_HEALTH_MODEL, XAI_REQUEST_HEADERS, normalizeStringValue, @@ -19,6 +21,7 @@ import { buildXaiBillingSummary, buildXaiPaidHealthSummary, mergeXaiBillingSummaries, + resolveXaiSubscriptionPlan, createStatusError, isDisabledAuthFile, isPaidXaiAuthFile, @@ -28,6 +31,7 @@ import { normalizeAuthIndex } from '@/utils/authIndex'; import type { QuotaProviderData } from '../types'; const XAI_PAID_HEALTH_REQUEST_TIMEOUT_MS = 15000; +const XAI_SUBSCRIPTION_REQUEST_TIMEOUT_MS = 8000; const toXaiRecord = (value: unknown): Record | null => { return value && typeof value === 'object' && !Array.isArray(value) @@ -145,6 +149,58 @@ const requestXaiPaidHealth = async (authIndex: string): Promise { + if (result.statusCode < 200 || result.statusCode >= 300) return null; + const body = result.body ?? result.bodyText; + if (typeof body === 'string') { + try { + const parsed = JSON.parse(body) as unknown; + return toXaiRecord(parsed); + } catch { + return null; + } + } + return toXaiRecord(body); +}; + +const readPlanField = (record: Record | null, keys: string[]) => { + if (!record) return null; + for (const key of keys) { + const value = normalizeStringValue(record[key]); + if (value) return value; + } + return null; +}; + +const requestXaiSubscription = async (authIndex: string) => { + const header = { ...XAI_REQUEST_HEADERS }; + const [userResult, settingsResult] = await Promise.allSettled([ + apiCallApi.request( + { authIndex, method: 'GET', url: XAI_USER_URL, header }, + { timeout: XAI_SUBSCRIPTION_REQUEST_TIMEOUT_MS } + ), + apiCallApi.request( + { authIndex, method: 'GET', url: XAI_SETTINGS_URL, header }, + { timeout: XAI_SUBSCRIPTION_REQUEST_TIMEOUT_MS } + ), + ]); + const user = userResult.status === 'fulfilled' ? readJsonRecord(userResult.value) : null; + const settings = + settingsResult.status === 'fulfilled' ? readJsonRecord(settingsResult.value) : null; + return resolveXaiSubscriptionPlan( + readPlanField(user, ['subscriptionTier', 'subscription_tier']), + readPlanField(settings, ['subscription_tier_display', 'subscriptionTierDisplay']) + ); +}; + +const withSubscriptionPlan = ( + summary: XaiBillingSummary, + plan: { label: string; tier: 'elite' | 'premium' | 'standard' } | null +): XaiBillingSummary => { + if (!plan) return summary; + return { ...summary, planLabel: plan.label, planTier: plan.tier }; +}; + const fetchXaiQuota = async (file: AuthFileItem, t: TFunction): Promise => { const rawAuthIndex = file['auth_index'] ?? file.authIndex; const authIndex = normalizeAuthIndex(rawAuthIndex); @@ -184,6 +240,11 @@ export const XAI_CONFIG: QuotaProviderData = { i18nPrefix: 'xai_quota', filterFn: (file) => isXaiFile(file) && !isDisabledAuthFile(file), fetchQuota: fetchXaiQuota, + enrichQuota: async (file, summary) => { + const authIndex = normalizeAuthIndex(file['auth_index'] ?? file.authIndex); + if (!authIndex) return summary; + return withSubscriptionPlan(summary, await requestXaiSubscription(authIndex)); + }, storeSelector: (state) => state.xaiQuota, storeSetter: 'setXaiQuota', buildLoadingState: () => ({ status: 'loading', billing: null }), diff --git a/src/features/quota/quotaEnrichment.ts b/src/features/quota/quotaEnrichment.ts new file mode 100644 index 00000000..79d9a6b6 --- /dev/null +++ b/src/features/quota/quotaEnrichment.ts @@ -0,0 +1,38 @@ +import type { TFunction } from 'i18next'; +import type { AuthFileItem } from '@/types'; +import { + captureQuotaCacheGeneration, + commitIfQuotaCacheCurrent, + useQuotaStore, +} from '@/stores/useQuotaStore'; +import { getQuotaCacheKey } from '@/utils/quota/identity'; +import type { QuotaAdapter, QuotaCardState, QuotaMapUpdater } from './providers'; + +/** Optional metadata must neither delay quota rendering nor replace a newer result. */ +export async function enrichQuotaInBackground( + adapter: QuotaAdapter, + file: AuthFileItem, + data: unknown, + expectedState: QuotaCardState, + t: TFunction +): Promise { + if (!adapter.enrichQuota) return; + const cacheKey = getQuotaCacheKey(file); + const currentState = () => adapter.storeSelector(useQuotaStore.getState())[cacheKey]; + if (currentState() !== expectedState) return; + const generation = captureQuotaCacheGeneration(file.name); + + try { + const enriched = await adapter.enrichQuota(file, data, t); + if (enriched === data) return; + commitIfQuotaCacheCurrent(generation, () => { + const setQuota = useQuotaStore.getState()[adapter.storeSetter] as QuotaMapUpdater; + setQuota((prev) => { + if (prev[cacheKey] !== expectedState) return prev; + return { ...prev, [cacheKey]: adapter.buildSuccessState(enriched) }; + }); + }); + } catch { + // Keep the successful quota and its original plan fallback on optional failures. + } +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7e4b13dc..e0514ba6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -624,6 +624,8 @@ "reset_at": "Resets {{time}}", "product_usage": "{{product}} usage", "plan_label": "Plan", + "resets_label": "Resets", + "prepaid": "Prepaid", "plan_supergrok": "SuperGrok", "plan_supergrok_heavy": "SuperGrok Heavy", "plan_paid": "Paid", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index cd634d0a..21b82868 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -627,6 +627,8 @@ "reset_at": "Сброс {{time}}", "product_usage": "Использование {{product}}", "plan_label": "План", + "resets_label": "Сброс", + "prepaid": "Предоплата", "plan_supergrok": "SuperGrok", "plan_supergrok_heavy": "SuperGrok Heavy", "plan_paid": "Платный", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 72e7cfcb..163efa93 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -622,6 +622,8 @@ "reset_at": "重置 {{time}}", "product_usage": "{{product}} 使用", "plan_label": "套餐", + "resets_label": "重置", + "prepaid": "预付", "plan_supergrok": "SuperGrok", "plan_supergrok_heavy": "SuperGrok Heavy", "plan_paid": "付费", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 8e748a5e..e3fb75fd 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -622,6 +622,8 @@ "reset_at": "重置 {{time}}", "product_usage": "{{product}} 使用", "plan_label": "方案", + "resets_label": "重置", + "prepaid": "預付", "plan_supergrok": "SuperGrok", "plan_supergrok_heavy": "SuperGrok Heavy", "plan_paid": "付費", diff --git a/src/types/quota.ts b/src/types/quota.ts index 2f9fd031..4d0a2dc6 100644 --- a/src/types/quota.ts +++ b/src/types/quota.ts @@ -405,6 +405,8 @@ export interface XaiBillingConfig { on_demand_cap?: XaiBillingCent | number | string | null; onDemandUsed?: XaiBillingCent | number | string | null; on_demand_used?: XaiBillingCent | number | string | null; + prepaidBalance?: XaiBillingCent | number | string | null; + prepaid_balance?: XaiBillingCent | number | string | null; billingPeriodStart?: string; billing_period_start?: string; billingPeriodEnd?: string; @@ -440,6 +442,10 @@ export interface XaiBillingSummary { onDemandCapCents: number | null; onDemandUsedCents: number | null; onDemandUsedPercent: number | null; + prepaidBalanceCents?: number | null; + /** Display name from Grok settings, for example "SuperGrok Heavy". */ + planLabel?: string; + planTier?: 'elite' | 'premium' | 'standard'; billingPeriodStart?: string; billingPeriodEnd?: string; usedPercent: number | null; diff --git a/src/utils/quota/builders.ts b/src/utils/quota/builders.ts index 901b1b91..ff9de0f8 100644 --- a/src/utils/quota/builders.ts +++ b/src/utils/quota/builders.ts @@ -426,6 +426,7 @@ const emptyXaiBillingSummary = (): XaiBillingSummary => ({ onDemandCapCents: null, onDemandUsedCents: null, onDemandUsedPercent: null, + prepaidBalanceCents: null, usedPercent: null, }); @@ -450,6 +451,22 @@ function xaiPeriodInstants( return { resetAtMs, periodHours }; } +export function resolveXaiSubscriptionPlan( + tier: string | null | undefined, + display: string | null | undefined +): { label: string; tier: 'elite' | 'premium' | 'standard' } | null { + const label = normalizeStringValue(display) ?? normalizeStringValue(tier); + if (!label) return null; + const key = `${display ?? ''} ${tier ?? ''}`.toLowerCase().replace(/[^a-z0-9]+/g, ''); + const planTier = + key.includes('heavy') + ? 'elite' + : key.includes('supergrok') || key.includes('premium') + ? 'premium' + : 'standard'; + return { label, tier: planTier }; +} + export function buildXaiBillingSummary( config: XaiBillingConfig | null | undefined ): XaiBillingSummary | null { @@ -496,6 +513,9 @@ export function buildXaiBillingSummary( ? Math.max(0, usedCents - monthlyLimitCents) : null; const onDemandUsedCents = explicitOnDemandUsedCents ?? derivedOnDemandUsedCents; + const prepaidBalanceCents = normalizeXaiCentValue( + config.prepaidBalance ?? config.prepaid_balance + ); const usedPercent = monthlyLimitCents !== null && monthlyLimitCents > 0 && includedUsedCents !== null ? (includedUsedCents / monthlyLimitCents) * 100 @@ -529,6 +549,7 @@ export function buildXaiBillingSummary( summary.onDemandCapCents = onDemandCapCents; summary.onDemandUsedCents = onDemandUsedCents; summary.onDemandUsedPercent = onDemandUsedPercent; + summary.prepaidBalanceCents = prepaidBalanceCents; summary.billingPeriodStart = hasMonthlyData ? billingPeriodStart : undefined; summary.billingPeriodEnd = hasMonthlyData ? billingPeriodEnd : undefined; summary.usedPercent = usedPercent; @@ -578,6 +599,9 @@ export function mergeXaiBillingSummaries( onDemandCapCents: primary.onDemandCapCents ?? fallback.onDemandCapCents, onDemandUsedCents: primary.onDemandUsedCents ?? fallback.onDemandUsedCents, onDemandUsedPercent: primary.onDemandUsedPercent ?? fallback.onDemandUsedPercent, + prepaidBalanceCents: primary.prepaidBalanceCents ?? fallback.prepaidBalanceCents, + planLabel: primary.planLabel ?? fallback.planLabel, + planTier: primary.planTier ?? fallback.planTier, billingPeriodStart: primary.billingPeriodStart ?? fallback.billingPeriodStart, billingPeriodEnd: primary.billingPeriodEnd ?? fallback.billingPeriodEnd, usedPercent: primary.usedPercent ?? fallback.usedPercent, diff --git a/src/utils/quota/constants.ts b/src/utils/quota/constants.ts index f0d2f61d..84af3878 100644 --- a/src/utils/quota/constants.ts +++ b/src/utils/quota/constants.ts @@ -154,6 +154,8 @@ export const KIMI_REQUEST_HEADERS = { // xAI/Grok API configuration export const XAI_BILLING_WEEKLY_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits'; export const XAI_BILLING_MONTHLY_URL = 'https://cli-chat-proxy.grok.com/v1/billing'; +export const XAI_USER_URL = 'https://cli-chat-proxy.grok.com/v1/user?include=subscription'; +export const XAI_SETTINGS_URL = 'https://cli-chat-proxy.grok.com/v1/settings'; export const XAI_API_ME_URL = 'https://api.x.ai/v1/me'; export const XAI_API_CHAT_URL = 'https://api.x.ai/v1/chat/completions'; export const XAI_PAID_HEALTH_MODEL = 'grok-4.5'; diff --git a/tests/xaiPaidQuotaFallback.test.ts b/tests/xaiPaidQuotaFallback.test.ts index 8bf6d8c8..b26449cf 100644 --- a/tests/xaiPaidQuotaFallback.test.ts +++ b/tests/xaiPaidQuotaFallback.test.ts @@ -78,7 +78,8 @@ describe('xAI paid OAuth quota fallback', () => { ); expect(requests.map((request) => request.url)).toEqual([XAI_API_ME_URL, XAI_API_CHAT_URL]); - expect(JSON.parse(requests[1]?.data ?? '{}')).toMatchObject({ + const chatRequest = requests.find((request) => request.url === XAI_API_CHAT_URL); + expect(JSON.parse(chatRequest?.data ?? '{}')).toMatchObject({ model: 'grok-4.5', max_tokens: 1, stream: false, diff --git a/tests/xaiQuotaEnrichment.test.ts b/tests/xaiQuotaEnrichment.test.ts new file mode 100644 index 00000000..fc238159 --- /dev/null +++ b/tests/xaiQuotaEnrichment.test.ts @@ -0,0 +1,124 @@ +import { afterEach, expect, test } from 'bun:test'; +import i18n from '@/i18n'; +import type { AuthFileItem } from '@/types'; +import { apiCallApi, type ApiCallResult } from '@/services/api'; +import { useQuotaStore } from '@/stores/useQuotaStore'; +import { XAI_CONFIG } from '@/features/quota/providers/xai/data'; +import type { QuotaAdapter } from '@/features/quota/providers'; +import { enrichQuotaInBackground } from '@/features/quota/quotaEnrichment'; +import { getQuotaCacheKey } from '@/utils/quota/identity'; +import { XAI_USER_URL, XAI_SETTINGS_URL, buildXaiBillingSummary } from '@/utils/quota'; + +const originalRequest = apiCallApi.request; +const file: AuthFileItem = { name: 'fixture.json', type: 'xai', auth_index: 'fixture' }; +const key = getQuotaCacheKey(file); +const adapter = XAI_CONFIG as unknown as QuotaAdapter; +const result = (body: unknown, statusCode = 200): ApiCallResult => ({ + statusCode, + body, + bodyText: JSON.stringify(body), + header: {}, +}); +const billing = () => + buildXaiBillingSummary({ monthlyLimit: { val: 15000 }, used: { val: 1500 } })!; +const current = () => useQuotaStore.getState().xaiQuota[key]; +const deferred = () => { + let resolve!: (value: ApiCallResult) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +}; +const beginEnrichment = () => { + const data = billing(); + const state = XAI_CONFIG.buildSuccessState(data); + useQuotaStore.getState().setXaiQuota({ [key]: state }); + const pending = enrichQuotaInBackground(adapter, file, data, state, i18n.t); + return { data, state, pending }; +}; + +afterEach(() => { + apiCallApi.request = originalRequest; + useQuotaStore.getState().clearQuotaCache(); +}); + +test('commits quota before requesting optional plan and updates it later', async () => { + const gate = deferred(); + const urls: string[] = []; + apiCallApi.request = async (request, config) => { + urls.push(request.url); + if (request.url === XAI_USER_URL || request.url === XAI_SETTINGS_URL) { + expect(current()?.status).toBe('success'); + expect(config?.timeout).toBe(8000); + return gate.promise; + } + return result({ config: { monthlyLimit: { val: 15000 }, used: { val: 1500 } } }); + }; + const data = await XAI_CONFIG.fetchQuota(file, i18n.t); + expect(urls).toHaveLength(2); + expect(urls).not.toContain(XAI_USER_URL); + const state = XAI_CONFIG.buildSuccessState(data); + useQuotaStore.getState().setXaiQuota({ [key]: state }); + const pending = enrichQuotaInBackground(adapter, file, data, state, i18n.t); + expect(current()).toBe(state); + expect(current().billing?.planLabel).toBeUndefined(); + gate.resolve(result({ subscription_tier_display: 'SuperGrok Heavy' })); + await pending; + expect(current().billing).toMatchObject({ + ...data, + planLabel: 'SuperGrok Heavy', + planTier: 'elite', + }); +}); + +for (const failure of ['network', 'http', 'malformed'] as const) { + test(`optional ${failure} failure preserves the exact original quota and fallback`, async () => { + apiCallApi.request = async () => { + if (failure === 'network') throw new Error('unavailable'); + return failure === 'http' ? result({}, 503) : result('not-json'); + }; + const { state, pending } = beginEnrichment(); + await pending; + expect(current()).toBe(state); + }); +} + +test('uses user tier when settings fails', async () => { + apiCallApi.request = async ({ url }) => { + if (url === XAI_SETTINGS_URL) throw new Error('unavailable'); + return result({ subscriptionTier: 'SuperGrok' }); + }; + const { pending } = beginEnrichment(); + await pending; + expect(current().billing).toMatchObject({ planLabel: 'SuperGrok', planTier: 'premium' }); +}); + +for (const change of ['session', 'file', 'new-result', 'refresh'] as const) { + test(`late subscription cannot overwrite ${change}`, async () => { + const gate = deferred(); + apiCallApi.request = async () => gate.promise; + const { pending } = beginEnrichment(); + if (change === 'session') useQuotaStore.getState().clearQuotaCache(); + if (change === 'file') useQuotaStore.getState().clearQuotaCache([file.name]); + if (change === 'new-result') { + useQuotaStore.getState().setXaiQuota({ [key]: XAI_CONFIG.buildSuccessState(billing()) }); + } + if (change === 'refresh') { + useQuotaStore.getState().setXaiQuota({ [key]: XAI_CONFIG.buildLoadingState() }); + } + const expected = current(); + gate.resolve(result({ subscription_tier_display: 'SuperGrok Heavy' })); + await pending; + expect(current()).toBe(expected); + }); +} + +test('unrelated file invalidation does not discard a valid subscription', async () => { + const gate = deferred(); + apiCallApi.request = async () => gate.promise; + const { pending } = beginEnrichment(); + useQuotaStore.getState().clearQuotaCache(['other.json']); + gate.resolve(result({ subscription_tier_display: 'SuperGrok Heavy' })); + await pending; + expect(current().billing?.planLabel).toBe('SuperGrok Heavy'); +}); diff --git a/tests/xaiQuotaUnavailable.test.ts b/tests/xaiQuotaUnavailable.test.ts index 1af3181f..35ef711c 100644 --- a/tests/xaiQuotaUnavailable.test.ts +++ b/tests/xaiQuotaUnavailable.test.ts @@ -15,8 +15,10 @@ import { XaiQuotaBody } from '@/features/quota/providers/xai/XaiQuotaBody'; import { QUOTA_CLASS_KEYS, bindQuotaClasses } from '@/features/quota/types'; import { buildXaiBillingSummary, + formatInstantShort, formatQuotaResetTime, mergeXaiBillingSummaries, + resolveXaiSubscriptionPlan, } from '@/utils/quota'; import type { XaiBillingConfig, XaiQuotaState } from '@/types'; @@ -67,6 +69,26 @@ beforeAll(async () => { await i18n.changeLanguage('en'); }); +describe('resolveXaiSubscriptionPlan', () => { + test('uses the settings display name and treats Heavy as the elite badge', () => { + expect(resolveXaiSubscriptionPlan('SuperGrokPro', 'SuperGrok Heavy')).toEqual({ + label: 'SuperGrok Heavy', + tier: 'elite', + }); + }); + + test('treats X Premium+ as the premium badge', () => { + expect(resolveXaiSubscriptionPlan('XPremiumPlus', 'X Premium+')).toEqual({ + label: 'X Premium+', + tier: 'premium', + }); + }); + + test('returns null when neither call produced a name', () => { + expect(resolveXaiSubscriptionPlan(null, ' ')).toBeNull(); + }); +}); + describe('XaiQuotaBody unavailable weekly usage', () => { test('reports unavailable instead of a fabricated zero when usagePercent is missing', () => { const quota = quotaFor(weeklyConfig(), monthlyConfig()); @@ -151,6 +173,50 @@ describe('XaiQuotaBody unavailable weekly usage', () => { expect(markup).toContain('Monthly credits'); }); + test('shows the Grok subscription name and the weekly reset beside it', () => { + const quota = quotaFor( + weeklyConfig({ + creditUsagePercent: 94, + prepaidBalance: { val: 250 }, + productUsage: [ + { product: 'GrokBuild', usagePercent: 93 }, + { product: 'GrokChat', usagePercent: 1 }, + ], + }), + monthlyConfig({ used: { val: 4 } }) + ); + if (!quota.billing) throw new Error('missing billing'); + quota.billing.planLabel = 'SuperGrok Heavy'; + quota.billing.planTier = 'elite'; + + const markup = render(quota); + + expect(markup).toContain('elitePlanValue'); + expect(markup).toContain('SuperGrok Heavy'); + expect(markup).toContain('Resets'); + expect(markup).toContain(formatInstantShort(Date.parse(WEEKLY_PERIOD_END))); + expect(markup).toContain('GrokChat usage'); + expect(markup).toContain('Prepaid'); + expect(markup).toContain('$2.50'); + expect(markup).toContain('$0.00 / $0.00'); + }); + + test('keeps monthly amount, percentage, and meter in the remaining direction', () => { + const markup = render( + quotaFor( + null, + monthlyConfig({ + monthlyLimit: { val: 15000 }, + used: { val: 1500 }, + }) + ) + ); + expect(markup).toContain('>90%<'); + expect(markup).toContain('$135.00 / $150.00'); + expect(markup).not.toContain('$15.00 / $150.00'); + expect(markup).toContain('width:90%'); + }); + test('keeps the monthly-only zero row when no weekly data exists', () => { const markup = render(quotaFor(null, monthlyConfig()));