@virex-tech/paywallo-sdk 1.5.4 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ #import <React/RCTBridgeModule.h>
2
+ #import <React/RCTEventEmitter.h>
3
+
4
+ @interface RCT_EXTERN_MODULE(PaywalloNotifications, RCTEventEmitter)
5
+
6
+ RCT_EXTERN_METHOD(requestPermission:(nonnull NSDictionary *)options
7
+ resolve:(RCTPromiseResolveBlock)resolve
8
+ reject:(RCTPromiseRejectBlock)reject)
9
+
10
+ RCT_EXTERN_METHOD(hasPermission:(RCTPromiseResolveBlock)resolve
11
+ reject:(RCTPromiseRejectBlock)reject)
12
+
13
+ RCT_EXTERN_METHOD(getToken:(RCTPromiseResolveBlock)resolve
14
+ reject:(RCTPromiseRejectBlock)reject)
15
+
16
+ RCT_EXTERN_METHOD(registerForPush:(RCTPromiseResolveBlock)resolve
17
+ reject:(RCTPromiseRejectBlock)reject)
18
+
19
+ RCT_EXTERN_METHOD(getInitialNotification:(RCTPromiseResolveBlock)resolve
20
+ reject:(RCTPromiseRejectBlock)reject)
21
+
22
+ @end
@@ -0,0 +1,250 @@
1
+ import Foundation
2
+ import React
3
+ import UserNotifications
4
+
5
+ @objc(PaywalloNotifications)
6
+ class PaywalloNotifications: RCTEventEmitter {
7
+
8
+ // MARK: - Event Names
9
+ private static let tokenRefreshEvent = "PaywalloTokenRefresh"
10
+ private static let messageReceivedEvent = "PaywalloMessageReceived"
11
+ private static let notificationOpenedEvent = "PaywalloNotificationOpened"
12
+
13
+ // MARK: - Pre-subscribe buffer
14
+ private var _hasListeners = false
15
+ private var preSubscribeBuffer: [[String: Any]] = []
16
+ private let bufferQueue = DispatchQueue(label: "com.paywallo.notifications.buffer")
17
+ private static let maxBufferSize = 100
18
+
19
+ // MARK: - State
20
+ private static var latestAPNSToken: String?
21
+ private static var initialNotification: [String: Any]?
22
+
23
+ // MARK: - RCTEventEmitter Overrides
24
+
25
+ override static func requiresMainQueueSetup() -> Bool { return true }
26
+
27
+ override func supportedEvents() -> [String] {
28
+ return [
29
+ PaywalloNotifications.tokenRefreshEvent,
30
+ PaywalloNotifications.messageReceivedEvent,
31
+ PaywalloNotifications.notificationOpenedEvent,
32
+ ]
33
+ }
34
+
35
+ override func startObserving() {
36
+ bufferQueue.sync { _hasListeners = true }
37
+ drainPreSubscribeBuffer()
38
+ }
39
+
40
+ override func stopObserving() {
41
+ bufferQueue.sync { _hasListeners = false }
42
+ }
43
+
44
+ // MARK: - Buffer Management
45
+
46
+ private func bufferOrEmit(eventName: String, body: [String: Any]) {
47
+ let shouldEmit: Bool = bufferQueue.sync {
48
+ if _hasListeners { return true }
49
+ var entry = body
50
+ entry["_eventName"] = eventName
51
+ if preSubscribeBuffer.count >= PaywalloNotifications.maxBufferSize {
52
+ preSubscribeBuffer.removeFirst()
53
+ }
54
+ preSubscribeBuffer.append(entry)
55
+ return false
56
+ }
57
+ if shouldEmit {
58
+ DispatchQueue.main.async {
59
+ self.sendEvent(withName: eventName, body: body)
60
+ }
61
+ }
62
+ }
63
+
64
+ private func drainPreSubscribeBuffer() {
65
+ let drained: [[String: Any]] = bufferQueue.sync {
66
+ let copy = preSubscribeBuffer
67
+ preSubscribeBuffer.removeAll()
68
+ return copy
69
+ }
70
+ for entry in drained {
71
+ var body = entry
72
+ guard let eventName = body.removeValue(forKey: "_eventName") as? String else { continue }
73
+ DispatchQueue.main.async {
74
+ self.sendEvent(withName: eventName, body: body)
75
+ }
76
+ }
77
+ }
78
+
79
+ // MARK: - Public Methods (exposed to JS)
80
+
81
+ @objc(requestPermission:resolve:reject:)
82
+ func requestPermission(
83
+ _ options: NSDictionary,
84
+ resolve: @escaping RCTPromiseResolveBlock,
85
+ reject: @escaping RCTPromiseRejectBlock
86
+ ) {
87
+ let provisional = options["provisional"] as? Bool ?? false
88
+ let center = UNUserNotificationCenter.current()
89
+
90
+ var authOptions: UNAuthorizationOptions = [.alert, .sound, .badge]
91
+ if provisional {
92
+ authOptions.insert(.provisional)
93
+ }
94
+
95
+ center.requestAuthorization(options: authOptions) { [weak self] _, error in
96
+ guard let self = self else {
97
+ DispatchQueue.main.async { reject("MODULE_RELEASED", "Module was released", nil) }
98
+ return
99
+ }
100
+
101
+ if let error = error {
102
+ DispatchQueue.main.async { reject("PERMISSION_ERROR", error.localizedDescription, error) }
103
+ return
104
+ }
105
+
106
+ self.getPermissionStatusString { status in
107
+ DispatchQueue.main.async { resolve(status) }
108
+ }
109
+ }
110
+ }
111
+
112
+ @objc(hasPermission:reject:)
113
+ func hasPermission(
114
+ _ resolve: @escaping RCTPromiseResolveBlock,
115
+ reject: @escaping RCTPromiseRejectBlock
116
+ ) {
117
+ getPermissionStatusString { status in
118
+ DispatchQueue.main.async { resolve(status) }
119
+ }
120
+ }
121
+
122
+ @objc(getToken:reject:)
123
+ func getToken(
124
+ _ resolve: @escaping RCTPromiseResolveBlock,
125
+ reject: @escaping RCTPromiseRejectBlock
126
+ ) {
127
+ DispatchQueue.main.async {
128
+ if let token = PaywalloNotifications.latestAPNSToken {
129
+ resolve(token)
130
+ } else {
131
+ resolve(nil)
132
+ }
133
+ }
134
+ }
135
+
136
+ @objc(registerForPush:reject:)
137
+ func registerForPush(
138
+ _ resolve: @escaping RCTPromiseResolveBlock,
139
+ reject: @escaping RCTPromiseRejectBlock
140
+ ) {
141
+ DispatchQueue.main.async {
142
+ UIApplication.shared.registerForRemoteNotifications()
143
+ resolve(true)
144
+ }
145
+ }
146
+
147
+ @objc(getInitialNotification:reject:)
148
+ func getInitialNotification(
149
+ _ resolve: @escaping RCTPromiseResolveBlock,
150
+ reject: @escaping RCTPromiseRejectBlock
151
+ ) {
152
+ DispatchQueue.main.async {
153
+ if let notification = PaywalloNotifications.initialNotification {
154
+ PaywalloNotifications.initialNotification = nil
155
+ resolve(notification)
156
+ } else {
157
+ resolve(nil)
158
+ }
159
+ }
160
+ }
161
+
162
+ // MARK: - Permission Helper
163
+
164
+ private func getPermissionStatusString(completion: @escaping (String) -> Void) {
165
+ UNUserNotificationCenter.current().getNotificationSettings { settings in
166
+ let status: String
167
+ switch settings.authorizationStatus {
168
+ case .authorized: status = "granted"
169
+ case .denied: status = "denied"
170
+ case .provisional: status = "provisional"
171
+ case .notDetermined: status = "notDetermined"
172
+ case .ephemeral: status = "ephemeral"
173
+ @unknown default: status = "notDetermined"
174
+ }
175
+ DispatchQueue.main.async { completion(status) }
176
+ }
177
+ }
178
+
179
+ // MARK: - Payload Extraction
180
+
181
+ private static func extractPayload(from userInfo: [AnyHashable: Any]) -> [String: Any] {
182
+ var payload: [String: Any] = [:]
183
+
184
+ if let aps = userInfo["aps"] as? [String: Any] {
185
+ if let alert = aps["alert"] as? [String: Any] {
186
+ payload["title"] = alert["title"] as? String ?? ""
187
+ payload["body"] = alert["body"] as? String ?? ""
188
+ } else if let alert = aps["alert"] as? String {
189
+ payload["body"] = alert
190
+ }
191
+ }
192
+
193
+ var data: [String: String] = [:]
194
+ for (key, value) in userInfo {
195
+ guard let key = key as? String, key != "aps" else { continue }
196
+ data[key] = "\(value)"
197
+ }
198
+ if !data.isEmpty {
199
+ payload["data"] = data
200
+ }
201
+
202
+ return payload
203
+ }
204
+
205
+ // MARK: - Init: observe token from AppDelegate
206
+
207
+ override init() {
208
+ super.init()
209
+
210
+ NotificationCenter.default.addObserver(
211
+ self, selector: #selector(onAPNSTokenReceived(_:)),
212
+ name: NSNotification.Name("PaywalloAPNSTokenReceived"), object: nil
213
+ )
214
+ NotificationCenter.default.addObserver(
215
+ self, selector: #selector(onRemoteNotificationReceived(_:)),
216
+ name: NSNotification.Name("PaywalloRemoteNotificationReceived"), object: nil
217
+ )
218
+ NotificationCenter.default.addObserver(
219
+ self, selector: #selector(onNotificationOpened(_:)),
220
+ name: NSNotification.Name("PaywalloNotificationOpened"), object: nil
221
+ )
222
+
223
+ // Check if a token was already captured before this observer registered (race condition fix)
224
+ if let existing = PaywalloNotifications.latestAPNSToken {
225
+ bufferOrEmit(eventName: PaywalloNotifications.tokenRefreshEvent, body: ["token": existing])
226
+ }
227
+ }
228
+
229
+ deinit {
230
+ NotificationCenter.default.removeObserver(self)
231
+ }
232
+
233
+ @objc private func onAPNSTokenReceived(_ notification: Notification) {
234
+ guard let token = notification.userInfo?["token"] as? String else { return }
235
+ DispatchQueue.main.async {
236
+ PaywalloNotifications.latestAPNSToken = token
237
+ self.bufferOrEmit(eventName: PaywalloNotifications.tokenRefreshEvent, body: ["token": token])
238
+ }
239
+ }
240
+
241
+ @objc private func onRemoteNotificationReceived(_ notification: Notification) {
242
+ guard let payload = notification.userInfo?["payload"] as? [String: Any] else { return }
243
+ bufferOrEmit(eventName: PaywalloNotifications.messageReceivedEvent, body: payload)
244
+ }
245
+
246
+ @objc private func onNotificationOpened(_ notification: Notification) {
247
+ guard let payload = notification.userInfo?["payload"] as? [String: Any] else { return }
248
+ bufferOrEmit(eventName: PaywalloNotifications.notificationOpenedEvent, body: payload)
249
+ }
250
+ }
@@ -1,6 +1,9 @@
1
1
  #import <React/RCTBridgeModule.h>
