From c755267a3acbad29a97bb824f469d3355f201849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Vargas=20Casaseca?= Date: Tue, 25 Oct 2022 17:48:09 +0200 Subject: [PATCH 01/14] Add protocol and manager to handle WPCOM plans In-App Purchases --- .../InAppPurchasesForWPComPlansManager.swift | 35 +++++++++++++++++++ .../WooCommerce.xcodeproj/project.pbxproj | 4 +++ 2 files changed, 39 insertions(+) create mode 100644 WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift new file mode 100644 index 00000000000..ac028c3c714 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift @@ -0,0 +1,35 @@ +import Foundation +import StoreKit + +enum WPComPlanProductTransactionStatus { + case notStarted // Neither purchased through Apple nor the WPCom plan was unlocked + case pending // In-App purchase was successful but the WPCom plan unlock request failed + case finished // In-App purchase and WPCom plan unlock succesful +} + +struct WPComPlanProduct { + let localizedTitle: String + let localizedDescription: String + let price: String + let currency: String + let status: WPComPlanProductTransactionStatus +} + +protocol InAppPurchasesForWPComPlansProtocol { + func fetchProducts() async throws -> [WPComPlanProduct] + func purchase(product: WPComPlanProduct, for remoteSiteId: Int64) async throws + func inAppPurchasesAreSupported() async -> Bool +} + +final class InAppPurchasesForWPComPlansManager: InAppPurchasesForWPComPlansProtocol { + func fetchProducts() async throws -> [WPComPlanProduct] { + [] + } + + func purchase(product: WPComPlanProduct, for remoteSiteId: Int64) async throws { + } + + func inAppPurchasesAreSupported() async -> Bool { + await Storefront.current?.countryCode == "USA" + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index bc2206b1c23..87e6884a0a2 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1320,6 +1320,7 @@ B958A7D628B5310100823EEF /* URLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = B958A7D428B5302500823EEF /* URLOpener.swift */; }; B958A7D828B5316A00823EEF /* MockURLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = B958A7D728B5316A00823EEF /* MockURLOpener.swift */; }; B96B536B2816ECFC00F753E6 /* CardPresentPluginsDataProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96B536A2816ECFC00F753E6 /* CardPresentPluginsDataProviderTests.swift */; }; + B96D6C0729081AE5003D2DC0 /* InAppPurchasesForWPComPlansManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96D6C0629081AE5003D2DC0 /* InAppPurchasesForWPComPlansManager.swift */; }; B9B0391628A6824200DC1C83 /* PermanentNoticePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B0391528A6824200DC1C83 /* PermanentNoticePresenter.swift */; }; B9B0391828A6838400DC1C83 /* PermanentNoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B0391728A6838400DC1C83 /* PermanentNoticeView.swift */; }; B9B0391A28A68ADE00DC1C83 /* ConstraintsUpdatingHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B0391928A68ADE00DC1C83 /* ConstraintsUpdatingHostingController.swift */; }; @@ -3241,6 +3242,7 @@ B958A7D428B5302500823EEF /* URLOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLOpener.swift; sourceTree = ""; }; B958A7D728B5316A00823EEF /* MockURLOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLOpener.swift; sourceTree = ""; }; B96B536A2816ECFC00F753E6 /* CardPresentPluginsDataProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentPluginsDataProviderTests.swift; sourceTree = ""; }; + B96D6C0629081AE5003D2DC0 /* InAppPurchasesForWPComPlansManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchasesForWPComPlansManager.swift; sourceTree = ""; }; B9B0391528A6824200DC1C83 /* PermanentNoticePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermanentNoticePresenter.swift; sourceTree = ""; }; B9B0391728A6838400DC1C83 /* PermanentNoticeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PermanentNoticeView.swift; sourceTree = ""; }; B9B0391928A68ADE00DC1C83 /* ConstraintsUpdatingHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConstraintsUpdatingHostingController.swift; sourceTree = ""; }; @@ -8513,6 +8515,7 @@ isa = PBXGroup; children = ( E1325EFA28FD544E00EC9B2A /* InAppPurchasesDebugView.swift */, + B96D6C0629081AE5003D2DC0 /* InAppPurchasesForWPComPlansManager.swift */, ); path = InAppPurchases; sourceTree = ""; @@ -10488,6 +10491,7 @@ 319A626127ACAE3400BC96C3 /* InPersonPaymentsPluginChoicesView.swift in Sources */, 6856D31F941A33BAE66F394D /* KeyboardFrameAdjustmentProvider.swift in Sources */, CCFC50592743E021001E505F /* EditableOrderViewModel.swift in Sources */, + B96D6C0729081AE5003D2DC0 /* InAppPurchasesForWPComPlansManager.swift in Sources */, 6856DB2E741639716E149967 /* KeyboardStateProvider.swift in Sources */, ABC35F18E744C5576B986CB3 /* InPersonPaymentsUnavailableView.swift in Sources */, ABC35528D2D6BE6F516E5CEF /* InPersonPaymentsOnboardingError.swift in Sources */, From 0582234702093da0f4cd3660d73543cc1031f9b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Vargas=20Casaseca?= Date: Wed, 26 Oct 2022 11:58:32 +0200 Subject: [PATCH 02/14] Add logic to IAP manager --- .../InAppPurchasesForWPComPlansManager.swift | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift index ac028c3c714..2c2b490049a 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift @@ -1,20 +1,22 @@ import Foundation import StoreKit +import Yosemite + +protocol WPComPlanProduct { + var displayName: String { get } + var description: String { get } + var id: String { get } + var displayPrice: String { get } +} + +extension StoreKit.Product: WPComPlanProduct {} enum WPComPlanProductTransactionStatus { case notStarted // Neither purchased through Apple nor the WPCom plan was unlocked - case pending // In-App purchase was successful but the WPCom plan unlock request failed + case pending // In-App purchase was successful but the WPCom plan unlock request is pending case finished // In-App purchase and WPCom plan unlock succesful } -struct WPComPlanProduct { - let localizedTitle: String - let localizedDescription: String - let price: String - let currency: String - let status: WPComPlanProductTransactionStatus -} - protocol InAppPurchasesForWPComPlansProtocol { func fetchProducts() async throws -> [WPComPlanProduct] func purchase(product: WPComPlanProduct, for remoteSiteId: Int64) async throws @@ -22,14 +24,44 @@ protocol InAppPurchasesForWPComPlansProtocol { } final class InAppPurchasesForWPComPlansManager: InAppPurchasesForWPComPlansProtocol { + private let stores = ServiceLocator.stores + // ISO 3166-1 Alpha-3 country code representation. + private let supportedCountriesCodes = ["USA"] + func fetchProducts() async throws -> [WPComPlanProduct] { - [] + try await withCheckedThrowingContinuation { continuation in + stores.dispatch(InAppPurchaseAction.loadProducts(completion: { result in + switch result { + case .success(let products): + continuation.resume(returning: products) + case .failure(let error): + continuation.resume(throwing: error) + } + })) + } + } + + func transactionStatus(for product: WPComPlanProduct) async -> WPComPlanProductTransactionStatus { + guard let result = await Transaction.latest(for: product.id) else { + return .notStarted + } + + return await Transaction.unfinished.contains(result) ? .pending : .finished } func purchase(product: WPComPlanProduct, for remoteSiteId: Int64) async throws { + guard let storeKitProduct = product as? StoreKit.Product else { + return + } + + stores.dispatch(InAppPurchaseAction.purchaseProduct(siteID: remoteSiteId, product: storeKitProduct, completion: { _ in })) } func inAppPurchasesAreSupported() async -> Bool { - await Storefront.current?.countryCode == "USA" + guard let countryCode = await Storefront.current?.countryCode else { + return false + } + + return supportedCountriesCodes.contains(countryCode) } } From 2f04498758b24914817ad5018bb7f69e41d6bc38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Vargas=20Casaseca?= Date: Wed, 26 Oct 2022 14:27:38 +0200 Subject: [PATCH 03/14] Refactor to pass the id instead of the whole WPComPlanProduct product. Fetch products before purchase. --- .../InAppPurchasesForWPComPlansManager.swift | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift index 2c2b490049a..6fedebb4a48 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift @@ -9,6 +9,10 @@ protocol WPComPlanProduct { var displayPrice: String { get } } +enum InAppPurchasesForWPComPlansError: Error { + case productNotFound +} + extension StoreKit.Product: WPComPlanProduct {} enum WPComPlanProductTransactionStatus { @@ -19,7 +23,8 @@ enum WPComPlanProductTransactionStatus { protocol InAppPurchasesForWPComPlansProtocol { func fetchProducts() async throws -> [WPComPlanProduct] - func purchase(product: WPComPlanProduct, for remoteSiteId: Int64) async throws + func transactionStatusForProduct(with id: String) async -> WPComPlanProductTransactionStatus + func purchaseProduct(with id: String, for remoteSiteId: Int64) async throws func inAppPurchasesAreSupported() async -> Bool } @@ -29,32 +34,32 @@ final class InAppPurchasesForWPComPlansManager: InAppPurchasesForWPComPlansProto private let supportedCountriesCodes = ["USA"] func fetchProducts() async throws -> [WPComPlanProduct] { - try await withCheckedThrowingContinuation { continuation in - stores.dispatch(InAppPurchaseAction.loadProducts(completion: { result in - switch result { - case .success(let products): - continuation.resume(returning: products) - case .failure(let error): - continuation.resume(throwing: error) - } - })) - } + try await fetchStoreKitProducts() } - func transactionStatus(for product: WPComPlanProduct) async -> WPComPlanProductTransactionStatus { - guard let result = await Transaction.latest(for: product.id) else { + func transactionStatusForProduct(with id: String) async -> WPComPlanProductTransactionStatus { + guard let result = await Transaction.latest(for: id) else { return .notStarted } return await Transaction.unfinished.contains(result) ? .pending : .finished } - func purchase(product: WPComPlanProduct, for remoteSiteId: Int64) async throws { - guard let storeKitProduct = product as? StoreKit.Product else { - return + func purchaseProduct(with id: String, for remoteSiteId: Int64) async throws { + guard let storeKitProduct = try await fetchStoreKitProducts().first(where: { $0.id == id }) else { + throw InAppPurchasesForWPComPlansError.productNotFound } - stores.dispatch(InAppPurchaseAction.purchaseProduct(siteID: remoteSiteId, product: storeKitProduct, completion: { _ in })) + try await withCheckedThrowingContinuation { continuation in + stores.dispatch(InAppPurchaseAction.purchaseProduct(siteID: remoteSiteId, product: storeKitProduct, completion: { result in + switch result { + case .success(_): + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + })) + } } func inAppPurchasesAreSupported() async -> Bool { @@ -64,4 +69,17 @@ final class InAppPurchasesForWPComPlansManager: InAppPurchasesForWPComPlansProto return supportedCountriesCodes.contains(countryCode) } + + private func fetchStoreKitProducts() async throws -> [StoreKit.Product] { + try await withCheckedThrowingContinuation { continuation in + stores.dispatch(InAppPurchaseAction.loadProducts(completion: { result in + switch result { + case .success(let products): + continuation.resume(returning: products) + case .failure(let error): + continuation.resume(throwing: error) + } + })) + } + } } From 62fbcb048ec19c5af06a24f769bef8d24aac935a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Vargas=20Casaseca?= Date: Wed, 26 Oct 2022 16:51:47 +0200 Subject: [PATCH 04/14] Add action to finish the product purchase --- .../InAppPurchasesForWPComPlansManager.swift | 14 ++++++++++++++ .../Yosemite/Actions/InAppPurchaseAction.swift | 1 + Yosemite/Yosemite/Stores/InAppPurchaseStore.swift | 9 +++++++++ 3 files changed, 24 insertions(+) diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift index 6fedebb4a48..aa2700d4750 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift @@ -25,6 +25,7 @@ protocol InAppPurchasesForWPComPlansProtocol { func fetchProducts() async throws -> [WPComPlanProduct] func transactionStatusForProduct(with id: String) async -> WPComPlanProductTransactionStatus func purchaseProduct(with id: String, for remoteSiteId: Int64) async throws + func finishProductPurchase(with id: String) async throws func inAppPurchasesAreSupported() async -> Bool } @@ -62,6 +63,19 @@ final class InAppPurchasesForWPComPlansManager: InAppPurchasesForWPComPlansProto } } + func finishProductPurchase(with id: String) async throws { + try await withCheckedThrowingContinuation { continuation in + stores.dispatch(InAppPurchaseAction.loadProducts(completion: { result in + switch result { + case .success(let products): + continuation.resume(returning: products) + case .failure(let error): + continuation.resume(throwing: error) + } + })) + } + } + func inAppPurchasesAreSupported() async -> Bool { guard let countryCode = await Storefront.current?.countryCode else { return false diff --git a/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift b/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift index f0774d2dfbb..608c39fd170 100644 --- a/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift +++ b/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift @@ -4,4 +4,5 @@ import StoreKit public enum InAppPurchaseAction: Action { case loadProducts(completion: (Result<[StoreKit.Product], Error>) -> Void) case purchaseProduct(siteID: Int64, product: StoreKit.Product, completion: (Result) -> Void) + case handleCompletedTransaction(_ result: VerificationResult, completion: (Result<(), Error>) -> Void) } diff --git a/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift b/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift index 49da4fd1035..a853a052942 100644 --- a/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift +++ b/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift @@ -32,6 +32,15 @@ public class InAppPurchaseStore: Store { loadProducts(completion: completion) case .purchaseProduct(let siteID, let product, let completion): purchaseProduct(siteID: siteID, product: product, completion: completion) + case .handleCompletedTransaction(let verificationResult, let completion): + Task { + do { + try await handleCompletedTransaction(verificationResult) + completion(.success(())) + } catch { + completion(.failure(error)) + } + } } } } From 144924ec4b67badbcd503cb377c0ab0cc72d423e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Vargas=20Casaseca?= Date: Wed, 26 Oct 2022 18:29:09 +0200 Subject: [PATCH 05/14] Move logic to InAppPurchaseStore. --- .../InAppPurchasesDebugView.swift | 2 +- .../InAppPurchasesForWPComPlansManager.swift | 66 ++++++++----------- .../Actions/InAppPurchaseAction.swift | 6 +- .../Yosemite/Stores/InAppPurchaseStore.swift | 65 ++++++++++++++++-- 4 files changed, 93 insertions(+), 46 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift index d2447c83204..7efbd1a366b 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift @@ -20,7 +20,7 @@ struct InAppPurchasesDebugView: View { } else { ForEach(products) { product in Button(product.description) { - stores.dispatch(InAppPurchaseAction.purchaseProduct(siteID: siteID, product: product, completion: { _ in })) + stores.dispatch(InAppPurchaseAction.purchaseProduct(siteID: siteID, productID: product.id, completion: { _ in })) } } } diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift index aa2700d4750..838952120c4 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift @@ -9,10 +9,6 @@ protocol WPComPlanProduct { var displayPrice: String { get } } -enum InAppPurchasesForWPComPlansError: Error { - case productNotFound -} - extension StoreKit.Product: WPComPlanProduct {} enum WPComPlanProductTransactionStatus { @@ -23,36 +19,45 @@ enum WPComPlanProductTransactionStatus { protocol InAppPurchasesForWPComPlansProtocol { func fetchProducts() async throws -> [WPComPlanProduct] - func transactionStatusForProduct(with id: String) async -> WPComPlanProductTransactionStatus + func userDidPurchaseProduct(with id: String) async throws -> Bool func purchaseProduct(with id: String, for remoteSiteId: Int64) async throws - func finishProductPurchase(with id: String) async throws + func retryWPComSyncForPurchasedProduct(with id: String) async throws func inAppPurchasesAreSupported() async -> Bool } final class InAppPurchasesForWPComPlansManager: InAppPurchasesForWPComPlansProtocol { private let stores = ServiceLocator.stores - // ISO 3166-1 Alpha-3 country code representation. - private let supportedCountriesCodes = ["USA"] + func fetchProducts() async throws -> [WPComPlanProduct] { - try await fetchStoreKitProducts() + try await withCheckedThrowingContinuation { continuation in + stores.dispatch(InAppPurchaseAction.loadProducts(completion: { result in + switch result { + case .success(let products): + continuation.resume(returning: products) + case .failure(let error): + continuation.resume(throwing: error) + } + })) + } } - func transactionStatusForProduct(with id: String) async -> WPComPlanProductTransactionStatus { - guard let result = await Transaction.latest(for: id) else { - return .notStarted + func userDidPurchaseProduct(with id: String) async throws -> Bool { + try await withCheckedThrowingContinuation { continuation in + stores.dispatch(InAppPurchaseAction.userDidPurchaseProduct(productID: id, completion: { result in + switch result { + case .success(let productIsPurchased): + continuation.resume(returning: productIsPurchased) + case .failure(let error): + continuation.resume(throwing: error) + } + })) } - - return await Transaction.unfinished.contains(result) ? .pending : .finished } func purchaseProduct(with id: String, for remoteSiteId: Int64) async throws { - guard let storeKitProduct = try await fetchStoreKitProducts().first(where: { $0.id == id }) else { - throw InAppPurchasesForWPComPlansError.productNotFound - } - try await withCheckedThrowingContinuation { continuation in - stores.dispatch(InAppPurchaseAction.purchaseProduct(siteID: remoteSiteId, product: storeKitProduct, completion: { result in + stores.dispatch(InAppPurchaseAction.purchaseProduct(siteID: remoteSiteId, productID: id, completion: { result in switch result { case .success(_): continuation.resume() @@ -63,9 +68,9 @@ final class InAppPurchasesForWPComPlansManager: InAppPurchasesForWPComPlansProto } } - func finishProductPurchase(with id: String) async throws { + func retryWPComSyncForPurchasedProduct(with id: String) async throws { try await withCheckedThrowingContinuation { continuation in - stores.dispatch(InAppPurchaseAction.loadProducts(completion: { result in + stores.dispatch(InAppPurchaseAction.retryWPComSyncForPurchasedProduct(productID: id, completion: { result in switch result { case .success(let products): continuation.resume(returning: products) @@ -77,22 +82,9 @@ final class InAppPurchasesForWPComPlansManager: InAppPurchasesForWPComPlansProto } func inAppPurchasesAreSupported() async -> Bool { - guard let countryCode = await Storefront.current?.countryCode else { - return false - } - - return supportedCountriesCodes.contains(countryCode) - } - - private func fetchStoreKitProducts() async throws -> [StoreKit.Product] { - try await withCheckedThrowingContinuation { continuation in - stores.dispatch(InAppPurchaseAction.loadProducts(completion: { result in - switch result { - case .success(let products): - continuation.resume(returning: products) - case .failure(let error): - continuation.resume(throwing: error) - } + await withCheckedContinuation { continuation in + stores.dispatch(InAppPurchaseAction.inAppPurchasesAreSupported(completion: { result in + continuation.resume(returning: result) })) } } diff --git a/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift b/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift index 608c39fd170..25785879708 100644 --- a/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift +++ b/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift @@ -3,6 +3,8 @@ import StoreKit public enum InAppPurchaseAction: Action { case loadProducts(completion: (Result<[StoreKit.Product], Error>) -> Void) - case purchaseProduct(siteID: Int64, product: StoreKit.Product, completion: (Result) -> Void) - case handleCompletedTransaction(_ result: VerificationResult, completion: (Result<(), Error>) -> Void) + case purchaseProduct(siteID: Int64, productID: String, completion: (Result) -> Void) + case userDidPurchaseProduct(productID: String, completion: (Result) -> Void) + case inAppPurchasesAreSupported(completion: (Bool) -> Void) + case retryWPComSyncForPurchasedProduct(productID: String, completion: (Result<(), Error>) -> Void) } diff --git a/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift b/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift index a853a052942..9fc72eaf8f5 100644 --- a/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift +++ b/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift @@ -4,6 +4,8 @@ import StoreKit import Networking public class InAppPurchaseStore: Store { + // ISO 3166-1 Alpha-3 country code representation. + private let supportedCountriesCodes = ["USA"] private var listenTask: Task? private let remote: InAppPurchasesRemote private var useBackend = true @@ -30,13 +32,24 @@ public class InAppPurchaseStore: Store { switch action { case .loadProducts(let completion): loadProducts(completion: completion) - case .purchaseProduct(let siteID, let product, let completion): - purchaseProduct(siteID: siteID, product: product, completion: completion) - case .handleCompletedTransaction(let verificationResult, let completion): + case .purchaseProduct(let siteID, let productID, let completion): + purchaseProduct(siteID: siteID, productID: productID, completion: completion) + case .retryWPComSyncForPurchasedProduct(let productID, let completion): Task { do { - try await handleCompletedTransaction(verificationResult) - completion(.success(())) + completion(.success(try await retryWPComSyncForPurchasedProduct(with: productID))) + } catch { + completion(.failure(error)) + } + } + case .inAppPurchasesAreSupported(completion: let completion): + Task { + completion(await inAppPurchasesAreSupported()) + } + case .userDidPurchaseProduct(productID: let productID, completion: let completion): + Task { + do { + completion(.success(try await userDidPurchaseProduct(with: productID))) } catch { completion(.failure(error)) } @@ -61,8 +74,12 @@ private extension InAppPurchaseStore { } } - func purchaseProduct(siteID: Int64, product: StoreKit.Product, completion: @escaping (Result) -> Void) { + func purchaseProduct(siteID: Int64, productID: String, completion: @escaping (Result) -> Void) { Task { + guard let product = try await StoreKit.Product.products(for: [productID]).first else { + return completion(.failure(Errors.transactionProductUnknown)) + } + logInfo("Purchasing product \(product.id) for site \(siteID)") var purchaseOptions: Set = [] if let appAccountToken = AppAccountToken.tokenWithSiteId(siteID) { @@ -115,6 +132,20 @@ private extension InAppPurchaseStore { await transaction.finish() } + func retryWPComSyncForPurchasedProduct(with id: String) async throws { + guard let verificationResult = await Transaction.latest(for: id) else { + // The user hasn't purchased this product. + throw Errors.transactionProductUnknown + } + + switch verificationResult { + case .verified(let transaction): + return try await submitTransaction(transaction) + case .unverified(_, let verificationError): + throw verificationError + } + } + func submitTransaction(_ transaction: StoreKit.Transaction) async throws { guard useBackend else { return @@ -149,6 +180,20 @@ private extension InAppPurchaseStore { } + func userDidPurchaseProduct(with id: String) async throws -> Bool { + guard let verificationResult = await Transaction.latest(for: id) else { + // The user hasn't purchased this product. + return false + } + + switch verificationResult { + case .verified(_): + return true + case .unverified(_, let verificationError): + throw verificationError + } + } + func getAppReceipt(refreshIfMissing: Bool = true) async throws -> Data { guard let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, let receiptData = try? Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) else { @@ -170,6 +215,14 @@ private extension InAppPurchaseStore { return try await remote.loadProducts() } + func inAppPurchasesAreSupported() async -> Bool { + guard let countryCode = await Storefront.current?.countryCode else { + return false + } + + return supportedCountriesCodes.contains(countryCode) + } + func listenForTransactions() { assert(listenTask == nil, "InAppPurchaseStore.listenForTransactions() called while already listening for transactions") From 654ca8a4fcbac133480c4be0c69adedfb9f13546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Vargas=20Casaseca?= Date: Thu, 27 Oct 2022 11:48:38 +0200 Subject: [PATCH 06/14] Use new protocol in debug view. --- .../InAppPurchasesDebugView.swift | 22 ++++++++++--------- .../InAppPurchasesForWPComPlansManager.swift | 6 ++++- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift index 7efbd1a366b..d6476d4663a 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift @@ -2,10 +2,11 @@ import SwiftUI import StoreKit import Yosemite +@MainActor struct InAppPurchasesDebugView: View { let siteID: Int64 - private let stores = ServiceLocator.stores - @State var products: [StoreKit.Product] = [] + private let inAppPurchasesForWPComPlansManager = InAppPurchasesForWPComPlansManager() + @State var products: [WPComPlanProduct] = [] var body: some View { List { @@ -18,9 +19,11 @@ struct InAppPurchasesDebugView: View { if products.isEmpty { Text("No products") } else { - ForEach(products) { product in + ForEach(products, id: \.id) { product in Button(product.description) { - stores.dispatch(InAppPurchaseAction.purchaseProduct(siteID: siteID, productID: product.id, completion: { _ in })) + Task { + try? await inAppPurchasesForWPComPlansManager.purchaseProduct(with: product.id, for: siteID) + } } } } @@ -33,14 +36,13 @@ struct InAppPurchasesDebugView: View { } private func loadProducts() { - stores.dispatch(InAppPurchaseAction.loadProducts(completion: { result in - switch result { - case .success(let products): - self.products = products - case .failure(let error): + Task { + do { + self.products = try await inAppPurchasesForWPComPlansManager.fetchProducts() + } catch { print("Error loading products: \(error)") } - })) + } } } diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift index 838952120c4..8b4e7ab4981 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift @@ -25,9 +25,13 @@ protocol InAppPurchasesForWPComPlansProtocol { func inAppPurchasesAreSupported() async -> Bool } +@MainActor final class InAppPurchasesForWPComPlansManager: InAppPurchasesForWPComPlansProtocol { - private let stores = ServiceLocator.stores + private let stores: StoresManager + init(stores: StoresManager = ServiceLocator.stores) { + self.stores = stores + } func fetchProducts() async throws -> [WPComPlanProduct] { try await withCheckedThrowingContinuation { continuation in From 647a70699f1d6a05ce9be95f16b5ab68da97819d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Vargas=20Casaseca?= Date: Thu, 27 Oct 2022 11:59:05 +0200 Subject: [PATCH 07/14] Add documentation to protocol functions. --- .../InAppPurchasesForWPComPlansManager.swift | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift index 8b4e7ab4981..cf8d9a71dfa 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift @@ -11,17 +11,37 @@ protocol WPComPlanProduct { extension StoreKit.Product: WPComPlanProduct {} -enum WPComPlanProductTransactionStatus { - case notStarted // Neither purchased through Apple nor the WPCom plan was unlocked - case pending // In-App purchase was successful but the WPCom plan unlock request is pending - case finished // In-App purchase and WPCom plan unlock succesful -} - protocol InAppPurchasesForWPComPlansProtocol { + /// Retrieves asynchronously all WPCom plans In-App Purchases products. + /// func fetchProducts() async throws -> [WPComPlanProduct] + + /// Returns whether the user purchases the product identified with the passed id. + /// + /// - Parameters: + /// - id: the id of the product whose purchase is to be verified + /// func userDidPurchaseProduct(with id: String) async throws -> Bool + + /// Triggers the purchase of WPCom plan specified by the passed product id, linked to the passed site Id. + /// + /// - Parameters: + /// id: the id of the product to be purchased + /// remoteSiteId: the id of the site linked to the purchasing plan + /// func purchaseProduct(with id: String, for remoteSiteId: Int64) async throws + + /// Retries forwarding the product purchase to our backend, so the plan can be unlocked. + /// This can happen when the purchase was previously successful but unlocking the WPCom plan request + /// failed. + /// + /// - Parameters: + /// id: the id of the purchased product whose WPCom plan unlock failed + /// func retryWPComSyncForPurchasedProduct(with id: String) async throws + + /// Returns whether In-App Purchases are supported for the current user configuration + /// func inAppPurchasesAreSupported() async -> Bool } From c6ff91bff451c50c96117aa86291c1f4b8aef129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Vargas=20Casaseca?= Date: Thu, 27 Oct 2022 12:09:44 +0200 Subject: [PATCH 08/14] Add documentation for the WPComPlanProduct protocol. --- .../InAppPurchases/InAppPurchasesForWPComPlansManager.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift index cf8d9a71dfa..a0eddac2bd9 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift @@ -3,9 +3,13 @@ import StoreKit import Yosemite protocol WPComPlanProduct { + // The localized product name, to be used as title in UI var displayName: String { get } + // The localized product description var description: String { get } + // The unique product identifier. To be used in further actions e.g purchasing a product var id: String { get } + // The localized price, including currency var displayPrice: String { get } } From fb03bfa733b69e997ccffb8ef5d63c941bb7f3c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Vargas=20Casaseca?= Date: Thu, 27 Oct 2022 13:46:40 +0200 Subject: [PATCH 09/14] Refactor to use entitlements instead of verifying if the user purchased the product. --- .../InAppPurchasesDebugView.swift | 22 ++++++++++++++++- .../InAppPurchasesForWPComPlansManager.swift | 6 ++--- .../Actions/InAppPurchaseAction.swift | 2 +- .../Yosemite/Stores/InAppPurchaseStore.swift | 24 +++++++++---------- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift index d6476d4663a..8fa879f97a8 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift @@ -7,6 +7,7 @@ struct InAppPurchasesDebugView: View { let siteID: Int64 private let inAppPurchasesForWPComPlansManager = InAppPurchasesForWPComPlansManager() @State var products: [WPComPlanProduct] = [] + @State var entitledProductIDs: [String] = [] var body: some View { List { @@ -20,7 +21,7 @@ struct InAppPurchasesDebugView: View { Text("No products") } else { ForEach(products, id: \.id) { product in - Button(product.description) { + Button(entitledProductIDs.contains(product.id) ? "Entitled: \(product.description)" : product.description) { Task { try? await inAppPurchasesForWPComPlansManager.purchaseProduct(with: product.id, for: siteID) } @@ -28,6 +29,12 @@ struct InAppPurchasesDebugView: View { } } } + + Section { + Button("Retry WPCom Synchronization for entitled products") { + retryWPComSynchronizationForPurchasedProducts() + } + } } .navigationTitle("IAP Debug") .onAppear { @@ -39,11 +46,24 @@ struct InAppPurchasesDebugView: View { Task { do { self.products = try await inAppPurchasesForWPComPlansManager.fetchProducts() + for product in self.products { + if try await inAppPurchasesForWPComPlansManager.userIsEntitledToProduct(with: product.id) { + self.entitledProductIDs.append(product.id) + } + } } catch { print("Error loading products: \(error)") } } } + + private func retryWPComSynchronizationForPurchasedProducts() { + Task { + for id in entitledProductIDs { + try await inAppPurchasesForWPComPlansManager.retryWPComSyncForPurchasedProduct(with: id) + } + } + } } struct InAppPurchasesDebugView_Previews: PreviewProvider { diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift index a0eddac2bd9..5d360934172 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift @@ -25,7 +25,7 @@ protocol InAppPurchasesForWPComPlansProtocol { /// - Parameters: /// - id: the id of the product whose purchase is to be verified /// - func userDidPurchaseProduct(with id: String) async throws -> Bool + func userIsEntitledToProduct(with id: String) async throws -> Bool /// Triggers the purchase of WPCom plan specified by the passed product id, linked to the passed site Id. /// @@ -70,9 +70,9 @@ final class InAppPurchasesForWPComPlansManager: InAppPurchasesForWPComPlansProto } } - func userDidPurchaseProduct(with id: String) async throws -> Bool { + func userIsEntitledToProduct(with id: String) async throws -> Bool { try await withCheckedThrowingContinuation { continuation in - stores.dispatch(InAppPurchaseAction.userDidPurchaseProduct(productID: id, completion: { result in + stores.dispatch(InAppPurchaseAction.userIsEntitledToProduct(productID: id, completion: { result in switch result { case .success(let productIsPurchased): continuation.resume(returning: productIsPurchased) diff --git a/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift b/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift index 25785879708..c7310a59285 100644 --- a/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift +++ b/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift @@ -4,7 +4,7 @@ import StoreKit public enum InAppPurchaseAction: Action { case loadProducts(completion: (Result<[StoreKit.Product], Error>) -> Void) case purchaseProduct(siteID: Int64, productID: String, completion: (Result) -> Void) - case userDidPurchaseProduct(productID: String, completion: (Result) -> Void) + case userIsEntitledToProduct(productID: String, completion: (Result) -> Void) case inAppPurchasesAreSupported(completion: (Bool) -> Void) case retryWPComSyncForPurchasedProduct(productID: String, completion: (Result<(), Error>) -> Void) } diff --git a/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift b/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift index 9fc72eaf8f5..95643df1f6f 100644 --- a/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift +++ b/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift @@ -46,10 +46,10 @@ public class InAppPurchaseStore: Store { Task { completion(await inAppPurchasesAreSupported()) } - case .userDidPurchaseProduct(productID: let productID, completion: let completion): + case .userIsEntitledToProduct(productID: let productID, completion: let completion): Task { do { - completion(.success(try await userDidPurchaseProduct(with: productID))) + completion(.success(try await userIsEntitledToProduct(with: productID))) } catch { completion(.failure(error)) } @@ -133,17 +133,17 @@ private extension InAppPurchaseStore { } func retryWPComSyncForPurchasedProduct(with id: String) async throws { - guard let verificationResult = await Transaction.latest(for: id) else { - // The user hasn't purchased this product. + guard let verificationResult = await Transaction.currentEntitlement(for: id) else { + // The user doesn't have a valid entitlement for this product throw Errors.transactionProductUnknown } - switch verificationResult { - case .verified(let transaction): - return try await submitTransaction(transaction) - case .unverified(_, let verificationError): - throw verificationError + guard await Transaction.unfinished.contains(verificationResult) else { + // The transaction is finished. Return successfully + return } + + try await handleCompletedTransaction(verificationResult) } func submitTransaction(_ transaction: StoreKit.Transaction) async throws { @@ -180,14 +180,14 @@ private extension InAppPurchaseStore { } - func userDidPurchaseProduct(with id: String) async throws -> Bool { - guard let verificationResult = await Transaction.latest(for: id) else { + func userIsEntitledToProduct(with id: String) async throws -> Bool { + guard let verificationResult = await Transaction.currentEntitlement(for: id) else { // The user hasn't purchased this product. return false } switch verificationResult { - case .verified(_): + case .verified(let transaction): return true case .unverified(_, let verificationError): throw verificationError From a82e03c72bb672020b183bbeacd3340f475b5608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Vargas=20Casaseca?= Date: Thu, 27 Oct 2022 14:53:23 +0200 Subject: [PATCH 10/14] Refactor code. --- .../InAppPurchasesDebugView.swift | 19 ++++++++++++++----- .../InAppPurchasesForWPComPlansManager.swift | 4 ++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift index 8fa879f97a8..3f746d92e1b 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift @@ -46,17 +46,26 @@ struct InAppPurchasesDebugView: View { Task { do { self.products = try await inAppPurchasesForWPComPlansManager.fetchProducts() - for product in self.products { - if try await inAppPurchasesForWPComPlansManager.userIsEntitledToProduct(with: product.id) { - self.entitledProductIDs.append(product.id) - } - } + await loadUserEntitlements() } catch { print("Error loading products: \(error)") } } } + private func loadUserEntitlements() async { + do { + for product in self.products { + if try await inAppPurchasesForWPComPlansManager.userIsEntitledToProduct(with: product.id) { + self.entitledProductIDs.append(product.id) + } + } + } + catch { + print("Error loading user entitlements: \(error)") + } + } + private func retryWPComSynchronizationForPurchasedProducts() { Task { for id in entitledProductIDs { diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift index 5d360934172..e1611e88c63 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift @@ -20,10 +20,10 @@ protocol InAppPurchasesForWPComPlansProtocol { /// func fetchProducts() async throws -> [WPComPlanProduct] - /// Returns whether the user purchases the product identified with the passed id. + /// Returns whether the user is entitled the product identified with the passed id. /// /// - Parameters: - /// - id: the id of the product whose purchase is to be verified + /// - id: the id of the product whose entitlement is to be verified /// func userIsEntitledToProduct(with id: String) async throws -> Bool From d0c8d667af024b9da71ee11fb366f1d803160262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Vargas=20Casaseca?= Date: Fri, 28 Oct 2022 14:55:08 +0200 Subject: [PATCH 11/14] Use continuation.resume to avoid the need of having a switch --- .../InAppPurchasesForWPComPlansManager.swift | 30 ++++--------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift index e1611e88c63..bef52e34911 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift @@ -60,12 +60,7 @@ final class InAppPurchasesForWPComPlansManager: InAppPurchasesForWPComPlansProto func fetchProducts() async throws -> [WPComPlanProduct] { try await withCheckedThrowingContinuation { continuation in stores.dispatch(InAppPurchaseAction.loadProducts(completion: { result in - switch result { - case .success(let products): - continuation.resume(returning: products) - case .failure(let error): - continuation.resume(throwing: error) - } + continuation.resume(with: result) })) } } @@ -73,25 +68,15 @@ final class InAppPurchasesForWPComPlansManager: InAppPurchasesForWPComPlansProto func userIsEntitledToProduct(with id: String) async throws -> Bool { try await withCheckedThrowingContinuation { continuation in stores.dispatch(InAppPurchaseAction.userIsEntitledToProduct(productID: id, completion: { result in - switch result { - case .success(let productIsPurchased): - continuation.resume(returning: productIsPurchased) - case .failure(let error): - continuation.resume(throwing: error) - } + continuation.resume(with: result) })) } } func purchaseProduct(with id: String, for remoteSiteId: Int64) async throws { - try await withCheckedThrowingContinuation { continuation in + _ = try await withCheckedThrowingContinuation { continuation in stores.dispatch(InAppPurchaseAction.purchaseProduct(siteID: remoteSiteId, productID: id, completion: { result in - switch result { - case .success(_): - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } + continuation.resume(with: result) })) } } @@ -99,12 +84,7 @@ final class InAppPurchasesForWPComPlansManager: InAppPurchasesForWPComPlansProto func retryWPComSyncForPurchasedProduct(with id: String) async throws { try await withCheckedThrowingContinuation { continuation in stores.dispatch(InAppPurchaseAction.retryWPComSyncForPurchasedProduct(productID: id, completion: { result in - switch result { - case .success(let products): - continuation.resume(returning: products) - case .failure(let error): - continuation.resume(throwing: error) - } + continuation.resume(with: result) })) } } From 16fe31821592b72bfe9c23b6f077ed687b6aadfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Vargas=20Casaseca?= Date: Fri, 28 Oct 2022 15:00:18 +0200 Subject: [PATCH 12/14] Remove not needed constant --- Yosemite/Yosemite/Stores/InAppPurchaseStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift b/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift index 95643df1f6f..1bce4471574 100644 --- a/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift +++ b/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift @@ -187,7 +187,7 @@ private extension InAppPurchaseStore { } switch verificationResult { - case .verified(let transaction): + case .verified(_): return true case .unverified(_, let verificationError): throw verificationError From 479aabb8b1cc8664a6128b32c7473752b8f3ccce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Vargas=20Casaseca?= Date: Fri, 28 Oct 2022 15:09:21 +0200 Subject: [PATCH 13/14] Use .task instead of .onAppear --- .../InAppPurchasesDebugView.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift index 3f746d92e1b..0bdcfc2a1e0 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift @@ -13,7 +13,9 @@ struct InAppPurchasesDebugView: View { List { Section { Button("Reload products") { - loadProducts() + Task { + await loadProducts() + } } } Section("Products") { @@ -37,19 +39,17 @@ struct InAppPurchasesDebugView: View { } } .navigationTitle("IAP Debug") - .onAppear { - loadProducts() + .task { + await loadProducts() } } - private func loadProducts() { - Task { - do { - self.products = try await inAppPurchasesForWPComPlansManager.fetchProducts() - await loadUserEntitlements() - } catch { - print("Error loading products: \(error)") - } + private func loadProducts() async { + do { + self.products = try await inAppPurchasesForWPComPlansManager.fetchProducts() + await loadUserEntitlements() + } catch { + print("Error loading products: \(error)") } } From e7a3c3e9d60b3e3389b70bf4da0a3f93bbd50ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Vargas=20Casaseca?= Date: Fri, 28 Oct 2022 16:14:03 +0200 Subject: [PATCH 14/14] Assert that in app purchases are supported. Show it in UI --- .../InAppPurchasesDebugView.swift | 14 ++++++++ .../Yosemite/Stores/InAppPurchaseStore.swift | 35 +++++++++++++------ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift index 0bdcfc2a1e0..5cf43277f30 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift @@ -8,6 +8,7 @@ struct InAppPurchasesDebugView: View { private let inAppPurchasesForWPComPlansManager = InAppPurchasesForWPComPlansManager() @State var products: [WPComPlanProduct] = [] @State var entitledProductIDs: [String] = [] + @State var inAppPurchasesAreSupported = true var body: some View { List { @@ -35,6 +36,13 @@ struct InAppPurchasesDebugView: View { Section { Button("Retry WPCom Synchronization for entitled products") { retryWPComSynchronizationForPurchasedProducts() + }.disabled(!inAppPurchasesAreSupported || entitledProductIDs.isEmpty) + } + + if !inAppPurchasesAreSupported { + Section { + Text("In-App Purchases are not supported for this user") + .foregroundColor(.red) } } } @@ -46,6 +54,12 @@ struct InAppPurchasesDebugView: View { private func loadProducts() async { do { + inAppPurchasesAreSupported = await inAppPurchasesForWPComPlansManager.inAppPurchasesAreSupported() + + guard inAppPurchasesAreSupported else { + return + } + self.products = try await inAppPurchasesForWPComPlansManager.fetchProducts() await loadUserEntitlements() } catch { diff --git a/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift b/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift index 1bce4471574..873b0aeddc6 100644 --- a/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift +++ b/Yosemite/Yosemite/Stores/InAppPurchaseStore.swift @@ -62,6 +62,7 @@ private extension InAppPurchaseStore { func loadProducts(completion: @escaping (Result<[StoreKit.Product], Error>) -> Void) { Task { do { + try await assertInAppPurchasesAreSupported() let identifiers = try await getProductIdentifiers() logInfo("Requesting StoreKit products: \(identifiers)") let products = try await StoreKit.Product.products(for: identifiers) @@ -76,18 +77,21 @@ private extension InAppPurchaseStore { func purchaseProduct(siteID: Int64, productID: String, completion: @escaping (Result) -> Void) { Task { - guard let product = try await StoreKit.Product.products(for: [productID]).first else { - return completion(.failure(Errors.transactionProductUnknown)) - } + do { + try await assertInAppPurchasesAreSupported() + + guard let product = try await StoreKit.Product.products(for: [productID]).first else { + return completion(.failure(Errors.transactionProductUnknown)) + } + + logInfo("Purchasing product \(product.id) for site \(siteID)") + var purchaseOptions: Set = [] + if let appAccountToken = AppAccountToken.tokenWithSiteId(siteID) { + logInfo("Generated appAccountToken \(appAccountToken) for site \(siteID)") + purchaseOptions.insert(.appAccountToken(appAccountToken)) + } - logInfo("Purchasing product \(product.id) for site \(siteID)") - var purchaseOptions: Set = [] - if let appAccountToken = AppAccountToken.tokenWithSiteId(siteID) { - logInfo("Generated appAccountToken \(appAccountToken) for site \(siteID)") - purchaseOptions.insert(.appAccountToken(appAccountToken)) - } - do { logInfo("Purchasing product \(product.id) for site \(siteID) with options \(purchaseOptions)") let purchaseResult = try await product.purchase(options: purchaseOptions) if case .success(let result) = purchaseResult { @@ -98,7 +102,7 @@ private extension InAppPurchaseStore { } completion(.success(purchaseResult)) } catch { - logError("Error purchasing product \(product.id) for site \(siteID): \(error)") + logError("Error purchasing product \(productID) for site \(siteID): \(error)") completion(.failure(error)) } } @@ -133,6 +137,8 @@ private extension InAppPurchaseStore { } func retryWPComSyncForPurchasedProduct(with id: String) async throws { + try await assertInAppPurchasesAreSupported() + guard let verificationResult = await Transaction.currentEntitlement(for: id) else { // The user doesn't have a valid entitlement for this product throw Errors.transactionProductUnknown @@ -146,6 +152,12 @@ private extension InAppPurchaseStore { try await handleCompletedTransaction(verificationResult) } + func assertInAppPurchasesAreSupported() async throws { + guard await inAppPurchasesAreSupported() else { + throw Errors.inAppPurchasesNotSupported + } + } + func submitTransaction(_ transaction: StoreKit.Transaction) async throws { guard useBackend else { return @@ -259,6 +271,7 @@ public extension InAppPurchaseStore { case transactionProductUnknown case storefrontUnknown case missingAppReceipt + case inAppPurchasesNotSupported } enum Constants {