1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
|
import Foundation
import StoreKit
#if canImport(UIKit)
import UIKit
#endif
@MainActor
@Observable
final class PurchaseService {
struct CachedEntitlement: Codable {
let tier: FeatureTier
let activeProductID: String?
let updatedAt: Date
}
static let shared = PurchaseService()
static let monthlyProductID = "domaindig.pro.monthly"
static let yearlyProductID = "domaindig.pro.yearly"
static let proPlusMonthlyProductID = "domaindig.dataplus.monthly"
static let proPlusYearlyProductID = "domaindig.dataplus.yearly"
static let productIDs = [
monthlyProductID,
yearlyProductID,
proPlusMonthlyProductID,
proPlusYearlyProductID
]
private static let entitlementCacheKey = "purchase.cachedEntitlement"
#if DEBUG
// Local-only screenshot/testing override. Release builds always use StoreKit entitlements.
private static let debugForceFreeArgument = "DOMAIN_DIG_FORCE_FREE"
private static let debugForceProArgument = "DOMAIN_DIG_FORCE_PRO"
private static let debugForceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS"
#endif
static var cachedEntitlement: CachedEntitlement? {
#if DEBUG
if let forcedEntitlement = debugForcedEntitlement {
return forcedEntitlement
}
#endif
guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil }
return try? JSONDecoder().decode(CachedEntitlement.self, from: data)
}
static var cachedTier: FeatureTier {
cachedEntitlement?.tier ?? .free
}
var products: [Product] = []
var currentTier: FeatureTier
var activeProductID: String?
var isLoadingProducts = false
var isPurchasing = false
var isRestoring = false
var statusMessage: String?
var errorMessage: String?
private var updatesTask: Task<Void, Never>?
private init() {
currentTier = Self.cachedTier
activeProductID = Self.cachedEntitlement?.activeProductID
applyDebugOverrideIfNeeded()
updatesTask = observeTransactionUpdates()
Task {
await refreshProducts()
await refreshEntitlements()
}
}
var hasProAccess: Bool {
currentTier != .free
}
var hasProPlusAccess: Bool {
currentTier == .proPlus
}
func refreshProducts() async {
isLoadingProducts = true
errorMessage = nil
do {
let fetchedProducts = try await Product.products(for: Self.productIDs)
products = fetchedProducts.sorted { lhs, rhs in
productSortIndex(for: lhs.id) < productSortIndex(for: rhs.id)
}
} catch {
products = []
errorMessage = storeMessage(for: error, fallback: "Pricing is unavailable right now.")
}
isLoadingProducts = false
}
func refreshEntitlements() async {
var activeTransactions: [Transaction] = []
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue
}
guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
continue
}
activeTransactions.append(transaction)
}
let activeProductID = activeTransactions
.sorted { $0.purchaseDate > $1.purchaseDate }
.first?
.productID
self.activeProductID = activeProductID
currentTier = tier(for: activeProductID)
persistCurrentEntitlement()
applyDebugOverrideIfNeeded()
}
func purchase(_ product: Product) async {
isPurchasing = true
statusMessage = nil
errorMessage = nil
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try verifiedTransaction(from: verification)
apply(transaction: transaction)
await transaction.finish()
await refreshEntitlements()
statusMessage = currentTier == .proPlus ? "Pro+ is active." : "Pro is active."
case .userCancelled:
break
case .pending:
statusMessage = "Purchase is pending approval."
@unknown default:
errorMessage = "The purchase could not be completed."
}
} catch {
errorMessage = storeMessage(for: error, fallback: "The purchase could not be completed.")
}
isPurchasing = false
}
func restorePurchases() async {
isRestoring = true
statusMessage = nil
errorMessage = nil
do {
try await AppStore.sync()
await refreshEntitlements()
statusMessage = hasProAccess ? "Purchases restored." : "No previous Pro purchase was found."
} catch {
errorMessage = storeMessage(for: error, fallback: "Restore failed. Try again when the App Store is available.")
}
isRestoring = false
}
func manageSubscription() async {
errorMessage = nil
#if canImport(UIKit)
if ProcessInfo.processInfo.isiOSAppOnMac {
errorMessage = "Manage Subscription is not available on this device."
return
}
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) else {
errorMessage = "Manage Subscription is not available right now."
return
}
do {
try await AppStore.showManageSubscriptions(in: scene)
} catch {
errorMessage = storeMessage(for: error, fallback: "Manage Subscription is not available right now.")
}
#else
errorMessage = "Manage Subscription is not available on this platform."
#endif
}
func clearMessages() {
statusMessage = nil
errorMessage = nil
}
private func apply(transaction: Transaction) {
guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
return
}
activeProductID = transaction.productID
currentTier = tier(for: transaction.productID)
persistCurrentEntitlement()
applyDebugOverrideIfNeeded()
}
private func observeTransactionUpdates() -> Task<Void, Never> {
Task.detached(priority: .background) { [weak self] in
for await result in Transaction.updates {
guard let self else { return }
await self.handleTransactionUpdate(result)
}
}
}
private func handleTransactionUpdate(_ result: VerificationResult<Transaction>) async {
guard case .verified(let transaction) = result else { return }
apply(transaction: transaction)
await transaction.finish()
await refreshEntitlements()
}
private func persistCurrentEntitlement() {
let cachedEntitlement = CachedEntitlement(
tier: currentTier,
activeProductID: activeProductID,
updatedAt: Date()
)
if let data = try? JSONEncoder().encode(cachedEntitlement) {
UserDefaults.standard.set(data, forKey: Self.entitlementCacheKey)
}
}
private func applyDebugOverrideIfNeeded() {
#if DEBUG
guard let forcedEntitlement = Self.debugForcedEntitlement else { return }
currentTier = forcedEntitlement.tier
activeProductID = forcedEntitlement.activeProductID
#endif
}
private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction {
switch result {
case .verified(let transaction):
return transaction
case .unverified:
throw StoreKitError.notEntitled
}
}
private func productSortIndex(for productID: String) -> Int {
switch productID {
case Self.monthlyProductID:
return 0
case Self.yearlyProductID:
return 1
case Self.proPlusMonthlyProductID:
return 2
case Self.proPlusYearlyProductID:
return 3
default:
return Int.max
}
}
private func tier(for productID: String?) -> FeatureTier {
switch productID {
case Self.monthlyProductID, Self.yearlyProductID:
return .pro
case Self.proPlusMonthlyProductID, Self.proPlusYearlyProductID:
return .proPlus
default:
return .free
}
}
private func storeMessage(for error: Error, fallback: String) -> String {
if let storeKitError = error as? StoreKitError {
switch storeKitError {
case .networkError:
return "The App Store is offline right now."
default:
return fallback
}
}
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
return message.isEmpty ? fallback : message
}
#if DEBUG
private static var debugForcedEntitlement: CachedEntitlement? {
let arguments = ProcessInfo.processInfo.arguments
if arguments.contains(debugForceFreeArgument) {
return CachedEntitlement(
tier: .free,
activeProductID: nil,
updatedAt: .distantPast
)
}
if arguments.contains(debugForceProPlusArgument) {
return CachedEntitlement(
tier: .proPlus,
activeProductID: proPlusMonthlyProductID,
updatedAt: .distantPast
)
}
if arguments.contains(debugForceProArgument) {
return CachedEntitlement(
tier: .pro,
activeProductID: monthlyProductID,
updatedAt: .distantPast
)
}
return nil
}
#endif
}
|