@purchasely/cordova-plugin-purchasely 6.0.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,40 @@
27
27
  }
28
28
  }
29
29
 
30
+ /// `PLYWebRedemptionDelegate`. The SDK calls this on the main thread, once per settled
31
+ /// redemption, on success and on failure alike, and always after the matching
32
+ /// REDEMPTION_CONSUMED / REDEMPTION_FAILED event reached the event delegate.
33
+ ///
34
+ /// Mapped to the flat 5-key shape the Android bridge emits, so one JS listener drives both
35
+ /// platforms. `context` and `context.subscription` stay separately nullable: a success can
36
+ /// carry no context at all, and a present context can carry no subscription.
37
+ ///
38
+ /// `errorMessage` can hold the backend's masked email hint for an expired link. The
39
+ /// REDEMPTION_FAILED event drops that hint on purpose; this channel keeps it, so the app
40
+ /// can tell the user where the fresh link went.
41
+ ///
42
+ /// The Android bridge carries it too: `RedemptionOutcome.Expired.toResult()` appends the
43
+ /// same hint. So the "show it, never log it" rule the JS docs state is unconditional, and
44
+ /// must not be written as an iOS-only caveat.
45
+ - (void)webRedemptionCompletedWithResult:(PLYWebRedemptionResult * _Nonnull)result {
46
+ if (self.webRedemptionCommand == nil) {
47
+ return;
48
+ }
49
+
50
+ PLYSubscription *subscription = result.context.subscription;
51
+ NSDictionary<NSString *, id> *body =
52
+ [CDVPurchasely webRedemptionBodyWithSuccess:result.isSuccess
53
+ hasContext:result.context != nil
54
+ subscription:subscription != nil ? subscription.asDictionary : nil
55
+ replay:result.replay
56
+ errorCode:result.errorCode
57
+ errorMessage:result.errorMessage];
58
+
59
+ CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:body];
60
+ [pluginResult setKeepCallbackAsBool:YES];
61
+ [self.commandDelegate sendPluginResult:pluginResult callbackId:self.webRedemptionCommand.callbackId];
62
+ }
63
+
30
64
  - (void)reloadContent: (NSNotification *)aNotification {
31
65
  if (self.purchasedCommand) {
32
66
  CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK];
@@ -8,11 +8,59 @@
8
8
  #import <Cordova/CDVPlugin.h>
9
9
  #import <Purchasely/Purchasely-Swift.h>
10
10
 
11
+ /// How the `proxy` start option resolves. Purchasely 6.1.0.
12
+ ///
13
+ /// The three JS states are NOT interchangeable, and a fourth case exists for a value the
14
+ /// bridge cannot convert. `proxyWithApi:` takes an `NSURL *_Nullable`, where nil means
15
+ /// CLEAR, so an unconvertible string must skip the modifier rather than pass nil: passing
16
+ /// nil would silently disable a proxy the app explicitly asked for, because of a typo.
17
+ typedef NS_ENUM(NSInteger, CDVPurchaselyProxyOption) {
18
+ /// The key is absent. Make no native call: leave the current setting untouched.
19
+ CDVPurchaselyProxyOptionAbsent = 0,
20
+ /// The key is present and null. Call `proxyWithApi:nil` to clear the proxy.
21
+ CDVPurchaselyProxyOptionClear,
22
+ /// The key holds a convertible string. Call `proxyWithApi:` with the URL.
23
+ CDVPurchaselyProxyOptionSet,
24
+ /// The key holds a string `NSURL` cannot convert. Log and make no native call.
25
+ CDVPurchaselyProxyOptionInvalid
26
+ };
27
+
11
28
  // Protocol conformance (PLYEventDelegate / PLYUserAttributeDelegate) is declared on the
12
29
  // CDVPurchasely (Events) and (UserAttributes) categories, which implement the delegate methods.
13
30
  @interface CDVPurchasely : CDVPlugin {
14
31
  }
15
32
 
33
+ /// Resolve the `proxy` start option to one of the four cases above.
34
+ ///
35
+ /// Pure, and exposed so a unit test drives the real bridge logic instead of a copy. `value`
36
+ /// is the raw option, so `nil` for an absent key and `NSNull` for an explicit JS null.
37
+ /// `outUrl` receives the URL only for `CDVPurchaselyProxyOptionSet`.
38
+ + (CDVPurchaselyProxyOption)proxyOptionFor:(id _Nullable)value url:(NSURL * _Nullable * _Nullable)outUrl;
39
+
40
+ /// Build the flat 5-key body a settled Web2App redemption reports to JS.
41
+ ///
42
+ /// Takes primitives rather than a `PLYWebRedemptionResult`, because that class declares
43
+ /// `init` unavailable and a test cannot construct one. `hasContext` is separate from
44
+ /// `subscription` on purpose: a present context carrying no subscription is NOT the same as
45
+ /// no context at all, and both must stay expressible.
46
+ ///
47
+ /// Exposed so the XCTest target asserts the real shape the delegate emits, rather than a
48
+ /// copy of it. Matches the React Native bridge's seam of the same name.
49
+ + (NSDictionary<NSString *, id> * _Nonnull)webRedemptionBodyWithSuccess:(BOOL)isSuccess
50
+ hasContext:(BOOL)hasContext
51
+ subscription:(NSDictionary * _Nullable)subscription
52
+ replay:(BOOL)replay
53
+ errorCode:(NSString * _Nullable)errorCode
54
+ errorMessage:(NSString * _Nullable)errorMessage;
55
+
56
+ /// Parse a canonical UUID string, or return nil.
57
+ ///
58
+ /// JS has no UUID type, so an anonymous user id crosses the bridge as a string. Exposed so
59
+ /// a unit test can pin the cross-platform contract: this refuses the lenient short form
60
+ /// (`"1-2-3-4-5"`) that Android's `UUID.fromString` accepts, which is why the Android
61
+ /// bridge adds a round-trip check.
62
+ + (NSUUID * _Nullable)canonicalUUIDFromString:(id _Nullable)value;
63
+
16
64
  // The presentation currently displayed (v6 uses id<PLYPresentation> for close()/back()).
17
65
  @property (nonatomic, strong) id<PLYPresentation> currentPresentation;
18
66
 
@@ -20,6 +68,12 @@
20
68
  @property CDVInvokedUrlCommand* eventCommand;
21
69
  @property CDVInvokedUrlCommand* attributeCommand;
22
70
 
71
+ // Purchasely 6.1.0. The command `addWebRedemptionListener` recorded, or nil. The
72
+ // PLYWebRedemptionDelegate is registered on the builder chain in `start:` (the native SDK
73
+ // has no runtime setter), so this is the only switch: nil makes
74
+ // `webRedemptionCompletedWithResult:` a no-op.
75
+ @property CDVInvokedUrlCommand* webRedemptionCommand;
76
+
23
77
  @property (nonatomic) NSMutableArray<id<PLYPresentation>> *presentationsLoaded;
24
78
 
25
79
  @property (nonatomic) CDVInvokedUrlCommand* purchaseResolve;
@@ -57,6 +111,9 @@
57
111
  - (void)userSubscriptionsHistory:(CDVInvokedUrlCommand*)command;
58
112
  - (void)addEventsListener:(CDVInvokedUrlCommand*)command;
59
113
  - (void)removeEventsListener:(CDVInvokedUrlCommand*)command;
114
+ - (void)releaseCallbackStream:(CDVInvokedUrlCommand * _Nullable)command;
115
+ - (void)addWebRedemptionListener:(CDVInvokedUrlCommand*)command;
116
+ - (void)removeWebRedemptionListener:(CDVInvokedUrlCommand*)command;
60
117
  - (void)registerActionInterceptor:(CDVInvokedUrlCommand*)command;
61
118
  - (void)unregisterActionInterceptor:(CDVInvokedUrlCommand*)command;
62
119
  - (void)completeActionInterceptor:(CDVInvokedUrlCommand*)command;
@@ -13,14 +13,96 @@
13
13
 
14
14
  @implementation CDVPurchasely
15
15
 
16
- - (instancetype)init {
17
- self = [super init];
16
+ // pluginInitialize is Cordova's setup hook and is called by every host, after
17
+ // viewController/webView/commandDelegate are assigned. The previous -init override was only
18
+ // reached on cordova-ios, whose -initWithWebViewEngine: calls [self init]; Capacitor's
19
+ // vendored CapacitorCordova calls [super init] instead, which dispatches to NSObject and
20
+ // never reaches a subclass override - so under Capacitor these collections stayed nil, and
21
+ // writing into a nil NSMutableDictionary/NSMutableArray is a silent no-op. That lost every
22
+ // interceptor callbackId (intercepted actions completed .notHandled without reaching JS) and
23
+ // every preloaded presentation ("Presentation not loaded" from preload/display).
24
+ - (void)pluginInitialize {
25
+ [super pluginInitialize];
18
26
 
19
27
  self.presentationsLoaded = [NSMutableArray new];
20
28
  self.actionInterceptorCallbackIds = [NSMutableDictionary new];
21
29
  self.pendingInterceptCompletions = [NSMutableDictionary new];
30
+ }
31
+
32
+ + (CDVPurchaselyProxyOption)proxyOptionFor:(id _Nullable)value url:(NSURL * _Nullable * _Nullable)outUrl {
33
+ if (outUrl != NULL) {
34
+ *outUrl = nil;
35
+ }
36
+ // An absent key and an explicit null are different operations. Check NSNull FIRST:
37
+ // it is a real object, so an `isKindOfClass:[NSString class]` test would fall through
38
+ // to the absent branch and turn a requested clear into a silent no-op.
39
+ if (value == nil) {
40
+ return CDVPurchaselyProxyOptionAbsent;
41
+ }
42
+ if (value == [NSNull null]) {
43
+ return CDVPurchaselyProxyOptionClear;
44
+ }
45
+ if (![value isKindOfClass:[NSString class]]) {
46
+ return CDVPurchaselyProxyOptionInvalid;
47
+ }
48
+ // Do not validate the scheme, the host, a query, a fragment or credentials here. The
49
+ // native SDK refuses those with an error log and keeps the production host, and it
50
+ // drops a trailing slash. The bridge only rejects what will not convert at all.
51
+ NSURL *url = [NSURL URLWithString:(NSString *)value];
52
+ if (url == nil) {
53
+ return CDVPurchaselyProxyOptionInvalid;
54
+ }
55
+ if (outUrl != NULL) {
56
+ *outUrl = url;
57
+ }
58
+ return CDVPurchaselyProxyOptionSet;
59
+ }
22
60
 
23
- return self;
61
+ + (NSDictionary<NSString *, id> * _Nonnull)webRedemptionBodyWithSuccess:(BOOL)isSuccess
62
+ hasContext:(BOOL)hasContext
63
+ subscription:(NSDictionary * _Nullable)subscription
64
+ replay:(BOOL)replay
65
+ errorCode:(NSString * _Nullable)errorCode
66
+ errorMessage:(NSString * _Nullable)errorMessage {
67
+ // Every key is always present, on both branches, so a JS listener reads one shape
68
+ // whether the redemption was granted or refused. Absence is NSNull, never a missing
69
+ // key: a missing key reaches JS as `undefined` instead of `null`.
70
+ id context = [NSNull null];
71
+ if (hasContext) {
72
+ context = @{ @"subscription": subscription ?: [NSNull null] };
73
+ }
74
+ return @{
75
+ @"isSuccess": @(isSuccess),
76
+ @"context": context,
77
+ @"replay": @(replay),
78
+ @"errorCode": errorCode ?: [NSNull null],
79
+ @"errorMessage": errorMessage ?: [NSNull null]
80
+ };
81
+ }
82
+
83
+ + (NSUUID * _Nullable)canonicalUUIDFromString:(id _Nullable)value {
84
+ if (![value isKindOfClass:[NSString class]]) {
85
+ return nil;
86
+ }
87
+ return [[NSUUID alloc] initWithUUIDString:(NSString *)value];
88
+ }
89
+
90
+ /// Cordova calls this when the WebView navigates, which invalidates every callbackId the
91
+ /// previous page handed us. Without it the stored commands stay live and every listener
92
+ /// callback is sent to a dead callbackId after a reload.
93
+ ///
94
+ /// The redemption case is the one that matters most: `webRedemptionDelegate:` is set on the
95
+ /// builder at `start:`, and the SDK holds the delegate weakly, so this object keeps
96
+ /// receiving outcomes for as long as the plugin lives.
97
+ /// `webRedemptionCompletedWithResult:` reads `webRedemptionCommand` at fire time, so a
98
+ /// reloaded page can re-register and keep working; clearing here makes the window in
99
+ /// between a clean no-op rather than a send on a dead callbackId.
100
+ - (void)onReset {
101
+ self.eventCommand = nil;
102
+ self.attributeCommand = nil;
103
+ self.webRedemptionCommand = nil;
104
+ self.purchasedCommand = nil;
105
+ [super onReset];
24
106
  }
25
107
 
26
108
  - (void)start:(CDVInvokedUrlCommand*)command {
@@ -85,6 +167,68 @@
85
167
  builder = [builder allowCampaigns:allowCampaigns.boolValue];
86
168
  }
87
169
 
170
+ // Both bridges report a refused-and-skipped option at the SAME severity, and
171
+ // deliberately with a plain log line on both: NSLog here, Log.e on Android. Neither
172
+ // renders UI. Do not reach for anything that puts an overlay or an alert in front of
173
+ // the host app: start() continues, the option was simply ignored, and a third-party
174
+ // SDK has no business interrupting someone else's app over an option it chose to skip.
175
+
176
+ // v6.1.0: JS has no UUID type, so the id crosses the bridge as a string and is parsed
177
+ // here. The native builder takes an NSUUID, which is where the guarantee used to live;
178
+ // a string-typed bridge is the only place left to catch a bad value. Refuse it loudly
179
+ // and skip the option. The SDK still starts, matching how native treats an unusable
180
+ // proxy url.
181
+ id anonymousUserId = opts[@"anonymousUserId"];
182
+ if ([anonymousUserId isKindOfClass:[NSString class]]) {
183
+ NSUUID *parsed = [CDVPurchasely canonicalUUIDFromString:anonymousUserId];
184
+ if (parsed == nil) {
185
+ // The value is NOT logged. A mis-wired field lands here just as easily as a
186
+ // typo -- an email, an appUserId -- and a device log is captured during
187
+ // support. The length is enough to tell a truncated id from a wrong field.
188
+ NSLog(@"[Purchasely] `anonymousUserId` must be a canonical UUID string, for example "
189
+ "\"3f2504e0-4f89-11d3-9a0c-0305e82c3301\". Received a %lu-character value. "
190
+ "The anonymous user id is not applied.",
191
+ (unsigned long)((NSString *)anonymousUserId).length);
192
+ } else {
193
+ NSNumber *override = opts[@"anonymousUserIdOverride"];
194
+ BOOL shouldOverride = [override isKindOfClass:[NSNumber class]] ? override.boolValue : NO;
195
+ builder = [builder appAnonymousUserId:parsed override:shouldOverride];
196
+ }
197
+ }
198
+
199
+ // v6.1.0: three states, and they are not interchangeable. An absent key makes no
200
+ // native call and leaves the current setting untouched; an explicit null clears the
201
+ // proxy and returns to api.purchasely.io, which is a supported operation and not an
202
+ // error; a string routes the API host. A value NSURL cannot convert skips the
203
+ // modifier, because `proxyWithApi:nil` means CLEAR, not "ignore this value", so
204
+ // passing nil would silently disable a proxy the app asked for.
205
+ NSURL *proxyUrl = nil;
206
+ switch ([CDVPurchasely proxyOptionFor:opts[@"proxy"] url:&proxyUrl]) {
207
+ case CDVPurchaselyProxyOptionAbsent:
208
+ break;
209
+ case CDVPurchaselyProxyOptionClear:
210
+ builder = [builder proxyWithApi:nil];
211
+ break;
212
+ case CDVPurchaselyProxyOptionSet:
213
+ builder = [builder proxyWithApi:proxyUrl];
214
+ break;
215
+ case CDVPurchaselyProxyOptionInvalid:
216
+ NSLog(@"[Purchasely] `proxy` must be an https base URL, for example "
217
+ "\"https://svc.purchasely.io\", or null to clear the proxy. Received "
218
+ "\"%@\". The proxy is not applied.", opts[@"proxy"]);
219
+ break;
220
+ }
221
+
222
+ // v6.1.0: registered unconditionally. The native SDK has no runtime setter on purpose,
223
+ // because a redemption can settle during `start()` (a cold start that the `ply/redeem`
224
+ // link itself triggered, or a token a previous launch left pending). The delegate
225
+ // callback returns early when `addWebRedemptionListener` recorded no command, so this
226
+ // is behaviour-neutral by default.
227
+ NSNumber *handlesRedemptionAlert = opts[@"appHandlesRedemptionAlert"];
228
+ builder = [builder webRedemptionDelegate:self
229
+ appHandlesRedemptionAlert:[handlesRedemptionAlert isKindOfClass:[NSNumber class]]
230
+ ? handlesRedemptionAlert.boolValue : NO];
231
+
88
232
  // Cold-start deeplink URL captured at launch (handled automatically once start completes).
89
233
  NSString *deeplink = opts[@"deeplink"];
90
234
  if ([deeplink isKindOfClass:[NSString class]] && deeplink.length > 0) {
@@ -521,6 +665,41 @@
521
665
  self.eventCommand = nil;
522
666
  }
523
667
 
668
+ /// End a kept-alive Cordova callback stream, freeing its JavaScript closure.
669
+ ///
670
+ /// Every result this bridge sends a listener carries `keepCallback:YES`, so dropping the
671
+ /// native command alone leaks the JS closure until the WebView reloads. NO_RESULT with
672
+ /// `keepCallback:NO` is cordova.js's own documented way out: it "is used to remove a
673
+ /// callback from the list without calling the callbacks".
674
+ - (void)releaseCallbackStream:(CDVInvokedUrlCommand * _Nullable)command {
675
+ if (command == nil) {
676
+ return;
677
+ }
678
+ CDVPluginResult *terminal = [CDVPluginResult resultWithStatus:CDVCommandStatus_NO_RESULT];
679
+ [terminal setKeepCallbackAsBool:NO];
680
+ [self.commandDelegate sendPluginResult:terminal callbackId:command.callbackId];
681
+ }
682
+
683
+ // v6.1.0. The PLYWebRedemptionDelegate is registered on the builder chain in `start:` (the
684
+ // native SDK has no runtime setter, because a redemption can settle during start()), so
685
+ // this action only records the command to route the outcome to. Call it BEFORE start().
686
+ - (void)addWebRedemptionListener:(CDVInvokedUrlCommand*)command {
687
+ // Close the previous stream before replacing it, or its JS closure stays in
688
+ // cordova.callbacks forever.
689
+ [self releaseCallbackStream:self.webRedemptionCommand];
690
+ self.webRedemptionCommand = command;
691
+ }
692
+
693
+ - (void)removeWebRedemptionListener:(CDVInvokedUrlCommand*)command {
694
+ // The delegate stays registered. Clearing the command makes
695
+ // `webRedemptionCompletedWithResult:` a no-op.
696
+ [self releaseCallbackStream:self.webRedemptionCommand];
697
+ self.webRedemptionCommand = nil;
698
+ // Acknowledge the remove itself, so ITS callbackId is freed too. A void action that
699
+ // never answers leaks its own entry exactly like the listener's.
700
+ [self successFor:command resultBool:YES];
701
+ }
702
+
524
703
  - (void)removeUserAttributeListener:(CDVInvokedUrlCommand*)command {
525
704
  // v6 `setUserAttributeDelegate:` is _Nonnull (no native unregister). Clearing
526
705
  // attributeCommand makes the user-attribute callbacks a no-op.
@@ -1266,6 +1445,18 @@ static BOOL PLYPresentationActionFromString(NSString *kind, PLYPresentationActio
1266
1445
  } else if (presentation != nil) {
1267
1446
  [self.presentationsLoaded addObject:presentation];
1268
1447
  [self successFor:command resultDict:[self resultDictionaryForFetchPresentation:presentation]];
1448
+ } else {
1449
+ // Neither branch taken means the Cordova command is never completed and the
1450
+ // JS promise returned by preload() stays pending for the life of the page:
1451
+ // no resolve, no reject, no error, nothing logged. Answer instead, so the
1452
+ // caller can handle it.
1453
+ //
1454
+ // Purchasely 6.0.0 does not produce this pair (PresentationRequest.swift
1455
+ // maps a success-with-nil presentation to PLYError.presentationNotLoaded),
1456
+ // so this is defence in depth against a future SDK, not a fix for a
1457
+ // reproduced failure. It costs one branch and removes a whole class of
1458
+ // silent hang.
1459
+ [self failureFor:command resultString: @"Presentation preload completed without a presentation and without an error"];
1269
1460
  }
1270
1461
  }];
1271
1462
  });
