@capivv/capacitor-sdk 0.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.
@@ -0,0 +1,655 @@
1
+ import Foundation
2
+ import Capacitor
3
+ import StoreKit
4
+
5
+ /// Capivv Capacitor Plugin
6
+ /// Provides in-app purchase and subscription management via StoreKit 2
7
+ @objc(CapivvPlugin)
8
+ public class CapivvPlugin: CAPPlugin, CAPBridgedPlugin {
9
+ public let identifier = "CapivvPlugin"
10
+ public let jsName = "Capivv"
11
+ public let pluginMethods: [CAPPluginMethod] = [
12
+ CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "identify", returnType: CAPPluginReturnPromise),
14
+ CAPPluginMethod(name: "logout", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "getUserInfo", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "isBillingSupported", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "getOfferings", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "getProduct", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "getProducts", returnType: CAPPluginReturnPromise),
20
+ CAPPluginMethod(name: "purchase", returnType: CAPPluginReturnPromise),
21
+ CAPPluginMethod(name: "restorePurchases", returnType: CAPPluginReturnPromise),
22
+ CAPPluginMethod(name: "checkEntitlement", returnType: CAPPluginReturnPromise),
23
+ CAPPluginMethod(name: "getEntitlements", returnType: CAPPluginReturnPromise),
24
+ CAPPluginMethod(name: "syncPurchases", returnType: CAPPluginReturnPromise),
25
+ CAPPluginMethod(name: "manageSubscriptions", returnType: CAPPluginReturnPromise)
26
+ ]
27
+
28
+ private var apiKey: String?
29
+ private var apiUrl: String = "https://api.capivv.com"
30
+ private var userId: String?
31
+ private var debug: Bool = false
32
+ private var transactionObserver: Task<Void, Never>?
33
+
34
+ deinit {
35
+ transactionObserver?.cancel()
36
+ }
37
+
38
+ // MARK: - Configuration
39
+
40
+ @objc func configure(_ call: CAPPluginCall) {
41
+ guard let apiKey = call.getString("apiKey") else {
42
+ call.reject("apiKey is required")
43
+ return
44
+ }
45
+
46
+ self.apiKey = apiKey
47
+
48
+ if let apiUrl = call.getString("apiUrl") {
49
+ self.apiUrl = apiUrl
50
+ }
51
+
52
+ self.debug = call.getBool("debug") ?? false
53
+
54
+ // Start listening for transactions
55
+ startTransactionObserver()
56
+
57
+ log("Configured with API URL: \(self.apiUrl)")
58
+ call.resolve()
59
+ }
60
+
61
+ // MARK: - User Management
62
+
63
+ @objc func identify(_ call: CAPPluginCall) {
64
+ guard apiKey != nil else {
65
+ call.reject("Not configured. Call configure() first.")
66
+ return
67
+ }
68
+
69
+ guard let userId = call.getString("userId") else {
70
+ call.reject("userId is required")
71
+ return
72
+ }
73
+
74
+ self.userId = userId
75
+ let attributes = call.getObject("attributes") ?? [:]
76
+
77
+ Task {
78
+ do {
79
+ let response = try await apiRequest(
80
+ method: "POST",
81
+ path: "/v1/users/\(userId)/login",
82
+ body: ["attributes": attributes]
83
+ )
84
+
85
+ call.resolve([
86
+ "userId": userId,
87
+ "entitlements": response["entitlements"] ?? [],
88
+ "originalPurchaseDate": response["original_purchase_date"] ?? NSNull(),
89
+ "latestPurchaseDate": response["latest_purchase_date"] ?? NSNull()
90
+ ])
91
+ } catch {
92
+ call.reject("Failed to identify user: \(error.localizedDescription)")
93
+ }
94
+ }
95
+ }
96
+
97
+ @objc func logout(_ call: CAPPluginCall) {
98
+ userId = nil
99
+ call.resolve()
100
+ }
101
+
102
+ @objc func getUserInfo(_ call: CAPPluginCall) {
103
+ guard let userId = userId else {
104
+ call.reject("Not identified. Call identify() first.")
105
+ return
106
+ }
107
+
108
+ Task {
109
+ do {
110
+ let response = try await apiRequest(
111
+ method: "GET",
112
+ path: "/v1/users/\(userId)/entitlements"
113
+ )
114
+
115
+ call.resolve([
116
+ "userId": userId,
117
+ "entitlements": response["entitlements"] ?? []
118
+ ])
119
+ } catch {
120
+ call.reject("Failed to get user info: \(error.localizedDescription)")
121
+ }
122
+ }
123
+ }
124
+
125
+ // MARK: - Billing
126
+
127
+ @objc func isBillingSupported(_ call: CAPPluginCall) {
128
+ if #available(iOS 15.0, *) {
129
+ call.resolve(["isSupported": true])
130
+ } else {
131
+ call.resolve(["isSupported": false])
132
+ }
133
+ }
134
+
135
+ // MARK: - Products & Offerings
136
+
137
+ @objc func getOfferings(_ call: CAPPluginCall) {
138
+ guard apiKey != nil else {
139
+ call.reject("Not configured. Call configure() first.")
140
+ return
141
+ }
142
+
143
+ Task {
144
+ do {
145
+ let response = try await apiRequest(
146
+ method: "GET",
147
+ path: "/v1/offerings"
148
+ )
149
+
150
+ guard let offerings = response["offerings"] as? [[String: Any]] else {
151
+ call.resolve(["offerings": []])
152
+ return
153
+ }
154
+
155
+ // Fetch StoreKit products for each offering
156
+ var enrichedOfferings: [[String: Any]] = []
157
+
158
+ for offering in offerings {
159
+ guard let products = offering["products"] as? [[String: Any]] else {
160
+ continue
161
+ }
162
+
163
+ let productIds = products.compactMap { $0["store_product_id"] as? String }
164
+
165
+ if #available(iOS 15.0, *) {
166
+ let storeProducts = try await Product.products(for: Set(productIds))
167
+ var enrichedProducts: [[String: Any]] = []
168
+
169
+ for storeProduct in storeProducts {
170
+ enrichedProducts.append(mapProduct(storeProduct))
171
+ }
172
+
173
+ var enrichedOffering = offering
174
+ enrichedOffering["products"] = enrichedProducts
175
+ enrichedOfferings.append(enrichedOffering)
176
+ }
177
+ }
178
+
179
+ call.resolve(["offerings": enrichedOfferings])
180
+ } catch {
181
+ call.reject("Failed to get offerings: \(error.localizedDescription)")
182
+ }
183
+ }
184
+ }
185
+
186
+ @objc func getProduct(_ call: CAPPluginCall) {
187
+ guard let productIdentifier = call.getString("productIdentifier") else {
188
+ call.reject("productIdentifier is required")
189
+ return
190
+ }
191
+
192
+ Task {
193
+ if #available(iOS 15.0, *) {
194
+ do {
195
+ let products = try await Product.products(for: [productIdentifier])
196
+
197
+ guard let product = products.first else {
198
+ call.reject("Product not found: \(productIdentifier)")
199
+ return
200
+ }
201
+
202
+ call.resolve(["product": mapProduct(product)])
203
+ } catch {
204
+ call.reject("Failed to get product: \(error.localizedDescription)")
205
+ }
206
+ } else {
207
+ call.reject("StoreKit 2 requires iOS 15 or later")
208
+ }
209
+ }
210
+ }
211
+
212
+ @objc func getProducts(_ call: CAPPluginCall) {
213
+ guard let productIdentifiers = call.getArray("productIdentifiers", String.self) else {
214
+ call.reject("productIdentifiers is required")
215
+ return
216
+ }
217
+
218
+ Task {
219
+ if #available(iOS 15.0, *) {
220
+ do {
221
+ let products = try await Product.products(for: Set(productIdentifiers))
222
+ let mapped = products.map { mapProduct($0) }
223
+ call.resolve(["products": mapped])
224
+ } catch {
225
+ call.reject("Failed to get products: \(error.localizedDescription)")
226
+ }
227
+ } else {
228
+ call.reject("StoreKit 2 requires iOS 15 or later")
229
+ }
230
+ }
231
+ }
232
+
233
+ // MARK: - Purchases
234
+
235
+ @objc func purchase(_ call: CAPPluginCall) {
236
+ guard apiKey != nil else {
237
+ call.reject("Not configured. Call configure() first.")
238
+ return
239
+ }
240
+
241
+ guard let userId = userId else {
242
+ call.reject("Not identified. Call identify() first.")
243
+ return
244
+ }
245
+
246
+ guard let productIdentifier = call.getString("productIdentifier") else {
247
+ call.reject("productIdentifier is required")
248
+ return
249
+ }
250
+
251
+ Task {
252
+ if #available(iOS 15.0, *) {
253
+ do {
254
+ let products = try await Product.products(for: [productIdentifier])
255
+
256
+ guard let product = products.first else {
257
+ call.reject("Product not found: \(productIdentifier)")
258
+ return
259
+ }
260
+
261
+ let result = try await product.purchase()
262
+
263
+ switch result {
264
+ case .success(let verification):
265
+ switch verification {
266
+ case .verified(let transaction):
267
+ // Verify with Capivv backend
268
+ await verifyTransaction(transaction, userId: userId)
269
+ await transaction.finish()
270
+
271
+ let transactionData = mapTransaction(transaction)
272
+
273
+ notifyListeners("purchaseCompleted", data: ["transaction": transactionData])
274
+
275
+ call.resolve([
276
+ "success": true,
277
+ "transaction": transactionData
278
+ ])
279
+
280
+ case .unverified(let transaction, let error):
281
+ log("Transaction verification failed: \(error.localizedDescription)")
282
+ await transaction.finish()
283
+
284
+ notifyListeners("purchaseFailed", data: [
285
+ "productIdentifier": productIdentifier,
286
+ "error": "Transaction verification failed"
287
+ ])
288
+
289
+ call.resolve([
290
+ "success": false,
291
+ "error": "Transaction verification failed: \(error.localizedDescription)"
292
+ ])
293
+ }
294
+
295
+ case .userCancelled:
296
+ call.resolve([
297
+ "success": false,
298
+ "error": "User cancelled"
299
+ ])
300
+
301
+ case .pending:
302
+ call.resolve([
303
+ "success": false,
304
+ "error": "Purchase pending (awaiting approval)"
305
+ ])
306
+
307
+ @unknown default:
308
+ call.resolve([
309
+ "success": false,
310
+ "error": "Unknown purchase result"
311
+ ])
312
+ }
313
+ } catch {
314
+ notifyListeners("purchaseFailed", data: [
315
+ "productIdentifier": productIdentifier,
316
+ "error": error.localizedDescription
317
+ ])
318
+
319
+ call.resolve([
320
+ "success": false,
321
+ "error": error.localizedDescription
322
+ ])
323
+ }
324
+ } else {
325
+ call.reject("StoreKit 2 requires iOS 15 or later")
326
+ }
327
+ }
328
+ }
329
+
330
+ @objc func restorePurchases(_ call: CAPPluginCall) {
331
+ guard let userId = userId else {
332
+ call.reject("Not identified. Call identify() first.")
333
+ return
334
+ }
335
+
336
+ Task {
337
+ if #available(iOS 15.0, *) {
338
+ do {
339
+ try await AppStore.sync()
340
+
341
+ // Verify all current entitlements with backend
342
+ for await result in Transaction.currentEntitlements {
343
+ if case .verified(let transaction) = result {
344
+ await verifyTransaction(transaction, userId: userId)
345
+ }
346
+ }
347
+
348
+ // Fetch updated entitlements
349
+ let response = try await apiRequest(
350
+ method: "GET",
351
+ path: "/v1/users/\(userId)/entitlements"
352
+ )
353
+
354
+ let entitlements = response["entitlements"] ?? []
355
+
356
+ notifyListeners("entitlementsUpdated", data: ["entitlements": entitlements])
357
+
358
+ call.resolve(["entitlements": entitlements])
359
+ } catch {
360
+ call.reject("Failed to restore purchases: \(error.localizedDescription)")
361
+ }
362
+ } else {
363
+ call.reject("StoreKit 2 requires iOS 15 or later")
364
+ }
365
+ }
366
+ }
367
+
368
+ // MARK: - Entitlements
369
+
370
+ @objc func checkEntitlement(_ call: CAPPluginCall) {
371
+ guard let userId = userId else {
372
+ call.reject("Not identified. Call identify() first.")
373
+ return
374
+ }
375
+
376
+ guard let entitlementIdentifier = call.getString("entitlementIdentifier") else {
377
+ call.reject("entitlementIdentifier is required")
378
+ return
379
+ }
380
+
381
+ Task {
382
+ do {
383
+ let response = try await apiRequest(
384
+ method: "GET",
385
+ path: "/v1/users/\(userId)/entitlements"
386
+ )
387
+
388
+ guard let entitlements = response["entitlements"] as? [[String: Any]] else {
389
+ call.resolve([
390
+ "hasAccess": false
391
+ ])
392
+ return
393
+ }
394
+
395
+ if let entitlement = entitlements.first(where: { ($0["identifier"] as? String) == entitlementIdentifier }) {
396
+ let isActive = entitlement["is_active"] as? Bool ?? false
397
+ call.resolve([
398
+ "hasAccess": isActive,
399
+ "entitlement": entitlement
400
+ ])
401
+ } else {
402
+ call.resolve([
403
+ "hasAccess": false
404
+ ])
405
+ }
406
+ } catch {
407
+ call.reject("Failed to check entitlement: \(error.localizedDescription)")
408
+ }
409
+ }
410
+ }
411
+
412
+ @objc func getEntitlements(_ call: CAPPluginCall) {
413
+ guard let userId = userId else {
414
+ call.reject("Not identified. Call identify() first.")
415
+ return
416
+ }
417
+
418
+ Task {
419
+ do {
420
+ let response = try await apiRequest(
421
+ method: "GET",
422
+ path: "/v1/users/\(userId)/entitlements"
423
+ )
424
+
425
+ call.resolve([
426
+ "entitlements": response["entitlements"] ?? []
427
+ ])
428
+ } catch {
429
+ call.reject("Failed to get entitlements: \(error.localizedDescription)")
430
+ }
431
+ }
432
+ }
433
+
434
+ @objc func syncPurchases(_ call: CAPPluginCall) {
435
+ guard let userId = userId else {
436
+ call.reject("Not identified. Call identify() first.")
437
+ return
438
+ }
439
+
440
+ Task {
441
+ if #available(iOS 15.0, *) {
442
+ do {
443
+ // Sync current entitlements with backend
444
+ for await result in Transaction.currentEntitlements {
445
+ if case .verified(let transaction) = result {
446
+ await verifyTransaction(transaction, userId: userId)
447
+ }
448
+ }
449
+
450
+ // Fetch updated entitlements
451
+ let response = try await apiRequest(
452
+ method: "GET",
453
+ path: "/v1/users/\(userId)/entitlements"
454
+ )
455
+
456
+ let entitlements = response["entitlements"] ?? []
457
+
458
+ notifyListeners("entitlementsUpdated", data: ["entitlements": entitlements])
459
+
460
+ call.resolve(["entitlements": entitlements])
461
+ } catch {
462
+ call.reject("Failed to sync purchases: \(error.localizedDescription)")
463
+ }
464
+ } else {
465
+ // Fallback for older iOS - just fetch from server
466
+ do {
467
+ let response = try await apiRequest(
468
+ method: "GET",
469
+ path: "/v1/users/\(userId)/entitlements"
470
+ )
471
+
472
+ call.resolve(["entitlements": response["entitlements"] ?? []])
473
+ } catch {
474
+ call.reject("Failed to sync purchases: \(error.localizedDescription)")
475
+ }
476
+ }
477
+ }
478
+ }
479
+
480
+ @objc func manageSubscriptions(_ call: CAPPluginCall) {
481
+ Task { @MainActor in
482
+ if #available(iOS 15.0, *) {
483
+ if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
484
+ do {
485
+ try await AppStore.showManageSubscriptions(in: windowScene)
486
+ call.resolve()
487
+ } catch {
488
+ call.reject("Failed to show subscription management: \(error.localizedDescription)")
489
+ }
490
+ } else {
491
+ call.reject("Could not find active window scene")
492
+ }
493
+ } else {
494
+ // Fallback to opening App Store subscription settings
495
+ if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
496
+ await UIApplication.shared.open(url)
497
+ call.resolve()
498
+ } else {
499
+ call.reject("Could not open subscription management")
500
+ }
501
+ }
502
+ }
503
+ }
504
+
505
+ // MARK: - Private Helpers
506
+
507
+ private func startTransactionObserver() {
508
+ if #available(iOS 15.0, *) {
509
+ transactionObserver = Task(priority: .background) {
510
+ for await result in Transaction.updates {
511
+ if case .verified(let transaction) = result {
512
+ if let userId = self.userId {
513
+ await self.verifyTransaction(transaction, userId: userId)
514
+ }
515
+ await transaction.finish()
516
+ }
517
+ }
518
+ }
519
+ }
520
+ }
521
+
522
+ @available(iOS 15.0, *)
523
+ private func verifyTransaction(_ transaction: Transaction, userId: String) async {
524
+ do {
525
+ // Get the receipt data
526
+ let receiptUrl = Bundle.main.appStoreReceiptURL
527
+ var receiptData: String?
528
+
529
+ if let url = receiptUrl, let data = try? Data(contentsOf: url) {
530
+ receiptData = data.base64EncodedString()
531
+ }
532
+
533
+ // Send to Capivv backend for verification
534
+ _ = try await apiRequest(
535
+ method: "POST",
536
+ path: "/v1/purchases/apple/verify",
537
+ body: [
538
+ "user_id": userId,
539
+ "transaction_id": String(transaction.id),
540
+ "original_transaction_id": String(transaction.originalID),
541
+ "product_id": transaction.productID,
542
+ "receipt_data": receiptData as Any
543
+ ]
544
+ )
545
+
546
+ log("Transaction verified: \(transaction.id)")
547
+ } catch {
548
+ log("Failed to verify transaction: \(error.localizedDescription)")
549
+ }
550
+ }
551
+
552
+ @available(iOS 15.0, *)
553
+ private func mapProduct(_ product: Product) -> [String: Any] {
554
+ var result: [String: Any] = [
555
+ "identifier": product.id,
556
+ "title": product.displayName,
557
+ "description": product.description,
558
+ "priceString": product.displayPrice,
559
+ "priceAmountMicros": Int((product.price as NSDecimalNumber).doubleValue * 1_000_000),
560
+ "currencyCode": product.priceFormatStyle.currencyCode
561
+ ]
562
+
563
+ switch product.type {
564
+ case .autoRenewable:
565
+ result["productType"] = "SUBSCRIPTION"
566
+ if let subscription = product.subscription {
567
+ result["subscriptionPeriod"] = formatPeriod(subscription.subscriptionPeriod)
568
+ if let introOffer = subscription.introductoryOffer {
569
+ result["trialPeriod"] = formatPeriod(introOffer.period)
570
+ }
571
+ }
572
+ case .consumable, .nonConsumable:
573
+ result["productType"] = "INAPP"
574
+ default:
575
+ result["productType"] = "INAPP"
576
+ }
577
+
578
+ return result
579
+ }
580
+
581
+ @available(iOS 15.0, *)
582
+ private func mapTransaction(_ transaction: Transaction) -> [String: Any] {
583
+ var result: [String: Any] = [
584
+ "transactionId": String(transaction.id),
585
+ "productIdentifier": transaction.productID,
586
+ "purchaseDate": ISO8601DateFormatter().string(from: transaction.purchaseDate),
587
+ "isAcknowledged": true,
588
+ "state": "PURCHASED"
589
+ ]
590
+
591
+ if let expirationDate = transaction.expirationDate {
592
+ result["expirationDate"] = ISO8601DateFormatter().string(from: expirationDate)
593
+ }
594
+
595
+ return result
596
+ }
597
+
598
+ @available(iOS 15.0, *)
599
+ private func formatPeriod(_ period: Product.SubscriptionPeriod) -> String {
600
+ switch period.unit {
601
+ case .day:
602
+ return "\(period.value) day\(period.value == 1 ? "" : "s")"
603
+ case .week:
604
+ return "\(period.value) week\(period.value == 1 ? "" : "s")"
605
+ case .month:
606
+ return "\(period.value) month\(period.value == 1 ? "" : "s")"
607
+ case .year:
608
+ return "\(period.value) year\(period.value == 1 ? "" : "s")"
609
+ @unknown default:
610
+ return "\(period.value) unit\(period.value == 1 ? "" : "s")"
611
+ }
612
+ }
613
+
614
+ private func apiRequest(method: String, path: String, body: [String: Any]? = nil) async throws -> [String: Any] {
615
+ guard let apiKey = apiKey else {
616
+ throw NSError(domain: "CapivvPlugin", code: 1, userInfo: [NSLocalizedDescriptionKey: "Not configured"])
617
+ }
618
+
619
+ guard let url = URL(string: "\(apiUrl)\(path)") else {
620
+ throw NSError(domain: "CapivvPlugin", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
621
+ }
622
+
623
+ var request = URLRequest(url: url)
624
+ request.httpMethod = method
625
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
626
+ request.setValue(apiKey, forHTTPHeaderField: "X-Capivv-Api-Key")
627
+
628
+ if let body = body, method != "GET" {
629
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
630
+ }
631
+
632
+ let (data, response) = try await URLSession.shared.data(for: request)
633
+
634
+ guard let httpResponse = response as? HTTPURLResponse else {
635
+ throw NSError(domain: "CapivvPlugin", code: 3, userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
636
+ }
637
+
638
+ if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
639
+ let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error"
640
+ throw NSError(domain: "CapivvPlugin", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: errorMessage])
641
+ }
642
+
643
+ if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
644
+ return json
645
+ }
646
+
647
+ return [:]
648
+ }
649
+
650
+ private func log(_ message: String) {
651
+ if debug {
652
+ print("[Capivv] \(message)")
653
+ }
654
+ }
655
+ }