wake-ios/wake/Utils/IAPManager.swift
2025-09-02 20:31:13 +08:00

130 lines
4.9 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 throws -> String {
guard !isPurchasing else { throw NSError(domain: "IAPError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Purchase already in progress"]) }
guard let product = pioneerProduct else {
throw NSError(domain: "IAPError", code: -2, userInfo: [NSLocalizedDescriptionKey: "Subscription product unavailable"])
}
isPurchasing = true
defer { isPurchasing = false }
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
switch verification {
case .unverified(_, let error):
throw error
case .verified(let transaction):
print("🎉 订阅成功!", transaction)
print("🔄 交易验证通过 - ID: \(transaction.id), 原始ID: \(transaction.originalID), 产品ID: \(transaction.productID)")
updateEntitlement(from: transaction)
let transactionID = String(transaction.id)
print("📝 使用交易ID: \(transactionID)")
await transaction.finish()
return transactionID
}
case .userCancelled:
throw NSError(domain: "IAPError", code: -3, userInfo: [NSLocalizedDescriptionKey: "Purchase was cancelled"])
case .pending:
throw NSError(domain: "IAPError", code: -4, userInfo: [NSLocalizedDescriptionKey: "Purchase is pending approval"])
@unknown default:
throw NSError(domain: "IAPError", code: -5, userInfo: [NSLocalizedDescriptionKey: "Unknown purchase result"])
}
} catch {
self.errorMessage = "Purchase failed: \(error.localizedDescription)"
throw error
}
}
// 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
}
}
}