expo-iap 2.9.7 → 3.0.1

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 (55) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +10 -2
  3. package/android/build.gradle +7 -2
  4. package/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +195 -650
  5. package/android/src/main/java/expo/modules/iap/PromiseUtils.kt +85 -0
  6. package/build/ExpoIap.types.d.ts +0 -6
  7. package/build/ExpoIap.types.d.ts.map +1 -1
  8. package/build/ExpoIap.types.js.map +1 -1
  9. package/build/helpers/subscription.d.ts.map +1 -1
  10. package/build/helpers/subscription.js +14 -3
  11. package/build/helpers/subscription.js.map +1 -1
  12. package/build/index.d.ts +6 -73
  13. package/build/index.d.ts.map +1 -1
  14. package/build/index.js +21 -154
  15. package/build/index.js.map +1 -1
  16. package/build/modules/android.d.ts +2 -2
  17. package/build/modules/android.d.ts.map +1 -1
  18. package/build/modules/android.js +11 -1
  19. package/build/modules/android.js.map +1 -1
  20. package/build/modules/ios.d.ts +0 -60
  21. package/build/modules/ios.d.ts.map +1 -1
  22. package/build/modules/ios.js +2 -121
  23. package/build/modules/ios.js.map +1 -1
  24. package/build/types/ExpoIapAndroid.types.d.ts +0 -8
  25. package/build/types/ExpoIapAndroid.types.d.ts.map +1 -1
  26. package/build/types/ExpoIapAndroid.types.js +0 -1
  27. package/build/types/ExpoIapAndroid.types.js.map +1 -1
  28. package/build/types/ExpoIapIOS.types.d.ts +0 -5
  29. package/build/types/ExpoIapIOS.types.d.ts.map +1 -1
  30. package/build/types/ExpoIapIOS.types.js.map +1 -1
  31. package/build/useIAP.d.ts +0 -18
  32. package/build/useIAP.d.ts.map +1 -1
  33. package/build/useIAP.js +1 -18
  34. package/build/useIAP.js.map +1 -1
  35. package/bun.lock +340 -137
  36. package/codecov.yml +17 -21
  37. package/ios/ExpoIapModule.swift +10 -3
  38. package/jest.config.js +5 -9
  39. package/package.json +5 -3
  40. package/plugin/build/withIAP.d.ts +4 -1
  41. package/plugin/build/withIAP.js +48 -28
  42. package/plugin/build/withLocalOpenIAP.d.ts +6 -2
  43. package/plugin/build/withLocalOpenIAP.js +179 -20
  44. package/plugin/src/withIAP.ts +81 -37
  45. package/plugin/src/withLocalOpenIAP.ts +232 -24
  46. package/src/ExpoIap.types.ts +0 -8
  47. package/src/helpers/subscription.ts +14 -3
  48. package/src/index.ts +22 -230
  49. package/src/modules/android.ts +16 -6
  50. package/src/modules/ios.ts +2 -168
  51. package/src/types/ExpoIapAndroid.types.ts +0 -11
  52. package/src/types/ExpoIapIOS.types.ts +0 -5
  53. package/src/useIAP.ts +3 -55
  54. package/android/src/main/java/expo/modules/iap/PlayUtils.kt +0 -178
  55. package/android/src/main/java/expo/modules/iap/Types.kt +0 -98
