Files
FamilyMealPlanner/MealMood/Services/StoreManager.swift
T
alexandrev-tibco 56abe28a82 1.2.0: report non-fatal errors to Crashlytics across key error paths
CrashlyticsService was wired only for the is_premium custom key. Route the
swallowed errors in the main catch blocks through CrashlyticsService.record
with a context tag, so non-fatals show up in Firebase (StoreKit load/purchase/
restore, calendar access/create/update/delete, reset-all-data, onboarding save).
Debug prints kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
2026-07-10 14:27:28 +02:00

257 lines
8.6 KiB
Swift

import StoreKit
import SwiftUI
@MainActor
final class StoreManager: ObservableObject {
enum ProductLoadState {
case idle
case loading
case loaded
case timedOut
case notFound
case failed
}
enum PurchaseResult {
case success
case cancelled
case pending
case failed
}
@Published var products: [Product] = []
@Published var isPremium: Bool = false
@Published var isLoading: Bool = false
@Published var isLoadingProducts: Bool = false
@Published var productLoadState: ProductLoadState = .idle
@Published var debugLoadedProductIds: [String] = []
@Published var debugLoadedProducts: [String] = []
static let monthlyProductId = "com.alexandrevazquez.mealmood.premium.monthly.sub"
static let yearlyProductId = "com.alexandrevazquez.mealmood.premium.yearly.sub"
static let legacyOneTimeProductId = "com.mealmood.premium.monthly"
private var productIds: [String] {
var ids = [Self.monthlyProductId, Self.yearlyProductId, Self.legacyOneTimeProductId]
if let bundleId = Bundle.main.bundleIdentifier {
ids.append("\(bundleId).premium.monthly.sub")
ids.append("\(bundleId).premium.yearly.sub")
ids.append("\(bundleId).premium.monthly")
}
var seen = Set<String>()
return ids.filter { seen.insert($0).inserted }
}
init() {
Task {
await loadProducts()
await checkPremiumStatus()
}
}
func loadProducts() async {
isLoadingProducts = true
productLoadState = .loading
defer { isLoadingProducts = false }
do {
let fetchedProducts = try await loadProductsWithRetries()
products = fetchedProducts
debugLoadedProductIds = fetchedProducts.map(\.id)
debugLoadedProducts = fetchedProducts.map { "\($0.id) [\($0.type)]" }
productLoadState = fetchedProducts.isEmpty ? .notFound : .loaded
} catch {
if error is TimeoutError {
productLoadState = .timedOut
} else {
productLoadState = .failed
CrashlyticsService.record(error, context: "load_products")
}
print("Failed to load products: \(error)")
}
}
func purchase(_ product: Product) async -> PurchaseResult {
isLoading = true
defer { isLoading = false }
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
if case .verified(let transaction) = verification {
await transaction.finish()
isPremium = true
AnalyticsService.logPremiumPurchased(environment: Self.environmentName(for: transaction))
return .success
}
return .failed
case .userCancelled:
return .cancelled
case .pending:
return .pending
@unknown default:
return .failed
}
} catch {
CrashlyticsService.record(error, context: "purchase")
print("Purchase failed: \(error)")
return .failed
}
}
func restorePurchases() async {
isLoading = true
defer { isLoading = false }
do {
try await AppStore.sync()
let wasNotPremium = !isPremium
await checkPremiumStatus()
if wasNotPremium && isPremium {
AnalyticsService.logPremiumRestored()
}
} catch {
CrashlyticsService.record(error, context: "restore")
print("Restore failed: \(error)")
}
}
private func checkPremiumStatus() async {
isPremium = await Self.hasActiveSubscription()
}
var monthlyProduct: Product? {
if let match = products.first(where: { $0.id == Self.monthlyProductId && $0.type == .autoRenewable }) {
return match
}
return products.first(where: { $0.type == .autoRenewable && $0.subscription?.subscriptionPeriod.unit == .month })
}
var yearlyProduct: Product? {
if let match = products.first(where: { $0.id == Self.yearlyProductId && $0.type == .autoRenewable }) {
return match
}
return products.first(where: { $0.type == .autoRenewable && $0.subscription?.subscriptionPeriod.unit == .year })
}
var lifetimeProduct: Product? {
products.first(where: { $0.type == .nonConsumable })
}
var yearlySavingsPercent: Int? {
guard let monthly = monthlyProduct, let yearly = yearlyProduct else { return nil }
let monthlyAnnualised = monthly.price * 12
guard monthlyAnnualised > 0 else { return nil }
let savings = (monthlyAnnualised - yearly.price) / monthlyAnnualised
let percent = Int((NSDecimalNumber(decimal: savings).doubleValue * 100).rounded())
return percent > 0 ? percent : nil
}
var hasTrialAvailable: Bool {
guard let yearly = yearlyProduct, let intro = yearly.subscription?.introductoryOffer else { return false }
return intro.paymentMode == .freeTrial
}
var debugProductIds: [String] { productIds }
static func hasActiveSubscription() async -> Bool {
let bundleId = Bundle.main.bundleIdentifier ?? ""
let subscriptionIds: Set<String> = [
monthlyProductId,
yearlyProductId,
"\(bundleId).premium.monthly.sub",
"\(bundleId).premium.yearly.sub"
]
// Legacy one-time purchases grant lifetime access no expiration check needed.
let legacyIds: Set<String> = [legacyOneTimeProductId, "\(bundleId).premium.monthly"]
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result,
transaction.revocationDate == nil else { continue }
if legacyIds.contains(transaction.productID) {
return true
}
if subscriptionIds.contains(transaction.productID) {
if let expirationDate = transaction.expirationDate, expirationDate < Date() {
continue
}
return true
}
}
return false
}
/// Maps a transaction's StoreKit environment to an analytics-friendly string
/// so sandbox/Xcode test purchases can be excluded from real conversion metrics.
static func environmentName(for transaction: StoreKit.Transaction) -> String {
switch transaction.environment {
case .production: return "production"
case .sandbox: return "sandbox"
case .xcode: return "xcode"
default: return "unknown"
}
}
private struct TimeoutError: Error {}
private func loadProductsWithRetries(maxAttempts: Int = 3) async throws -> [Product] {
var lastProducts: [Product] = []
for attempt in 1...maxAttempts {
let products = try await loadProductsWithTimeout(seconds: 12)
if !products.isEmpty {
return products
}
lastProducts = products
if attempt < maxAttempts {
try await Task.sleep(nanoseconds: 800_000_000)
}
}
return lastProducts
}
private func loadProductsWithTimeout(seconds: UInt64) async throws -> [Product] {
let ids = productIds
return try await withThrowingTaskGroup(of: [Product].self) { group in
group.addTask {
try await self.loadProductsBySingleID(ids: ids)
}
group.addTask {
try await Task.sleep(nanoseconds: seconds * 1_000_000_000)
throw TimeoutError()
}
let firstResult = try await group.next() ?? []
group.cancelAll()
return firstResult
}
}
private func loadProductsBySingleID(ids: [String]) async throws -> [Product] {
var merged: [String: Product] = [:]
// First, attempt a single batch request with all IDs.
let batchItems = try await Product.products(for: ids)
for item in batchItems {
merged[item.id] = item
}
if !merged.isEmpty {
return Array(merged.values)
}
// Fallback to one-by-one requests for better resilience/debugging.
for id in ids {
let items = try await Product.products(for: [id])
for item in items {
merged[item.id] = item
}
}
return Array(merged.values)
}
}