Skip to content

Commit

Permalink
Get candidate transactions
Browse files Browse the repository at this point in the history
Summary:
This diff enables us to get the candidate transactions for IAP (i.e. the transactions we should consider). We define a candidate transaction as a transaction that meets the following criteria:
1. The transaction has not been refunded or revoked
2. The purchase date of the transaction is newer than the last time we checked the transaction history
3. The transaction is finished (i.e. the products have been delivered to the user)
4. We have not already considered this transaction (i.e. it's not in the cache)

Reviewed By: jjiang10

Differential Revision: D61942259

fbshipit-source-id: e4388b93530cd84920efd4fb181649fc279c5e19
  • Loading branch information
ryantobinmeta authored and facebook-github-bot committed Sep 11, 2024
1 parent 7d0a0b4 commit d86f396
Show file tree
Hide file tree
Showing 8 changed files with 256 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import StoreKit
final class IAPTransactionCache: NSObject {
static let restoredPurchasesKey = "com.facebook.sdk:RestoredPurchasesKey"
static let loggedTransactionsKey = "com.facebook.sdk:LoggedTransactionsKey"
static let newCandidatesDateKey = "com.facebook.sdk:NewCandidatesDateKey"

var configuredDependencies: ObjectDependencies?
var defaultDependencies: ObjectDependencies? = .init(
Expand Down Expand Up @@ -74,28 +75,51 @@ extension IAPTransactionCache {
}
}

func addTransaction(transactionID: Int, eventName: AppEvents.Name) {
var newCandidatesDate: Date? {
// swiftlint:disable:next implicit_getter
get {
guard let dependencies = try? getDependencies() else {
return nil
}
guard let date =
dependencies.dataStore.fb_object(forKey: IAPTransactionCache.newCandidatesDateKey) as? Date else {
return nil
}
return date
}
set {
guard let dependencies = try? getDependencies() else {
return
}
guard let newValue else {
return
}
dependencies.dataStore.fb_setObject(newValue, forKey: IAPTransactionCache.newCandidatesDateKey)
}
}

func addTransaction(transactionID: UInt64, eventName: AppEvents.Name) {
synchronized(self) {
let newTransaction = IAPCachedTransaction(transactionID: transactionID, eventName: eventName.rawValue)
loggedTransactions.insert(newTransaction)
persist()
}
}

func removeTransaction(transactionID: Int, eventName: AppEvents.Name) {
func removeTransaction(transactionID: UInt64, eventName: AppEvents.Name) {
synchronized(self) {
let oldTransaction = IAPCachedTransaction(transactionID: transactionID, eventName: eventName.rawValue)
loggedTransactions.remove(oldTransaction)
persist()
}
}

func contains(transactionID: Int, eventName: AppEvents.Name) -> Bool {
func contains(transactionID: UInt64, eventName: AppEvents.Name) -> Bool {
let transactionCandidate = IAPCachedTransaction(transactionID: transactionID, eventName: eventName.rawValue)
return loggedTransactions.contains(transactionCandidate)
}

func contains(transactionID: Int) -> Bool {
func contains(transactionID: UInt64) -> Bool {
return loggedTransactions.contains { $0.transactionID == transactionID } // swiftlint:disable:this implicit_return
}
}
Expand All @@ -104,7 +128,7 @@ extension IAPTransactionCache {

extension IAPTransactionCache {
struct IAPCachedTransaction: Hashable, Equatable, Codable {
var transactionID: Int
var transactionID: UInt64
var eventName: String
}
}
Expand All @@ -124,6 +148,7 @@ extension IAPTransactionCache {
func reset() {
UserDefaults.standard.removeObject(forKey: IAPTransactionCache.restoredPurchasesKey)
UserDefaults.standard.removeObject(forKey: IAPTransactionCache.loggedTransactionsKey)
UserDefaults.standard.removeObject(forKey: IAPTransactionCache.newCandidatesDateKey)
configuredDependencies = nil
loggedTransactions = []
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
* All rights reserved.
*
* This source code is licensed under the license found in the
* LICENSE file in the root directory of this source tree.
*/

import Foundation
import StoreKit

@available(iOS 15.0, *)
extension Transaction {
static func getNewCandidateTransactions() async -> [VerificationResult<Transaction>] {
let unfinishedTransactionIDs = await Transaction.unfinished.getValues().map { result in
result.iapTransaction.transaction.id
}
let transactionsToConsider = await Transaction.all.getValues()

let candidateTransactions = transactionsToConsider.filter { result in
let transaction = result.iapTransaction.transaction
let id = transaction.id
var dateCheck = true
if let candidateDate = IAPTransactionCache.shared.newCandidatesDate {
dateCheck = transaction.purchaseDate >= candidateDate
}
return transaction.revocationDate == nil &&
dateCheck &&
!unfinishedTransactionIDs.contains(id) &&
!IAPTransactionCache.shared.contains(transactionID: id)
}
return candidateTransactions
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import XCTest
final class AsyncSequenceTests: StoreKitTestCase {

func testGetValues() async throws {
guard let products = try? await Product.products(for: [Self.ProductIdentifiers.product1.rawValue]) else {
guard let products =
try? await Product.products(for: [Self.ProductIdentifiers.nonConsumableProduct1.rawValue]) else {
return
}
_ = try await products.first?.purchase()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
{
"identifier" : "C0F1F7F1",
"nonRenewingSubscriptions" : [

{
"displayPrice" : "0.99",
"familyShareable" : false,
"internalID" : "15F87AA6",
"localizations" : [
{
"description" : "",
"displayName" : "",
"locale" : "en_US"
}
],
"productID" : "com.fbsdk.nonrenewing.s1",
"referenceName" : "Non-renewing Subscription 1",
"type" : "NonRenewingSubscription"
}
],
"products" : [
{
Expand All @@ -15,8 +29,23 @@
"locale" : "en_US"
}
],
"productID" : "com.fbsdk.p1",
"referenceName" : "Product",
"productID" : "com.fbsdk.nonconsumable.p1",
"referenceName" : "Non Consumable Product 1",
"type" : "NonConsumable"
},
{
"displayPrice" : "0.99",
"familyShareable" : false,
"internalID" : "AEEFA3B1",
"localizations" : [
{
"description" : "",
"displayName" : "",
"locale" : "en_US"
}
],
"productID" : "com.fbsdk.nonconsumable.p2",
"referenceName" : "Non Consumable Product 2",
"type" : "NonConsumable"
}
],
Expand Down Expand Up @@ -73,7 +102,40 @@
]
},
"subscriptionGroups" : [
{
"id" : "D14ED020",
"localizations" : [

],
"name" : "Group 1",
"subscriptions" : [
{
"adHocOffers" : [

],
"codeOffers" : [

],
"displayPrice" : "0.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "666E0D10",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "",
"displayName" : "",
"locale" : "en_US"
}
],
"productID" : "com.fbsdk.autorenewing.s1",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "Autorenewing Subscription 1",
"subscriptionGroupID" : "D14ED020",
"type" : "RecurringSubscription"
}
]
}
],
"version" : {
"major" : 3,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ final class IAPTransactionTests: StoreKitTestCase {
@available(iOS 17.0, *)
func testValidIAPTransaction() async throws {
guard let transaction =
try? await testSession.buyProduct(identifier: Self.ProductIdentifiers.product1.rawValue) else {
try? await testSession.buyProduct(identifier: Self.ProductIdentifiers.nonConsumableProduct1.rawValue) else {
return
}
let iapTransaction = IAPTransaction(transaction: transaction, isVerified: true)
Expand All @@ -29,7 +29,7 @@ final class IAPTransactionTests: StoreKitTestCase {
@available(iOS 17.0, *)
func testInvalidIAPTransaction() async throws {
guard let transaction =
try? await testSession.buyProduct(identifier: Self.ProductIdentifiers.product1.rawValue) else {
try? await testSession.buyProduct(identifier: Self.ProductIdentifiers.nonConsumableProduct1.rawValue) else {
return
}
let iapTransaction = IAPTransaction(transaction: transaction, isVerified: false)
Expand All @@ -39,7 +39,8 @@ final class IAPTransactionTests: StoreKitTestCase {

@available(iOS 15.0, *)
func testVerificationResult() async throws {
guard let products = try? await Product.products(for: [Self.ProductIdentifiers.product1.rawValue]) else {
guard let products =
try? await Product.products(for: [Self.ProductIdentifiers.nonConsumableProduct1.rawValue]) else {
return
}
guard let purchaseResult = try? await products.first?.purchase() else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,33 @@ class StoreKitTestCase: XCTestCase {
static let configFileName = "FBSDKStoreKitConfigurationUnitTests"

enum ProductIdentifiers: String, CaseIterable {
case product1 = "com.fbsdk.p1"
case nonConsumableProduct1 = "com.fbsdk.nonconsumable.p1"
case nonConsumableProduct2 = "com.fbsdk.nonconsumable.p2"
case autoRenewingSubscription1 = "com.fbsdk.autorenewing.s1"
case nonRenewingSubscription1 = "com.fbsdk.nonrenewing.s1"
}

enum StoreKitTestCaseError: Error {
case purchaseFailed
}

static var allIdentifiers = ProductIdentifiers.allCases.map { id in
id.rawValue
}

// swiftlint:disable:next implicitly_unwrapped_optional
var testSession: SKTestSession!

@available(iOS 15.0, *)
func getIAPTransactionForPurchaseResult(result: Product.PurchaseResult) throws -> IAPTransaction {
switch result {
case let .success(verificationResult):
return verificationResult.iapTransaction
default:
throw StoreKitTestCaseError.purchaseFailed
}
}

override func setUp() async throws {
try await super.setUp()
testSession = try SKTestSession(configurationFileNamed: Self.configFileName)
Expand All @@ -35,6 +56,7 @@ class StoreKitTestCase: XCTestCase {
}

override func tearDown() {
IAPTransactionCache.shared.reset()
testSession = nil
super.tearDown()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
* All rights reserved.
*
* This source code is licensed under the license found in the
* LICENSE file in the root directory of this source tree.
*/

@testable import FBSDKCoreKit
@testable import IAPTestsHostApp

import StoreKitTest
import XCTest

@available(iOS 15.0, *)
final class TransactionTests: StoreKitTestCase {

func testGetAllTransactions() async {
guard let products = try? await Product.products(for: Self.allIdentifiers) else {
return
}
for product in products {
guard let purchaseResult = try? await product.purchase() else {
return
}
guard let transaction = try? getIAPTransactionForPurchaseResult(result: purchaseResult) else {
return
}
await transaction.transaction.finish()
}
let transactions = await Transaction.all.getValues()
XCTAssertEqual(transactions.count, products.count)
}

func testGetCurrentEntitlements() async {
guard let products = try? await Product.products(for: Self.allIdentifiers) else {
return
}
for product in products {
guard let purchaseResult = try? await product.purchase() else {
return
}
guard let transaction = try? getIAPTransactionForPurchaseResult(result: purchaseResult) else {
return
}
await transaction.transaction.finish()
}
let transactions = await Transaction.currentEntitlements.getValues()
XCTAssertEqual(transactions.count, products.count)
}

func testGetNewCandidateTransactions() async {
guard let products = try? await Product.products(for: Self.allIdentifiers),
products.count == Self.allIdentifiers.count else {
return
}
guard let result0 = try? await products[0].purchase(),
let transaction0 = try? getIAPTransactionForPurchaseResult(result: result0) else {
return
}
await transaction0.transaction.finish()
IAPTransactionCache.shared.newCandidatesDate = Date()
guard let result1 = try? await products[1].purchase(),
let transaction1 = try? getIAPTransactionForPurchaseResult(result: result1) else {
return
}
await transaction1.transaction.finish()
do {
try testSession.refundTransaction(identifier: UInt(transaction1.transaction.id))
} catch {
return
}
let result2 = try? await products[2].purchase()
guard result2 != nil else {
return
}
guard let result3 = try? await products[3].purchase(),
let transaction3 = try? getIAPTransactionForPurchaseResult(result: result3) else {
return
}
await transaction3.transaction.finish()
var candidateTransactions = await Transaction.getNewCandidateTransactions()
XCTAssertEqual(candidateTransactions.count, 1)
XCTAssertEqual(candidateTransactions.first?.iapTransaction.transaction.id, transaction3.transaction.id)
IAPTransactionCache.shared.addTransaction(transactionID: transaction3.transaction.id, eventName: .purchased)
candidateTransactions = await Transaction.getNewCandidateTransactions()
XCTAssertEqual(candidateTransactions.count, 0)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ final class IAPTransactionCacheTests: XCTestCase {
XCTAssertFalse(UserDefaults.standard.fb_bool(forKey: IAPTransactionCache.restoredPurchasesKey))
}

func testNewCandidatesDate() {
XCTAssertNil(IAPTransactionCache.shared.newCandidatesDate)
let now = Date()
IAPTransactionCache.shared.newCandidatesDate = now
let persisted = UserDefaults.standard.fb_object(forKey: IAPTransactionCache.newCandidatesDateKey) as? Date
XCTAssertEqual(IAPTransactionCache.shared.newCandidatesDate, now)
XCTAssertEqual(persisted, now)
}

func testAddTransaction() {
IAPTransactionCache.shared.addTransaction(transactionID: 1, eventName: AppEvents.Name.purchased)
IAPTransactionCache.shared.addTransaction(transactionID: 1, eventName: AppEvents.Name.purchased)
Expand Down

1 comment on commit d86f396

@Betterben69
Copy link

Choose a reason for hiding this comment

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

Please sign in to comment.