cfa9769a06
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
266 lines
9.2 KiB
Swift
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)
|
|
}
|
|
}
|