expo-helium 3.0.17 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,163 +1,282 @@
1
+ import type {
2
+ CustomerInfo,
3
+ PurchasesEntitlementInfo,
4
+ PurchasesError,
5
+ PurchasesPackage,
6
+ SubscriptionOption
7
+ } from 'react-native-purchases';
1
8
  import Purchases, {PURCHASES_ERROR_CODE, PurchasesStoreProduct} from 'react-native-purchases';
2
- import type { PurchasesError, PurchasesPackage, CustomerInfoUpdateListener, CustomerInfo, PurchasesEntitlementInfo } from 'react-native-purchases';
9
+ import {Platform} from 'react-native';
3
10
  import {HeliumPurchaseConfig, HeliumPurchaseResult} from "../HeliumPaywallSdk.types";
4
11
  import {setRevenueCatAppUserId} from "../index";
5
12
 
6
13
  // Rename the factory function
7
14
  export function createRevenueCatPurchaseConfig(config?: {
8
15
  apiKey?: string;
16
+ apiKeyIOS?: string;
17
+ apiKeyAndroid?: string;
9
18
  }): HeliumPurchaseConfig {
10
- const rcHandler = new RevenueCatHeliumHandler(config?.apiKey);
11
- return {
12
- makePurchase: rcHandler.makePurchase.bind(rcHandler),
13
- restorePurchases: rcHandler.restorePurchases.bind(rcHandler),
14
- };
19
+ const rcHandler = new RevenueCatHeliumHandler(config);
20
+ return {
21
+ makePurchaseIOS: rcHandler.makePurchaseIOS.bind(rcHandler),
22
+ makePurchaseAndroid: rcHandler.makePurchaseAndroid.bind(rcHandler),
23
+ restorePurchases: rcHandler.restorePurchases.bind(rcHandler),
24
+ };
15
25
  }
16
26
 
