10f6d0ca20
iCloud sync: - Force viewContext.refreshAllObjects() on remote change notifications so data from other devices is picked up immediately without app restart - Call refreshFromCloudKit() on foreground to merge any changes made while the device was inactive - Wait up to 10s for initial CloudKit sync on launch before showing onboarding (shows "Checking iCloud..." during the wait) - New OnboardingICloudCheckView: shown on fresh installs with iCloud available, lets user restore from iCloud before starting onboarding from scratch Localization: - Added de, fr, it, ja, pt-BR lproj folders - New iCloud onboarding strings in en + es-ES (+ button literals) ASO metadata (fastlane): - Updated en-US: new subtitle, keywords, description (fixed "no cloud sync" claim), promotional text, release notes - Added full metadata for es-ES, de-DE, fr-FR, it, ja, pt-BR (63 files total) - All keyword fields validated ≤100 Unicode chars Infrastructure: - Gemfile + Gemfile.lock for fastlane - Scripts/archive_and_upload_appstore.sh for CI/CD Version: 1.2.1 (build 7) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
295 lines
8.6 KiB
Swift
295 lines
8.6 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()
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
func setPremiumForTesting(_ value: Bool, familyShared: Bool = false) {
|
|
isPremium = value
|
|
isFamilyShared = familyShared
|
|
}
|
|
#endif
|
|
|
|
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: - TestFlight Detection
|
|
|
|
/// TestFlight builds use a "sandboxReceipt" instead of the production "receipt".
|
|
private var isRunningOnTestFlight: Bool {
|
|
Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
|
|
}
|
|
|
|
// MARK: - Update Premium Status
|
|
|
|
func updatePremiumStatus() async {
|
|
var isEntitled = false
|
|
var familyShared = false
|
|
|
|
#if DEBUG
|
|
if debugOverrideEnabled {
|
|
isPremium = true
|
|
isFamilyShared = false
|
|
return
|
|
}
|
|
#endif
|
|
|
|
// TestFlight builds always get Premium so testers can evaluate all features
|
|
if isRunningOnTestFlight {
|
|
isPremium = true
|
|
isFamilyShared = false
|
|
sharedDefaults?.set(true, forKey: "premiumUnlocked")
|
|
return
|
|
}
|
|
|
|
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")
|
|
]
|
|
|
|
/// Condensed benefits shown on the paywall (4 max, outcome-focused)
|
|
static var paywallBenefits: [(icon: String, title: String, subtitle: String)] {[
|
|
("clock.arrow.circlepath",
|
|
String(localized: "paywall_benefit_history_title"),
|
|
String(localized: "paywall_benefit_history_subtitle")),
|
|
("chart.bar.xaxis",
|
|
String(localized: "paywall_benefit_charts_title"),
|
|
String(localized: "paywall_benefit_charts_subtitle")),
|
|
("wand.and.stars",
|
|
String(localized: "paywall_benefit_forecasts_title"),
|
|
String(localized: "paywall_benefit_forecasts_subtitle")),
|
|
("xmark.circle",
|
|
String(localized: "paywall_benefit_noads_title"),
|
|
String(localized: "paywall_benefit_noads_subtitle"))
|
|
]}
|
|
}
|