a0-purchases
Version:
Lightweight subscription management for AI apps with auto-detecting providers
430 lines (339 loc) • 10.5 kB
Markdown
# a0-purchases
A unified cross-platform in-app purchase library for React Native and Web with RevenueCat-level features.
## Installation
```bash
npm install a0-purchases
# or
yarn add a0-purchases
# or
pnpm add a0-purchases
```
## Features
- 🌐 **Cross-platform**: Works on iOS, Android, and Web
- 🔄 **Automatic user management**: Anonymous users with seamless aliasing
- 🎯 **Simple API**: One unified interface across all platforms
- 🔌 **Platform adapters**: Native IAP for mobile, Stripe for web
- 🏃 **Zero configuration**: Works out of the box
- 🐛 **Debug mode**: Comprehensive logging for development
## Quick Start
### Basic Setup (Direct API)
```typescript
import { Purchases } from 'a0-purchases';
// Initialize the SDK
await Purchases.initialize({
debug: true // Enable debug logs in development
});
// Check if user has premium access
if (Purchases.isPremium()) {
// Unlock premium features
}
// Get available packages
const offerings = Purchases.getOfferings();
const package = offerings.current?.availablePackages[0];
// Make a purchase
try {
const result = await Purchases.purchase(package.identifier);
console.log('Purchase successful!', result.customerInfo);
} catch (error) {
console.error('Purchase failed', error);
}
```
### React Integration
```tsx
import { A0PurchaseProvider, useA0Purchases } from 'a0-purchases';
// Wrap your app with the provider
function App() {
return (
<A0PurchaseProvider config={{ debug: true }}>
<YourApp />
</A0PurchaseProvider>
);
}
// Use the hook in your components
function PremiumButton() {
const {
isPremium,
isAnonymous,
purchase,
logIn,
isLoading
} = useA0Purchases();
if (isPremium) {
return <Text>You have premium!</Text>;
}
return (
<View>
<Button
onPress={() => purchase('premium_monthly')}
disabled={isLoading}
>
Upgrade to Premium
</Button>
{isAnonymous && (
<Text onPress={() => logIn('user_123')}>
Sign in to save your purchase
</Text>
)}
</View>
);
}
```
## User ID Management
The SDK provides flexible user ID management with automatic aliasing support:
### Anonymous Users (Default)
```typescript
// Initialize without a user ID - creates anonymous user
await Purchases.initialize();
console.log(Purchases.getUserId()); // "$AnonymousUser:abc123..."
console.log(Purchases.isAnonymous()); // true
```
### Custom User IDs
```typescript
// Initialize with your own user ID
await Purchases.initialize({
appUserId: 'user_12345'
});
console.log(Purchases.getUserId()); // "user_12345"
console.log(Purchases.isAnonymous()); // false
```
### Anonymous to Identified (Aliasing)
```typescript
// Start with anonymous user
await Purchases.initialize();
// User makes purchases while anonymous
await Purchases.purchase('premium_monthly');
// Later, after user signs in
await Purchases.logIn('user_12345');
// The anonymous user is now aliased with the identified user
// All purchases are transferred automatically
```
**Note**: Aliasing only works from anonymous → identified users. You cannot alias two identified users together for security reasons.
### Auth State Synchronization
The SDK automatically keeps purchase user state in sync with your app's auth state:
```typescript
// User logs in to your app
await Purchases.initialize({ appUserId: 'user_123' });
// Later, user logs out of your app
// Initialize without appUserId to reset to anonymous
await Purchases.initialize();
// Creates new anonymous user - old custom ID is cleared
// This prevents users from staying logged in to purchases
// after logging out of your app
```
## API Reference
### Core Methods
- `Purchases.initialize(config?)` - Initialize the SDK
- `Purchases.getCustomerInfo()` - Get current user's purchase info
- `Purchases.getOfferings()` - Get available packages
- `Purchases.isPremium()` - Quick check for premium access
- `Purchases.isAnonymous()` - Check if current user is anonymous
- `Purchases.getUserId()` - Get current user ID
- `Purchases.purchase(packageId)` - Make a purchase
- `Purchases.restore()` - Restore purchases (mobile only)
- `Purchases.refreshCustomerInfo()` - Sync with backend
- `Purchases.logIn(userId)` - Switch to identified user
- `Purchases.logOut()` - Sign out current user
- `Purchases.getManageSubscriptionUrl()` - Get subscription management URL
- `Purchases.subscribe(listener)` - Listen to state changes
- `Purchases.destroy()` - Clean up resources
### Configuration Options
```typescript
interface PurchasesConfig {
// Enable debug logging (default: false)
debug?: boolean;
// Optional custom user ID (default: anonymous)
appUserId?: string;
}
```
### React Hook API
```typescript
const {
isPremium, // boolean - has active premium
isLoading, // boolean - operation in progress
isAnonymous, // boolean - is current user anonymous
userId, // string | null - current user ID
purchase, // (packageId: string) => Promise<void>
restore, // () => Promise<void>
logIn, // (userId: string) => Promise<void>
logOut, // () => Promise<void>
refreshCustomerInfo, // () => Promise<void>
getCustomerInfo, // () => CustomerInfo | null
offerings, // PurchasesOfferings
} = useA0Purchases();
```
#### React Hook Examples
```tsx
function UserProfile() {
const { userId, isAnonymous, logIn, logOut } = useA0Purchases();
if (isAnonymous) {
return (
<Button onPress={() => logIn('user_123')}>
Sign In to Save Purchases
</Button>
);
}
return (
<View>
<Text>Logged in as: {userId}</Text>
<Button onPress={logOut}>Sign Out</Button>
</View>
);
}
function SubscriptionManager() {
const { isPremium, purchase, restore, refreshCustomerInfo } = useA0Purchases();
return (
<View>
{isPremium ? (
<Text>Premium Active ✓</Text>
) : (
<Button onPress={() => purchase('premium_monthly')}>
Upgrade to Premium
</Button>
)}
<Button onPress={restore}>Restore Purchases</Button>
<Button onPress={refreshCustomerInfo}>Refresh Status</Button>
</View>
);
}
```
## Platform Support
| Platform | Purchase Method | Restore | Notes |
|----------|----------------|---------|-------|
| iOS | StoreKit (via expo-iap) | ✅ | Native Apple payments |
| Android | Google Play Billing | ✅ | Native Google payments |
| Web | Stripe Checkout | N/A | Redirects to hosted checkout |
## Best Practices
### ✅ DO: Let the Library Handle User IDs
```typescript
// GOOD: Let the library manage user state
function App() {
const userIdFromAuth = getCurrentUserId(); // undefined if logged out
return (
<A0PurchaseProvider config={{
appUserId: userIdFromAuth, // Pass through your auth state
debug: __DEV__
}}>
<YourApp />
</A0PurchaseProvider>
);
}
// BAD: Hardcoding user IDs
<A0PurchaseProvider config={{ appUserId: "123" }}>
```
### ✅ DO: Handle Missing Offerings
```typescript
// GOOD: Check for offerings before using
const { offerings } = useA0Purchases();
const packages = offerings?.current?.availablePackages || [];
// BAD: Assuming offerings exist
Object.values(offerings.all)[0].availablePackages // Can crash!
```
### ✅ DO: Use the Provider Directly
```typescript
// GOOD: Use A0PurchaseProvider directly
export function App() {
return (
<A0PurchaseProvider>
<MainScreen />
</A0PurchaseProvider>
);
}
// UNNECESSARY: Double-wrapping contexts
function SubscriptionProvider({ children }) {
return (
<A0PurchaseProvider>
<AnotherProvider>
{children}
</AnotherProvider>
</A0PurchaseProvider>
);
}
```
### ✅ DO: Sync with Your Auth System
```typescript
// GOOD: Keep purchase user in sync with app auth
function App() {
const { user } = useAuth(); // Your auth system
return (
<A0PurchaseProvider config={{
appUserId: user?.id // undefined when logged out = anonymous
}}>
<YourApp />
</A0PurchaseProvider>
);
}
```
### ✅ DO: Handle All Purchase States
```typescript
function PurchaseButton() {
const { purchase, isLoading, isPremium } = useA0Purchases();
if (isPremium) return <Text>Already Premium!</Text>;
if (isLoading) return <ActivityIndicator />;
return (
<Button
onPress={async () => {
try {
await purchase('premium_monthly');
} catch (error) {
if (error.userCancelled) {
// User cancelled - no action needed
} else {
Alert.alert('Purchase failed', error.message);
}
}
}}
title="Upgrade"
/>
);
}
```
## Debugging
Enable debug mode to see detailed logs:
```typescript
await Purchases.initialize({ debug: true });
```
This will log:
- Network requests to the backend
- User ID changes and aliasing
- Purchase flow steps
- Platform adapter operations
## Troubleshooting
### ChunkLoadError in Web Environments
If you encounter `ChunkLoadError: Loading chunk # failed` in production, this is typically caused by:
1. **Deployment timing**: Users have the old version loaded while you deploy a new version
2. **CDN caching**: Old chunk files are cached but no longer exist
3. **Bundler issues**: Dynamic imports creating chunks that aren't properly deployed
**Solution in v0.2.5+**: The library now uses static imports instead of dynamic imports to prevent chunk splitting issues.
If you still encounter this error:
- Clear browser cache
- Ensure all build artifacts are deployed together
- Consider using service workers to handle version mismatches
## Migration from RevenueCat
```typescript
// RevenueCat
Purchases.configure({ apiKey: "..." });
const offerings = await Purchases.getOfferings();
await Purchases.purchasePackage(package);
// a0-purchases
await Purchases.initialize();
const offerings = Purchases.getOfferings();
await Purchases.purchase(package.identifier);
```
## Error Handling
The library uses typed errors with specific error codes:
```typescript
try {
await Purchases.purchase('premium_monthly');
} catch (error) {
if (error.userCancelled) {
// User cancelled the purchase
} else if (error.code === PURCHASES_ERROR_CODE.PRODUCT_NOT_AVAILABLE_ERROR) {
// Product not available
} else {
// Other error
console.error(error.message);
}
}
```
## License
MIT