@@ -32,49 +32,74 @@ const addLineToGradle = (
32
32
  const lines = content.split('\n');
33
33
  const index = lines.findIndex((line) => line.match(anchor));
34
34
  if (index === -1) {
35
- console.warn(
36
- `Anchor "${anchor}" not found in build.gradle. Appending to end.`,
35
+ WarningAggregator.addWarningAndroid(
36
+ 'expo-iap',
37
+ `dependencies { ... } block not found; skipping injection: ${lineToAdd.trim()}`,
37
38
  );
38
- lines.push(lineToAdd);
39
+ return content;
39
40
  } else {
40
41
  lines.splice(index + offset, 0, lineToAdd);
41
42
  }
42
43
  return lines.join('\n');
43
44
  };
44
45
 
45
- const modifyAppBuildGradle = (gradle: string): string => {
46
+ const modifyAppBuildGradle = (
47
+ gradle: string,
48
+ language: 'groovy' | 'kotlin',
49
+ ): string => {
46
50
  let modified = gradle;
47
51
 
48
- // Add billing library dependencies to app-level build.gradle
49
- const billingDep = ` implementation "com.android.billingclient:billing-ktx:8.0.0"`;
50
- const gmsDep = ` implementation "com.google.android.gms:play-services-base:18.1.0"`;
51
-
52
- let hasAddedDependency = false;
53
-
54
- if (!modified.includes(billingDep)) {
55
- modified = addLineToGradle(modified, /dependencies\s*{/, billingDep);
56
- hasAddedDependency = true;
57
- }
58
- if (!modified.includes(gmsDep)) {
59
- modified = addLineToGradle(modified, /dependencies\s*{/, gmsDep, 1);
60
- hasAddedDependency = true;
52
+ // Ensure OpenIAP dependency exists at desired version in app-level build.gradle(.kts)
53
+ const impl = (ga: string, v: string) =>
54
+ language === 'kotlin'
55
+ ? ` implementation("${ga}:${v}")`
56
+ : ` implementation "${ga}:${v}"`;
57
+ // Pin OpenIAP Google library to 1.1.0
58
+ const openiapDep = impl('io.github.hyochan.openiap:openiap-google', '1.1.0');
59
+
60
+ // Remove any existing openiap-google lines (any version, groovy/kotlin, implementation/api)
61
+ const openiapAnyLine =
62
+ /^\s*(?:implementation|api)\s*\(?\s*["']io\.github\.hyochan\.openiap:openiap-google:[^"']+["']\s*\)?\s*$/gm;
63
+ const hadExisting = openiapAnyLine.test(modified);
64
+ if (hadExisting) {
65
+ modified = modified.replace(openiapAnyLine, '').replace(/\n{3,}/g, '\n\n');
61
66
  }
62
67
 
63
- // Log only once and only if we actually added dependencies
64
- if (hasAddedDependency)
65
- logOnce('🛠️ expo-iap: Added billing dependencies to build.gradle');
68
+ // Ensure the desired dependency line is present
69
+ if (
70
+ !new RegExp(
71
+ String.raw`io\.github\.hyochan\.openiap:openiap-google:1\.1\.0`,
72
+ ).test(modified)
73
+ ) {
74
+ // Insert just after the opening `dependencies {` line
75
+ modified = addLineToGradle(modified, /dependencies\s*{/, openiapDep, 1);
76
+ logOnce(
77
+ hadExisting
78
+ ? '🛠️ expo-iap: Replaced OpenIAP dependency with 1.1.0'
79
+ : '🛠️ expo-iap: Added OpenIAP dependency (1.1.0) to build.gradle',
80
+ );
81
+ }
66
82
 
67
83
  return modified;
68
84
  };
69
85
 
70
- const withIapAndroid: ConfigPlugin = (config) => {
71
- // Add IAP dependencies to app build.gradle
72
- config = withAppBuildGradle(config, (config) => {
73
- config.modResults.contents = modifyAppBuildGradle(
74
- config.modResults.contents,
75
- );
76
- return config;
77
- });
86
+ const withIapAndroid: ConfigPlugin<{addDeps?: boolean} | void> = (
87
+ config,
88
+ props,
89
+ ) => {
90
+ const addDeps = props?.addDeps ?? true;
91
+
92
+ if (addDeps) {
93
+ config = withAppBuildGradle(config, (config) => {
94
+ // language provided by config-plugins: 'groovy' | 'kotlin'
95
+ const language = (config.modResults as any).language || 'groovy';
96
+ config.modResults.contents = modifyAppBuildGradle(
97
+ config.modResults.contents,
98
+ language,
99
+ );
100
+ return config;
101
+ });
102
+ }
78
103
 
79
104
  config = withAndroidManifest(config, (config) => {
80
105
  const manifest = config.modResults;
@@ -140,7 +165,12 @@ const withIapIOS: ConfigPlugin = (config) => {
140
165
 
141
166
  export interface ExpoIapPluginOptions {
142
167
  /** Local development path for OpenIAP library */
143
- localPath?: string;
168
+ localPath?:
169
+ | string
170
+ | {
171
+ ios?: string;
172
+ android?: string;
173
+ };
144
174
  /** Enable local development mode */
145
175
  enableLocalDev?: boolean;
146
176
  }
@@ -150,22 +180,36 @@ const withIap: ConfigPlugin<ExpoIapPluginOptions | void> = (
150
180
  options,
151
181
  ) => {
152
182
  try {
153
- // Apply Android modifications
154
- let result = withIapAndroid(config);
183
+ // Respect explicit flag; fall back to presence of localPath only when flag is unset
184
+ const isLocalDev = options?.enableLocalDev ?? !!options?.localPath;
185
+ // Apply Android modifications (skip adding deps when linking local module)
186
+ let result = withIapAndroid(config, {addDeps: !isLocalDev});
155
187
 
156
188
  // iOS: choose one path to avoid overlap
157
- if (options?.enableLocalDev || options?.localPath) {
189
+ if (isLocalDev) {
158
190
  if (!options?.localPath) {
159
191
  WarningAggregator.addWarningIOS(
160
192
  'expo-iap',
161
193
  'enableLocalDev is true but no localPath provided. Skipping local OpenIAP integration.',
162
194
  );
163
195
  } else {
164
- const localPath = path.resolve(options.localPath);
165
- logOnce(
166
- `🔧 [expo-iap] Enabling local OpenIAP development at: ${localPath}`,
167
- );
168
- result = withLocalOpenIAP(result, {localPath});
196
+ const raw = options.localPath;
197
+ const resolved =
198
+ typeof raw === 'string'
199
+ ? path.resolve(raw)
200
+ : {
201
+ ios: raw.ios ? path.resolve(raw.ios) : undefined,
202
+ android: raw.android ? path.resolve(raw.android) : undefined,
203
+ };
204
+
205
+ const preview =
206
+ typeof resolved === 'string'
207
+ ? resolved
208
+ : `ios=${resolved.ios ?? 'auto'}, android=${
209
+ resolved.android ?? 'auto'
210
+ }`;
211
+ logOnce(`🔧 [expo-iap] Enabling local OpenIAP: ${preview}`);
212
+ result = withLocalOpenIAP(result, {localPath: resolved});
169
213
  }
170
214
  } else {
171
215
  // Ensure iOS Podfile is set up to resolve public CocoaPods specs
@@ -1,4 +1,9 @@
1
- import {ConfigPlugin, withDangerousMod} from '@expo/config-plugins';
1
+ import {
2
+ ConfigPlugin,
3
+ withDangerousMod,
4
+ withSettingsGradle,
5
+ withAppBuildGradle,
6
+ } from 'expo/config-plugins';
2
7
  import * as fs from 'fs';
3
8
  import * as path from 'path';
4
9
 
@@ -6,46 +11,60 @@ import * as path from 'path';
6
11
  * Plugin to add local OpenIAP pod dependency for development
7
12
  * This is only for local development with openiap-apple library
8
13
  */
9
- const withLocalOpenIAP: ConfigPlugin<{localPath?: string} | void> = (
14
+ type LocalPathOption = string | {ios?: string; android?: string};
15
+
16
+ const withLocalOpenIAP: ConfigPlugin<{localPath?: LocalPathOption} | void> = (
10
17
  config,
11
18
  props,
12
19
  ) => {
13
- return withDangerousMod(config, [
20
+ // Helper to resolve Android module path
21
+ const resolveAndroidModulePath = (p?: string): string | null => {
22
+ if (!p) return null;
23
+ // Prefer the module directory if it exists
24
+ const candidates = [
25
+ path.join(p, 'openiap-google'),
26
+ path.join(p, 'openiap'),
27
+ p,
28
+ ];
29
+ for (const c of candidates) {
30
+ if (
31
+ fs.existsSync(path.join(c, 'build.gradle')) ||
32
+ fs.existsSync(path.join(c, 'build.gradle.kts'))
33
+ ) {
34
+ return c;
35
+ }
36
+ }
37
+ return null;
38
+ };
39
+
40
+ // iOS: inject local pod path
41
+ config = withDangerousMod(config, [
14
42
  'ios',
15
43
  async (config) => {
16
- const {platformProjectRoot} = config.modRequest;
44
+ const {platformProjectRoot, projectRoot} = config.modRequest as any;
45
+ const raw = props?.localPath;
46
+ const iosPath =
47
+ (typeof raw === 'string' ? raw : raw?.ios) ||
48
+ path.resolve(projectRoot, 'openiap-apple');
17
49
  const podfilePath = path.join(platformProjectRoot, 'Podfile');
18
50
 
19
- // Default local path or use provided one
20
- const localOpenIapPath =
21
- props?.localPath ||
22
- path.resolve(config.modRequest.projectRoot, 'openiap-apple');
23
-
24
- // Check if local path exists
25
- if (!fs.existsSync(localOpenIapPath)) {
26
- console.warn(
27
- `⚠️ Local openiap-apple path not found: ${localOpenIapPath}`,
28
- );
29
- console.warn(
30
- ' Skipping local pod injection. Using default pod resolution.',
31
- );
51
+ if (!fs.existsSync(iosPath)) {
52
+ console.warn(`⚠️ Local openiap-apple path not found: ${iosPath}`);
53
+ console.warn(' Skipping local pod injection.');
32
54
  return config;
33
55
  }
34
56
 
35
- // Read Podfile
36
57
  if (!fs.existsSync(podfilePath)) {
37
58
  console.warn(`⚠️ Podfile not found at ${podfilePath}. Skipping.`);
38
59
  return config;
39
60
  }
40
61
  let podfileContent = fs.readFileSync(podfilePath, 'utf8');
41
62
 
42
- // Check if already has the local pod reference
43
63
  if (podfileContent.includes("pod 'openiap',")) {
44
64
  console.log('✅ Local OpenIAP pod already configured');
45
65
  return config;
46
66
  }
47
67
 
48
- // Find the target block and inject the local pod
49
68
  const targetRegex =
50
69
  /target\s+['"][\w]+['"]\s+do\s*\n\s*use_expo_modules!/;
51
70
 
@@ -54,12 +73,10 @@ const withLocalOpenIAP: ConfigPlugin<{localPath?: string} | void> = (
54
73
  return `${match}
55
74
 
56
75
  # Local OpenIAP pod for development (added by expo-iap plugin)
57
- pod 'openiap', :path => '${localOpenIapPath}'`;
76
+ pod 'openiap', :path => '${iosPath}'`;
58
77
  });
59
-
60
- // Write back to Podfile
61
78
  fs.writeFileSync(podfilePath, podfileContent);
62
- console.log(`✅ Added local OpenIAP pod at: ${localOpenIapPath}`);
79
+ console.log(`✅ Added local OpenIAP pod at: ${iosPath}`);
63
80
  } else {
64
81
  console.warn('⚠️ Could not find target block in Podfile');
65
82
  }
@@ -67,6 +84,197 @@ const withLocalOpenIAP: ConfigPlugin<{localPath?: string} | void> = (
67
84
  return config;
68
85
  },
69
86
  ]);
87
+
88
+ // Android: include local module and add dependency if available
89
+ config = withSettingsGradle(config, (config) => {
90
+ const raw = props?.localPath;
91
+ const projectRoot = (config.modRequest as any).projectRoot as string;
92
+ const androidInput = typeof raw === 'string' ? undefined : raw?.android;
93
+ const androidModulePath =
94
+ resolveAndroidModulePath(androidInput) ||
95
+ resolveAndroidModulePath(path.resolve(projectRoot, 'openiap-google')) ||
96
+ null;
97
+
98
+ if (!androidModulePath || !fs.existsSync(androidModulePath)) {
99
+ if (androidInput) {
100
+ console.warn(
101
+ `⚠️ Could not resolve Android OpenIAP module at: ${androidInput}. Skipping local Android linkage.`,
102
+ );
103
+ }
104
+ return config;
105
+ }
106
+
107
+ // 1) settings.gradle: include and map projectDir
108
+ const settings = config.modResults;
109
+ const includeLine = "include ':openiap-google'";
110
+ const projectDirLine = `project(':openiap-google').projectDir = new File('${androidModulePath.replace(
111
+ /\\/g,
112
+ '/',
113
+ )}')`;
114
+ let contents = settings.contents ?? '';
115
+
116
+ // Ensure pluginManagement has plugin mappings required by the included module
117
+ const injectPluginManagement = () => {
118
+ const header = 'pluginManagement {';
119
+ const needsVannik =
120
+ !/id\s*\(\s*["']com\.vanniktech\.maven\.publish["']/.test(contents);
121
+ const needsKotlinAndroid =
122
+ !/id\s*\(\s*["']org\.jetbrains\.kotlin\.android["']/.test(contents);
123
+ const needsCompose =
124
+ !/id\s*\(\s*["']org\.jetbrains\.kotlin\.plugin\.compose["']/.test(
125
+ contents,
126
+ );
127
+ const needsRepos = !/pluginManagement[\s\S]*?repositories\s*\{/.test(
128
+ contents,
129
+ );
130
+
131
+ const pluginLines: string[] = [];
132
+ if (needsVannik)
133
+ pluginLines.push(
134
+ ` id("com.vanniktech.maven.publish") version "0.29.0"`,
135
+ );
136
+ if (needsKotlinAndroid)
137
+ pluginLines.push(
138
+ ` id("org.jetbrains.kotlin.android") version "2.0.21"`,
139
+ );
140
+ if (needsCompose)
141
+ pluginLines.push(
142
+ ` id("org.jetbrains.kotlin.plugin.compose") version "2.0.21"`,
143
+ );
144
+
145
+ // If everything already present, skip
146
+ if (pluginLines.length === 0 && !needsRepos) return;
147
+
148
+ const pluginsBlock = pluginLines.length
149
+ ? `plugins {\n${pluginLines.join('\n')}\n}`
150
+ : '';
151
+ const reposBlock = `repositories { gradlePluginPortal(); google(); mavenCentral() }`;
152
+
153
+ if (contents.includes(header)) {
154
+ contents = contents.replace(/pluginManagement\s*\{/, (m) => {
155
+ let injection =
156
+ m + `\n // Added by expo-iap (local openiap-google)\n`;
157
+ if (pluginsBlock) injection += ` ${pluginsBlock}\n`;
158
+ if (needsRepos) injection += ` ${reposBlock}\n`;
159
+ return injection;
160
+ });
161
+ } else {
162
+ contents =
163
+ `pluginManagement {\n // Added by expo-iap (local openiap-google)\n` +
164
+ (pluginsBlock ? ` ${pluginsBlock}\n` : '') +
165
+ ` ${reposBlock}\n}\n\n${contents}`;
166
+ }
167
+ };
168
+
169
+ if (
170
+ !/com\.vanniktech\.maven\.publish/.test(contents) ||
171
+ !/org\.jetbrains\.kotlin\.android/.test(contents)
172
+ ) {
173
+ injectPluginManagement();
174
+ }
175
+ if (!contents.includes(includeLine)) contents += `\n${includeLine}\n`;
176
+ if (!contents.includes(projectDirLine)) contents += `${projectDirLine}\n`;
177
+ settings.contents = contents;
178
+ console.log(`✅ Linked local Android module at: ${androidModulePath}`);
179
+ return config;
180
+ });
181
+
182
+ // 2) app/build.gradle: add implementation project(':openiap-google')
183
+ config = withAppBuildGradle(config, (config) => {
184
+ const raw = props?.localPath;
185
+ const projectRoot = (config.modRequest as any).projectRoot as string;
186
+ const androidInput = typeof raw === 'string' ? undefined : raw?.android;
187
+ const androidModulePath =
188
+ resolveAndroidModulePath(androidInput) ||
189
+ resolveAndroidModulePath(path.resolve(projectRoot, 'openiap-google')) ||
190
+ null;
191
+
192
+ if (!androidModulePath || !fs.existsSync(androidModulePath)) {
193
+ return config;
194
+ }
195
+
196
+ const gradle = config.modResults;
197
+ const dependencyLine = ` implementation project(':openiap-google')`;
198
+
199
+ // Remove any previously added Maven deps for openiap-google to avoid duplicate classes
200
+ const removalPatterns = [
201
+ // Groovy DSL: implementation "io.github.hyochan.openiap:openiap-google:x.y.z" or api "..."
202
+ /^\s*(?:implementation|api)\s+["']io\.github\.hyochan\.openiap:openiap-google:[^"']+["']\s*$/gm,
203
+ // Kotlin DSL: implementation("io.github.hyochan.openiap:openiap-google:x.y.z") or api("...")
204
+ /^\s*(?:implementation|api)\s*\(\s*["']io\.github\.hyochan\.openiap:openiap-google:[^"']+["']\s*\)\s*$/gm,
205
+ ];
206
+ let contents = gradle.contents;
207
+ let removedAny = false;
208
+ for (const pattern of removalPatterns) {
209
+ if (pattern.test(contents)) {
210
+ contents = contents.replace(pattern, '\n');
211
+ removedAny = true;
212
+ }
213
+ }
214
+ if (removedAny) {
215
+ gradle.contents = contents;
216
+ console.log(
217
+ '🧹 Removed Maven openiap-google to use local :openiap-google',
218
+ );
219
+ }
220
+ if (!gradle.contents.includes(dependencyLine)) {
221
+ const anchor = /dependencies\s*\{/m;
222
+ if (anchor.test(gradle.contents)) {
223
+ gradle.contents = gradle.contents.replace(
224
+ anchor,
225
+ (m) => `${m}\n${dependencyLine}`,
226
+ );
227
+ } else {
228
+ gradle.contents += `\n\ndependencies {\n${dependencyLine}\n}\n`;
229
+ }
230
+ console.log('🛠️ Added dependency on local :openiap-google project');
231
+ }
232
+ return config;
233
+ });
234
+
235
+ // 3) Ensure final cleanup in app/build.gradle after all mods are applied
236
+ config = withDangerousMod(config, [
237
+ 'android',
238
+ async (config) => {
239
+ try {
240
+ const {platformProjectRoot} = config.modRequest as any;
241
+ const appBuildGradle = path.join(
242
+ platformProjectRoot,
243
+ 'app',
244
+ 'build.gradle',
245
+ );
246
+ if (fs.existsSync(appBuildGradle)) {
247
+ let contents = fs.readFileSync(appBuildGradle, 'utf8');
248
+ const patterns = [
249
+ // Groovy DSL
250
+ /^\s*(?:implementation|api)\s+["']io\.github\.hyochan\.openiap:openiap-google:[^"']+["']\s*$/gm,
251
+ // Kotlin DSL
252
+ /^\s*(?:implementation|api)\s*\(\s*["']io\.github\.hyochan\.openiap:openiap-google:[^"']+["']\s*\)\s*$/gm,
253
+ ];
254
+ let changed = false;
255
+ for (const p of patterns) {
256
+ if (p.test(contents)) {
257
+ contents = contents.replace(p, '\n');
258
+ changed = true;
259
+ }
260
+ }
261
+ if (changed) {
262
+ fs.writeFileSync(appBuildGradle, contents);
263
+ console.log(
264
+ '🧹 expo-iap: Cleaned Maven openiap-google for local :openiap-google',
265
+ );
266
+ }
267
+ }
268
+ } catch (e) {
269
+ console.warn('expo-iap: cleanup step failed:', e);
270
+ }
271
+ return config;
272
+ },
273
+ ]);
274
+
275
+ // (removed) Avoid global root build.gradle mutations; included module should manage its plugins
276
+
277
+ return config;
70
278
  };
71
279
 
72
280
  export default withLocalOpenIAP;
@@ -37,7 +37,6 @@ export type PurchaseCommon = {
37
37
  id: string; // Transaction identifier - used by finishTransaction
38
38
  productId: string; // Product identifier - which product was purchased
39
39
  ids?: string[]; // Product identifiers for purchases that include multiple products
40
- transactionId?: string; // @deprecated - use id instead
41
40
  transactionDate: number;
42
41
  transactionReceipt: string;
43
42
  purchaseToken?: string; // Unified purchase token (jwsRepresentation for iOS, purchaseToken for Android)
@@ -70,17 +69,11 @@ export type Purchase =
70
69
  | (PurchaseAndroid & AndroidPlatform)
71
70
  | (PurchaseIOS & IosPlatform);
72
71
 
73
- // Removed legacy type aliases `ProductPurchase` and `SubscriptionPurchase` in v2.9.0
74
-
75
72
  export type PurchaseResult = {
76
73
  responseCode?: number;
77
74
  debugMessage?: string;
78
75
  code?: string;
79
76
  message?: string;
80
- /**
81
- * @deprecated Use `purchaseToken` instead. This field will be removed in a future version.
82
- */
83
- purchaseTokenAndroid?: string;
84
77
  purchaseToken?: string;
85
78
  };
86
79
  /**
@@ -399,7 +392,6 @@ export interface RequestPurchaseAndroidProps {
399
392
  */
400
393
  export interface RequestSubscriptionAndroidProps
401
394
  extends RequestPurchaseAndroidProps {
402
- readonly purchaseTokenAndroid?: string;
403
395
  readonly replacementModeAndroid?: number;
404
396
  readonly subscriptionOffers: {
405
397
  sku: string;
@@ -39,7 +39,8 @@ export const getActiveSubscriptions = async (
39
39
  // Check if this purchase has subscription-specific fields
40
40
  const hasSubscriptionFields =
41
41
  ('expirationDateIOS' in purchase && !!purchase.expirationDateIOS) ||
42
- 'autoRenewingAndroid' in purchase;
42
+ 'autoRenewingAndroid' in purchase ||
43
+ ('environmentIOS' in purchase && !!(purchase as any).environmentIOS);
43
44
 
44
45
  if (!hasSubscriptionFields) {
45
46
  return false;
@@ -76,12 +77,22 @@ export const getActiveSubscriptions = async (
76
77
  return false;
77
78
  });
78
79
 
80
+ // Deduplicate by transaction identifier (id)
81
+ const seen = new Set<string>();
82
+ const dedupedPurchases = filteredPurchases.filter((p) => {
83
+ const key = String(p.id);
84
+ if (seen.has(key)) return false;
85
+ seen.add(key);
86
+ return true;
87
+ });
88
+
79
89
  // Convert to ActiveSubscription format
80
- for (const purchase of filteredPurchases) {
90
+ for (const purchase of dedupedPurchases) {
81
91
  const subscription: ActiveSubscription = {
82
92
  productId: purchase.productId,
83
93
  isActive: true,
84
- transactionId: purchase.transactionId || purchase.id,
94
+ // Use unified id as transaction identifier in v3
95
+ transactionId: purchase.id,
85
96
  purchaseToken: purchase.purchaseToken,
86
97
  transactionDate: purchase.transactionDate,
87
98
  };