-
Notifications
You must be signed in to change notification settings - Fork 121
[In-App Purchases] Site creation flow interface #7959
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 12 commits
c755267
0582234
2f04498
e68d90f
62fbcb0
144924e
654ca8a
647a706
c6ff91b
55a0dfa
fb03bfa
a82e03c
d0c8d66
b16c516
16fe318
479aabb
e7a3c3e
d658a7f
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,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) | ||
| } | ||
|
||
| })) | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
| })) | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Member
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. 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
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. Good remark, added in e7a3c3e |
||
| case retryWPComSyncForPurchasedProduct(productID: String, completion: (Result<(), Error>) -> Void) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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)) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -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) { | ||
|
|
@@ -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 | ||
|
Member
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. If I understand it correctly,
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. Indeed, |
||
| return | ||
| } | ||
|
|
||
| try await handleCompletedTransaction(verificationResult) | ||
| } | ||
|
|
||
| func submitTransaction(_ transaction: StoreKit.Transaction) async throws { | ||
| guard useBackend else { | ||
| return | ||
|
|
@@ -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): | ||
|
||
| 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 { | ||
|
|
@@ -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") | ||
|
|
||
|
|
||
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.
Instead of calling this from
.onAppear, you can call it from.taskand makeloadProductsasync. I had it like that before putting the logic in YosemiteThere 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.
Good hint, changed in 479aabb