expo-iap 3.1.7 → 3.1.9

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 +92 -0
  2. package/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt +69 -2
  3. package/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +62 -4
  4. package/build/index.d.ts +32 -2
  5. package/build/index.d.ts.map +1 -1
  6. package/build/index.js +40 -15
  7. package/build/index.js.map +1 -1
  8. package/build/modules/android.d.ts +68 -0
  9. package/build/modules/android.d.ts.map +1 -1
  10. package/build/modules/android.js +74 -0
  11. package/build/modules/android.js.map +1 -1
  12. package/build/modules/ios.d.ts +23 -0
  13. package/build/modules/ios.d.ts.map +1 -1
  14. package/build/modules/ios.js +34 -3
  15. package/build/modules/ios.js.map +1 -1
  16. package/build/types.d.ts +116 -13
  17. package/build/types.d.ts.map +1 -1
  18. package/build/types.js.map +1 -1
  19. package/build/useIAP.d.ts +8 -0
  20. package/build/useIAP.d.ts.map +1 -1
  21. package/build/useIAP.js +11 -1
  22. package/build/useIAP.js.map +1 -1
  23. package/bun.lockb +0 -0
  24. package/coverage/clover.xml +258 -234
  25. package/coverage/coverage-final.json +3 -3
  26. package/coverage/lcov-report/index.html +27 -27
  27. package/coverage/lcov-report/src/helpers/index.html +1 -1
  28. package/coverage/lcov-report/src/helpers/subscription.ts.html +1 -1
  29. package/coverage/lcov-report/src/index.html +19 -19
  30. package/coverage/lcov-report/src/index.ts.html +136 -31
  31. package/coverage/lcov-report/src/modules/android.ts.html +257 -8
  32. package/coverage/lcov-report/src/modules/index.html +23 -23
  33. package/coverage/lcov-report/src/modules/ios.ts.html +137 -11
  34. package/coverage/lcov-report/src/utils/debug.ts.html +1 -1
  35. package/coverage/lcov-report/src/utils/errorMapping.ts.html +1 -1
  36. package/coverage/lcov-report/src/utils/index.html +1 -1
  37. package/coverage/lcov.info +473 -429
  38. package/ios/ExpoIapHelper.swift +4 -0
  39. package/ios/ExpoIapModule.swift +34 -2
  40. package/openiap-versions.json +3 -3
  41. package/package.json +1 -1
  42. package/plugin/build/withIAP.d.ts +26 -0
  43. package/plugin/build/withIAP.js +67 -3
  44. package/plugin/build/withLocalOpenIAP.d.ts +2 -0
  45. package/plugin/build/withLocalOpenIAP.js +7 -0
  46. package/plugin/src/withIAP.ts +141 -3
  47. package/plugin/src/withLocalOpenIAP.ts +14 -4
  48. package/plugin/tsconfig.tsbuildinfo +1 -1
  49. package/src/index.ts +49 -14
  50. package/src/modules/android.ts +83 -0
  51. package/src/modules/ios.ts +45 -3
  52. package/src/types.ts +124 -13
  53. package/src/useIAP.ts +26 -1
@@ -86,6 +86,10 @@ enum ExpoIapHelper {
86
86
  case .all:
87
87
  break
88
88
  }
89
+ // Include useAlternativeBilling if present
90
+ if let useAlternativeBilling = payload["useAlternativeBilling"] {
91
+ normalized["useAlternativeBilling"] = useAlternativeBilling
92
+ }
89
93
  return try OpenIapSerialization.decode(
90
94
  object: normalized, as: RequestPurchaseProps.self)
91
95
  }
