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