From de0af8e2d2722b0fde2a72d6c023598c504fa4c9 Mon Sep 17 00:00:00 2001 From: Cesar de la Vega Date: Fri, 31 Jul 2020 17:51:20 -0700 Subject: [PATCH] Fixes handling of deferred payments (ask to buy) (#296) * adds handling of SKPaymentTransactionStateDeferred * adds test * Moves error to RCPurchasesErrorUtils * extracts the completion block logic into a function and protects against transaction.payment.productIdentifier --- Purchases/Public/RCPurchases.m | 33 ++++++++++++++----- Purchases/Public/RCPurchasesErrorUtils.h | 9 ++++- Purchases/Public/RCPurchasesErrorUtils.m | 6 +++- .../Purchasing/PurchasesTests.swift | 21 ++++++++++++ 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/Purchases/Public/RCPurchases.m b/Purchases/Public/RCPurchases.m index 386a50d3c3..e0aeda57dd 100644 --- a/Purchases/Public/RCPurchases.m +++ b/Purchases/Public/RCPurchases.m @@ -1031,10 +1031,7 @@ - (void)storeKitWrapper:(RCStoreKitWrapper *)storeKitWrapper break; } case SKPaymentTransactionStateFailed: { - RCPurchaseCompletedBlock completion = nil; - @synchronized (self) { - completion = self.purchaseCompleteCallbacks[transaction.payment.productIdentifier]; - } + _Nullable RCPurchaseCompletedBlock completion = [self getAndRemovePurchaseCompletedBlockFor:transaction]; CALL_IF_SET_ON_MAIN_THREAD( completion, @@ -1046,18 +1043,36 @@ - (void)storeKitWrapper:(RCStoreKitWrapper *)storeKitWrapper if (self.finishTransactions) { [self.storeKitWrapper finishTransaction:transaction]; } - - @synchronized (self) { - self.purchaseCompleteCallbacks[transaction.payment.productIdentifier] = nil; - } break; } - case SKPaymentTransactionStateDeferred: + case SKPaymentTransactionStateDeferred: { + _Nullable RCPurchaseCompletedBlock completion = [self getAndRemovePurchaseCompletedBlockFor:transaction]; + + NSError *pendingError = [RCPurchasesErrorUtils paymentDeferredError]; + CALL_IF_SET_ON_MAIN_THREAD(completion, + transaction, + nil, + pendingError, + transaction.error.code == SKErrorPaymentCancelled); + break; + } case SKPaymentTransactionStatePurchasing: break; } } +- (nullable RCPurchaseCompletedBlock)getAndRemovePurchaseCompletedBlockFor:(SKPaymentTransaction *)transaction +{ + RCPurchaseCompletedBlock completion = nil; + if (transaction.payment.productIdentifier) { + @synchronized (self) { + completion = self.purchaseCompleteCallbacks[transaction.payment.productIdentifier]; + self.purchaseCompleteCallbacks[transaction.payment.productIdentifier] = nil; + } + } + return completion; +} + - (void)storeKitWrapper:(RCStoreKitWrapper *)storeKitWrapper removedTransaction:(SKPaymentTransaction *)transaction {} diff --git a/Purchases/Public/RCPurchasesErrorUtils.h b/Purchases/Public/RCPurchasesErrorUtils.h index 60699a8f8c..ef2fafd138 100644 --- a/Purchases/Public/RCPurchasesErrorUtils.h +++ b/Purchases/Public/RCPurchasesErrorUtils.h @@ -40,7 +40,6 @@ NS_SWIFT_NAME(Purchases.ErrorUtils) */ + (NSError *)backendErrorWithBackendCode:(nullable NSNumber *)backendCode backendMessage:(nullable NSString *)backendMessage; - /** * Maps an RCBackendError code to a [RCPurchasesErrorCode] code. Constructs an NSError with the mapped code and adds a * [RCUnderlyingErrorKey] in the [NSError.userInfo] dictionary. The backend error code will be mapped using @@ -79,6 +78,14 @@ NS_SWIFT_NAME(Purchases.ErrorUtils) */ + (NSError *)missingAppUserIDError; +/** + * Constructs an NSError with the [RCPaymentPendingError] code. + * + * @note This error is used during an “ask to buy” flow for a payment. The completion block of the purchasing function + * will get this error to indicate the guardian has to complete the purchase. + */ ++ (NSError *)paymentDeferredError; + /** * Maps an SKErrorCode code to a RCPurchasesErrorCode code. Constructs an NSError with the mapped code and adds a * [RCUnderlyingErrorKey] in the NSError.userInfo dictionary. The SKErrorCode code will be mapped using diff --git a/Purchases/Public/RCPurchasesErrorUtils.m b/Purchases/Public/RCPurchasesErrorUtils.m index e40735ab70..48e33f7021 100644 --- a/Purchases/Public/RCPurchasesErrorUtils.m +++ b/Purchases/Public/RCPurchasesErrorUtils.m @@ -210,7 +210,6 @@ + (NSError *)errorWithCode:(RCPurchasesErrorCode)code return [self errorWithCode:code message:message underlyingError:nil]; } - + (NSError *)errorWithCode:(RCPurchasesErrorCode)code underlyingError:(nullable NSError *)underlyingError { return [self errorWithCode:code message:nil underlyingError:underlyingError extraUserInfo:nil]; @@ -300,6 +299,11 @@ + (NSError *)missingAppUserIDError { return [self errorWithCode:RCInvalidAppUserIdError]; } ++ (NSError *)paymentDeferredError { + return [self errorWithCode:RCPaymentPendingError + message:@"The payment is deferred."]; +} + + (NSError *)purchasesErrorWithSKError:(NSError *)skError { RCPurchasesErrorCode errorCode = RCPurchasesErrorCodeFromSKError(skError); diff --git a/PurchasesTests/Purchasing/PurchasesTests.swift b/PurchasesTests/Purchasing/PurchasesTests.swift index 38bce429e5..af2da53edb 100644 --- a/PurchasesTests/Purchasing/PurchasesTests.swift +++ b/PurchasesTests/Purchasing/PurchasesTests.swift @@ -2070,6 +2070,27 @@ class PurchasesTests: XCTestCase { expect(RCSystemInfo.serverHostURL()) == defaultHostURL } + func testNotifiesIfTransactionIsDeferredFromStoreKit() { + setupPurchases() + let product = MockSKProduct(mockProductIdentifier: "com.product.id1") + var receivedError: NSError? + self.purchases?.purchaseProduct(product) { (tx, info, error, userCancelled) in + receivedError = error as NSError? + } + + let transaction = MockTransaction() + transaction.mockPayment = self.storeKitWrapper.payment! + + transaction.mockState = SKPaymentTransactionState.deferred + self.storeKitWrapper.delegate?.storeKitWrapper(self.storeKitWrapper, updatedTransaction: transaction) + + expect(self.backend.postReceiptDataCalled).to(beFalse()) + expect(self.storeKitWrapper.finishCalled).to(beFalse()) + expect(receivedError).toEventuallyNot(beNil()) + expect(receivedError?.domain).toEventually(equal(Purchases.ErrorDomain)) + expect(receivedError?.code).toEventually(equal(Purchases.ErrorCode.paymentPendingError.rawValue)) + } + private func verifyUpdatedCaches(newAppUserID: String) { expect(self.backend.getSubscriberCallCount).toEventually(equal(2)) expect(self.deviceCache.cachedPurchaserInfo.count).toEventually(equal(2))