Files
FamilyMealPlanner/MealMood/Services/StoreManager.swift
T
alexandrev-tibco cfa9769a06 2.0.2: evento de fallo de compra + analytics off en UI tests
premium_purchase_failed con reason (cancelled/pending/unverified/error) en todas
las salidas no exitosas de StoreManager.purchase. Hasta ahora un usuario que se
echaba atras y uno al que le fallaba la compra eran indistinguibles: los dos
dejaban un premium_upgrade_tapped huerfano. El caso de Aleman (6 taps en 2 dias,
cero compras) no se puede diagnosticar sin esto.

Los UI tests dejan de mandar eventos a GA4 de produccion: launch argument
UITEST_DISABLE_ANALYTICS que hace no-op logEvent y apaga la recoleccion de
Firebase, para cortar tambien los automaticos (first_open, session_start,
screen_view). Ya habian contaminado los datos: los eventos de pasos 5/6 bajo la
version 2.0 eran ejecuciones de test, no usuarios.

Detalle que costo encontrar: setAnalyticsCollectionEnabled persiste entre
lanzamientos, asi que se escribe siempre (!isDisabled) y no solo al apagar. Si
no, un unico test dejaba analytics muerto para siempre en esa instalacion.
Verificado por pares de lanzamientos: con flag acaba en disabled, sin flag
vuelve a enabled.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
2026-08-13 14:02:35 +02:00

266 lines
9.2 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
}
AnalyticsService.logPremiumPurchaseFailed(reason: "unverified", productId: product.id)
return .failed
case .userCancelled:
AnalyticsService.logPremiumPurchaseFailed(reason: "cancelled", productId: product.id)
return .cancelled
case .pending:
AnalyticsService.logPremiumPurchaseFailed(reason: "pending", productId: product.id)
return .pending
@unknown default:
AnalyticsService.logPremiumPurchaseFailed(reason: "unknown", productId: product.id)
return .failed
}
} catch {
CrashlyticsService.record(error, context: "purchase")
AnalyticsService.logPremiumPurchaseFailed(
reason: "error",
productId: product.id,
detail: error.localizedDescription
)
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)
}
}