@idealyst/payments 1.2.109 → 1.2.111

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idealyst/payments",
3
- "version": "1.2.109",
3
+ "version": "1.2.111",
4
4
  "description": "Cross-platform In-App Purchase wrapper for React Native (StoreKit 2, Google Play Billing)",
5
5
  "documentation": "https://github.com/IdealystIO/idealyst-framework/tree/main/packages/payments#readme",
6
6
  "readme": "README.md",
package/src/errors.ts CHANGED
@@ -11,56 +11,79 @@ export function createIAPError(
11
11
  /**
12
12
  * Normalize a react-native-iap error (or any thrown value) into an IAPError.
13
13
  *
14
- * react-native-iap errors have a `code` property with values like
15
- * "E_USER_CANCELLED", "E_ALREADY_OWNED", etc.
14
+ * react-native-iap v14 uses kebab-case ErrorCode enum values (e.g. 'user-cancelled').
15
+ * Older versions used E_ prefix strings (e.g. 'E_USER_CANCELLED').
16
+ * Both formats are handled here.
16
17
  */
17
18
  export function normalizeError(error: unknown): IAPError {
18
19
  if (error && typeof error === 'object' && 'code' in error) {
19
20
  const iapError = error as { code?: string; message?: string };
20
21
 
21
22
  switch (iapError.code) {
23
+ // v14: 'user-cancelled' | legacy: 'E_USER_CANCELLED'
24
+ case 'user-cancelled':
22
25
  case 'E_USER_CANCELLED':
23
26
  return createIAPError(
24
27
  'user_cancelled',
25
28
  'Purchase was cancelled by user',
26
29
  error,
27
30
  );
31
+ // v14: 'already-owned' | legacy: 'E_ALREADY_OWNED'
32
+ case 'already-owned':
28
33
  case 'E_ALREADY_OWNED':
29
34
  return createIAPError(
30
35
  'already_owned',
31
36
  'This item has already been purchased',
32
37
  error,
33
38
  );
39
+ // v14: 'not-prepared' | legacy: 'E_NOT_PREPARED'
40
+ case 'not-prepared':
34
41
  case 'E_NOT_PREPARED':
35
42
  return createIAPError(
36
43
  'not_initialized',
37
44
  'IAP connection not initialized. Call initializeIAP() first.',
38
45
  error,
39
46
  );
47
+ // v14: 'deferred-payment' | legacy: 'E_DEFERRED'
48
+ case 'deferred-payment':
49
+ case 'pending':
40
50
  case 'E_DEFERRED':
41
51
  return createIAPError(
42
52
  'purchase_pending',
43
53
  'Purchase is pending approval (e.g., Ask to Buy)',
44
54
  error,
45
55
  );
56
+ // v14: 'item-unavailable' | legacy: 'E_ITEM_UNAVAILABLE'
57
+ case 'item-unavailable':
46
58
  case 'E_ITEM_UNAVAILABLE':
47
59
  return createIAPError(
48
60
  'item_unavailable',
49
61
  'The requested product is not available in the store',
50
62
  error,
51
63
  );
64
+ // v14: 'network-error' | legacy: 'E_NETWORK_ERROR'
65
+ case 'network-error':
52
66
  case 'E_NETWORK_ERROR':
53
67
  return createIAPError(
54
68
  'network_error',
55
69
  iapError.message || 'A network error occurred',
56
70
  error,
57
71
  );
72
+ // v14: 'service-error' | legacy: 'E_SERVICE_ERROR'
73
+ case 'service-error':
58
74
  case 'E_SERVICE_ERROR':
59
75
  return createIAPError(
60
76
  'store_error',
61
77
  iapError.message || 'The store service encountered an error',
62
78
  error,
63
79
  );
80
+ // v14: 'iap-not-available'
81
+ case 'iap-not-available':
82
+ return createIAPError(
83
+ 'not_available',
84
+ 'In-App Purchases are not available on this device',
85
+ error,
86
+ );
64
87
  default:
65
88
  return createIAPError(
66
89
  'unknown',
@@ -1,6 +1,13 @@
1
1
  // ============================================================================
2
2
  // Native IAP Implementation
3
- // Wraps react-native-iap for StoreKit 2 (iOS) and Google Play Billing (Android)
3
+ // Wraps react-native-iap v14.x for StoreKit 2 (iOS) and Google Play Billing (Android)
4
+ //
5
+ // v14 API changes from earlier versions:
6
+ // - getProducts/getSubscriptions → fetchProducts({ skus, type })
7
+ // - requestPurchase returns void — results come via purchaseUpdatedListener
8
+ // - Errors come via purchaseErrorListener
9
+ // - Product fields: id (not productId), displayPrice (not localizedPrice)
10
+ // - Purchase fields: productId, id (transaction ID)
4
11
  // ============================================================================
5
12
 
6
13
  import { Platform } from 'react-native';
@@ -32,6 +39,16 @@ try {
32
39
  let _status: IAPProviderStatus = { ...INITIAL_PROVIDER_STATUS };
33
40
  let _config: IAPConfig = {};
34
41
 
42
+ // Listener subscriptions (EmitterSubscription-like objects with .remove())
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
+ let _purchaseUpdateSubscription: any = null;
45
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
+ let _purchaseErrorSubscription: any = null;
47
+
48
+ // Pending purchase promise callbacks — one purchase at a time
49
+ let _pendingResolve: ((raw: unknown) => void) | null = null;
50
+ let _pendingReject: ((error: unknown) => void) | null = null;
51
+
35
52
  /**
36
53
  * Get the current IAP provider status.
37
54
  */
@@ -41,6 +58,8 @@ export function getIAPStatus(): IAPProviderStatus {
41
58
 
42
59
  /**
43
60
  * Initialize the IAP connection to the native store.
61
+ * Sets up purchaseUpdatedListener and purchaseErrorListener to bridge
62
+ * v14's event-based purchase flow back to Promises.
44
63
  */
45
64
  export async function initializeIAP(config?: IAPConfig): Promise<void> {
46
65
  if (!RNIap) {
@@ -61,14 +80,28 @@ export async function initializeIAP(config?: IAPConfig): Promise<void> {
61
80
  try {
62
81
  await RNIap.initConnection();
63
82
 
64
- // Flush failed purchases cached as pending on Android
65
- if (Platform.OS === 'android') {
66
- try {
67
- await RNIap.flushFailedPurchasesCachedAsPendingAndroid();
68
- } catch {
69
- // Non-critical — ignore flush errors
70
- }
71
- }
83
+ // Set up purchase listeners these fire when requestPurchase completes or fails
84
+ _purchaseUpdateSubscription = RNIap.purchaseUpdatedListener(
85
+ (purchase: unknown) => {
86
+ if (_pendingResolve) {
87
+ const resolve = _pendingResolve;
88
+ _pendingResolve = null;
89
+ _pendingReject = null;
90
+ resolve(purchase);
91
+ }
92
+ },
93
+ );
94
+
95
+ _purchaseErrorSubscription = RNIap.purchaseErrorListener(
96
+ (error: unknown) => {
97
+ if (_pendingReject) {
98
+ const reject = _pendingReject;
99
+ _pendingResolve = null;
100
+ _pendingReject = null;
101
+ reject(error);
102
+ }
103
+ },
104
+ );
72
105
 
73
106
  _status = {
74
107
  state: 'ready',
@@ -90,8 +123,8 @@ export async function getProducts(skus: string[]): Promise<IAPProduct[]> {
90
123
  assertReady();
91
124
 
92
125
  try {
93
- const products = await RNIap!.getProducts({ skus });
94
- return products.map(mapProduct);
126
+ const products = await RNIap!.fetchProducts({ skus, type: 'in-app' });
127
+ return products.map((p: unknown) => mapProduct(p, 'iap'));
95
128
  } catch (error) {
96
129
  throw normalizeError(error);
97
130
  }
@@ -106,8 +139,8 @@ export async function getSubscriptions(
106
139
  assertReady();
107
140
 
108
141
  try {
109
- const subscriptions = await RNIap!.getSubscriptions({ skus });
110
- return subscriptions.map(mapSubscription);
142
+ const products = await RNIap!.fetchProducts({ skus, type: 'subs' });
143
+ return products.map(mapSubscription);
111
144
  } catch (error) {
112
145
  throw normalizeError(error);
113
146
  }
@@ -120,13 +153,21 @@ export async function purchaseProduct(sku: string): Promise<IAPPurchase> {
120
153
  assertReady();
121
154
 
122
155
  try {
123
- const purchase = await RNIap!.requestPurchase({ sku });
156
+ const rawPurchase = await requestPurchaseWithEvents({
157
+ request: {
158
+ apple: { sku },
159
+ google: { skus: [sku] },
160
+ },
161
+ type: 'in-app',
162
+ });
163
+
164
+ const mapped = mapPurchase(rawPurchase);
124
165
 
125
166
  if (_config.autoFinishTransactions) {
126
- await RNIap!.finishTransaction({ purchase, isConsumable: false });
167
+ await RNIap!.finishTransaction({ purchase: rawPurchase, isConsumable: false });
127
168
  }
128
169
 
129
- return mapPurchase(purchase);
170
+ return mapped;
130
171
  } catch (error) {
131
172
  throw normalizeError(error);
132
173
  }
@@ -143,19 +184,27 @@ export async function purchaseSubscription(
143
184
  assertReady();
144
185
 
145
186
  try {
146
- const request: Record<string, unknown> = { sku };
147
-
148
- if (Platform.OS === 'android' && offerToken) {
149
- request.subscriptionOffers = [{ sku, offerToken }];
187
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
+ const googleRequest: any = { skus: [sku] };
189
+ if (offerToken) {
190
+ googleRequest.offerToken = offerToken;
150
191
  }
151
192
 
152
- const purchase = await RNIap!.requestSubscription(request);
193
+ const rawPurchase = await requestPurchaseWithEvents({
194
+ request: {
195
+ apple: { sku },
196
+ google: googleRequest,
197
+ },
198
+ type: 'subs',
199
+ });
200
+
201
+ const mapped = mapPurchase(rawPurchase);
153
202
 
154
203
  if (_config.autoFinishTransactions) {
155
- await RNIap!.finishTransaction({ purchase, isConsumable: false });
204
+ await RNIap!.finishTransaction({ purchase: rawPurchase, isConsumable: false });
156
205
  }
157
206
 
158
- return mapPurchase(purchase);
207
+ return mapped;
159
208
  } catch (error) {
160
209
  throw normalizeError(error);
161
210
  }
@@ -174,8 +223,14 @@ export async function finishTransaction(
174
223
  assertReady();
175
224
 
176
225
  try {
226
+ // finishTransaction expects the raw purchase object; we reconstruct the
227
+ // minimal shape that react-native-iap needs (id + productId + purchaseToken)
177
228
  await RNIap!.finishTransaction({
178
- purchase: { transactionId: purchase.transactionId },
229
+ purchase: {
230
+ id: purchase.transactionId,
231
+ productId: purchase.sku,
232
+ purchaseToken: purchase.purchaseToken,
233
+ },
179
234
  isConsumable: isConsumable ?? false,
180
235
  });
181
236
  } catch (error) {
@@ -199,22 +254,74 @@ export async function restorePurchases(): Promise<IAPPurchase[]> {
199
254
 
200
255
  /**
201
256
  * End the IAP connection. Call on cleanup (e.g., app unmount).
257
+ * Removes purchase listeners and rejects any pending purchase promise.
202
258
  */
203
259
  export async function endConnection(): Promise<void> {
204
260
  if (!RNIap) return;
205
261
 
262
+ // Reject any pending purchase
263
+ if (_pendingReject) {
264
+ const reject = _pendingReject;
265
+ _pendingResolve = null;
266
+ _pendingReject = null;
267
+ reject(createIAPError('not_initialized', 'IAP connection ended while purchase was pending'));
268
+ }
269
+
270
+ // Remove listeners
271
+ if (_purchaseUpdateSubscription) {
272
+ _purchaseUpdateSubscription.remove();
273
+ _purchaseUpdateSubscription = null;
274
+ }
275
+ if (_purchaseErrorSubscription) {
276
+ _purchaseErrorSubscription.remove();
277
+ _purchaseErrorSubscription = null;
278
+ }
279
+
206
280
  try {
207
281
  await RNIap.endConnection();
208
- _status = { ...INITIAL_PROVIDER_STATUS };
209
282
  } catch {
210
283
  // Ignore errors during cleanup
211
284
  }
285
+
286
+ _status = { ...INITIAL_PROVIDER_STATUS };
212
287
  }
213
288
 
214
289
  // ============================================================================
215
290
  // Internal Helpers
216
291
  // ============================================================================
217
292
 
293
+ /**
294
+ * Bridge v14's fire-and-forget requestPurchase to a Promise.
295
+ * Stores resolve/reject callbacks that are settled by the purchase listeners
296
+ * set up in initializeIAP.
297
+ */
298
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
299
+ function requestPurchaseWithEvents(params: any): Promise<any> {
300
+ if (_pendingResolve || _pendingReject) {
301
+ return Promise.reject(
302
+ createIAPError('unknown', 'A purchase is already in progress'),
303
+ );
304
+ }
305
+
306
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
307
+ return new Promise<any>((resolve, reject) => {
308
+ _pendingResolve = resolve;
309
+ _pendingReject = reject;
310
+
311
+ // requestPurchase returns void in v14 — result comes through listeners
312
+ RNIap!.requestPurchase(params).catch((error: unknown) => {
313
+ // If requestPurchase itself throws (e.g., invalid params),
314
+ // settle the promise immediately
315
+ if (_pendingReject) {
316
+ const pendingReject = _pendingReject;
317
+ _pendingResolve = null;
318
+ _pendingReject = null;
319
+ pendingReject(error);
320
+ }
321
+ });
322
+ });
323
+ }
324
+
218
325
  function assertReady(): void {
219
326
  if (!RNIap) {
220
327
  throw createIAPError(
@@ -235,26 +342,25 @@ function getPlatform(): ProductPlatform {
235
342
  }
236
343
 
237
344
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
238
- function mapProduct(raw: any): IAPProduct {
345
+ function mapProduct(raw: any, type: 'iap' | 'sub' = 'iap'): IAPProduct {
239
346
  return {
240
- sku: raw.productId ?? raw.sku ?? '',
241
- title: raw.title ?? '',
347
+ sku: raw.id ?? raw.productId ?? '',
348
+ title: raw.title ?? raw.displayName ?? '',
242
349
  description: raw.description ?? '',
243
- price: parseFloat(raw.price ?? '0'),
244
- priceFormatted: raw.localizedPrice ?? raw.price ?? '',
350
+ price: typeof raw.price === 'number' ? raw.price : parseFloat(raw.price ?? '0'),
351
+ priceFormatted: raw.displayPrice ?? raw.localizedPrice ?? String(raw.price ?? ''),
245
352
  currency: raw.currency ?? '',
246
- type: 'iap',
353
+ type,
247
354
  platform: getPlatform(),
248
355
  };
249
356
  }
250
357
 
251
358
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
252
359
  function mapSubscription(raw: any): IAPSubscription {
253
- const base = mapProduct(raw);
360
+ const base = mapProduct(raw, 'sub');
254
361
 
255
362
  return {
256
363
  ...base,
257
- type: 'sub',
258
364
  subscriptionPeriod: parseSubscriptionPeriod(raw),
259
365
  introductoryPrice: raw.introductoryPrice
260
366
  ? mapDiscount(raw.introductoryPrice, 'introductory')
@@ -275,7 +381,19 @@ function parseSubscriptionPeriod(raw: any): SubscriptionPeriod {
275
381
  };
276
382
  }
277
383
 
278
- // Android: subscriptionPeriodAndroid (ISO 8601 duration, e.g., "P1M", "P1Y")
384
+ // Android: subscriptionOfferDetailsAndroid may contain period info
385
+ if (raw.subscriptionOfferDetailsAndroid?.length) {
386
+ const offer = raw.subscriptionOfferDetailsAndroid[0];
387
+ const pricingPhases = offer?.pricingPhases?.pricingPhaseList;
388
+ if (pricingPhases?.length) {
389
+ const billingPeriod = pricingPhases[0]?.billingPeriod;
390
+ if (billingPeriod) {
391
+ return parseISO8601Period(billingPeriod);
392
+ }
393
+ }
394
+ }
395
+
396
+ // Fallback: subscriptionPeriodAndroid (older format)
279
397
  if (raw.subscriptionPeriodAndroid) {
280
398
  return parseISO8601Period(raw.subscriptionPeriodAndroid);
281
399
  }
@@ -332,7 +450,7 @@ function mapDiscount(raw: any, type: 'introductory' | 'promotional'): Subscripti
332
450
  return {
333
451
  identifier: raw.identifier,
334
452
  price: parseFloat(raw.price ?? '0'),
335
- priceFormatted: raw.localizedPrice ?? raw.price ?? '',
453
+ priceFormatted: raw.displayPrice ?? raw.localizedPrice ?? raw.price ?? '',
336
454
  period: raw.subscriptionPeriod
337
455
  ? parseISO8601Period(raw.subscriptionPeriod)
338
456
  : { unit: 'month', numberOfUnits: 1 },
@@ -344,13 +462,16 @@ function mapDiscount(raw: any, type: 'introductory' | 'promotional'): Subscripti
344
462
 
345
463
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
346
464
  function mapPurchase(raw: any, stateOverride?: IAPPurchase['purchaseState']): IAPPurchase {
465
+ // v14: transaction ID is in `id`, product is in `productId`
347
466
  return {
348
- sku: raw.productId ?? raw.sku ?? '',
349
- transactionId: raw.transactionId ?? '',
467
+ sku: raw.productId ?? raw.id ?? '',
468
+ transactionId: raw.id ?? raw.transactionId ?? '',
350
469
  transactionDate: raw.transactionDate
351
- ? parseInt(raw.transactionDate, 10)
470
+ ? (typeof raw.transactionDate === 'number'
471
+ ? raw.transactionDate
472
+ : parseInt(raw.transactionDate, 10))
352
473
  : Date.now(),
353
- transactionReceipt: raw.transactionReceipt ?? '',
474
+ transactionReceipt: raw.transactionReceipt ?? raw.dataAndroid ?? '',
354
475
  purchaseToken: raw.purchaseToken,
355
476
  isAcknowledged: raw.isAcknowledgedAndroid,
356
477
  purchaseState: stateOverride ?? 'purchased',