/ services / api / overageCreditGrant.ts
overageCreditGrant.ts
  1  import axios from 'axios'
  2  import { getOauthConfig } from '../../constants/oauth.js'
  3  import { getOauthAccountInfo } from '../../utils/auth.js'
  4  import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
  5  import { logError } from '../../utils/log.js'
  6  import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js'
  7  import { getOAuthHeaders, prepareApiRequest } from '../../utils/teleport/api.js'
  8  
  9  export type OverageCreditGrantInfo = {
 10    available: boolean
 11    eligible: boolean
 12    granted: boolean
 13    amount_minor_units: number | null
 14    currency: string | null
 15  }
 16  
 17  type CachedGrantEntry = {
 18    info: OverageCreditGrantInfo
 19    timestamp: number
 20  }
 21  
 22  const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
 23  
 24  /**
 25   * Fetch the current user's overage credit grant eligibility from the backend.
 26   * The backend resolves tier-specific amounts and role-based claim permission,
 27   * so the CLI just reads the response without replicating that logic.
 28   */
 29  async function fetchOverageCreditGrant(): Promise<OverageCreditGrantInfo | null> {
 30    try {
 31      const { accessToken, orgUUID } = await prepareApiRequest()
 32      const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/overage_credit_grant`
 33      const response = await axios.get<OverageCreditGrantInfo>(url, {
 34        headers: getOAuthHeaders(accessToken),
 35      })
 36      return response.data
 37    } catch (err) {
 38      logError(err)
 39      return null
 40    }
 41  }
 42  
 43  /**
 44   * Get cached grant info. Returns null if no cache or cache is stale.
 45   * Callers should render nothing (not block) when this returns null —
 46   * refreshOverageCreditGrantCache fires lazily to populate it.
 47   */
 48  export function getCachedOverageCreditGrant(): OverageCreditGrantInfo | null {
 49    const orgId = getOauthAccountInfo()?.organizationUuid
 50    if (!orgId) return null
 51    const cached = getGlobalConfig().overageCreditGrantCache?.[orgId]
 52    if (!cached) return null
 53    if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null
 54    return cached.info
 55  }
 56  
 57  /**
 58   * Drop the current org's cached entry so the next read refetches.
 59   * Leaves other orgs' entries intact.
 60   */
 61  export function invalidateOverageCreditGrantCache(): void {
 62    const orgId = getOauthAccountInfo()?.organizationUuid
 63    if (!orgId) return
 64    const cache = getGlobalConfig().overageCreditGrantCache
 65    if (!cache || !(orgId in cache)) return
 66    saveGlobalConfig(prev => {
 67      const next = { ...prev.overageCreditGrantCache }
 68      delete next[orgId]
 69      return { ...prev, overageCreditGrantCache: next }
 70    })
 71  }
 72  
 73  /**
 74   * Fetch and cache grant info. Fire-and-forget; call when an upsell surface
 75   * is about to render and the cache is empty.
 76   */
 77  export async function refreshOverageCreditGrantCache(): Promise<void> {
 78    if (isEssentialTrafficOnly()) return
 79    const orgId = getOauthAccountInfo()?.organizationUuid
 80    if (!orgId) return
 81    const info = await fetchOverageCreditGrant()
 82    if (!info) return
 83    // Skip rewriting info if grant data is unchanged — avoids config write
 84    // amplification (inc-4552 pattern). Still refresh the timestamp so the
 85    // TTL-based staleness check in getCachedOverageCreditGrant doesn't keep
 86    // re-triggering API calls on every component mount.
 87    saveGlobalConfig(prev => {
 88      // Derive from prev (lock-fresh) rather than a pre-lock getGlobalConfig()
 89      // read — saveConfigWithLock re-reads config from disk under the file lock,
 90      // so another CLI instance may have written between any outer read and lock
 91      // acquire.
 92      const prevCached = prev.overageCreditGrantCache?.[orgId]
 93      const existing = prevCached?.info
 94      const dataUnchanged =
 95        existing &&
 96        existing.available === info.available &&
 97        existing.eligible === info.eligible &&
 98        existing.granted === info.granted &&
 99        existing.amount_minor_units === info.amount_minor_units &&
100        existing.currency === info.currency
101      // When data is unchanged and timestamp is still fresh, skip the write entirely
102      if (
103        dataUnchanged &&
104        prevCached &&
105        Date.now() - prevCached.timestamp <= CACHE_TTL_MS
106      ) {
107        return prev
108      }
109      const entry: CachedGrantEntry = {
110        info: dataUnchanged ? existing : info,
111        timestamp: Date.now(),
112      }
113      return {
114        ...prev,
115        overageCreditGrantCache: {
116          ...prev.overageCreditGrantCache,
117          [orgId]: entry,
118        },
119      }
120    })
121  }
122  
123  /**
124   * Format the grant amount for display. Returns null if amount isn't available
125   * (not eligible, or currency we don't know how to format).
126   */
127  export function formatGrantAmount(info: OverageCreditGrantInfo): string | null {
128    if (info.amount_minor_units == null || !info.currency) return null
129    // For now only USD; backend may expand later
130    if (info.currency.toUpperCase() === 'USD') {
131      const dollars = info.amount_minor_units / 100
132      return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`
133    }
134    return null
135  }
136  
137  export type { CachedGrantEntry as OverageCreditGrantCacheEntry }