/ services / api / referral.ts
referral.ts
  1  import axios from 'axios'
  2  import { getOauthConfig } from '../../constants/oauth.js'
  3  import {
  4    getOauthAccountInfo,
  5    getSubscriptionType,
  6    isClaudeAISubscriber,
  7  } from '../../utils/auth.js'
  8  import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
  9  import { logForDebugging } from '../../utils/debug.js'
 10  import { logError } from '../../utils/log.js'
 11  import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js'
 12  import { getOAuthHeaders, prepareApiRequest } from '../../utils/teleport/api.js'
 13  import type {
 14    ReferralCampaign,
 15    ReferralEligibilityResponse,
 16    ReferralRedemptionsResponse,
 17    ReferrerRewardInfo,
 18  } from '../oauth/types.js'
 19  
 20  // Cache expiration time: 24 hours (eligibility changes only on subscription/experiment changes)
 21  const CACHE_EXPIRATION_MS = 24 * 60 * 60 * 1000
 22  
 23  // Track in-flight fetch to prevent duplicate API calls
 24  let fetchInProgress: Promise<ReferralEligibilityResponse | null> | null = null
 25  
 26  export async function fetchReferralEligibility(
 27    campaign: ReferralCampaign = 'claude_code_guest_pass',
 28  ): Promise<ReferralEligibilityResponse> {
 29    const { accessToken, orgUUID } = await prepareApiRequest()
 30  
 31    const headers = {
 32      ...getOAuthHeaders(accessToken),
 33      'x-organization-uuid': orgUUID,
 34    }
 35  
 36    const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/referral/eligibility`
 37  
 38    const response = await axios.get(url, {
 39      headers,
 40      params: { campaign },
 41      timeout: 5000, // 5 second timeout for background fetch
 42    })
 43  
 44    return response.data
 45  }
 46  
 47  export async function fetchReferralRedemptions(
 48    campaign: string = 'claude_code_guest_pass',
 49  ): Promise<ReferralRedemptionsResponse> {
 50    const { accessToken, orgUUID } = await prepareApiRequest()
 51  
 52    const headers = {
 53      ...getOAuthHeaders(accessToken),
 54      'x-organization-uuid': orgUUID,
 55    }
 56  
 57    const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/referral/redemptions`
 58  
 59    const response = await axios.get<ReferralRedemptionsResponse>(url, {
 60      headers,
 61      params: { campaign },
 62      timeout: 10000, // 10 second timeout
 63    })
 64  
 65    return response.data
 66  }
 67  
 68  /**
 69   * Prechecks for if user can access guest passes feature
 70   */
 71  function shouldCheckForPasses(): boolean {
 72    return !!(
 73      getOauthAccountInfo()?.organizationUuid &&
 74      isClaudeAISubscriber() &&
 75      getSubscriptionType() === 'max'
 76    )
 77  }
 78  
 79  /**
 80   * Check cached passes eligibility from GlobalConfig
 81   * Returns current cached state and cache status
 82   */
 83  export function checkCachedPassesEligibility(): {
 84    eligible: boolean
 85    needsRefresh: boolean
 86    hasCache: boolean
 87  } {
 88    if (!shouldCheckForPasses()) {
 89      return {
 90        eligible: false,
 91        needsRefresh: false,
 92        hasCache: false,
 93      }
 94    }
 95  
 96    const orgId = getOauthAccountInfo()?.organizationUuid
 97    if (!orgId) {
 98      return {
 99        eligible: false,
100        needsRefresh: false,
101        hasCache: false,
102      }
103    }
104  
105    const config = getGlobalConfig()
106    const cachedEntry = config.passesEligibilityCache?.[orgId]
107  
108    if (!cachedEntry) {
109      // No cached entry, needs fetch
110      return {
111        eligible: false,
112        needsRefresh: true,
113        hasCache: false,
114      }
115    }
116  
117    const { eligible, timestamp } = cachedEntry
118    const now = Date.now()
119    const needsRefresh = now - timestamp > CACHE_EXPIRATION_MS
120  
121    return {
122      eligible,
123      needsRefresh,
124      hasCache: true,
125    }
126  }
127  
128  const CURRENCY_SYMBOLS: Record<string, string> = {
129    USD: '$',
130    EUR: '€',
131    GBP: '£',
132    BRL: 'R$',
133    CAD: 'CA$',
134    AUD: 'A$',
135    NZD: 'NZ$',
136    SGD: 'S$',
137  }
138  
139  export function formatCreditAmount(reward: ReferrerRewardInfo): string {
140    const symbol = CURRENCY_SYMBOLS[reward.currency] ?? `${reward.currency} `
141    const amount = reward.amount_minor_units / 100
142    const formatted = amount % 1 === 0 ? amount.toString() : amount.toFixed(2)
143    return `${symbol}${formatted}`
144  }
145  
146  /**
147   * Get cached referrer reward info from eligibility cache
148   * Returns the reward info if the user is in a v1 campaign, null otherwise
149   */
150  export function getCachedReferrerReward(): ReferrerRewardInfo | null {
151    const orgId = getOauthAccountInfo()?.organizationUuid
152    if (!orgId) return null
153    const config = getGlobalConfig()
154    const cachedEntry = config.passesEligibilityCache?.[orgId]
155    return cachedEntry?.referrer_reward ?? null
156  }
157  
158  /**
159   * Get the cached remaining passes count from eligibility cache
160   * Returns the number of remaining passes, or null if not available
161   */
162  export function getCachedRemainingPasses(): number | null {
163    const orgId = getOauthAccountInfo()?.organizationUuid
164    if (!orgId) return null
165    const config = getGlobalConfig()
166    const cachedEntry = config.passesEligibilityCache?.[orgId]
167    return cachedEntry?.remaining_passes ?? null
168  }
169  
170  /**
171   * Fetch passes eligibility and store in GlobalConfig
172   * Returns the fetched response or null on error
173   */
174  export async function fetchAndStorePassesEligibility(): Promise<ReferralEligibilityResponse | null> {
175    // Return existing promise if fetch is already in progress
176    if (fetchInProgress) {
177      logForDebugging('Passes: Reusing in-flight eligibility fetch')
178      return fetchInProgress
179    }
180  
181    const orgId = getOauthAccountInfo()?.organizationUuid
182  
183    if (!orgId) {
184      return null
185    }
186  
187    // Store the promise to share with concurrent calls
188    fetchInProgress = (async () => {
189      try {
190        const response = await fetchReferralEligibility()
191  
192        const cacheEntry = {
193          ...response,
194          timestamp: Date.now(),
195        }
196  
197        saveGlobalConfig(current => ({
198          ...current,
199          passesEligibilityCache: {
200            ...current.passesEligibilityCache,
201            [orgId]: cacheEntry,
202          },
203        }))
204  
205        logForDebugging(
206          `Passes eligibility cached for org ${orgId}: ${response.eligible}`,
207        )
208  
209        return response
210      } catch (error) {
211        logForDebugging('Failed to fetch and cache passes eligibility')
212        logError(error as Error)
213        return null
214      } finally {
215        // Clear the promise when done
216        fetchInProgress = null
217      }
218    })()
219  
220    return fetchInProgress
221  }
222  
223  /**
224   * Get cached passes eligibility data or fetch if needed
225   * Main entry point for all eligibility checks
226   *
227   * This function never blocks on network - it returns cached data immediately
228   * and fetches in the background if needed. On cold start (no cache), it returns
229   * null and the passes command won't be available until the next session.
230   */
231  export async function getCachedOrFetchPassesEligibility(): Promise<ReferralEligibilityResponse | null> {
232    if (!shouldCheckForPasses()) {
233      return null
234    }
235  
236    const orgId = getOauthAccountInfo()?.organizationUuid
237    if (!orgId) {
238      return null
239    }
240  
241    const config = getGlobalConfig()
242    const cachedEntry = config.passesEligibilityCache?.[orgId]
243    const now = Date.now()
244  
245    // No cache - trigger background fetch and return null (non-blocking)
246    // The passes command won't be available this session, but will be next time
247    if (!cachedEntry) {
248      logForDebugging(
249        'Passes: No cache, fetching eligibility in background (command unavailable this session)',
250      )
251      void fetchAndStorePassesEligibility()
252      return null
253    }
254  
255    // Cache exists but is stale - return stale cache and trigger background refresh
256    if (now - cachedEntry.timestamp > CACHE_EXPIRATION_MS) {
257      logForDebugging(
258        'Passes: Cache stale, returning cached data and refreshing in background',
259      )
260      void fetchAndStorePassesEligibility() // Background refresh
261      const { timestamp, ...response } = cachedEntry
262      return response as ReferralEligibilityResponse
263    }
264  
265    // Cache is fresh - return it immediately
266    logForDebugging('Passes: Using fresh cached eligibility data')
267    const { timestamp, ...response } = cachedEntry
268    return response as ReferralEligibilityResponse
269  }
270  
271  /**
272   * Prefetch passes eligibility on startup
273   */
274  export async function prefetchPassesEligibility(): Promise<void> {
275    // Skip network requests if nonessential traffic is disabled
276    if (isEssentialTrafficOnly()) {
277      return
278    }
279  
280    void getCachedOrFetchPassesEligibility()
281  }