UNPKG

a0-purchases

Version:

Lightweight subscription management for AI apps with auto-detecting providers

299 lines 14.4 kB
import { Platform } from 'react-native'; import { BasePlatformAdapter } from '../../core/adapters'; import { PURCHASES_ERROR_CODE } from '../../types'; export class NativeAdapter extends BasePlatformAdapter { constructor() { super(...arguments); this.name = 'native'; this.expoIAP = null; this.purchaseUpdateSubscription = null; this.purchaseErrorSubscription = null; this.currentPurchasePromise = null; } logEvent(event, data) { if (this.config.debug) { console.log(`[NativeAdapter] ${event}`, data ? JSON.stringify(data, null, 2) : ''); } } createError(message, code = PURCHASES_ERROR_CODE.UNKNOWN_ERROR, originalError, userCancelled = false) { const error = new Error(message); error.code = code; error.message = message; error.userCancelled = userCancelled; if (originalError) { error.underlyingError = originalError; } return error; } async loadExpoIAP() { if (this.expoIAP) { return this.expoIAP; } try { this.expoIAP = await import('expo-iap'); return this.expoIAP; } catch (error) { throw this.createError('Failed to load expo-iap module', PURCHASES_ERROR_CODE.SERVICE_UNAVAILABLE, error); } } isSupported() { return Platform.OS === 'ios' || Platform.OS === 'android'; } async init(config) { // Store configuration if (config) { this.config = Object.assign(Object.assign({}, this.config), config); } if (!this.isSupported()) { throw this.createError(`Native adapter not supported on platform: ${Platform.OS}`, PURCHASES_ERROR_CODE.SERVICE_UNAVAILABLE); } try { const iap = await this.loadExpoIAP(); // Initialize connection await iap.initConnection(); this.logEvent('Native IAP connection initialized'); // Set up listeners this.setupListeners(); this.logEvent('Native IAP listeners set up'); } catch (error) { this.logEvent('Failed to initialize native adapter', error); throw this.createError('Native IAP initialization failed', PURCHASES_ERROR_CODE.INITIALIZATION_ERROR, error); } } setupListeners() { var _a, _b; if (!this.expoIAP) return; // Clean up existing listeners (_a = this.purchaseUpdateSubscription) === null || _a === void 0 ? void 0 : _a.remove(); (_b = this.purchaseErrorSubscription) === null || _b === void 0 ? void 0 : _b.remove(); this.purchaseUpdateSubscription = this.expoIAP.purchaseUpdatedListener((purchase) => { var _a; this.logEvent('Purchase updated', purchase); if (!this.currentPurchasePromise) { this.logEvent('Purchase update received but no promise waiting'); // Try to finish dangling transaction (_a = this.expoIAP) === null || _a === void 0 ? void 0 : _a.finishTransaction({ purchase, isConsumable: false }).catch(err => this.logEvent('Error finishing dangling transaction', err)); return; } // Check purchase state for Android if (Platform.OS === 'android' && 'purchaseStateAndroid' in purchase) { if (purchase.purchaseStateAndroid === 1) { // Purchased this.handlePurchaseSuccess(purchase); } else if (purchase.purchaseStateAndroid === 2) { // Pending this.logEvent('Purchase pending (Android)'); this.currentPurchasePromise.reject(this.createError('Purchase is pending confirmation', PURCHASES_ERROR_CODE.UNKNOWN_ERROR)); this.currentPurchasePromise = null; } else { this.logEvent('Unhandled Android purchase state', { state: purchase.purchaseStateAndroid }); this.currentPurchasePromise.reject(this.createError(`Unhandled purchase state: ${purchase.purchaseStateAndroid}`, PURCHASES_ERROR_CODE.PURCHASE_INVALID_ERROR)); this.currentPurchasePromise = null; } } else { // iOS or valid purchase this.handlePurchaseSuccess(purchase); } }); this.purchaseErrorSubscription = this.expoIAP.purchaseErrorListener((error) => { this.logEvent('Purchase error', error); if (!this.currentPurchasePromise) { this.logEvent('Purchase error received but no promise waiting'); return; } const userCancelled = error.code === 'E_USER_CANCELLED'; const libraryError = this.createError(error.message || 'Purchase failed', PURCHASES_ERROR_CODE.PURCHASE_INVALID_ERROR, error, userCancelled); this.currentPurchasePromise.reject(libraryError); this.currentPurchasePromise = null; }); } async handlePurchaseSuccess(purchase) { var _a; if (!this.currentPurchasePromise || !this.expoIAP) return; try { // Finish the transaction first await this.expoIAP.finishTransaction({ purchase, isConsumable: false }); this.logEvent('Transaction finished', { transactionId: purchase.transactionId }); // Convert to our Purchase interface const result = { id: purchase.id, transactionId: purchase.transactionId, transactionReceipt: purchase.transactionReceipt }; this.currentPurchasePromise.resolve(result); this.currentPurchasePromise = null; } catch (error) { this.logEvent('Error finishing transaction', error); (_a = this.currentPurchasePromise) === null || _a === void 0 ? void 0 : _a.reject(this.createError('Failed to complete purchase', PURCHASES_ERROR_CODE.PURCHASE_INVALID_ERROR, error)); this.currentPurchasePromise = null; } } async fetchStoreProducts(skus) { if (!this.expoIAP || skus.length === 0) { return []; } try { this.logEvent('Fetching store products', { skus }); // Fetch subscriptions first const subscriptions = await this.expoIAP.getSubscriptions(skus); // Then fetch products, excluding ones that are subscriptions const products = await this.expoIAP.getProducts(skus.filter(sku => !subscriptions.some((sub) => sub.id === sku))); const allProducts = [...subscriptions, ...products]; // Convert to our StoreProduct interface const storeProducts = allProducts.map((product) => { var _a, _b; return ({ id: product.id, price: parseFloat(((_a = product.price) === null || _a === void 0 ? void 0 : _a.toString()) || '0') || 0, priceString: ((_b = product.price) === null || _b === void 0 ? void 0 : _b.toString()) || '', title: product.title || product.id, description: product.description, currency: product.currency, platform: Platform.OS }); }); this.logEvent('Store products fetched', { count: storeProducts.length }); return storeProducts; } catch (error) { this.logEvent('Error fetching store products', error); return []; } } async purchase(pkg, userId) { if (!this.expoIAP) { throw this.createError('Native IAP not initialized', PURCHASES_ERROR_CODE.SERVICE_UNAVAILABLE); } if (this.currentPurchasePromise) { throw this.createError('Purchase already in progress', PURCHASES_ERROR_CODE.PURCHASE_IN_PROGRESS_ERROR); } return new Promise(async (resolve, reject) => { var _a, _b; this.currentPurchasePromise = { resolve, reject }; try { const productId = pkg.product.identifier; this.logEvent('Starting native purchase', { productId }); // Check if it's a subscription by trying subscriptions first try { await ((_a = this.expoIAP) === null || _a === void 0 ? void 0 : _a.requestPurchase({ request: { sku: productId }, type: 'subs' })); } catch (subError) { // If subscription fails, try as regular product this.logEvent('Subscription purchase failed, trying as product', subError); try { await ((_b = this.expoIAP) === null || _b === void 0 ? void 0 : _b.requestPurchase({ request: { sku: productId } })); } catch (productError) { this.currentPurchasePromise = null; throw this.createError('Failed to initiate purchase', PURCHASES_ERROR_CODE.PURCHASE_INVALID_ERROR, productError); } } this.logEvent('Purchase request sent, awaiting completion...'); } catch (error) { this.currentPurchasePromise = null; const userCancelled = error.code === 'E_USER_CANCELLED'; reject(this.createError(error.message || 'Purchase initiation failed', PURCHASES_ERROR_CODE.PURCHASE_INVALID_ERROR, error, userCancelled)); } }); } async restore(userId) { if (!this.expoIAP) { throw this.createError('Native IAP not initialized', PURCHASES_ERROR_CODE.SERVICE_UNAVAILABLE); } if (!userId) { throw this.createError('User ID is required for restore', PURCHASES_ERROR_CODE.INVALID_APP_USER_ID_ERROR); } try { this.logEvent('Starting restore purchases - syncing receipts'); // Get all purchase history from the device const purchaseHistory = await this.expoIAP.getPurchaseHistory(); this.logEvent('Purchase history retrieved', { count: purchaseHistory.length }); if (purchaseHistory.length === 0) { this.logEvent('No purchases to restore'); // Not an error - user might genuinely have no purchases return; } // Collect all valid transaction IDs const transactionIds = []; for (const purchase of purchaseHistory) { if (purchase.transactionId) { transactionIds.push(purchase.transactionId); } // Also try to extract originalTransactionId from receipt if (purchase.transactionReceipt) { try { const receiptData = JSON.parse(purchase.transactionReceipt); if (receiptData.originalTransactionId && !transactionIds.includes(receiptData.originalTransactionId)) { transactionIds.push(receiptData.originalTransactionId); } } catch (e) { // Receipt might not be JSON, continue } } } if (transactionIds.length === 0) { this.logEvent('No valid transaction IDs found'); return; } this.logEvent('Found transaction IDs to sync', { count: transactionIds.length }); // For now, send the first transaction ID to match current backend API // TODO: Update backend to accept multiple transaction IDs for bulk sync const primaryTransactionId = transactionIds[0]; // Call backend restore endpoint const { backendClient } = await import('../../api/backendClient'); await backendClient.restorePurchases(userId, primaryTransactionId); // Also get and finish any pending/available purchases to clear the queue try { const availablePurchases = await this.expoIAP.getAvailablePurchases(); this.logEvent('Clearing pending transactions', { count: availablePurchases.length }); for (const purchase of availablePurchases) { try { await this.expoIAP.finishTransaction({ purchase, isConsumable: false }); this.logEvent('Finished pending transaction', { transactionId: purchase.transactionId }); } catch (error) { this.logEvent('Failed to finish transaction', { transactionId: purchase.transactionId, error }); // Continue with other transactions } } } catch (error) { this.logEvent('Failed to clear pending transactions', error); // Not critical - continue with restore } this.logEvent('Receipt sync completed successfully'); } catch (error) { this.logEvent('Restore purchases failed', error); if (error.code) { throw error; // Re-throw library errors } throw this.createError('Failed to restore purchases', PURCHASES_ERROR_CODE.RESTORE_ERROR, error); } } dispose() { var _a, _b; this.logEvent('Disposing native adapter'); (_a = this.purchaseUpdateSubscription) === null || _a === void 0 ? void 0 : _a.remove(); (_b = this.purchaseErrorSubscription) === null || _b === void 0 ? void 0 : _b.remove(); this.purchaseUpdateSubscription = null; this.purchaseErrorSubscription = null; this.currentPurchasePromise = null; this.expoIAP = null; this.logEvent('Native adapter disposed'); } } //# sourceMappingURL=NativeAdapter.js.map