-
Notifications
You must be signed in to change notification settings - Fork 121
Store creation M2: checkout flow #8216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
87b45a2
cdc57a0
2fdc8c1
3686ffa
cf8cac5
5f68a79
3f95795
a834671
8c846b3
1d1cdf7
61e6d2c
9faa5f0
a83219e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import WebKit | ||
|
|
||
| /// View model used for the web view controller to check out. | ||
| final class WebCheckoutViewModel: AuthenticatedWebViewModel { | ||
| // `AuthenticatedWebViewModel` protocol conformance. | ||
| let title = Localization.title | ||
| let initialURL: URL? | ||
|
|
||
| /// Keeps track of whether the completion has been triggered so that it's only invoked once. | ||
| /// There are usually multiple redirects with the same success URL prefix. | ||
| private var isComplete: Bool = false | ||
|
|
||
| private let completion: () -> Void | ||
|
|
||
| /// - Parameters: | ||
| /// - siteSlug: The slug of the site URL that the web checkout is for. | ||
| /// - completion: Invoked when the webview reaches the success URL. | ||
| init(siteSlug: String, completion: @escaping () -> Void) { | ||
| self.initialURL = URL(string: String(format: Constants.checkoutURLFormat, siteSlug)) | ||
| self.completion = completion | ||
| } | ||
|
|
||
| func handleDismissal() { | ||
| // no-op: dismissal is handled in the close button in the navigation bar. | ||
| } | ||
|
|
||
| func handleRedirect(for url: URL?) { | ||
| guard let path = url?.absoluteString else { | ||
| return | ||
| } | ||
| handleCompletionIfPossible(path) | ||
| } | ||
|
|
||
| func decidePolicy(for navigationURL: URL) async -> WKNavigationActionPolicy { | ||
| handleCompletionIfPossible(navigationURL.absoluteString) | ||
| return .allow | ||
| } | ||
| } | ||
|
|
||
| private extension WebCheckoutViewModel { | ||
| func handleCompletionIfPossible(_ url: String) { | ||
| guard url.starts(with: Constants.completionURLPrefix) else { | ||
| return | ||
| } | ||
| // Running on the main thread is necessary if this method is triggered from `decidePolicy`. | ||
| DispatchQueue.main.async { [weak self] in | ||
| self?.handleSuccess() | ||
| } | ||
| } | ||
|
|
||
| func handleSuccess() { | ||
| guard isComplete == false else { | ||
| return | ||
| } | ||
| completion() | ||
| isComplete = true | ||
| } | ||
| } | ||
|
|
||
| private extension WebCheckoutViewModel { | ||
| enum Constants { | ||
| static let checkoutURLFormat = "https://wordpress.com/checkout/%@" | ||
| static let completionURLPrefix = "https://wordpress.com/checkout/thank-you/" | ||
| } | ||
|
|
||
| enum Localization { | ||
| static let title = NSLocalizedString("Checkout", comment: "Title of the WPCOM checkout web view.") | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| import Foundation | ||
| import protocol Yosemite.StoresManager | ||
| import enum Yosemite.PaymentAction | ||
| import struct Yosemite.WPComPlan | ||
|
|
||
| /// An `InAppPurchasesForWPComPlansProtocol` implementation for purchasing a WPCOM plan in a webview. | ||
| struct WebPurchasesForWPComPlans { | ||
| struct Plan: WPComPlanProduct, Equatable { | ||
| let displayName: String | ||
| let description: String | ||
| let id: String | ||
| let displayPrice: String | ||
| } | ||
|
|
||
| private let stores: StoresManager | ||
|
|
||
| init(stores: StoresManager = ServiceLocator.stores) { | ||
| self.stores = stores | ||
| } | ||
| } | ||
|
|
||
| extension WebPurchasesForWPComPlans: InAppPurchasesForWPComPlansProtocol { | ||
| func fetchProducts() async throws -> [WPComPlanProduct] { | ||
| let result = await loadPlan(thatMatchesID: Constants.eCommerceMonthlyPlanProductID) | ||
| switch result { | ||
| case .success(let plan): | ||
| return [plan].map { Plan(displayName: $0.name, description: "", id: "\($0.productID)", displayPrice: $0.formattedPrice) } | ||
| case .failure(let error): | ||
| throw error | ||
| } | ||
| } | ||
|
|
||
| func userIsEntitledToProduct(with id: String) async throws -> Bool { | ||
| // A newly created site does not have any WPCOM plans. In web, the user can purchase a WPCOM plan for every site. | ||
| false | ||
| } | ||
|
|
||
| func purchaseProduct(with id: String, for remoteSiteId: Int64) async throws -> InAppPurchaseResult { | ||
| let createCartResult = await createCart(productID: id, for: remoteSiteId) | ||
| switch createCartResult { | ||
| case .success: | ||
| // `StoreCreationCoordinator` will then launch the checkout webview after a cart is created. | ||
| return .pending | ||
| case .failure(let error): | ||
| throw error | ||
| } | ||
| } | ||
|
|
||
| func retryWPComSyncForPurchasedProduct(with id: String) async throws { | ||
| // no-op | ||
| } | ||
|
|
||
| func inAppPurchasesAreSupported() async -> Bool { | ||
| // Web purchases are available for everyone and every site. | ||
| true | ||
| } | ||
| } | ||
|
|
||
| private extension WebPurchasesForWPComPlans { | ||
| @MainActor | ||
| func loadPlan(thatMatchesID productID: Int64) async -> Result<WPComPlan, Error> { | ||
| await withCheckedContinuation { continuation in | ||
| stores.dispatch(PaymentAction.loadPlan(productID: productID) { result in | ||
| continuation.resume(returning: result) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| @MainActor | ||
| func createCart(productID: String, for siteID: Int64) async -> Result<Void, Error> { | ||
| await withCheckedContinuation { continuation in | ||
| stores.dispatch(PaymentAction.createCart(productID: productID, siteID: siteID) { result in | ||
| continuation.resume(returning: result) | ||
| }) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private extension WebPurchasesForWPComPlans { | ||
| enum Constants { | ||
| static let eCommerceMonthlyPlanProductID: Int64 = 1021 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,16 +26,12 @@ final class StoreCreationCoordinator: Coordinator { | |
| /// This property is kept as a lazy var instead of a dependency in the initializer because `InAppPurchasesForWPComPlansManager` is a @MainActor. | ||
| /// If it's passed in the initializer, all call sites have to become @MainActor which results in too many changes. | ||
| @MainActor | ||
| private lazy var iapManager: InAppPurchasesForWPComPlansProtocol = { | ||
| #if DEBUG | ||
| private lazy var purchasesManager: InAppPurchasesForWPComPlansProtocol = { | ||
| if featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) { | ||
| return InAppPurchasesForWPComPlansManager(stores: stores) | ||
| } else { | ||
| return MockInAppPurchases() | ||
| return WebPurchasesForWPComPlans(stores: stores) | ||
| } | ||
| #else | ||
| InAppPurchasesForWPComPlansManager(stores: stores) | ||
| #endif | ||
| }() | ||
|
|
||
| @Published private var siteIDFromStoreCreation: Int64? | ||
|
|
@@ -54,7 +50,7 @@ final class StoreCreationCoordinator: Coordinator { | |
| stores: StoresManager = ServiceLocator.stores, | ||
| analytics: Analytics = ServiceLocator.analytics, | ||
| featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, | ||
| iapManager: InAppPurchasesForWPComPlansProtocol? = nil) { | ||
| purchasesManager: InAppPurchasesForWPComPlansProtocol? = nil) { | ||
| self.source = source | ||
| self.navigationController = navigationController | ||
| // Passing the `standard` configuration to include sites without WooCommerce (`isWooCommerceActive = false`). | ||
|
|
@@ -68,8 +64,8 @@ final class StoreCreationCoordinator: Coordinator { | |
| self.featureFlagService = featureFlagService | ||
|
|
||
| Task { @MainActor in | ||
| if let iapManager { | ||
| self.iapManager = iapManager | ||
| if let purchasesManager { | ||
| self.purchasesManager = purchasesManager | ||
| } | ||
| } | ||
|
Comment on lines
66
to
70
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❓ Just curious - why does this have to be called async?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's because the IAP implementation Without the Task/MainActor context, an error is thrown: |
||
| } | ||
|
|
@@ -81,15 +77,17 @@ final class StoreCreationCoordinator: Coordinator { | |
| Task { @MainActor in | ||
| do { | ||
| presentIAPEligibilityInProgressView() | ||
| guard await iapManager.inAppPurchasesAreSupported() else { | ||
| guard await purchasesManager.inAppPurchasesAreSupported() else { | ||
| throw PlanPurchaseError.iapNotSupported | ||
| } | ||
| let products = try await iapManager.fetchProducts() | ||
| let products = try await purchasesManager.fetchProducts() | ||
| let expectedPlanIdentifier = featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) ? | ||
| Constants.iapPlanIdentifier: Constants.webPlanIdentifier | ||
| guard let product = products.first, | ||
| product.id == Constants.planIdentifier else { | ||
| product.id == expectedPlanIdentifier else { | ||
| throw PlanPurchaseError.noMatchingProduct | ||
| } | ||
| guard try await iapManager.userIsEntitledToProduct(with: product.id) == false else { | ||
| guard try await purchasesManager.userIsEntitledToProduct(with: product.id) == false else { | ||
| throw PlanPurchaseError.productNotEligible | ||
| } | ||
| navigationController.dismiss(animated: true) { [weak self] in | ||
|
|
@@ -299,18 +297,20 @@ private extension StoreCreationCoordinator { | |
| guard let self else { return } | ||
| self.showWPCOMPlan(from: navigationController, | ||
| planToPurchase: planToPurchase, | ||
| siteID: result.siteID) | ||
| siteID: result.siteID, | ||
| siteSlug: result.siteSlug) | ||
| } | ||
| navigationController.pushViewController(storeSummary, animated: true) | ||
| } | ||
|
|
||
| @MainActor | ||
| func showWPCOMPlan(from navigationController: UINavigationController, | ||
| planToPurchase: WPComPlanProduct, | ||
| siteID: Int64) { | ||
| siteID: Int64, | ||
| siteSlug: String) { | ||
| let storePlan = StoreCreationPlanHostingController(viewModel: .init(plan: planToPurchase)) { [weak self] in | ||
| guard let self else { return } | ||
| await self.purchasePlan(from: navigationController, siteID: siteID, planToPurchase: planToPurchase) | ||
| await self.purchasePlan(from: navigationController, siteID: siteID, siteSlug: siteSlug, planToPurchase: planToPurchase) | ||
| } onClose: { [weak self] in | ||
| guard let self else { return } | ||
| self.showDiscardChangesAlert() | ||
|
|
@@ -321,25 +321,40 @@ private extension StoreCreationCoordinator { | |
| @MainActor | ||
| func purchasePlan(from navigationController: UINavigationController, | ||
| siteID: Int64, | ||
| siteSlug: String, | ||
| planToPurchase: WPComPlanProduct) async { | ||
| do { | ||
| let result = try await iapManager.purchaseProduct(with: planToPurchase.id, for: siteID) | ||
| switch result { | ||
| case .success: | ||
| showInProgressViewWhileWaitingForJetpackSite(from: navigationController, siteID: siteID) | ||
| default: | ||
| if !featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) { | ||
| // Since a successful result cannot be easily mocked, any result is considered a success | ||
| // when using a mock for IAP. | ||
| let result = try await purchasesManager.purchaseProduct(with: planToPurchase.id, for: siteID) | ||
|
|
||
| if featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) { | ||
| switch result { | ||
| case .success: | ||
| showInProgressViewWhileWaitingForJetpackSite(from: navigationController, siteID: siteID) | ||
| default: | ||
| return | ||
| } | ||
| } else { | ||
| switch result { | ||
| case .pending: | ||
| showWebCheckout(from: navigationController, siteID: siteID, siteSlug: siteSlug) | ||
| default: | ||
| return | ||
| } | ||
| return | ||
| } | ||
| } catch { | ||
| showPlanPurchaseErrorAlert(from: navigationController, error: error) | ||
| } | ||
| } | ||
|
|
||
| @MainActor | ||
| func showWebCheckout(from navigationController: UINavigationController, siteID: Int64, siteSlug: String) { | ||
| let checkoutViewModel = WebCheckoutViewModel(siteSlug: siteSlug) { [weak self] in | ||
| self?.showInProgressViewWhileWaitingForJetpackSite(from: navigationController, siteID: siteID) | ||
| } | ||
| let checkoutController = AuthenticatedWebViewController(viewModel: checkoutViewModel) | ||
| navigationController.pushViewController(checkoutController, animated: true) | ||
| } | ||
|
|
||
| @MainActor | ||
| func showInProgressViewWhileWaitingForJetpackSite(from navigationController: UINavigationController, | ||
| siteID: Int64) { | ||
|
|
@@ -410,11 +425,6 @@ private extension StoreCreationCoordinator { | |
| return continuation.resume(throwing: StoreCreationError.newSiteUnavailable) | ||
| } | ||
|
|
||
| // When using a mock for IAP, returns the site without waiting for the site to become a Jetpack site. | ||
| if !self.featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) { | ||
| return continuation.resume(returning: site) | ||
| } | ||
|
|
||
| guard site.isJetpackConnected && site.isJetpackThePluginInstalled else { | ||
| return continuation.resume(throwing: StoreCreationError.newSiteIsNotJetpackSite) | ||
| } | ||
|
|
@@ -425,8 +435,10 @@ private extension StoreCreationCoordinator { | |
|
|
||
| @MainActor | ||
| func showPlanPurchaseErrorAlert(from navigationController: UINavigationController, error: Error) { | ||
| let errorMessage = featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) ? | ||
| Localization.PlanPurchaseErrorAlert.defaultErrorMessage: Localization.PlanPurchaseErrorAlert.webPurchaseErrorMessage | ||
| let alertController = UIAlertController(title: Localization.PlanPurchaseErrorAlert.title, | ||
| message: Localization.PlanPurchaseErrorAlert.defaultErrorMessage, | ||
| message: errorMessage, | ||
| preferredStyle: .alert) | ||
| alertController.view.tintColor = .text | ||
| _ = alertController.addCancelActionWithTitle(Localization.StoreCreationErrorAlert.cancelActionTitle) { _ in } | ||
|
|
@@ -493,6 +505,10 @@ private extension StoreCreationCoordinator { | |
| "Please try again and make sure you are signed in to an App Store account eligible for purchase.", | ||
| comment: "Message of the alert when the WPCOM plan cannot be purchased in the store creation flow." | ||
| ) | ||
| static let webPurchaseErrorMessage = NSLocalizedString( | ||
| "Please try again, or exit the screen and check back on your store if you previously left the checkout screen while payment is in progress.", | ||
| comment: "Message of the alert when the WPCOM plan cannot be purchased in a webview in the store creation flow." | ||
| ) | ||
| static let cancelActionTitle = NSLocalizedString( | ||
| "OK", | ||
| comment: "Button title to dismiss the alert when the WPCOM plan cannot be purchased in the store creation flow." | ||
|
|
@@ -510,7 +526,8 @@ private extension StoreCreationCoordinator { | |
|
|
||
| enum Constants { | ||
| // TODO: 8108 - update the identifier to production value when it's ready | ||
| static let planIdentifier = "debug.woocommerce.ecommerce.monthly" | ||
| static let iapPlanIdentifier = "debug.woocommerce.ecommerce.monthly" | ||
| static let webPlanIdentifier = "1021" | ||
| } | ||
|
|
||
| /// Error scenarios when purchasing a WPCOM plan. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method is to add any tracks needed when the view is dismissed, not about implementing the dismissal 😅 Sorry if the name is misleading.