Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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,10 +2,12 @@ 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] = []

var body: some View {
List {
Expand All @@ -18,13 +20,21 @@ struct InAppPurchasesDebugView: View {
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()
}
}
}
.navigationTitle("IAP Debug")
.onAppear {
Expand All @@ -33,14 +43,35 @@ struct InAppPurchasesDebugView: View {
}

private func loadProducts() {
stores.dispatch(InAppPurchaseAction.loadProducts(completion: { result in
switch result {
case .success(let products):
self.products = products
case .failure(let error):
Task {
Copy link
Member

Choose a reason for hiding this comment

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

Instead of calling this from .onAppear, you can call it from .task and make loadProducts async. I had it like that before putting the logic in Yosemite

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 hint, changed in 479aabb

do {
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 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,119 @@
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
switch result {
case .success(let products):
continuation.resume(returning: products)
case .failure(let error):
continuation.resume(throwing: error)
}
Copy link
Member

Choose a reason for hiding this comment

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

Noting that you can use continuation.resume(with: result) directly to simplify this and the other methods

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great, thanks for the tip. Changed in d0c8d66

}))
}
}

func userIsEntitledToProduct(with id: String) async throws -> Bool {
try await withCheckedThrowingContinuation { continuation in
stores.dispatch(InAppPurchaseAction.userIsEntitledToProduct(productID: id, completion: { result in
switch result {
case .success(let productIsPurchased):
continuation.resume(returning: productIsPurchased)
case .failure(let error):
continuation.resume(throwing: error)
}
}))
}
}

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
switch result {
case .success(_):
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}))
}
}

func retryWPComSyncForPurchasedProduct(with id: String) async throws {
try await withCheckedThrowingContinuation { continuation in
stores.dispatch(InAppPurchaseAction.retryWPComSyncForPurchasedProduct(productID: id, completion: { result in
switch result {
case .success(let products):
continuation.resume(returning: products)
case .failure(let error):
continuation.resume(throwing: error)
}
}))
}
}

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 @@ -1327,6 +1327,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 @@ -3256,6 +3257,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 @@ -8551,6 +8553,7 @@
isa = PBXGroup;
children = (
E1325EFA28FD544E00EC9B2A /* InAppPurchasesDebugView.swift */,
B96D6C0629081AE5003D2DC0 /* InAppPurchasesForWPComPlansManager.swift */,
);
path = InAppPurchases;
sourceTree = "<group>";
Expand Down Expand Up @@ -10533,6 +10536,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)
}
68 changes: 65 additions & 3 deletions Yosemite/Yosemite/Stores/InAppPurchaseStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Error>?
private let remote: InAppPurchasesRemote
private var useBackend = true
Expand All @@ -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))
}
}
}
}
}
Expand All @@ -52,8 +74,12 @@ private extension InAppPurchaseStore {
}
}

func purchaseProduct(siteID: Int64, product: StoreKit.Product, completion: @escaping (Result<StoreKit.Product.PurchaseResult, Error>) -> Void) {
func purchaseProduct(siteID: Int64, productID: String, completion: @escaping (Result<StoreKit.Product.PurchaseResult, Error>) -> Void) {
Task {
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<StoreKit.Product.PurchaseOption> = []
if let appAccountToken = AppAccountToken.tokenWithSiteId(siteID) {
Expand Down Expand Up @@ -106,6 +132,20 @@ private extension InAppPurchaseStore {
await transaction.finish()
}

func retryWPComSyncForPurchasedProduct(with id: String) async throws {
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
Copy link
Member

Choose a reason for hiding this comment

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

If I understand it correctly, Transaction.unfinished is an async sequence that will emit changes as they happen and so it would never end. Then, if the transaction has somehow been finished when this is called, unfinished would not have a value for this product and this code would wait indefinitely.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Indeed, Transaction.unfinished is an async sequence, but I don't think it never ends, when they find that there are no more values to transmit (no more unfinished transactions found), it ends (returning nil in public mutating func next() async -> VerificationResult<Transaction>?). I tested and worked fine for that case, but I will retest it to be sure.

return
}

try await handleCompletedTransaction(verificationResult)
}

func submitTransaction(_ transaction: StoreKit.Transaction) async throws {
guard useBackend else {
return
Expand Down Expand Up @@ -140,6 +180,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(let transaction):
Copy link
Member

Choose a reason for hiding this comment

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

let transaction is never used, so it can be replaced with _ to avoid a warning

Copy link
Contributor Author

Choose a reason for hiding this comment

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

True, changed in 16fe318

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 {
Expand All @@ -161,6 +215,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")

Expand Down