@capgo/native-purchases 8.0.4 → 8.0.6

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.
@@ -50,7 +50,7 @@ repositories {
50
50
 
51
51
  dependencies {
52
52
  implementation "com.google.guava:guava:33.5.0-android"
53
- def billing_version = "8.2.0"
53
+ def billing_version = "8.2.1"
54
54
  implementation "com.android.billingclient:billing:$billing_version"
55
55
  implementation fileTree(dir: 'libs', include: ['*.jar'])
56
56
  implementation project(':capacitor-android')
@@ -42,7 +42,7 @@ import org.json.JSONArray;
42
42
  @CapacitorPlugin(name = "NativePurchases")
43
43
  public class NativePurchasesPlugin extends Plugin {
44
44
 
45
- private final String pluginVersion = "8.0.4";
45
+ private final String pluginVersion = "8.0.6";
46
46
  public static final String TAG = "NativePurchases";
47
47
  private static final Phaser semaphoreReady = new Phaser(1);
48
48
  private BillingClient billingClient;
@@ -2,10 +2,6 @@ import Foundation
2
2
  import Capacitor
3
3
  import StoreKit
4
4
 
5
- /**
6
- * Please read the Capacitor iOS Plugin Development Guide
7
- * here: https://capacitorjs.com/docs/plugins/ios
8
- */
9
5
  @objc(NativePurchasesPlugin)
10
6
  public class NativePurchasesPlugin: CAPPlugin, CAPBridgedPlugin {
11
7
  public let identifier = "NativePurchasesPlugin"
@@ -24,7 +20,7 @@ public class NativePurchasesPlugin: CAPPlugin, CAPBridgedPlugin {
24
20
  CAPPluginMethod(name: "isEntitledToOldBusinessModel", returnType: CAPPluginReturnPromise)
25
21
  ]
26
22
 
27
- private let pluginVersion: String = "8.0.4"
23
+ private let pluginVersion: String = "8.0.6"
28
24
  private var transactionUpdatesTask: Task<Void, Never>?
29
25
 
30
26
  @objc func getPluginVersion(_ call: CAPPluginCall) {
@@ -33,38 +29,28 @@ public class NativePurchasesPlugin: CAPPlugin, CAPBridgedPlugin {
33
29
 
34
30
  override public func load() {
35
31
  super.load()
36
- // Start listening to StoreKit transaction updates as early as possible
37
- if #available(iOS 15.0, *) {
38
- startTransactionUpdatesListener()
39
- }
32
+ startTransactionUpdatesListener()
40
33
  }
41
34
 
42
35
  deinit {
43
- if #available(iOS 15.0, *) { cancelTransactionUpdatesListener() }
44
- }
45
-
46
- private func cancelTransactionUpdatesListener() {
47
- self.transactionUpdatesTask?.cancel()
48
- self.transactionUpdatesTask = nil
36
+ transactionUpdatesTask?.cancel()
37
+ transactionUpdatesTask = nil
49
38
  }
50
39
 
51
- @available(iOS 15.0, *)
52
40
  private func startTransactionUpdatesListener() {
53
- // Ensure only one listener is running
54
- cancelTransactionUpdatesListener()
55
- let task = Task.detached { [weak self] in
41
+ transactionUpdatesTask?.cancel()
42
+ transactionUpdatesTask = Task.detached { [weak self] in
56
43
  for await result in Transaction.updates {
57
44
  guard !Task.isCancelled else { break }
58
45
  switch result {
59
46
  case .verified(let transaction):
60
- // Build payload similar to purchase response
61
- let payload = await TransactionHelpers.buildTransactionResponse(from: transaction, jwsRepresentation: result.jwsRepresentation, alwaysIncludeWillCancel: true)
62
-
63
- // Finish the transaction to avoid blocking future purchases
47
+ let payload = await TransactionHelpers.buildTransactionResponse(
48
+ from: transaction,
49
+ jwsRepresentation: result.jwsRepresentation,
50
+ alwaysIncludeWillCancel: true
51
+ )
64
52
  await transaction.finish()
65
-
66
- // Notify JS listeners on main thread, after slight delay
67
- try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s delay
53
+ try? await Task.sleep(nanoseconds: 500_000_000)
68
54
  await MainActor.run {
69
55
  self?.notifyListeners("transactionUpdated", data: payload)
70
56
  }
@@ -78,93 +64,70 @@ public class NativePurchasesPlugin: CAPPlugin, CAPBridgedPlugin {
78
64
  }
79
65
  }
80
66
  }
81
- transactionUpdatesTask = task
82
67
  }
83
68
 
84
- // MARK: - Plugin Methods
85
-
86
69
  @objc func isBillingSupported(_ call: CAPPluginCall) {
87
- if #available(iOS 15, *) {
88
- call.resolve([
89
- "isBillingSupported": true
90
- ])
91
- } else {
92
- call.resolve([
93
- "isBillingSupported": false
94
- ])
95
- }
70
+ call.resolve(["isBillingSupported": true])
96
71
  }
97
72
 
98
73
  @objc func purchaseProduct(_ call: CAPPluginCall) {
99
- if #available(iOS 15, *) {
100
- print("purchaseProduct")
101
- let productIdentifier = call.getString("productIdentifier", "")
102
- let quantity = call.getInt("quantity", 1)
103
- let appAccountToken = call.getString("appAccountToken")
104
- let autoAcknowledge = call.getBool("autoAcknowledgePurchases") ?? true
105
-
106
- if productIdentifier.isEmpty {
107
- call.reject("productIdentifier is Empty, give an id")
108
- return
109
- }
110
-
111
- print("Auto-acknowledge enabled: \(autoAcknowledge)")
112
-
113
- Task { @MainActor in
114
- do {
115
- let products = try await Product.products(for: [productIdentifier])
116
- guard let product = products.first else {
117
- call.reject("Cannot find product for id \(productIdentifier)")
118
- return
119
- }
74
+ print("purchaseProduct")
75
+ let productIdentifier = call.getString("productIdentifier", "")
76
+ let quantity = call.getInt("quantity", 1)
77
+ let appAccountToken = call.getString("appAccountToken")
78
+ let autoAcknowledge = call.getBool("autoAcknowledgePurchases") ?? true
79
+
80
+ if productIdentifier.isEmpty {
81
+ call.reject("productIdentifier is Empty, give an id")
82
+ return
83
+ }
120
84
 
121
- let purchaseOptions = self.buildPurchaseOptions(quantity: quantity, appAccountToken: appAccountToken)
122
- let result = try await product.purchase(options: purchaseOptions)
123
- print("purchaseProduct result \(result)")
85
+ print("Auto-acknowledge enabled: \(autoAcknowledge)")
124
86
 
125
- await self.handlePurchaseResult(result, call: call, autoFinish: autoAcknowledge)
126
- } catch {
127
- print(error)
128
- call.reject(error.localizedDescription)
87
+ Task { @MainActor in
88
+ do {
89
+ let products = try await Product.products(for: [productIdentifier])
90
+ guard let product = products.first else {
91
+ call.reject("Cannot find product for id \(productIdentifier)")
92
+ return
129
93
  }
130
- }
131
- } else {
132
- print("Not implemented under ios 15")
133
- call.reject("Not implemented under ios 15")
134
- }
135
- }
136
94
 
137
- @available(iOS 15.0, *)
138
- private func buildPurchaseOptions(quantity: Int, appAccountToken: String?) -> Set<Product.PurchaseOption> {
139
- var purchaseOptions = Set<Product.PurchaseOption>()
140
- purchaseOptions.insert(Product.PurchaseOption.quantity(quantity))
95
+ var purchaseOptions = Set<Product.PurchaseOption>()
96
+ purchaseOptions.insert(.quantity(quantity))
97
+ if let token = appAccountToken, !token.isEmpty, let uuid = UUID(uuidString: token) {
98
+ purchaseOptions.insert(.appAccountToken(uuid))
99
+ }
141
100
 
142
- if let accountToken = appAccountToken, !accountToken.isEmpty, let tokenData = UUID(uuidString: accountToken) {
143
- purchaseOptions.insert(Product.PurchaseOption.appAccountToken(tokenData))
101
+ let result = try await product.purchase(options: purchaseOptions)
102
+ print("purchaseProduct result \(result)")
103
+ await self.handlePurchaseResult(result, call: call, autoFinish: autoAcknowledge)
104
+ } catch {
105
+ print(error)
106
+ call.reject(error.localizedDescription)
107
+ }
144
108
  }
145
-
146
- return purchaseOptions
147
109
  }
148
110
 
149
- @available(iOS 15.0, *)
150
111
  @MainActor
151
- private func handlePurchaseResult(_ result: Product.PurchaseResult, call: CAPPluginCall, autoFinish: Bool) async {
112
+ private func handlePurchaseResult(
113
+ _ result: Product.PurchaseResult,
114
+ call: CAPPluginCall,
115
+ autoFinish: Bool
116
+ ) async {
152
117
  switch result {
153
118
  case let .success(verificationResult):
154
119
  switch verificationResult {
155
120
  case .verified(let transaction):
156
- let response = await TransactionHelpers.buildTransactionResponse(from: transaction, jwsRepresentation: verificationResult.jwsRepresentation)
157
-
121
+ let response = await TransactionHelpers.buildTransactionResponse(
122
+ from: transaction,
123
+ jwsRepresentation: verificationResult.jwsRepresentation
124
+ )
158
125
  if autoFinish {
159
126
  print("Auto-finishing transaction: \(transaction.id)")
160
127
  await transaction.finish()
161
128
  } else {
162
129
  print("Manual finish required for transaction: \(transaction.id)")
163
- print("Transaction will remain unfinished until acknowledgePurchase() is called")
164
- // Don't finish - transaction remains in StoreKit's queue
165
- // Can be retrieved later via Transaction.all
166
130
  }
167
-
168
131
  call.resolve(response)
169
132
  case .unverified(_, let error):
170
133
  call.reject(error.localizedDescription)
@@ -179,237 +142,136 @@ public class NativePurchasesPlugin: CAPPlugin, CAPBridgedPlugin {
179
142
  }
180
143
 
181
144
  @objc func restorePurchases(_ call: CAPPluginCall) {
182
- if #available(iOS 15.0, *) {
183
- print("restorePurchases")
184
- Task {
185
- do {
186
- try await AppStore.sync()
187
- // make finish() calls for all transactions and consume all consumables
188
- for transaction in SKPaymentQueue.default().transactions {
189
- SKPaymentQueue.default().finishTransaction(transaction)
190
- }
191
- await MainActor.run {
192
- call.resolve()
193
- }
194
- } catch {
195
- await MainActor.run {
196
- call.reject(error.localizedDescription)
197
- }
145
+ print("restorePurchases")
146
+ Task {
147
+ do {
148
+ try await AppStore.sync()
149
+ for transaction in SKPaymentQueue.default().transactions {
150
+ SKPaymentQueue.default().finishTransaction(transaction)
198
151
  }
152
+ await MainActor.run { call.resolve() }
153
+ } catch {
154
+ await MainActor.run { call.reject(error.localizedDescription) }
199
155
  }
200
- } else {
201
- print("Not implemented under ios 15")
202
- call.reject("Not implemented under ios 15")
203
156
  }
204
157
  }
205
158
 
206
159
  @objc func getProducts(_ call: CAPPluginCall) {
207
- if #available(iOS 15.0, *) {
208
- let productIdentifiers = call.getArray("productIdentifiers", String.self) ?? []
209
- print("productIdentifiers \(productIdentifiers)")
210
- Task {
211
- do {
212
- let products = try await Product.products(for: productIdentifiers)
213
- print("products \(products)")
214
- let productsJson: [[String: Any]] = products.map { $0.dictionary }
215
- await MainActor.run {
216
- call.resolve([
217
- "products": productsJson
218
- ])
219
- }
220
- } catch {
221
- print("error \(error)")
222
- await MainActor.run {
223
- call.reject(error.localizedDescription)
224
- }
225
- }
160
+ let productIdentifiers = call.getArray("productIdentifiers", String.self) ?? []
161
+ print("productIdentifiers \(productIdentifiers)")
162
+ Task {
163
+ do {
164
+ let products = try await Product.products(for: productIdentifiers)
165
+ print("products \(products)")
166
+ let productsJson: [[String: Any]] = products.map { $0.dictionary }
167
+ await MainActor.run { call.resolve(["products": productsJson]) }
168
+ } catch {
169
+ print("error \(error)")
170
+ await MainActor.run { call.reject(error.localizedDescription) }
226
171
  }
227
- } else {
228
- print("Not implemented under ios 15")
229
- call.reject("Not implemented under ios 15")
230
172
  }
231
173
  }
232
174
 
233
175
  @objc func getProduct(_ call: CAPPluginCall) {
234
- if #available(iOS 15.0, *) {
235
- let productIdentifier = call.getString("productIdentifier") ?? ""
236
- print("productIdentifier \(productIdentifier)")
237
- if productIdentifier.isEmpty {
238
- call.reject("productIdentifier is empty")
239
- return
240
- }
176
+ let productIdentifier = call.getString("productIdentifier") ?? ""
177
+ print("productIdentifier \(productIdentifier)")
178
+ if productIdentifier.isEmpty {
179
+ call.reject("productIdentifier is empty")
180
+ return
181
+ }
241
182
 
242
- Task {
243
- do {
244
- let products = try await Product.products(for: [productIdentifier])
245
- print("products \(products)")
246
- if let product = products.first {
247
- let productJson = product.dictionary
248
- await MainActor.run {
249
- call.resolve(["product": productJson])
250
- }
251
- } else {
252
- await MainActor.run {
253
- call.reject("Product not found")
254
- }
255
- }
256
- } catch {
257
- print(error)
258
- await MainActor.run {
259
- call.reject(error.localizedDescription)
260
- }
183
+ Task {
184
+ do {
185
+ let products = try await Product.products(for: [productIdentifier])
186
+ print("products \(products)")
187
+ if let product = products.first {
188
+ await MainActor.run { call.resolve(["product": product.dictionary]) }
189
+ } else {
190
+ await MainActor.run { call.reject("Product not found") }
261
191
  }
192
+ } catch {
193
+ print(error)
194
+ await MainActor.run { call.reject(error.localizedDescription) }
262
195
  }
263
- } else {
264
- print("Not implemented under iOS 15")
265
- call.reject("Not implemented under iOS 15")
266
196
  }
267
197
  }
268
198
 
269
199
  @objc func getPurchases(_ call: CAPPluginCall) {
200
+ print("getPurchases")
270
201
  let appAccountTokenFilter = call.getString("appAccountToken")
271
- if #available(iOS 15.0, *) {
272
- print("getPurchases")
273
- Task {
274
- let allPurchases = await TransactionHelpers.collectAllPurchases(appAccountTokenFilter: appAccountTokenFilter)
275
- await MainActor.run {
276
- call.resolve(["purchases": allPurchases])
277
- }
278
- }
279
- } else {
280
- print("Not implemented under iOS 15")
281
- call.reject("Not implemented under iOS 15")
202
+ Task {
203
+ let allPurchases = await TransactionHelpers.collectAllPurchases(
204
+ appAccountTokenFilter: appAccountTokenFilter
205
+ )
206
+ await MainActor.run { call.resolve(["purchases": allPurchases]) }
282
207
  }
283
208
  }
284
209
 
285
210
  @objc func manageSubscriptions(_ call: CAPPluginCall) {
286
- if #available(iOS 15.0, *) {
287
- print("manageSubscriptions")
288
- Task { @MainActor in
289
- do {
290
- // Get the current window scene
291
- guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {
292
- call.reject("Unable to get window scene")
293
- return
294
- }
295
- // Open the App Store subscription management page
296
- try await AppStore.showManageSubscriptions(in: windowScene)
297
- call.resolve()
298
- } catch {
299
- print("manageSubscriptions error: \(error)")
300
- call.reject(error.localizedDescription)
211
+ print("manageSubscriptions")
212
+ Task { @MainActor in
213
+ do {
214
+ guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {
215
+ call.reject("Unable to get window scene")
216
+ return
301
217
  }
218
+ try await AppStore.showManageSubscriptions(in: windowScene)
219
+ call.resolve()
220
+ } catch {
221
+ print("manageSubscriptions error: \(error)")
222
+ call.reject(error.localizedDescription)
302
223
  }
303
- } else {
304
- print("Not implemented under iOS 15")
305
- call.reject("Not implemented under iOS 15")
306
224
  }
307
225
  }
308
226
 
309
227
  @objc func acknowledgePurchase(_ call: CAPPluginCall) {
310
- if #available(iOS 15.0, *) {
311
- print("acknowledgePurchase called on iOS")
228
+ print("acknowledgePurchase called on iOS")
312
229
 
313
- guard let purchaseToken = call.getString("purchaseToken") else {
314
- call.reject("purchaseToken is required")
315
- return
316
- }
317
-
318
- // On iOS, purchaseToken is the transactionId (UInt64 as string)
319
- guard let transactionId = UInt64(purchaseToken) else {
320
- call.reject("Invalid purchaseToken format")
321
- return
322
- }
230
+ guard let purchaseToken = call.getString("purchaseToken") else {
231
+ call.reject("purchaseToken is required")
232
+ return
233
+ }
323
234
 
324
- Task {
325
- // Search for the transaction in StoreKit's unfinished transactions
326
- // This works even after app restart because StoreKit persists them
327
- var foundTransaction: Transaction?
328
-
329
- for await verificationResult in Transaction.all {
330
- switch verificationResult {
331
- case .verified(let transaction):
332
- if transaction.id == transactionId {
333
- foundTransaction = transaction
334
- break
335
- }
336
- case .unverified:
337
- continue
338
- }
339
- if foundTransaction != nil {
340
- break
341
- }
342
- }
235
+ guard let transactionId = UInt64(purchaseToken) else {
236
+ call.reject("Invalid purchaseToken format")
237
+ return
238
+ }
343
239
 
344
- guard let transaction = foundTransaction else {
345
- await MainActor.run {
346
- call.reject("Transaction not found or already finished. Transaction ID: \(transactionId)")
347
- }
348
- return
240
+ Task {
241
+ var foundTransaction: Transaction?
242
+ for await verificationResult in Transaction.all {
243
+ if case .verified(let transaction) = verificationResult, transaction.id == transactionId {
244
+ foundTransaction = transaction
245
+ break
349
246
  }
247
+ }
350
248
 
351
- print("Manually finishing transaction: \(transaction.id)")
352
- await transaction.finish()
353
-
249
+ guard let transaction = foundTransaction else {
354
250
  await MainActor.run {
355
- print("Transaction finished successfully")
356
- call.resolve()
251
+ call.reject("Transaction not found or already finished. Transaction ID: \(transactionId)")
357
252
  }
253
+ return
254
+ }
255
+
256
+ print("Manually finishing transaction: \(transaction.id)")
257
+ await transaction.finish()
258
+ await MainActor.run {
259
+ print("Transaction finished successfully")
260
+ call.resolve()
358
261
  }
359
- } else {
360
- call.reject("Not implemented under iOS 15")
361
262
  }
362
263
  }
363
264
 
265
+ }
266
+
267
+ // MARK: - iOS 16+ App Transaction Methods
268
+ extension NativePurchasesPlugin {
364
269
  @objc func getAppTransaction(_ call: CAPPluginCall) {
365
270
  if #available(iOS 16.0, *) {
366
- print("getAppTransaction called on iOS")
367
271
  Task { @MainActor in
368
- do {
369
- let verificationResult = try await AppTransaction.shared
370
- switch verificationResult {
371
- case .verified(let appTransaction):
372
- var response: [String: Any] = [:]
373
-
374
- // originalAppVersion is the CFBundleVersion (build number) at the time of original download
375
- response["originalAppVersion"] = appTransaction.originalAppVersion
376
-
377
- // Original purchase date
378
- response["originalPurchaseDate"] = ISO8601DateFormatter().string(from: appTransaction.originalPurchaseDate)
379
-
380
- // Bundle ID
381
- response["bundleId"] = appTransaction.bundleID
382
-
383
- // Current app version (build number)
384
- response["appVersion"] = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
385
-
386
- // Environment
387
- switch appTransaction.environment {
388
- case .sandbox:
389
- response["environment"] = "Sandbox"
390
- case .production:
391
- response["environment"] = "Production"
392
- case .xcode:
393
- response["environment"] = "Xcode"
394
- default:
395
- response["environment"] = "Production"
396
- }
397
-
398
- // JWS representation for server-side verification
399
- response["jwsRepresentation"] = verificationResult.jwsRepresentation
400
-
401
- call.resolve(["appTransaction": response])
402
-
403
- case .unverified(_, let error):
404
- call.reject("App transaction verification failed: \(error.localizedDescription)")
405
- }
406
- } catch {
407
- print("getAppTransaction error: \(error)")
408
- call.reject("Failed to get app transaction: \(error.localizedDescription)")
409
- }
272
+ await self.handleGetAppTransaction(call)
410
273
  }
411
274
  } else {
412
- print("getAppTransaction not implemented under iOS 16")
413
275
  call.reject("App Transaction requires iOS 16.0 or later")
414
276
  }
415
277
  }
@@ -421,44 +283,78 @@ public class NativePurchasesPlugin: CAPPlugin, CAPBridgedPlugin {
421
283
  }
422
284
 
423
285
  if #available(iOS 16.0, *) {
424
- print("isEntitledToOldBusinessModel called with targetBuildNumber: \(targetBuildNumber)")
425
286
  Task { @MainActor in
426
- do {
427
- let verificationResult = try await AppTransaction.shared
428
- switch verificationResult {
429
- case .verified(let appTransaction):
430
- let originalBuildNumber = appTransaction.originalAppVersion
431
-
432
- // Compare build numbers (integers)
433
- let isOlder = self.compareVersions(originalBuildNumber, targetBuildNumber) < 0
434
-
435
- call.resolve([
436
- "isOlderVersion": isOlder,
437
- "originalAppVersion": originalBuildNumber
438
- ])
439
-
440
- case .unverified(_, let error):
441
- call.reject("App transaction verification failed: \(error.localizedDescription)")
442
- }
443
- } catch {
444
- print("isEntitledToOldBusinessModel error: \(error)")
445
- call.reject("Failed to get app transaction: \(error.localizedDescription)")
446
- }
287
+ await self.handleIsEntitledToOldBusinessModel(call, targetBuildNumber: targetBuildNumber)
447
288
  }
448
289
  } else {
449
- print("isEntitledToOldBusinessModel not implemented under iOS 16")
450
290
  call.reject("App Transaction requires iOS 16.0 or later")
451
291
  }
452
292
  }
453
293
 
454
- // MARK: - Version Comparison Helper
294
+ @available(iOS 16.0, *)
295
+ @MainActor
296
+ private func handleGetAppTransaction(_ call: CAPPluginCall) async {
297
+ print("getAppTransaction called on iOS")
298
+ do {
299
+ let verificationResult = try await AppTransaction.shared
300
+ switch verificationResult {
301
+ case .verified(let appTransaction):
302
+ let response: [String: Any] = [
303
+ "originalAppVersion": appTransaction.originalAppVersion,
304
+ "originalPurchaseDate": ISO8601DateFormatter().string(
305
+ from: appTransaction.originalPurchaseDate
306
+ ),
307
+ "bundleId": appTransaction.bundleID,
308
+ "appVersion": Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "",
309
+ "jwsRepresentation": verificationResult.jwsRepresentation,
310
+ "environment": appTransaction.environment.environmentString
311
+ ]
312
+ call.resolve(["appTransaction": response])
313
+ case .unverified(_, let error):
314
+ call.reject("App transaction verification failed: \(error.localizedDescription)")
315
+ }
316
+ } catch {
317
+ print("getAppTransaction error: \(error)")
318
+ call.reject("Failed to get app transaction: \(error.localizedDescription)")
319
+ }
320
+ }
455
321
 
456
- /// Compares two build numbers as integers.
457
- /// Returns: negative if v1 < v2, zero if v1 == v2, positive if v1 > v2
458
- private func compareVersions(_ version1: String, _ version2: String) -> Int {
459
- let v1Int = Int(version1) ?? 0
460
- let v2Int = Int(version2) ?? 0
461
- return v1Int - v2Int
322
+ @available(iOS 16.0, *)
323
+ @MainActor
324
+ private func handleIsEntitledToOldBusinessModel(
325
+ _ call: CAPPluginCall,
326
+ targetBuildNumber: String
327
+ ) async {
328
+ print("isEntitledToOldBusinessModel called with targetBuildNumber: \(targetBuildNumber)")
329
+ do {
330
+ let verificationResult = try await AppTransaction.shared
331
+ switch verificationResult {
332
+ case .verified(let appTransaction):
333
+ let originalBuildNumber = appTransaction.originalAppVersion
334
+ let originalInt = Int(originalBuildNumber) ?? 0
335
+ let targetInt = Int(targetBuildNumber) ?? 0
336
+ call.resolve([
337
+ "isOlderVersion": originalInt < targetInt,
338
+ "originalAppVersion": originalBuildNumber
339
+ ])
340
+ case .unverified(_, let error):
341
+ call.reject("App transaction verification failed: \(error.localizedDescription)")
342
+ }
343
+ } catch {
344
+ print("isEntitledToOldBusinessModel error: \(error)")
345
+ call.reject("Failed to get app transaction: \(error.localizedDescription)")
346
+ }
462
347
  }
348
+ }
463
349
 
350
+ @available(iOS 16.0, *)
351
+ private extension AppStore.Environment {
352
+ var environmentString: String {
353
+ switch self {
354
+ case .sandbox: return "Sandbox"
355
+ case .production: return "Production"
356
+ case .xcode: return "Xcode"
357
+ default: return "Production"
358
+ }
359
+ }
464
360
  }
@@ -8,7 +8,6 @@
8
8
  import Foundation
9
9
  import StoreKit
10
10
 
11
- @available(iOS 15.0, *)
12
11
  extension Product {
13
12
 
14
13
  var dictionary: [String: Any] {
@@ -1,87 +1,61 @@
1
- //
2
- // TransactionHelpers.swift
3
- // CapgoNativePurchases
4
- //
5
- // Created by Martin DONADIEU
6
- //
7
-
8
1
  import Foundation
9
2
  import StoreKit
10
3
 
11
- @available(iOS 15.0, *)
12
4
  internal class TransactionHelpers {
13
5
 
14
- static func buildTransactionResponse(from transaction: Transaction, jwsRepresentation: String? = nil, alwaysIncludeWillCancel: Bool = false) async -> [String: Any] {
6
+ static func buildTransactionResponse(
7
+ from transaction: Transaction,
8
+ jwsRepresentation: String? = nil,
9
+ alwaysIncludeWillCancel: Bool = false
10
+ ) async -> [String: Any] {
15
11
  var response: [String: Any] = ["transactionId": String(transaction.id)]
16
12
 
17
- // Always include willCancel key with NSNull() default if requested (for transaction listener)
18
13
  if alwaysIncludeWillCancel {
19
14
  response["willCancel"] = NSNull()
20
15
  }
21
16
 
22
- // Get receipt data (may not exist in Xcode/sandbox testing)
17
+ addReceiptAndJws(to: &response, jws: jwsRepresentation)
18
+ addTransactionDetails(to: &response, transaction: transaction)
19
+
20
+ if transaction.productType == .autoRenewable {
21
+ addSubscriptionInfo(to: &response, transaction: transaction)
22
+ await addRenewalInfo(to: &response, transaction: transaction)
23
+ }
24
+
25
+ return response
26
+ }
27
+
28
+ private static func addReceiptAndJws(to response: inout [String: Any], jws: String?) {
23
29
  if let receiptBase64 = getReceiptData() {
24
30
  response["receipt"] = receiptBase64
25
31
  }
26
-
27
- // Add StoreKit 2 JWS representation (always available when passed from VerificationResult)
28
- if let jws = jwsRepresentation {
32
+ if let jws = jws {
29
33
  response["jwsRepresentation"] = jws
30
34
  }
35
+ }
31
36
 
32
- // Add detailed transaction information
37
+ private static func addTransactionDetails(to response: inout [String: Any], transaction: Transaction) {
33
38
  response["productIdentifier"] = transaction.productID
34
39
  response["purchaseDate"] = ISO8601DateFormatter().string(from: transaction.purchaseDate)
35
40
  response["productType"] = transaction.productType == .autoRenewable ? "subs" : "inapp"
36
41
  response["isUpgraded"] = transaction.isUpgraded
42
+ response["ownershipType"] = transaction.ownershipType.descriptionString
37
43
 
38
44
  if let revocationDate = transaction.revocationDate {
39
45
  response["revocationDate"] = ISO8601DateFormatter().string(from: revocationDate)
40
46
  }
41
-
42
47
  if let revocationReason = transaction.revocationReason {
43
48
  response["revocationReason"] = revocationReason.descriptionString
44
49
  }
45
-
46
50
  if #available(iOS 17.0, *) {
47
51
  response["transactionReason"] = transaction.reason.descriptionString
48
52
  }
49
-
50
- // Add ownership type (purchased or familyShared)
51
- switch transaction.ownershipType {
52
- case .purchased:
53
- response["ownershipType"] = "purchased"
54
- case .familyShared:
55
- response["ownershipType"] = "familyShared"
56
- default:
57
- response["ownershipType"] = "purchased"
58
- }
59
-
60
- // Add environment (Sandbox, Production, or Xcode) - iOS 16.0+
61
53
  if #available(iOS 16.0, *) {
62
- switch transaction.environment {
63
- case .sandbox:
64
- response["environment"] = "Sandbox"
65
- case .production:
66
- response["environment"] = "Production"
67
- case .xcode:
68
- response["environment"] = "Xcode"
69
- default:
70
- response["environment"] = "Production"
71
- }
54
+ response["environment"] = transaction.environment.descriptionString
72
55
  }
73
-
74
56
  if let token = transaction.appAccountToken {
75
57
  response["appAccountToken"] = token.uuidString
76
58
  }
77
-
78
- // Add subscription-specific information
79
- if transaction.productType == .autoRenewable {
80
- addSubscriptionInfo(to: &response, transaction: transaction)
81
- await addRenewalInfo(to: &response, transaction: transaction)
82
- }
83
-
84
- return response
85
59
  }
86
60
 
87
61
  static func getReceiptData() -> String? {
@@ -94,7 +68,9 @@ internal class TransactionHelpers {
94
68
  }
95
69
 
96
70
  static func addSubscriptionInfo(to response: inout [String: Any], transaction: Transaction) {
97
- response["originalPurchaseDate"] = ISO8601DateFormatter().string(from: transaction.originalPurchaseDate)
71
+ response["originalPurchaseDate"] = ISO8601DateFormatter().string(
72
+ from: transaction.originalPurchaseDate
73
+ )
98
74
  if let expirationDate = transaction.expirationDate {
99
75
  response["expirationDate"] = ISO8601DateFormatter().string(from: expirationDate)
100
76
  response["isActive"] = expirationDate > Date()
@@ -102,8 +78,7 @@ internal class TransactionHelpers {
102
78
  }
103
79
 
104
80
  static func addRenewalInfo(to response: inout [String: Any], transaction: Transaction) async {
105
- let subscriptionStatus = await transaction.subscriptionStatus
106
- guard let subscriptionStatus = subscriptionStatus else {
81
+ guard let subscriptionStatus = await transaction.subscriptionStatus else {
107
82
  response["willCancel"] = NSNull()
108
83
  return
109
84
  }
@@ -111,11 +86,9 @@ internal class TransactionHelpers {
111
86
  response["subscriptionState"] = subscriptionStatus.state.descriptionString
112
87
 
113
88
  if subscriptionStatus.state == .subscribed {
114
- let renewalInfo = subscriptionStatus.renewalInfo
115
- switch renewalInfo {
116
- case .verified(let value):
89
+ if case .verified(let value) = subscriptionStatus.renewalInfo {
117
90
  response["willCancel"] = !value.willAutoRenew
118
- case .unverified:
91
+ } else {
119
92
  response["willCancel"] = NSNull()
120
93
  }
121
94
  } else {
@@ -123,78 +96,49 @@ internal class TransactionHelpers {
123
96
  }
124
97
  }
125
98
 
126
- static func shouldFilterTransaction(_ transaction: Transaction, filter: String?) -> Bool {
127
- guard let filter = filter else { return false }
128
- let transactionAccountToken = transaction.appAccountToken?.uuidString
129
- return transactionAccountToken != filter
130
- }
131
-
132
99
  static func collectAllPurchases(appAccountTokenFilter: String?) async -> [[String: Any]] {
133
100
  var allPurchases: [[String: Any]] = []
101
+ var seenIds = Set<String>()
134
102
 
135
- // Get all current entitlements (active subscriptions)
136
- await collectCurrentEntitlements(appAccountTokenFilter: appAccountTokenFilter, into: &allPurchases)
137
-
138
- // Also get all transactions (including non-consumables and expired subscriptions)
139
- await collectAllTransactions(appAccountTokenFilter: appAccountTokenFilter, into: &allPurchases)
140
-
141
- return allPurchases
142
- }
143
-
144
- static func collectCurrentEntitlements(appAccountTokenFilter: String?, into allPurchases: inout [[String: Any]]) async {
145
103
  for await result in Transaction.currentEntitlements {
146
- guard case .verified(let transaction) = result else {
147
- if case .unverified(_, let error) = result {
148
- print("Skipping unverified entitlement: \(error.localizedDescription)")
149
- }
150
- continue
151
- }
152
-
153
- if shouldFilterTransaction(transaction, filter: appAccountTokenFilter) {
154
- continue
155
- }
104
+ guard case .verified(let transaction) = result else { continue }
105
+ if let filter = appAccountTokenFilter,
106
+ transaction.appAccountToken?.uuidString != filter { continue }
156
107
 
157
- let purchaseData = await buildTransactionResponse(from: transaction, jwsRepresentation: result.jwsRepresentation)
158
- allPurchases.append(purchaseData)
108
+ let idStr = String(transaction.id)
109
+ seenIds.insert(idStr)
110
+ let data = await buildTransactionResponse(
111
+ from: transaction,
112
+ jwsRepresentation: result.jwsRepresentation
113
+ )
114
+ allPurchases.append(data)
159
115
  }
160
- }
161
116
 
162
- static func collectAllTransactions(appAccountTokenFilter: String?, into allPurchases: inout [[String: Any]]) async {
163
117
  for await result in Transaction.all {
164
- guard case .verified(let transaction) = result else {
165
- if case .unverified(_, let error) = result {
166
- print("Skipping unverified transaction: \(error.localizedDescription)")
167
- }
168
- continue
169
- }
118
+ guard case .verified(let transaction) = result else { continue }
119
+ if let filter = appAccountTokenFilter,
120
+ transaction.appAccountToken?.uuidString != filter { continue }
170
121
 
171
- if shouldFilterTransaction(transaction, filter: appAccountTokenFilter) {
172
- continue
173
- }
122
+ let idStr = String(transaction.id)
123
+ if seenIds.contains(idStr) { continue }
174
124
 
175
- let transactionIdString = String(transaction.id)
176
- let alreadyExists = allPurchases.contains { purchase in
177
- (purchase["transactionId"] as? String) == transactionIdString
178
- }
179
-
180
- if !alreadyExists {
181
- let purchaseData = await buildTransactionResponse(from: transaction, jwsRepresentation: result.jwsRepresentation)
182
- allPurchases.append(purchaseData)
183
- }
125
+ let data = await buildTransactionResponse(
126
+ from: transaction,
127
+ jwsRepresentation: result.jwsRepresentation
128
+ )
129
+ allPurchases.append(data)
184
130
  }
131
+
132
+ return allPurchases
185
133
  }
186
134
  }
187
135
 
188
- @available(iOS 15.0, *)
189
136
  private extension Transaction.RevocationReason {
190
137
  var descriptionString: String {
191
138
  switch self {
192
- case .developerIssue:
193
- return "developerIssue"
194
- case .other:
195
- return "other"
196
- default:
197
- return "unknown"
139
+ case .developerIssue: return "developerIssue"
140
+ case .other: return "other"
141
+ default: return "unknown"
198
142
  }
199
143
  }
200
144
  }
@@ -203,32 +147,44 @@ private extension Transaction.RevocationReason {
203
147
  private extension Transaction.Reason {
204
148
  var descriptionString: String {
205
149
  switch self {
206
- case .purchase:
207
- return "purchase"
208
- case .renewal:
209
- return "renewal"
210
- default:
211
- return "unknown"
150
+ case .purchase: return "purchase"
151
+ case .renewal: return "renewal"
152
+ default: return "unknown"
153
+ }
154
+ }
155
+ }
156
+
157
+ private extension Transaction.OwnershipType {
158
+ var descriptionString: String {
159
+ switch self {
160
+ case .purchased: return "purchased"
161
+ case .familyShared: return "familyShared"
162
+ default: return "purchased"
163
+ }
164
+ }
165
+ }
166
+
167
+ @available(iOS 16.0, *)
168
+ private extension AppStore.Environment {
169
+ var descriptionString: String {
170
+ switch self {
171
+ case .sandbox: return "Sandbox"
172
+ case .production: return "Production"
173
+ case .xcode: return "Xcode"
174
+ default: return "Production"
212
175
  }
213
176
  }
214
177
  }
215
178
 
216
- @available(iOS 15.0, *)
217
179
  private extension Product.SubscriptionInfo.RenewalState {
218
180
  var descriptionString: String {
219
181
  switch self {
220
- case .subscribed:
221
- return "subscribed"
222
- case .expired:
223
- return "expired"
224
- case .revoked:
225
- return "revoked"
226
- case .inGracePeriod:
227
- return "inGracePeriod"
228
- case .inBillingRetryPeriod:
229
- return "inBillingRetryPeriod"
230
- default:
231
- return "unknown"
182
+ case .subscribed: return "subscribed"
183
+ case .expired: return "expired"
184
+ case .revoked: return "revoked"
185
+ case .inGracePeriod: return "inGracePeriod"
186
+ case .inBillingRetryPeriod: return "inBillingRetryPeriod"
187
+ default: return "unknown"
232
188
  }
233
189
  }
234
190
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/native-purchases",
3
- "version": "8.0.4",
3
+ "version": "8.0.6",
4
4
  "description": "In-app Subscriptions Made Easy",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",