17
27
  export class RevenueCatHeliumHandler {
18
- private productIdToPackageMapping: Record<string, PurchasesPackage> = {};
19
- private isMappingInitialized: boolean = false;
20
- private initializationPromise: Promise<void> | null = null;
28
+ private productIdToPackageMapping: Record<string, PurchasesPackage> = {};
29
+ private isMappingInitialized: boolean = false;
30
+ private initializationPromise: Promise<void> | null = null;
21
31
 
22
- private rcProductToPackageMapping: Record<string, PurchasesStoreProduct> = {};
32
+ private rcProductToPackageMapping: Record<string, PurchasesStoreProduct> = {};
23
33
 
24
- constructor(apiKey?: string) {
25
- if (apiKey) {
26
- Purchases.configure({ apiKey });
27
- }
28
- void this.initializePackageMapping();
34
+ constructor(config?: { apiKey?: string; apiKeyIOS?: string; apiKeyAndroid?: string }) {
35
+ // Determine which API key to use based on platform
36
+ let effectiveApiKey: string | undefined;
37
+ if (Platform.OS === 'ios' && config?.apiKeyIOS) {
38
+ effectiveApiKey = config.apiKeyIOS;
39
+ } else if (Platform.OS === 'android' && config?.apiKeyAndroid) {
40
+ effectiveApiKey = config.apiKeyAndroid;
41
+ } else {
42
+ effectiveApiKey = config?.apiKey;
29
43
  }
30
44
 
31
- private async initializePackageMapping(): Promise<void> {
32
- if (this.initializationPromise) {
33
- return this.initializationPromise;
34
- }
35
- this.initializationPromise = (async () => {
36
- try {
37
- // Keep this value as up-to-date as possible
38
- setRevenueCatAppUserId(await Purchases.getAppUserID());
39
-
40
- const offerings = await Purchases.getOfferings();
41
- const allOfferings = offerings.all;
42
- for (const offering of Object.values(allOfferings)) {
43
- offering.availablePackages.forEach((pkg: PurchasesPackage) => {
44
- if (pkg.product?.identifier) {
45
- this.productIdToPackageMapping[pkg.product.identifier] = pkg;
46
- }
47
- });
48
- }
49
- this.isMappingInitialized = true;
50
- } catch (error) {
51
- this.isMappingInitialized = false;
52
- } finally {
53
- this.initializationPromise = null;
54
- }
55
- })();
56
- return this.initializationPromise;
45
+ if (effectiveApiKey) {
46
+ Purchases.configure({apiKey: effectiveApiKey});
57
47
  }
48
+ void this.initializePackageMapping();
49
+ }
58
50
 
59
- private async ensureMappingInitialized(): Promise<void> {
60
- if (!this.isMappingInitialized && !this.initializationPromise) {
61
- await this.initializePackageMapping();
62
- } else if (this.initializationPromise) {
63
- await this.initializationPromise;
64
- }
51
+ private async initializePackageMapping(): Promise<void> {
52
+ if (this.initializationPromise) {
53
+ return this.initializationPromise;
65
54
  }
66
-
67
- async makePurchase(productId: string): Promise<HeliumPurchaseResult> {
68
- await this.ensureMappingInitialized();
55
+ this.initializationPromise = (async () => {
56
+ try {
69
57
  // Keep this value as up-to-date as possible
70
58
  setRevenueCatAppUserId(await Purchases.getAppUserID());
71
59
 
72
- const pkg: PurchasesPackage | undefined = this.productIdToPackageMapping[productId];
73
- let rcProduct: PurchasesStoreProduct | undefined;
74
- if (!pkg) {
75
- // Use cached if available
76
- rcProduct = this.rcProductToPackageMapping[productId];
77
- if (!rcProduct) {
78
- // Try to retrieve now
79
- try {
80
- const rcProducts = await Purchases.getProducts([productId]);
81
- rcProduct = rcProducts.length > 0 ? rcProducts[0] : undefined;
82
- } catch {
83
- // 'failed' status will be returned
84
- }
85
- if (rcProduct) {
86
- this.rcProductToPackageMapping[productId] = rcProduct;
87
- }
60
+ const offerings = await Purchases.getOfferings();
61
+ const allOfferings = offerings.all;
62
+ for (const offering of Object.values(allOfferings)) {
63
+ offering.availablePackages.forEach((pkg: PurchasesPackage) => {
64
+ if (pkg.product?.identifier) {
65
+ this.productIdToPackageMapping[pkg.product.identifier] = pkg;
88
66
  }
67
+ });
89
68
  }
69
+ this.isMappingInitialized = true;
70
+ } catch (error) {
71
+ this.isMappingInitialized = false;
72
+ } finally {
73
+ this.initializationPromise = null;
74
+ }
75
+ })();
76
+ return this.initializationPromise;
77
+ }
90
78
 
91
- try {
92
- let customerInfo: CustomerInfo;
93
- if (pkg) {
94
- customerInfo = (await Purchases.purchasePackage(pkg)).customerInfo;
95
- } else if (rcProduct) {
96
- customerInfo = (await Purchases.purchaseStoreProduct(rcProduct)).customerInfo;
97
- } else {
98
- return { status: 'failed', error: `RevenueCat Product/Package not found for ID: ${productId}` };
99
- }
100
- const isActive = this.isProductActive(customerInfo, productId);
101
- if (isActive) {
102
- return { status: 'purchased' };
103
- } else {
104
- // This case might occur if the purchase succeeded but the entitlement wasn't immediately active
105
- // or if a different product became active.
106
- // Consider if polling/listening might be needed here too, similar to pending.
107
- // For now, returning failed as the specific product isn't confirmed active.
108
- return { status: 'failed', error: 'Purchase possibly complete but entitlement/subscription not active for this product.' };
109
- }
110
- } catch (error) {
111
- const purchasesError = error as PurchasesError;
112
-
113
- if (purchasesError?.code === PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) {
114
- // Wait for a terminal state for up to 5 seconds
115
- return new Promise((resolve) => {
116
- // Define the listener function separately to remove it later
117
- const updateListener: CustomerInfoUpdateListener = (updatedCustomerInfo: CustomerInfo) => {
118
- const isActive = this.isProductActive(updatedCustomerInfo, productId);
119
- if (isActive) {
120
- clearTimeout(timeoutId);
121
- // Remove listener using the function reference
122
- Purchases.removeCustomerInfoUpdateListener(updateListener);
123
- resolve({ status: 'purchased' });
124
- }
125
- };
126
-
127
- const timeoutId = setTimeout(() => {
128
- // Remove listener using the function reference on timeout
129
- Purchases.removeCustomerInfoUpdateListener(updateListener);
130
- resolve({ status: 'pending' });
131
- }, 5000);
132
-
133
- // Add the listener
134
- Purchases.addCustomerInfoUpdateListener(updateListener);
135
- });
136
- }
79
+ private async ensureMappingInitialized(): Promise<void> {
80
+ if (!this.isMappingInitialized && !this.initializationPromise) {
81
+ await this.initializePackageMapping();
82
+ } else if (this.initializationPromise) {
83
+ await this.initializationPromise;
84
+ }
85
+ }
137
86
 
138
- if (purchasesError?.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
139
- return { status: 'cancelled' };
140
- }
87
+ async makePurchaseIOS(productId: string): Promise<HeliumPurchaseResult> {
88
+ // Keep this value as up-to-date as possible
89
+ setRevenueCatAppUserId(await Purchases.getAppUserID());
141
90
 
142
- // Handle other errors
143
- return { status: 'failed', error: purchasesError?.message || 'RevenueCat purchase failed.' };
91
+ await this.ensureMappingInitialized();
92
+ const pkg: PurchasesPackage | undefined = this.productIdToPackageMapping[productId];
93
+ let rcProduct: PurchasesStoreProduct | undefined;
94
+ if (!pkg) {
95
+ // Use cached if available
96
+ rcProduct = this.rcProductToPackageMapping[productId];
97
+ if (!rcProduct) {
98
+ // Try to retrieve now
99
+ try {
100
+ const rcProducts = await Purchases.getProducts([productId]);
101
+ rcProduct = rcProducts.length > 0 ? rcProducts[0] : undefined;
102
+ } catch {
103
+ // 'failed' status will be returned
104
+ }
105
+ if (rcProduct) {
106
+ this.rcProductToPackageMapping[productId] = rcProduct;
144
107
  }
108
+ }
145
109
  }
146
110
 
147
- // Helper function to check if a product is active in CustomerInfo
148
- private isProductActive(customerInfo: CustomerInfo, productId: string): boolean {
149
- return Object.values(customerInfo.entitlements.active).some((entitlement: PurchasesEntitlementInfo) => entitlement.productIdentifier === productId)
150
- || customerInfo.activeSubscriptions.includes(productId)
151
- || customerInfo.allPurchasedProductIdentifiers.includes(productId);
111
+ try {
112
+ let customerInfo: CustomerInfo;
113
+ if (pkg) {
114
+ customerInfo = (await Purchases.purchasePackage(pkg)).customerInfo;
115
+ } else if (rcProduct) {
116
+ customerInfo = (await Purchases.purchaseStoreProduct(rcProduct)).customerInfo;
117
+ } else {
118
+ return {status: 'failed', error: `RevenueCat Product/Package not found for ID: ${productId}`};
119
+ }
120
+ const isActive = this.isProductActive(customerInfo, productId);
121
+ if (isActive) {
122
+ return {status: 'purchased'};
123
+ } else {
124
+ // This case might occur if the purchase succeeded but the entitlement wasn't immediately active
125
+ // or if a different product became active.
126
+ // Consider if polling/listening might be needed here too, similar to pending.
127
+ // For now, returning failed as the specific product isn't confirmed active.
128
+ return {
129
+ status: 'failed',
130
+ error: 'Purchase possibly complete but entitlement/subscription not active for this product.'
131
+ };
132
+ }
133
+ } catch (error) {
134
+ const purchasesError = error as PurchasesError;
135
+
136
+ if (purchasesError?.code === PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) {
137
+ return {status: 'pending'};
138
+ }
139
+
140
+ if (purchasesError?.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
141
+ return {status: 'cancelled'};
142
+ }
143
+
144
+ // Handle other errors
145
+ return {status: 'failed', error: purchasesError?.message || 'RevenueCat purchase failed.'};
152
146
  }
147
+ }
148
+
149
+ // Android-specific purchase logic (completely separated from iOS)
150
+ async makePurchaseAndroid(productId: string, basePlanId?: string, offerId?: string): Promise<HeliumPurchaseResult> {
151
+ // Keep this value as up-to-date as possible
152
+ setRevenueCatAppUserId(await Purchases.getAppUserID());
153
+
154
+ // Handle subscription with base plan or offer
155
+ if (basePlanId || offerId) {
156
+ const subscriptionOption = await this.findAndroidSubscriptionOption(
157
+ productId,
158
+ basePlanId,
159
+ offerId
160
+ );
153
161
 
154
- async restorePurchases(): Promise<boolean> {
162
+ if (subscriptionOption) {
155
163
  try {
156
- const customerInfo = await Purchases.restorePurchases();
157
- const isActive = Object.keys(customerInfo.entitlements.active).length > 0;
158
- return isActive;
164
+ const customerInfo = (await Purchases.purchaseSubscriptionOption(subscriptionOption)).customerInfo;
165
+
166
+ const isActive = this.isProductActive(customerInfo, productId);
167
+ if (isActive) {
168
+ return {status: 'purchased'};
169
+ } else {
170
+ return {
171
+ status: 'failed',
172
+ error: 'Purchase possibly complete but entitlement/subscription not active for this product.'
173
+ };
174
+ }
159
175
  } catch (error) {
160
- return false;
176
+ const purchasesError = error as PurchasesError;
177
+
178
+ if (purchasesError?.code === PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) {
179
+ return {status: 'pending'};
180
+ }
181
+
182
+ if (purchasesError?.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
183
+ return {status: 'cancelled'};
184
+ }
185
+
186
+ return {status: 'failed', error: purchasesError?.message || 'RevenueCat purchase failed.'};
161
187
  }
188
+ }
189
+ }
190
+
191
+ // Handle one-time purchase or subscription that didn't have matching base plan / offer
192
+ let rcProduct: PurchasesStoreProduct;
193
+ try {
194
+ const products = await Purchases.getProducts([productId]);
195
+ if (products.length === 0) {
196
+ return {status: 'failed', error: `Android product not found: ${productId}`};
197
+ }
198
+ rcProduct = products[0];
199
+ } catch {
200
+ return {status: 'failed', error: `Failed to retrieve Android product: ${productId}`};
201
+ }
202
+
203
+ try {
204
+ const customerInfo = (await Purchases.purchaseStoreProduct(rcProduct)).customerInfo;
205
+
206
+ const isActive = this.isProductActive(customerInfo, productId);
207
+ if (isActive) {
208
+ return {status: 'purchased'};
209
+ } else {
210
+ return {
211
+ status: 'failed',
212
+ error: 'Purchase possibly complete but entitlement/subscription not active for this product.'
213
+ };
214
+ }
215
+ } catch (error) {
216
+ const purchasesError = error as PurchasesError;
217
+
218
+ if (purchasesError?.code === PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) {
219
+ return {status: 'pending'};
220
+ }
221
+
222
+ if (purchasesError?.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
223
+ return {status: 'cancelled'};
224
+ }
225
+
226
+ return {status: 'failed', error: purchasesError?.message || 'RevenueCat purchase failed.'};
227
+ }
228
+ }
229
+
230
+ // Android helper: Find subscription option
231
+ private async findAndroidSubscriptionOption(
232
+ productId: string,
233
+ basePlanId?: string,
234
+ offerId?: string
235
+ ): Promise<SubscriptionOption | undefined> {
236
+ try {
237
+ const products = await Purchases.getProducts([productId]);
238
+ if (products.length === 0) {
239
+ return undefined;
240
+ }
241
+
242
+ const product = products[0];
243
+
244
+ if (!product.subscriptionOptions || product.subscriptionOptions.length === 0) {
245
+ return undefined;
246
+ }
247
+
248
+ let subscriptionOption: SubscriptionOption | undefined;
249
+
250
+ if (offerId && basePlanId) {
251
+ // Look for specific offer: "basePlanId:offerId"
252
+ const targetId = `${basePlanId}:${offerId}`;
253
+ subscriptionOption = product.subscriptionOptions.find(opt => opt.id === targetId);
254
+ } else if (basePlanId) {
255
+ subscriptionOption = product.subscriptionOptions.find(
256
+ opt => opt.id === basePlanId && opt.isBasePlan
257
+ );
258
+ }
259
+
260
+ return subscriptionOption;
261
+ } catch (error) {
262
+ return undefined;
263
+ }
264
+ }
265
+
266
+ // Helper function to check if a product is active in CustomerInfo
267
+ private isProductActive(customerInfo: CustomerInfo, productId: string): boolean {
268
+ return Object.values(customerInfo.entitlements.active).some((entitlement: PurchasesEntitlementInfo) => entitlement.productIdentifier === productId)
269
+ || customerInfo.activeSubscriptions.includes(productId)
270
+ || customerInfo.allPurchasedProductIdentifiers.includes(productId);
271
+ }
272
+
273
+ async restorePurchases(): Promise<boolean> {
274
+ try {
275
+ const customerInfo = await Purchases.restorePurchases();
276
+ const isActive = Object.keys(customerInfo.entitlements.active).length > 0;
277
+ return isActive;
278
+ } catch (error) {
279
+ return false;
162
280
  }
281
+ }
163
282
  }