Reputation: 1
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
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