Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
19 changes: 19 additions & 0 deletions Networking/Networking/Model/WordPressApiError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ public enum WordPressApiError: Error, Decodable, Equatable {
///
case unknown(code: String, message: String)

/// An order already exists for this IAP receipt
///
case productPurchased

/// Decodable Initializer.
///
public init(from decoder: Decoder) throws {
Expand All @@ -16,6 +20,8 @@ public enum WordPressApiError: Error, Decodable, Equatable {
let message = try container.decode(String.self, forKey: .message)

switch code {
case Constants.productPurchased:
self = .productPurchased
default:
self = .unknown(code: code, message: message)
}
Expand All @@ -25,6 +31,7 @@ public enum WordPressApiError: Error, Decodable, Equatable {
/// Constants for Possible Error Identifiers
///
private enum Constants {
static let productPurchased = "product_purchased"
}

/// Coding Keys
Expand All @@ -50,6 +57,10 @@ extension WordPressApiError: CustomStringConvertible {

public var description: String {
switch self {
case .productPurchased:
return NSLocalizedString(
"An order aready exists for this receipt",
comment: "Error message when an order already exists in the backend for a given receipt")
case .unknown(let code, let message):
let messageFormat = NSLocalizedString(
"WordPress API Error: [%1$@] %2$@",
Expand All @@ -59,3 +70,11 @@ extension WordPressApiError: CustomStringConvertible {
}
}
}

// MARK: - LocalizedError Conformance
//
extension WordPressApiError: LocalizedError {
public var errorDescription: String? {
description
}
}
42 changes: 31 additions & 11 deletions Yosemite/Yosemite/Stores/InAppPurchaseStore.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Combine
import Foundation
import Storage
import StoreKit
Expand All @@ -9,6 +10,7 @@ public class InAppPurchaseStore: Store {
private var listenTask: Task<Void, Error>?
private let remote: InAppPurchasesRemote
private var useBackend = true
private var pauseTransactionListener = CurrentValueSubject<Bool, Never>(false)

public override init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network) {
remote = InAppPurchasesRemote(network: network)
Expand Down Expand Up @@ -93,6 +95,12 @@ private extension InAppPurchaseStore {


logInfo("Purchasing product \(product.id) for site \(siteID) with options \(purchaseOptions)")
logInfo("Pausing transaction listener")
pauseTransactionListener.send(true)
defer {
logInfo("Resuming transaction listener")
pauseTransactionListener.send(false)
}
let purchaseResult = try await product.purchase(options: purchaseOptions)
switch purchaseResult {
case .success(let result):
Expand Down Expand Up @@ -193,15 +201,22 @@ private extension InAppPurchaseStore {
let receiptData = try await getAppReceipt()

logInfo("Sending transaction to API for site \(siteID)")
let orderID = try await remote.createOrder(
for: siteID,
price: priceInCents,
productIdentifier: product.id,
appStoreCountryCode: countryCode,
receiptData: receiptData
)
logInfo("Successfully registered purchase with Order ID \(orderID)")

do {
let orderID = try await remote.createOrder(
for: siteID,
price: priceInCents,
productIdentifier: product.id,
appStoreCountryCode: countryCode,
receiptData: receiptData
)
logInfo("Successfully registered purchase with Order ID \(orderID)")
} catch WordPressApiError.productPurchased {
// Ignore errors for existing purchase
logInfo("Existing order found for transaction \(transaction.id) on site \(siteID), ignoring")
} catch {
// Rethrow any other error
throw error
}
}

func userIsEntitledToProduct(with id: String) async throws -> Bool {
Expand Down Expand Up @@ -251,11 +266,16 @@ private extension InAppPurchaseStore {
assert(listenTask == nil, "InAppPurchaseStore.listenForTransactions() called while already listening for transactions")

listenTask = Task.detached { [weak self] in
guard let self else {
return
}
for await result in Transaction.updates {
do {
try await self?.handleCompletedTransaction(result)
// Wait until the purchase finishes
_ = await self.pauseTransactionListener.values.contains(false)
Copy link
Contributor

Choose a reason for hiding this comment

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

With this approach it can happen that we handle the same transaction twice right, sending a duplicated order? Even if the backend ignores duplicated requests, it would be nice to prevent sending the same requests twice.

Copy link
Member Author

Choose a reason for hiding this comment

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

I think that is fine. Since we aren't using the App Store Server API v2 yet, we aren't really sending "a transaction" to the API. We send the app receipt, which might not have even changed between requests, and use Apple's verifyReceipt endpoint response to get the list of transactions.

I think dealing with potential duplicate requests is something we need to live with, and this PR doesn't change that. It only ensures that if a duplicate transaction happens, it happens in the right order.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for your answer @koke. It's a pity that we cannot prevent at the moment sending duplicate requests in a simple way for now, but we can live with that.

try await self.handleCompletedTransaction(result)
} catch {
self?.logError("Error handling transaction update: \(error)")
self.logError("Error handling transaction update: \(error)")
}
}
}
Expand Down