initial version
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
import Foundation
|
||||
import StoreKit
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
class IAPService: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published private(set) var isPremium = false
|
||||
@Published private(set) var products: [Product] = []
|
||||
@Published private(set) var purchaseState: PurchaseState = .idle
|
||||
@Published private(set) var isFamilyShared = false
|
||||
#if DEBUG
|
||||
@Published var debugOverrideEnabled = false
|
||||
#endif
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
static let premiumProductID = "com.portfoliojournal.premium"
|
||||
static let premiumPrice = "€4.69"
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
private var updateListenerTask: Task<Void, Error>?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let sharedDefaults = UserDefaults(suiteName: AppConstants.appGroupIdentifier)
|
||||
|
||||
// MARK: - Purchase State
|
||||
|
||||
enum PurchaseState: Equatable {
|
||||
case idle
|
||||
case purchasing
|
||||
case purchased
|
||||
case failed(String)
|
||||
case restored
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
#if DEBUG
|
||||
debugOverrideEnabled = UserDefaults.standard.bool(forKey: "debugPremiumOverride")
|
||||
#endif
|
||||
updateListenerTask = listenForTransactions()
|
||||
|
||||
Task {
|
||||
await loadProducts()
|
||||
await updatePremiumStatus()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
updateListenerTask?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Load Products
|
||||
|
||||
func loadProducts() async {
|
||||
do {
|
||||
products = try await Product.products(for: [Self.premiumProductID])
|
||||
print("Loaded \(products.count) products")
|
||||
} catch {
|
||||
print("Failed to load products: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Purchase
|
||||
|
||||
func purchase() async throws {
|
||||
guard let product = products.first else {
|
||||
throw IAPError.productNotFound
|
||||
}
|
||||
|
||||
purchaseState = .purchasing
|
||||
|
||||
do {
|
||||
let result = try await product.purchase()
|
||||
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try checkVerified(verification)
|
||||
|
||||
// Check if family shared
|
||||
isFamilyShared = transaction.ownershipType == .familyShared
|
||||
|
||||
await transaction.finish()
|
||||
await updatePremiumStatus()
|
||||
|
||||
purchaseState = .purchased
|
||||
|
||||
// Track analytics
|
||||
FirebaseService.shared.logPurchaseSuccess(
|
||||
productId: product.id,
|
||||
price: product.price,
|
||||
isFamilyShared: isFamilyShared
|
||||
)
|
||||
|
||||
case .userCancelled:
|
||||
purchaseState = .idle
|
||||
|
||||
case .pending:
|
||||
purchaseState = .idle
|
||||
|
||||
@unknown default:
|
||||
purchaseState = .idle
|
||||
}
|
||||
} catch {
|
||||
purchaseState = .failed(error.localizedDescription)
|
||||
FirebaseService.shared.logPurchaseFailure(
|
||||
productId: product.id,
|
||||
error: error.localizedDescription
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Restore Purchases
|
||||
|
||||
func restorePurchases() async {
|
||||
purchaseState = .purchasing
|
||||
|
||||
do {
|
||||
try await AppStore.sync()
|
||||
await updatePremiumStatus()
|
||||
|
||||
if isPremium {
|
||||
purchaseState = .restored
|
||||
} else {
|
||||
purchaseState = .failed("No purchases to restore")
|
||||
}
|
||||
} catch {
|
||||
purchaseState = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Update Premium Status
|
||||
|
||||
func updatePremiumStatus() async {
|
||||
var isEntitled = false
|
||||
var familyShared = false
|
||||
|
||||
#if DEBUG
|
||||
if debugOverrideEnabled {
|
||||
isPremium = true
|
||||
isFamilyShared = false
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
for await result in StoreKit.Transaction.currentEntitlements {
|
||||
if case .verified(let transaction) = result {
|
||||
if transaction.productID == Self.premiumProductID {
|
||||
isEntitled = true
|
||||
familyShared = transaction.ownershipType == .familyShared
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isPremium = isEntitled
|
||||
isFamilyShared = familyShared
|
||||
sharedDefaults?.set(isEntitled, forKey: "premiumUnlocked")
|
||||
|
||||
// Update Core Data
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
PremiumStatus.updateStatus(
|
||||
isPremium: isEntitled,
|
||||
productIdentifier: Self.premiumProductID,
|
||||
transactionId: nil,
|
||||
isFamilyShared: familyShared,
|
||||
in: context
|
||||
)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func setDebugPremiumOverride(_ enabled: Bool) {
|
||||
debugOverrideEnabled = enabled
|
||||
UserDefaults.standard.set(enabled, forKey: "debugPremiumOverride")
|
||||
if enabled {
|
||||
isPremium = true
|
||||
isFamilyShared = false
|
||||
sharedDefaults?.set(true, forKey: "premiumUnlocked")
|
||||
} else {
|
||||
Task { await updatePremiumStatus() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Transaction Listener
|
||||
|
||||
private func listenForTransactions() -> Task<Void, Error> {
|
||||
return Task.detached { [weak self] in
|
||||
for await result in StoreKit.Transaction.updates {
|
||||
if case .verified(let transaction) = result {
|
||||
await transaction.finish()
|
||||
await self?.updatePremiumStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verification
|
||||
|
||||
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
||||
switch result {
|
||||
case .unverified(_, let error):
|
||||
throw IAPError.verificationFailed(error.localizedDescription)
|
||||
case .verified(let safe):
|
||||
return safe
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Product Info
|
||||
|
||||
var premiumProduct: Product? {
|
||||
products.first { $0.id == Self.premiumProductID }
|
||||
}
|
||||
|
||||
var formattedPrice: String {
|
||||
premiumProduct?.displayPrice ?? Self.premiumPrice
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IAP Error
|
||||
|
||||
enum IAPError: LocalizedError {
|
||||
case productNotFound
|
||||
case verificationFailed(String)
|
||||
case purchaseFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .productNotFound:
|
||||
return "Product not found. Please try again later."
|
||||
case .verificationFailed(let message):
|
||||
return "Verification failed: \(message)"
|
||||
case .purchaseFailed(let message):
|
||||
return "Purchase failed: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Premium Features
|
||||
|
||||
extension IAPService {
|
||||
static let premiumFeatures: [(icon: String, title: String, description: String)] = [
|
||||
("person.2", "Multiple Accounts", "Separate portfolios for business or family"),
|
||||
("infinity", "Unlimited Sources", "Track as many investments as you want"),
|
||||
("clock.arrow.circlepath", "Full History", "Access your complete investment history"),
|
||||
("chart.bar.xaxis", "Advanced Charts", "5 types of detailed analytics charts"),
|
||||
("wand.and.stars", "Predictions", "AI-powered 12-month forecasts"),
|
||||
("square.and.arrow.up", "Export Data", "Export to CSV and JSON formats"),
|
||||
("xmark.circle", "No Ads", "Ad-free experience forever"),
|
||||
("person.2", "Family Sharing", "Share with up to 5 family members")
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user