126 lines
4.2 KiB
Swift
126 lines
4.2 KiB
Swift
import Foundation
|
|
import StoreKit
|
|
|
|
@MainActor
|
|
final class IAPManager: ObservableObject {
|
|
@Published var isPurchasing: Bool = false
|
|
@Published var pioneerProduct: Product?
|
|
@Published var errorMessage: String?
|
|
@Published var isSubscribed: Bool = false
|
|
@Published var subscriptionExpiry: Date? = nil
|
|
|
|
// Product IDs from App Store Connect
|
|
private let productIDs: [String] = [
|
|
"MEMBERSHIP_PIONEER_MONTHLY"
|
|
]
|
|
|
|
init() {
|
|
Task { await observeTransactions() }
|
|
}
|
|
|
|
// Load products defined in App Store Connect
|
|
func loadProducts() async {
|
|
do {
|
|
let products = try await Product.products(for: productIDs)
|
|
// You can refine selection logic if you have multiple tiers
|
|
self.pioneerProduct = products.first
|
|
if products.isEmpty {
|
|
// No products found is a common setup issue (App Store Connect, StoreKit config, or bundle ID)
|
|
self.errorMessage = "No subscription products found. Please try again later."
|
|
}
|
|
} catch {
|
|
self.errorMessage = "Failed to load products: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
// Trigger App Store purchase sheet
|
|
func purchasePioneer() async {
|
|
guard !isPurchasing else { return }
|
|
guard let product = pioneerProduct else {
|
|
// Surface an actionable error so the UI can inform the user
|
|
self.errorMessage = "Subscription product unavailable. Please try again later."
|
|
return
|
|
}
|
|
isPurchasing = true
|
|
defer { isPurchasing = false }
|
|
|
|
do {
|
|
let result = try await product.purchase()
|
|
switch result {
|
|
case .success(let verification):
|
|
switch verification {
|
|
case .unverified(_, let error):
|
|
self.errorMessage = "Purchase unverified: \(error.localizedDescription)"
|
|
case .verified(let transaction):
|
|
// Update entitlement for the purchased product
|
|
updateEntitlement(from: transaction)
|
|
await transaction.finish()
|
|
}
|
|
case .userCancelled:
|
|
break
|
|
case .pending:
|
|
break
|
|
@unknown default:
|
|
break
|
|
}
|
|
} catch {
|
|
self.errorMessage = "Purchase failed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
// Restore purchases (sync entitlements)
|
|
func restorePurchases() async {
|
|
do {
|
|
try await AppStore.sync()
|
|
} catch {
|
|
self.errorMessage = "Restore failed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
// Observe transaction updates for entitlement changes
|
|
private func observeTransactions() async {
|
|
for await result in Transaction.updates {
|
|
do {
|
|
let transaction: Transaction = try checkVerified(result)
|
|
// Update entitlement state for transaction.productID
|
|
updateEntitlement(from: transaction)
|
|
await transaction.finish()
|
|
} catch {
|
|
// Ignore unverified transactions
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check current entitlements (useful on launch)
|
|
func refreshEntitlements() async {
|
|
for await result in Transaction.currentEntitlements {
|
|
if case .verified(let transaction) = result,
|
|
productIDs.contains(transaction.productID) {
|
|
updateEntitlement(from: transaction)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper: verify
|
|
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
|
switch result {
|
|
case .unverified(_, let error):
|
|
throw error
|
|
case .verified(let safe):
|
|
return safe
|
|
}
|
|
}
|
|
|
|
private func updateEntitlement(from transaction: Transaction) {
|
|
guard productIDs.contains(transaction.productID) else { return }
|
|
// For auto-renewable subs, use expirationDate and revocationDate
|
|
if transaction.revocationDate == nil {
|
|
self.isSubscribed = true
|
|
self.subscriptionExpiry = transaction.expirationDate
|
|
} else {
|
|
self.isSubscribed = false
|
|
self.subscriptionExpiry = nil
|
|
}
|
|
}
|
|
}
|