expo-iap 3.0.8 → 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.
Files changed (53) hide show
  1. package/CLAUDE.md +2 -2
  2. package/CONTRIBUTING.md +19 -0
  3. package/README.md +18 -6
  4. package/android/build.gradle +24 -1
  5. package/android/src/main/java/expo/modules/iap/ExpoIapLog.kt +69 -0
  6. package/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +190 -59
  7. package/build/index.d.ts +20 -47
  8. package/build/index.d.ts.map +1 -1
  9. package/build/index.js +94 -137
  10. package/build/index.js.map +1 -1
  11. package/build/modules/android.d.ts.map +1 -1
  12. package/build/modules/android.js +2 -1
  13. package/build/modules/android.js.map +1 -1
  14. package/build/modules/ios.d.ts +16 -1
  15. package/build/modules/ios.d.ts.map +1 -1
  16. package/build/modules/ios.js +29 -16
  17. package/build/modules/ios.js.map +1 -1
  18. package/build/types.d.ts +8 -6
  19. package/build/types.d.ts.map +1 -1
  20. package/build/types.js.map +1 -1
  21. package/build/useIAP.d.ts +1 -1
  22. package/build/useIAP.d.ts.map +1 -1
  23. package/build/useIAP.js +12 -15
  24. package/build/useIAP.js.map +1 -1
  25. package/build/utils/errorMapping.d.ts +32 -23
  26. package/build/utils/errorMapping.d.ts.map +1 -1
  27. package/build/utils/errorMapping.js +117 -22
  28. package/build/utils/errorMapping.js.map +1 -1
  29. package/ios/ExpoIap.podspec +3 -2
  30. package/ios/ExpoIapHelper.swift +96 -0
  31. package/ios/ExpoIapLog.swift +127 -0
  32. package/ios/ExpoIapModule.swift +218 -340
  33. package/openiap-versions.json +5 -0
  34. package/package.json +2 -2
  35. package/plugin/build/withIAP.js +6 -4
  36. package/plugin/src/withIAP.ts +14 -4
  37. package/scripts/update-types.mjs +20 -1
  38. package/src/index.ts +122 -165
  39. package/src/modules/android.ts +2 -1
  40. package/src/modules/ios.ts +31 -19
  41. package/src/types.ts +8 -6
  42. package/src/useIAP.ts +17 -25
  43. package/src/utils/errorMapping.ts +203 -23
  44. package/build/purchase-error.d.ts +0 -67
  45. package/build/purchase-error.d.ts.map +0 -1
  46. package/build/purchase-error.js +0 -166
  47. package/build/purchase-error.js.map +0 -1
  48. package/build/utils/purchase.d.ts +0 -9
  49. package/build/utils/purchase.d.ts.map +0 -1
  50. package/build/utils/purchase.js +0 -34
  51. package/build/utils/purchase.js.map +0 -1
  52. package/src/purchase-error.ts +0 -265
  53. package/src/utils/purchase.ts +0 -52
@@ -0,0 +1,5 @@
1
+ {
2
+ "apple": "1.2.2",
3
+ "google": "1.2.6",
4
+ "gql": "1.0.8"
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-iap",
3
- "version": "3.0.8",
3
+ "version": "3.1.0",
4
4
  "description": "In App Purchase module in Expo",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -27,7 +27,7 @@
27
27
  "docs:build": "cd docs && bun run build",
28
28
  "docs:serve": "cd docs && bun run serve",
29
29
  "docs:install": "cd docs && bun install",
30
- "generate:types": "node scripts/update-types.mjs --tag 1.0.6",
30
+ "generate:types": "node scripts/update-types.mjs",
31
31
  "generate:icon": "npx sharp-cli resize 32 32 -i docs/static/img/icon.png -o docs/static/img/favicon-32x32.png && npx sharp-cli resize 16 16 -i docs/static/img/icon.png -o docs/static/img/favicon-16x16.png && npx sharp-cli resize 180 180 -i docs/static/img/icon.png -o docs/static/img/apple-touch-icon.png && npx sharp-cli resize 192 192 -i docs/static/img/icon.png -o docs/static/img/android-chrome-192x192.png && npx sharp-cli resize 512 512 -i docs/static/img/icon.png -o docs/static/img/android-chrome-512x512.png && npx sharp-cli resize 150 150 -i docs/static/img/icon.png -o docs/static/img/mstile-150x150.png && npx sharp-cli resize 1200 630 -i docs/static/img/icon.png -o docs/static/img/og-image.png && npx sharp-cli resize 1200 600 -i docs/static/img/icon.png -o docs/static/img/twitter-card.png && npx sharp-cli resize 16 16 -i docs/static/img/icon.png -o docs/static/img/favicon.png && cp docs/static/img/favicon-16x16.png docs/static/img/favicon.ico"
32
32
  },
