UNPKG

a0-purchases

Version:

Lightweight subscription management for AI apps with auto-detecting providers

459 lines 18.4 kB
import { Platform } from 'react-native'; import { commerceState } from './commerceState'; import { backendClient } from '../api/backendClient'; import { PURCHASES_ERROR_CODE } from '../types'; import { getOrCreateUserId, setUserId, clearUserId, isAnonymousUserId, setDebugEnabled } from '../storage'; import { NativeAdapter } from '../platform/native/NativeAdapter'; import { WebAdapter } from '../platform/web/WebAdapter'; class PurchasesService { constructor() { this.adapter = null; this.initializationPromise = null; this.stripePresenter = null; this.config = { debug: false }; } logEvent(event, data) { if (this.config.debug) { console.log(`[PurchasesService] ${event}`, data ? JSON.stringify(data, null, 2) : ''); } } createError(message, code = PURCHASES_ERROR_CODE.UNKNOWN_ERROR, originalError) { const error = new Error(message); error.code = code; error.message = message; error.userCancelled = false; if (originalError) { error.underlyingError = originalError; } return error; } async selectAdapter() { // Use static imports to avoid chunk loading issues if (Platform.OS === 'ios' || Platform.OS === 'android') { try { const nativeAdapter = new NativeAdapter(); if (nativeAdapter.isSupported()) { this.logEvent(`Selected native adapter: ${nativeAdapter.name}`); return nativeAdapter; } } catch (error) { this.logEvent('Failed to load native adapter, falling back to web', error); } } // Fallback to web adapter const webAdapter = new WebAdapter(); this.logEvent(`Selected web adapter: ${webAdapter.name}`); return webAdapter; } /** * Initialize the purchases service * This is idempotent - multiple calls are safe * @param config - Optional configuration object * @param config.debug - Enable debug logging (default: false) */ async initialize(config) { // Store configuration if (config) { this.config = Object.assign(Object.assign({}, this.config), config); } // Configure the backend client with the same settings backendClient.configure(this.config); // Enable debug logging in storage module setDebugEnabled(this.config.debug || false); if (commerceState.isReady()) { this.logEvent('Already initialized'); return; } if (this.initializationPromise) { this.logEvent('Initialization in progress, awaiting...'); return this.initializationPromise; } this.initializationPromise = this._doInitialize(); return this.initializationPromise; } async _doInitialize() { try { commerceState._setInitializing(true); this.logEvent('Starting initialization...'); // 1. Get or create user ID (with optional custom ID from config) const userId = await getOrCreateUserId(this.config.appUserId); commerceState._setUserId(userId); this.logEvent('User ID obtained', { userId, isAnonymous: isAnonymousUserId(userId) }); // 2. Select appropriate adapter this.adapter = await this.selectAdapter(); // 3. Fetch global data in parallel const platformProvider = this.adapter.name === 'native' ? (Platform.OS === 'ios' ? 'ios' : 'android') : 'stripe'; this.logEvent('Fetching global data...', { provider: platformProvider }); const [offerings, customerInfo] = await Promise.all([ backendClient.fetchOfferings(platformProvider), userId ? backendClient.getCustomerInfo(userId).catch(() => null) : null ]); // Update state with global data commerceState._setOfferings(offerings); commerceState._setCustomerInfo(customerInfo); // 4. Initialize platform adapter await this.adapter.init(this.config); // 5. Fetch platform-specific data (if supported) if (this.adapter.name === 'native' && offerings) { try { const skus = Object.values(offerings.all) .flatMap(offering => offering.availablePackages) .map(pkg => pkg.product.identifier); if (skus.length > 0) { const storeProducts = await this.adapter.fetchStoreProducts(skus); commerceState._setStoreProducts(storeProducts); this.logEvent('Store products fetched', { count: storeProducts.length }); } } catch (error) { this.logEvent('Failed to fetch store products', error); // Don't fail initialization for this } } commerceState._setInitialized(true); this.logEvent('Initialization completed successfully'); // Auto-sync receipts in the background (fire and forget) if (this.adapter.name === 'native' && userId) { this.autoSyncReceipts().catch(error => { this.logEvent('Auto-sync receipts failed (non-blocking)', error); // Don't throw - this is a background operation }); } } catch (error) { this.logEvent('Initialization failed', error); commerceState._setError(error); commerceState._setInitialized(false); throw error; } finally { commerceState._setInitializing(false); this.initializationPromise = null; } } async ensureInitialized() { // If initialization has already succeeded, just return if (commerceState.isReady()) { return; } const state = commerceState.getState(); // If the last initialization attempt resulted in an error, avoid repeatedly trying // again and simply surface the existing error. This prevents // a tight retry-loop (e.g. missing configuration in production) that would // otherwise continuously call getOrCreateUserId / backendClient.login. if (state.lastError) { throw state.lastError; } await this.initialize(); } // Public API methods /** * Get current offerings */ getOfferings() { return commerceState.getOfferings(); } /** * Get current customer info */ getCustomerInfo() { return commerceState.getCustomerInfo(); } /** * Check if user has premium access */ isPremium() { return commerceState.isPremium(); } /** * Check if the current user is anonymous */ isAnonymous() { const userId = commerceState.getUserId(); return isAnonymousUserId(userId); } /** * Get current user ID */ getUserId() { return commerceState.getUserId(); } /** * Subscribe to state changes */ subscribe(listener) { return commerceState.subscribe(listener); } /** * Refresh customer info from backend */ async refreshCustomerInfo() { await this.ensureInitialized(); const userId = commerceState.getUserId(); if (!userId) { throw this.createError('Cannot refresh customer info without user ID', PURCHASES_ERROR_CODE.INVALID_APP_USER_ID_ERROR); } try { this.logEvent('Refreshing customer info...'); const customerInfo = await backendClient.getCustomerInfo(userId); commerceState._setCustomerInfo(customerInfo); this.logEvent('Customer info refreshed'); return customerInfo; } catch (error) { this.logEvent('Failed to refresh customer info', error); throw error; } } /** * Purchase a package */ async purchase(packageId) { await this.ensureInitialized(); if (commerceState.getState().isPurchasing) { throw this.createError('Purchase already in progress', PURCHASES_ERROR_CODE.PURCHASE_IN_PROGRESS_ERROR); } if (commerceState.getState().isRestoring) { throw this.createError('Restore operation in progress', PURCHASES_ERROR_CODE.OPERATION_ALREADY_IN_PROGRESS_ERROR); } const userId = commerceState.getUserId(); if (!userId) { throw this.createError('Cannot purchase without user ID', PURCHASES_ERROR_CODE.INVALID_APP_USER_ID_ERROR); } if (!this.adapter) { throw this.createError('No platform adapter available', PURCHASES_ERROR_CODE.SERVICE_UNAVAILABLE); } // Find the package const offerings = commerceState.getOfferings(); const pkg = Object.values(offerings.all) .flatMap(offering => offering.availablePackages) .find(p => p.identifier === packageId); if (!pkg) { throw this.createError(`Package not found: ${packageId}`, PURCHASES_ERROR_CODE.PRODUCT_NOT_AVAILABLE_ERROR); } try { commerceState._setPurchasing(true); this.logEvent('Starting purchase...', { packageId, productId: pkg.product.identifier }); // Execute platform-specific purchase const purchase = await this.adapter.purchase(pkg, userId); this.logEvent('Platform purchase completed', purchase); // For native purchases, validate with backend if (this.adapter.name === 'native' && purchase.transactionReceipt) { const platform = Platform.OS; const validationResult = await backendClient.postPurchase(userId, purchase.id, '', // purchaseToken for Android purchase.transactionId || '', purchase.transactionReceipt, platform); // Update customer info with validated result commerceState._setCustomerInfo(validationResult.customerInfo); this.logEvent('Purchase validated with backend'); return { customerInfo: validationResult.customerInfo, productIdentifier: purchase.id, transactionId: purchase.transactionId }; } else { // For web purchases, refresh customer info after completion const customerInfo = await this.refreshCustomerInfo(); return { customerInfo: customerInfo, productIdentifier: pkg.product.identifier, transactionId: purchase.transactionId }; } } catch (error) { this.logEvent('Purchase failed', error); throw error; } finally { commerceState._setPurchasing(false); } } /** * Restore purchases (native only) */ async restore() { await this.ensureInitialized(); if (commerceState.getState().isRestoring) { throw this.createError('Restore already in progress', PURCHASES_ERROR_CODE.OPERATION_ALREADY_IN_PROGRESS_ERROR); } if (!this.adapter) { throw this.createError('No platform adapter available', PURCHASES_ERROR_CODE.SERVICE_UNAVAILABLE); } if (this.adapter.name !== 'native') { // For web, just refresh customer info return this.refreshCustomerInfo(); } const userId = commerceState.getUserId(); if (!userId) { throw this.createError('Cannot restore without user ID', PURCHASES_ERROR_CODE.INVALID_APP_USER_ID_ERROR); } try { commerceState._setRestoring(true); this.logEvent('Starting restore...'); await this.adapter.restore(userId); // Refresh customer info after restore const customerInfo = await this.refreshCustomerInfo(); this.logEvent('Restore completed'); return customerInfo; } catch (error) { this.logEvent('Restore failed', error); throw error; } finally { commerceState._setRestoring(false); } } /** * Get manage subscription URL */ async getManageSubscriptionUrl() { await this.ensureInitialized(); const userId = commerceState.getUserId(); if (!userId) { throw this.createError('Cannot get manage subscription URL without user ID', PURCHASES_ERROR_CODE.INVALID_APP_USER_ID_ERROR); } try { const response = await backendClient.createManageSubscriptionSession(userId); return response.url; } catch (error) { // Fallback to native URL if available if (Platform.OS === 'ios') { return 'https://apps.apple.com/account/subscriptions'; } throw error; } } /** * Log in with a new user ID * If the current user is anonymous, this will alias (merge) them with the new user */ async logIn(newUserId) { if (!newUserId) { throw this.createError('User ID is required', PURCHASES_ERROR_CODE.INVALID_APP_USER_ID_ERROR); } const currentUserId = commerceState.getUserId(); const wasAnonymous = isAnonymousUserId(currentUserId); if (currentUserId === newUserId) { this.logEvent('Already logged in with this user ID'); return commerceState.getCustomerInfo(); } try { // Ensure backend has record for new user first await backendClient.login(newUserId); // If current user exists and differs, make sure it too is registered if (currentUserId && currentUserId !== newUserId && wasAnonymous) { // Only link anonymous users to prevent connecting real accounts this.logEvent('Previous user was anonymous, will link to new user'); // Safely attempt to register the old ID in case it was never created (idempotent on backend) try { await backendClient.login(currentUserId); } catch (e) { // Non-fatal – continue even if the old user already exists or creation fails this.logEvent('Ensuring old user exists failed (non-blocking)', e); } this.logEvent('Linking users', { from: currentUserId, to: newUserId, wasAnonymous, }); await backendClient.linkAlias(currentUserId, newUserId); } else if (currentUserId && currentUserId !== newUserId && !wasAnonymous) { this.logEvent('Not linking users - previous user was not anonymous', { from: currentUserId, to: newUserId }); } // Update stored user ID await setUserId(newUserId); commerceState._setUserId(newUserId); // Refresh customer info for new user const customerInfo = await backendClient.getCustomerInfo(newUserId); commerceState._setCustomerInfo(customerInfo); this.logEvent('Logged in successfully', { userId: newUserId, aliased: wasAnonymous, }); return customerInfo; } catch (error) { this.logEvent('Login failed', error); throw error; } } /** * Log out current user */ async logOut() { this.logEvent('Logging out...'); // Clean up adapter if (this.adapter) { this.adapter.dispose(); this.adapter = null; } // Clear stored user ID await clearUserId(); // Reset state commerceState._reset(); this.logEvent('Logged out successfully'); } /** * Auto-sync receipts in the background (called during initialization) * This ensures the backend always has the latest purchase state */ async autoSyncReceipts() { try { this.logEvent('Starting background receipt sync...'); // Don't set isRestoring flag - this is a background operation const userId = commerceState.getUserId(); if (!userId || !this.adapter) { this.logEvent('Skipping auto-sync: no user ID or adapter'); return; } // Call the adapter's restore method this.adapter.restore(userId); // Refresh customer info to update local state const customerInfo = await backendClient.getCustomerInfo(userId); commerceState._setCustomerInfo(customerInfo); this.logEvent('Background receipt sync completed successfully'); } catch (error) { // Log but don't throw - this is a non-blocking background operation this.logEvent('Background receipt sync failed', { error: error.message, code: error.code }); } } /** * Register a Stripe presenter component (for backward compatibility) */ registerStripePresenter(presenter) { this.logEvent('Stripe presenter registered/unregistered', { registered: !!presenter }); this.stripePresenter = presenter; } /** * Destroy the service and clean up resources */ destroy() { this.logEvent('Destroying service...'); if (this.adapter) { this.adapter.dispose(); this.adapter = null; } commerceState._reset(); this.initializationPromise = null; this.stripePresenter = null; this.logEvent('Service destroyed'); } } // Export singleton instance export const purchasesService = new PurchasesService(); //# sourceMappingURL=PurchasesService.js.map