2
+ #import <React/RCTEventEmitter.h>
2
3
 
3
- @interface RCT_EXTERN_MODULE(PaywalloStoreKit, NSObject)
4
+ @interface RCT_EXTERN_MODULE(PaywalloStoreKit, RCTEventEmitter)
5
+
6
+ RCT_EXTERN_METHOD(supportedEvents)
4
7
 
5
8
  RCT_EXTERN_METHOD(getProducts:(NSArray *)productIds
6
9
  resolve:(RCTPromiseResolveBlock)resolve
@@ -1,11 +1,28 @@
1
1
  import Foundation
2
2
  import StoreKit
3
+ import React
3
4
 
4
5
  @available(iOS 15.0, *)
5
6
  @objc(PaywalloStoreKit)
6
- class PaywalloStoreKit: NSObject {
7
+ class PaywalloStoreKit: RCTEventEmitter {
7
8
 
8
9
  private var updateListenerTask: Task<Void, Never>?
10
+ private var hasListeners = false
11
+
12
+ // Buffer for transaction events that arrive before JS subscribes via
13
+ // NativeEventEmitter. StoreKit 2 starts delivering Transaction.updates
14
+ // as soon as the module is constructed (cold start), but JS may not
15
+ // have called addListener yet. Without buffering, those payloads are
16
+ // silently dropped and sales go untracked.
17
+ // In-memory only; capped at maxBufferedEvents to avoid unbounded growth.
18
+ private var preSubscribeBuffer: [[String: Any]] = []
19
+ private let bufferQueue = DispatchQueue(label: "com.paywallo.storekit.buffer")
20
+ private static let maxBufferedEvents = 100
21
+
22
+ // Event name emitted for every StoreKit 2 Transaction.updates signal
23
+ // (renewals, refunds, cancellations). JS side subscribes via
24
+ // NativeEventEmitter and fans the payload out to the EventBatcher.
25
+ private static let transactionUpdateEvent = "PaywalloTransactionUpdate"
9
26
 
10
27
  override init() {
11
28
  super.init()
@@ -16,22 +33,154 @@ class PaywalloStoreKit: NSObject {
16
33
  updateListenerTask?.cancel()
17
34
  }
18
35
 
36
+ // MARK: - RCTEventEmitter overrides
37
+
38
+ override func supportedEvents() -> [String]! {
39
+ return [PaywalloStoreKit.transactionUpdateEvent]
40
+ }
41
+
42
+ override func startObserving() {
43
+ hasListeners = true
44
+ drainPreSubscribeBuffer()
45
+ NotificationCenter.default.addObserver(
46
+ self,
47
+ selector: #selector(handleAppForeground),
48
+ name: UIApplication.didBecomeActiveNotification,
49
+ object: nil
50
+ )
51
+ }
52
+
53
+ override func stopObserving() {
54
+ hasListeners = false
55
+ NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil)
56
+ }
57
+
58
+ @objc private func handleAppForeground() {
59
+ guard updateListenerTask == nil || updateListenerTask!.isCancelled else { return }
60
+ #if DEBUG
61
+ print("[Paywallo:StoreKit] Restarting transaction listener after foreground")
62
+ #endif
63
+ startTransactionListener()
64
+ }
65
+
66
+ /// Drains any transaction events that were buffered before JS subscribed,
67
+ /// emitting them in FIFO order. Called from `startObserving()` once the
68
+ /// JS-side NativeEventEmitter has registered its listener.
69
+ private func drainPreSubscribeBuffer() {
70
+ let buffered: [[String: Any]] = bufferQueue.sync {
71
+ let copy = preSubscribeBuffer
72
+ preSubscribeBuffer.removeAll()
73
+ return copy
74
+ }
75
+ guard !buffered.isEmpty else { return }
76
+ #if DEBUG
77
+ print("[Paywallo:StoreKit] Draining \(buffered.count) pre-subscribe transaction event(s)")
78
+ #endif
79
+ for payload in buffered {
80
+ sendEvent(withName: PaywalloStoreKit.transactionUpdateEvent, body: payload)
81
+ }
82
+ }
83
+
84
+ override static func requiresMainQueueSetup() -> Bool {
85
+ return false
86
+ }
87
+
19
88
  // MARK: - Transaction Listener
20
89
 
21
90
  private func startTransactionListener() {
22
91
  updateListenerTask = Task { [weak self] in
23
92
  for await result in Transaction.updates {
24
- guard let _ = self else { return }
93
+ guard let self = self else { return }
25
94
  if case .verified(let transaction) = result {
26
95
  #if DEBUG
27
- print("[Paywallo:StoreKit] Transaction update: \(transaction.productID) status=\(transaction.revocationDate != nil ? "revoked" : "active") id=\(transaction.id)")
96
+ print("[Paywallo:StoreKit] Transaction update: \(transaction.productID) revoked=\(transaction.revocationDate != nil) id=\(transaction.id)")
28
97
  #endif
98
+ await self.emitTransactionUpdate(transaction)
29
99
  await transaction.finish()
30
100
  }
31
101
  }
32
102
  }
33
103
  }
34
104
 
105
+ /// Classifies a StoreKit 2 `Transaction` coming from `Transaction.updates`
106
+ /// into one of three domain events — `renewed`, `refunded`, `canceled` —
107
+ /// and forwards a minimal payload to JS via RCTEventEmitter.
108
+ ///
109
+ /// Classification rules:
110
+ /// - `revocationDate != nil` + `revocationReason == .developerIssue` → `canceled`
111
+ /// - `revocationDate != nil` (any other reason, incl. `.other`) → `refunded`
112
+ /// - otherwise → `renewed` (auto-renewable subscription renewal or
113
+ /// restored purchase)
114
+ private func emitTransactionUpdate(_ transaction: Transaction) async {
115
+ let type: String
116
+ if transaction.revocationDate != nil {
117
+ if let reason = transaction.revocationReason, reason == .developerIssue {
118
+ type = "canceled"
119
+ } else {
120
+ type = "refunded"
121
+ }
122
+ } else {
123
+ type = "renewed"
124
+ }
125
+
126
+ var payload: [String: Any] = [
127
+ "type": type,
128
+ "transactionId": String(transaction.id),
129
+ "productId": transaction.productID,
130
+ ]
131
+
132
+ // Best-effort amount + currency lookup. Products are cached on the JS
133
+ // side but StoreKit 2 also lets us fetch by id synchronously-ish; if
134
+ // the fetch fails we simply omit the fields (server-side webhook will
135
+ // still carry the truth).
136
+ if let priceInfo = await resolveProductPrice(productId: transaction.productID) {
137
+ payload["amount"] = priceInfo.amount
138
+ payload["currency"] = priceInfo.currency
139
+ }
140
+
141
+ if hasListeners {
142
+ sendEvent(withName: PaywalloStoreKit.transactionUpdateEvent, body: payload)
143
+ } else {
144
+ // JS not yet subscribed (cold-start race). Buffer up to
145
+ // maxBufferedEvents; drop oldest on overflow to bound memory.
146
+ bufferQueue.sync {
147
+ if preSubscribeBuffer.count >= PaywalloStoreKit.maxBufferedEvents {
148
+ preSubscribeBuffer.removeFirst()
149
+ #if DEBUG
150
+ print("[Paywallo:StoreKit] Pre-subscribe buffer overflow — dropping oldest event")
151
+ #endif
152
+ }
153
+ preSubscribeBuffer.append(payload)
154
+ }
155
+ #if DEBUG
156
+ print("[Paywallo:StoreKit] Buffered transaction event (no JS listener yet): \(transaction.productID)")
157
+ #endif
158
+ }
159
+ }
160
+
161
+ private func resolveProductPrice(productId: String) async -> (amount: Double, currency: String)? {
162
+ do {
163
+ let products = try await Product.products(for: [productId])
164
+ guard let product = products.first else { return nil }
165
+ let amount = NSDecimalNumber(decimal: product.price).doubleValue
166
+ var currency: String = "USD"
167
+ if let json = try? JSONSerialization.jsonObject(with: product.jsonRepresentation) as? [String: Any],
168
+ let attrs = json["attributes"] as? [String: Any],
169
+ let offers = attrs["offers"] as? [[String: Any]],
170
+ let firstOffer = offers.first,
171
+ let currencyCode = firstOffer["currencyCode"] as? String {
172
+ currency = currencyCode
173
+ } else if #available(iOS 16, *) {
174
+ currency = Locale.current.currency?.identifier ?? "USD"
175
+ } else {
176
+ currency = Locale.current.currencyCode ?? "USD"
177
+ }
178
+ return (amount, currency)
179
+ } catch {
180
+ return nil
181
+ }
182
+ }
183
+
35
184
  // MARK: - Get Products
36
185
 
37
186
  @objc
@@ -263,9 +412,4 @@ class PaywalloStoreKit: NSObject {
263
412
  return "P\(period.value)D"
264
413
  }
265
414
  }
266
-
267
- @objc
268
- static func requiresMainQueueSetup() -> Bool {
269
- return false
270
- }
271
415
  }
