From 6cee6ed8690309a59e2fce0d5801abc4b1ee1c89 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 3 Sep 2026 18:14:36 +0200 Subject: [PATCH 01/26] feat(types): add REDEMPTION_CONSUMED / REDEMPTION_FAILED and the redemption payload types --- packages/purchasely/src/types.ts | 141 ++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/packages/purchasely/src/types.ts b/packages/purchasely/src/types.ts index dacd0fe2..0568e6db 100644 --- a/packages/purchasely/src/types.ts +++ b/packages/purchasely/src/types.ts @@ -199,7 +199,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 +249,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. The SDK + * gives that hint to the web redemption listener only, on iOS. 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 +405,61 @@ 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 iOS only**, 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. The + * `REDEMPTION_FAILED` event drops that hint on purpose. Show this text to + * the user. Do not send it to an analytics stack or to a crash reporter. + */ + errorMessage: string | null; }; /** From 7ce24faefbda362542d821d904782be5ad97dabc Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 3 Sep 2026 18:15:45 +0200 Subject: [PATCH 02/26] feat(js): add anonymousUserId / proxy / appHandlesRedemptionAlert builder modifiers and the web redemption listener --- packages/purchasely/src/index.ts | 69 +++++++++++++++++- packages/purchasely/src/startBuilder.ts | 93 ++++++++++++++++++++++++- 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/packages/purchasely/src/index.ts b/packages/purchasely/src/index.ts index c4939f6b..23e42c41 100644 --- a/packages/purchasely/src/index.ts +++ b/packages/purchasely/src/index.ts @@ -23,6 +23,7 @@ import type { PLYPromotionalOfferSignature, PLYSubscription, PLYUserAttribute, + PLYWebRedemptionResult, } from './types'; import { PLYPresentationBuilder, @@ -40,7 +41,7 @@ import type { PLYPresentationActionKind, } from './presentationTypes'; -const purchaselyVersion = '6.0.0'; +const purchaselyVersion = '6.1.0'; const PurchaselyEventEmitter = new NativeEventEmitter(NativeModules.Purchasely); @@ -126,6 +127,70 @@ const removeUserAttributeRemovedListener = () => { ); }; +type WebRedemptionListenerCallback = ( + result: PLYWebRedemptionResult +) => void; + +/** + * Listen to the outcome of a Web2App redemption + * (`{scheme}://ply/redeem/{token}`). + * + * **Add the listener before `Purchasely.builder(...).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. A listener + * that you add after `start()` misses exactly the case it is most needed for. + * + * 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, so the app acts on a screen that the user + * already dismissed. + * - `true`: the SDK shows nothing and calls the listener as soon as the + * redemption settles. The app must then show its own result screen. + * + * Two 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 every time, + * 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. A redemption still completes with + * `allowDeeplink(false)`. + * - **On iOS only**, `result.errorMessage` for an expired link can contain a + * masked email address, so the app can tell the user where the fresh link + * went. The analytics event drops it. Show that text to the user. Do not + * forward it to an analytics stack or to a crash reporter. + * + * @example + * ```ts + * Purchasely.addWebRedemptionListener((result) => { + * if (result.isSuccess) { + * unlock(result.context?.subscription) + * } else { + * showError(result.errorCode, result.errorMessage) + * } + * }) + * await Purchasely.builder('API_KEY').appHandlesRedemptionAlert(true).start() + * ``` + */ +const addWebRedemptionListener = ( + callback: WebRedemptionListenerCallback +) => { + return PurchaselyEventEmitter.addListener( + 'WEB_REDEMPTION_LISTENER', + callback + ); +}; + +/** Remove every listener added with {@link addWebRedemptionListener}. */ +const removeWebRedemptionListener = () => { + return PurchaselyEventEmitter.removeAllListeners('WEB_REDEMPTION_LISTENER'); +}; + export interface UserAttributeListener { onUserAttributeSet?: ( key: string, @@ -543,6 +608,8 @@ const Purchasely = { removeUserAttributeRemovedListener, setUserAttributeListener, clearUserAttributeListener, + addWebRedemptionListener, + removeWebRedemptionListener, purchaseWithPlanVendorId, setUserAttributeWithDate, signPromotionalOffer, diff --git a/packages/purchasely/src/startBuilder.ts b/packages/purchasely/src/startBuilder.ts index 08222fd3..4600f21d 100644 --- a/packages/purchasely/src/startBuilder.ts +++ b/packages/purchasely/src/startBuilder.ts @@ -28,6 +28,10 @@ interface StartBuilderState { allowCampaigns?: boolean | null; automaticDeeplinkHandling?: boolean | null; deeplink?: string | null; + anonymousUserId?: string | null; + anonymousUserIdOverride?: boolean | null; + proxyApi?: string | null; + appHandlesRedemptionAlert?: boolean | null; androidStores: AndroidStore[]; storekitVersion: StorekitVersion; } @@ -39,6 +43,7 @@ interface StartBuilderState { * - `allowDeeplink` / `allowCampaigns` are optional chain modifiers. * When omitted we keep each native SDK's default/backend-configured value. * - `stores(...)` is Android-only. + * - `proxy(...)` is Android-only. * - `storekitVersion(...)` is iOS-only. * * The default running mode is `'observer'` — the host app keeps full @@ -51,7 +56,7 @@ export class PurchaselyBuilder { * * @internal */ - static bridgeVersion = '6.0.0'; + static bridgeVersion = '6.1.0'; private constructor(private readonly state: StartBuilderState) {} @@ -113,6 +118,75 @@ 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; + } + + /** + * Android-only. + * + * Route Purchasely API traffic through a proxy instead of + * `api.purchasely.io`, for a region where that host is unreachable. 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. 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. + * + * @param api The `https` base URL of the API proxy. + */ + proxy(api: string): this { + this.state.proxyApi = api; + 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 +226,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 +239,21 @@ 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; + } + if (this.state.proxyApi !== undefined && this.state.proxyApi !== null) { + startOptions.proxy = this.state.proxyApi; + } + if ( + this.state.appHandlesRedemptionAlert !== undefined && + this.state.appHandlesRedemptionAlert !== null + ) { + startOptions.appHandlesRedemptionAlert = this.state.appHandlesRedemptionAlert; + } const configured: boolean = await NativeModules.Purchasely.start( this.state.apiKey, From 36a364c19536102cf614c00e85ff27685c4ccb96 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 3 Sep 2026 18:16:52 +0200 Subject: [PATCH 03/26] feat(ios): bridge anonymousUserId and the web redemption delegate --- packages/purchasely/ios/PurchaselyRN.h | 2 +- packages/purchasely/ios/PurchaselyRN.m | 67 +++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/packages/purchasely/ios/PurchaselyRN.h b/packages/purchasely/ios/PurchaselyRN.h index bcecc6c0..0c7909a7 100644 --- a/packages/purchasely/ios/PurchaselyRN.h +++ b/packages/purchasely/ios/PurchaselyRN.h @@ -9,7 +9,7 @@ #import @import Purchasely; -@interface PurchaselyRN: RCTEventEmitter +@interface PurchaselyRN: RCTEventEmitter @property (nonatomic, retain) UIViewController* presentedPresentationViewController; diff --git a/packages/purchasely/ios/PurchaselyRN.m b/packages/purchasely/ios/PurchaselyRN.m index 4100d2cb..e17b4a7d 100644 --- a/packages/purchasely/ios/PurchaselyRN.m +++ b/packages/purchasely/ios/PurchaselyRN.m @@ -554,8 +554,10 @@ - (NSDictionary *)constantsToExport { // Applied on the builder chain — before `startWithInitialized:` — so these // take effect atomically with configuration, closing the race window a // 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. + // to fire against the wrong default. `automaticDeeplinkHandling` and + // `proxy` have no iOS builder equivalent (both Android-only) and are + // ignored here. + BOOL appHandlesRedemptionAlert = NO; if ([startOptions isKindOfClass:[NSDictionary class]]) { id allowDeeplink = startOptions[@"allowDeeplink"]; if ([allowDeeplink isKindOfClass:[NSNumber class]]) { @@ -565,8 +567,37 @@ - (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) { + RCTLogError(@"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]; + } + } + 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 +1277,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 +1364,37 @@ - (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; + + id context = [NSNull null]; + if (result.context != nil) { + PLYSubscription *subscription = result.context.subscription; + context = @{ @"subscription": subscription != nil ? subscription.asDictionary : [NSNull null] }; + } + + NSDictionary *body = @{ + @"isSuccess": @(result.isSuccess), + @"context": context, + @"replay": @(result.replay), + @"errorCode": result.errorCode ?: [NSNull null], + @"errorMessage": result.errorMessage ?: [NSNull null] + }; + + [self sendEventWithName:@"WEB_REDEMPTION_LISTENER" body:body]; +} + - (void)purchasePerformed { if (!self.shouldEmit) return; [self sendEventWithName: @"PURCHASE_LISTENER" body: @{}]; From 717bb73f561ed390c605cd75bbc61c502fea5a01 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 3 Sep 2026 18:18:08 +0200 Subject: [PATCH 04/26] feat(android): bridge anonymousUserId, proxy and the web redemption listener --- .../reactnativepurchasely/PurchaselyModule.kt | 138 ++++++++++++++---- 1 file changed, 108 insertions(+), 30 deletions(-) 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..d30cc9a1 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,36 @@ 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 webRedemptionListener = PLYWebRedemptionListener { result -> + val params = when (result) { + is PLYWebRedemptionResult.Success -> mapOf( + Pair("isSuccess", true), + Pair("context", result.context?.let { + mapOf(Pair("subscription", it.subscription?.let { data -> subscriptionToMap(data) })) + }), + 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), + ) + } + sendEvent(reactApplicationContext, "WEB_REDEMPTION_LISTENER", Arguments.makeNativeMap(params)) + } + override fun getName(): String { return "Purchasely" } @@ -203,6 +235,47 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : ) { startOptions.getBoolean("automaticDeeplinkHandling") } else null + val proxyApi = if (startOptions.hasKey("proxy") && !startOptions.isNull("proxy")) { + startOptions.getString("proxy") + } else null + val appHandlesRedemptionAlert = 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. + // + // `UUID.fromString` accepts a short form such as "1-2-3-4-5" that + // `NSUUID` refuses, so the round-trip check makes both platforms agree on + // what "canonical" means. + val anonymousUserIdString = if ( + startOptions.hasKey("anonymousUserId") && !startOptions.isNull("anonymousUserId") + ) { + startOptions.getString("anonymousUserId") + } else null + val anonymousUserId = anonymousUserIdString?.let { value -> + val parsed = try { + UUID.fromString(value) + } catch (e: IllegalArgumentException) { + null + } + if (parsed == null || !parsed.toString().equals(value, ignoreCase = true)) { + Log.e("Purchasely", "`anonymousUserId` must be a canonical UUID string, for example " + + "\"3f2504e0-4f89-11d3-9a0c-0305e82c3301\". Received \"$value\". " + + "The anonymous user id is not applied.") + null + } else parsed + } + val anonymousUserIdOverride = if ( + startOptions.hasKey("anonymousUserIdOverride") && !startOptions.isNull("anonymousUserIdOverride") + ) { + startOptions.getBoolean("anonymousUserIdOverride") + } else false Purchasely.Builder(reactApplicationContext.applicationContext) .apiKey(apiKey) @@ -225,6 +298,15 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : allowDeeplink?.let { this.allowDeeplink(it) } allowCampaigns?.let { this.allowCampaigns(it) } automaticDeeplinkHandling?.let { this.automaticDeeplinkHandling(it) } + proxyApi?.let { this.proxy(it) } + anonymousUserId?.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(appHandlesRedemptionAlert, webRedemptionListener) } .build() @@ -610,6 +692,30 @@ fun decrementUserAttribute(key: String, value: Double, legalBasis: String?) { Purchasely.clearUserAttributes() } + /** + * 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. + */ + private fun subscriptionToMap(data: PLYSubscriptionData): Map { + return 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 + } + } + @ReactMethod fun userSubscriptions(invalidate: Boolean = false, promise: Promise) { GlobalScope.launch { @@ -617,21 +723,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 +739,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) { From f9036a185cc2bf63650ac1367aff55fe550c095e Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 3 Sep 2026 18:22:15 +0200 Subject: [PATCH 05/26] test: cover the 6.1.0 builder modifiers, the redemption events and the web redemption listener --- .../reactnativepurchasely/PurchaselyModule.kt | 140 +++++++++------- .../PurchaselyModuleTest.kt | 108 ++++++++++++ .../purchasely/src/__tests__/index.test.ts | 18 +- .../src/__tests__/startBuilder.test.ts | 106 +++++++++++- .../purchasely/src/__tests__/types.test.ts | 158 +++++++++++++++++- 5 files changed, 467 insertions(+), 63 deletions(-) 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 d30cc9a1..e4e49c64 100644 --- a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt +++ b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt @@ -90,24 +90,7 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : * `replay = false` and `context = null`, which keeps the JS shape stable. */ private val webRedemptionListener = PLYWebRedemptionListener { result -> - val params = when (result) { - is PLYWebRedemptionResult.Success -> mapOf( - Pair("isSuccess", true), - Pair("context", result.context?.let { - mapOf(Pair("subscription", it.subscription?.let { data -> subscriptionToMap(data) })) - }), - 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), - ) - } + val params = webRedemptionResultToMap(result) sendEvent(reactApplicationContext, "WEB_REDEMPTION_LISTENER", Arguments.makeNativeMap(params)) } @@ -249,27 +232,16 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : // 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. - // - // `UUID.fromString` accepts a short form such as "1-2-3-4-5" that - // `NSUUID` refuses, so the round-trip check makes both platforms agree on - // what "canonical" means. val anonymousUserIdString = if ( startOptions.hasKey("anonymousUserId") && !startOptions.isNull("anonymousUserId") ) { startOptions.getString("anonymousUserId") } else null - val anonymousUserId = anonymousUserIdString?.let { value -> - val parsed = try { - UUID.fromString(value) - } catch (e: IllegalArgumentException) { - null - } - if (parsed == null || !parsed.toString().equals(value, ignoreCase = true)) { - Log.e("Purchasely", "`anonymousUserId` must be a canonical UUID string, for example " + - "\"3f2504e0-4f89-11d3-9a0c-0305e82c3301\". Received \"$value\". " + - "The anonymous user id is not applied.") - null - } else parsed + val anonymousUserId = parseCanonicalUuid(anonymousUserIdString) + if (anonymousUserIdString != null && anonymousUserId == 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") @@ -692,30 +664,6 @@ fun decrementUserAttribute(key: String, value: Double, legalBasis: String?) { Purchasely.clearUserAttributes() } - /** - * 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. - */ - private fun subscriptionToMap(data: PLYSubscriptionData): Map { - return 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 - } - } - @ReactMethod fun userSubscriptions(invalidate: Boolean = false, promise: Promise) { GlobalScope.launch { @@ -1448,6 +1396,30 @@ 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 { + 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 + } + } + fun transformPlanToMap(plan: PLYPlan?): Map { if(plan == null) return emptyMap() @@ -1570,3 +1542,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..5f6a0b7a 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/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..62c837a1 100644 --- a/packages/purchasely/src/__tests__/startBuilder.test.ts +++ b/packages/purchasely/src/__tests__/startBuilder.test.ts @@ -39,7 +39,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 +53,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 +173,106 @@ 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() — Android only, 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', + }) + }) + }) + + 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 +312,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..3c2461b9 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,157 @@ 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', + cancelledDate: '', + plan: { vendorId: 'plan-123', name: 'Gold', type: PlanType.PLAN_TYPE_AUTO_RENEWING_SUBSCRIPTION }, + product: { name: 'Gold', vendorId: 'product-123', 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 +825,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') }) From 1bdf52fcc7fefe510af78b5465459e176987ac61 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 3 Sep 2026 18:23:40 +0200 Subject: [PATCH 06/26] feat(example): show the 6.1.0 redemption listener and the new start modifiers --- example/src/App.tsx | 52 +++++++++++++++++++ .../purchasely/src/__tests__/types.test.ts | 30 ++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index 91782483..dfaf345f 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -19,6 +19,36 @@ const Stack = createNativeStackNavigator() function App(): React.JSX.Element { async function setupPurchasely() { let configured = false + + // 6.1.0 — Web2App redemption. Add the listener BEFORE start(): a + // redemption can settle during start(), from a cold start that the + // `ply/redeem` link itself triggered, or from a token that a previous + // launch left pending. A listener added after start() misses it. + // + // A redemption deeplink is not subject to allowDeeplink: the native + // SDK intercepts `ply/redeem` before the routing branch that gate + // sits behind. + Purchasely.addWebRedemptionListener((result) => { + if (result.isSuccess) { + console.log( + 'Redemption granted. replay=' + + result.replay + + ' subscription=' + + result.context?.subscription?.plan?.vendorId + ) + } else { + // On iOS, errorMessage for an expired link can contain a + // masked email address. Show it to the user. Do not send it to + // an analytics stack or to a crash reporter. + console.log( + 'Redemption failed. code=' + + result.errorCode + + ' message=' + + result.errorMessage + ) + } + }) + try { // chained builder — the only supported way to start the SDK. // `allowDeeplink(true)` replaces the legacy `readyToOpenDeeplink`. @@ -32,6 +62,28 @@ function App(): React.JSX.Element { .allowCampaigns(true) .storekitVersion('storeKit2') // iOS: 'storeKit2' or 'storeKit1' .stores(['google']) // Android stores + // 6.1.0 — the anonymous user id this device reports. The + // bridge parses the string into a native UUID and rejects a + // value that is not canonical. The SDK stores it uppercase, + // and applies it only when the device holds no anonymous id + // yet, unless the second argument is true. + // + // Kept inactive here on purpose: a hardcoded id would pin + // every install of this demo app to one anonymous user, and + // the E2E suite asserts on the generated id. + // .anonymousUserId('3f2504e0-4f89-11d3-9a0c-0305e82c3301') + // + // 6.1.0 — Android-only. Route the API traffic through a proxy + // for a region where `api.purchasely.io` is unreachable. Only + // https is accepted. Ignored on iOS. + // + // Kept inactive here on purpose: this demo app must keep + // talking to production. + // .proxy('https://svc.purchasely.io') + // + // 6.1.0 — keep the SDK's own redemption popin (the default). + // Pass true to show your own result screen instead. + .appHandlesRedemptionAlert(false) .start() } catch (e) { console.log('Purchasely SDK configuration error:', e) diff --git a/packages/purchasely/src/__tests__/types.test.ts b/packages/purchasely/src/__tests__/types.test.ts index 3c2461b9..86aea3b0 100644 --- a/packages/purchasely/src/__tests__/types.test.ts +++ b/packages/purchasely/src/__tests__/types.test.ts @@ -631,8 +631,34 @@ describe('Purchasely Types', () => { subscriptionSource: SubscriptionSource.APPLE_APP_STORE, nextRenewalDate: '2024-02-15T12:00:00Z', cancelledDate: '', - plan: { vendorId: 'plan-123', name: 'Gold', type: PlanType.PLAN_TYPE_AUTO_RENEWING_SUBSCRIPTION }, - product: { name: 'Gold', vendorId: 'product-123', plans: [] }, + 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, From 1205ba453643b970f37d0e9e67bb399582f33c2b Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 3 Sep 2026 18:24:35 +0200 Subject: [PATCH 07/26] chore(release): bump the five packages, the bridge version and the docs to 6.1.0 --- CLAUDE.md | 12 +++++++----- VERSIONS.md | 1 + packages/amazon/package.json | 2 +- packages/android-player/package.json | 2 +- packages/google/package.json | 2 +- packages/huawei/package.json | 2 +- packages/purchasely/package.json | 2 +- 7 files changed, 13 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bd2f86c6..ec9230e8 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 @@ -646,6 +646,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/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/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/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/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/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", From 5368762ed9fa4341f25a01341d9e0dda5cb20de2 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 3 Sep 2026 18:24:57 +0200 Subject: [PATCH 08/26] chore(deps): pin the native iOS and Android SDKs to 6.1.0 --- packages/amazon/android/build.gradle | 2 +- packages/android-player/android/build.gradle | 2 +- packages/google/android/build.gradle | 2 +- packages/huawei/android/build.gradle | 2 +- packages/purchasely/android/build.gradle | 2 +- packages/purchasely/react-native-purchasely.podspec | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) 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/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/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/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/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/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}' From f71cfc3d93345097047485a0c50514fc9f5b6170 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 3 Sep 2026 18:26:38 +0200 Subject: [PATCH 09/26] docs: remove em-dashes from the 6.1.0 prose --- example/src/App.tsx | 8 ++++---- .../java/com/reactnativepurchasely/PurchaselyModule.kt | 2 +- .../com/reactnativepurchasely/PurchaselyModuleTest.kt | 2 +- packages/purchasely/ios/PurchaselyRN.m | 2 +- packages/purchasely/src/__tests__/startBuilder.test.ts | 10 +++++----- packages/purchasely/src/types.ts | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index dfaf345f..c9ed9305 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -20,7 +20,7 @@ function App(): React.JSX.Element { async function setupPurchasely() { let configured = false - // 6.1.0 — Web2App redemption. Add the listener BEFORE start(): a + // 6.1.0, Web2App redemption. Add the listener BEFORE start(): a // redemption can settle during start(), from a cold start that the // `ply/redeem` link itself triggered, or from a token that a previous // launch left pending. A listener added after start() misses it. @@ -62,7 +62,7 @@ function App(): React.JSX.Element { .allowCampaigns(true) .storekitVersion('storeKit2') // iOS: 'storeKit2' or 'storeKit1' .stores(['google']) // Android stores - // 6.1.0 — the anonymous user id this device reports. The + // 6.1.0. The anonymous user id this device reports. The // bridge parses the string into a native UUID and rejects a // value that is not canonical. The SDK stores it uppercase, // and applies it only when the device holds no anonymous id @@ -73,7 +73,7 @@ function App(): React.JSX.Element { // the E2E suite asserts on the generated id. // .anonymousUserId('3f2504e0-4f89-11d3-9a0c-0305e82c3301') // - // 6.1.0 — Android-only. Route the API traffic through a proxy + // 6.1.0. Android-only. Route the API traffic through a proxy // for a region where `api.purchasely.io` is unreachable. Only // https is accepted. Ignored on iOS. // @@ -81,7 +81,7 @@ function App(): React.JSX.Element { // talking to production. // .proxy('https://svc.purchasely.io') // - // 6.1.0 — keep the SDK's own redemption popin (the default). + // 6.1.0. Keep the SDK's own redemption popin (the default). // Pass true to show your own result screen instead. .appHandlesRedemptionAlert(false) .start() 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 e4e49c64..fefe709c 100644 --- a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt +++ b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt @@ -230,7 +230,7 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : // 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 + // 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") 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 5f6a0b7a..822121d8 100644 --- a/packages/purchasely/android/src/test/java/com/reactnativepurchasely/PurchaselyModuleTest.kt +++ b/packages/purchasely/android/src/test/java/com/reactnativepurchasely/PurchaselyModuleTest.kt @@ -552,7 +552,7 @@ class PurchaselyModuleTest { // endregion - // region 6.1.0 — anonymous user id + web redemption + // region 6.1.0: anonymous user id + web redemption /** * `UUID.fromString` is lenient: it accepts a short form the iOS `NSUUID` diff --git a/packages/purchasely/ios/PurchaselyRN.m b/packages/purchasely/ios/PurchaselyRN.m index e17b4a7d..311d377f 100644 --- a/packages/purchasely/ios/PurchaselyRN.m +++ b/packages/purchasely/ios/PurchaselyRN.m @@ -570,7 +570,7 @@ - (NSDictionary *)constantsToExport { // 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 + // 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]]) { diff --git a/packages/purchasely/src/__tests__/startBuilder.test.ts b/packages/purchasely/src/__tests__/startBuilder.test.ts index 62c837a1..a596b9d7 100644 --- a/packages/purchasely/src/__tests__/startBuilder.test.ts +++ b/packages/purchasely/src/__tests__/startBuilder.test.ts @@ -173,7 +173,7 @@ describe('PurchaselyBuilder', () => { }) }) - describe('anonymousUserId() — 6.1.0', () => { + 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') @@ -196,7 +196,7 @@ describe('PurchaselyBuilder', () => { }) }) - it('does not validate the string in JS — the bridge parses it and rejects a bad value', async () => { + 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) @@ -213,7 +213,7 @@ describe('PurchaselyBuilder', () => { }) }) - describe('proxy() — Android only, 6.1.0', () => { + describe('proxy() Android only, 6.1.0', () => { it('forwards the api url through startOptions', async () => { await PurchaselyBuilder.apiKey('api-key') .proxy('https://svc.purchasely.io') @@ -224,7 +224,7 @@ describe('PurchaselyBuilder', () => { }) }) - it('does not validate the scheme in JS — the native SDK refuses a bad value', async () => { + 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', @@ -232,7 +232,7 @@ describe('PurchaselyBuilder', () => { }) }) - describe('appHandlesRedemptionAlert() — 6.1.0', () => { + 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({ diff --git a/packages/purchasely/src/types.ts b/packages/purchasely/src/types.ts index 0568e6db..787d8f53 100644 --- a/packages/purchasely/src/types.ts +++ b/packages/purchasely/src/types.ts @@ -202,7 +202,7 @@ export type PLYEventName = | '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 + * platforms. A replayed link reports this event too. Read * `properties.redemption.purchase_context.replay` to tell a first * redemption from a repeat. */ From ec90bdc8db0391c4d68d13289475ef544f3803a6 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 3 Sep 2026 18:32:02 +0200 Subject: [PATCH 10/26] refactor(android): rename the redemption locals so they never read as builder members --- .../com/reactnativepurchasely/PurchaselyModule.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 fefe709c..a77cba3f 100644 --- a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt +++ b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt @@ -89,7 +89,7 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : * 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 webRedemptionListener = PLYWebRedemptionListener { result -> + private val bridgeWebRedemptionListener = PLYWebRedemptionListener { result -> val params = webRedemptionResultToMap(result) sendEvent(reactApplicationContext, "WEB_REDEMPTION_LISTENER", Arguments.makeNativeMap(params)) } @@ -221,7 +221,7 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : val proxyApi = if (startOptions.hasKey("proxy") && !startOptions.isNull("proxy")) { startOptions.getString("proxy") } else null - val appHandlesRedemptionAlert = if ( + val handlesRedemptionAlert = if ( startOptions.hasKey("appHandlesRedemptionAlert") && !startOptions.isNull("appHandlesRedemptionAlert") ) { startOptions.getBoolean("appHandlesRedemptionAlert") @@ -237,8 +237,8 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : ) { startOptions.getString("anonymousUserId") } else null - val anonymousUserId = parseCanonicalUuid(anonymousUserIdString) - if (anonymousUserIdString != null && anonymousUserId == 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.") @@ -271,14 +271,14 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : allowCampaigns?.let { this.allowCampaigns(it) } automaticDeeplinkHandling?.let { this.automaticDeeplinkHandling(it) } proxyApi?.let { this.proxy(it) } - anonymousUserId?.let { this.anonymousUserId(it, anonymousUserIdOverride) } + 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(appHandlesRedemptionAlert, webRedemptionListener) + this.webRedemptionListener(handlesRedemptionAlert, bridgeWebRedemptionListener) } .build() From ffb78d37790894bac5c9a0c87f7fa6e9c5f4b37a Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 3 Sep 2026 18:33:55 +0200 Subject: [PATCH 11/26] test(ios): assert the bridge exposes the web redemption event and delegate --- .../ios/PurchaselyTests/PurchaselyRNTests.m | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/purchasely/ios/PurchaselyTests/PurchaselyRNTests.m b/packages/purchasely/ios/PurchaselyTests/PurchaselyRNTests.m index 516a741f..745d9df1 100644 --- a/packages/purchasely/ios/PurchaselyTests/PurchaselyRNTests.m +++ b/packages/purchasely/ios/PurchaselyTests/PurchaselyRNTests.m @@ -348,6 +348,27 @@ - (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"); +} + - (void)testSupportedEventsIncludesCloseRequested { NSArray *events = [self.purchaselyModule supportedEvents]; XCTAssertTrue([events containsObject:@"PURCHASELY_PRESENTATION_CLOSE_REQUESTED"], From 094b93c7c3c277a08e14af0769be564d238a09c5 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 3 Sep 2026 18:35:15 +0200 Subject: [PATCH 12/26] style(ios): match the [Purchasely] log prefix convention --- packages/purchasely/ios/PurchaselyRN.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/purchasely/ios/PurchaselyRN.m b/packages/purchasely/ios/PurchaselyRN.m index 311d377f..d62622c6 100644 --- a/packages/purchasely/ios/PurchaselyRN.m +++ b/packages/purchasely/ios/PurchaselyRN.m @@ -576,7 +576,7 @@ - (NSDictionary *)constantsToExport { if ([anonymousUserId isKindOfClass:[NSString class]]) { NSUUID *parsed = [[NSUUID alloc] initWithUUIDString:(NSString *)anonymousUserId]; if (parsed == nil) { - RCTLogError(@"Purchasely: `anonymousUserId` must be a canonical UUID string, " + RCTLogError(@"[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 { From b567783d24aaae8484c735cd63223fb3c67d8038 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 09:49:12 +0200 Subject: [PATCH 13/26] chore(ios): regenerate Podfile.lock for Purchasely 6.1.0 --- example/ios/Podfile.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 From e28c37c874620d7905a4cc37001dbecba74c3eb8 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 10:14:52 +0200 Subject: [PATCH 14/26] fix(types): purchaseToken and the subscription dates are optional on iOS The native iOS PLYSubscription has no purchase token property, so PLYSubscription+Hybrid.m asDictionary never emitted the key. The type promised a required string on every platform. nextRenewalDate and cancelledDate have the same gap: the iOS bridge omits each key when the native date is nil. Reported by Greptile on #293. The gap predates 6.1.0 and also affects userSubscriptions(), but the new web redemption context surfaces the same shape on a new API, so the contract is corrected here. --- packages/purchasely/src/types.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/purchasely/src/types.ts b/packages/purchasely/src/types.ts index 787d8f53..0a42fffd 100644 --- a/packages/purchasely/src/types.ts +++ b/packages/purchasely/src/types.ts @@ -122,10 +122,24 @@ 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. Optional so iOS callers see `undefined` instead of a required field + * that is silently absent. Same reasoning as + * {@link cumulatedRevenuesInUSD}. + */ + purchaseToken?: string; 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`, so read it as optional rather than + * as an empty string. + */ + nextRenewalDate?: string; + /** Absent when the subscription is not cancelled. See {@link nextRenewalDate}. */ + cancelledDate?: string; plan: PLYPlan; product: PLYProduct; /** From baa95aad812a7c2aef06b08600d8ce61a556cb7e Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 10:14:52 +0200 Subject: [PATCH 15/26] docs: document anonymousUserId, proxy and the Web2App redemption listener --- sdk_public_doc.md | 114 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/sdk_public_doc.md b/sdk_public_doc.md index 52e1fb61..174b7395 100644 --- a/sdk_public_doc.md +++ b/sdk_public_doc.md @@ -222,6 +222,120 @@ 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, Android only) + +Route Purchasely API traffic through a proxy instead of `api.purchasely.io`, +for a region where that host is unreachable. + +```typescript +await Purchasely.builder('YOUR_API_KEY') + .proxy('https://svc.purchasely.io') + .start(); +``` + +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. The native SDK +refuses any other value with an error log and keeps the production host. + +This option is **Android only**. The iOS bridge ignores it. + +### Web2App redemption (6.1.0) + +Listen to the outcome of a Web2App redemption +(`{scheme}://ply/redeem/{token}`). + +**Add the listener before `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. A listener that you add after `start()` +misses exactly the case it is most needed for. + +```typescript +import Purchasely from 'react-native-purchasely'; + +// Add the listener FIRST. +Purchasely.addWebRedemptionListener((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); + } +}); + +// Then start the SDK. +await Purchasely.builder('YOUR_API_KEY') + .appHandlesRedemptionAlert(false) // default: the SDK shows its own popin + .start(); +``` + +Call `Purchasely.removeWebRedemptionListener()` to remove it. + +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 iOS only**, `errorMessage` for an expired link can contain a masked + email address, so you can tell the user where the fresh link went. Show that + text to the user. Do not send it to an analytics stack or to a crash + reporter. The `REDEMPTION_FAILED` event drops it. + +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**. From 118a9c37562d5b5f3b15eee013e8ee402eb67de4 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 11:39:15 +0200 Subject: [PATCH 16/26] feat(ios): wire proxy, which iOS 6.1.0 does support MOB-308 landed in the iOS 6.1.0 release. The builder exposes proxy() and proxy(api:), bridged as proxyWithApi:. The bridge and the docs wrongly described the modifier as Android-only, based on a check against an earlier develop commit. The native parameter is an NSURL?, where nil means turn the proxy off rather than ignore the value. A string that NSURL cannot parse therefore skips the modifier and logs an error, instead of passing nil and silently disabling a proxy the app asked for. --- packages/purchasely/ios/PurchaselyRN.m | 23 ++++++++++++++++--- .../src/__tests__/startBuilder.test.ts | 2 +- packages/purchasely/src/startBuilder.ts | 23 +++++++++++-------- sdk_public_doc.md | 15 ++++++++---- 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/packages/purchasely/ios/PurchaselyRN.m b/packages/purchasely/ios/PurchaselyRN.m index d62622c6..37e25145 100644 --- a/packages/purchasely/ios/PurchaselyRN.m +++ b/packages/purchasely/ios/PurchaselyRN.m @@ -554,9 +554,8 @@ - (NSDictionary *)constantsToExport { // Applied on the builder chain — before `startWithInitialized:` — so these // take effect atomically with configuration, closing the race window a // separate post-start call would leave open for an early campaign/deeplink - // to fire against the wrong default. `automaticDeeplinkHandling` and - // `proxy` have no iOS builder equivalent (both Android-only) and are - // ignored here. + // 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"]; @@ -585,6 +584,24 @@ - (NSDictionary *)constantsToExport { 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. + id proxyApi = startOptions[@"proxy"]; + if ([proxyApi isKindOfClass:[NSString class]]) { + NSURL *proxyUrl = [NSURL URLWithString:(NSString *)proxyApi]; + if (proxyUrl == nil) { + RCTLogError(@"[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]; diff --git a/packages/purchasely/src/__tests__/startBuilder.test.ts b/packages/purchasely/src/__tests__/startBuilder.test.ts index a596b9d7..f9c94cfe 100644 --- a/packages/purchasely/src/__tests__/startBuilder.test.ts +++ b/packages/purchasely/src/__tests__/startBuilder.test.ts @@ -213,7 +213,7 @@ describe('PurchaselyBuilder', () => { }) }) - describe('proxy() Android only, 6.1.0', () => { + describe('proxy() 6.1.0', () => { it('forwards the api url through startOptions', async () => { await PurchaselyBuilder.apiKey('api-key') .proxy('https://svc.purchasely.io') diff --git a/packages/purchasely/src/startBuilder.ts b/packages/purchasely/src/startBuilder.ts index 4600f21d..98e2afee 100644 --- a/packages/purchasely/src/startBuilder.ts +++ b/packages/purchasely/src/startBuilder.ts @@ -43,7 +43,6 @@ interface StartBuilderState { * - `allowDeeplink` / `allowCampaigns` are optional chain modifiers. * When omitted we keep each native SDK's default/backend-configured value. * - `stores(...)` is Android-only. - * - `proxy(...)` is Android-only. * - `storekitVersion(...)` is iOS-only. * * The default running mode is `'observer'` — the host app keeps full @@ -149,16 +148,22 @@ export class PurchaselyBuilder { } /** - * Android-only. - * * Route Purchasely API traffic through a proxy instead of - * `api.purchasely.io`, for a region where that host is unreachable. The - * SDK overrides the API host only: the paywall host and the tracking - * host always stay on production. + * `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. * - * `api` must be an `https` base URL. 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. + * This is a start-time option. Neither native SDK has a runtime setter + * for it. * * @param api The `https` base URL of the API proxy. */ diff --git a/sdk_public_doc.md b/sdk_public_doc.md index 174b7395..7e8b6123 100644 --- a/sdk_public_doc.md +++ b/sdk_public_doc.md @@ -248,10 +248,10 @@ existing id: 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, Android only) +### 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. +for a region where that host is unreachable, such as mainland China. ```typescript await Purchasely.builder('YOUR_API_KEY') @@ -259,11 +259,16 @@ await Purchasely.builder('YOUR_API_KEY') .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. The native SDK -refuses any other value with an error log and keeps the production 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. -This option is **Android only**. The iOS bridge ignores it. +This is a start-time option on both platforms. Neither native SDK has a +runtime setter for it. ### Web2App redemption (6.1.0) From 10bfacdf22e25b02783b67414c54634443fdaa62 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 11:48:04 +0200 Subject: [PATCH 17/26] feat(proxy): accept null to clear the proxy on both platforms proxy(api) rejected null and the forwarding dropped it, so a proxy could be set but never cleared. Both native SDKs document null as the way back to api.purchasely.io. The three states are now distinct end to end: an absent key leaves each SDK's current setting untouched, null clears the proxy, a string sets it. The iOS bridge maps NSNull to proxyWithApi:nil and Android calls proxy(null) only when the key is present. Four tests cover the three states and the never-called/cleared distinction. Verified they fail against the old forwarding. --- .../reactnativepurchasely/PurchaselyModule.kt | 9 +++- packages/purchasely/ios/PurchaselyRN.m | 8 +++- .../src/__tests__/startBuilder.test.ts | 44 +++++++++++++++++++ packages/purchasely/src/startBuilder.ts | 21 +++++++-- sdk_public_doc.md | 16 +++++++ 5 files changed, 91 insertions(+), 7 deletions(-) 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 a77cba3f..3aa761e0 100644 --- a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt +++ b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt @@ -218,7 +218,12 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : ) { startOptions.getBoolean("automaticDeeplinkHandling") } else null - val proxyApi = if (startOptions.hasKey("proxy") && !startOptions.isNull("proxy")) { + // 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 ( @@ -270,7 +275,7 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : allowDeeplink?.let { this.allowDeeplink(it) } allowCampaigns?.let { this.allowCampaigns(it) } automaticDeeplinkHandling?.let { this.automaticDeeplinkHandling(it) } - proxyApi?.let { this.proxy(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 diff --git a/packages/purchasely/ios/PurchaselyRN.m b/packages/purchasely/ios/PurchaselyRN.m index 37e25145..3f7e4947 100644 --- a/packages/purchasely/ios/PurchaselyRN.m +++ b/packages/purchasely/ios/PurchaselyRN.m @@ -591,8 +591,14 @@ - (NSDictionary *)constantsToExport { // 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 isKindOfClass:[NSString class]]) { + if (proxyApi == [NSNull null]) { + builder = [builder proxyWithApi:nil]; + } else if ([proxyApi isKindOfClass:[NSString class]]) { NSURL *proxyUrl = [NSURL URLWithString:(NSString *)proxyApi]; if (proxyUrl == nil) { RCTLogError(@"[Purchasely] `proxy` must be an https base URL, " diff --git a/packages/purchasely/src/__tests__/startBuilder.test.ts b/packages/purchasely/src/__tests__/startBuilder.test.ts index f9c94cfe..dc8db282 100644 --- a/packages/purchasely/src/__tests__/startBuilder.test.ts +++ b/packages/purchasely/src/__tests__/startBuilder.test.ts @@ -230,6 +230,50 @@ describe('PurchaselyBuilder', () => { 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('appHandlesRedemptionAlert() 6.1.0', () => { diff --git a/packages/purchasely/src/startBuilder.ts b/packages/purchasely/src/startBuilder.ts index 98e2afee..6b1a6a01 100644 --- a/packages/purchasely/src/startBuilder.ts +++ b/packages/purchasely/src/startBuilder.ts @@ -30,6 +30,11 @@ interface StartBuilderState { 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; androidStores: AndroidStore[]; @@ -165,9 +170,14 @@ export class PurchaselyBuilder { * This is a start-time option. Neither native SDK has a runtime setter * for it. * - * @param api The `https` base URL of the API proxy. + * 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): this { + proxy(api: string | null): this { this.state.proxyApi = api; return this; } @@ -231,7 +241,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; } @@ -250,7 +260,10 @@ export class PurchaselyBuilder { startOptions.anonymousUserId = this.state.anonymousUserId; startOptions.anonymousUserIdOverride = this.state.anonymousUserIdOverride ?? false; } - if (this.state.proxyApi !== undefined && this.state.proxyApi !== null) { + // `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 ( diff --git a/sdk_public_doc.md b/sdk_public_doc.md index 7e8b6123..e245d902 100644 --- a/sdk_public_doc.md +++ b/sdk_public_doc.md @@ -267,6 +267,22 @@ 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. From 368e5db4808c4aad29b12108e7efb563f1bcfda4 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 11:54:44 +0200 Subject: [PATCH 18/26] feat(js): put the web redemption listener on the start builder The listener belongs on the chain, the same shape as the native SDKs (webRedemptionDelegate on iOS, webRedemptionListener on Android). The callback never crosses the bridge: the native side registers itself as the delegate and forwards each outcome as an event, so the builder only has to subscribe the JS callback. Chaining it also removes the footgun the standalone form left behind. A redemption can settle during start(), so a listener added afterwards misses exactly the case it is needed for. The modifier subscribes at chain time, which makes that ordering impossible to get wrong. The optional second argument sets appHandlesRedemptionAlert, mirroring Android's two-arity form. The listener moves to its own module so the builder can subscribe without importing index.ts, which would be a cycle. Its emitter is built on first use, not at import time, so importing the builder does not construct one. addWebRedemptionListener and removeWebRedemptionListener stay for the runtime case. Also drops the commented-out anonymousUserId and proxy call sites from the example app, and the appHandlesRedemptionAlert(false) line that only restated the default. --- example/src/App.tsx | 75 ++++++------------ .../src/__tests__/startBuilder.test.ts | 60 +++++++++++++- packages/purchasely/src/index.ts | 74 +++-------------- packages/purchasely/src/redemption.ts | 79 +++++++++++++++++++ packages/purchasely/src/startBuilder.ts | 46 +++++++++++ sdk_public_doc.md | 45 ++++++----- 6 files changed, 243 insertions(+), 136 deletions(-) create mode 100644 packages/purchasely/src/redemption.ts diff --git a/example/src/App.tsx b/example/src/App.tsx index c9ed9305..d8f2862f 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -20,35 +20,6 @@ function App(): React.JSX.Element { async function setupPurchasely() { let configured = false - // 6.1.0, Web2App redemption. Add the listener BEFORE start(): a - // redemption can settle during start(), from a cold start that the - // `ply/redeem` link itself triggered, or from a token that a previous - // launch left pending. A listener added after start() misses it. - // - // A redemption deeplink is not subject to allowDeeplink: the native - // SDK intercepts `ply/redeem` before the routing branch that gate - // sits behind. - Purchasely.addWebRedemptionListener((result) => { - if (result.isSuccess) { - console.log( - 'Redemption granted. replay=' + - result.replay + - ' subscription=' + - result.context?.subscription?.plan?.vendorId - ) - } else { - // On iOS, errorMessage for an expired link can contain a - // masked email address. Show it to the user. Do not send it to - // an analytics stack or to a crash reporter. - console.log( - 'Redemption failed. code=' + - result.errorCode + - ' message=' + - result.errorMessage - ) - } - }) - try { // chained builder — the only supported way to start the SDK. // `allowDeeplink(true)` replaces the legacy `readyToOpenDeeplink`. @@ -62,28 +33,30 @@ function App(): React.JSX.Element { .allowCampaigns(true) .storekitVersion('storeKit2') // iOS: 'storeKit2' or 'storeKit1' .stores(['google']) // Android stores - // 6.1.0. The anonymous user id this device reports. The - // bridge parses the string into a native UUID and rejects a - // value that is not canonical. The SDK stores it uppercase, - // and applies it only when the device holds no anonymous id - // yet, unless the second argument is true. - // - // Kept inactive here on purpose: a hardcoded id would pin - // every install of this demo app to one anonymous user, and - // the E2E suite asserts on the generated id. - // .anonymousUserId('3f2504e0-4f89-11d3-9a0c-0305e82c3301') - // - // 6.1.0. Android-only. Route the API traffic through a proxy - // for a region where `api.purchasely.io` is unreachable. Only - // https is accepted. Ignored on iOS. - // - // Kept inactive here on purpose: this demo app must keep - // talking to production. - // .proxy('https://svc.purchasely.io') - // - // 6.1.0. Keep the SDK's own redemption popin (the default). - // Pass true to show your own result screen instead. - .appHandlesRedemptionAlert(false) + // 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 iOS, 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/purchasely/src/__tests__/startBuilder.test.ts b/packages/purchasely/src/__tests__/startBuilder.test.ts index dc8db282..97f5ef15 100644 --- a/packages/purchasely/src/__tests__/startBuilder.test.ts +++ b/packages/purchasely/src/__tests__/startBuilder.test.ts @@ -25,9 +25,13 @@ jest.mock('react-native', () => ({ handleDeeplink: jest.fn().mockResolvedValue(true), }, }, + NativeEventEmitter: jest.fn().mockImplementation(() => ({ + addListener: jest.fn(() => ({ remove: jest.fn() })), + removeAllListeners: jest.fn(), + })), })) -import { NativeModules } from 'react-native' +import { NativeEventEmitter, NativeModules } from 'react-native' import { PurchaselyBuilder } from '../startBuilder' const mockNative = NativeModules.Purchasely as any @@ -276,6 +280,60 @@ describe('PurchaselyBuilder', () => { }) }) + describe('webRedemptionListener() 6.1.0', () => { + it('subscribes the callback on the WEB_REDEMPTION_LISTENER event', async () => { + const callback = jest.fn() + await PurchaselyBuilder.apiKey('api-key') + .webRedemptionListener(callback) + .start() + + const emitterMock = NativeEventEmitter as unknown as jest.Mock + const instance = emitterMock.mock.results[0]?.value + expect(instance).toBeDefined() + expect(instance.addListener).toHaveBeenCalledWith( + 'WEB_REDEMPTION_LISTENER', + callback + ) + }) + + // The whole point of putting this on the chain: a redemption can + // settle while start() runs, so the listener must already exist by + // then. Subscribing at chain time, not inside start(), is what + // guarantees it. + it('subscribes before native start() is called', async () => { + const order: string[] = [] + mockNative.start = jest.fn().mockImplementation(async () => { + order.push('start') + return true + }) + + const builder = PurchaselyBuilder.apiKey('api-key') + builder.webRedemptionListener(() => {}) + order.push('subscribed') + await builder.start() + + expect(order).toEqual(['subscribed', 'start']) + }) + + 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() diff --git a/packages/purchasely/src/index.ts b/packages/purchasely/src/index.ts index 23e42c41..60033f01 100644 --- a/packages/purchasely/src/index.ts +++ b/packages/purchasely/src/index.ts @@ -23,7 +23,6 @@ import type { PLYPromotionalOfferSignature, PLYSubscription, PLYUserAttribute, - PLYWebRedemptionResult, } from './types'; import { PLYPresentationBuilder, @@ -31,6 +30,10 @@ import { removeDefaultPresentationDismissHandler, } from './presentation'; import { PurchaselyBuilder } from './startBuilder'; +import { + addWebRedemptionListener, + removeWebRedemptionListener, +} from './redemption'; import { interceptAction, removeActionInterceptor, @@ -127,70 +130,6 @@ const removeUserAttributeRemovedListener = () => { ); }; -type WebRedemptionListenerCallback = ( - result: PLYWebRedemptionResult -) => void; - -/** - * Listen to the outcome of a Web2App redemption - * (`{scheme}://ply/redeem/{token}`). - * - * **Add the listener before `Purchasely.builder(...).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. A listener - * that you add after `start()` misses exactly the case it is most needed for. - * - * 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, so the app acts on a screen that the user - * already dismissed. - * - `true`: the SDK shows nothing and calls the listener as soon as the - * redemption settles. The app must then show its own result screen. - * - * Two 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 every time, - * 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. A redemption still completes with - * `allowDeeplink(false)`. - * - **On iOS only**, `result.errorMessage` for an expired link can contain a - * masked email address, so the app can tell the user where the fresh link - * went. The analytics event drops it. Show that text to the user. Do not - * forward it to an analytics stack or to a crash reporter. - * - * @example - * ```ts - * Purchasely.addWebRedemptionListener((result) => { - * if (result.isSuccess) { - * unlock(result.context?.subscription) - * } else { - * showError(result.errorCode, result.errorMessage) - * } - * }) - * await Purchasely.builder('API_KEY').appHandlesRedemptionAlert(true).start() - * ``` - */ -const addWebRedemptionListener = ( - callback: WebRedemptionListenerCallback -) => { - return PurchaselyEventEmitter.addListener( - 'WEB_REDEMPTION_LISTENER', - callback - ); -}; - -/** Remove every listener added with {@link addWebRedemptionListener}. */ -const removeWebRedemptionListener = () => { - return PurchaselyEventEmitter.removeAllListeners('WEB_REDEMPTION_LISTENER'); -}; - export interface UserAttributeListener { onUserAttributeSet?: ( key: string, @@ -684,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/redemption.ts b/packages/purchasely/src/redemption.ts new file mode 100644 index 00000000..d42e0977 --- /dev/null +++ b/packages/purchasely/src/redemption.ts @@ -0,0 +1,79 @@ +import { NativeEventEmitter, NativeModules } 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; +}; + +/** @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 iOS only**, `result.errorMessage` for an expired link can contain a + * masked email address, so the app can tell the user where the fresh link + * went. The `REDEMPTION_FAILED` event drops it. Show that text to the user. + * Do not forward it to an analytics stack or to a crash reporter. + */ +export const addWebRedemptionListener = ( + callback: WebRedemptionListenerCallback +) => { + return emitter().addListener(WEB_REDEMPTION_EVENT, callback); +}; + +/** Remove every listener added with {@link addWebRedemptionListener}. */ +export const removeWebRedemptionListener = () => { + return emitter().removeAllListeners(WEB_REDEMPTION_EVENT); +}; diff --git a/packages/purchasely/src/startBuilder.ts b/packages/purchasely/src/startBuilder.ts index 6b1a6a01..45587c9a 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 { + addWebRedemptionListener, + type WebRedemptionListenerCallback, +} from './redemption'; type LogLevelString = 'debug' | 'info' | 'warn' | 'error'; type RunningModeString = 'observer' | 'full'; @@ -182,6 +186,48 @@ export class PurchaselyBuilder { 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 { + // Subscribed now, not at start(), so the listener is already in place + // for a redemption that settles while start() runs. + addWebRedemptionListener(callback); + if (appHandlesRedemptionAlert !== undefined) { + this.state.appHandlesRedemptionAlert = appHandlesRedemptionAlert; + } + return this; + } + /** * Hand the Web2App redemption result screen to the app. * diff --git a/sdk_public_doc.md b/sdk_public_doc.md index e245d902..d7d170d5 100644 --- a/sdk_public_doc.md +++ b/sdk_public_doc.md @@ -291,33 +291,40 @@ runtime setter for it. Listen to the outcome of a Web2App redemption (`{scheme}://ply/redeem/{token}`). -**Add the listener before `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. A listener that you add after `start()` -misses exactly the case it is most needed for. +Set the listener on the start chain, the same way the native SDKs do: ```typescript import Purchasely from 'react-native-purchasely'; -// Add the listener FIRST. -Purchasely.addWebRedemptionListener((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); - } -}); - -// Then start the SDK. await Purchasely.builder('YOUR_API_KEY') - .appHandlesRedemptionAlert(false) // default: the SDK shows its own popin + .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(); ``` -Call `Purchasely.removeWebRedemptionListener()` to remove it. +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. From f8b28204b4d1ea182287b3acfbedc46cf40eb559 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 12:03:37 +0200 Subject: [PATCH 19/26] fix(js): the chain listener replaces instead of stacking, and never leaks webRedemptionListener() subscribed on the spot and dropped the removal handle. Two consequences, both reported on #293: calling the modifier twice left two live subscriptions, so one redemption invoked both callbacks and could show two result screens; and a builder that was never started kept a live subscription forever, retaining a stale callback. The callback is now held in builder state and subscribed in start(), immediately before the native call. That still guarantees the listener is in place for a redemption settling during start(), which is the reason the modifier is on the chain at all. redemption.ts keeps the chain-owned subscription so a later chain replaces an earlier one. A listener added with addWebRedemptionListener is app-owned and is left untouched. Three tests reproduce the reported behaviour and were verified to fail against it. --- .../purchasely/src/__mocks__/emitterSpy.ts | 36 +++++++++ .../src/__tests__/startBuilder.test.ts | 80 +++++++++++++++---- packages/purchasely/src/redemption.ts | 29 +++++++ packages/purchasely/src/startBuilder.ts | 20 ++++- 4 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 packages/purchasely/src/__mocks__/emitterSpy.ts 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/__tests__/startBuilder.test.ts b/packages/purchasely/src/__tests__/startBuilder.test.ts index 97f5ef15..55f714d6 100644 --- a/packages/purchasely/src/__tests__/startBuilder.test.ts +++ b/packages/purchasely/src/__tests__/startBuilder.test.ts @@ -25,13 +25,19 @@ jest.mock('react-native', () => ({ handleDeeplink: jest.fn().mockResolvedValue(true), }, }, - NativeEventEmitter: jest.fn().mockImplementation(() => ({ - addListener: jest.fn(() => ({ remove: jest.fn() })), - removeAllListeners: jest.fn(), - })), + 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 { NativeEventEmitter, NativeModules } from 'react-native' +import { NativeModules } from 'react-native' +import emitterSpy from '../__mocks__/emitterSpy' import { PurchaselyBuilder } from '../startBuilder' const mockNative = NativeModules.Purchasely as any @@ -281,40 +287,80 @@ describe('PurchaselyBuilder', () => { }) 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() - const emitterMock = NativeEventEmitter as unknown as jest.Mock - const instance = emitterMock.mock.results[0]?.value - expect(instance).toBeDefined() - expect(instance.addListener).toHaveBeenCalledWith( + expect(emitterSpy.addListener).toHaveBeenCalledWith( 'WEB_REDEMPTION_LISTENER', callback ) }) - // The whole point of putting this on the chain: a redemption can - // settle while start() runs, so the listener must already exist by - // then. Subscribing at chain time, not inside start(), is what - // guarantees it. + // 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 }) - const builder = PurchaselyBuilder.apiKey('api-key') - builder.webRedemptionListener(() => {}) - order.push('subscribed') - await builder.start() + 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] + + 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() + }) + it('sets appHandlesRedemptionAlert from the optional second argument', async () => { await PurchaselyBuilder.apiKey('api-key') .webRedemptionListener(() => {}, true) diff --git a/packages/purchasely/src/redemption.ts b/packages/purchasely/src/redemption.ts index d42e0977..c8dd93b4 100644 --- a/packages/purchasely/src/redemption.ts +++ b/packages/purchasely/src/redemption.ts @@ -1,4 +1,5 @@ import { NativeEventEmitter, NativeModules } from 'react-native'; +import type { EmitterSubscription } from 'react-native'; import type { PLYWebRedemptionResult } from './types'; @@ -77,3 +78,31 @@ export const addWebRedemptionListener = ( export const removeWebRedemptionListener = () => { return emitter().removeAllListeners(WEB_REDEMPTION_EVENT); }; + +/** + * 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; + +/** + * 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 45587c9a..e345f5ad 100644 --- a/packages/purchasely/src/startBuilder.ts +++ b/packages/purchasely/src/startBuilder.ts @@ -2,7 +2,7 @@ import { NativeModules } from 'react-native'; import { LogLevels, RunningMode } from './enums'; import { - addWebRedemptionListener, + setBuilderWebRedemptionListener, type WebRedemptionListenerCallback, } from './redemption'; @@ -41,6 +41,7 @@ interface StartBuilderState { */ proxyApi?: string | null; appHandlesRedemptionAlert?: boolean | null; + webRedemptionCallback?: WebRedemptionListenerCallback; androidStores: AndroidStore[]; storekitVersion: StorekitVersion; } @@ -219,9 +220,13 @@ export class PurchaselyBuilder { callback: WebRedemptionListenerCallback, appHandlesRedemptionAlert?: boolean ): this { - // Subscribed now, not at start(), so the listener is already in place - // for a redemption that settles while start() runs. - addWebRedemptionListener(callback); + // 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; } @@ -319,6 +324,13 @@ export class PurchaselyBuilder { 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, androidStoreNames, From 2701f90b8fc84aa348974607f131cbb5f5595c92 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 12:03:37 +0200 Subject: [PATCH 20/26] test(ios): cover the web redemption payload policy The iOS bridge had three tests on the redemption feature and all three only checked that a symbol exists: the event name, the protocol conformance and the selector. Nothing checked the payload the delegate builds, which is where the null policy and the context nesting live. PLYWebRedemptionResult has no public initializer, so a test cannot build one. The body construction is split into a class method that takes the parts, which makes the policy testable: which key holds NSNull, that a present context with no subscription stays distinct from no context at all, and that the same five keys appear on success and on failure. Six tests, verified to fail when the two null cases are collapsed. Android already covered the equivalent mapping in PurchaselyModuleTest.kt. --- packages/purchasely/ios/PurchaselyRN.h | 18 ++++ packages/purchasely/ios/PurchaselyRN.m | 40 +++++-- .../ios/PurchaselyTests/PurchaselyRNTests.m | 101 ++++++++++++++++++ 3 files changed, 149 insertions(+), 10 deletions(-) diff --git a/packages/purchasely/ios/PurchaselyRN.h b/packages/purchasely/ios/PurchaselyRN.h index 0c7909a7..bd791562 100644 --- a/packages/purchasely/ios/PurchaselyRN.h +++ b/packages/purchasely/ios/PurchaselyRN.h @@ -11,6 +11,24 @@ @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; @property (class, nonatomic, strong) UIViewController *sharedViewController; diff --git a/packages/purchasely/ios/PurchaselyRN.m b/packages/purchasely/ios/PurchaselyRN.m index 3f7e4947..47ad4c34 100644 --- a/packages/purchasely/ios/PurchaselyRN.m +++ b/packages/purchasely/ios/PurchaselyRN.m @@ -1401,21 +1401,41 @@ - (void)onUserAttributeRemovedWithKey:(NSString * _Nonnull)key - (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 (result.context != nil) { - PLYSubscription *subscription = result.context.subscription; - context = @{ @"subscription": subscription != nil ? subscription.asDictionary : [NSNull null] }; + if (hasContext) { + context = @{ @"subscription": subscription ?: [NSNull null] }; } - NSDictionary *body = @{ - @"isSuccess": @(result.isSuccess), + // 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": @(result.replay), - @"errorCode": result.errorCode ?: [NSNull null], - @"errorMessage": result.errorMessage ?: [NSNull null] + @"replay": @(replay), + @"errorCode": errorCode ?: [NSNull null], + @"errorMessage": errorMessage ?: [NSNull null] }; - - [self sendEventWithName:@"WEB_REDEMPTION_LISTENER" body:body]; } - (void)purchasePerformed { diff --git a/packages/purchasely/ios/PurchaselyTests/PurchaselyRNTests.m b/packages/purchasely/ios/PurchaselyTests/PurchaselyRNTests.m index 745d9df1..c861de23 100644 --- a/packages/purchasely/ios/PurchaselyTests/PurchaselyRNTests.m +++ b/packages/purchasely/ios/PurchaselyTests/PurchaselyRNTests.m @@ -369,6 +369,107 @@ - (void)testWebRedemptionCompletedIsImplemented { @"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"], From d142c7149cff12c5cdd5eb4da7e8fa2318323b41 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 12:07:38 +0200 Subject: [PATCH 21/26] fix(test): guard an indexed access so the definition build passes yarn prepare generates declarations with a stricter tsconfig than yarn typecheck uses, so an unguarded emitterSpy.subscriptions[0] passed typecheck and then failed the declaration build with TS18048. Both E2E jobs stop at that step, which is how it surfaced. docs: correct the CLAUDE.md claim that native tests cannot run in CI. Both suites have run in CI since 2026-07-23: Android JUnit in the build-android job and iOS XCTest in its own iOS Unit Tests (bridge) job, both driven through the example project because they need the React Native dependencies. The CI job list was also two years stale and missing four jobs. Both documented commands were run before committing. --- CLAUDE.md | 49 +++++++++++++------ .../src/__tests__/startBuilder.test.ts | 3 +- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ec9230e8..1f972800 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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' - -# 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 +# 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) - 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) diff --git a/packages/purchasely/src/__tests__/startBuilder.test.ts b/packages/purchasely/src/__tests__/startBuilder.test.ts index 55f714d6..75bd5ee2 100644 --- a/packages/purchasely/src/__tests__/startBuilder.test.ts +++ b/packages/purchasely/src/__tests__/startBuilder.test.ts @@ -343,6 +343,7 @@ describe('PurchaselyBuilder', () => { .webRedemptionListener(first) .start() const firstSubscription = emitterSpy.subscriptions[0] + expect(firstSubscription).toBeDefined() const replacement = jest.fn() mockNative.start = jest.fn().mockResolvedValue(true) @@ -350,7 +351,7 @@ describe('PurchaselyBuilder', () => { .webRedemptionListener(replacement) .start() - expect(firstSubscription.remove).toHaveBeenCalled() + expect(firstSubscription?.remove).toHaveBeenCalled() }) // Also reported: subscribing at chain time meant an abandoned builder From e7c38496aefccdeb577f0bb7af4953a3b7ed5435 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 12:14:21 +0200 Subject: [PATCH 22/26] fix(js): clear the chain handle when redemption listeners are removed removeWebRedemptionListener() left builderSubscription pointing at a subscription it had already torn down. React Native's removeAllListeners goes straight to RCTDeviceEventEmitter and settles the native count itself, while the per-subscription remove() closure still believes it owns a listener, so the next chain registration sent a second removeListeners(1) for a listener already accounted for. On iOS that is not a harmless miscount. RCTEventEmitter.m does _listenerCount = MAX(_listenerCount - count, 0) and calls stopObserving the moment it reaches zero, and stopObserving clears shouldEmit, which gates every event the module sends. One extra decrement could silence analytics and the presentation lifecycle while their listeners were still registered. Reported on #293. The reporter's mechanism was a double remove, which React Native already guards by nulling the closure; the real path is removeAllListeners bypassing that closure entirely. --- .../src/__tests__/startBuilder.test.ts | 23 ++++++++ packages/purchasely/src/redemption.ts | 59 ++++++++++++------- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/packages/purchasely/src/__tests__/startBuilder.test.ts b/packages/purchasely/src/__tests__/startBuilder.test.ts index 75bd5ee2..e341ac93 100644 --- a/packages/purchasely/src/__tests__/startBuilder.test.ts +++ b/packages/purchasely/src/__tests__/startBuilder.test.ts @@ -38,6 +38,7 @@ jest.mock('react-native', () => ({ import { NativeModules } from 'react-native' import emitterSpy from '../__mocks__/emitterSpy' +import { removeWebRedemptionListener } from '../redemption' import { PurchaselyBuilder } from '../startBuilder' const mockNative = NativeModules.Purchasely as any @@ -362,6 +363,28 @@ describe('PurchaselyBuilder', () => { 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) diff --git a/packages/purchasely/src/redemption.ts b/packages/purchasely/src/redemption.ts index c8dd93b4..a01ccb63 100644 --- a/packages/purchasely/src/redemption.ts +++ b/packages/purchasely/src/redemption.ts @@ -27,6 +27,27 @@ const emitter = (): NativeEventEmitter => { 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; + +/** + * 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 + */ /** @internal */ export const WEB_REDEMPTION_EVENT = 'WEB_REDEMPTION_LISTENER'; @@ -74,32 +95,28 @@ export const addWebRedemptionListener = ( return emitter().addListener(WEB_REDEMPTION_EVENT, callback); }; -/** Remove every listener added with {@link addWebRedemptionListener}. */ -export const removeWebRedemptionListener = () => { - return emitter().removeAllListeners(WEB_REDEMPTION_EVENT); -}; - /** - * The chain-owned subscription, if any. + * Remove every listener on the redemption event, whoever added it. * - * 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. + * 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. * - * @internal + * 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. */ -let builderSubscription: EmitterSubscription | undefined; +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 => { From 5a0c60e96abc4f35951f2e5ad26d5ed05521d784 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 12:14:21 +0200 Subject: [PATCH 23/26] fix(bridge): report the web checkout subscription source A subscription bought through web checkout reported no source at all on Android: the storeType mapping listed four values and StoreType.WEB_CHECKOUT_STRIPE fell into else -> null. iOS passed its raw value through, but the JS enum had no member for it, so neither platform gave a caller a usable answer. This matters for 6.1.0 specifically. Web2App redemption grants subscriptions from that source, so PLYWebRedemptionResult context.subscription is the payload most likely to carry it. Adds the Android when branch, the sourceStripe constant on both bridges, the Constants field, and SubscriptionSource.WEB_CHECKOUT_STRIPE. Also widens purchaseToken, nextRenewalDate and cancelledDate to string | null. Making them optional was not enough: iOS omits the keys, but Android's PLYSubscription.toMap() assigns each one unconditionally from a nullable field, so a caller receives an explicit null. Verified against the 6.1.0 tag. --- .../reactnativepurchasely/PurchaselyModule.kt | 6 ++++++ packages/purchasely/ios/PurchaselyRN.m | 1 + .../purchasely/src/__mocks__/testUtils.ts | 1 + .../purchasely/src/__tests__/enums.test.ts | 20 ++++++++++++++++++- packages/purchasely/src/enums.ts | 5 +++++ packages/purchasely/src/interfaces.ts | 1 + packages/purchasely/src/types.ts | 20 +++++++++++-------- 7 files changed, 45 insertions(+), 9 deletions(-) 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 3aa761e0..b4d09289 100644 --- a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt +++ b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt @@ -139,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 @@ -1410,11 +1411,16 @@ fun decrementUserAttribute(key: String, value: Double, legalBasis: String?) { */ 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) { diff --git a/packages/purchasely/ios/PurchaselyRN.m b/packages/purchasely/ios/PurchaselyRN.m index 47ad4c34..f74f6eb4 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), 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/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/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/types.ts b/packages/purchasely/src/types.ts index 0a42fffd..9c3edb8d 100644 --- a/packages/purchasely/src/types.ts +++ b/packages/purchasely/src/types.ts @@ -126,20 +126,24 @@ export type PLYSubscription = { * 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. Optional so iOS callers see `undefined` instead of a required field - * that is silently absent. Same reasoning as + * 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; + purchaseToken?: string | null; subscriptionSource: SubscriptionSource; /** * Absent when the subscription has no renewal date. The iOS bridge omits - * the key when the native date is `nil`, so read it as optional rather than - * as an empty string. + * the key when the native date is `nil`; Android reports an explicit + * `null`. Never read it as an empty string. */ - nextRenewalDate?: string; - /** Absent when the subscription is not cancelled. See {@link nextRenewalDate}. */ - cancelledDate?: string; + nextRenewalDate?: string | null; + /** Absent or null when the subscription is not cancelled. See {@link nextRenewalDate}. */ + cancelledDate?: string | null; plan: PLYPlan; product: PLYProduct; /** From dc170877efd06cef372f88df7d29506edbaba013 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 12:25:09 +0200 Subject: [PATCH 24/26] docs: the masked email hint reaches the listener on Android too The docs said the expired-link email hint was iOS only. It is not. Android 6.1.0 RedemptionOutcome.Expired.toResult() builds errorMessage as " A new link was sent to .", the same string the docs showed as the iOS example, and its own source comments it as "masked email = PII". The wording was actively harmful: an integrator would gate the do-not-log rule on Platform.OS === 'ios' and forward personal data to analytics on Android. Corrected in the type doc, the listener doc, the public guide and the example app, with an explicit instruction not to make the rule platform-specific. The other half of the claim holds on both platforms: Android's toEvent drops the hint, so REDEMPTION_FAILED never carries it. Found by an independent review of the pull request. --- example/src/App.tsx | 7 ++++--- packages/purchasely/src/redemption.ts | 30 ++++++++++++++------------- packages/purchasely/src/types.ts | 18 +++++++++------- sdk_public_doc.md | 10 +++++---- 4 files changed, 36 insertions(+), 29 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index d8f2862f..8dd9fb86 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -46,9 +46,10 @@ function App(): React.JSX.Element { result.context?.subscription?.plan?.vendorId ) } else { - // On iOS, 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. + // 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 + diff --git a/packages/purchasely/src/redemption.ts b/packages/purchasely/src/redemption.ts index a01ccb63..c6a5536d 100644 --- a/packages/purchasely/src/redemption.ts +++ b/packages/purchasely/src/redemption.ts @@ -38,16 +38,6 @@ const emitter = (): NativeEventEmitter => { */ let builderSubscription: EmitterSubscription | undefined; -/** - * 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 - */ /** @internal */ export const WEB_REDEMPTION_EVENT = 'WEB_REDEMPTION_LISTENER'; @@ -84,10 +74,12 @@ export type WebRedemptionListenerCallback = ( * - 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 iOS only**, `result.errorMessage` for an expired link can contain a - * masked email address, so the app can tell the user where the fresh link - * went. The `REDEMPTION_FAILED` event drops it. Show that text to the user. - * Do not forward it to an analytics stack or to a crash reporter. + * - **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 @@ -117,6 +109,16 @@ export const removeWebRedemptionListener = () => { 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 => { diff --git a/packages/purchasely/src/types.ts b/packages/purchasely/src/types.ts index 9c3edb8d..796fa39b 100644 --- a/packages/purchasely/src/types.ts +++ b/packages/purchasely/src/types.ts @@ -318,9 +318,9 @@ export type PLYEventPropertyRedemptionPurchaseContext = { * 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. The SDK - * gives that hint to the web redemption listener only, on iOS. See - * `Purchasely.addWebRedemptionListener`. + * 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. */ @@ -471,11 +471,13 @@ export type PLYWebRedemptionResult = { * Human-readable reason, in English. Null on success. It never contains the * token. * - * **On iOS only**, 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. The - * `REDEMPTION_FAILED` event drops that hint on purpose. Show this text to - * the user. Do not send it to an analytics stack or to a crash reporter. + * **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 d7d170d5..f5c49bef 100644 --- a/sdk_public_doc.md +++ b/sdk_public_doc.md @@ -355,10 +355,12 @@ Three behaviours to know: - 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 iOS only**, `errorMessage` for an expired link can contain a masked - email address, so you can tell the user where the fresh link went. Show that - text to the user. Do not send it to an analytics stack or to a crash - reporter. The `REDEMPTION_FAILED` event drops it. +- **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 From d2ba578f3e9152526531eaa123aa6412843aca9b Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 12:25:09 +0200 Subject: [PATCH 25/26] fix(ios): warn instead of red-boxing a host app on a bad option RCTLogError shows a full-screen RedBox in a debug build, so an invalid anonymousUserId or an unparsable proxy string looked like a crash even though start() continues and only the modifier is skipped. Android logs with Log.e and shows no UI, so the two platforms disagreed on how loud a skippable misconfiguration is. RCTLogWarn matches the rest of this file, matches Android, and still surfaces the mistake at integration time. Also moves a docblock in redemption.ts onto the function it describes, and replaces an empty-string cancelledDate in the redemption fixture with null, which is what Android actually reports. --- packages/purchasely/ios/PurchaselyRN.m | 4 ++-- packages/purchasely/src/__tests__/types.test.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/purchasely/ios/PurchaselyRN.m b/packages/purchasely/ios/PurchaselyRN.m index f74f6eb4..b28728f9 100644 --- a/packages/purchasely/ios/PurchaselyRN.m +++ b/packages/purchasely/ios/PurchaselyRN.m @@ -576,7 +576,7 @@ - (NSDictionary *)constantsToExport { if ([anonymousUserId isKindOfClass:[NSString class]]) { NSUUID *parsed = [[NSUUID alloc] initWithUUIDString:(NSString *)anonymousUserId]; if (parsed == nil) { - RCTLogError(@"[Purchasely] `anonymousUserId` must be a canonical UUID string, " + 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 { @@ -602,7 +602,7 @@ - (NSDictionary *)constantsToExport { } else if ([proxyApi isKindOfClass:[NSString class]]) { NSURL *proxyUrl = [NSURL URLWithString:(NSString *)proxyApi]; if (proxyUrl == nil) { - RCTLogError(@"[Purchasely] `proxy` must be an https base URL, " + RCTLogWarn(@"[Purchasely] `proxy` must be an https base URL, " "for example \"https://svc.purchasely.io\". Received \"%@\". " "The proxy is not applied.", proxyApi); } else { diff --git a/packages/purchasely/src/__tests__/types.test.ts b/packages/purchasely/src/__tests__/types.test.ts index 86aea3b0..ea82ac68 100644 --- a/packages/purchasely/src/__tests__/types.test.ts +++ b/packages/purchasely/src/__tests__/types.test.ts @@ -630,7 +630,9 @@ describe('Purchasely Types', () => { purchaseToken: 'token-123', subscriptionSource: SubscriptionSource.APPLE_APP_STORE, nextRenewalDate: '2024-02-15T12:00:00Z', - cancelledDate: '', + // Android reports an explicit null here, iOS omits the + // key. Never an empty string. + cancelledDate: null, plan: { vendorId: 'monthly-plan', productId: 'premium-product', From 194b1a02683a30c3edf9cfb4887111e83e03dc69 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 12:33:25 +0200 Subject: [PATCH 26/26] docs: document the nullable subscription fields and the web checkout source The Retrieve User Subscriptions section printed subscriptionSource, nextRenewalDate and cancelledDate without saying any of them can be absent, and the enum's new WEB_CHECKOUT_STRIPE member was undocumented. Adds the platform asymmetry (iOS omits the key, Android sends an explicit null), the Android-only nature of purchaseToken, and a table of subscriptionSource values noting that a Web2App redemption reports the web checkout source. --- sdk_public_doc.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/sdk_public_doc.md b/sdk_public_doc.md index f5c49bef..6f1565fa 100644 --- a/sdk_public_doc.md +++ b/sdk_public_doc.md @@ -666,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. ---