@virex-tech/paywallo-sdk 1.5.3 → 2.0.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.
@@ -0,0 +1,19 @@
1
+ #import <React/RCTBridgeModule.h>
2
+ #import <React/RCTEventEmitter.h>
3
+
4
+ @interface RCT_EXTERN_MODULE(PaywalloNotifications, RCTEventEmitter)
5
+
6
+ RCT_EXTERN_METHOD(requestPermission:(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(getInitialNotification:(RCTPromiseResolveBlock)resolve
17
+ reject:(RCTPromiseRejectBlock)reject)
18
+
19
+ @end
@@ -0,0 +1,263 @@
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 (same pattern as PaywalloStoreKit)
14
+ // hasListeners is read/written from bufferQueue to avoid races between
15
+ // startObserving (main thread) and bufferOrEmit (NotificationCenter delivery thread).
16
+ private var _hasListeners = false
17
+ private var preSubscribeBuffer: [[String: Any]] = []
18
+ private let bufferQueue = DispatchQueue(label: "com.paywallo.notifications.buffer")
19
+ private static let maxBufferSize = 100
20
+
21
+ // MARK: - State
22
+ private static var latestAPNSToken: String?
23
+ private static var initialNotification: [String: Any]?
24
+
25
+ // MARK: - RCTEventEmitter Overrides
26
+
27
+ override static func requiresMainQueueSetup() -> Bool { return false }
28
+
29
+ override func supportedEvents() -> [String] {
30
+ return [
31
+ PaywalloNotifications.tokenRefreshEvent,
32
+ PaywalloNotifications.messageReceivedEvent,
33
+ PaywalloNotifications.notificationOpenedEvent,
34
+ ]
35
+ }
36
+
37
+ override func startObserving() {
38
+ bufferQueue.sync { _hasListeners = true }
39
+ drainPreSubscribeBuffer()
40
+ }
41
+
42
+ override func stopObserving() {
43
+ bufferQueue.sync { _hasListeners = false }
44
+ }
45
+
46
+ // MARK: - Buffer Management
47
+
48
+ private func bufferOrEmit(eventName: String, body: [String: Any]) {
49
+ // Determine whether to emit or buffer inside the lock, but never call
50
+ // sendEvent (which may dispatch back to main) while holding the lock —
51
+ // that would risk deadlock or re-entrancy on a sync queue.
52
+ let shouldEmit: Bool = bufferQueue.sync {
53
+ if _hasListeners {
54
+ return true
55
+ }
56
+ var entry = body
57
+ entry["_eventName"] = eventName
58
+ if preSubscribeBuffer.count >= PaywalloNotifications.maxBufferSize {
59
+ preSubscribeBuffer.removeFirst()
60
+ }
61
+ preSubscribeBuffer.append(entry)
62
+ return false
63
+ }
64
+ if shouldEmit {
65
+ sendEvent(withName: eventName, body: body)
66
+ }
67
+ }
68
+
69
+ private func drainPreSubscribeBuffer() {
70
+ // Copy and clear while holding the lock; emit outside the lock.
71
+ let drained: [[String: Any]] = bufferQueue.sync {
72
+ let copy = preSubscribeBuffer
73
+ preSubscribeBuffer.removeAll()
74
+ return copy
75
+ }
76
+ for entry in drained {
77
+ var body = entry
78
+ guard let eventName = body.removeValue(forKey: "_eventName") as? String else { continue }
79
+ sendEvent(withName: eventName, body: body)
80
+ }
81
+ }
82
+
83
+ // MARK: - Public Methods (exposed to JS)
84
+
85
+ @objc func requestPermission(
86
+ _ options: NSDictionary,
87
+ resolve: @escaping RCTPromiseResolveBlock,
88
+ reject: @escaping RCTPromiseRejectBlock
89
+ ) {
90
+ let provisional = options["provisional"] as? Bool ?? false
91
+ let center = UNUserNotificationCenter.current()
92
+
93
+ var authOptions: UNAuthorizationOptions = [.alert, .sound, .badge]
94
+ if provisional {
95
+ authOptions.insert(.provisional)
96
+ }
97
+
98
+ center.requestAuthorization(options: authOptions) { granted, error in
99
+ if let error = error {
100
+ reject("PERMISSION_ERROR", error.localizedDescription, error)
101
+ return
102
+ }
103
+
104
+ DispatchQueue.main.async {
105
+ UIApplication.shared.registerForRemoteNotifications()
106
+ }
107
+
108
+ // Return status string matching SDK's PushPermissionStatus type
109
+ self.getPermissionStatusString { status in
110
+ resolve(status)
111
+ }
112
+ }
113
+ }
114
+
115
+ @objc func hasPermission(
116
+ _ resolve: @escaping RCTPromiseResolveBlock,
117
+ reject: @escaping RCTPromiseRejectBlock
118
+ ) {
119
+ getPermissionStatusString { status in
120
+ resolve(status)
121
+ }
122
+ }
123
+
124
+ @objc func getToken(
125
+ _ resolve: @escaping RCTPromiseResolveBlock,
126
+ reject: @escaping RCTPromiseRejectBlock
127
+ ) {
128
+ if let token = PaywalloNotifications.latestAPNSToken {
129
+ resolve(token)
130
+ } else {
131
+ resolve(nil)
132
+ }
133
+ }
134
+
135
+ @objc func getInitialNotification(
136
+ _ resolve: @escaping RCTPromiseResolveBlock,
137
+ reject: @escaping RCTPromiseRejectBlock
138
+ ) {
139
+ if let notification = PaywalloNotifications.initialNotification {
140
+ PaywalloNotifications.initialNotification = nil // consume once
141
+ resolve(notification)
142
+ } else {
143
+ resolve(nil)
144
+ }
145
+ }
146
+
147
+ // MARK: - Permission Helper
148
+
149
+ private func getPermissionStatusString(completion: @escaping (String) -> Void) {
150
+ UNUserNotificationCenter.current().getNotificationSettings { settings in
151
+ let status: String
152
+ switch settings.authorizationStatus {
153
+ case .authorized: status = "granted"
154
+ case .denied: status = "denied"
155
+ case .provisional: status = "provisional"
156
+ case .notDetermined: status = "notDetermined"
157
+ case .ephemeral: status = "ephemeral"
158
+ @unknown default: status = "notDetermined"
159
+ }
160
+ completion(status)
161
+ }
162
+ }
163
+
164
+ // MARK: - Static methods called from swizzled AppDelegate
165
+
166
+ @objc static func didRegisterForRemoteNotifications(deviceToken: Data) {
167
+ let token = deviceToken.map { String(format: "%02x", $0) }.joined()
168
+ latestAPNSToken = token
169
+ // Emit to JS — need to find the module instance through the bridge
170
+ // This is handled via NotificationCenter since we can't access the bridge statically
171
+ NotificationCenter.default.post(
172
+ name: NSNotification.Name("PaywalloAPNSTokenReceived"),
173
+ object: nil,
174
+ userInfo: ["token": token]
175
+ )
176
+ }
177
+
178
+ @objc static func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) {
179
+ let payload = extractPayload(from: userInfo)
180
+ NotificationCenter.default.post(
181
+ name: NSNotification.Name("PaywalloRemoteNotificationReceived"),
182
+ object: nil,
183
+ userInfo: ["payload": payload]
184
+ )
185
+ }
186
+
187
+ @objc static func didOpenNotification(userInfo: [AnyHashable: Any]) {
188
+ let payload = extractPayload(from: userInfo)
189
+ // If module not ready yet, store as initial notification
190
+ initialNotification = payload
191
+ NotificationCenter.default.post(
192
+ name: NSNotification.Name("PaywalloNotificationOpened"),
193
+ object: nil,
194
+ userInfo: ["payload": payload]
195
+ )
196
+ }
197
+
198
+ // MARK: - Payload Extraction
199
+
200
+ private static func extractPayload(from userInfo: [AnyHashable: Any]) -> [String: Any] {
201
+ var payload: [String: Any] = [:]
202
+
203
+ if let aps = userInfo["aps"] as? [String: Any] {
204
+ if let alert = aps["alert"] as? [String: Any] {
205
+ payload["title"] = alert["title"] as? String ?? ""
206
+ payload["body"] = alert["body"] as? String ?? ""
207
+ } else if let alert = aps["alert"] as? String {
208
+ payload["body"] = alert
209
+ }
210
+ }
211
+
212
+ // Extract custom data (everything except "aps")
213
+ var data: [String: String] = [:]
214
+ for (key, value) in userInfo {
215
+ guard let key = key as? String, key != "aps" else { continue }
216
+ data[key] = "\(value)"
217
+ }
218
+ if !data.isEmpty {
219
+ payload["data"] = data
220
+ }
221
+
222
+ return payload
223
+ }
224
+
225
+ // MARK: - NotificationCenter observers (bridge between static calls and instance)
226
+
227
+ override init() {
228
+ super.init()
229
+ PaywalloNotificationsSwizzle.swizzleIfNeeded()
230
+
231
+ NotificationCenter.default.addObserver(
232
+ self, selector: #selector(onAPNSTokenReceived(_:)),
233
+ name: NSNotification.Name("PaywalloAPNSTokenReceived"), object: nil
234
+ )
235
+ NotificationCenter.default.addObserver(
236
+ self, selector: #selector(onRemoteNotificationReceived(_:)),
237
+ name: NSNotification.Name("PaywalloRemoteNotificationReceived"), object: nil
238
+ )
239
+ NotificationCenter.default.addObserver(
240
+ self, selector: #selector(onNotificationOpened(_:)),
241
+ name: NSNotification.Name("PaywalloNotificationOpened"), object: nil
242
+ )
243
+ }
244
+
245
+ deinit {
246
+ NotificationCenter.default.removeObserver(self)
247
+ }
248
+
249
+ @objc private func onAPNSTokenReceived(_ notification: Notification) {
250
+ guard let token = notification.userInfo?["token"] as? String else { return }
251
+ bufferOrEmit(eventName: PaywalloNotifications.tokenRefreshEvent, body: ["token": token])
252
+ }
253
+
254
+ @objc private func onRemoteNotificationReceived(_ notification: Notification) {
255
+ guard let payload = notification.userInfo?["payload"] as? [String: Any] else { return }
256
+ bufferOrEmit(eventName: PaywalloNotifications.messageReceivedEvent, body: payload)
257
+ }
258
+
259
+ @objc private func onNotificationOpened(_ notification: Notification) {
260
+ guard let payload = notification.userInfo?["payload"] as? [String: Any] else { return }
261
+ bufferOrEmit(eventName: PaywalloNotifications.notificationOpenedEvent, body: payload)
262
+ }
263
+ }
@@ -0,0 +1,126 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ /// Swizzles UIApplicationDelegate methods to intercept APNS callbacks.
5
+ /// Runs automatically at load time via swizzleIfNeeded() called from PaywalloNotifications.init().
6
+ /// Same approach used by OneSignal, Firebase, and other push SDKs.
7
+ @objc class PaywalloNotificationsSwizzle: NSObject {
8
+
9
+ private static var hasSwizzled = false
10
+
11
+ /// Call once at module init time to swizzle AppDelegate APNS methods.
12
+ @objc static func swizzleIfNeeded() {
13
+ guard !hasSwizzled else { return }
14
+
15
+ // Defer to main queue — UIApplication.shared.delegate is set after app launch.
16
+ // Set hasSwizzled only after the delegate is confirmed, so a nil-delegate
17
+ // early call doesn't permanently suppress swizzling.
18
+ DispatchQueue.main.async {
19
+ guard let delegate = UIApplication.shared.delegate else { return }
20
+ guard !hasSwizzled else { return }
21
+ hasSwizzled = true
22
+ let delegateClass: AnyClass = type(of: delegate)
23
+
24
+ swizzleDidRegisterForRemoteNotifications(delegateClass)
25
+ swizzleDidFailToRegisterForRemoteNotifications(delegateClass)
26
+ swizzleDidReceiveRemoteNotification(delegateClass)
27
+ }
28
+ }
29
+
30
+ // MARK: - didRegisterForRemoteNotificationsWithDeviceToken
31
+
32
+ private static func swizzleDidRegisterForRemoteNotifications(_ cls: AnyClass) {
33
+ let original = #selector(UIApplicationDelegate.application(_:didRegisterForRemoteNotificationsWithDeviceToken:))
34
+ let swizzled = #selector(PaywalloNotificationsSwizzle.pw_didRegisterForRemoteNotifications(_:deviceToken:))
35
+ swizzle(cls: cls, original: original, swizzled: swizzled)
36
+ }
37
+
38
+ // MARK: - didFailToRegisterForRemoteNotificationsWithError
39
+
40
+ private static func swizzleDidFailToRegisterForRemoteNotifications(_ cls: AnyClass) {
41
+ let original = #selector(UIApplicationDelegate.application(_:didFailToRegisterForRemoteNotificationsWithError:))
42
+ let swizzled = #selector(PaywalloNotificationsSwizzle.pw_didFailToRegisterForRemoteNotifications(_:error:))
43
+ swizzle(cls: cls, original: original, swizzled: swizzled)
44
+ }
45
+
46
+ // MARK: - didReceiveRemoteNotification (with completion handler)
47
+
48
+ private static func swizzleDidReceiveRemoteNotification(_ cls: AnyClass) {
49
+ let original = #selector(UIApplicationDelegate.application(_:didReceiveRemoteNotification:fetchCompletionHandler:))
50
+ let swizzled = #selector(PaywalloNotificationsSwizzle.pw_didReceiveRemoteNotification(_:userInfo:completionHandler:))
51
+ swizzle(cls: cls, original: original, swizzled: swizzled)
52
+ }
53
+
54
+ // MARK: - Core swizzle helper
55
+
56
+ /// Track whether the original method existed before swizzle for each selector,
57
+ /// so the pw_ implementations know whether to forward (exchange case) or not (add case).
58
+ private static var didExchange: [String: Bool] = [:]
59
+
60
+ /// Exchanges implementations if the method exists on cls, otherwise adds it directly.
61
+ private static func swizzle(cls: AnyClass, original: Selector, swizzled: Selector) {
62
+ guard let swizzledMethod = class_getInstanceMethod(PaywalloNotificationsSwizzle.self, swizzled) else {
63
+ return
64
+ }
65
+
66
+ let key = NSStringFromSelector(original)
67
+
68
+ if let originalMethod = class_getInstanceMethod(cls, original) {
69
+ // Method exists on the AppDelegate — exchange so both run.
70
+ // After exchange: calling `original` runs our pw_ body,
71
+ // calling `pw_` from within pw_ runs the ORIGINAL body (not recursion).
72
+ method_exchangeImplementations(originalMethod, swizzledMethod)
73
+ didExchange[key] = true
74
+ } else {
75
+ // Method not implemented in the host AppDelegate — add our impl directly.
76
+ // pw_ methods must NOT call themselves here (no original to forward to).
77
+ let types = method_getTypeEncoding(swizzledMethod)
78
+ class_addMethod(cls, original, method_getImplementation(swizzledMethod), types)
79
+ didExchange[key] = false
80
+ }
81
+ }
82
+
83
+ // MARK: - Swizzled implementations
84
+ // After method_exchangeImplementations:
85
+ // - The original selector on AppDelegate points to our pw_ body
86
+ // - Calling pw_ from within itself calls the ORIGINAL AppDelegate implementation
87
+ // (because the IMP under pw_ was exchanged to the original). Not infinite recursion.
88
+ //
89
+ // When class_addMethod was used instead (no original method):
90
+ // - There is no original to forward to, so we skip the self-call to avoid
91
+ // actual infinite recursion / stack overflow.
92
+
93
+ // All pw_ methods are called on the main thread (UIApplicationDelegate callbacks
94
+ // are main-thread-only). didExchange is written on main by swizzleIfNeeded, so
95
+ // reading it here is safe without additional locking.
96
+
97
+ @objc func pw_didRegisterForRemoteNotifications(_ application: UIApplication, deviceToken: Data) {
98
+ PaywalloNotifications.didRegisterForRemoteNotifications(deviceToken: deviceToken)
99
+ let key = NSStringFromSelector(#selector(UIApplicationDelegate.application(_:didRegisterForRemoteNotificationsWithDeviceToken:)))
100
+ if PaywalloNotificationsSwizzle.didExchange[key] == true {
101
+ pw_didRegisterForRemoteNotifications(application, deviceToken: deviceToken)
102
+ }
103
+ }
104
+
105
+ @objc func pw_didFailToRegisterForRemoteNotifications(_ application: UIApplication, error: Error) {
106
+ let key = NSStringFromSelector(#selector(UIApplicationDelegate.application(_:didFailToRegisterForRemoteNotificationsWithError:)))
107
+ if PaywalloNotificationsSwizzle.didExchange[key] == true {
108
+ pw_didFailToRegisterForRemoteNotifications(application, error: error)
109
+ }
110
+ }
111
+
112
+ @objc func pw_didReceiveRemoteNotification(
113
+ _ application: UIApplication,
114
+ userInfo: [AnyHashable: Any],
115
+ completionHandler: @escaping (UIBackgroundFetchResult) -> Void
116
+ ) {
117
+ PaywalloNotifications.didReceiveRemoteNotification(userInfo: userInfo)
118
+ let key = NSStringFromSelector(#selector(UIApplicationDelegate.application(_:didReceiveRemoteNotification:fetchCompletionHandler:)))
119
+ if PaywalloNotificationsSwizzle.didExchange[key] == true {
120
+ pw_didReceiveRemoteNotification(application, userInfo: userInfo, completionHandler: completionHandler)
121
+ } else {
122
+ // No original handler — we must call the completion handler ourselves
123
+ completionHandler(.noData)
124
+ }
125
+ }
126
+ }
@@ -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.3",
3
+ "version": "2.0.0",
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
  }