@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.
- package/CapivvCapacitor.podspec +20 -0
- package/README.md +378 -0
- package/android/build.gradle +74 -0
- package/android/src/main/AndroidManifest.xml +5 -0
- package/android/src/main/java/com/capivv/capacitor/CapivvPlugin.kt +708 -0
- package/dist/esm/components/index.d.ts +1 -0
- package/dist/esm/components/index.js +9 -0
- package/dist/esm/components/index.js.map +1 -0
- package/dist/esm/definitions.d.ts +327 -0
- package/dist/esm/definitions.js +20 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +7 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/l10n/translations.d.ts +73 -0
- package/dist/esm/l10n/translations.js +397 -0
- package/dist/esm/l10n/translations.js.map +1 -0
- package/dist/esm/templates/types.d.ts +118 -0
- package/dist/esm/templates/types.js +8 -0
- package/dist/esm/templates/types.js.map +1 -0
- package/dist/esm/web.d.ts +72 -0
- package/dist/esm/web.js +230 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +661 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +664 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Plugin/CapivvPlugin.m +21 -0
- package/ios/Plugin/CapivvPlugin.swift +655 -0
- package/package.json +89 -0
|
@@ -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
|
+
}
|