@@ -13,7 +13,7 @@ public final class ExpoIapModule: Module {
13
13
  nonisolated public func definition() -> ModuleDefinition {
14
14
  Name("ExpoIap")
15
15
 
16
- Constants {
16
+ Property("errorCodes") {
17
17
  OpenIapSerialization.errorCodes()
18
18
  }
19
19
 
@@ -35,7 +35,9 @@ public final class ExpoIapModule: Module {
35
35
  }
36
36
  }
37
37
 
38
- AsyncFunction("initConnection") { () async throws -> Bool in
38
+ AsyncFunction("initConnection") { (config: [String: Any]?) async throws -> Bool in
39
+ // Note: iOS doesn't support alternative billing config parameter
40
+ // Config is ignored on iOS platform
39
41
  let isConnected = try await OpenIapModule.shared.initConnection()
40
42
  await MainActor.run { self.isInitialized = isConnected }
41
43
  return isConnected
@@ -59,8 +61,10 @@ public final class ExpoIapModule: Module {
59
61
 
60
62
  AsyncFunction("requestPurchase") { (payload: [String: Any]) async throws -> Any? in
61
63
  ExpoIapLog.payload("requestPurchase", payload: payload)
64
+ print("🔍 [ExpoIap] Raw payload useAlternativeBilling: \(payload["useAlternativeBilling"] ?? "nil")")
62
65
  try await ExpoIapHelper.ensureConnection(isInitialized: self.isInitialized)
63
66
  let props = try ExpoIapHelper.decodeRequestPurchaseProps(from: payload)
67
+ print("🔍 [ExpoIap] Decoded props useAlternativeBilling: \(props.useAlternativeBilling ?? false)")
64
68
 
65
69
  do {
66
70
  guard let result = try await OpenIapModule.shared.requestPurchase(props) else {
@@ -334,5 +338,33 @@ public final class ExpoIapModule: Module {
334
338
  throw PurchaseError.make(code: .skuNotFound, productId: sku)
335
339
  }
336
340
  }
341
+
342
+ // MARK: - External Purchase (iOS 16.0+)
343
+
344
+ AsyncFunction("canPresentExternalPurchaseNoticeIOS") { () async throws -> Bool in
345
+ ExpoIapLog.payload("canPresentExternalPurchaseNoticeIOS", payload: nil)
346
+ try await ExpoIapHelper.ensureConnection(isInitialized: self.isInitialized)
347
+ let canPresent = try await OpenIapModule.shared.canPresentExternalPurchaseNoticeIOS()
348
+ ExpoIapLog.result("canPresentExternalPurchaseNoticeIOS", value: canPresent)
349
+ return canPresent
350
+ }
351
+
352
+ AsyncFunction("presentExternalPurchaseNoticeSheetIOS") { () async throws -> [String: Any] in
353
+ ExpoIapLog.payload("presentExternalPurchaseNoticeSheetIOS", payload: nil)
354
+ try await ExpoIapHelper.ensureConnection(isInitialized: self.isInitialized)
355
+ let result = try await OpenIapModule.shared.presentExternalPurchaseNoticeSheetIOS()
356
+ let sanitized = ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode(result))
357
+ ExpoIapLog.result("presentExternalPurchaseNoticeSheetIOS", value: sanitized)
358
+ return sanitized
359
+ }
360
+
361
+ AsyncFunction("presentExternalPurchaseLinkIOS") { (url: String) async throws -> [String: Any] in
362
+ ExpoIapLog.payload("presentExternalPurchaseLinkIOS", payload: ["url": url])
363
+ try await ExpoIapHelper.ensureConnection(isInitialized: self.isInitialized)
364
+ let result = try await OpenIapModule.shared.presentExternalPurchaseLinkIOS(url)
365
+ let sanitized = ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode(result))
366
+ ExpoIapLog.result("presentExternalPurchaseLinkIOS", value: sanitized)
367
+ return sanitized
368
+ }
337
369
  }
338
370
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "apple": "1.2.4",
3
- "google": "1.2.10",
4
- "gql": "1.0.9"
2
+ "apple": "1.2.10",
3
+ "google": "1.2.12",
4
+ "gql": "1.0.12"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-iap",
3
- "version": "3.1.7",
3
+ "version": "3.1.9",
4
4
  "description": "In App Purchase module in Expo",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -1,4 +1,22 @@
1
1
  import { ConfigPlugin } from 'expo/config-plugins';
2
+ export interface IOSAlternativeBillingConfig {
3
+ /** Country codes where external purchases are supported (ISO 3166-1 alpha-2) */
4
+ countries?: string[];
5
+ /** External purchase URLs per country (iOS 15.4+) */
6
+ links?: Record<string, string>;
7
+ /** Multiple external purchase URLs per country (iOS 17.5+, up to 5 per country) */
8
+ multiLinks?: Record<string, string[]>;
9
+ /** Custom link regions (iOS 18.1+) */
10
+ customLinkRegions?: string[];
11
+ /** Streaming link regions for music apps (iOS 18.2+) */
12
+ streamingLinkRegions?: string[];
13
+ /** Enable external purchase link entitlement */
14
+ enableExternalPurchaseLink?: boolean;
15
+ /** Enable external purchase link streaming entitlement (music apps only) */
16
+ enableExternalPurchaseLinkStreaming?: boolean;
17
+ }
18
+ /** Add external purchase entitlements and Info.plist configuration */
19
+ declare const withIosAlternativeBilling: ConfigPlugin<IOSAlternativeBillingConfig | undefined>;
2
20
  export interface ExpoIapPluginOptions {
3
21
  /** Local development path for OpenIAP library */
4
22
  localPath?: string | {
@@ -7,6 +25,14 @@ export interface ExpoIapPluginOptions {
7
25
  };
8
26
  /** Enable local development mode */
9
27
  enableLocalDev?: boolean;
28
+ /**
29
+ * iOS Alternative Billing configuration.
30
+ * Configure external purchase countries, links, and entitlements.
31
+ * Requires approval from Apple.
32
+ * @platform ios
33
+ */
34
+ iosAlternativeBilling?: IOSAlternativeBillingConfig;
10
35
  }
36
+ export { withIosAlternativeBilling };
11
37
  declare const _default: ConfigPlugin<void | ExpoIapPluginOptions>;
12
38
  export default _default;
@@ -36,6 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.withIosAlternativeBilling = void 0;
39
40
  const config_plugins_1 = require("expo/config-plugins");
40
41
  const fs = __importStar(require("fs"));
41
42
  const path = __importStar(require("path"));
@@ -117,8 +118,68 @@ const withIapAndroid = (config, props) => {
117
118
  });
118
119
  return config;
119
120
  };
121
+ /** Add external purchase entitlements and Info.plist configuration */
122
+ const withIosAlternativeBilling = (config, options) => {
123
+ if (!options || !options.countries || options.countries.length === 0) {
124
+ return config;
125
+ }
126
+ // Add entitlements
127
+ config = (0, config_plugins_1.withEntitlementsPlist)(config, (config) => {
128
+ // Always add basic external purchase entitlement when countries are specified
129
+ config.modResults['com.apple.developer.storekit.external-purchase'] = true;
130
+ logOnce('✅ Added com.apple.developer.storekit.external-purchase to entitlements');
131
+ // Add external purchase link entitlement if enabled
132
+ if (options.enableExternalPurchaseLink) {
133
+ config.modResults['com.apple.developer.storekit.external-purchase-link'] =
134
+ true;
135
+ logOnce('✅ Added com.apple.developer.storekit.external-purchase-link to entitlements');
136
+ }
137
+ // Add streaming entitlement if enabled
138
+ if (options.enableExternalPurchaseLinkStreaming) {
139
+ config.modResults['com.apple.developer.storekit.external-purchase-link-streaming'] = true;
140
+ logOnce('✅ Added com.apple.developer.storekit.external-purchase-link-streaming to entitlements');
141
+ }
142
+ return config;
143
+ });
144
+ // Add Info.plist configuration
145
+ config = (0, config_plugins_1.withInfoPlist)(config, (config) => {
146
+ const plist = config.modResults;
147
+ // 1. SKExternalPurchase (Required)
148
+ plist.SKExternalPurchase = options.countries;
149
+ logOnce(`✅ Added SKExternalPurchase with countries: ${options.countries?.join(', ')}`);
150
+ // 2. SKExternalPurchaseLink (Optional - iOS 15.4+)
151
+ if (options.links && Object.keys(options.links).length > 0) {
152
+ plist.SKExternalPurchaseLink = options.links;
153
+ logOnce(`✅ Added SKExternalPurchaseLink for ${Object.keys(options.links).length} countries`);
154
+ }
155
+ // 3. SKExternalPurchaseMultiLink (iOS 17.5+)
156
+ if (options.multiLinks && Object.keys(options.multiLinks).length > 0) {
157
+ plist.SKExternalPurchaseMultiLink = options.multiLinks;
158
+ logOnce(`✅ Added SKExternalPurchaseMultiLink for ${Object.keys(options.multiLinks).length} countries`);
159
+ }
160
+ // 4. SKExternalPurchaseCustomLinkRegions (iOS 18.1+)
161
+ if (options.customLinkRegions && options.customLinkRegions.length > 0) {
162
+ plist.SKExternalPurchaseCustomLinkRegions = options.customLinkRegions;
163
+ logOnce(`✅ Added SKExternalPurchaseCustomLinkRegions: ${options.customLinkRegions.join(', ')}`);
164
+ }
165
+ // 5. SKExternalPurchaseLinkStreamingRegions (iOS 18.2+)
166
+ if (options.streamingLinkRegions &&
167
+ options.streamingLinkRegions.length > 0) {
168
+ plist.SKExternalPurchaseLinkStreamingRegions =
169
+ options.streamingLinkRegions;
170
+ logOnce(`✅ Added SKExternalPurchaseLinkStreamingRegions: ${options.streamingLinkRegions.join(', ')}`);
171
+ }
172
+ return config;
173
+ });
174
+ return config;
175
+ };
176
+ exports.withIosAlternativeBilling = withIosAlternativeBilling;
120
177
  /** Ensure Podfile uses CocoaPods CDN and no stale local OpenIAP entry remains. */
121
- const withIapIOS = (config) => {
178
+ const withIapIOS = (config, options) => {
179
+ // Add iOS alternative billing configuration if provided
180
+ if (options) {
181
+ config = withIosAlternativeBilling(config, options);
182
+ }
122
183
  return (0, config_plugins_1.withDangerousMod)(config, [
123
184
  'ios',
124
185
  async (config) => {
@@ -168,12 +229,15 @@ const withIap = (config, options) => {
168
229
  ? resolved
169
230
  : `ios=${resolved.ios ?? 'auto'}, android=${resolved.android ?? 'auto'}`;
170
231
  logOnce(`🔧 [expo-iap] Enabling local OpenIAP: ${preview}`);
171
- result = (0, withLocalOpenIAP_1.default)(result, { localPath: resolved });
232
+ result = (0, withLocalOpenIAP_1.default)(result, {
233
+ localPath: resolved,
234
+ iosAlternativeBilling: options?.iosAlternativeBilling,
235
+ });
172
236
  }
173
237
  }
174
238
  else {
175
239
  // Ensure iOS Podfile is set up to resolve public CocoaPods specs
176
- result = withIapIOS(result);
240
+ result = withIapIOS(result, options?.iosAlternativeBilling);
177
241
  logOnce('📦 [expo-iap] Using OpenIAP from CocoaPods');
178
242
  }
179
243
  return result;
@@ -1,4 +1,5 @@
1
1
  import { ConfigPlugin } from 'expo/config-plugins';
2
+ import type { IOSAlternativeBillingConfig } from './withIAP';
2
3
  /**
3
4
  * Plugin to add local OpenIAP pod dependency for development
4
5
  * This is only for local development with openiap-apple library
@@ -9,5 +10,6 @@ type LocalPathOption = string | {
9
10
  };
10
11
  declare const withLocalOpenIAP: ConfigPlugin<{
11
12
  localPath?: LocalPathOption;
13
+ iosAlternativeBilling?: IOSAlternativeBillingConfig;
12
14
  } | void>;
13
15
  export default withLocalOpenIAP;
@@ -37,6 +37,13 @@ const config_plugins_1 = require("expo/config-plugins");
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
39
  const withLocalOpenIAP = (config, props) => {
40
+ // Import and apply iOS alternative billing configuration if provided
41
+ if (props?.iosAlternativeBilling) {
42
+ // Import withIosAlternativeBilling from withIAP module
43
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
44
+ const { withIosAlternativeBilling } = require('./withIAP');
45
+ config = withIosAlternativeBilling(config, props.iosAlternativeBilling);
46
+ }
40
47
  // Helper to resolve Android module path
41
48
  const resolveAndroidModulePath = (p) => {
42
49
  if (!p)
@@ -5,6 +5,8 @@ import {
5
5
  withAndroidManifest,
6
6
  withAppBuildGradle,
7
7
  withDangerousMod,
8
+ withEntitlementsPlist,
9
+ withInfoPlist,
8
10
  } from 'expo/config-plugins';
9
11
  import * as fs from 'fs';
10
12
  import * as path from 'path';
@@ -137,8 +139,133 @@ const withIapAndroid: ConfigPlugin<{addDeps?: boolean} | void> = (
137
139
  return config;
138
140
  };
139
141
 
142
+ export interface IOSAlternativeBillingConfig {
143
+ /** Country codes where external purchases are supported (ISO 3166-1 alpha-2) */
144
+ countries?: string[];
145
+ /** External purchase URLs per country (iOS 15.4+) */
146
+ links?: Record<string, string>;
147
+ /** Multiple external purchase URLs per country (iOS 17.5+, up to 5 per country) */
148
+ multiLinks?: Record<string, string[]>;
149
+ /** Custom link regions (iOS 18.1+) */
150
+ customLinkRegions?: string[];
151
+ /** Streaming link regions for music apps (iOS 18.2+) */
152
+ streamingLinkRegions?: string[];
153
+ /** Enable external purchase link entitlement */
154
+ enableExternalPurchaseLink?: boolean;
155
+ /** Enable external purchase link streaming entitlement (music apps only) */
156
+ enableExternalPurchaseLinkStreaming?: boolean;
157
+ }
158
+
159
+ /** Add external purchase entitlements and Info.plist configuration */
160
+ const withIosAlternativeBilling: ConfigPlugin<
161
+ IOSAlternativeBillingConfig | undefined
162
+ > = (config, options) => {
163
+ if (!options || !options.countries || options.countries.length === 0) {
164
+ return config;
165
+ }
166
+
167
+ // Add entitlements
168
+ config = withEntitlementsPlist(config, (config) => {
169
+ // Always add basic external purchase entitlement when countries are specified
170
+ config.modResults['com.apple.developer.storekit.external-purchase'] = true;
171
+ logOnce(
172
+ '✅ Added com.apple.developer.storekit.external-purchase to entitlements',
173
+ );
174
+
175
+ // Add external purchase link entitlement if enabled
176
+ if (options.enableExternalPurchaseLink) {
177
+ config.modResults['com.apple.developer.storekit.external-purchase-link'] =
178
+ true;
179
+ logOnce(
180
+ '✅ Added com.apple.developer.storekit.external-purchase-link to entitlements',
181
+ );
182
+ }
183
+
184
+ // Add streaming entitlement if enabled
185
+ if (options.enableExternalPurchaseLinkStreaming) {
186
+ config.modResults[
187
+ 'com.apple.developer.storekit.external-purchase-link-streaming'
188
+ ] = true;
189
+ logOnce(
190
+ '✅ Added com.apple.developer.storekit.external-purchase-link-streaming to entitlements',
191
+ );
192
+ }
193
+
194
+ return config;
195
+ });
196
+
197
+ // Add Info.plist configuration
198
+ config = withInfoPlist(config, (config) => {
199
+ const plist = config.modResults;
200
+
201
+ // 1. SKExternalPurchase (Required)
202
+ plist.SKExternalPurchase = options.countries;
203
+ logOnce(
204
+ `✅ Added SKExternalPurchase with countries: ${options.countries?.join(
205
+ ', ',
206
+ )}`,
207
+ );
208
+
209
+ // 2. SKExternalPurchaseLink (Optional - iOS 15.4+)
210
+ if (options.links && Object.keys(options.links).length > 0) {
211
+ plist.SKExternalPurchaseLink = options.links;
212
+ logOnce(
213
+ `✅ Added SKExternalPurchaseLink for ${
214
+ Object.keys(options.links).length
215
+ } countries`,
216
+ );
217
+ }
218
+
219
+ // 3. SKExternalPurchaseMultiLink (iOS 17.5+)
220
+ if (options.multiLinks && Object.keys(options.multiLinks).length > 0) {
221
+ plist.SKExternalPurchaseMultiLink = options.multiLinks;
222
+ logOnce(
223
+ `✅ Added SKExternalPurchaseMultiLink for ${
224
+ Object.keys(options.multiLinks).length
225
+ } countries`,
226
+ );
227
+ }
228
+
229
+ // 4. SKExternalPurchaseCustomLinkRegions (iOS 18.1+)
230
+ if (options.customLinkRegions && options.customLinkRegions.length > 0) {
231
+ plist.SKExternalPurchaseCustomLinkRegions = options.customLinkRegions;
232
+ logOnce(
233
+ `✅ Added SKExternalPurchaseCustomLinkRegions: ${options.customLinkRegions.join(
234
+ ', ',
235
+ )}`,
236
+ );
237
+ }
238
+
239
+ // 5. SKExternalPurchaseLinkStreamingRegions (iOS 18.2+)
240
+ if (
241
+ options.streamingLinkRegions &&
242
+ options.streamingLinkRegions.length > 0
243
+ ) {
244
+ plist.SKExternalPurchaseLinkStreamingRegions =
245
+ options.streamingLinkRegions;
246
+ logOnce(
247
+ `✅ Added SKExternalPurchaseLinkStreamingRegions: ${options.streamingLinkRegions.join(
248
+ ', ',
249
+ )}`,
250
+ );
251
+ }
252
+
253
+ return config;
254
+ });
255
+
256
+ return config;
257
+ };
258
+
140
259
  /** Ensure Podfile uses CocoaPods CDN and no stale local OpenIAP entry remains. */
141
- const withIapIOS: ConfigPlugin = (config) => {
260
+ const withIapIOS: ConfigPlugin<IOSAlternativeBillingConfig | undefined> = (
261
+ config,
262
+ options,
263
+ ) => {
264
+ // Add iOS alternative billing configuration if provided
265
+ if (options) {
266
+ config = withIosAlternativeBilling(config, options);
267
+ }
268
+
142
269
  return withDangerousMod(config, [
143
270
  'ios',
144
271
  async (config) => {
@@ -182,6 +309,13 @@ export interface ExpoIapPluginOptions {
182
309
  };
183
310
  /** Enable local development mode */
184
311
  enableLocalDev?: boolean;
312
+ /**
313
+ * iOS Alternative Billing configuration.
314
+ * Configure external purchase countries, links, and entitlements.
315
+ * Requires approval from Apple.
316
+ * @platform ios
317
+ */
318
+ iosAlternativeBilling?: IOSAlternativeBillingConfig;
185
319
  }
186
320
 
187
321
  const withIap: ConfigPlugin<ExpoIapPluginOptions | void> = (
@@ -218,11 +352,14 @@ const withIap: ConfigPlugin<ExpoIapPluginOptions | void> = (
218
352
  resolved.android ?? 'auto'
219
353
  }`;
220
354
  logOnce(`🔧 [expo-iap] Enabling local OpenIAP: ${preview}`);
221
- result = withLocalOpenIAP(result, {localPath: resolved});
355
+ result = withLocalOpenIAP(result, {
356
+ localPath: resolved,
357
+ iosAlternativeBilling: options?.iosAlternativeBilling,
358
+ });
222
359
  }
223
360
  } else {
224
361
  // Ensure iOS Podfile is set up to resolve public CocoaPods specs
225
- result = withIapIOS(result);
362
+ result = withIapIOS(result, options?.iosAlternativeBilling);
226
363
  logOnce('📦 [expo-iap] Using OpenIAP from CocoaPods');
227
364
  }
228
365
 
@@ -237,4 +374,5 @@ const withIap: ConfigPlugin<ExpoIapPluginOptions | void> = (
237
374
  }
238
375
  };
239
376
 
377
+ export {withIosAlternativeBilling};
240
378
  export default createRunOncePlugin(withIap, pkg.name, pkg.version);
@@ -6,6 +6,7 @@ import {
6
6
  } from 'expo/config-plugins';
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
+ import type {IOSAlternativeBillingConfig} from './withIAP';
9
10
 
10
11
  /**
11
12
  * Plugin to add local OpenIAP pod dependency for development
@@ -13,10 +14,19 @@ import * as path from 'path';
13
14
  */
14
15
  type LocalPathOption = string | {ios?: string; android?: string};
15
16
 
16
- const withLocalOpenIAP: ConfigPlugin<{localPath?: LocalPathOption} | void> = (
17
- config,
18
- props,
19
- ) => {
17
+ const withLocalOpenIAP: ConfigPlugin<
18
+ {
19
+ localPath?: LocalPathOption;
20
+ iosAlternativeBilling?: IOSAlternativeBillingConfig;
21
+ } | void
22
+ > = (config, props) => {
23
+ // Import and apply iOS alternative billing configuration if provided
24
+ if (props?.iosAlternativeBilling) {
25
+ // Import withIosAlternativeBilling from withIAP module
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
27
+ const {withIosAlternativeBilling} = require('./withIAP');
28
+ config = withIosAlternativeBilling(config, props.iosAlternativeBilling);
29
+ }
20
30
  // Helper to resolve Android module path
21
31
  const resolveAndroidModulePath = (p?: string): string | null => {
22
32
  if (!p) return null;
@@ -1 +1 @@
1
- {"root":["./src/expoConfig.augmentation.d.ts","./src/withIAP.ts","./src/withLocalOpenIAP.ts"],"version":"5.9.2"}
1
+ {"root":["./src/expoConfig.augmentation.d.ts","./src/withIAP.ts","./src/withLocalOpenIAP.ts"],"version":"5.9.3"}
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ import type {
37
37
  RequestSubscriptionPropsByPlatforms,
38
38
  RequestSubscriptionAndroidProps,
39
39
  RequestSubscriptionIosProps,
40
+ UserChoiceBillingDetails,
40
41
  } from './types';
41
42
  import {ErrorCode} from './types';
42
43
  import {createPurchaseError, type PurchaseError} from './utils/errorMapping';
@@ -57,12 +58,14 @@ export enum OpenIapEvent {
57
58
  PurchaseUpdated = 'purchase-updated',
58
59
  PurchaseError = 'purchase-error',
59
60
  PromotedProductIOS = 'promoted-product-ios',
61
+ UserChoiceBillingAndroid = 'user-choice-billing-android',
60
62
  }
61
63
 
62
64
  type ExpoIapEventPayloads = {
63
65
  [OpenIapEvent.PurchaseUpdated]: Purchase;
64
66
  [OpenIapEvent.PurchaseError]: PurchaseError;
65
67
  [OpenIapEvent.PromotedProductIOS]: Product;
68
+ [OpenIapEvent.UserChoiceBillingAndroid]: UserChoiceBillingDetails;
66
69
  };
67
70
 
68
71
  type ExpoIapEventListener<E extends OpenIapEvent> = (
@@ -193,8 +196,45 @@ export const promotedProductListenerIOS = (
193
196
  return emitter.addListener(OpenIapEvent.PromotedProductIOS, listener);
194
197
  };
195
198
 
196
- export const initConnection: MutationField<'initConnection'> = async () =>
197
- ExpoIapModule.initConnection();
199
+ /**
200
+ * Android-only listener for User Choice Billing events.
201
+ * This fires when a user selects alternative billing instead of Google Play billing
202
+ * in the User Choice Billing dialog (only in 'user-choice' mode).
203
+ *
204
+ * @param listener - Callback function that receives the external transaction token and product IDs
205
+ * @returns EventSubscription that can be used to unsubscribe
206
+ *
207
+ * @example
208
+ * ```typescript
209
+ * const subscription = userChoiceBillingListenerAndroid((details) => {
210
+ * console.log('User selected alternative billing');
211
+ * console.log('Token:', details.externalTransactionToken);
212
+ * console.log('Products:', details.products);
213
+ *
214
+ * // Process payment in your system, then report token to Google
215
+ * await processPaymentAndReportToken(details);
216
+ * });
217
+ *
218
+ * // Later, clean up
219
+ * subscription.remove();
220
+ * ```
221
+ *
222
+ * @platform Android
223
+ */
224
+ export const userChoiceBillingListenerAndroid = (
225
+ listener: (details: UserChoiceBillingDetails) => void,
226
+ ) => {
227
+ if (Platform.OS !== 'android') {
228
+ ExpoIapConsole.warn(
229
+ 'userChoiceBillingListenerAndroid: This listener is only available on Android',
230
+ );
231
+ return {remove: () => {}};
232
+ }
233
+ return emitter.addListener(OpenIapEvent.UserChoiceBillingAndroid, listener);
234
+ };
235
+
236
+ export const initConnection: MutationField<'initConnection'> = async (config) =>
237
+ ExpoIapModule.initConnection(config ?? null);
198
238
 
199
239
  export const endConnection: MutationField<'endConnection'> = async () =>
200
240
  ExpoIapModule.endConnection();
@@ -375,21 +415,16 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
375
415
  );
376
416
  }
377
417
 
378
- let payload: MutationRequestPurchaseArgs;
379
- if (canonical === 'in-app') {
380
- payload = {
381
- type: 'in-app',
382
- request: request as RequestPurchasePropsByPlatforms,
383
- };
384
- } else if (canonical === 'subs') {
385
- payload = {
386
- type: 'subs',
387
- request: request as RequestSubscriptionPropsByPlatforms,
388
- };
389
- } else {
418
+ if (canonical !== 'in-app' && canonical !== 'subs') {
390
419
  throw new Error(`Unsupported product type: ${canonical}`);
391
420
  }
392
421
 
422
+ const payload: MutationRequestPurchaseArgs = {
423
+ type: canonical === 'in-app' ? 'in-app' : 'subs',
424
+ request,
425
+ useAlternativeBilling: args.useAlternativeBilling,
426
+ };
427
+
393
428
  const purchase = (await ExpoIapModule.requestPurchase(payload)) as
394
429
  | Purchase
395
430
  | Purchase[]
@@ -163,3 +163,86 @@ export const acknowledgePurchaseAndroid: MutationField<
163
163
  export const openRedeemOfferCodeAndroid = async (): Promise<void> => {
164
164
  return Linking.openURL(`https://play.google.com/redeem?code=`);
165
165
  };
166
+
167
+ /**
168
+ * Check if alternative billing is available for this user/device (Android only).
169
+ * Step 1 of alternative billing flow.
170
+ *
171
+ * Returns true if available, false otherwise.
172
+ * Throws OpenIapError.NotPrepared if billing client not ready.
173
+ *
174
+ * @returns {Promise<boolean>}
175
+ *
176
+ * @example
177
+ * ```typescript
178
+ * const isAvailable = await checkAlternativeBillingAvailabilityAndroid();
179
+ * if (isAvailable) {
180
+ * // Proceed with alternative billing flow
181
+ * }
182
+ * ```
183
+ */
184
+ export const checkAlternativeBillingAvailabilityAndroid: MutationField<
185
+ 'checkAlternativeBillingAvailabilityAndroid'
186
+ > = async () => {
187
+ return ExpoIapModule.checkAlternativeBillingAvailabilityAndroid();
188
+ };
189
+
190
+ /**
191
+ * Show alternative billing information dialog to user (Android only).
192
+ * Step 2 of alternative billing flow.
193
+ * Must be called BEFORE processing payment in your payment system.
194
+ *
195
+ * Returns true if user accepted, false if user canceled.
196
+ * Throws OpenIapError.NotPrepared if billing client not ready.
197
+ *
198
+ * @returns {Promise<boolean>}
199
+ *
200
+ * @example
201
+ * ```typescript
202
+ * const userAccepted = await showAlternativeBillingDialogAndroid();
203
+ * if (userAccepted) {
204
+ * // Process payment in your payment system
205
+ * const success = await processCustomPayment();
206
+ * if (success) {
207
+ * // Create reporting token
208
+ * const token = await createAlternativeBillingTokenAndroid();
209
+ * // Send token to your backend for Google Play reporting
210
+ * }
211
+ * }
212
+ * ```
213
+ */
214
+ export const showAlternativeBillingDialogAndroid: MutationField<
215
+ 'showAlternativeBillingDialogAndroid'
216
+ > = async () => {
217
+ return ExpoIapModule.showAlternativeBillingDialogAndroid();
218
+ };
219
+
220
+ /**
221
+ * Create external transaction token for Google Play reporting (Android only).
222
+ * Step 3 of alternative billing flow.
223
+ * Must be called AFTER successful payment in your payment system.
224
+ * Token must be reported to Google Play backend within 24 hours.
225
+ *
226
+ * Returns token string, or null if creation failed.
227
+ * Throws OpenIapError.NotPrepared if billing client not ready.
228
+ *
229
+ * @param {string} sku - The product SKU that was purchased
230
+ * @returns {Promise<string | null>}
231
+ *
232
+ * @example
233
+ * ```typescript
234
+ * const token = await createAlternativeBillingTokenAndroid('premium_subscription');
235
+ * if (token) {
236
+ * // Send token to your backend
237
+ * await fetch('/api/report-transaction', {
238
+ * method: 'POST',
239
+ * body: JSON.stringify({ token, sku: 'premium_subscription' })
240
+ * });
241
+ * }
242
+ * ```
243
+ */
244
+ export const createAlternativeBillingTokenAndroid: MutationField<
245
+ 'createAlternativeBillingTokenAndroid'
246
+ > = async (sku?: string) => {
247
+ return ExpoIapModule.createAlternativeBillingTokenAndroid(sku);
248
+ };