a0-purchases
Version:
Lightweight subscription management for AI apps with auto-detecting providers
299 lines • 14.4 kB
JavaScript
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