@@ -195,7 +195,7 @@
195
195
  // data:, and blob: — to prevent mixed-content and injection attacks.
196
196
  NSString *scheme = url.scheme.lowercaseString;
197
197
  NSSet<NSString *> *allowedSchemes = [NSSet setWithObjects:
198
- @"https", @"about", @"mailto", @"itms-apps", @"itms-services", nil];
198
+ @"https", @"about", @"mailto", @"itms-apps", nil];
199
199
  if (scheme && ![allowedSchemes containsObject:scheme]) {
200
200
  decisionHandler(WKNavigationActionPolicyCancel);
201
201
  return;
@@ -203,8 +203,7 @@
203
203
 
204
204
  // mailto: and App Store links must be opened by the OS, not loaded inside the WebView.
205
205
  if ([scheme isEqualToString:@"mailto"] ||
206
- [scheme isEqualToString:@"itms-apps"] ||
207
- [scheme isEqualToString:@"itms-services"]) {
206
+ [scheme isEqualToString:@"itms-apps"]) {
208
207
  [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
209
208
  decisionHandler(WKNavigationActionPolicyCancel);
210
209
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@virex-tech/paywallo-sdk",
3
- "version": "1.5.4",
3
+ "version": "2.0.1",
4
4
  "description": "SDK React Native para integração com Paywallo - Paywalls, Subscriptions e Analytics",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -11,7 +11,8 @@
11
11
  "react-native": "./dist/index.mjs",
12
12
  "import": "./dist/index.mjs",
13
13
  "require": "./dist/index.js"
14
- }
14
+ },
15
+ "./package.json": "./package.json"
15
16
  },
16
17
  "files": [
17
18
  "dist",
@@ -42,7 +43,9 @@
42
43
  "react-native",
43
44
  "mobile",
44
45
  "subscription",
45
- "paywallo"
46
+ "paywallo",
47
+ "push-notifications",
48
+ "fcm"
46
49
  ],
47
50
  "author": "Virex Tech",
48
51
  "license": "MIT",
@@ -62,11 +65,15 @@
62
65
  "node": ">=18.0.0"
63
66
  },
64
67
  "peerDependencies": {
68
+ "@react-native-firebase/app": "^22.0.0",
65
69
  "expo-tracking-transparency": "*",
66
70
  "react": "*",
67
71
  "react-native": "*"
68
72
  },
69
73
  "peerDependenciesMeta": {
74
+ "@react-native-firebase/app": {
75
+ "optional": true
76
+ },
70
77
  "expo-tracking-transparency": {
71
78
  "optional": true
72
79
  }
@@ -86,6 +93,6 @@
86
93
  "vitest": "^4.1.2"
87
94
  },
88
95
  "dependencies": {
89
- "@virex-tech/paywallo-sdk": "^1.5.2"
96
+ "zod": "^4.3.6"
90
97
  }
91
98
  }