diff --git a/CLAUDE.md b/CLAUDE.md index bd2f86c6..1f972800 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,13 +11,13 @@ | Property | Value | |----------|-------| -| Current Version | 6.0.0 | +| Current Version | 6.1.0 | | React Native | 0.86.0 | | TypeScript | 5.8.3 (strict mode) | | Node.js | v22 (see `.nvmrc`) | | Package Manager | Yarn 3.6.1 (workspaces) | -| Native iOS SDK | 6.0.0 | -| Native Android SDK | 6.0.1 | +| Native iOS SDK | 6.1.0 | +| Native Android SDK | 6.1.0 | ### Supported App Stores - Apple App Store (iOS) @@ -404,11 +404,11 @@ Android and iOS jobs invoke Gradle and `xcodebuild` directly. ### Native Dependencies **iOS (CocoaPods):** -- Purchasely SDK v6.0.0 +- Purchasely SDK v6.1.0 - Deployment target: iOS 15.1 **Android (Gradle):** -- io.purchasely:core:6.0.1 +- io.purchasely:core:6.1.0 - Min SDK: 23 - Kotlin: 2.3.21+ - Java: 11 @@ -478,19 +478,28 @@ yarn test # All tests yarn test --coverage # With coverage yarn test --watch # Watch mode -# iOS tests (XCTest) - Run locally or in example app context -# Native tests require React Native dependencies from the example app -cd packages/purchasely/ios -xcodebuild test -workspace Purchasely.xcworkspace -scheme Purchasely -destination 'platform=iOS Simulator,name=iPhone 15' +# iOS tests (XCTest) - CI-enabled, via the example workspace +cd example/ios +UDID=$(xcrun simctl list devices booted -j | jq -r '[.devices[][]][0].udid') +xcodebuild test -workspace example.xcworkspace \ + -scheme react-native-purchasely-Unit-Tests \ + -destination "id=$UDID" CODE_SIGNING_ALLOWED=NO -# Android tests (JUnit) - Run locally or in example app context -# Native tests require React Native dependencies from the example app -cd packages/purchasely/android -./gradlew test # Run unit tests -./gradlew testDebugUnitTest # Run debug variant tests +# Android tests (JUnit) - CI-enabled, via the example project +cd example/android +./gradlew :react-native-purchasely:testDebugUnitTest ``` -**Note:** Native tests (iOS XCTest and Android JUnit) require React Native dependencies and should be run locally or within the example app context. They cannot run in CI as standalone jobs. TypeScript tests run in CI automatically. +**Native tests run in CI.** Both suites need the React Native dependencies, so +neither runs from its own package directory: `cd packages/purchasely/android && +./gradlew test` fails with `Could not find any matches for +com.facebook.react:react-native:+`. Drive them through the example project +instead, which is exactly what CI does: + +- Android JUnit runs in the `build-android` job, step "Run Purchasely Android + native unit tests" (`:react-native-purchasely:testDebugUnitTest`). +- iOS XCTest runs in its own `iOS Unit Tests (bridge)` job, on the + `react-native-purchasely-Unit-Tests` scheme against a booted simulator. ### Test Guidelines @@ -531,10 +540,20 @@ When adding new features: 1. **lint** (ubuntu-latest) - TypeScript + ESLint checks, type checking 2. **test** (ubuntu-latest) - TypeScript/Jest unit tests with coverage -3. **build-android** (ubuntu-latest) - Build Android example app with Gradle caching -4. **build-ios** (macos-latest) - Build iOS example app with CocoaPods caching - -**Note:** Native tests (Android JUnit and iOS XCTest) are not included in CI as they require React Native dependencies from the example app context. Run these tests locally during development. +3. **build-android** (ubuntu-latest) - Build the Android example app, then run the + Android JUnit suite (`:react-native-purchasely:testDebugUnitTest`) +4. **build-rn-0-86-android** (ubuntu-latest) - Build against the supported RN version +5. **build-ios** (macos-latest) - Build iOS example app with CocoaPods caching +6. **build-rn-0-86-ios** (macos-latest) - Build against the supported RN version +7. **iOS Build (use_frameworks!)** (macos-latest) - Build the bridge pod under + `use_frameworks!` +8. **iOS Unit Tests (bridge)** (macos-latest) - Run the XCTest bundle on a simulator + +**Native tests are part of CI**, both platforms. They run through the example +project rather than from their own package directory, because they need the +React Native dependencies. E2E (`e2e-android.yml`, `e2e-ios.yml`) runs nightly +and on a pull request that touches the bridge paths, and must never gate +`publish.yml`. ### Publish (publish.yml) @@ -646,6 +665,8 @@ See `VERSIONS.md` for native SDK version mapping: | React Native SDK | iOS SDK | Android SDK | |------------------|---------|-------------| +| 6.1.0 | 6.1.0 | 6.1.0 | +| 6.0.0 | 6.0.0 | 6.0.1 | | 5.7.3 | 5.7.4 | 5.7.4 | | 5.7.2 | 5.7.2 | 5.7.3 | | 5.7.1 | 5.7.1 | 5.7.1 | diff --git a/VERSIONS.md b/VERSIONS.md index 9aa7f615..daae975a 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -118,3 +118,4 @@ This file provides the underlying native SDK versions that the React Native SDK | 6.0.0-rc.2 | 6.0.0-rc.2 | 6.0.0-rc.2 | | 6.0.0-rc.3 | 6.0.0-rc.3 | 6.0.0-rc.3 | | 6.0.0 | 6.0.0 | 6.0.1 | +| 6.1.0 | 6.1.0 | 6.1.0 | diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index ce9c956a..2f39f8f7 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -3,7 +3,7 @@ PODS: - hermes-engine (250829098.0.14): - hermes-engine/Pre-built (= 250829098.0.14) - hermes-engine/Pre-built (250829098.0.14) - - Purchasely (6.0.0) + - Purchasely (6.1.0) - RCTDeprecation (0.86.0) - RCTRequired (0.86.0) - RCTSwiftUI (0.86.0) @@ -1450,11 +1450,11 @@ PODS: - ReactNativeDependencies - SwiftUIIntrospect (~> 1.0) - Yoga - - react-native-purchasely (6.0.0): - - Purchasely (= 6.0.0) + - react-native-purchasely (6.1.0): + - Purchasely (= 6.1.0) - React-Core - - react-native-purchasely/Tests (6.0.0): - - Purchasely (= 6.0.0) + - react-native-purchasely/Tests (6.1.0): + - Purchasely (= 6.1.0) - React-Core - react-native-safe-area-context (5.5.2): - hermes-engine @@ -2229,7 +2229,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d hermes-engine: 7fa7794edc84e91e759bb4fe5095fe8bc63f8ceb - Purchasely: 4309bcf9d139c40dcad8ebe3d93751c8d6ae02cd + Purchasely: c80ea99b186314d8980ff6a6c57de6c0ea626920 RCTDeprecation: 2a74a2c57675e64419bd89078efde81f7c1de90b RCTRequired: 30451112e6fef4e6f31b4e7eee0845156e35e4b0 RCTSwiftUI: 5aaf0b07e747ba749dc6acc94d8bd41eea4b570f @@ -2268,7 +2268,7 @@ SPEC CHECKSUMS: React-microtasksnativemodule: 2eb3f49d0d8e77b5343455eccd057010b8d38b6b React-mutationobservernativemodule: f0a0d5ae9b51caf7becbeabf836d716cfedb6bf2 react-native-pager-view: e91b5624568fda9e4c0fb0c44ed9669c2335e212 - react-native-purchasely: 662d42a2c30aac229d9eb5c0a902be86e768d8a9 + react-native-purchasely: eb3a758ca2d76ea404d369206e5f8b40b85dd9c4 react-native-safe-area-context: 3836dc43241ba89903508a442029e57f7491539f React-NativeModulesApple: a092d89b58f635ebfab88048b0eda9fb516819fd React-networking: 968bbbe73590149feb1e72b2af4f6a68e4796ece diff --git a/example/src/App.tsx b/example/src/App.tsx index 91782483..8dd9fb86 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -19,6 +19,7 @@ const Stack = createNativeStackNavigator() function App(): React.JSX.Element { async function setupPurchasely() { let configured = false + try { // chained builder — the only supported way to start the SDK. // `allowDeeplink(true)` replaces the legacy `readyToOpenDeeplink`. @@ -32,6 +33,31 @@ function App(): React.JSX.Element { .allowCampaigns(true) .storekitVersion('storeKit2') // iOS: 'storeKit2' or 'storeKit1' .stores(['google']) // Android stores + // 6.1.0, Web2App redemption. On the chain, so the listener + // exists before start() runs: a redemption can settle during + // start(), from a cold start the `ply/redeem` link triggered + // or a token a previous launch left pending. + .webRedemptionListener((result) => { + if (result.isSuccess) { + console.log( + 'Redemption granted. replay=' + + result.replay + + ' subscription=' + + result.context?.subscription?.plan?.vendorId + ) + } else { + // On BOTH platforms, errorMessage for an expired link + // can carry a masked email address. Show it to the + // user. Do not send it to analytics or to a crash + // reporter. + console.log( + 'Redemption failed. code=' + + result.errorCode + + ' message=' + + result.errorMessage + ) + } + }) .start() } catch (e) { console.log('Purchasely SDK configuration error:', e) diff --git a/packages/amazon/android/build.gradle b/packages/amazon/android/build.gradle index 22c4231d..905e9c1d 100644 --- a/packages/amazon/android/build.gradle +++ b/packages/amazon/android/build.gradle @@ -62,5 +62,5 @@ dependencies { api 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'io.purchasely:amazon:6.0.1' + implementation 'io.purchasely:amazon:6.1.0' } diff --git a/packages/amazon/package.json b/packages/amazon/package.json index 11c4db4a..de69b78e 100644 --- a/packages/amazon/package.json +++ b/packages/amazon/package.json @@ -1,6 +1,6 @@ { "name": "@purchasely/react-native-purchasely-amazon", - "version": "6.0.0", + "version": "6.1.0", "description": "Purchasely Amazon In-App Purchases dependency", "source": "./src/index.tsx", "main": "./lib/commonjs/index.js", diff --git a/packages/android-player/android/build.gradle b/packages/android-player/android/build.gradle index 9573886e..8f4f9e5f 100644 --- a/packages/android-player/android/build.gradle +++ b/packages/android-player/android/build.gradle @@ -63,5 +63,5 @@ dependencies { api 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'io.purchasely:player:6.0.1' + implementation 'io.purchasely:player:6.1.0' } diff --git a/packages/android-player/package.json b/packages/android-player/package.json index 4a5fda9c..10d04432 100644 --- a/packages/android-player/package.json +++ b/packages/android-player/package.json @@ -1,6 +1,6 @@ { "name": "@purchasely/react-native-purchasely-android-player", - "version": "6.0.0", + "version": "6.1.0", "description": "Player Android", "source": "./src/index.ts", "main": "./lib/commonjs/index.js", diff --git a/packages/google/android/build.gradle b/packages/google/android/build.gradle index 19ec7b6d..f73d3999 100644 --- a/packages/google/android/build.gradle +++ b/packages/google/android/build.gradle @@ -63,5 +63,5 @@ dependencies { api 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'io.purchasely:google-play:6.0.1' + implementation 'io.purchasely:google-play:6.1.0' } diff --git a/packages/google/package.json b/packages/google/package.json index f8b0181b..f7cba24d 100644 --- a/packages/google/package.json +++ b/packages/google/package.json @@ -1,6 +1,6 @@ { "name": "@purchasely/react-native-purchasely-google", - "version": "6.0.0", + "version": "6.1.0", "description": "Purchasely Google Play Billing dependency", "source": "./src/index.tsx", "main": "./lib/commonjs/index.js", diff --git a/packages/huawei/android/build.gradle b/packages/huawei/android/build.gradle index 71ea481e..2d025deb 100644 --- a/packages/huawei/android/build.gradle +++ b/packages/huawei/android/build.gradle @@ -66,5 +66,5 @@ dependencies { api 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'io.purchasely:huawei-services:6.0.1' + implementation 'io.purchasely:huawei-services:6.1.0' } diff --git a/packages/huawei/package.json b/packages/huawei/package.json index 33dfb0d4..1749160f 100644 --- a/packages/huawei/package.json +++ b/packages/huawei/package.json @@ -1,6 +1,6 @@ { "name": "@purchasely/react-native-purchasely-huawei", - "version": "6.0.0", + "version": "6.1.0", "description": "Purchasely Huawei Mobile Services dependencies", "source": "./src/index.tsx", "main": "./lib/commonjs/index.js", diff --git a/packages/purchasely/android/build.gradle b/packages/purchasely/android/build.gradle index 83c576b8..3d8593bb 100644 --- a/packages/purchasely/android/build.gradle +++ b/packages/purchasely/android/build.gradle @@ -138,7 +138,7 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.2' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' - api 'io.purchasely:core:6.0.1' + api 'io.purchasely:core:6.1.0' api 'androidx.lifecycle:lifecycle-common-java8:2.2.0' // Test dependencies diff --git a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt index f91b0521..b4d09289 100644 --- a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt +++ b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt @@ -12,6 +12,8 @@ import io.purchasely.ext.presentation.PLYPresentation import io.purchasely.ext.presentation.PLYPresentationType import io.purchasely.models.PLYPlan import io.purchasely.models.PLYPresentationPlan +import io.purchasely.models.PLYSubscriptionData +import io.purchasely.models.PLYWebRedemptionResult import io.purchasely.storage.userData.PLYUserAttributeSource import io.purchasely.storage.userData.PLYUserAttributeType import io.purchasely.views.presentation.PLYThemeMode @@ -79,6 +81,19 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : } } + /** + * Bridges [PLYWebRedemptionListener] to the `WEB_REDEMPTION_LISTENER` event. + * + * The native SDK calls this on the main thread, once per settled redemption. + * The sealed result is flattened to the same 5-field shape the iOS bridge + * emits, so one JS listener drives both platforms. A `Failure` still reports + * `replay = false` and `context = null`, which keeps the JS shape stable. + */ + private val bridgeWebRedemptionListener = PLYWebRedemptionListener { result -> + val params = webRedemptionResultToMap(result) + sendEvent(reactApplicationContext, "WEB_REDEMPTION_LISTENER", Arguments.makeNativeMap(params)) + } + override fun getName(): String { return "Purchasely" } @@ -124,6 +139,7 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : constants["sourcePlayStore"] = StoreType.GOOGLE_PLAY_STORE.ordinal constants["sourceHuaweiAppGallery"] = StoreType.HUAWEI_APP_GALLERY.ordinal constants["sourceAmazonAppstore"] = StoreType.AMAZON_APP_STORE.ordinal + constants["sourceStripe"] = StoreType.WEB_CHECKOUT_STRIPE.ordinal constants["sourceNone"] = StoreType.NONE.ordinal constants["consumable"] = DistributionType.CONSUMABLE.ordinal constants["nonConsumable"] = DistributionType.NON_CONSUMABLE.ordinal @@ -203,6 +219,41 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : ) { startOptions.getBoolean("automaticDeeplinkHandling") } else null + // Three states, and they are not interchangeable: + // key absent -> leave the SDK's current setting alone + // null -> clear the proxy, back to api.purchasely.io + // string -> set it + val proxySpecified = startOptions.hasKey("proxy") + val proxyApi = if (proxySpecified && !startOptions.isNull("proxy")) { + startOptions.getString("proxy") + } else null + val handlesRedemptionAlert = if ( + startOptions.hasKey("appHandlesRedemptionAlert") && !startOptions.isNull("appHandlesRedemptionAlert") + ) { + startOptions.getBoolean("appHandlesRedemptionAlert") + } else false + + // JS has no UUID type, so the id crosses the bridge as a string and is + // parsed here. The native builder takes a `UUID?`, which is where the + // guarantee used to live; a string-typed bridge is the only place left to + // catch a bad value. Reject it loudly and skip the modifier. The SDK + // still starts, matching how native treats an unusable proxy url. + val anonymousUserIdString = if ( + startOptions.hasKey("anonymousUserId") && !startOptions.isNull("anonymousUserId") + ) { + startOptions.getString("anonymousUserId") + } else null + val parsedAnonymousUserId = parseCanonicalUuid(anonymousUserIdString) + if (anonymousUserIdString != null && parsedAnonymousUserId == null) { + Log.e("Purchasely", "`anonymousUserId` must be a canonical UUID string, for example " + + "\"3f2504e0-4f89-11d3-9a0c-0305e82c3301\". Received \"$anonymousUserIdString\". " + + "The anonymous user id is not applied.") + } + val anonymousUserIdOverride = if ( + startOptions.hasKey("anonymousUserIdOverride") && !startOptions.isNull("anonymousUserIdOverride") + ) { + startOptions.getBoolean("anonymousUserIdOverride") + } else false Purchasely.Builder(reactApplicationContext.applicationContext) .apiKey(apiKey) @@ -225,6 +276,15 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : allowDeeplink?.let { this.allowDeeplink(it) } allowCampaigns?.let { this.allowCampaigns(it) } automaticDeeplinkHandling?.let { this.automaticDeeplinkHandling(it) } + if (proxySpecified) this.proxy(proxyApi) + parsedAnonymousUserId?.let { this.anonymousUserId(it, anonymousUserIdOverride) } + // Registered unconditionally: the native SDK has no runtime setter on + // purpose, because a redemption can settle during `start()` (a cold + // start that the link itself triggered, or a token left pending by a + // previous launch). The bridge emits `WEB_REDEMPTION_LISTENER`, which + // reaches no one when JS added no listener, so this is + // behaviour-neutral by default. + this.webRedemptionListener(handlesRedemptionAlert, bridgeWebRedemptionListener) } .build() @@ -617,21 +677,7 @@ fun decrementUserAttribute(key: String, value: Double, legalBasis: String?) { val subscriptions = Purchasely.userSubscriptions(invalidate) val result = ArrayList() for (data in subscriptions) { - val map = data.data.toMap().toMutableMap().apply { - this["subscriptionSource"] = when(data.data.storeType) { - StoreType.GOOGLE_PLAY_STORE -> StoreType.GOOGLE_PLAY_STORE.ordinal - StoreType.HUAWEI_APP_GALLERY -> StoreType.HUAWEI_APP_GALLERY.ordinal - StoreType.AMAZON_APP_STORE -> StoreType.AMAZON_APP_STORE.ordinal - StoreType.APPLE_APP_STORE -> StoreType.APPLE_APP_STORE.ordinal - else -> null - } - if(data.data.plan == null) { - this["plan"] = transformPlanToMap(data.plan) - } - this["product"] = data.product.toMap() - remove("subscription_status") //Add in a next version - } - result.add(Arguments.makeNativeMap(map)) + result.add(Arguments.makeNativeMap(subscriptionToMap(data))) } promise.resolve(Arguments.makeNativeArray(result)) } catch (e: Exception) { @@ -647,21 +693,7 @@ fun decrementUserAttribute(key: String, value: Double, legalBasis: String?) { val subscriptions = Purchasely.userSubscriptionsHistory(invalidateCache) val result = ArrayList() for (data in subscriptions) { - val map = data.data.toMap().toMutableMap().apply { - this["subscriptionSource"] = when(data.data.storeType) { - StoreType.GOOGLE_PLAY_STORE -> StoreType.GOOGLE_PLAY_STORE.ordinal - StoreType.HUAWEI_APP_GALLERY -> StoreType.HUAWEI_APP_GALLERY.ordinal - StoreType.AMAZON_APP_STORE -> StoreType.AMAZON_APP_STORE.ordinal - StoreType.APPLE_APP_STORE -> StoreType.APPLE_APP_STORE.ordinal - else -> null - } - if(data.data.plan == null) { - this["plan"] = transformPlanToMap(data.plan) - } - this["product"] = data.product.toMap() - remove("subscription_status") //Add in a next version - } - result.add(Arguments.makeNativeMap(map)) + result.add(Arguments.makeNativeMap(subscriptionToMap(data))) } promise.resolve(Arguments.makeNativeArray(result)) } catch (e: Exception) { @@ -1370,6 +1402,35 @@ fun decrementUserAttribute(key: String, value: Double, legalBasis: String?) { private val pendingActionInterceptors = ConcurrentHashMap>() + /** + * Map one [PLYSubscriptionData] to the JS `PLYSubscription` shape. + * + * Shared by `userSubscriptions`, `userSubscriptionsHistory` and the web + * redemption listener, whose `context.subscription` is the same type, so + * the three report one subscription shape. + */ + fun subscriptionToMap(data: PLYSubscriptionData): Map { + return data.data.toMap().toMutableMap().apply { + // WEB_CHECKOUT_STRIPE was missing here and fell into `else`, so a + // web-checkout subscription reported a null source. Web2App + // redemption grants subscriptions from that source, so the new + // redemption context surfaced the gap. + this["subscriptionSource"] = when(data.data.storeType) { + StoreType.GOOGLE_PLAY_STORE -> StoreType.GOOGLE_PLAY_STORE.ordinal + StoreType.HUAWEI_APP_GALLERY -> StoreType.HUAWEI_APP_GALLERY.ordinal + StoreType.AMAZON_APP_STORE -> StoreType.AMAZON_APP_STORE.ordinal + StoreType.APPLE_APP_STORE -> StoreType.APPLE_APP_STORE.ordinal + StoreType.WEB_CHECKOUT_STRIPE -> StoreType.WEB_CHECKOUT_STRIPE.ordinal + else -> null + } + if(data.data.plan == null) { + this["plan"] = transformPlanToMap(data.plan) + } + this["product"] = data.product.toMap() + remove("subscription_status") //Add in a next version + } + } + fun transformPlanToMap(plan: PLYPlan?): Map { if(plan == null) return emptyMap() @@ -1492,3 +1553,55 @@ fun decrementUserAttribute(key: String, value: Double, legalBasis: String?) { return metadata } } + +/** + * Parse a canonical UUID string, or return null. + * + * JS has no UUID type, so an anonymous user id crosses the bridge as a string. + * `UUID.fromString` is lenient and accepts a short form such as `"1-2-3-4-5"` + * that the iOS `NSUUID` parser refuses. The round-trip check makes both + * platforms agree on what "canonical" means, so one id string is accepted, or + * refused, on both. + * + * The caller logs the refusal. This function stays pure so a unit test can + * drive it without an Android logger. + */ +internal fun parseCanonicalUuid(value: String?): UUID? { + if (value == null) return null + val parsed = try { + UUID.fromString(value) + } catch (e: IllegalArgumentException) { + return null + } + return if (parsed.toString().equals(value, ignoreCase = true)) parsed else null +} + +/** + * Flatten a [PLYWebRedemptionResult] to the shape the JS listener receives. + * + * The sealed Kotlin result and the flat iOS `PLYWebRedemptionResult` object + * both map to the same 5 keys, so one JS listener drives both platforms. A + * `Failure` still reports `replay = false` and `context = null`, which keeps + * the JS shape stable. + * + * `context` and `context.subscription` stay separately nullable: a success can + * carry no context at all, and a present context can carry no subscription. + */ +internal fun webRedemptionResultToMap(result: PLYWebRedemptionResult): Map = when (result) { + is PLYWebRedemptionResult.Success -> mapOf( + Pair("isSuccess", true), + Pair("context", result.context?.let { context -> + mapOf(Pair("subscription", context.subscription?.let(PurchaselyModule::subscriptionToMap))) + }), + Pair("replay", result.replay), + Pair("errorCode", null), + Pair("errorMessage", null), + ) + is PLYWebRedemptionResult.Failure -> mapOf( + Pair("isSuccess", false), + Pair("context", null), + Pair("replay", false), + Pair("errorCode", result.errorCode), + Pair("errorMessage", result.errorMessage), + ) +} diff --git a/packages/purchasely/android/src/test/java/com/reactnativepurchasely/PurchaselyModuleTest.kt b/packages/purchasely/android/src/test/java/com/reactnativepurchasely/PurchaselyModuleTest.kt index e76a9786..822121d8 100644 --- a/packages/purchasely/android/src/test/java/com/reactnativepurchasely/PurchaselyModuleTest.kt +++ b/packages/purchasely/android/src/test/java/com/reactnativepurchasely/PurchaselyModuleTest.kt @@ -6,6 +6,8 @@ import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableArray import io.purchasely.ext.* +import io.purchasely.models.PLYWebRedemptionContext +import io.purchasely.models.PLYWebRedemptionResult import io.purchasely.ext.presentation.PLYPresentationBase import io.purchasely.ext.presentation.PLYPresentationType import io.purchasely.storage.userData.PLYUserAttributeSource @@ -549,6 +551,112 @@ class PurchaselyModuleTest { } // endregion + + // region 6.1.0: anonymous user id + web redemption + + /** + * `UUID.fromString` is lenient: it accepts a short form the iOS `NSUUID` + * parser refuses. `parseCanonicalUuid` adds a round-trip check so one id + * string is accepted, or refused, on both platforms. + */ + @Test + fun `parseCanonicalUuid accepts a canonical uuid`() { + val parsed = parseCanonicalUuid("3f2504e0-4f89-11d3-9a0c-0305e82c3301") + + assertNotNull(parsed) + assertEquals("3f2504e0-4f89-11d3-9a0c-0305e82c3301", parsed.toString()) + } + + @Test + fun `parseCanonicalUuid accepts an uppercase uuid`() { + val parsed = parseCanonicalUuid("3F2504E0-4F89-11D3-9A0C-0305E82C3301") + + assertNotNull(parsed) + assertEquals("3f2504e0-4f89-11d3-9a0c-0305e82c3301", parsed.toString()) + } + + @Test + fun `parseCanonicalUuid refuses the lenient short form that iOS refuses`() { + assertNull(parseCanonicalUuid("1-2-3-4-5")) + } + + @Test + fun `parseCanonicalUuid refuses a value that is not a uuid`() { + assertNull(parseCanonicalUuid("not-a-uuid")) + assertNull(parseCanonicalUuid("")) + assertNull(parseCanonicalUuid("3f2504e0-4f89-11d3-9a0c")) + } + + @Test + fun `parseCanonicalUuid returns null for a null value`() { + assertNull(parseCanonicalUuid(null)) + } + + @Test + fun `webRedemptionResultToMap flattens a success that describes nothing`() { + val map = webRedemptionResultToMap(PLYWebRedemptionResult.Success(null, false)) + + assertEquals(true, map["isSuccess"]) + assertNull(map["context"]) + assertEquals(false, map["replay"]) + assertNull(map["errorCode"]) + assertNull(map["errorMessage"]) + } + + /** + * A present context can still carry a null subscription. Both levels stay + * nullable, so the JS side sees the same two-level shape the native SDKs + * report. + */ + @Test + fun `webRedemptionResultToMap keeps a present context with a null subscription`() { + val result = PLYWebRedemptionResult.Success(PLYWebRedemptionContext(null), false) + + val map = webRedemptionResultToMap(result) + + assertEquals(true, map["isSuccess"]) + @Suppress("UNCHECKED_CAST") + val context = map["context"] as Map + assertTrue(context.containsKey("subscription")) + assertNull(context["subscription"]) + } + + @Test + fun `webRedemptionResultToMap reports a replayed token`() { + val map = webRedemptionResultToMap(PLYWebRedemptionResult.Success(null, true)) + + assertEquals(true, map["isSuccess"]) + assertEquals(true, map["replay"]) + } + + @Test + fun `webRedemptionResultToMap flattens a failure and keeps the JS shape stable`() { + val result = PLYWebRedemptionResult.Failure( + "EXPIRED_REDEMPTION_TOKEN", + "Redemption link has expired." + ) + + val map = webRedemptionResultToMap(result) + + assertEquals(false, map["isSuccess"]) + assertNull(map["context"]) + // A failure still reports replay, so the JS shape never changes. + assertEquals(false, map["replay"]) + assertEquals("EXPIRED_REDEMPTION_TOKEN", map["errorCode"]) + assertEquals("Redemption link has expired.", map["errorMessage"]) + } + + /** A transport or parsing failure never reached the server, so it has no code. */ + @Test + fun `webRedemptionResultToMap accepts a failure with no error code`() { + val map = webRedemptionResultToMap(PLYWebRedemptionResult.Failure(null, "Network error")) + + assertEquals(false, map["isSuccess"]) + assertNull(map["errorCode"]) + assertEquals("Network error", map["errorMessage"]) + } + + // endregion } /** diff --git a/packages/purchasely/ios/PurchaselyRN.h b/packages/purchasely/ios/PurchaselyRN.h index bcecc6c0..bd791562 100644 --- a/packages/purchasely/ios/PurchaselyRN.h +++ b/packages/purchasely/ios/PurchaselyRN.h @@ -9,7 +9,25 @@ #import @import Purchasely; -@interface PurchaselyRN: RCTEventEmitter +@interface PurchaselyRN: RCTEventEmitter + +/// Build the `WEB_REDEMPTION_LISTENER` event body from the parts of a +/// `PLYWebRedemptionResult`. +/// +/// Split out of `webRedemptionCompletedWithResult:` so the payload policy is +/// testable. `PLYWebRedemptionResult` has no public initializer, so a test +/// cannot build one, and this is the part worth checking: which key holds +/// `NSNull`, how a context nests its subscription, and that the same five keys +/// appear on a success and on a failure alike. +/// +/// `hasContext` and `subscription` are separate on purpose. A success can +/// carry no context at all, and a present context can carry no subscription. ++ (nonnull NSDictionary *)webRedemptionBodyWithSuccess:(BOOL)isSuccess + hasContext:(BOOL)hasContext + subscription:(nullable NSDictionary *)subscription + replay:(BOOL)replay + errorCode:(nullable NSString *)errorCode + errorMessage:(nullable NSString *)errorMessage; @property (nonatomic, retain) UIViewController* presentedPresentationViewController; diff --git a/packages/purchasely/ios/PurchaselyRN.m b/packages/purchasely/ios/PurchaselyRN.m index 4100d2cb..b28728f9 100644 --- a/packages/purchasely/ios/PurchaselyRN.m +++ b/packages/purchasely/ios/PurchaselyRN.m @@ -481,6 +481,7 @@ - (NSDictionary *)constantsToExport { @"sourcePlayStore": @(PLYSubscriptionSourceGooglePlayStore), @"sourceHuaweiAppGallery": @(PLYSubscriptionSourceHuaweiAppGallery), @"sourceAmazonAppstore": @(PLYSubscriptionSourceAmazonAppstore), + @"sourceStripe": @(PLYSubscriptionSourceStripe), @"sourceNone": @(PLYSubscriptionSourceNone), @"firebaseAppInstanceId": @(PLYAttributeFirebaseAppInstanceId), @"airshipChannelId": @(PLYAttributeAirshipChannelId), @@ -556,6 +557,7 @@ - (NSDictionary *)constantsToExport { // separate post-start call would leave open for an early campaign/deeplink // to fire against the wrong default. `automaticDeeplinkHandling` has no // iOS builder equivalent (Android-only) and is ignored here. + BOOL appHandlesRedemptionAlert = NO; if ([startOptions isKindOfClass:[NSDictionary class]]) { id allowDeeplink = startOptions[@"allowDeeplink"]; if ([allowDeeplink isKindOfClass:[NSNumber class]]) { @@ -565,8 +567,61 @@ - (NSDictionary *)constantsToExport { if ([allowCampaigns isKindOfClass:[NSNumber class]]) { builder = [builder allowCampaigns:[allowCampaigns boolValue]]; } + // JS has no UUID type, so the id crosses the bridge as a string and is + // parsed here. The native builder takes a `UUID?`, which is where the + // guarantee used to live; a string-typed bridge is the only place left + // to catch a bad value. Reject it loudly and skip the modifier. The + // SDK still starts, matching how native treats an unusable proxy url. + id anonymousUserId = startOptions[@"anonymousUserId"]; + if ([anonymousUserId isKindOfClass:[NSString class]]) { + NSUUID *parsed = [[NSUUID alloc] initWithUUIDString:(NSString *)anonymousUserId]; + if (parsed == nil) { + RCTLogWarn(@"[Purchasely] `anonymousUserId` must be a canonical UUID string, " + "for example \"3f2504e0-4f89-11d3-9a0c-0305e82c3301\". Received \"%@\". " + "The anonymous user id is not applied.", anonymousUserId); + } else { + id override = startOptions[@"anonymousUserIdOverride"]; + BOOL shouldOverride = [override isKindOfClass:[NSNumber class]] ? [override boolValue] : NO; + builder = [builder appAnonymousUserId:parsed override:shouldOverride]; + } + } + // The native modifier takes an `NSURL?`, and a `nil` there means + // "turn the proxy off", not "ignore this value". So a string that + // `NSURL` cannot parse must skip the modifier entirely rather than + // pass nil, which would silently disable a proxy the app asked for. + // Native validates the rest (https, host, no query/fragment) and + // keeps the production host on a bad value, so the bridge does not + // re-check those. + // Three states, and they are not interchangeable: + // key absent -> leave the SDK's current setting alone + // NSNull -> clear the proxy, back to api.purchasely.io + // NSString -> set it + id proxyApi = startOptions[@"proxy"]; + if (proxyApi == [NSNull null]) { + builder = [builder proxyWithApi:nil]; + } else if ([proxyApi isKindOfClass:[NSString class]]) { + NSURL *proxyUrl = [NSURL URLWithString:(NSString *)proxyApi]; + if (proxyUrl == nil) { + RCTLogWarn(@"[Purchasely] `proxy` must be an https base URL, " + "for example \"https://svc.purchasely.io\". Received \"%@\". " + "The proxy is not applied.", proxyApi); + } else { + builder = [builder proxyWithApi:proxyUrl]; + } + } + id handlesAlert = startOptions[@"appHandlesRedemptionAlert"]; + if ([handlesAlert isKindOfClass:[NSNumber class]]) { + appHandlesRedemptionAlert = [handlesAlert boolValue]; + } } + // Registered unconditionally: the native SDK has no runtime setter on + // purpose, because a redemption can settle during `start()` (a cold start + // that the link itself triggered, or a token left pending by a previous + // launch). The bridge emits `WEB_REDEMPTION_LISTENER`, which reaches no one + // when JS added no listener, so this is behaviour-neutral by default. + builder = [builder webRedemptionDelegate:self appHandlesRedemptionAlert:appHandlesRedemptionAlert]; + [builder startWithInitialized:^(NSError * _Nullable error) { if (error != nil) { [self reject: reject with: error]; @@ -1246,6 +1301,7 @@ - (id _Nullable) getUserAttributeValueForRN:(id _Nullable) value { @"PURCHASE_LISTENER", @"USER_ATTRIBUTE_SET_LISTENER", @"USER_ATTRIBUTE_REMOVED_LISTENER", + @"WEB_REDEMPTION_LISTENER", // cross-platform bridge events. Names mirror the Android bridge so the // same JS layer drives both platforms. See the presentation section below. @"PURCHASELY_PRESENTATION_LOADED", @@ -1332,6 +1388,57 @@ - (void)onUserAttributeRemovedWithKey:(NSString * _Nonnull)key } +/// `PLYWebRedemptionDelegate`. The SDK calls this on the main thread, once per +/// settled redemption. Mapped to the flat 5-field shape the Android bridge +/// emits, so one JS listener drives both platforms. +/// +/// `context` and `context.subscription` are separately nullable, and both stay +/// nullable in the emitted body: a success can carry no context at all, and a +/// present context can carry no subscription. +/// +/// `errorMessage` can hold the backend's masked email hint for an expired +/// link. The `REDEMPTION_FAILED` event drops that hint on purpose; this +/// channel keeps it, so the app can tell the user where the fresh link went. +- (void)webRedemptionCompletedWithResult:(PLYWebRedemptionResult * _Nonnull)result { + if (!self.shouldEmit) return; + + PLYSubscription *subscription = result.context.subscription; + NSDictionary *body = + [PurchaselyRN webRedemptionBodyWithSuccess:result.isSuccess + hasContext:result.context != nil + subscription:subscription != nil ? subscription.asDictionary : nil + replay:result.replay + errorCode:result.errorCode + errorMessage:result.errorMessage]; + + [self sendEventWithName:@"WEB_REDEMPTION_LISTENER" body:body]; +} + ++ (NSDictionary *)webRedemptionBodyWithSuccess:(BOOL)isSuccess + hasContext:(BOOL)hasContext + subscription:(NSDictionary * _Nullable)subscription + replay:(BOOL)replay + errorCode:(NSString * _Nullable)errorCode + errorMessage:(NSString * _Nullable)errorMessage { + // `context` and `context.subscription` are separately nullable, and the + // two nulls mean different things: no context at all versus a context + // that describes no subscription. Both stay distinguishable in JS. + id context = [NSNull null]; + if (hasContext) { + context = @{ @"subscription": subscription ?: [NSNull null] }; + } + + // The same five keys on every branch, so the JS shape never changes + // between a success and a failure. + return @{ + @"isSuccess": @(isSuccess), + @"context": context, + @"replay": @(replay), + @"errorCode": errorCode ?: [NSNull null], + @"errorMessage": errorMessage ?: [NSNull null] + }; +} + - (void)purchasePerformed { if (!self.shouldEmit) return; [self sendEventWithName: @"PURCHASE_LISTENER" body: @{}]; diff --git a/packages/purchasely/ios/PurchaselyTests/PurchaselyRNTests.m b/packages/purchasely/ios/PurchaselyTests/PurchaselyRNTests.m index 516a741f..c861de23 100644 --- a/packages/purchasely/ios/PurchaselyTests/PurchaselyRNTests.m +++ b/packages/purchasely/ios/PurchaselyTests/PurchaselyRNTests.m @@ -348,6 +348,128 @@ - (void)testClosePresentationIsBridged { @"closePresentation: should be exported to the bridge"); } +#pragma mark - Web2App redemption (6.1.0) + +- (void)testSupportedEventsIncludesWebRedemptionListener { + NSArray *events = [self.purchaselyModule supportedEvents]; + XCTAssertTrue([events containsObject:@"WEB_REDEMPTION_LISTENER"], + @"supportedEvents should expose the web redemption event"); +} + +- (void)testModuleConformsToWebRedemptionDelegate { + // The bridge registers itself on the start chain + // (`webRedemptionDelegate:appHandlesRedemptionAlert:`), so it must conform. + XCTAssertTrue([self.purchaselyModule conformsToProtocol:@protocol(PLYWebRedemptionDelegate)], + @"PurchaselyRN should conform to PLYWebRedemptionDelegate"); +} + +- (void)testWebRedemptionCompletedIsImplemented { + // Swift `webRedemptionCompleted(result:)` bridges to this selector. + XCTAssertTrue([self.purchaselyModule respondsToSelector:@selector(webRedemptionCompletedWithResult:)], + @"the web redemption delegate callback should be implemented"); +} + +/// The five keys must be present on every branch. A JS listener reads the same +/// shape whether the redemption succeeded or failed. +- (void)assertWebRedemptionShape:(NSDictionary *)body { + XCTAssertEqual(body.count, 5, @"the body must always carry exactly five keys"); + for (NSString *key in @[@"isSuccess", @"context", @"replay", @"errorCode", @"errorMessage"]) { + XCTAssertNotNil(body[key], @"%@ must be present", key); + } +} + +- (void)testWebRedemptionBodySuccessWithNoContext { + NSDictionary *body = [PurchaselyRN webRedemptionBodyWithSuccess:YES + hasContext:NO + subscription:nil + replay:NO + errorCode:nil + errorMessage:nil]; + + [self assertWebRedemptionShape:body]; + XCTAssertEqualObjects(body[@"isSuccess"], @YES); + XCTAssertEqualObjects(body[@"context"], [NSNull null], + @"no context at all must be NSNull, not an empty dictionary"); + XCTAssertEqualObjects(body[@"replay"], @NO); + XCTAssertEqualObjects(body[@"errorCode"], [NSNull null]); + XCTAssertEqualObjects(body[@"errorMessage"], [NSNull null]); +} + +/// A present context with no subscription is NOT the same as no context. Both +/// levels stay separately nullable, matching the Android bridge. +- (void)testWebRedemptionBodyKeepsAPresentContextWithNoSubscription { + NSDictionary *body = [PurchaselyRN webRedemptionBodyWithSuccess:YES + hasContext:YES + subscription:nil + replay:NO + errorCode:nil + errorMessage:nil]; + + [self assertWebRedemptionShape:body]; + XCTAssertTrue([body[@"context"] isKindOfClass:[NSDictionary class]], + @"a present context must stay a dictionary"); + NSDictionary *context = body[@"context"]; + XCTAssertEqualObjects(context[@"subscription"], [NSNull null]); +} + +- (void)testWebRedemptionBodyNestsTheSubscription { + NSDictionary *subscription = @{@"purchaseToken": @"token-123"}; + NSDictionary *body = [PurchaselyRN webRedemptionBodyWithSuccess:YES + hasContext:YES + subscription:subscription + replay:NO + errorCode:nil + errorMessage:nil]; + + [self assertWebRedemptionShape:body]; + NSDictionary *context = body[@"context"]; + XCTAssertEqualObjects(context[@"subscription"], subscription); +} + +- (void)testWebRedemptionBodyReportsAReplayedToken { + NSDictionary *body = [PurchaselyRN webRedemptionBodyWithSuccess:YES + hasContext:YES + subscription:nil + replay:YES + errorCode:nil + errorMessage:nil]; + + XCTAssertEqualObjects(body[@"replay"], @YES); + XCTAssertEqualObjects(body[@"isSuccess"], @YES, + @"a replay is still a success"); +} + +- (void)testWebRedemptionBodyFailureKeepsTheShapeStable { + NSDictionary *body = [PurchaselyRN webRedemptionBodyWithSuccess:NO + hasContext:NO + subscription:nil + replay:NO + errorCode:@"EXPIRED_REDEMPTION_TOKEN" + errorMessage:@"Redemption link has expired."]; + + [self assertWebRedemptionShape:body]; + XCTAssertEqualObjects(body[@"isSuccess"], @NO); + XCTAssertEqualObjects(body[@"context"], [NSNull null]); + XCTAssertEqualObjects(body[@"replay"], @NO, + @"a failure still reports replay, so the shape never changes"); + XCTAssertEqualObjects(body[@"errorCode"], @"EXPIRED_REDEMPTION_TOKEN"); + XCTAssertEqualObjects(body[@"errorMessage"], @"Redemption link has expired."); +} + +/// A transport or parsing failure never reached the server, so it carries no code. +- (void)testWebRedemptionBodyFailureWithNoErrorCode { + NSDictionary *body = [PurchaselyRN webRedemptionBodyWithSuccess:NO + hasContext:NO + subscription:nil + replay:NO + errorCode:nil + errorMessage:@"Redemption could not be completed."]; + + [self assertWebRedemptionShape:body]; + XCTAssertEqualObjects(body[@"errorCode"], [NSNull null]); + XCTAssertEqualObjects(body[@"errorMessage"], @"Redemption could not be completed."); +} + - (void)testSupportedEventsIncludesCloseRequested { NSArray *events = [self.purchaselyModule supportedEvents]; XCTAssertTrue([events containsObject:@"PURCHASELY_PRESENTATION_CLOSE_REQUESTED"], diff --git a/packages/purchasely/package.json b/packages/purchasely/package.json index 19de6742..47735c7b 100644 --- a/packages/purchasely/package.json +++ b/packages/purchasely/package.json @@ -1,7 +1,7 @@ { "name": "react-native-purchasely", "title": "Purchasely React Native", - "version": "6.0.0", + "version": "6.1.0", "description": "Purchasely is a solution to ease the integration and boost your In-App Purchase & Subscriptions on the App Store, Google Play Store and Huawei App Gallery.", "source": "./src/index.tsx", "main": "./lib/commonjs/index.js", diff --git a/packages/purchasely/react-native-purchasely.podspec b/packages/purchasely/react-native-purchasely.podspec index 619ebcff..c452401f 100644 --- a/packages/purchasely/react-native-purchasely.podspec +++ b/packages/purchasely/react-native-purchasely.podspec @@ -20,7 +20,7 @@ Pod::Spec.new do |s| s.requires_arc = true s.dependency "React-Core" - s.dependency "Purchasely", '6.0.0' + s.dependency "Purchasely", '6.1.0' s.test_spec 'Tests' do |test_spec| test_spec.source_files = 'ios/PurchaselyTests/**/*.{h,m,mm,swift}' diff --git a/packages/purchasely/src/__mocks__/emitterSpy.ts b/packages/purchasely/src/__mocks__/emitterSpy.ts new file mode 100644 index 00000000..32e35924 --- /dev/null +++ b/packages/purchasely/src/__mocks__/emitterSpy.ts @@ -0,0 +1,36 @@ +/** + * Shared spy for the native event emitter. + * + * `startBuilder.ts` reaches the emitter through `redemption.ts`, which builds + * it once and keeps it. A `jest.fn()` factory that returns a fresh object per + * construction therefore hides every call behind an instance the test cannot + * see. This module holds one set of spies so a test can assert on them and, + * importantly, inspect the subscriptions handed back. + */ +type Subscription = { remove: jest.Mock } + +const subscriptions: Subscription[] = [] +let onAddHook: (() => void) | undefined + +const addListener = jest.fn((_event: string, _callback: unknown) => { + onAddHook?.() + const subscription: Subscription = { remove: jest.fn() } + subscriptions.push(subscription) + return subscription +}) + +const removeAllListeners = jest.fn() + +const reset = () => { + addListener.mockClear() + removeAllListeners.mockClear() + subscriptions.length = 0 + onAddHook = undefined +} + +/** Run `fn` each time a listener is added, to record ordering. */ +const onAdd = (fn: () => void) => { + onAddHook = fn +} + +export default { addListener, removeAllListeners, subscriptions, reset, onAdd } diff --git a/packages/purchasely/src/__mocks__/testUtils.ts b/packages/purchasely/src/__mocks__/testUtils.ts index 5c3ce4d3..a50e1c40 100644 --- a/packages/purchasely/src/__mocks__/testUtils.ts +++ b/packages/purchasely/src/__mocks__/testUtils.ts @@ -16,6 +16,7 @@ export const mockConstants = { sourcePlayStore: 1, sourceHuaweiAppGallery: 2, sourceAmazonAppstore: 3, + sourceStripe: 5, sourceNone: 4, firebaseAppInstanceId: 0, airshipChannelId: 1, diff --git a/packages/purchasely/src/__tests__/enums.test.ts b/packages/purchasely/src/__tests__/enums.test.ts index 16cd99b9..aaa00dfb 100644 --- a/packages/purchasely/src/__tests__/enums.test.ts +++ b/packages/purchasely/src/__tests__/enums.test.ts @@ -30,6 +30,22 @@ import { } from '../enums' import * as EnumsModule from '../enums' +// Web2App redemption grants web-checkout subscriptions, and both natives +// expose that source (Android StoreType.WEB_CHECKOUT_STRIPE, iOS +// PLYSubscriptionSource.stripe). The enum had no member for it, so a redeemed +// subscription reported a source the JS side could not name. +describe('SubscriptionSource web checkout', () => { + it('exposes WEB_CHECKOUT_STRIPE', () => { + expect(SubscriptionSource.WEB_CHECKOUT_STRIPE).toBeDefined() + }) + + it('keeps WEB_CHECKOUT_STRIPE distinct from NONE', () => { + expect(SubscriptionSource.WEB_CHECKOUT_STRIPE).not.toBe( + SubscriptionSource.NONE + ) + }) +}) + describe('Purchasely Enums', () => { describe('ProductResult', () => { it('should have correct enum values from constants', () => { @@ -83,8 +99,10 @@ describe('Purchasely Enums', () => { expect(members).toContain('GOOGLE_PLAY_STORE') expect(members).toContain('HUAWEI_APP_GALLERY') expect(members).toContain('AMAZON_APPSTORE') + // Both natives expose a web-checkout source, added in the 6.1.0 work. + expect(members).toContain('WEB_CHECKOUT_STRIPE') expect(members).toContain('NONE') - expect(members).toHaveLength(5) + expect(members).toHaveLength(6) }) }) diff --git a/packages/purchasely/src/__tests__/index.test.ts b/packages/purchasely/src/__tests__/index.test.ts index 26853a5b..7ceab93c 100644 --- a/packages/purchasely/src/__tests__/index.test.ts +++ b/packages/purchasely/src/__tests__/index.test.ts @@ -151,7 +151,7 @@ describe('Purchasely SDK', () => { null, mockConstants.logLevelError, mockConstants.runningModeObserver, - '6.0.0', + '6.1.0', {} ) }) @@ -862,6 +862,22 @@ describe('Purchasely SDK', () => { expect(mockEventEmitter.removeAllListeners).toHaveBeenCalledWith('USER_ATTRIBUTE_SET_LISTENER') expect(mockEventEmitter.removeAllListeners).toHaveBeenCalledWith('USER_ATTRIBUTE_REMOVED_LISTENER') }) + + it('should add the web redemption listener on the WEB_REDEMPTION_LISTENER event', () => { + const callback = jest.fn() + Purchasely.addWebRedemptionListener(callback) + + expect(mockEventEmitter.addListener).toHaveBeenCalledWith( + 'WEB_REDEMPTION_LISTENER', + callback + ) + }) + + it('should remove the web redemption listener', () => { + Purchasely.removeWebRedemptionListener() + + expect(mockEventEmitter.removeAllListeners).toHaveBeenCalledWith('WEB_REDEMPTION_LISTENER') + }) }) describe('Synchronization', () => { diff --git a/packages/purchasely/src/__tests__/startBuilder.test.ts b/packages/purchasely/src/__tests__/startBuilder.test.ts index 5b97442b..e341ac93 100644 --- a/packages/purchasely/src/__tests__/startBuilder.test.ts +++ b/packages/purchasely/src/__tests__/startBuilder.test.ts @@ -25,9 +25,20 @@ jest.mock('react-native', () => ({ handleDeeplink: jest.fn().mockResolvedValue(true), }, }, + NativeEventEmitter: jest.fn().mockImplementation(() => { + // Babel wraps the default export, so unwrap it. + const mod = require('../__mocks__/emitterSpy') + const shared = mod.default ?? mod + return { + addListener: shared.addListener, + removeAllListeners: shared.removeAllListeners, + } + }), })) import { NativeModules } from 'react-native' +import emitterSpy from '../__mocks__/emitterSpy' +import { removeWebRedemptionListener } from '../redemption' import { PurchaselyBuilder } from '../startBuilder' const mockNative = NativeModules.Purchasely as any @@ -39,7 +50,7 @@ describe('PurchaselyBuilder', () => { mockNative.handleDeeplink = jest.fn().mockResolvedValue(true) // Static field can leak mutations across tests — reset to the // package default before each test. - PurchaselyBuilder.bridgeVersion = '6.0.0' + PurchaselyBuilder.bridgeVersion = '6.1.0' }) describe('apiKey() defaults', () => { @@ -53,7 +64,7 @@ describe('PurchaselyBuilder', () => { null, // appUserId mockConstants.logLevelError, mockConstants.runningModeObserver, - '6.0.0', + '6.1.0', {} // no chain-only options set -> empty startOptions map ) }) @@ -173,6 +184,267 @@ describe('PurchaselyBuilder', () => { }) }) + describe('anonymousUserId() 6.1.0', () => { + it('forwards the id and the default override=false through startOptions', async () => { + await PurchaselyBuilder.apiKey('api-key') + .anonymousUserId('3f2504e0-4f89-11d3-9a0c-0305e82c3301') + .start() + + expect(mockNative.start.mock.calls[0][7]).toEqual({ + anonymousUserId: '3f2504e0-4f89-11d3-9a0c-0305e82c3301', + anonymousUserIdOverride: false, + }) + }) + + it('forwards override=true when asked', async () => { + await PurchaselyBuilder.apiKey('api-key') + .anonymousUserId('3f2504e0-4f89-11d3-9a0c-0305e82c3301', true) + .start() + + expect(mockNative.start.mock.calls[0][7]).toEqual({ + anonymousUserId: '3f2504e0-4f89-11d3-9a0c-0305e82c3301', + anonymousUserIdOverride: true, + }) + }) + + it('does not validate the string in JS: the bridge parses it and rejects a bad value', async () => { + await expect( + PurchaselyBuilder.apiKey('api-key').anonymousUserId('not-a-uuid').start() + ).resolves.toBe(true) + + expect(mockNative.start.mock.calls[0][7]).toEqual({ + anonymousUserId: 'not-a-uuid', + anonymousUserIdOverride: false, + }) + }) + + it('omits both keys when the modifier is never called', async () => { + await PurchaselyBuilder.apiKey('api-key').start() + expect(mockNative.start.mock.calls[0][7]).toEqual({}) + }) + }) + + describe('proxy() 6.1.0', () => { + it('forwards the api url through startOptions', async () => { + await PurchaselyBuilder.apiKey('api-key') + .proxy('https://svc.purchasely.io') + .start() + + expect(mockNative.start.mock.calls[0][7]).toEqual({ + proxy: 'https://svc.purchasely.io', + }) + }) + + it('does not validate the scheme in JS: the native SDK refuses a bad value', async () => { + await PurchaselyBuilder.apiKey('api-key').proxy('http://insecure.example').start() + expect(mockNative.start.mock.calls[0][7]).toEqual({ + proxy: 'http://insecure.example', + }) + }) + + // The three states are not interchangeable. `null` clears the proxy on + // both natives, and an absent key leaves each SDK's current setting + // untouched. Forwarding `null` as "absent" would make a clear silently + // do nothing. + it('forwards an explicit null so the natives clear the proxy', async () => { + await PurchaselyBuilder.apiKey('api-key').proxy(null).start() + + const startOptions = mockNative.start.mock.calls[0][7] + expect(startOptions).toEqual({ proxy: null }) + expect('proxy' in startOptions).toBe(true) + expect(startOptions.proxy).toBeNull() + }) + + it('omits the key when the modifier is never called', async () => { + await PurchaselyBuilder.apiKey('api-key').start() + + const startOptions = mockNative.start.mock.calls[0][7] + expect(startOptions).toEqual({}) + expect('proxy' in startOptions).toBe(false) + }) + + it('distinguishes never-called from cleared', async () => { + await PurchaselyBuilder.apiKey('api-key').start() + const never = mockNative.start.mock.calls[0][7] + + mockNative.start = jest.fn().mockResolvedValue(true) + await PurchaselyBuilder.apiKey('api-key').proxy(null).start() + const cleared = mockNative.start.mock.calls[0][7] + + expect('proxy' in never).toBe(false) + expect('proxy' in cleared).toBe(true) + expect(never).not.toEqual(cleared) + }) + + it('the last call wins, so a proxy can be replaced then cleared', async () => { + await PurchaselyBuilder.apiKey('api-key') + .proxy('https://first.example') + .proxy('https://svc.purchasely.io') + .proxy(null) + .start() + + expect(mockNative.start.mock.calls[0][7]).toEqual({ proxy: null }) + }) + }) + + describe('webRedemptionListener() 6.1.0', () => { + beforeEach(() => emitterSpy.reset()) + + it('subscribes the callback on the WEB_REDEMPTION_LISTENER event', async () => { + const callback = jest.fn() + await PurchaselyBuilder.apiKey('api-key') + .webRedemptionListener(callback) + .start() + + expect(emitterSpy.addListener).toHaveBeenCalledWith( + 'WEB_REDEMPTION_LISTENER', + callback + ) + }) + + // A redemption can settle while start() runs, so the listener has to be + // in place before the native call, never after it. + it('subscribes before native start() is called', async () => { + const order: string[] = [] + emitterSpy.onAdd(() => order.push('subscribed')) + mockNative.start = jest.fn().mockImplementation(async () => { + order.push('start') + return true + }) + + await PurchaselyBuilder.apiKey('api-key') + .webRedemptionListener(() => {}) + .start() + + expect(order).toEqual(['subscribed', 'start']) + }) + + // Reported on the pull request: the modifier used to subscribe on the + // spot and drop the handle, so two calls left two live subscriptions + // and one redemption invoked both callbacks. + it('the last listener replaces the previous one instead of stacking', async () => { + const first = jest.fn() + const replacement = jest.fn() + + await PurchaselyBuilder.apiKey('api-key') + .webRedemptionListener(first) + .webRedemptionListener(replacement) + .start() + + const subscribed = emitterSpy.addListener.mock.calls + .filter((c: unknown[]) => c[0] === 'WEB_REDEMPTION_LISTENER') + .map((c: unknown[]) => c[1]) + expect(subscribed).toEqual([replacement]) + expect(subscribed).not.toContain(first) + }) + + it('replaces a listener registered by an earlier chain', async () => { + const first = jest.fn() + await PurchaselyBuilder.apiKey('api-key') + .webRedemptionListener(first) + .start() + const firstSubscription = emitterSpy.subscriptions[0] + expect(firstSubscription).toBeDefined() + + const replacement = jest.fn() + mockNative.start = jest.fn().mockResolvedValue(true) + await PurchaselyBuilder.apiKey('api-key') + .webRedemptionListener(replacement) + .start() + + expect(firstSubscription?.remove).toHaveBeenCalled() + }) + + // Also reported: subscribing at chain time meant an abandoned builder + // still received events forever. + it('subscribes nothing when the builder is never started', () => { + PurchaselyBuilder.apiKey('api-key').webRedemptionListener(jest.fn()) + + expect(emitterSpy.addListener).not.toHaveBeenCalled() + }) + + // Reported on the pull request. React Native's removeAllListeners goes + // straight to RCTDeviceEventEmitter and settles the native count + // itself, so a stale per-subscription remove() would send a second + // removeListeners(1). On iOS that can drive _listenerCount to zero and + // trigger stopObserving, which silences every event the module sends. + it('drops the chain handle when the listener is removed, so no stale remove fires', async () => { + await PurchaselyBuilder.apiKey('api-key') + .webRedemptionListener(jest.fn()) + .start() + const firstSubscription = emitterSpy.subscriptions[0] + expect(firstSubscription).toBeDefined() + + removeWebRedemptionListener() + + mockNative.start = jest.fn().mockResolvedValue(true) + await PurchaselyBuilder.apiKey('api-key') + .webRedemptionListener(jest.fn()) + .start() + + expect(firstSubscription?.remove).not.toHaveBeenCalled() + }) + + it('sets appHandlesRedemptionAlert from the optional second argument', async () => { + await PurchaselyBuilder.apiKey('api-key') + .webRedemptionListener(() => {}, true) + .start() + + expect(mockNative.start.mock.calls[0][7]).toEqual({ + appHandlesRedemptionAlert: true, + }) + }) + + it('leaves appHandlesRedemptionAlert unset when the second argument is omitted', async () => { + await PurchaselyBuilder.apiKey('api-key') + .webRedemptionListener(() => {}) + .start() + + expect(mockNative.start.mock.calls[0][7]).toEqual({}) + }) + }) + + describe('appHandlesRedemptionAlert() 6.1.0', () => { + it('forwards true through startOptions', async () => { + await PurchaselyBuilder.apiKey('api-key').appHandlesRedemptionAlert(true).start() + expect(mockNative.start.mock.calls[0][7]).toEqual({ + appHandlesRedemptionAlert: true, + }) + }) + + it('forwards an explicit false through startOptions', async () => { + await PurchaselyBuilder.apiKey('api-key').appHandlesRedemptionAlert(false).start() + expect(mockNative.start.mock.calls[0][7]).toEqual({ + appHandlesRedemptionAlert: false, + }) + }) + + it('omits the key when the modifier is never called', async () => { + await PurchaselyBuilder.apiKey('api-key').start() + expect(mockNative.start.mock.calls[0][7]).toEqual({}) + }) + }) + + describe('the 6.1.0 options travel in the same atomic startOptions map', () => { + it('carries every modifier in one start() call', async () => { + await PurchaselyBuilder.apiKey('api-key') + .allowDeeplink(false) + .anonymousUserId('3f2504e0-4f89-11d3-9a0c-0305e82c3301', true) + .proxy('https://svc.purchasely.io') + .appHandlesRedemptionAlert(true) + .start() + + expect(mockNative.start).toHaveBeenCalledTimes(1) + expect(mockNative.start.mock.calls[0][7]).toEqual({ + allowDeeplink: false, + anonymousUserId: '3f2504e0-4f89-11d3-9a0c-0305e82c3301', + anonymousUserIdOverride: true, + proxy: 'https://svc.purchasely.io', + appHandlesRedemptionAlert: true, + }) + }) + }) + describe('handleDeeplink() — cold-start replay', () => { it('replays the deeplink through native.handleDeeplink after start() resolves', async () => { const callOrder: string[] = [] @@ -212,7 +484,7 @@ describe('PurchaselyBuilder', () => { it('uses the static bridgeVersion by default', async () => { await PurchaselyBuilder.apiKey('api-key').start() - expect(mockNative.start.mock.calls[0][6]).toBe('6.0.0') + expect(mockNative.start.mock.calls[0][6]).toBe('6.1.0') }) it('overrides the bridge version with the sdkVersion argument when provided', async () => { diff --git a/packages/purchasely/src/__tests__/types.test.ts b/packages/purchasely/src/__tests__/types.test.ts index f5501c4f..ea82ac68 100644 --- a/packages/purchasely/src/__tests__/types.test.ts +++ b/packages/purchasely/src/__tests__/types.test.ts @@ -29,6 +29,8 @@ import type { PLYCommitmentInfo, PLYCommitmentProgress, PLYBillingPlanType, + PLYEventPropertyRedemption, + PLYWebRedemptionResult, } from '../types' import type { PLYPurchasePayload } from '../presentationTypes' @@ -542,6 +544,185 @@ describe('Purchasely Types', () => { ) expect(properties.stripe_purchase_id).toBe('pi_test_123') }) + + // Wire shape verified against iOS `RedemptionOutcome.swift` and the + // Android `RedemptionProperties` serializer, which the Android + // `PLYEventPropertiesRedemptionJsonRegressionTest` pins byte for byte. + it('should accept the REDEMPTION_CONSUMED payload', () => { + const event: PLYEvent = { + name: 'REDEMPTION_CONSUMED', + properties: { + sdk_version: '6.1.0', + event_name: 'REDEMPTION_CONSUMED', + event_created_at_ms: 1705315200000, + event_created_at: '2024-01-15T12:00:00Z', + redemption: { + token: 'redemption-token-123', + receipt: { + id: 'receipt-123', + validation_status: 'COMPLETED', + }, + subscriptions: [ + { + public_id: 'subs-123', + plan_id: 'plan-123', + store_type: 'APPLE_APP_STORE', + subscription_status: 'ACTIVE', + environment: 'PROD', + }, + ], + purchase_context: { + version: 1, + source: 'web', + sandbox: false, + replay: false, + built_in_attributes: [ + { key: 'firebase_app_instance_id', type: 'string', value: 'abc' }, + ], + custom_attributes: [{ key: 'plan', type: 'string', value: 'gold' }], + }, + }, + }, + } + + expect(event.properties.redemption?.receipt?.validation_status).toBe('COMPLETED') + expect(event.properties.redemption?.subscriptions).toHaveLength(1) + expect(event.properties.redemption?.purchase_context?.replay).toBe(false) + }) + + it('should accept the REDEMPTION_FAILED payload, with error_message at the top level', () => { + const event: PLYEvent = { + name: 'REDEMPTION_FAILED', + properties: { + sdk_version: '6.1.0', + event_name: 'REDEMPTION_FAILED', + event_created_at_ms: 1705315200000, + event_created_at: '2024-01-15T12:00:00Z', + redemption: { + token: 'redemption-token-123', + error_code: 'EXPIRED_REDEMPTION_TOKEN', + }, + error_message: 'Redemption link has expired.', + }, + } + + expect(event.properties.redemption?.error_code).toBe('EXPIRED_REDEMPTION_TOKEN') + expect(event.properties.error_message).toBe('Redemption link has expired.') + }) + + // A transport or parsing failure never reaches the server, so it + // carries no code. + it('should accept a REDEMPTION_FAILED payload without an error code', () => { + const redemption: PLYEventPropertyRedemption = { + token: 'redemption-token-123', + } + + expect(redemption.error_code).toBeUndefined() + }) + }) + + describe('PLYWebRedemptionResult', () => { + it('should accept a success that granted a subscription', () => { + const result: PLYWebRedemptionResult = { + isSuccess: true, + context: { + subscription: { + purchaseToken: 'token-123', + subscriptionSource: SubscriptionSource.APPLE_APP_STORE, + nextRenewalDate: '2024-02-15T12:00:00Z', + // Android reports an explicit null here, iOS omits the + // key. Never an empty string. + cancelledDate: null, + plan: { + vendorId: 'monthly-plan', + productId: 'premium-product', + name: 'Monthly', + type: PlanType.PLAN_TYPE_AUTO_RENEWING_SUBSCRIPTION, + amount: 999, + localizedAmount: '$9.99', + currencyCode: 'USD', + currencySymbol: '$', + price: '$9.99/month', + period: 'P1M', + hasIntroductoryPrice: false, + introPrice: '', + introAmount: 0, + introDuration: '', + introPeriod: '', + hasFreeTrial: false, + hasOfferPrice: false, + offerPrice: '', + offerAmount: 0, + offerDuration: '', + offerPeriod: '', + }, + product: { + name: 'Premium', + vendorId: 'premium-product', + plans: [], + }, + }, + }, + replay: false, + errorCode: null, + errorMessage: null, + } + + expect(result.context?.subscription?.purchaseToken).toBe('token-123') + }) + + // Both levels are nullable on both platforms: a 200 can describe + // nothing, and a present context can hold no subscription. + it('should accept a success with a null context', () => { + const result: PLYWebRedemptionResult = { + isSuccess: true, + context: null, + replay: true, + errorCode: null, + errorMessage: null, + } + + expect(result.context).toBeNull() + expect(result.replay).toBe(true) + }) + + it('should accept a success whose context holds a null subscription', () => { + const result: PLYWebRedemptionResult = { + isSuccess: true, + context: { subscription: null }, + replay: false, + errorCode: null, + errorMessage: null, + } + + expect(result.context?.subscription).toBeNull() + }) + + it('should accept a failure, with replay false and no context', () => { + const result: PLYWebRedemptionResult = { + isSuccess: false, + context: null, + replay: false, + errorCode: 'INVALID_REDEMPTION_TOKEN', + errorMessage: 'Redemption link is not valid.', + } + + expect(result.errorCode).toBe('INVALID_REDEMPTION_TOKEN') + expect(result.replay).toBe(false) + }) + + // A transport failure never reaches the server, so it carries no code. + it('should accept a failure with a null error code', () => { + const result: PLYWebRedemptionResult = { + isSuccess: false, + context: null, + replay: false, + errorCode: null, + errorMessage: 'Redemption could not be completed.', + } + + expect(result.errorCode).toBeNull() + }) }) describe('PLYPresentationPlan', () => { @@ -672,9 +853,12 @@ describe('Purchasely Event Names', () => { 'USER_LOGGED_IN', 'USER_LOGGED_OUT', 'SUBSCRIPTION_CONTENT_USED', + // New on both native platforms in 6.1.0 (Web2App redemption). + 'REDEMPTION_CONSUMED', + 'REDEMPTION_FAILED', ] - expect(eventNames).toHaveLength(42) + expect(eventNames).toHaveLength(44) eventNames.forEach(name => { expect(typeof name).toBe('string') }) diff --git a/packages/purchasely/src/enums.ts b/packages/purchasely/src/enums.ts index bf477b9b..b32f1e7b 100644 --- a/packages/purchasely/src/enums.ts +++ b/packages/purchasely/src/enums.ts @@ -21,6 +21,11 @@ export enum SubscriptionSource { GOOGLE_PLAY_STORE = constants.sourcePlayStore, HUAWEI_APP_GALLERY = constants.sourceHuaweiAppGallery, AMAZON_APPSTORE = constants.sourceAmazonAppstore, + /** + * A subscription bought through web checkout. Web2App redemption grants + * subscriptions from this source, so a redeemed subscription reports it. + */ + WEB_CHECKOUT_STRIPE = constants.sourceStripe, NONE = constants.sourceNone, } diff --git a/packages/purchasely/src/index.ts b/packages/purchasely/src/index.ts index c4939f6b..60033f01 100644 --- a/packages/purchasely/src/index.ts +++ b/packages/purchasely/src/index.ts @@ -30,6 +30,10 @@ import { removeDefaultPresentationDismissHandler, } from './presentation'; import { PurchaselyBuilder } from './startBuilder'; +import { + addWebRedemptionListener, + removeWebRedemptionListener, +} from './redemption'; import { interceptAction, removeActionInterceptor, @@ -40,7 +44,7 @@ import type { PLYPresentationActionKind, } from './presentationTypes'; -const purchaselyVersion = '6.0.0'; +const purchaselyVersion = '6.1.0'; const PurchaselyEventEmitter = new NativeEventEmitter(NativeModules.Purchasely); @@ -543,6 +547,8 @@ const Purchasely = { removeUserAttributeRemovedListener, setUserAttributeListener, clearUserAttributeListener, + addWebRedemptionListener, + removeWebRedemptionListener, purchaseWithPlanVendorId, setUserAttributeWithDate, signPromotionalOffer, @@ -617,6 +623,11 @@ export { removeAllActionInterceptors, } from './interceptor'; export { PurchaselyBuilder } from './startBuilder'; +export { + addWebRedemptionListener, + removeWebRedemptionListener, +} from './redemption'; +export type { WebRedemptionListenerCallback } from './redemption'; export { PLYPresentationView }; export default Purchasely; diff --git a/packages/purchasely/src/interfaces.ts b/packages/purchasely/src/interfaces.ts index b2c9f7e7..a69ebb3f 100644 --- a/packages/purchasely/src/interfaces.ts +++ b/packages/purchasely/src/interfaces.ts @@ -13,6 +13,7 @@ export interface Constants { sourcePlayStore: number; sourceHuaweiAppGallery: number; sourceAmazonAppstore: number; + sourceStripe: number; sourceNone: number; firebaseAppInstanceId: number; airshipChannelId: number; diff --git a/packages/purchasely/src/redemption.ts b/packages/purchasely/src/redemption.ts new file mode 100644 index 00000000..c6a5536d --- /dev/null +++ b/packages/purchasely/src/redemption.ts @@ -0,0 +1,127 @@ +import { NativeEventEmitter, NativeModules } from 'react-native'; +import type { EmitterSubscription } from 'react-native'; + +import type { PLYWebRedemptionResult } from './types'; + +/** + * Emitter for the Web2App redemption event. + * + * Kept in its own module so `startBuilder.ts` can subscribe a listener from + * the start chain without importing `index.ts`, which would be a cycle. + * + * @internal + */ +let redemptionEventEmitter: NativeEventEmitter | undefined; + +/** + * Constructed on first use, not at import time. `startBuilder.ts` imports + * this module, so an emitter built at module load would be created by every + * consumer of the builder, whether or not the app listens for a redemption. + */ +const emitter = (): NativeEventEmitter => { + if (redemptionEventEmitter === undefined) { + redemptionEventEmitter = new NativeEventEmitter( + NativeModules.Purchasely + ); + } + return redemptionEventEmitter; +}; + +/** + * The chain-owned subscription, if any. + * + * The builder owns at most one listener. Keeping its handle here is what lets + * a later `webRedemptionListener(...)` replace an earlier one instead of + * stacking a second live subscription on the same event. + * + * @internal + */ +let builderSubscription: EmitterSubscription | undefined; + +/** @internal */ +export const WEB_REDEMPTION_EVENT = 'WEB_REDEMPTION_LISTENER'; + +export type WebRedemptionListenerCallback = ( + result: PLYWebRedemptionResult +) => void; + +/** + * Listen to the outcome of a Web2App redemption + * (`{scheme}://ply/redeem/{token}`). + * + * Prefer `Purchasely.builder(key).webRedemptionListener(cb)`, which subscribes + * the callback before `start()` runs and cannot be ordered wrongly. Use this + * function when the app must add or replace the listener after the SDK + * started, and accept that a redemption which settles during `start()` is + * then missed. + * + * The SDK calls the listener on the main thread, exactly once per settled + * redemption, on success and on failure alike. + * + * The `appHandlesRedemptionAlert` start option decides *when*: + * + * - `false` (the default): the SDK shows its own popin and calls the listener + * after the user acknowledges it. + * - `true`: the SDK shows nothing and calls the listener as soon as the + * redemption settles. The app must then show its own result screen. + * + * Three more behaviours to know: + * + * - `result.replay` is `true` when the **server** reports that the token was + * redeemed before. The SDK keeps no cache and calls the server on every + * attempt, so this is a verdict about the token, not an observation of the + * user. + * - A redemption deeplink is **not** subject to `allowDeeplink`. The native + * SDK intercepts `ply/redeem` out of band, before the routing branch that + * the gate sits behind. + * - **On both platforms**, `result.errorMessage` for an expired link can + * contain a masked email address, so the app can tell the user where the + * fresh link went. That is personal data. The `REDEMPTION_FAILED` event + * drops it on iOS and on Android alike. Show that text to the user. Do not + * forward it to an analytics stack or to a crash reporter, and do not gate + * that rule on `Platform.OS`. + */ +export const addWebRedemptionListener = ( + callback: WebRedemptionListenerCallback +) => { + return emitter().addListener(WEB_REDEMPTION_EVENT, callback); +}; + +/** + * Remove every listener on the redemption event, whoever added it. + * + * The chain-owned handle is dropped as well. It has to be: React Native's + * `removeAllListeners` goes straight to `RCTDeviceEventEmitter` and settles + * the native count itself, while the per-subscription `remove()` closure is + * left believing it still owns a listener. Calling that stale `remove()` + * later would send a second `removeListeners(1)` for a listener already + * accounted for. + * + * On iOS that is not a harmless miscount. `RCTEventEmitter` does + * `_listenerCount = MAX(_listenerCount - count, 0)` and calls `stopObserving` + * the moment the count reaches zero, and `stopObserving` clears the bridge's + * `shouldEmit` flag, which gates EVERY event the module sends. One extra + * decrement can therefore silence analytics and the presentation lifecycle + * while their listeners are still registered. + */ +export const removeWebRedemptionListener = () => { + builderSubscription = undefined; + return emitter().removeAllListeners(WEB_REDEMPTION_EVENT); +}; + +/** + * Register the listener that `PurchaselyBuilder.webRedemptionListener(...)` + * carries, replacing the one a previous chain registered. + * + * Only the chain-owned subscription is removed. A listener the app added with + * {@link addWebRedemptionListener} is left alone, because that is a separate, + * app-owned registration with its own lifetime. + * + * @internal + */ +export const setBuilderWebRedemptionListener = ( + callback: WebRedemptionListenerCallback +): void => { + builderSubscription?.remove(); + builderSubscription = addWebRedemptionListener(callback); +}; diff --git a/packages/purchasely/src/startBuilder.ts b/packages/purchasely/src/startBuilder.ts index 08222fd3..e345f5ad 100644 --- a/packages/purchasely/src/startBuilder.ts +++ b/packages/purchasely/src/startBuilder.ts @@ -1,6 +1,10 @@ import { NativeModules } from 'react-native'; import { LogLevels, RunningMode } from './enums'; +import { + setBuilderWebRedemptionListener, + type WebRedemptionListenerCallback, +} from './redemption'; type LogLevelString = 'debug' | 'info' | 'warn' | 'error'; type RunningModeString = 'observer' | 'full'; @@ -28,6 +32,16 @@ interface StartBuilderState { allowCampaigns?: boolean | null; automaticDeeplinkHandling?: boolean | null; deeplink?: string | null; + anonymousUserId?: string | null; + anonymousUserIdOverride?: boolean | null; + /** + * Tri-state: `undefined` means the modifier was never called, so neither + * native SDK touches its current setting. `null` means clear the proxy. + * A string means set it. + */ + proxyApi?: string | null; + appHandlesRedemptionAlert?: boolean | null; + webRedemptionCallback?: WebRedemptionListenerCallback; androidStores: AndroidStore[]; storekitVersion: StorekitVersion; } @@ -51,7 +65,7 @@ export class PurchaselyBuilder { * * @internal */ - static bridgeVersion = '6.0.0'; + static bridgeVersion = '6.1.0'; private constructor(private readonly state: StartBuilderState) {} @@ -113,6 +127,132 @@ export class PurchaselyBuilder { return this; } + /** + * Set the anonymous user id that the SDK reports for this device. + * + * `id` must be a canonical UUID string, for example + * `'3f2504e0-4f89-11d3-9a0c-0305e82c3301'`. JavaScript has no UUID type, + * so the native bridge parses the string. The bridge logs an error and + * skips the modifier when the string is not a canonical UUID. The SDK + * still starts. + * + * The SDK stores the id in **uppercase**, on iOS and on Android. + * + * The SDK applies the id at `start()`, before it sends a network request + * or an event. The SDK applies the id only when the device holds no + * anonymous id yet, unless `override` is `true`. + * + * **`override: true` splits the user history.** The backend keeps every + * event and every purchase under the previous id. Use `override: true` + * only when the app owns the anonymous identity, for example after a + * cross-device restore. + * + * @param id A canonical UUID string. + * @param override `false` (the default) keeps an id that the SDK + * established before. `true` replaces it. + */ + anonymousUserId(id: string, override: boolean = false): this { + this.state.anonymousUserId = id; + this.state.anonymousUserIdOverride = override; + return this; + } + + /** + * Route Purchasely API traffic through a proxy instead of + * `api.purchasely.io`, for a region where that host is unreachable, such + * as mainland China. The SDK overrides the API host only: the paywall + * host and the tracking host always stay on production. + * + * Purchasely operates a proxy at `https://svc.purchasely.io`. You can + * also host your own. + * + * `api` must be an `https` base URL with a host, and it must carry no + * query, no fragment and no credentials. The native SDK refuses any + * other value with an error log and keeps the production host, so the + * bridge does not validate the value again. Each native SDK drops a + * trailing slash. + * + * This is a start-time option. Neither native SDK has a runtime setter + * for it. + * + * Pass `null` to clear the proxy and return to `api.purchasely.io`. A + * chain that never calls this modifier leaves the current setting + * untouched on both platforms. + * + * @param api The `https` base URL of the API proxy, or `null` for no + * proxy. + */ + proxy(api: string | null): this { + this.state.proxyApi = api; + return this; + } + + /** + * Set the listener notified when a Web2App redemption + * (`{scheme}://ply/redeem/{token}`) settles. + * + * This mirrors the native chains, `webRedemptionDelegate(_:)` on iOS and + * `webRedemptionListener(_)` on Android. The callback stays in + * JavaScript: the native bridge registers itself as the delegate and + * forwards each outcome as an event, so nothing has to cross the bridge + * as a function. + * + * Prefer this over `Purchasely.addWebRedemptionListener`. Subscribing + * from the chain guarantees the listener exists before `start()` runs, + * which is the one ordering an app cannot get wrong here: a redemption + * can settle during `start()`, from a cold start that the link itself + * triggered, or from a token that a previous launch left pending. + * + * ```ts + * await Purchasely.builder('API_KEY') + * .webRedemptionListener((result) => { + * if (result.isSuccess) unlock(result.context?.subscription) + * }, true) + * .start() + * ``` + * + * @param callback Called on the main thread, exactly once per settled + * redemption, on success and on failure alike. + * @param appHandlesRedemptionAlert Optional shorthand for + * {@link appHandlesRedemptionAlert}. Omit it to keep the SDK popin. + */ + webRedemptionListener( + callback: WebRedemptionListenerCallback, + appHandlesRedemptionAlert?: boolean + ): this { + // Stored, not subscribed here. Subscribing on the spot would leak a + // live subscription from a builder that is never started, and would + // stack a second listener when the modifier is called twice. The + // subscription happens in start(), immediately before the native + // call, which still guarantees the listener exists for a redemption + // that settles while start() runs. + this.state.webRedemptionCallback = callback; + if (appHandlesRedemptionAlert !== undefined) { + this.state.appHandlesRedemptionAlert = appHandlesRedemptionAlert; + } + return this; + } + + /** + * Hand the Web2App redemption result screen to the app. + * + * This flag decides who shows the outcome of a redemption, and with it + * when the SDK calls the listener that you add with + * `Purchasely.addWebRedemptionListener`: + * + * - `false` (the default): the SDK shows its own popin and calls the + * listener after the user acknowledges the popin. + * - `true`: the SDK shows nothing and calls the listener as soon as the + * redemption settles. The app must then show its own result screen. + * + * This is a start-time option because it changes what the native SDK + * presents. Set it before `start()`. + */ + appHandlesRedemptionAlert(handles: boolean): this { + this.state.appHandlesRedemptionAlert = handles; + return this; + } + /** Android-only. */ stores(stores: AndroidStore[]): this { this.state.androidStores = stores; @@ -152,7 +292,7 @@ export class PurchaselyBuilder { // window where a campaign/deeplink can fire against the wrong default. // Omitted options are intentionally absent so native defaults match // Flutter v6. - const startOptions: Record = {}; + const startOptions: Record = {}; if (this.state.allowDeeplink !== undefined && this.state.allowDeeplink !== null) { startOptions.allowDeeplink = this.state.allowDeeplink; } @@ -165,6 +305,31 @@ export class PurchaselyBuilder { ) { startOptions.automaticDeeplinkHandling = this.state.automaticDeeplinkHandling; } + // The bridge parses `anonymousUserId` into a native UUID. An invalid + // string is rejected there, with a log, and start() still succeeds. + if (this.state.anonymousUserId !== undefined && this.state.anonymousUserId !== null) { + startOptions.anonymousUserId = this.state.anonymousUserId; + startOptions.anonymousUserIdOverride = this.state.anonymousUserIdOverride ?? false; + } + // `null` is forwarded on purpose: it is the documented way to clear a + // proxy on both natives. Only `undefined` (never called) omits the + // key, which leaves each SDK's current setting untouched. + if (this.state.proxyApi !== undefined) { + startOptions.proxy = this.state.proxyApi; + } + if ( + this.state.appHandlesRedemptionAlert !== undefined && + this.state.appHandlesRedemptionAlert !== null + ) { + startOptions.appHandlesRedemptionAlert = this.state.appHandlesRedemptionAlert; + } + + // Subscribed before the native start() call, never after: a redemption + // can settle during start(), and this is the last point at which the + // listener is guaranteed to be in place for it. + if (this.state.webRedemptionCallback !== undefined) { + setBuilderWebRedemptionListener(this.state.webRedemptionCallback); + } const configured: boolean = await NativeModules.Purchasely.start( this.state.apiKey, diff --git a/packages/purchasely/src/types.ts b/packages/purchasely/src/types.ts index dacd0fe2..796fa39b 100644 --- a/packages/purchasely/src/types.ts +++ b/packages/purchasely/src/types.ts @@ -122,10 +122,28 @@ export type PLYUserAttribute = { }; export type PLYSubscription = { - purchaseToken: string; + /** + * Android-only. The native iOS `PLYSubscription` has no purchase token + * property, so the iOS bridge + * (`PLYSubscription+Hybrid.m asDictionary`) cannot emit this key and never + * did. + * + * Nullable AND optional, because the platforms disagree on how they report + * the absence. iOS omits the key, so a caller sees `undefined`. Android's + * `PLYSubscription.toMap()` assigns the key unconditionally from a nullable + * field, so a caller sees an explicit `null`. Same reasoning as + * {@link cumulatedRevenuesInUSD}. + */ + purchaseToken?: string | null; subscriptionSource: SubscriptionSource; - nextRenewalDate: string; - cancelledDate: string; + /** + * Absent when the subscription has no renewal date. The iOS bridge omits + * the key when the native date is `nil`; Android reports an explicit + * `null`. Never read it as an empty string. + */ + nextRenewalDate?: string | null; + /** Absent or null when the subscription is not cancelled. See {@link nextRenewalDate}. */ + cancelledDate?: string | null; plan: PLYPlan; product: PLYProduct; /** @@ -199,7 +217,20 @@ export type PLYEventName = | 'WEB_CHECKOUT_OPENED_IN_WEB_BROWSER' | 'WEB_CHECKOUT_ERROR' | 'WEB_CHECKOUT_TAPPED' - | 'WEB_CHECKOUT_TIMED_OUT'; + | 'WEB_CHECKOUT_TIMED_OUT' + /** + * A Web2App redemption granted its content. New in 6.1.0 on both native + * platforms. A replayed link reports this event too. Read + * `properties.redemption.purchase_context.replay` to tell a first + * redemption from a repeat. + */ + | 'REDEMPTION_CONSUMED' + /** + * A Web2App redemption failed. New in 6.1.0 on both native platforms. + * Read `properties.redemption.error_code` and + * `properties.error_message`. + */ + | 'REDEMPTION_FAILED'; export type PLYEventPropertyPlan = { type?: string; @@ -236,6 +267,77 @@ export type PLYEventPropertySubscription = { product?: string; }; +/** The receipt a redemption validated. */ +export type PLYEventPropertyRedemptionReceipt = { + id?: string; + /** Uppercase, e.g. `'COMPLETED'`. */ + validation_status?: string; +}; + +/** + * One subscription a redemption transferred, as `REDEMPTION_CONSUMED` reports + * it. The SDK reports active subscriptions and non-consumables only. An + * expired subscription is absent: a redemption grants, it does not report + * history. + */ +export type PLYEventPropertyRedemptionSubscription = { + public_id?: string; + plan_id?: string; + store_type?: string; + subscription_status?: string; + environment?: string; +}; + +/** + * One attribute a redemption restored. `value` stays the JSON the backend + * sent, so the event reports it exactly as `type` declares it. + */ +export type PLYEventPropertyRedemptionAttribute = { + key?: string; + type?: string; + value?: any; +}; + +/** + * The web journey behind a redemption. The SDK reports what it applied, not + * the raw response: a block the SDK does not consume is absent here too. + */ +export type PLYEventPropertyRedemptionPurchaseContext = { + version?: number; + source?: string; + sandbox?: boolean; + /** `true` when the same redemption link is consumed again. */ + replay?: boolean; + built_in_attributes?: PLYEventPropertyRedemptionAttribute[]; + custom_attributes?: PLYEventPropertyRedemptionAttribute[]; +}; + +/** + * What a Web2App redemption reports. `REDEMPTION_CONSUMED` carries `token`, + * `receipt`, `subscriptions` and `purchase_context`. `REDEMPTION_FAILED` + * carries `token` and `error_code`, with the reason in the top-level + * `error_message`. + * + * The masked email hint of an expired link never reaches this event, on + * either platform. The SDK gives that hint to the web redemption listener + * only. See `Purchasely.addWebRedemptionListener`. + * + * Every field is optional: the SDK omits a key it has no value for. + */ +export type PLYEventPropertyRedemption = { + /** The redemption link token this event reports on. */ + token?: string; + receipt?: PLYEventPropertyRedemptionReceipt; + subscriptions?: PLYEventPropertyRedemptionSubscription[]; + purchase_context?: PLYEventPropertyRedemptionPurchaseContext; + /** + * Backend error code, on `REDEMPTION_FAILED` only. Known values: + * `'EXPIRED_REDEMPTION_TOKEN'`, `'INVALID_REDEMPTION_TOKEN'`. A transport + * failure or a parsing failure carries no code. + */ + error_code?: string; +}; + export type PLYEvent = { name: PLYEventName; properties: PLYEventProperties; @@ -321,6 +423,63 @@ export type PLYEventProperties = { client_reference_id?: string; stripe_checkout_session_id?: string; stripe_purchase_id?: string; + /** Set on `REDEMPTION_CONSUMED` and `REDEMPTION_FAILED`. New in 6.1.0. */ + redemption?: PLYEventPropertyRedemption; +}; + +/** + * What a Web2App redemption granted. + * + * Both levels are nullable. `context` is null when the server's 200 response + * carried nothing to describe. A present `context` can still hold a null + * `subscription`: the receipt validated and the SDK refreshed the + * entitlements, but the response carried no subscription, or the products + * behind it are not loaded yet. Both cases stay a success. Call + * `Purchasely.userSubscriptions()` from the listener for the full picture. + */ +export type PLYWebRedemptionContext = { + subscription: PLYSubscription | null; +}; + +/** + * Outcome of one Web2App redemption, delivered to the listener you add with + * `Purchasely.addWebRedemptionListener`. + * + * Read `isSuccess` first: it decides which fields hold a value. The shape is + * flat because it mirrors the native iOS `PLYWebRedemptionResult` and the + * Android `PLYWebRedemptionResult` sealed class through one bridge event. + */ +export type PLYWebRedemptionResult = { + /** `true` for a granted redemption, `false` for a failed one. */ + isSuccess: boolean; + /** Null on failure, and nullable on success. See {@link PLYWebRedemptionContext}. */ + context: PLYWebRedemptionContext | null; + /** + * `true` when the server reports that the token was redeemed before. The + * SDK keeps no cache and calls the server on every attempt, so this is a + * verdict about the token, not an observation of the user. Always `false` + * on failure. + */ + replay: boolean; + /** + * Backend error code. Null on success, and null on a failure that never + * reached the server. Known values: `'EXPIRED_REDEMPTION_TOKEN'`, + * `'INVALID_REDEMPTION_TOKEN'`. + */ + errorCode: string | null; + /** + * Human-readable reason, in English. Null on success. It never contains the + * token. + * + * **On both platforms**, an expired link puts the backend's masked email + * hint here, for example `'A new link was sent to j***@example.com.'`, so + * the app can tell the user where the fresh link went. That hint is + * personal data. The `REDEMPTION_FAILED` event drops it on purpose, on iOS + * and on Android alike. Show this text to the user. Do not send it to an + * analytics stack or to a crash reporter, and do not gate that rule on the + * platform. + */ + errorMessage: string | null; }; /** diff --git a/sdk_public_doc.md b/sdk_public_doc.md index 52e1fb61..6f1565fa 100644 --- a/sdk_public_doc.md +++ b/sdk_public_doc.md @@ -222,6 +222,150 @@ try { } ``` +### Anonymous user id (6.1.0) + +Set the anonymous user id that the SDK reports for this device. + +```typescript +await Purchasely.builder('YOUR_API_KEY') + .anonymousUserId('3f2504e0-4f89-11d3-9a0c-0305e82c3301') + .start(); +``` + +`id` must be a canonical UUID string. JavaScript has no UUID type, so the +native bridge parses the string. The bridge logs an error and skips the option +when the string is not a canonical UUID. The SDK still starts. + +The SDK stores the id in uppercase. The SDK applies the id only when the device +holds no anonymous id yet. Pass `true` as the second argument to replace an +existing id: + +```typescript +.anonymousUserId('3f2504e0-4f89-11d3-9a0c-0305e82c3301', true) +``` + +**`override: true` splits the user history.** The backend keeps every event and +every purchase under the previous id. Use `true` only when your app owns the +anonymous identity, for example after a cross-device restore. + +### API proxy (6.1.0) + +Route Purchasely API traffic through a proxy instead of `api.purchasely.io`, +for a region where that host is unreachable, such as mainland China. + +```typescript +await Purchasely.builder('YOUR_API_KEY') + .proxy('https://svc.purchasely.io') + .start(); +``` + +Purchasely operates a proxy at `https://svc.purchasely.io`. You can also host +your own. + +The SDK overrides the API host only. The paywall host and the tracking host +always stay on production. `api` must be an `https` base URL with a host, and +it must carry no query, no fragment and no credentials. The native SDK refuses +any other value with an error log and keeps the production host. + +Pass `null` to clear the proxy and return to `api.purchasely.io`: + +```typescript +await Purchasely.builder('YOUR_API_KEY') + .proxy(null) + .start(); +``` + +The three states differ: + +| Call | Effect | +|------|--------| +| `.proxy('https://...')` | Routes the API host through the proxy | +| `.proxy(null)` | Clears the proxy, back to `api.purchasely.io` | +| never called | Leaves the current setting untouched | + +This is a start-time option on both platforms. Neither native SDK has a +runtime setter for it. + +### Web2App redemption (6.1.0) + +Listen to the outcome of a Web2App redemption +(`{scheme}://ply/redeem/{token}`). + +Set the listener on the start chain, the same way the native SDKs do: + +```typescript +import Purchasely from 'react-native-purchasely'; + +await Purchasely.builder('YOUR_API_KEY') + .webRedemptionListener((result) => { + if (result.isSuccess) { + console.log('Redemption granted', result.context?.subscription); + if (result.replay) { + console.log('The server reports this token was redeemed before'); + } + } else { + console.log('Redemption failed', result.errorCode, result.errorMessage); + } + }) + .start(); +``` + +The second argument is a shorthand for `appHandlesRedemptionAlert`: + +```typescript +.webRedemptionListener(onRedemption, true) // the app shows the result screen +``` + +**Set the listener on the chain, not after `start()`.** A redemption can +settle during `start()`, from a cold start that the link itself triggered, or +from a token that a previous launch left pending. The chain form subscribes +the callback before `start()` runs, so that case cannot be missed. + +`Purchasely.addWebRedemptionListener(cb)` and +`Purchasely.removeWebRedemptionListener()` remain available for an app that +must add or replace the listener while the SDK is already running. A +redemption that settles during `start()` is then missed. + +The SDK calls the listener on the main thread, exactly once per settled +redemption, on success and on failure alike. + +`appHandlesRedemptionAlert` decides *when* the SDK calls the listener: + +| Value | The SDK shows | The SDK calls the listener | +|-------|---------------|----------------------------| +| `false` (default) | its own result popin | after the user acknowledges the popin | +| `true` | nothing | as soon as the redemption settles | + +Use `true` when your app shows its own result screen. + +The result has five fields: + +| Field | Description | +|-------|-------------| +| `isSuccess` | `true` for a granted redemption, `false` for a failed one | +| `context` | What the redemption granted, or `null`. `context.subscription` is separately nullable | +| `replay` | `true` when the server reports the token was redeemed before | +| `errorCode` | `'EXPIRED_REDEMPTION_TOKEN'`, `'INVALID_REDEMPTION_TOKEN'`, or `null` | +| `errorMessage` | Human-readable reason, or `null` | + +Three behaviours to know: + +- `replay` is a verdict about the **token**, not an observation of the user. + The SDK keeps no cache and calls the server on every attempt. +- A redemption deeplink is **not** subject to `allowDeeplink`. The native SDK + intercepts `ply/redeem` out of band, so a redemption still completes with + `allowDeeplink(false)`. +- **On both platforms**, `errorMessage` for an expired link can contain a + masked email address, so you can tell the user where the fresh link went. + That hint is personal data. Show it to the user. Do not send it to an + analytics stack or to a crash reporter, and do not make that rule + platform-specific. The `REDEMPTION_FAILED` event drops the hint on iOS and + on Android alike. + +The SDK also emits two analytics events for a redemption, +`REDEMPTION_CONSUMED` and `REDEMPTION_FAILED`. Read them with +`Purchasely.addEventListener`. + ### API Key You can find your API Key in the Purchasely Console under **App settings > Backend & SDK configuration**. @@ -522,6 +666,33 @@ try { } ``` +#### Nullable fields (6.1.0) + +`purchaseToken`, `nextRenewalDate` and `cancelledDate` are optional **and** +nullable. Guard them before use: + +```typescript +const token = subscriptions[0]?.purchaseToken ?? null; +``` + +The two platforms report an absent value differently. iOS omits the key, so you +read `undefined`. Android assigns the key from a nullable field, so you read an +explicit `null`. Never treat any of the three as an empty string. + +`purchaseToken` is Android-only: the native iOS `PLYSubscription` has no +purchase token property, so the iOS bridge cannot report one. + +#### `subscriptionSource` values + +| Value | Meaning | +|-------|---------| +| `APPLE_APP_STORE` | Bought on the App Store | +| `GOOGLE_PLAY_STORE` | Bought on Google Play | +| `HUAWEI_APP_GALLERY` | Bought on Huawei AppGallery | +| `AMAZON_APPSTORE` | Bought on the Amazon Appstore | +| `WEB_CHECKOUT_STRIPE` | Bought through web checkout. **New in 6.1.0.** A subscription granted by a Web2App redemption reports this source | +| `NONE` | No source | + > **Note**: There is a **few seconds delay** for `Purchasely.userSubscriptions()` to be updated after a purchase or restoration. If you rely on this method to get the current subscription status right after a purchase, you should **wait for 3 seconds** before calling this method. ---