Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Experiments/Experiments/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public enum FeatureFlag: Int {
case storeCreationM2

/// Whether in-app purchases are enabled for store creation milestone 2 behind `storeCreationM2` feature flag.
/// If disabled, mock in-app purchases are provided by `MockInAppPurchases`.
/// If disabled, purchases are backed by `WebPurchasesForWPComPlans` for checkout in a webview.
///
case storeCreationM2WithInAppPurchasesEnabled

Expand Down
6 changes: 6 additions & 0 deletions Networking/Networking/Remote/PaymentRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ public struct WPComPlan: Decodable, Equatable {
public let name: String
public let formattedPrice: String

public init(productID: Int64, name: String, formattedPrice: String) {
self.productID = productID
self.name = name
self.formattedPrice = formattedPrice
}

private enum CodingKeys: String, CodingKey {
case productID = "product_id"
case name = "product_name"
Expand Down
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.
Copy link
Contributor

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.

}

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
Expand Up @@ -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?
Expand All @@ -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`).
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Just curious - why does this have to be called async?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's because the IAP implementation InAppPurchasesForWPComPlansManager is a MainActor, and thus this purchasesManager property has to also be MainActor. Because the property is a MainActor, setting/mutating it requires it to be from a MainActor context. We could make the StoreCreationCoordinator initializer to be MainActor, but that introduces a lot of changes upstream so I thought it's easier to set it this way.

Without the Task/MainActor context, an error is thrown:

Main actor-isolated property 'purchasesManager' can not be mutated from a non-isolated context

}
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 }
Expand Down Expand Up @@ -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."
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions WooCommerce/Classes/Yosemite/AuthenticatedState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class AuthenticatedState: StoresManagerState {
OrderNoteStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
OrderStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
OrderStatusStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
PaymentStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
PaymentGatewayStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
ProductAttributeStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
ProductAttributeTermStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
Expand Down
Loading