diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift index d2447c83204..5cf43277f30 100644 --- a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesDebugView.swift @@ -2,45 +2,90 @@ 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] = [] + @State var entitledProductIDs: [String] = [] + @State var inAppPurchasesAreSupported = true var body: some View { List { Section { Button("Reload products") { - loadProducts() + Task { + await loadProducts() + } } } Section("Products") { if products.isEmpty { Text("No products") } else { - ForEach(products) { product in - Button(product.description) { - stores.dispatch(InAppPurchaseAction.purchaseProduct(siteID: siteID, product: product, completion: { _ in })) + ForEach(products, id: \.id) { product in + Button(entitledProductIDs.contains(product.id) ? "Entitled: \(product.description)" : product.description) { + Task { + try? await inAppPurchasesForWPComPlansManager.purchaseProduct(with: product.id, for: siteID) + } } } } } + + 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) + } + } } .navigationTitle("IAP Debug") - .onAppear { - loadProducts() + .task { + await loadProducts() + } + } + + private func loadProducts() async { + do { + inAppPurchasesAreSupported = await inAppPurchasesForWPComPlansManager.inAppPurchasesAreSupported() + + guard inAppPurchasesAreSupported else { + return + } + + self.products = try await inAppPurchasesForWPComPlansManager.fetchProducts() + 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 loadProducts() { - stores.dispatch(InAppPurchaseAction.loadProducts(completion: { result in - switch result { - case .success(let products): - self.products = products - case .failure(let error): - print("Error loading products: \(error)") + private func retryWPComSynchronizationForPurchasedProducts() { + Task { + for id in entitledProductIDs { + try await inAppPurchasesForWPComPlansManager.retryWPComSyncForPurchasedProduct(with: id) } - })) + } } } diff --git a/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift new file mode 100644 index 00000000000..bef52e34911 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/InAppPurchases/InAppPurchasesForWPComPlansManager.swift @@ -0,0 +1,99 @@ +import Foundation +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 } +} + +extension StoreKit.Product: WPComPlanProduct {} + +protocol InAppPurchasesForWPComPlansProtocol { + /// Retrieves asynchronously all WPCom plans In-App Purchases products. + /// + func fetchProducts() async throws -> [WPComPlanProduct] + + /// Returns whether the user is entitled the product identified with the passed id. + /// + /// - Parameters: + /// - id: the id of the product whose entitlement is to be verified + /// + 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. + /// + /// - 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 +} + +@MainActor +final class InAppPurchasesForWPComPlansManager: InAppPurchasesForWPComPlansProtocol { + private let stores: StoresManager + + init(stores: StoresManager = ServiceLocator.stores) { + self.stores = stores + } + + func fetchProducts() async throws -> [WPComPlanProduct] { + try await withCheckedThrowingContinuation { continuation in + stores.dispatch(InAppPurchaseAction.loadProducts(completion: { result in + continuation.resume(with: result) + })) + } + } + + func userIsEntitledToProduct(with id: String) async throws -> Bool { + try await withCheckedThrowingContinuation { continuation in + stores.dispatch(InAppPurchaseAction.userIsEntitledToProduct(productID: id, completion: { result in + continuation.resume(with: result) + })) + } + } + + func purchaseProduct(with id: String, for remoteSiteId: Int64) async throws { + _ = try await withCheckedThrowingContinuation { continuation in + stores.dispatch(InAppPurchaseAction.purchaseProduct(siteID: remoteSiteId, productID: id, completion: { result in + continuation.resume(with: result) + })) + } + } + + func retryWPComSyncForPurchasedProduct(with id: String) async throws { + try await withCheckedThrowingContinuation { continuation in + stores.dispatch(InAppPurchaseAction.retryWPComSyncForPurchasedProduct(productID: id, completion: { result in + continuation.resume(with: result) + })) + } + } + + func inAppPurchasesAreSupported() async -> Bool { + await withCheckedContinuation { continuation in + stores.dispatch(InAppPurchaseAction.inAppPurchasesAreSupported(completion: { result in + continuation.resume(returning: result) + })) + } + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 17ba406748f..7fcb5152320 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1328,6 +1328,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 */; }; @@ -3258,6 +3259,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 = ""; }; @@ -8554,6 +8556,7 @@ isa = PBXGroup; children = ( E1325EFA28FD544E00EC9B2A /* InAppPurchasesDebugView.swift */, + B96D6C0629081AE5003D2DC0 /* InAppPurchasesForWPComPlansManager.swift */, ); path = InAppPurchases; sourceTree = ""; @@ -10537,6 +10540,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 */, diff --git a/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift b/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift index f0774d2dfbb..c7310a59285 100644 --- a/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift +++ b/Yosemite/Yosemite/Actions/InAppPurchaseAction.swift @@ -3,5 +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 purchaseProduct(siteID: Int64, 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 49da4fd1035..873b0aeddc6 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,8 +32,28 @@ 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 .purchaseProduct(let siteID, let productID, let completion): + purchaseProduct(siteID: siteID, productID: productID, completion: completion) + case .retryWPComSyncForPurchasedProduct(let productID, let completion): + Task { + do { + completion(.success(try await retryWPComSyncForPurchasedProduct(with: productID))) + } catch { + completion(.failure(error)) + } + } + case .inAppPurchasesAreSupported(completion: let completion): + Task { + completion(await inAppPurchasesAreSupported()) + } + case .userIsEntitledToProduct(productID: let productID, completion: let completion): + Task { + do { + completion(.success(try await userIsEntitledToProduct(with: productID))) + } catch { + completion(.failure(error)) + } + } } } } @@ -40,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) @@ -52,16 +75,23 @@ 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 { - 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 { + 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) with options \(purchaseOptions)") let purchaseResult = try await product.purchase(options: purchaseOptions) if case .success(let result) = purchaseResult { @@ -72,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)) } } @@ -106,6 +136,28 @@ private extension InAppPurchaseStore { await transaction.finish() } + 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 + } + + guard await Transaction.unfinished.contains(verificationResult) else { + // The transaction is finished. Return successfully + return + } + + 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 @@ -140,6 +192,20 @@ private extension InAppPurchaseStore { } + 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(_): + 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 { @@ -161,6 +227,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") @@ -197,6 +271,7 @@ public extension InAppPurchaseStore { case transactionProductUnknown case storefrontUnknown case missingAppReceipt + case inAppPurchasesNotSupported } enum Constants {