package/www/Purchasely.js CHANGED
@@ -59,11 +59,25 @@ function presentationDispatcher(success, callbacks) {
59
59
  // allowDeeplink (bool, optional)
60
60
  // allowCampaigns (bool, optional)
61
61
  // deeplink (string, optional — cold-start deeplink URL)
62
+ //
63
+ // Purchasely 6.1.0 adds four options:
64
+ // anonymousUserId (string, optional — a canonical UUID string; a bad value is
65
+ // logged and skipped, start() still succeeds)
66
+ // anonymousUserIdOverride (bool, optional — false; true SPLITS the user history)
67
+ // proxy (string|null, optional — Android+iOS. THREE STATES:
68
+ // 'https://…' routes the API host
69
+ // null CLEARS it, back to api.purchasely.io
70
+ // key absent leaves the current setting untouched
71
+ // A clear is a supported native operation, not an error.)
72
+ // appHandlesRedemptionAlert (bool, optional — false keeps the SDK popin, true hands the
73
+ // result screen to the app. See addWebRedemptionListener.)
74
+ //
75
+ // README.md "What is new in 6.1.0" is the reference for all four.
62
76
  exports.start = function (options, success, error) {
63
77
  var opts = options || {};
64
78
  var cordovaSdkVersion = cordova.define.moduleMap['cordova/plugin_list'].exports['metadata']['cordova-plugin-purchasely']
65
79
  if(!cordovaSdkVersion) {
66
- cordovaSdkVersion = "6.0.0";
80
+ cordovaSdkVersion = "6.1.0";
67
81
  }
68
82
  opts.sdkVersion = cordovaSdkVersion;
69
83
  exec(success, error, 'Purchasely', 'start', [opts]);
@@ -89,7 +103,76 @@ PLYStartBuilder.prototype.storekitVersion = function (value) { this._options.sto
89
103
  PLYStartBuilder.prototype.storeKit1 = function (value) { this._options.storeKit1 = value; return this; };
90
104
  PLYStartBuilder.prototype.deeplink = function (value) { this._options.deeplink = value; return this; };
91
105
 
106
+ // Purchasely 6.1.0. `id` must be a canonical UUID string; `override` defaults to false.
107
+ // See the exports.start option block for the full contract.
108
+ PLYStartBuilder.prototype.anonymousUserId = function (id, override) {
109
+ this._options.anonymousUserId = id;
110
+ this._options.anonymousUserIdOverride = override === undefined ? false : override;
111
+ return this;
112
+ };
113
+
114
+ // Purchasely 6.1.0. Pass an https base URL to route the API host, or null to CLEAR a
115
+ // proxy and return to api.purchasely.io. Both differ from never calling the modifier,
116
+ // which leaves the current setting untouched. See the exports.start option block.
117
+ //
118
+ // An argument is required. `proxy()` with none is refused, because the no-argument native
119
+ // modifiers disagree across platforms: iOS `proxy()` routes through Purchasely's own
120
+ // proxy at svc.purchasely.io, while Android `proxy()` clears. A Cordova shorthand would
121
+ // therefore mean two different things on the two platforms.
122
+ //
123
+ // `undefined` is never stored: JSON.stringify drops an undefined-valued key, which would
124
+ // make an explicit clear indistinguishable from an absent option on both natives.
125
+ PLYStartBuilder.prototype.proxy = function (api) {
126
+ // An explicit `undefined` is treated exactly like no argument at all, and NOT as null.
127
+ // The two public entry points have to agree on the same input: JSON.stringify drops an
128
+ // undefined-valued key, so `start({ proxy: undefined })` reaches native as Absent.
129
+ // Mapping it to null here would make `builder(k).proxy(config.proxy)` CLEAR the proxy
130
+ // whenever config.proxy has not loaded yet, which is the opposite of leaving it alone.
131
+ if (arguments.length === 0 || api === undefined) {
132
+ defaultError('[Purchasely] proxy() requires an argument: an https base URL, or ' +
133
+ 'null to clear the proxy. The proxy option is not applied.');
134
+ return this;
135
+ }
136
+ this._options.proxy = api;
137
+ return this;
138
+ };
139
+
140
+ // Purchasely 6.1.0. false (the default) keeps the SDK's own redemption popin.
141
+ PLYStartBuilder.prototype.appHandlesRedemptionAlert = function (handles) {
142
+ this._options.appHandlesRedemptionAlert = handles;
143
+ return this;
144
+ };
145
+
146
+ // Purchasely 6.1.0: the PRIMARY way to receive Web2App redemption outcomes.
147
+ //
148
+ // Purchasely.builder(apiKey).webRedemptionListener(onRedemption, true).start()
149
+ //
150
+ // The callback never crosses the bridge: each native registers ITSELF as the delegate at
151
+ // start() and forwards outcomes as a Cordova callback stream, so this is a JS concern only.
152
+ //
153
+ // STORED HERE, SUBSCRIBED IN start(), just before the native call. Do not move it back:
154
+ // subscribing at chain time leaks a live callback from a builder that is never started. A
155
+ // redemption can only settle once the SDK runs, so subscribing here still covers one that
156
+ // settles DURING start(), which is the case the feature exists for.
157
+ //
158
+ // 2nd argument = appHandlesRedemptionAlert; omitting it keeps the native default. Callback
159
+ // first, matching iOS -- the natives disagree on order (Android takes the flag first).
160
+ PLYStartBuilder.prototype.webRedemptionListener = function (callback, appHandlesRedemptionAlert) {
161
+ // Kept off _options on purpose: that object is the exec payload, and a callback has no
162
+ // business being serialized into it.
163
+ this._webRedemptionCallback = callback;
164
+ if (appHandlesRedemptionAlert !== undefined) {
165
+ this._options.appHandlesRedemptionAlert = appHandlesRedemptionAlert;
166
+ }
167
+ return this;
168
+ };
169
+
92
170
  PLYStartBuilder.prototype.start = function (success, error) {
171
+ // Subscribe immediately before the native start call, never earlier. See
172
+ // webRedemptionListener above for why this is deferred to here.
173
+ if (this._webRedemptionCallback) {
174
+ exports.addWebRedemptionListener(this._webRedemptionCallback);
175
+ }
93
176
  if (success) {
94
177
  exports.start(this._options, success, error);
95
178
  return undefined;
@@ -118,6 +201,37 @@ exports.addEventsListener = function (success, error) {
118
201
  exec(success, error, 'Purchasely', 'addEventsListener', []);
119
202
  };
120
203
 
204
+ // Purchasely 6.1.0: the outcome of a Web2App redemption ({scheme}://ply/redeem/{token}).
205
+ //
206
+ // SECONDARY PATH, for replacing the listener while the SDK already runs. Prefer
207
+ // builder(apiKey).webRedemptionListener(cb). Both share ONE native slot: last caller wins.
208
+ //
209
+ // success receives { isSuccess, context, replay, errorCode, errorMessage }:
210
+ // isSuccess Bool.
211
+ // context { subscription } or null, and `subscription` is separately nullable.
212
+ // Same shape as userSubscriptions(), so purchaseToken, nextRenewalDate and
213
+ // cancelledDate may be absent -- Android sends an explicit null, iOS omits
214
+ // the key. A truthiness check covers both; `!== undefined` does not.
215
+ // replay Bool. The SERVER says the token was redeemed before. False on failure.
216
+ // errorCode 'EXPIRED_REDEMPTION_TOKEN' | 'INVALID_REDEMPTION_TOKEN' | null.
217
+ // errorMessage Human-readable, English, or null. Never contains the token.
218
+ //
219
+ // Called on the main thread, exactly once per settled redemption.
220
+ //
221
+ // PRIVACY, BOTH PLATFORMS: errorMessage for an expired link can carry a MASKED EMAIL
222
+ // ADDRESS. Show it to the user; never log it or send it to analytics or a crash reporter.
223
+ // The rule is unconditional -- do NOT gate it on a platform check. The REDEMPTION_FAILED
224
+ // event drops the hint, so that channel is safe.
225
+ exports.addWebRedemptionListener = function (success, error) {
226
+ exec(success, error, 'Purchasely', 'addWebRedemptionListener', []);
227
+ };
228
+
229
+ // Purchasely 6.1.0: stop receiving redemption outcomes. The native delegate stays
230
+ // registered (it is fixed at start()); clearing the callback makes it a no-op.
231
+ exports.removeWebRedemptionListener = function () {
232
+ exec(() => {}, defaultError, 'Purchasely', 'removeWebRedemptionListener', []);
233
+ };
234
+
121
235
  exports.addUserAttributeListener = function(success, error) {
122
236
  exec(success, error, 'Purchasely', 'addUserAttributeListener', []);
123
237
  };
@@ -508,6 +622,11 @@ exports.userDidConsumeSubscriptionContent = function () {
508
622
 
509
623
  // PAR-29: invalidateCache forces a fresh fetch instead of returning the cached list
510
624
  // (native default false on both platforms).
625
+ // A subscription's purchaseToken, nextRenewalDate and cancelledDate can all be ABSENT, and
626
+ // the two platforms report absence differently: Android sends the key with an explicit
627
+ // null, iOS omits it entirely (and never emits purchaseToken at all, because the native
628
+ // PLYSubscription has no such property). Handle both -- a truthiness check covers them,
629
+ // `!== undefined` does not. Same shape as the web redemption context's `subscription`.
511
630
  exports.userSubscriptions = function (success, error, invalidateCache) {
512
631
  exec(success, defaultError, 'Purchasely', 'userSubscriptions', [!!invalidateCache]);
513
632
  };
@@ -731,12 +850,23 @@ exports.PurchaseResult = {
731
850
  RESTORED: 2
732
851
  }
733
852
 
853
+ // Values are the NATIVE raw values, verified against the shipped 6.1.0 artifacts:
854
+ // iOS PLYSubscriptionSource (stripe = 4, none = 5) and Android StoreType ordinals
855
+ // (WEB_CHECKOUT_STRIPE = 4, NONE = 5). Both platforms agree, so there is no
856
+ // per-platform mapping here and no renumbering hazard.
857
+ //
858
+ // `webCheckoutStripe` was missing and `none` was 4, which was correct before the native
859
+ // SDKs inserted the Stripe case at 4 (Android ~5.5.0) and pushed NONE to 5. A Web2App
860
+ // subscription therefore reported `none`, and a genuinely sourceless one reported a value
861
+ // this object had no name for. A Web2App redemption grants a subscription from exactly
862
+ // that source, so `context.subscription` is the payload most likely to carry it.
734
863
  exports.SubscriptionSource = {
735
864
  appleAppStore: 0,
736
865
  googlePlayStore: 1,
737
866
  amazonAppstore: 2,
738
867
  huaweiAppGallery: 3,
739
- none: 4
868
+ webCheckoutStripe: 4,
869
+ none: 5
740
870
  }
741
871
 
742
872
  exports.PlanType = {