murat
murat

Reputation: 1

Swift Consumable Credit Store Access Issue

I finished my application but I am having a problem in app purchases, first of all, I did what I had to do in the app store connectte document, it said ready to submit, let me share it with you before sending it to review

enter image description here

let's come to the problem, when I launched the app from xcode and tested it, there was no problem, I could see the products in the store enter image description here

but I realized that there was a problem, when I released the version in testflight, when I tested it from there, I couldn't connect to the store, I searched a lot, I tried it, I said it probably doesn't show up because I tested it without connecting a sanbox account, and I sent it to review, I thought there would be no problem there, but the tester couldn't see the products.

import StoreKit
import FirebaseAuth
import FirebaseFirestore
import Combine

class PurchaseManager: NSObject, SKProductsRequestDelegate, SKPaymentTransactionObserver {
    static let shared = PurchaseManager()
    
    // MARK: - Product IDs
    private let productIDs = Set([
        "muratcancicekk.FaceAiEdit.credits.small",
        "muratcancicekk.FaceAiEdit.credits.medium",
        "muratcancicekk.FaceAiEdit.credits.big" // 15 credits
    ])
    
    // MARK: - Properties
    private var productsRequest: SKProductsRequest?
    private var loadProductsCompletion: CheckedContinuation<[SKProduct], Error>?
    private var purchaseCompletion: CheckedContinuation<Void, Error>?
    private var restoreCompletion: CheckedContinuation<Void, Error>?
    private(set) var availableProducts: [SKProduct]?
    private var isFetchingProducts = false

    private let creditBalanceSubject = CurrentValueSubject<Int, Never>(0)
    var creditBalancePublisher: AnyPublisher<Int, Never> {
        creditBalanceSubject.eraseToAnyPublisher()
    }
    
    // MARK: - Initialization
    private override init() {
        super.init()
        loadStoredCredits()
        SKPaymentQueue.default().add(self)
        printDebugInfo()
        
        // Load products immediately on init
        Task {
            do {
                availableProducts = try await fetchProducts()
               // debugPrintProducts()
            } catch {
                print("Failed to load products: \(error)")
            }
        }
    }
    
    deinit {
        SKPaymentQueue.default().remove(self)
    }
    
    // MARK: - Debug Methods
    private func printDebugInfo() {
        print("=== PurchaseManager Debug Info ===")
        print("Bundle ID: \(Bundle.main.bundleIdentifier ?? "Not found")")
        print("Environment: \(isTestFlight ? "TestFlight" : "Production")")
        print("Receipt URL: \(Bundle.main.appStoreReceiptURL?.absoluteString ?? "Not found")")
        print("Configured Product IDs: \(productIDs)")
        print("Can make payments: \(SKPaymentQueue.canMakePayments())")
        print("================================")
    }
    
//    func debugPrintProducts() {
//        print("\n=== Available Products ===")
//        if availableProducts.isEmpty {
//            print("No products available")
//        } else {
//            for product in availableProducts {
//                print("""
//                    Product ID: \(product.productIdentifier)
//                    Title: \(product.localizedTitle)
//                    Price: \(formattedPrice(for: product))
//                    Locale: \(product.priceLocale.identifier)
//                    Description: \(product.localizedDescription)
//                    ----------------------
//                    """)
//            }
//        }
//        print("========================\n")
//    }
//    
    // MARK: - Product Fetching
    func fetchProducts() async throws -> [SKProduct] {
            // Return cached products if available
            if let cachedProducts = availableProducts, !cachedProducts.isEmpty {
                return cachedProducts
            }
            
            // Prevent multiple simultaneous requests
            guard !isFetchingProducts else {
                throw PurchaseError.fetchInProgress
            }
            
            print("Fetching products...")
            
            guard SKPaymentQueue.canMakePayments() else {
                print("Device cannot make payments!")
                throw PurchaseError.paymentsNotAllowed
            }
            
            // Cancel any existing request
            productsRequest?.cancel()
            productsRequest = nil
            
            isFetchingProducts = true
            
            do {
                let products = try await withCheckedThrowingContinuation { continuation in
                    loadProductsCompletion = continuation
                    let request = SKProductsRequest(productIdentifiers: productIDs)
                    request.delegate = self
                    productsRequest = request
                    print("Starting product request...")
                    request.start()
                }
                
                // Cache the products
                self.availableProducts = products
                return products
            } catch {
                isFetchingProducts = false
                throw error
            }
        }
    
