Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}))
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}))
}
}
}
4 changes: 4 additions & 0 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -3258,6 +3259,7 @@
B958A7D428B5302500823EEF /* URLOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLOpener.swift; sourceTree = "<group>"; };
B958A7D728B5316A00823EEF /* MockURLOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLOpener.swift; sourceTree = "<group>"; };
B96B536A2816ECFC00F753E6 /* CardPresentPluginsDataProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentPluginsDataProviderTests.swift; sourceTree = "<group>"; };
B96D6C0629081AE5003D2DC0 /* InAppPurchasesForWPComPlansManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchasesForWPComPlansManager.swift; sourceTree = "<group>"; };
B9B0391528A6824200DC1C83 /* PermanentNoticePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermanentNoticePresenter.swift; sourceTree = "<group>"; };
B9B0391728A6838400DC1C83 /* PermanentNoticeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PermanentNoticeView.swift; sourceTree = "<group>"; };
B9B0391928A68ADE00DC1C83 /* ConstraintsUpdatingHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConstraintsUpdatingHostingController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -8554,6 +8556,7 @@
isa = PBXGroup;
children = (
E1325EFA28FD544E00EC9B2A /* InAppPurchasesDebugView.swift */,
B96D6C0629081AE5003D2DC0 /* InAppPurchasesForWPComPlansManager.swift */,
);
path = InAppPurchases;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
5 changes: 4 additions & 1 deletion Yosemite/Yosemite/Actions/InAppPurchaseAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<StoreKit.Product.PurchaseResult, Error>) -> Void)
case purchaseProduct(siteID: Int64, productID: String, completion: (Result<StoreKit.Product.PurchaseResult, Error>) -> Void)
case userIsEntitledToProduct(productID: String, completion: (Result<Bool, Error>) -> Void)
case inAppPurchasesAreSupported(completion: (Bool) -> Void)
Copy link
Member

Choose a reason for hiding this comment

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

Should we add this to the debug screen as well? My testing account region is set to Spain, so it'd be nice if the debug screen showed that it's not supported in my country. Ideally we'd fail loadProducts/purchaseProduct from an unsupported region as well

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good remark, added in e7a3c3e

case retryWPComSyncForPurchasedProduct(productID: String, completion: (Result<(), Error>) -> Void)
}
Loading