Skip to content

Commit 8e84778

Browse files
authored
Merge pull request #8216 from woocommerce/feat/8108-web-checkout
Store creation M2: checkout flow
2 parents 199cb52 + a83219e commit 8e84778

File tree

13 files changed

+461
-65
lines changed

13 files changed

+461
-65
lines changed

Experiments/Experiments/FeatureFlag.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ public enum FeatureFlag: Int {
8383
case storeCreationM2
8484

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

Networking/Networking/Remote/PaymentRemote.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ public struct WPComPlan: Decodable, Equatable {
5555
public let name: String
5656
public let formattedPrice: String
5757

58+
public init(productID: Int64, name: String, formattedPrice: String) {
59+
self.productID = productID
60+
self.name = name
61+
self.formattedPrice = formattedPrice
62+
}
63+
5864
private enum CodingKeys: String, CodingKey {
5965
case productID = "product_id"
6066
case name = "product_name"

WooCommerce/Classes/Authentication/Epilogue/StorePickerCoordinator.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ private extension StorePickerConfiguration {
179179
case .switchingStores:
180180
return .modally
181181
case .storeCreationFromLogin:
182-
return .navigationStack(animated: false)
182+
return .navigationStack(animated: true)
183183
default:
184184
return .navigationStack(animated: true)
185185
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import WebKit
2+
3+
/// View model used for the web view controller to check out.
4+
final class WebCheckoutViewModel: AuthenticatedWebViewModel {
5+
// `AuthenticatedWebViewModel` protocol conformance.
6+
let title = Localization.title
7+
let initialURL: URL?
8+
9+
/// Keeps track of whether the completion has been triggered so that it's only invoked once.
10+
/// There are usually multiple redirects with the same success URL prefix.
11+
private var isComplete: Bool = false
12+
13+
private let completion: () -> Void
14+
15+
/// - Parameters:
16+
/// - siteSlug: The slug of the site URL that the web checkout is for.
17+
/// - completion: Invoked when the webview reaches the success URL.
18+
init(siteSlug: String, completion: @escaping () -> Void) {
19+
self.initialURL = URL(string: String(format: Constants.checkoutURLFormat, siteSlug))
20+
self.completion = completion
21+
}
22+
23+
func handleDismissal() {
24+
// no-op: dismissal is handled in the close button in the navigation bar.
25+
}
26+
27+
func handleRedirect(for url: URL?) {
28+
guard let path = url?.absoluteString else {
29+
return
30+
}
31+
handleCompletionIfPossible(path)
32+
}
33+
34+
func decidePolicy(for navigationURL: URL) async -> WKNavigationActionPolicy {
35+
handleCompletionIfPossible(navigationURL.absoluteString)
36+
return .allow
37+
}
38+
}
39+
40+
private extension WebCheckoutViewModel {
41+
func handleCompletionIfPossible(_ url: String) {
42+
guard url.starts(with: Constants.completionURLPrefix) else {
43+
return
44+
}
45+
// Running on the main thread is necessary if this method is triggered from `decidePolicy`.
46+
DispatchQueue.main.async { [weak self] in
47+
self?.handleSuccess()
48+
}
49+
}
50+
51+
func handleSuccess() {
52+
guard isComplete == false else {
53+
return
54+
}
55+
completion()
56+
isComplete = true
57+
}
58+
}
59+
60+
private extension WebCheckoutViewModel {
61+
enum Constants {
62+
static let checkoutURLFormat = "https://wordpress.com/checkout/%@"
63+
static let completionURLPrefix = "https://wordpress.com/checkout/thank-you/"
64+
}
65+
66+
enum Localization {
67+
static let title = NSLocalizedString("Checkout", comment: "Title of the WPCOM checkout web view.")
68+
}
69+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import Foundation
2+
import protocol Yosemite.StoresManager
3+
import enum Yosemite.PaymentAction
4+
import struct Yosemite.WPComPlan
5+
6+
/// An `InAppPurchasesForWPComPlansProtocol` implementation for purchasing a WPCOM plan in a webview.
7+
struct WebPurchasesForWPComPlans {
8+
struct Plan: WPComPlanProduct, Equatable {
9+
let displayName: String
10+
let description: String
11+
let id: String
12+
let displayPrice: String
13+
}
14+
15+
private let stores: StoresManager
16+
17+
init(stores: StoresManager = ServiceLocator.stores) {
18+
self.stores = stores
19+
}
20+
}
21+
22+
extension WebPurchasesForWPComPlans: InAppPurchasesForWPComPlansProtocol {
23+
func fetchProducts() async throws -> [WPComPlanProduct] {
24+
let result = await loadPlan(thatMatchesID: Constants.eCommerceMonthlyPlanProductID)
25+
switch result {
26+
case .success(let plan):
27+
return [plan].map { Plan(displayName: $0.name, description: "", id: "\($0.productID)", displayPrice: $0.formattedPrice) }
28+
case .failure(let error):
29+
throw error
30+
}
31+
}
32+
33+
func userIsEntitledToProduct(with id: String) async throws -> Bool {
34+
// A newly created site does not have any WPCOM plans. In web, the user can purchase a WPCOM plan for every site.
35+
false
36+
}
37+
38+
func purchaseProduct(with id: String, for remoteSiteId: Int64) async throws -> InAppPurchaseResult {
39+
let createCartResult = await createCart(productID: id, for: remoteSiteId)
40+
switch createCartResult {
41+
case .success:
42+
// `StoreCreationCoordinator` will then launch the checkout webview after a cart is created.
43+
return .pending
44+
case .failure(let error):
45+
throw error
46+
}
47+
}
48+
49+
func retryWPComSyncForPurchasedProduct(with id: String) async throws {
50+
// no-op
51+
}
52+
53+
func inAppPurchasesAreSupported() async -> Bool {
54+
// Web purchases are available for everyone and every site.
55+
true
56+
}
57+
}
58+
59+
private extension WebPurchasesForWPComPlans {
60+
@MainActor
61+
func loadPlan(thatMatchesID productID: Int64) async -> Result<WPComPlan, Error> {
62+
await withCheckedContinuation { continuation in
63+
stores.dispatch(PaymentAction.loadPlan(productID: productID) { result in
64+
continuation.resume(returning: result)
65+
})
66+
}
67+
}
68+
69+
@MainActor
70+
func createCart(productID: String, for siteID: Int64) async -> Result<Void, Error> {
71+
await withCheckedContinuation { continuation in
72+
stores.dispatch(PaymentAction.createCart(productID: productID, siteID: siteID) { result in
73+
continuation.resume(returning: result)
74+
})
75+
}
76+
}
77+
}
78+
79+
private extension WebPurchasesForWPComPlans {
80+
enum Constants {
81+
static let eCommerceMonthlyPlanProductID: Int64 = 1021
82+
}
83+
}

WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,12 @@ final class StoreCreationCoordinator: Coordinator {
2626
/// This property is kept as a lazy var instead of a dependency in the initializer because `InAppPurchasesForWPComPlansManager` is a @MainActor.
2727
/// If it's passed in the initializer, all call sites have to become @MainActor which results in too many changes.
2828
@MainActor
29-
private lazy var iapManager: InAppPurchasesForWPComPlansProtocol = {
30-
#if DEBUG
29+
private lazy var purchasesManager: InAppPurchasesForWPComPlansProtocol = {
3130
if featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) {
3231
return InAppPurchasesForWPComPlansManager(stores: stores)
3332
} else {
34-
return MockInAppPurchases()
33+
return WebPurchasesForWPComPlans(stores: stores)
3534
}
36-
#else
37-
InAppPurchasesForWPComPlansManager(stores: stores)
38-
#endif
3935
}()
4036

4137
@Published private var siteIDFromStoreCreation: Int64?
@@ -54,7 +50,7 @@ final class StoreCreationCoordinator: Coordinator {
5450
stores: StoresManager = ServiceLocator.stores,
5551
analytics: Analytics = ServiceLocator.analytics,
5652
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService,
57-
iapManager: InAppPurchasesForWPComPlansProtocol? = nil) {
53+
purchasesManager: InAppPurchasesForWPComPlansProtocol? = nil) {
5854
self.source = source
5955
self.navigationController = navigationController
6056
// Passing the `standard` configuration to include sites without WooCommerce (`isWooCommerceActive = false`).
@@ -68,8 +64,8 @@ final class StoreCreationCoordinator: Coordinator {
6864
self.featureFlagService = featureFlagService
6965

7066
Task { @MainActor in
71-
if let iapManager {
72-
self.iapManager = iapManager
67+
if let purchasesManager {
68+
self.purchasesManager = purchasesManager
7369
}
7470
}
7571
}
@@ -81,15 +77,17 @@ final class StoreCreationCoordinator: Coordinator {
8177
Task { @MainActor in
8278
do {
8379
presentIAPEligibilityInProgressView()
84-
guard await iapManager.inAppPurchasesAreSupported() else {
80+
guard await purchasesManager.inAppPurchasesAreSupported() else {
8581
throw PlanPurchaseError.iapNotSupported
8682
}
87-
let products = try await iapManager.fetchProducts()
83+
let products = try await purchasesManager.fetchProducts()
84+
let expectedPlanIdentifier = featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) ?
85+
Constants.iapPlanIdentifier: Constants.webPlanIdentifier
8886
guard let product = products.first,
89-
product.id == Constants.planIdentifier else {
87+
product.id == expectedPlanIdentifier else {
9088
throw PlanPurchaseError.noMatchingProduct
9189
}
92-
guard try await iapManager.userIsEntitledToProduct(with: product.id) == false else {
90+
guard try await purchasesManager.userIsEntitledToProduct(with: product.id) == false else {
9391
throw PlanPurchaseError.productNotEligible
9492
}
9593
navigationController.dismiss(animated: true) { [weak self] in
@@ -299,18 +297,20 @@ private extension StoreCreationCoordinator {
299297
guard let self else { return }
300298
self.showWPCOMPlan(from: navigationController,
301299
planToPurchase: planToPurchase,
302-
siteID: result.siteID)
300+
siteID: result.siteID,
301+
siteSlug: result.siteSlug)
303302
}
304303
navigationController.pushViewController(storeSummary, animated: true)
305304
}
306305

307306
@MainActor
308307
func showWPCOMPlan(from navigationController: UINavigationController,
309308
planToPurchase: WPComPlanProduct,
310-
siteID: Int64) {
309+
siteID: Int64,
310+
siteSlug: String) {
311311
let storePlan = StoreCreationPlanHostingController(viewModel: .init(plan: planToPurchase)) { [weak self] in
312312
guard let self else { return }
313-
await self.purchasePlan(from: navigationController, siteID: siteID, planToPurchase: planToPurchase)
313+
await self.purchasePlan(from: navigationController, siteID: siteID, siteSlug: siteSlug, planToPurchase: planToPurchase)
314314
} onClose: { [weak self] in
315315
guard let self else { return }
316316
self.showDiscardChangesAlert()
@@ -321,25 +321,40 @@ private extension StoreCreationCoordinator {
321321
@MainActor
322322
func purchasePlan(from navigationController: UINavigationController,
323323
siteID: Int64,
324+
siteSlug: String,
324325
planToPurchase: WPComPlanProduct) async {
325326
do {
326-
let result = try await iapManager.purchaseProduct(with: planToPurchase.id, for: siteID)
327-
switch result {
328-
case .success:
329-
showInProgressViewWhileWaitingForJetpackSite(from: navigationController, siteID: siteID)
330-
default:
331-
if !featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) {
332-
// Since a successful result cannot be easily mocked, any result is considered a success
333-
// when using a mock for IAP.
327+
let result = try await purchasesManager.purchaseProduct(with: planToPurchase.id, for: siteID)
328+
329+
if featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) {
330+
switch result {
331+
case .success:
334332
showInProgressViewWhileWaitingForJetpackSite(from: navigationController, siteID: siteID)
333+
default:
334+
return
335+
}
336+
} else {
337+
switch result {
338+
case .pending:
339+
showWebCheckout(from: navigationController, siteID: siteID, siteSlug: siteSlug)
340+
default:
341+
return
335342
}
336-
return
337343
}
338344
} catch {
339345
showPlanPurchaseErrorAlert(from: navigationController, error: error)
340346
}
341347
}
342348

349+
@MainActor
350+
func showWebCheckout(from navigationController: UINavigationController, siteID: Int64, siteSlug: String) {
351+
let checkoutViewModel = WebCheckoutViewModel(siteSlug: siteSlug) { [weak self] in
352+
self?.showInProgressViewWhileWaitingForJetpackSite(from: navigationController, siteID: siteID)
353+
}
354+
let checkoutController = AuthenticatedWebViewController(viewModel: checkoutViewModel)
355+
navigationController.pushViewController(checkoutController, animated: true)
356+
}
357+
343358
@MainActor
344359
func showInProgressViewWhileWaitingForJetpackSite(from navigationController: UINavigationController,
345360
siteID: Int64) {
@@ -410,11 +425,6 @@ private extension StoreCreationCoordinator {
410425
return continuation.resume(throwing: StoreCreationError.newSiteUnavailable)
411426
}
412427

413-
// When using a mock for IAP, returns the site without waiting for the site to become a Jetpack site.
414-
if !self.featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) {
415-
return continuation.resume(returning: site)
416-
}
417-
418428
guard site.isJetpackConnected && site.isJetpackThePluginInstalled else {
419429
return continuation.resume(throwing: StoreCreationError.newSiteIsNotJetpackSite)
420430
}
@@ -425,8 +435,10 @@ private extension StoreCreationCoordinator {
425435

426436
@MainActor
427437
func showPlanPurchaseErrorAlert(from navigationController: UINavigationController, error: Error) {
438+
let errorMessage = featureFlagService.isFeatureFlagEnabled(.storeCreationM2WithInAppPurchasesEnabled) ?
439+
Localization.PlanPurchaseErrorAlert.defaultErrorMessage: Localization.PlanPurchaseErrorAlert.webPurchaseErrorMessage
428440
let alertController = UIAlertController(title: Localization.PlanPurchaseErrorAlert.title,
429-
message: Localization.PlanPurchaseErrorAlert.defaultErrorMessage,
441+
message: errorMessage,
430442
preferredStyle: .alert)
431443
alertController.view.tintColor = .text
432444
_ = alertController.addCancelActionWithTitle(Localization.StoreCreationErrorAlert.cancelActionTitle) { _ in }
@@ -493,6 +505,10 @@ private extension StoreCreationCoordinator {
493505
"Please try again and make sure you are signed in to an App Store account eligible for purchase.",
494506
comment: "Message of the alert when the WPCOM plan cannot be purchased in the store creation flow."
495507
)
508+
static let webPurchaseErrorMessage = NSLocalizedString(
509+
"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.",
510+
comment: "Message of the alert when the WPCOM plan cannot be purchased in a webview in the store creation flow."
511+
)
496512
static let cancelActionTitle = NSLocalizedString(
497513
"OK",
498514
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 {
510526

511527
enum Constants {
512528
// TODO: 8108 - update the identifier to production value when it's ready
513-
static let planIdentifier = "debug.woocommerce.ecommerce.monthly"
529+
static let iapPlanIdentifier = "debug.woocommerce.ecommerce.monthly"
530+
static let webPlanIdentifier = "1021"
514531
}
515532

516533
/// Error scenarios when purchasing a WPCOM plan.

WooCommerce/Classes/Yosemite/AuthenticatedState.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class AuthenticatedState: StoresManagerState {
4848
OrderNoteStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
4949
OrderStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
5050
OrderStatusStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
51+
PaymentStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
5152
PaymentGatewayStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
5253
ProductAttributeStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
5354
ProductAttributeTermStore(dispatcher: dispatcher, storageManager: storageManager, network: network),

0 commit comments

Comments
 (0)