-
Notifications
You must be signed in to change notification settings - Fork 384
/
Copy pathStorePaymentManager.swift
463 lines (394 loc) · 17.8 KB
/
StorePaymentManager.swift
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
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
//
// StorePaymentManager.swift
// MullvadVPN
//
// Created by pronebird on 10/03/2020.
// Copyright © 2020 Mullvad VPN AB. All rights reserved.
//
import MullvadLogging
import MullvadREST
import MullvadTypes
import Operations
import StoreKit
import UIKit
/// Manager responsible for handling AppStore payments and passing StoreKit receipts to the backend.
///
/// - Warning: only interact with this object on the main queue.
final class StorePaymentManager: NSObject, SKPaymentTransactionObserver {
private enum OperationCategory {
static let sendStoreReceipt = "StorePaymentManager.sendStoreReceipt"
static let productsRequest = "StorePaymentManager.productsRequest"
}
private let logger = Logger(label: "StorePaymentManager")
private let operationQueue: OperationQueue = {
let queue = AsyncOperationQueue()
queue.name = "StorePaymentManagerQueue"
return queue
}()
private let backgroundTaskProvider: BackgroundTaskProvider
private let paymentQueue: SKPaymentQueue
private let apiProxy: APIQuerying
private let accountsProxy: RESTAccountHandling
private var observerList = ObserverList<StorePaymentObserver>()
private let transactionLog: StoreTransactionLog
/// Payment manager's delegate.
weak var delegate: StorePaymentManagerDelegate?
/// A dictionary that maps each payment to account number.
private var paymentToAccountToken = [SKPayment: String]()
/// Returns true if the device is able to make payments.
static var canMakePayments: Bool {
SKPaymentQueue.canMakePayments()
}
/// Designated initializer
///
/// - Parameters:
/// - backgroundTaskProvider: the background task provider.
/// - queue: the payment queue. Typically `SKPaymentQueue.default()`.
/// - apiProxy: the object implement `APIQuerying`
/// - accountsProxy: the object implementing `RESTAccountHandling`.
/// - transactionLog: an instance of transaction log. Typically ``StoreTransactionLog/default``.
init(
backgroundTaskProvider: BackgroundTaskProvider,
queue: SKPaymentQueue,
apiProxy: APIQuerying,
accountsProxy: RESTAccountHandling,
transactionLog: StoreTransactionLog
) {
self.backgroundTaskProvider = backgroundTaskProvider
paymentQueue = queue
self.apiProxy = apiProxy
self.accountsProxy = accountsProxy
self.transactionLog = transactionLog
}
/// Loads transaction log from disk and starts monitoring payment queue.
func start() {
// Load transaction log from file before starting the payment queue.
logger.debug("Load transaction log.")
transactionLog.read()
logger.debug("Start payment queue monitoring")
paymentQueue.add(self)
}
// MARK: - SKPaymentTransactionObserver
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
// Ensure that all calls happen on main queue because StoreKit does not guarantee on which queue the delegate
// will be invoked.
DispatchQueue.main.async {
self.handleTransactions(transactions)
}
}
// MARK: - Payment observation
/// Add payment observer
/// - Parameter observer: an observer object.
func addPaymentObserver(_ observer: StorePaymentObserver) {
observerList.append(observer)
}
/// Remove payment observer
/// - Parameter observer: an observer object.
func removePaymentObserver(_ observer: StorePaymentObserver) {
observerList.remove(observer)
}
// MARK: - Products and payments
/// Fetch products from AppStore using product identifiers.
///
/// - Parameters:
/// - productIdentifiers: a set of product identifiers.
/// - completionHandler: completion handler. Invoked on main queue.
/// - Returns: the request cancellation token
func requestProducts(
with productIdentifiers: Set<StoreSubscription>,
completionHandler: @escaping (Result<SKProductsResponse, Error>) -> Void
) -> Cancellable {
let productIdentifiers = productIdentifiers.productIdentifiersSet
let operation = ProductsRequestOperation(
productIdentifiers: productIdentifiers,
completionHandler: completionHandler
)
operation.addCondition(MutuallyExclusive(category: OperationCategory.productsRequest))
operationQueue.addOperation(operation)
return operation
}
/// Add payment and associate it with the account number.
///
/// Validates the user account with backend before adding the payment to the queue.
///
/// - Parameters:
/// - payment: an intance of `SKPayment`.
/// - accountNumber: the account number to credit.
func addPayment(_ payment: SKPayment, for accountNumber: String) {
logger.debug("Validating account before the purchase.")
// Validate account token before adding new payment to the queue.
validateAccount(accountNumber: accountNumber) { error in
if let error {
self.logger.error("Failed to validate the account. Payment is ignored.")
let event = StorePaymentEvent.failure(
StorePaymentFailure(
transaction: nil,
payment: payment,
accountNumber: accountNumber,
error: error
)
)
self.observerList.notify { observer in
observer.storePaymentManager(self, didReceiveEvent: event)
}
} else {
self.logger.debug("Add payment to the queue.")
self.associateAccountNumber(accountNumber, and: payment)
self.paymentQueue.add(payment)
}
}
}
/// Restore purchases by sending the AppStore receipt to backend.
///
/// - Parameters:
/// - accountNumber: the account number to credit.
/// - completionHandler: completion handler invoked on the main queue.
/// - Returns: the request cancellation token.
func restorePurchases(
for accountNumber: String,
completionHandler: @escaping (Result<REST.CreateApplePaymentResponse, Error>) -> Void
) -> Cancellable {
logger.debug("Restore purchases.")
return sendStoreReceipt(
accountNumber: accountNumber,
forceRefresh: true,
completionHandler: completionHandler
)
}
// MARK: - Private methods
/// Associate account number with the payment object.
///
/// - Parameters:
/// - accountNumber: the account number that should be credited with the payment.
/// - payment: the payment object.
private func associateAccountNumber(_ accountNumber: String, and payment: SKPayment) {
dispatchPrecondition(condition: .onQueue(.main))
paymentToAccountToken[payment] = accountNumber
}
/// Remove association between the payment object and the account number.
///
/// Since the association between account numbers and payments is not persisted, this method may consult the delegate to provide the account number to
/// credit. This can happen for dangling transactions that remain in the payment queue between the application restarts. In the future this association should be
/// solved by using `SKPaymentQueue.applicationUsername`.
///
/// - Parameter payment: the payment object.
/// - Returns: The account number on success, otherwise `nil`.
private func deassociateAccountNumber(_ payment: SKPayment) -> String? {
dispatchPrecondition(condition: .onQueue(.main))
if let accountToken = paymentToAccountToken[payment] {
paymentToAccountToken.removeValue(forKey: payment)
return accountToken
} else {
return delegate?.storePaymentManager(self, didRequestAccountTokenFor: payment)
}
}
/// Validate account number.
///
/// - Parameters:
/// - accountNumber: the account number
/// - completionHandler: completion handler invoked on main queue. The completion block Receives `nil` upon success, otherwise an error.
private func validateAccount(
accountNumber: String,
completionHandler: @escaping (StorePaymentManagerError?) -> Void
) {
let accountOperation = ResultBlockOperation<Account>(dispatchQueue: .main) { finish in
self.accountsProxy.getAccountData(accountNumber: accountNumber).execute(
retryStrategy: .default,
completionHandler: finish
)
}
accountOperation.addObserver(BackgroundObserver(
backgroundTaskProvider: backgroundTaskProvider,
name: "Validate account number",
cancelUponExpiration: false
))
accountOperation.completionQueue = .main
accountOperation.completionHandler = { result in
completionHandler(result.error.map { StorePaymentManagerError.validateAccount($0) })
}
operationQueue.addOperation(accountOperation)
}
/// Send the AppStore receipt stored on device to the backend.
///
/// - Parameters:
/// - accountNumber: the account number to credit.
/// - forceRefresh: indicates whether the receipt should be downloaded from AppStore even when it's present on device.
/// - completionHandler: a completion handler invoked on main queue.
/// - Returns: the request cancellation token.
private func sendStoreReceipt(
accountNumber: String,
forceRefresh: Bool,
completionHandler: @escaping (Result<REST.CreateApplePaymentResponse, Error>) -> Void
) -> Cancellable {
let operation = SendStoreReceiptOperation(
apiProxy: apiProxy,
accountNumber: accountNumber,
forceRefresh: forceRefresh,
receiptProperties: nil,
completionHandler: completionHandler
)
operation.addObserver(
BackgroundObserver(
backgroundTaskProvider: backgroundTaskProvider,
name: "Send AppStore receipt",
cancelUponExpiration: true
)
)
operation.addCondition(MutuallyExclusive(category: OperationCategory.sendStoreReceipt))
operationQueue.addOperation(operation)
return operation
}
/// Handles an array of StoreKit transactions.
/// - Parameter transactions: an array of transactions
private func handleTransactions(_ transactions: [SKPaymentTransaction]) {
transactions.forEach { transaction in
handleTransaction(transaction)
}
}
/// Handle single StoreKit transaction.
/// - Parameter transaction: a transaction
private func handleTransaction(_ transaction: SKPaymentTransaction) {
switch transaction.transactionState {
case .deferred:
logger.info("Deferred \(transaction.payment.productIdentifier)")
case .failed:
let transactionError = transaction.error?.localizedDescription ?? "No error"
logger.error("Failed to purchase \(transaction.payment.productIdentifier): \(transactionError)")
didFailPurchase(transaction: transaction)
case .purchased:
logger.info("Purchased \(transaction.payment.productIdentifier)")
didFinishOrRestorePurchase(transaction: transaction)
case .purchasing:
logger.info("Purchasing \(transaction.payment.productIdentifier)")
case .restored:
logger.info("Restored \(transaction.payment.productIdentifier)")
didFinishOrRestorePurchase(transaction: transaction)
@unknown default:
logger.warning("Unknown transactionState = \(transaction.transactionState.rawValue)")
}
}
/// Handle failed transaction by finishing it and notifying the observers.
///
/// - Parameter transaction: the failed transaction.
private func didFailPurchase(transaction: SKPaymentTransaction) {
paymentQueue.finishTransaction(transaction)
let paymentFailure = if let accountToken = deassociateAccountNumber(transaction.payment) {
StorePaymentFailure(
transaction: transaction,
payment: transaction.payment,
accountNumber: accountToken,
error: .storePayment(transaction.error!)
)
} else {
StorePaymentFailure(
transaction: transaction,
payment: transaction.payment,
accountNumber: nil,
error: .noAccountSet
)
}
observerList.notify { observer in
observer.storePaymentManager(self, didReceiveEvent: .failure(paymentFailure))
}
}
/// Handle successful transaction that's in purchased or restored state.
///
/// - Consults with transaction log before handling the transaction. Transactions that are already processed are removed from the payment queue,
/// observers are not notified as they had already received the corresponding events.
/// - Keeps transaction in the queue if association between transaction payment and account number cannot be established. Notifies observers with the error.
/// - Sends the AppStore receipt to backend.
///
/// - Parameter transaction: the transaction that's in purchased or restored state.
private func didFinishOrRestorePurchase(transaction: SKPaymentTransaction) {
// Obtain transaction identifier which must be set on transactions with purchased or restored state.
guard let transactionIdentifier = transaction.transactionIdentifier else {
logger.warning("Purchased or restored transaction does not contain a transaction identifier!")
return
}
// Check if transaction is already processed.
guard !transactionLog.contains(transactionIdentifier: transactionIdentifier) else {
logger.debug("Found transaction that is already processed.")
paymentQueue.finishTransaction(transaction)
return
}
// Find the account number associated with the payment.
guard let accountNumber = deassociateAccountNumber(transaction.payment) else {
logger.debug("Cannot locate the account associated with the purchase. Keep transaction in the queue.")
let event = StorePaymentEvent.failure(
StorePaymentFailure(
transaction: transaction,
payment: transaction.payment,
accountNumber: nil,
error: .noAccountSet
)
)
observerList.notify { observer in
observer.storePaymentManager(self, didReceiveEvent: event)
}
return
}
// Send the AppStore receipt to the backend.
_ = sendStoreReceipt(accountNumber: accountNumber, forceRefresh: false) { result in
self.didSendStoreReceipt(
accountNumber: accountNumber,
transactionIdentifier: transactionIdentifier,
transaction: transaction,
result: result
)
}
}
/// Handles the result of uploading the AppStore receipt to the backend.
///
/// If the server response is successful, this function adds the transaction identifier to the transaction log to make sure that the same transaction is not
/// processed twice, then finishes the transaction.
///
/// This is important because the call to `SKPaymentQueue.finishTransaction()` may fail, causing the same transaction to re-appear on the payment
/// queue. Since the transaction was already processed, no action needs to be performed besides another attempt to finish it and hopefully remove it from
/// the payment queue for good.
///
/// If the server response indicates an error, then this function keeps the transaction in the payment queue in order to process it again later.
///
/// Finally, the ``StorePaymentEvent`` is produced and dispatched to observers to notify them on the progress.
///
/// - Parameters:
/// - accountNumber: the account number to credit
/// - transactionIdentifier: the transaction identifier
/// - transaction: the transaction object
/// - result: the result of uploading the AppStore receipt to the backend.
private func didSendStoreReceipt(
accountNumber: String,
transactionIdentifier: String,
transaction: SKPaymentTransaction,
result: Result<REST.CreateApplePaymentResponse, Error>
) {
var event: StorePaymentEvent?
switch result {
case let .success(response):
// Save transaction identifier to transaction log to identify it later if it resurrects on the payment queue.
transactionLog.add(transactionIdentifier: transactionIdentifier)
// Finish transaction to remove it from the payment queue.
paymentQueue.finishTransaction(transaction)
event = StorePaymentEvent.finished(StorePaymentCompletion(
transaction: transaction,
accountNumber: accountNumber,
serverResponse: response
))
case let .failure(error as StorePaymentManagerError):
logger.debug("Failed to upload the receipt. Keep transaction in the queue.")
event = StorePaymentEvent.failure(StorePaymentFailure(
transaction: transaction,
payment: transaction.payment,
accountNumber: accountNumber,
error: error
))
default:
break
}
if let event {
observerList.notify { observer in
observer.storePaymentManager(self, didReceiveEvent: event)
}
}
}
}
// swiftlint:disable:this file_length