257 lines
7.2 KiB
Swift
257 lines
7.2 KiB
Swift
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")
|
|
]
|
|
}
|