Skip to content
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

Created StoreKit2TransactionFetcher #2539

Merged
merged 2 commits into from
May 25, 2023
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
22 changes: 19 additions & 3 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@
4F2018732A15797D0061F6EF /* TestLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57057FF728B0048900995F21 /* TestLogHandler.swift */; };
4F69EB092A14406E00ED6D4B /* Matchers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F69EB082A14406E00ED6D4B /* Matchers.swift */; };
4F69EB0A2A14406E00ED6D4B /* Matchers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F69EB082A14406E00ED6D4B /* Matchers.swift */; };
4F7DBFBD2A1E986C00A2F511 /* StoreKit2TransactionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7DBFBC2A1E986C00A2F511 /* StoreKit2TransactionFetcher.swift */; };
4F8A58172A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8A58162A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift */; };
4F8A58182A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8A58162A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift */; };
4FA4C8DA2A168956007D2803 /* OfflineCustomerInfoCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA4C8D92A168956007D2803 /* OfflineCustomerInfoCreator.swift */; };
Expand All @@ -214,6 +215,7 @@
4FA696BD2A0020A000D228B1 /* MainThreadMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA696BC2A0020A000D228B1 /* MainThreadMonitor.swift */; };
4FCBA84F2A15391B004134BD /* SnapshotTesting+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 576C8A9127D27DDD0058FA6E /* SnapshotTesting+Extensions.swift */; };
4FCBA8512A153940004134BD /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 4FCBA8502A153940004134BD /* SnapshotTesting */; };
4FD291BE2A1E9A2E0098D1B9 /* StoreKit2TransactionFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD291BD2A1E9A2E0098D1B9 /* StoreKit2TransactionFetcherTests.swift */; };
57032ABF28C13CE4004FF47A /* StoreKit2SettingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57032ABE28C13CE4004FF47A /* StoreKit2SettingTests.swift */; };
57045B3829C514A8001A5417 /* ProductEntitlementMappingDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57045B3729C514A8001A5417 /* ProductEntitlementMappingDecodingTests.swift */; };
57045B3A29C51751001A5417 /* GetProductEntitlementMappingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57045B3929C51751001A5417 /* GetProductEntitlementMappingOperation.swift */; };
Expand Down Expand Up @@ -879,12 +881,14 @@
4F0BBAAB2A1D253D000E75AB /* OfflineCustomerInfoCreatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineCustomerInfoCreatorTests.swift; sourceTree = "<group>"; };
4F2017D42A15587F0061F6EF /* OfflineStoreKitIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineStoreKitIntegrationTests.swift; sourceTree = "<group>"; };
4F69EB082A14406E00ED6D4B /* Matchers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Matchers.swift; sourceTree = "<group>"; };
4F7DBFBC2A1E986C00A2F511 /* StoreKit2TransactionFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2TransactionFetcher.swift; sourceTree = "<group>"; };
4F8A58162A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOfflineCustomerInfoCreator.swift; sourceTree = "<group>"; };
4FA4C8D92A168956007D2803 /* OfflineCustomerInfoCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineCustomerInfoCreator.swift; sourceTree = "<group>"; };
4FA4C9722A16D3AC007D2803 /* MockBackendConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBackendConfiguration.swift; sourceTree = "<group>"; };
4FA696A329FC43C600D228B1 /* ReceiptParserTests-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "ReceiptParserTests-Info.plist"; sourceTree = "<group>"; };
4FA696BC2A0020A000D228B1 /* MainThreadMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainThreadMonitor.swift; sourceTree = "<group>"; };
4FCBA8522A1539D0004134BD /* __Snapshots__ */ = {isa = PBXFileReference; lastKnownFileType = folder; path = __Snapshots__; sourceTree = "<group>"; };
4FD291BD2A1E9A2E0098D1B9 /* StoreKit2TransactionFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2TransactionFetcherTests.swift; sourceTree = "<group>"; };
57032ABE28C13CE4004FF47A /* StoreKit2SettingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2SettingTests.swift; sourceTree = "<group>"; };
57045B3729C514A8001A5417 /* ProductEntitlementMappingDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductEntitlementMappingDecodingTests.swift; sourceTree = "<group>"; };
57045B3929C51751001A5417 /* GetProductEntitlementMappingOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProductEntitlementMappingOperation.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1304,6 +1308,7 @@
A55D082F2722368600D919E0 /* SK2BeginRefundRequestHelper.swift */,
F516BD28282434070083480B /* StoreKit2StorefrontListener.swift */,
2D294E5B26DECFD500B8FE4F /* StoreKit2TransactionListener.swift */,
4F7DBFBC2A1E986C00A2F511 /* StoreKit2TransactionFetcher.swift */,
);
path = StoreKit2;
sourceTree = "<group>";
Expand Down Expand Up @@ -1452,6 +1457,7 @@
2DAC5F7326F13C9800C5258F /* StoreKitUnitTests */ = {
isa = PBXGroup;
children = (
4FD291BC2A1E9A180098D1B9 /* StoreKit2 */,
571E7AD0279F2CE9003B3606 /* TestHelpers */,
57488C8129CB91D20000EE7E /* OfflineEntitlements */,
A563F587271E076800246E0C /* BeginRefundRequestHelperTests.swift */,
Expand All @@ -1464,16 +1470,13 @@
F55FFA622763F60700995146 /* TransactionsManagerTests.swift */,
5791A1C72767FC9400C972AA /* ManageSubscriptionsHelperTests.swift */,
5738F46D278CAC520096D623 /* StoreTransactionTests.swift */,
57C381B62791E593009E3940 /* StoreKit2TransactionListenerTests.swift */,
F516BD322828FDD90083480B /* StoreKit2StorefrontListenerTests.swift */,
2D43017726EBFD7100BAB891 /* UnitTestsConfiguration.storekit */,
F5FCD3FB27DA2034003BDC04 /* PriceFormatterProviderTests.swift */,
2D222BAA27FB7008003D5F37 /* LocalReceiptParserStoreKitTests.swift */,
578D79932936B0810042E434 /* LoggerTests.swift */,
57DE80882807540D008D6C6F /* StorefrontTests.swift */,
F5E5E2E82847953000216ECD /* ProductsFetcherSK2Tests.swift */,
F5355162286B70E0009CA47A /* OfferingsManagerStoreKitTests.swift */,
57E6194F28D291DC0093170C /* StoreKit2CachingProductsManagerTests.swift */,
57CD86E5291C344000768DE1 /* UserDefaultsDefaultTests.swift */,
);
path = StoreKitUnitTests;
Expand Down Expand Up @@ -2038,6 +2041,17 @@
path = BasicTypes;
sourceTree = "<group>";
};
4FD291BC2A1E9A180098D1B9 /* StoreKit2 */ = {
isa = PBXGroup;
children = (
57C381B62791E593009E3940 /* StoreKit2TransactionListenerTests.swift */,
F516BD322828FDD90083480B /* StoreKit2StorefrontListenerTests.swift */,
57E6194F28D291DC0093170C /* StoreKit2CachingProductsManagerTests.swift */,
4FD291BD2A1E9A2E0098D1B9 /* StoreKit2TransactionFetcherTests.swift */,
);
path = StoreKit2;
sourceTree = "<group>";
};
570896B627596E6E00296F1C /* APITesters */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -2927,6 +2941,7 @@
2D90F8BF26FD20D6009B9142 /* MockAttributionFetcher.swift in Sources */,
57DE80AF28075D77008D6C6F /* OSVersionEquivalent.swift in Sources */,
2D90F8C526FD216A009B9142 /* MockSKProductDiscount.swift in Sources */,
4FD291BE2A1E9A2E0098D1B9 /* StoreKit2TransactionFetcherTests.swift in Sources */,
B359DDF027EAA2B4003ABA54 /* MockDateProvider.swift in Sources */,
57C381E3279627B7009E3940 /* MockStoreProductDiscount.swift in Sources */,
576C8AB927D2996C0058FA6E /* CurrentTestCaseTracker.swift in Sources */,
Expand Down Expand Up @@ -3044,6 +3059,7 @@
578D79742936A36B0042E434 /* LoggerType.swift in Sources */,
B34605EB279A766C0031CA74 /* OperationQueue+Extensions.swift in Sources */,
57E6C2C72975AAE1001AFE98 /* FileReader.swift in Sources */,
4F7DBFBD2A1E986C00A2F511 /* StoreKit2TransactionFetcher.swift in Sources */,
5766AB4728401B8400FA6091 /* PackageType.swift in Sources */,
B3F3E8DA277158FE0047A5B9 /* DNSChecker.swift in Sources */,
A525BF4B26C320D100C354C4 /* SubscriberAttributesManager.swift in Sources */,
Expand Down
40 changes: 11 additions & 29 deletions Sources/OfflineEntitlements/PurchasedProductsFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,20 @@ protocol PurchasedProductsFetcherType {

/// A type that can fetch purchased products from StoreKit 2.
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
class PurchasedProductsFetcher: PurchasedProductsFetcherType {
final class PurchasedProductsFetcher: PurchasedProductsFetcherType {

private typealias Transactions = [StoreKit.VerificationResult<StoreKit.Transaction>]

private let transactionFetcher: StoreKit2TransactionFetcherType
private let sandboxDetector: SandboxEnvironmentDetector
private let cache: InMemoryCachedObject<Transactions>

init(
storeKit2TransactionFetcher: StoreKit2TransactionFetcherType = StoreKit2TransactionFetcher(),
sandboxDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector()
) {
self.sandboxDetector = sandboxDetector
self.transactionFetcher = storeKit2TransactionFetcher
self.cache = .init()
}

Expand Down Expand Up @@ -78,16 +81,16 @@ class PurchasedProductsFetcher: PurchasedProductsFetcherType {
threshold: .purchasedProducts,
message: Strings.offlineEntitlements.purchased_products_fetching_too_slow
) {
return try await Self.fetchTransactions()
return try await self.fetchTransactions()
}

self.cache.cache(instance: result)
return result
}
}

private static func fetchTransactions() async throws -> Transactions {
guard await !Self.hasPendingConsumablePurchase else {
private func fetchTransactions() async throws -> Transactions {
guard await !self.transactionFetcher.hasPendingConsumablePurchase else {
throw Error.foundConsumablePurchase
}

Expand All @@ -101,15 +104,11 @@ class PurchasedProductsFetcher: PurchasedProductsFetcherType {
return result
}

private static var hasPendingConsumablePurchase: Bool {
get async {
return await StoreKit.Transaction.unfinished.contains {
$0.productType.productCategory == .nonSubscription
}
}
}

}

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
extension PurchasedProductsFetcher: Sendable {}

// MARK: - Error

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
Expand All @@ -129,20 +128,3 @@ extension PurchasedProductsFetcher {
}

}
// MARK: - Extensions

@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
private extension StoreKit.VerificationResult where SignedType == StoreKit.Transaction {

var productType: StoreProduct.ProductType {
return .init(self.underlyingTransaction.productType)
}

private var underlyingTransaction: StoreKit.Transaction {
switch self {
case let .unverified(transaction, _): return transaction
case let .verified(transaction): return transaction
}
}

}
72 changes: 72 additions & 0 deletions Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//
// 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 }

}

@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
final class StoreKit2TransactionFetcher: StoreKit2TransactionFetcherType {

var unfinishedVerifiedTransactions: [StoreTransaction] {
get async {
return await StoreKit.Transaction
.unfinished
.compactMap { $0.verifiedTransaction }
.map { StoreTransaction(sk2Transaction: $0) }
.extractValues()
}
}

var hasPendingConsumablePurchase: Bool {
Copy link
Contributor

Choose a reason for hiding this comment

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

I see we are not using verifiedTransaction here. Should we filter to only consider verified transactions here as well? Not sure when a purchase will be unverified in SK2 though, so this might be ok.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm yeah I think might as well, since we won't try to post unverified ones anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

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
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//
// 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
//
// StoreKit2TransactionFetcherTests.swift
//
// Created by Nacho Soto on 5/24/23.

import Nimble
@testable import RevenueCat
import StoreKitTest
import XCTest

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
@MainActor
class StoreKit2TransactionFetcherTests: StoreKitConfigTestCase {

private var fetcher: StoreKit2TransactionFetcher!

@MainActor
override func setUp() async throws {
try await super.setUp()
try AvailabilityChecks.iOS15APIAvailableOrSkipTest()

self.fetcher = .init()
}

// MARK: - unfinishedVerifiedTransactions

func testNoUnfinishedVerifiedTransactions() async {
let transactions = await self.fetcher.unfinishedVerifiedTransactions
expect(transactions).to(beEmpty())
}

func testOneUnfinishedVerifiedTransaction() async throws {
let transaction = try await self.createTransaction(finished: false)
let result = await self.fetcher.unfinishedVerifiedTransactions

expect(result) == [transaction]
}

func testOneUnfinishedConsumablePurchase() async throws {
let transaction = try await self.createTransactionForConsumableProduct(finished: false)
let result = await self.fetcher.unfinishedVerifiedTransactions

expect(result) == [transaction]
}

func testMultipleUnfinishedVerifiedTransaction() async throws {
let transaction1 = try await self.createTransaction(productID: Self.product1, finished: false)
let transaction2 = try await self.createTransaction(productID: Self.product2, finished: false)

let result = await self.fetcher.unfinishedVerifiedTransactions
expect(result).to(haveCount(2))
expect(result).to(contain([transaction1, transaction2]))
}

func testFiltersOutFinishedTransaction() async throws {
_ = try await self.createTransaction(productID: Self.product1, finished: true)
let transaction = try await self.createTransaction(productID: Self.product2, finished: false)

let result = await self.fetcher.unfinishedVerifiedTransactions
expect(result) == [transaction]
}

// MARK: - hasPendingConsumablePurchase

func testHasNoPendingConsumablePurchase() async throws {
let result = await self.fetcher.hasPendingConsumablePurchase
expect(result) == false
}

func testHasNoPendingConsumablePurchaseWithNormalProduct() async throws {
_ = try await self.createTransaction(finished: false)

let result = await self.fetcher.hasPendingConsumablePurchase
expect(result) == false
}

func testHasNoPendingConsumablePurchaseWithFinishedConsumable() async throws {
_ = try await self.createTransactionForConsumableProduct(finished: true)

let result = await self.fetcher.hasPendingConsumablePurchase
expect(result) == false
}

func testHasPendingConsumablePurchase() async throws {
_ = try await self.createTransactionForConsumableProduct(finished: false)

let result = await self.fetcher.hasPendingConsumablePurchase
expect(result) == true
}

}

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
private extension StoreKit2TransactionFetcherTests {

func createTransaction(
productID: String? = nil,
finished: Bool
) async throws -> StoreTransaction {
return StoreTransaction(
sk2Transaction: try await self.simulateAnyPurchase(productID: productID,
finishTransaction: finished)
)
}

func createTransactionForConsumableProduct(finished: Bool) async throws -> StoreTransaction {
return try await self.createTransaction(productID: Self.consumable, finished: finished)
}

static let product1 = "com.revenuecat.monthly_4.99.1_week_intro"
static let product2 = "com.revenuecat.annual_39.99_no_trial"
static let consumable = "com.revenuecat.consumable"

}
Loading