import axios from 'axios' import { getOauthConfig } from '../../constants/oauth.js' import { getOauthAccountInfo } from '../../utils/auth.js' import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' import { logError } from '../../utils/log.js' import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js' import { getOAuthHeaders, prepareApiRequest } from '../../utils/teleport/api.js' export type OverageCreditGrantInfo = { available: boolean eligible: boolean granted: boolean amount_minor_units: number | null currency: string | null } type CachedGrantEntry = { info: OverageCreditGrantInfo timestamp: number } const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour /** * Fetch the current user's overage credit grant eligibility from the backend. * The backend resolves tier-specific amounts and role-based claim permission, * so the CLI just reads the response without replicating that logic. */ async function fetchOverageCreditGrant(): Promise { try { const { accessToken, orgUUID } = await prepareApiRequest() const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/overage_credit_grant` const response = await axios.get(url, { headers: getOAuthHeaders(accessToken), }) return response.data } catch (err) { logError(err) return null } } /** * Get cached grant info. Returns null if no cache or cache is stale. * Callers should render nothing (not block) when this returns null — * refreshOverageCreditGrantCache fires lazily to populate it. */ export function getCachedOverageCreditGrant(): OverageCreditGrantInfo | null { const orgId = getOauthAccountInfo()?.organizationUuid if (!orgId) return null const cached = getGlobalConfig().overageCreditGrantCache?.[orgId] if (!cached) return null if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null return cached.info } /** * Drop the current org's cached entry so the next read refetches. * Leaves other orgs' entries intact. */ export function invalidateOverageCreditGrantCache(): void { const orgId = getOauthAccountInfo()?.organizationUuid if (!orgId) return const cache = getGlobalConfig().overageCreditGrantCache if (!cache || !(orgId in cache)) return saveGlobalConfig(prev => { const next = { ...prev.overageCreditGrantCache } delete next[orgId] return { ...prev, overageCreditGrantCache: next } }) } /** * Fetch and cache grant info. Fire-and-forget; call when an upsell surface * is about to render and the cache is empty. */ export async function refreshOverageCreditGrantCache(): Promise { if (isEssentialTrafficOnly()) return const orgId = getOauthAccountInfo()?.organizationUuid if (!orgId) return const info = await fetchOverageCreditGrant() if (!info) return // Skip rewriting info if grant data is unchanged — avoids config write // amplification (inc-4552 pattern). Still refresh the timestamp so the // TTL-based staleness check in getCachedOverageCreditGrant doesn't keep // re-triggering API calls on every component mount. saveGlobalConfig(prev => { // Derive from prev (lock-fresh) rather than a pre-lock getGlobalConfig() // read — saveConfigWithLock re-reads config from disk under the file lock, // so another CLI instance may have written between any outer read and lock // acquire. const prevCached = prev.overageCreditGrantCache?.[orgId] const existing = prevCached?.info const dataUnchanged = existing && existing.available === info.available && existing.eligible === info.eligible && existing.granted === info.granted && existing.amount_minor_units === info.amount_minor_units && existing.currency === info.currency // When data is unchanged and timestamp is still fresh, skip the write entirely if ( dataUnchanged && prevCached && Date.now() - prevCached.timestamp <= CACHE_TTL_MS ) { return prev } const entry: CachedGrantEntry = { info: dataUnchanged ? existing : info, timestamp: Date.now(), } return { ...prev, overageCreditGrantCache: { ...prev.overageCreditGrantCache, [orgId]: entry, }, } }) } /** * Format the grant amount for display. Returns null if amount isn't available * (not eligible, or currency we don't know how to format). */ export function formatGrantAmount(info: OverageCreditGrantInfo): string | null { if (info.amount_minor_units == null || !info.currency) return null // For now only USD; backend may expand later if (info.currency.toUpperCase() === 'USD') { const dollars = info.amount_minor_units / 100 return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}` } return null } export type { CachedGrantEntry as OverageCreditGrantCacheEntry }