    // MARK: - SKProductsRequestDelegate
    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
          print("\n=== Products Response ===")
          print("Valid Products Found: \(response.products.count)")
          
          // Store completion locally and clear shared reference
          guard let completion = loadProductsCompletion else { return }
          loadProductsCompletion = nil
          
          // Log invalid product IDs
          if !response.invalidProductIdentifiers.isEmpty {
              print("Invalid Product IDs (\(response.invalidProductIdentifiers.count)):")
              response.invalidProductIdentifiers.forEach { print("- \($0)") }
          }
          
          // Handle the response
          if response.products.isEmpty {
              print("No valid products found!")
              completion.resume(throwing: PurchaseError.noProductsFound)
          } else {
              print("\nValid Products:")
              response.products.forEach { product in
                  print("""
                      - ID: \(product.productIdentifier)
                      - Title: \(product.localizedTitle)
                      - Price: \(formattedPrice(for: product))
                      """)
              }
              availableProducts = response.products
              completion.resume(returning: response.products)
          }
          
          // Clean up
          productsRequest = nil
          isFetchingProducts = false
      }
      
    
    func request(_ request: SKRequest, didFailWithError error: Error) {
         print("Product request failed: \(error.localizedDescription)")
         
         // Store completion locally and clear shared reference
         guard let completion = loadProductsCompletion else { return }
         loadProductsCompletion = nil
         
         // Clean up and complete with error
         completion.resume(throwing: error)
         productsRequest = nil
         isFetchingProducts = false
     }
    
    // MARK: - Purchase Handling
    func purchase(_ product: SKProduct) async throws {
        print("Initiating purchase for product: \(product.productIdentifier)")
        return try await withCheckedThrowingContinuation { continuation in
            purchaseCompletion = continuation
            let payment = SKPayment(product: product)
            SKPaymentQueue.default().add(payment)
        }
    }
    
    // MARK: - SKPaymentTransactionObserver
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        for transaction in transactions {
            switch transaction.transactionState {
            case .purchased:
                print("Transaction purchased: \(transaction.payment.productIdentifier)")
                handlePurchased(transaction)
            case .failed:
                print("Transaction failed: \(transaction.payment.productIdentifier)")
                handleFailed(transaction)
            case .restored:
                print("Transaction restored: \(transaction.payment.productIdentifier)")
                handleRestored(transaction)
            case .purchasing:
                print("Transaction purchasing: \(transaction.payment.productIdentifier)")
            case .deferred:
                print("Transaction deferred: \(transaction.payment.productIdentifier)")
            @unknown default:
                print("Unknown transaction state: \(transaction.payment.productIdentifier)")
            }
        }
    }
    
    func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
        print("Restore completed")
        restoreCompletion?.resume()
        restoreCompletion = nil
    }
    
    func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
        print("Restore failed: \(error.localizedDescription)")
        restoreCompletion?.resume(throwing: error)
        restoreCompletion = nil
    }
    
    private func handlePurchased(_ storeTransaction: SKPaymentTransaction) {
        let credits = creditsFor(productIdentifier: storeTransaction.payment.productIdentifier)
        
        Task {
            do {
                guard let userId = Auth.auth().currentUser?.uid else {
                    return
                }
                
                let transactionRef = Firestore.firestore()
                    .collection("creditTransactions")
                    .document()
                
                let newBalance = CreditManager.shared.currentBalance + credits
                
                let creditTransaction = CreditTransaction(
                    id: transactionRef.documentID,
                    userId: userId,
                    amount: credits,
                    type: .system,
                    featureId: nil,
                    timestamp: Date(),
                    balanceAfter: newBalance,
                    metadata: [
                        "source": "in_app_purchase",
                        "productId": storeTransaction.payment.productIdentifier,
                        "environment": isTestFlight ? "testflight" : "production",
                        "transactionId": storeTransaction.transactionIdentifier ?? "unknown"
                    ]
                )
                
                try await transactionRef.setData(from: creditTransaction)
                
                let userRef = Firestore.firestore()
                    .collection("users")
                    .document(userId)
                
                try await userRef.updateData([
                    "creditBalance": newBalance,
                    "lastUpdated": Date()
                ])
                
                await MainActor.run {
                    CreditManager.shared.currentBalance = newBalance
                    creditBalanceSubject.send(newBalance)
                    UserDefaults.standard.set(newBalance, forKey: "creditBalance")
                }
                
                print("Purchase successfully processed: \(storeTransaction.payment.productIdentifier)")
                
                SKPaymentQueue.default().finishTransaction(storeTransaction)
                purchaseCompletion?.resume()
                purchaseCompletion = nil
                
            } catch {
                print("Error processing purchase: \(error)")
                SKPaymentQueue.default().finishTransaction(storeTransaction)
                purchaseCompletion?.resume(throwing: error)
                purchaseCompletion = nil
            }
        }
    }
    
    private func handleFailed(_ transaction: SKPaymentTransaction) {
        print("Purchase failed: \(String(describing: transaction.error))")
        
        if let error = transaction.error as? SKError {
            print("SKError code: \(error.code.rawValue)")
            print("SKError description: \(error.localizedDescription)")
        }
        
        SKPaymentQueue.default().finishTransaction(transaction)
        
        if let error = transaction.error as? SKError {
            purchaseCompletion?.resume(throwing: error)
        } else {
            purchaseCompletion?.resume(throwing: PurchaseError.purchaseFailed)
        }
        purchaseCompletion = nil
    }
    
    private func handleRestored(_ transaction: SKPaymentTransaction) {
        print("Purchase restored: \(transaction.payment.productIdentifier)")
        let credits = creditsFor(productIdentifier: transaction.original?.payment.productIdentifier ?? "")
        updateCredits(credits)
        SKPaymentQueue.default().finishTransaction(transaction)
        
        if purchaseCompletion != nil {
            purchaseCompletion?.resume()
            purchaseCompletion = nil
        }
    }
    
    // MARK: - Helper Methods
    private func creditsFor(productIdentifier: String) -> Int {
        switch productIdentifier {
        case "muratcancicekk.FaceAiEdit.credits.small":
            return 15
        case "muratcancicekk.FaceAiEdit.credits.medium":
            return 40
        case "muratcancicekk.FaceAiEdit.credits.big":
            return 100
        default:
            return 0
        }
    }
    
    private func loadStoredCredits() {
        let credits = UserDefaults.standard.integer(forKey: "creditBalance")
        creditBalanceSubject.send(credits)
    }
    
    private func updateCredits(_ amount: Int) {
        let newBalance = creditBalanceSubject.value + amount
        creditBalanceSubject.send(newBalance)
        UserDefaults.standard.set(newBalance, forKey: "creditBalance")
    }
    
    private func formattedPrice(for product: SKProduct) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.locale = product.priceLocale
        return formatter.string(from: product.price) ?? "\(product.price)"
    }
    
    var isTestFlight: Bool {
        Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandbox"
    }
}

enum PurchaseError: LocalizedError {
    case paymentsNotAllowed
    case noProductsFound
    case purchaseFailed
    case restoreFailed
    case fetchInProgress
    case unknown
    
    var errorDescription: String? {
        switch self {
        case .paymentsNotAllowed:
            return "Payments are not allowed on this device"
        case .noProductsFound:
            return "No products are available for purchase. Please check your App Store Connect configuration."
        case .purchaseFailed:
            return "Failed to complete the purchase"
        case .restoreFailed:
            return "Failed to restore purchases"
        case .fetchInProgress:
            return "Product fetch already in progress"
        case .unknown:
            return "An unknown error occurred"
        }
    }
}

I have updated the manager many times, I have written debugs, but already when I run it from xcode, I did not encounter any problems, I did not see any debug in testflight

Upvotes: 0

Views: 51

Answers (0)

Related Questions