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 }