Files
purchases-ios/Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift
T
NachoSoto de47c5771b GetCustomerInfo posts receipts if there are pending transactions (#2533)
This fixes the last edge-case for offline entitlements. Solves the
following scenario:
```swift
func testPurchaseWhileServerIsDownPostsReceiptWhenForegroundingApp() async throws {
    let logger = TestLogHandler()

    // 1. Purchase while server is down
    self.serverDown()
    try await self.purchaseMonthlyProduct()

    logger.verifyMessageWasNotLogged("Finishing transaction")

    // 2. Server is back
    self.serverUp()

    // 3. Request current CustomerInfo
    let info1 = try await Purchases.shared.customerInfo()
    try await self.verifyEntitlementWentThrough(info1)

    // 4. Ensure transaction is finished
    logger.verifyMessageWasLogged("Finishing transaction", level: .info)

    // 5. Restart app
    Purchases.shared.invalidateCustomerInfoCache()
    await self.resetSingleton()

    // 6. To ensure (with a clean cache) that the receipt was posted
    let info2 = try await Purchases.shared.customerInfo()
    try await self.verifyEntitlementWentThrough(info2)
}
```

This race condition happened when fetching `CustomerInfo` after we had
computed an offline `CustomerInfo` with a purchase: if the server is
still not aware of the purchase, then we lose that until some arbitrary
amount of time when `StoreKit` might decide to send the pending
transactions in the queue.
For that reason, instead of waiting, we proactively check now whenever
`CustomerInfoManager` fetches new info.

The refactors #2540 and #2542 where required to break a retain cycle.
Also thanks to #2534 we know for sure that there aren't any cycles.

Note that this is an iOS 15.0+ only feature. It would be useful to
better ensure consistency in older versions, but it's not strictly
required, as those versions don't support offline entitlements either.
2023-05-30 12:38:18 -07:00

74 lines
2.1 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// StoreKit2TransactionFetcher.swift
//
// Created by Nacho Soto on 5/24/23.
import Foundation
import StoreKit
protocol StoreKit2TransactionFetcherType: Sendable {
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
var unfinishedVerifiedTransactions: [StoreTransaction] { get async }
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
var hasPendingConsumablePurchase: Bool { get async }
}
final class StoreKit2TransactionFetcher: StoreKit2TransactionFetcherType {
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
var unfinishedVerifiedTransactions: [StoreTransaction] {
get async {
return await StoreKit.Transaction
.unfinished
.compactMap { $0.verifiedTransaction }
.map { StoreTransaction(sk2Transaction: $0) }
.extractValues()
}
}
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
var hasPendingConsumablePurchase: Bool {
get async {
return await StoreKit.Transaction
.unfinished
.compactMap { $0.verifiedTransaction }
.map(\.productType)
.map { StoreProduct.ProductType($0) }
.contains { $0.productCategory == .nonSubscription }
}
}
}
// MARK: -
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
extension StoreKit.VerificationResult where SignedType == StoreKit.Transaction {
var underlyingTransaction: StoreKit.Transaction {
switch self {
case let .unverified(transaction, _): return transaction
case let .verified(transaction): return transaction
}
}
var verifiedTransaction: StoreKit.Transaction? {
switch self {
case let .verified(transaction): return transaction
case .unverified: return nil
}
}
}