33
33
  "keywords": [
@@ -41,6 +41,8 @@ const fs = __importStar(require("fs"));
41
41
  const path = __importStar(require("path"));
42
42
  const withLocalOpenIAP_1 = __importDefault(require("./withLocalOpenIAP"));
43
43
  const pkg = require('../../package.json');
44
+ const openiapVersions = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../openiap-versions.json'), 'utf8'));
45
+ const OPENIAP_ANDROID_VERSION = openiapVersions.google;
44
46
  // Log a message only once per Node process
45
47
  const logOnce = (() => {
46
48
  const printed = new Set();
@@ -69,7 +71,7 @@ const modifyAppBuildGradle = (gradle, language) => {
69
71
  const impl = (ga, v) => language === 'kotlin'
70
72
  ? ` implementation("${ga}:${v}")`
71
73
  : ` implementation "${ga}:${v}"`;
72
- const openiapDep = impl('io.github.hyochan.openiap:openiap-google', '1.1.12');
74
+ const openiapDep = impl('io.github.hyochan.openiap:openiap-google', OPENIAP_ANDROID_VERSION);
73
75
  // Remove any existing openiap-google lines (any version, groovy/kotlin, implementation/api)
74
76
  const openiapAnyLine = /^\s*(?:implementation|api)\s*\(?\s*["']io\.github\.hyochan\.openiap:openiap-google:[^"']+["']\s*\)?\s*$/gm;
75
77
  const hadExisting = openiapAnyLine.test(modified);
@@ -77,12 +79,12 @@ const modifyAppBuildGradle = (gradle, language) => {
77
79
  modified = modified.replace(openiapAnyLine, '').replace(/\n{3,}/g, '\n\n');
78
80
  }
79
81
  // Ensure the desired dependency line is present
80
- if (!new RegExp(String.raw `io\.github\.hyochan\.openiap:openiap-google:1\.1\.10`).test(modified)) {
82
+ if (!new RegExp(String.raw `io\.github\.hyochan\.openiap:openiap-google:${OPENIAP_ANDROID_VERSION}`).test(modified)) {
81
83
  // Insert just after the opening `dependencies {` line
82
84
  modified = addLineToGradle(modified, /dependencies\s*{/, openiapDep, 1);
83
85
  logOnce(hadExisting
84
- ? '🛠️ expo-iap: Replaced OpenIAP dependency with 1.1.12'
85
- : '🛠️ expo-iap: Added OpenIAP dependency (1.1.12) to build.gradle');
86
+ ? `🛠️ expo-iap: Replaced OpenIAP dependency with ${OPENIAP_ANDROID_VERSION}`
87
+ : `🛠️ expo-iap: Added OpenIAP dependency (${OPENIAP_ANDROID_VERSION}) to build.gradle`);
86
88
  }
87
89
  return modified;
88
90
  };
@@ -11,6 +11,13 @@ import * as path from 'path';
11
11
  import withLocalOpenIAP from './withLocalOpenIAP';
12
12
 
13
13
  const pkg = require('../../package.json');
14
+ const openiapVersions = JSON.parse(
15
+ fs.readFileSync(
16
+ path.resolve(__dirname, '../../openiap-versions.json'),
17
+ 'utf8',
18
+ ),
19
+ );
20
+ const OPENIAP_ANDROID_VERSION = openiapVersions.google;
14
21
 
15
22
  // Log a message only once per Node process
16
23
  const logOnce = (() => {
@@ -54,7 +61,10 @@ const modifyAppBuildGradle = (
54
61
  language === 'kotlin'
55
62
  ? ` implementation("${ga}:${v}")`
56
63
  : ` implementation "${ga}:${v}"`;
57
- const openiapDep = impl('io.github.hyochan.openiap:openiap-google', '1.1.12');
64
+ const openiapDep = impl(
65
+ 'io.github.hyochan.openiap:openiap-google',
66
+ OPENIAP_ANDROID_VERSION,
67
+ );
58
68
 
59
69
  // Remove any existing openiap-google lines (any version, groovy/kotlin, implementation/api)
60
70
  const openiapAnyLine =
@@ -67,15 +77,15 @@ const modifyAppBuildGradle = (
67
77
  // Ensure the desired dependency line is present
68
78
  if (
69
79
  !new RegExp(
70
- String.raw`io\.github\.hyochan\.openiap:openiap-google:1\.1\.10`,
80
+ String.raw`io\.github\.hyochan\.openiap:openiap-google:${OPENIAP_ANDROID_VERSION}`,
71
81
  ).test(modified)
72
82
  ) {
73
83
  // Insert just after the opening `dependencies {` line
74
84
  modified = addLineToGradle(modified, /dependencies\s*{/, openiapDep, 1);
75
85
  logOnce(
76
86
  hadExisting
77
- ? '🛠️ expo-iap: Replaced OpenIAP dependency with 1.1.12'
78
- : '🛠️ expo-iap: Added OpenIAP dependency (1.1.12) to build.gradle',
87
+ ? `🛠️ expo-iap: Replaced OpenIAP dependency with ${OPENIAP_ANDROID_VERSION}`
88
+ : `🛠️ expo-iap: Added OpenIAP dependency (${OPENIAP_ANDROID_VERSION}) to build.gradle`,
79
89
  );
80
90
  }
81
91
 
@@ -3,8 +3,27 @@ import {mkdtempSync, readFileSync, writeFileSync, rmSync} from 'node:fs';
3
3
  import {join} from 'node:path';
4
4
  import {tmpdir} from 'node:os';
5
5
  import {execFileSync} from 'node:child_process';
6
+ import {fileURLToPath, URL} from 'node:url';
7
+
8
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
9
+ let versions;
10
+ try {
11
+ versions = JSON.parse(
12
+ readFileSync(join(__dirname, '..', 'openiap-versions.json'), 'utf8'),
13
+ );
14
+ } catch {
15
+ throw new Error(
16
+ 'expo-iap: Unable to load openiap-versions.json. Ensure the file exists and is valid JSON.',
17
+ );
18
+ }
19
+
20
+ const DEFAULT_TAG = versions?.gql;
21
+ if (typeof DEFAULT_TAG !== 'string' || DEFAULT_TAG.length === 0) {
22
+ throw new Error(
23
+ 'expo-iap: "gql" version missing in openiap-versions.json. Specify --tag manually or update the file.',
24
+ );
25
+ }
6
26
 
7
- const DEFAULT_TAG = '1.0.1';
8
27
  const PROJECT_ROOT = process.cwd();
9
28
 
10
29
  function parseArgs() {
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  validateReceiptIOS,
10
10
  deepLinkToSubscriptionsIOS,
11
11
  syncIOS,
12
+ getStorefrontIOS,
12
13
  } from './modules/ios';
13
14
  import {
14
15
  isProductAndroid,
@@ -22,14 +23,12 @@ import type {
22
23
  DeepLinkOptions,
23
24
  FetchProductsResult,
24
25
  MutationField,
26
+ MutationRequestPurchaseArgs,
25
27
  MutationValidateReceiptArgs,
26
28
  Product,
27
- ProductAndroid,
28
- ProductIOS,
29
29
  ProductQueryType,
30
30
  ProductSubscription,
31
31
  Purchase,
32
- PurchaseInput,
33
32
  PurchaseOptions,
34
33
  QueryField,
35
34
  RequestPurchasePropsByPlatforms,
@@ -38,15 +37,12 @@ import type {
38
37
  RequestSubscriptionPropsByPlatforms,
39
38
  RequestSubscriptionAndroidProps,
40
39
  RequestSubscriptionIosProps,
41
- DiscountOfferInputIOS,
42
40
  } from './types';
43
41
  import {ErrorCode} from './types';
44
- import {PurchaseError} from './purchase-error';
45
- import {normalizePurchaseId, normalizePurchaseList} from './utils/purchase';
42
+ import {createPurchaseError, type PurchaseError} from './utils/errorMapping';
46
43
 
47
44
  // Export all types
48
45
  export * from './types';
49
- export {ErrorCodeUtils, ErrorCodeMapping} from './purchase-error';
50
46
  export * from './modules/android';
51
47
  export * from './modules/ios';
52
48
 
@@ -57,18 +53,12 @@ export {
57
53
  } from './helpers/subscription';
58
54
 
59
55
  // Get the native constant value
60
- export const PI = ExpoIapModule.PI;
61
-
62
56
  export enum OpenIapEvent {
63
57
  PurchaseUpdated = 'purchase-updated',
64
58
  PurchaseError = 'purchase-error',
65
59
  PromotedProductIOS = 'promoted-product-ios',
66
60
  }
67
61
 
68
- export function setValueAsync(value: string) {
69
- return ExpoIapModule.setValueAsync(value);
70
- }
71
-
72
62
  type ExpoIapEventPayloads = {
73
63
  [OpenIapEvent.PurchaseUpdated]: Purchase;
74
64
  [OpenIapEvent.PurchaseError]: PurchaseError;
@@ -109,7 +99,7 @@ const normalizeProductType = (type?: ProductTypeInput) => {
109
99
  if (!type || type === 'inapp' || type === 'in-app') {
110
100
  return {
111
101
  canonical: 'in-app' as ProductQueryType,
112
- native: 'inapp' as const,
102
+ native: 'in-app' as const,
113
103
  };
114
104
  }
115
105
  if (type === 'subs') {
@@ -127,36 +117,47 @@ const normalizeProductType = (type?: ProductTypeInput) => {
127
117
  throw new Error(`Unsupported product type: ${type}`);
128
118
  };
129
119
 
120
+ const normalizePurchasePlatform = (purchase: Purchase): Purchase => {
121
+ const platform = purchase.platform;
122
+ if (typeof platform !== 'string') {
123
+ return purchase;
124
+ }
125
+
126
+ const lowered = platform.toLowerCase();
127
+ if (lowered === platform || (lowered !== 'ios' && lowered !== 'android')) {
128
+ return purchase;
129
+ }
130
+
131
+ return {...purchase, platform: lowered};
132
+ };
133
+
134
+ const normalizePurchaseArray = (purchases: Purchase[]): Purchase[] =>
135
+ purchases.map((purchase) => normalizePurchasePlatform(purchase));
136
+
130
137
  export const purchaseUpdatedListener = (
131
138
  listener: (event: Purchase) => void,
132
139
  ) => {
133
- console.log('[JS] Registering purchaseUpdatedListener');
134
140
  const wrappedListener = (event: Purchase) => {
135
- const normalized = normalizePurchaseId(event);
136
- console.log('[JS] purchaseUpdatedListener fired:', normalized);
141
+ const normalized = normalizePurchasePlatform(event);
137
142
  listener(normalized);
138
143
  };
139
144
  const emitterSubscription = emitter.addListener(
140
145
  OpenIapEvent.PurchaseUpdated,
141
146
  wrappedListener,
142
147
  );
143
- console.log('[JS] purchaseUpdatedListener registered successfully');
144
148
  return emitterSubscription;
145
149
  };
146
150
 
147
151
  export const purchaseErrorListener = (
148
152
  listener: (error: PurchaseError) => void,
149
153
  ) => {
150
- console.log('[JS] Registering purchaseErrorListener');
151
154
  const wrappedListener = (error: PurchaseError) => {
152
- console.log('[JS] purchaseErrorListener fired:', error);
153
155
  listener(error);
154
156
  };
155
157
  const emitterSubscription = emitter.addListener(
156
158
  OpenIapEvent.PurchaseError,
157
159
  wrappedListener,
158
160
  );
159
- console.log('[JS] purchaseErrorListener registered successfully');
160
161
  return emitterSubscription;
161
162
  };
162
163
 
@@ -206,10 +207,11 @@ export const endConnection: MutationField<'endConnection'> = async () =>
206
207
  * @param request.type - Product query type: 'in-app', 'subs', or 'all'
207
208
  */
208
209
  export const fetchProducts: QueryField<'fetchProducts'> = async (request) => {
210
+ console.log('fetchProducts called with:', request);
209
211
  const {skus, type} = request ?? {};
210
212
 
211
213
  if (!Array.isArray(skus) || skus.length === 0) {
212
- throw new PurchaseError({
214
+ throw createPurchaseError({
213
215
  message: 'No SKUs provided',
214
216
  code: ErrorCode.EmptySkuList,
215
217
  });
@@ -223,22 +225,22 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => {
223
225
  const filterIosItems = (
224
226
  items: unknown[],
225
227
  ): Product[] | ProductSubscription[] =>
226
- items.filter((item): item is ProductIOS | ProductSubscription => {
228
+ items.filter((item): item is Product | ProductSubscription => {
227
229
  if (!isProductIOS(item)) {
228
230
  return false;
229
231
  }
230
- const candidate = item as ProductIOS | ProductSubscription;
232
+ const candidate = item as Product | ProductSubscription;
231
233
  return typeof candidate.id === 'string' && skuSet.has(candidate.id);
232
234
  });
233
235
 
234
236
  const filterAndroidItems = (
235
237
  items: unknown[],
236
238
  ): Product[] | ProductSubscription[] =>
237
- items.filter((item): item is ProductAndroid | ProductSubscription => {
239
+ items.filter((item): item is Product | ProductSubscription => {
238
240
  if (!isProductAndroid(item)) {
239
241
  return false;
240
242
  }
241
- const candidate = item as ProductAndroid | ProductSubscription;
243
+ const candidate = item as Product | ProductSubscription;
242
244
  return typeof candidate.id === 'string' && skuSet.has(candidate.id);
243
245
  });
244
246
 
@@ -287,44 +289,18 @@ export const getAvailablePurchases: QueryField<
287
289
  }) ?? (() => Promise.resolve([] as Purchase[]));
288
290
 
289
291
  const purchases = await resolvePurchases();
290
- return normalizePurchaseList(purchases);
292
+ return normalizePurchaseArray(purchases as Purchase[]);
291
293
  };
292
294
 
293
- /**
294
- * Restore completed transactions (cross-platform behavior)
295
- *
296
- * - iOS: perform a lightweight sync to refresh transactions and ignore sync errors,
297
- * then fetch available purchases to surface restored items to the app.
298
- * - Android: simply fetch available purchases (restoration happens via query).
299
- *
300
- * This helper returns the restored/available purchases so callers can update UI/state.
301
- *
302
- * @param options.alsoPublishToEventListenerIOS - iOS only: whether to also publish to the event listener
303
- * @param options.onlyIncludeActiveItemsIOS - iOS only: whether to only include active items
304
- * @returns Promise resolving to the list of available/restored purchases
305
- */
306
- export const restorePurchases: MutationField<'restorePurchases'> = async () => {
307
- if (Platform.OS === 'ios') {
308
- await syncIOS().catch(() => undefined);
295
+ export const getStorefront: QueryField<'getStorefrontIOS'> = async () => {
296
+ // Cross-platform storefront
297
+ if (Platform.OS === 'android') {
298
+ if (typeof ExpoIapModule.getStorefrontAndroid === 'function') {
299
+ return ExpoIapModule.getStorefrontAndroid();
300
+ }
301
+ return '';
309
302
  }
310
-
311
- await getAvailablePurchases({
312
- alsoPublishToEventListenerIOS: false,
313
- onlyIncludeActiveItemsIOS: true,
314
- });
315
- };
316
-
317
- const offerToRecordIOS = (
318
- offer: DiscountOfferInputIOS | undefined,
319
- ): Record<keyof DiscountOfferInputIOS, string> | undefined => {
320
- if (!offer) return undefined;
321
- return {
322
- identifier: offer.identifier,
323
- keyIdentifier: offer.keyIdentifier,
324
- nonce: offer.nonce,
325
- signature: offer.signature,
326
- timestamp: offer.timestamp.toString(),
327
- };
303
+ return getStorefrontIOS();
328
304
  };
329
305
 
330
306
  /**
@@ -403,24 +379,35 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
403
379
  );
404
380
  }
405
381
 
406
- const {
407
- sku,
408
- andDangerouslyFinishTransactionAutomatically = false,
409
- appAccountToken,
410
- quantity,
411
- withOffer,
412
- } = normalizedRequest;
413
-
414
- const offer = offerToRecordIOS(withOffer ?? undefined);
415
- const purchase = await ExpoIapModule.requestPurchase({
416
- sku,
417
- andDangerouslyFinishTransactionAutomatically,
418
- appAccountToken,
419
- quantity,
420
- withOffer: offer,
421
- });
382
+ let payload: MutationRequestPurchaseArgs;
383
+ if (canonical === 'in-app') {
384
+ payload = {
385
+ type: 'in-app',
386
+ request: request as RequestPurchasePropsByPlatforms,
387
+ };
388
+ } else if (canonical === 'subs') {
389
+ payload = {
390
+ type: 'subs',
391
+ request: request as RequestSubscriptionPropsByPlatforms,
392
+ };
393
+ } else {
394
+ throw new Error(`Unsupported product type: ${canonical}`);
395
+ }
396
+
397
+ const purchase = (await ExpoIapModule.requestPurchase(payload)) as
398
+ | Purchase
399
+ | Purchase[]
400
+ | null;
401
+
402
+ if (Array.isArray(purchase)) {
403
+ return normalizePurchaseArray(purchase);
404
+ }
422
405
 
423
- return normalizePurchaseId(purchase as Purchase);
406
+ if (purchase) {
407
+ return normalizePurchasePlatform(purchase);
408
+ }
409
+
410
+ return canonical === 'subs' ? [] : null;
424
411
  }
425
412
 
426
413
  if (Platform.OS === 'android') {
@@ -454,7 +441,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
454
441
  isOfferPersonalized: isOfferPersonalized ?? false,
455
442
  })) as Purchase[];
456
443
 
457
- return normalizePurchaseList(result);
444
+ return normalizePurchaseArray(result);
458
445
  }
459
446
 
460
447
  if (canonical === 'subs') {
@@ -497,7 +484,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
497
484
  isOfferPersonalized: isOfferPersonalized ?? false,
498
485
  })) as Purchase[];
499
486
 
500
- return normalizePurchaseList(result);
487
+ return normalizePurchaseArray(result);
501
488
  }
502
489
 
503
490
  throw new Error(
@@ -508,43 +495,23 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
508
495
  throw new Error('Platform not supported');
509
496
  };
510
497
 
511
- const toPurchaseInput = (
512
- purchase: Purchase | PurchaseInput,
513
- ): PurchaseInput => ({
514
- id: purchase.id,
515
- ids: purchase.ids ?? undefined,
516
- isAutoRenewing: purchase.isAutoRenewing,
517
- platform: purchase.platform,
518
- productId: purchase.productId,
519
- purchaseState: purchase.purchaseState,
520
- purchaseToken: purchase.purchaseToken ?? null,
521
- quantity: purchase.quantity,
522
- transactionDate: purchase.transactionDate,
523
- });
524
-
525
498
  export const finishTransaction: MutationField<'finishTransaction'> = async ({
526
499
  purchase,
527
500
  isConsumable = false,
528
501
  }) => {
529
- const normalizedPurchase = toPurchaseInput(purchase);
530
-
531
502
  if (Platform.OS === 'ios') {
532
- const transactionId = normalizedPurchase.id;
533
- if (!transactionId) {
534
- throw new Error('purchase.id required to finish iOS transaction');
535
- }
536
- await ExpoIapModule.finishTransaction(transactionId);
503
+ await ExpoIapModule.finishTransaction(purchase, isConsumable);
537
504
  return;
538
505
  }
539
506
 
540
507
  if (Platform.OS === 'android') {
541
- const token = normalizedPurchase.purchaseToken ?? undefined;
508
+ const token = purchase.purchaseToken ?? undefined;
542
509
 
543
510
  if (!token) {
544
- throw new PurchaseError({
511
+ throw createPurchaseError({
545
512
  message: 'Purchase token is required to finish transaction',
546
513
  code: ErrorCode.DeveloperError,
547
- productId: normalizedPurchase.productId,
514
+ productId: purchase.productId,
548
515
  platform: 'android',
549
516
  });
550
517
  }
@@ -562,43 +529,58 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ({
562
529
  };
563
530
 
564
531
  /**
565
- * Retrieves the current storefront information from iOS App Store
566
- *
567
- * @returns Promise resolving to the storefront country code
568
- * @throws Error if called on non-iOS platform
532
+ * Restore completed transactions (cross-platform behavior)
569
533
  *
570
- * @example
571
- * ```typescript
572
- * const storefront = await getStorefrontIOS();
573
- * console.log(storefront); // 'US'
574
- * ```
534
+ * - iOS: perform a lightweight sync to refresh transactions and ignore sync errors,
535
+ * then fetch available purchases to surface restored items to the app.
536
+ * - Android: simply fetch available purchases (restoration happens via query).
575
537
  *
576
- * @platform iOS
538
+ * This helper triggers the refresh flows but does not return the purchases; consumers should
539
+ * call `getAvailablePurchases` or rely on hook state to inspect the latest items.
577
540
  */
578
- export const getStorefrontIOS = (): Promise<string> => {
579
- if (Platform.OS !== 'ios') {
580
- console.warn('getStorefrontIOS: This method is only available on iOS');
581
- return Promise.resolve('');
541
+ export const restorePurchases: MutationField<'restorePurchases'> = async () => {
542
+ if (Platform.OS === 'ios') {
543
+ await syncIOS().catch(() => undefined);
582
544
  }
583
- return ExpoIapModule.getStorefrontIOS();
545
+
546
+ await getAvailablePurchases({
547
+ alsoPublishToEventListenerIOS: false,
548
+ onlyIncludeActiveItemsIOS: true,
549
+ });
584
550
  };
585
551
 
586
552
  /**
587
- * Gets the storefront country code from the underlying native store.
588
- * Returns a two-letter country code such as 'US', 'KR', or empty string on failure.
553
+ * Deeplinks to native interface that allows users to manage their subscriptions
554
+ * @param options.skuAndroid - Required for Android to locate specific subscription (ignored on iOS)
555
+ * @param options.packageNameAndroid - Required for Android to identify your app (ignored on iOS)
556
+ *
557
+ * @returns Promise that resolves when the deep link is successfully opened
558
+ *
559
+ * @throws {Error} When called on unsupported platform or when required Android parameters are missing
560
+ *
561
+ * @example
562
+ * import { deepLinkToSubscriptions } from 'expo-iap';
589
563
  *
590
- * @platform ios
591
- * @platform android
564
+ * // Works on both iOS and Android
565
+ * await deepLinkToSubscriptions({
566
+ * skuAndroid: 'your_subscription_sku',
567
+ * packageNameAndroid: 'com.example.app'
568
+ * });
592
569
  */
593
- export const getStorefront = (): Promise<string> => {
594
- // Cross-platform storefront
570
+ export const deepLinkToSubscriptions: MutationField<
571
+ 'deepLinkToSubscriptions'
572
+ > = async (options) => {
573
+ if (Platform.OS === 'ios') {
574
+ await deepLinkToSubscriptionsIOS();
575
+ return;
576
+ }
577
+
595
578
  if (Platform.OS === 'android') {
596
- if (typeof ExpoIapModule.getStorefrontAndroid === 'function') {
597
- return ExpoIapModule.getStorefrontAndroid();
598
- }
599
- return Promise.resolve('');
579
+ await deepLinkToSubscriptionsAndroid((options as DeepLinkOptions) ?? null);
580
+ return;
600
581
  }
601
- return getStorefrontIOS();
582
+
583
+ throw new Error(`Unsupported platform: ${Platform.OS}`);
602
584
  };
603
585
 
604
586
  /**
@@ -641,39 +623,14 @@ export const validateReceipt: MutationField<'validateReceipt'> = async (
641
623
  throw new Error('Platform not supported');
642
624
  };
643
625
 
644
- /**
645
- * Deeplinks to native interface that allows users to manage their subscriptions
646
- * @param options.skuAndroid - Required for Android to locate specific subscription (ignored on iOS)
647
- * @param options.packageNameAndroid - Required for Android to identify your app (ignored on iOS)
648
- *
649
- * @returns Promise that resolves when the deep link is successfully opened
650
- *
651
- * @throws {Error} When called on unsupported platform or when required Android parameters are missing
652
- *
653
- * @example
654
- * import { deepLinkToSubscriptions } from 'expo-iap';
655
- *
656
- * // Works on both iOS and Android
657
- * await deepLinkToSubscriptions({
658
- * skuAndroid: 'your_subscription_sku',
659
- * packageNameAndroid: 'com.example.app'
660
- * });
661
- */
662
- export const deepLinkToSubscriptions: MutationField<
663
- 'deepLinkToSubscriptions'
664
- > = async (options) => {
665
- if (Platform.OS === 'ios') {
666
- await deepLinkToSubscriptionsIOS();
667
- return;
668
- }
669
-
670
- if (Platform.OS === 'android') {
671
- await deepLinkToSubscriptionsAndroid((options as DeepLinkOptions) ?? null);
672
- return;
673
- }
674
-
675
- throw new Error(`Unsupported platform: ${Platform.OS}`);
676
- };
677
-
678
626
  export * from './useIAP';
679
- export * from './utils/errorMapping';
627
+ export {
628
+ ErrorCodeUtils,
629
+ ErrorCodeMapping,
630
+ createPurchaseError,
631
+ createPurchaseErrorFromPlatform,
632
+ } from './utils/errorMapping';
633
+ export type {
634
+ PurchaseError as ExpoPurchaseError,
635
+ PurchaseErrorProps,
636
+ } from './utils/errorMapping';
@@ -19,7 +19,8 @@ export function isProductAndroid<T extends {platform?: string}>(
19
19
  item != null &&
20
20
  typeof item === 'object' &&
21
21
  'platform' in item &&
22
- (item as any).platform === 'android'
22
+ typeof (item as any).platform === 'string' &&
23
+ (item as any).platform.toLowerCase() === 'android'
23
24
  );
24
25
  }
25
26