Files
Reflect/Shared/IAPManager.swift
Trey t b02a497a86 Fix subscription store not loading on TestFlight
The subscription group ID was still set to the old Feels value (21914363).
Updated to the correct Reflect group ID (21951685) from App Store Connect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:10:32 -06:00

501 lines
17 KiB
Swift

//
// IAPManager.swift
// Reflect
//
// Refactored StoreKit 2 subscription manager with clean state model.
//
import Foundation
import StoreKit
import SwiftUI
import os.log
// MARK: - Subscription State
enum SubscriptionState: Equatable {
case unknown
case subscribed(expirationDate: Date?, willAutoRenew: Bool)
case billingRetry(expirationDate: Date?, willAutoRenew: Bool)
case gracePeriod(expirationDate: Date?, willAutoRenew: Bool)
case inTrial(daysRemaining: Int)
case trialExpired
case expired
case revoked
}
// MARK: - IAPManager
@MainActor
class IAPManager: ObservableObject {
// MARK: - Shared Instance
/// Shared instance for service-level access (e.g., HealthKit gating)
static let shared = IAPManager()
// MARK: - Debug Toggle
/// Set to `true` to bypass all subscription checks and grant full access (for development only)
/// Togglable at runtime in DEBUG builds via Settings > Debug > Bypass Subscription
#if DEBUG
@Published var bypassSubscription: Bool {
didSet { UserDefaults.standard.set(bypassSubscription, forKey: "debug_bypassSubscription") }
}
#else
let bypassSubscription = false
#endif
// MARK: - Constants
static let subscriptionGroupID = "21951685"
private let productIdentifiers: Set<String> = [
"com.88oakapps.reflect.IAP.subscriptions.monthly",
"com.88oakapps.reflect.IAP.subscriptions.yearly"
]
private let trialDays = 30
// MARK: - Published State
@Published private(set) var state: SubscriptionState = .unknown
@Published private(set) var availableProducts: [Product] = []
@Published private(set) var currentProduct: Product? = nil
@Published private(set) var isLoading = false
// MARK: - Storage
/// Reads firstLaunchDate directly from UserDefaults to ensure we always get the latest value
/// (Using @AppStorage in a class doesn't auto-sync when other components update the same key)
private var firstLaunchDate: Date {
get {
GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.firstLaunchDate.rawValue) as? Date ?? Date()
}
set {
GroupUserDefaults.groupDefaults.set(newValue, forKey: UserDefaultsStore.Keys.firstLaunchDate.rawValue)
}
}
// MARK: - Private
private var updateListenerTask: Task<Void, Error>?
/// Last time subscription status was checked (for throttling)
private var lastStatusCheckTime: Date?
/// Minimum interval between status checks (5 minutes)
private let statusCheckInterval: TimeInterval = 300
// MARK: - Computed Properties
var isSubscribed: Bool {
switch state {
case .subscribed, .billingRetry, .gracePeriod:
return true
default:
return false
}
}
var hasFullAccess: Bool {
if bypassSubscription { return true }
switch state {
case .subscribed, .billingRetry, .gracePeriod, .inTrial:
return true
case .unknown, .trialExpired, .expired, .revoked:
return false
}
}
var shouldShowPaywall: Bool {
if bypassSubscription { return false }
switch state {
case .trialExpired, .expired, .revoked:
return true
case .unknown, .subscribed, .billingRetry, .gracePeriod, .inTrial:
return false
}
}
var shouldShowTrialWarning: Bool {
if bypassSubscription { return false }
if case .inTrial = state { return true }
return false
}
var daysLeftInTrial: Int {
if case .inTrial(let days) = state { return days }
return 0
}
var trialExpirationDate: Date? {
Calendar.current.date(byAdding: .day, value: trialDays, to: firstLaunchDate)
}
/// Products sorted by price (lowest first)
var sortedProducts: [Product] {
availableProducts.sorted { $0.price < $1.price }
}
// MARK: - Initialization
init() {
#if DEBUG
self.bypassSubscription = UserDefaults.standard.bool(forKey: "debug_bypassSubscription")
#endif
restoreCachedSubscriptionState()
updateListenerTask = listenForTransactions()
Task {
await checkSubscriptionStatus()
}
}
deinit {
updateListenerTask?.cancel()
}
// MARK: - Public Methods
/// Check subscription status - call on app launch and when becoming active
/// Throttled to avoid excessive StoreKit calls on rapid foreground transitions
func checkSubscriptionStatus() async {
// Throttle: skip if we checked recently (unless state is unknown)
if state != .unknown,
let lastCheck = lastStatusCheckTime,
Date().timeIntervalSince(lastCheck) < statusCheckInterval {
return
}
// Only update isLoading if value actually changes to avoid unnecessary view updates
if !isLoading { isLoading = true }
defer {
if isLoading { isLoading = false }
lastStatusCheckTime = Date()
}
// Fetch available products
await loadProducts()
// Check for active subscription
let hasActiveSubscription = await checkForActiveSubscription()
if hasActiveSubscription {
// State already set in checkForActiveSubscription cache it
switch state {
case .subscribed(let expiration, _),
.billingRetry(let expiration, _),
.gracePeriod(let expiration, _):
cacheSubscriptionExpiration(expiration)
default:
break
}
syncSubscriptionStatusToUserDefaults()
trackSubscriptionAnalytics(source: "status_check_active")
return
}
// Preserve terminal StoreKit states (expired/revoked) instead of overriding with trial fallback.
if case .expired = state {
syncSubscriptionStatusToUserDefaults()
trackSubscriptionAnalytics(source: "status_check_terminal")
return
}
if case .revoked = state {
syncSubscriptionStatusToUserDefaults()
trackSubscriptionAnalytics(source: "status_check_terminal")
return
}
// Live check found no active subscription.
// Before downgrading, check if cached expiration is still valid (covers offline/transient failures).
let cachedExpiration = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) as? Date
if let expiration = cachedExpiration, expiration > Date() {
state = .subscribed(expirationDate: expiration, willAutoRenew: false)
syncSubscriptionStatusToUserDefaults()
trackSubscriptionAnalytics(source: "status_check_cached")
return
}
// Subscription genuinely gone clear cache and fall back to trial
cacheSubscriptionExpiration(nil)
updateTrialState()
trackSubscriptionAnalytics(source: "status_check_fallback")
}
/// Sync subscription status to UserDefaults for widget access
private func syncSubscriptionStatusToUserDefaults() {
let accessValue = bypassSubscription ? true : hasFullAccess
GroupUserDefaults.groupDefaults.set(accessValue, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
}
/// Cache subscription state so premium access survives offline/failed checks
private func cacheSubscriptionExpiration(_ date: Date?) {
GroupUserDefaults.groupDefaults.set(date, forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue)
}
/// Restore cached subscription state on launch (before async check completes)
private func restoreCachedSubscriptionState() {
let hasActive = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
guard hasActive else { return }
let cachedExpiration = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) as? Date
// If we have a cached expiration and it's still in the future, restore subscribed state
if let expiration = cachedExpiration, expiration > Date() {
state = .subscribed(expirationDate: expiration, willAutoRenew: false)
} else if cachedExpiration == nil && hasActive {
// Had access but no expiration cached (e.g., upgraded from older version) trust it
state = .subscribed(expirationDate: nil, willAutoRenew: false)
}
}
/// Restore purchases
func restore(source: String = "settings") async {
do {
try await AppStore.sync()
await checkSubscriptionStatus()
AnalyticsManager.shared.trackPurchaseRestored(source: source)
trackSubscriptionAnalytics(source: "restore")
} catch {
AnalyticsManager.shared.trackPurchaseFailed(productId: nil, source: source, error: error.localizedDescription)
AppLogger.iap.error("Failed to restore purchases: \(error.localizedDescription)")
}
}
// MARK: - Private Methods
private func loadProducts() async {
do {
let products = try await Product.products(for: productIdentifiers)
availableProducts = products.filter { $0.type == .autoRenewable }
} catch {
AppLogger.iap.error("Failed to load products: \(error.localizedDescription)")
}
}
private func checkForActiveSubscription() async -> Bool {
var nonActiveState: SubscriptionState?
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
// Skip revoked transactions
if transaction.revocationDate != nil { continue }
// Check if this is one of our subscription products
guard productIdentifiers.contains(transaction.productID) else { continue }
// Get the product for this transaction
currentProduct = availableProducts.first { $0.id == transaction.productID }
// Get renewal info
if let product = currentProduct,
let subscription = product.subscription,
let statuses = try? await subscription.status {
var hadVerifiedStatus = false
for status in statuses {
guard case .verified(let renewalInfo) = status.renewalInfo else { continue }
hadVerifiedStatus = true
switch status.state {
case .subscribed:
state = .subscribed(
expirationDate: transaction.expirationDate,
willAutoRenew: renewalInfo.willAutoRenew
)
return true
case .inBillingRetryPeriod:
state = .billingRetry(
expirationDate: transaction.expirationDate,
willAutoRenew: renewalInfo.willAutoRenew
)
return true
case .inGracePeriod:
state = .gracePeriod(
expirationDate: transaction.expirationDate,
willAutoRenew: renewalInfo.willAutoRenew
)
return true
case .expired:
nonActiveState = .expired
continue
case .revoked:
nonActiveState = .revoked
continue
default:
continue
}
}
// We had detailed status and none were active, so do not fallback to subscribed.
if hadVerifiedStatus {
continue
}
}
// Fallback if we couldn't get detailed status
state = .subscribed(expirationDate: transaction.expirationDate, willAutoRenew: false)
return true
}
// No active subscription found
if let nonActiveState {
state = nonActiveState
} else {
currentProduct = nil
}
return false
}
#if DEBUG
/// Reset subscription state for UI testing. Called after group defaults are cleared
/// so that stale cached state from previous test runs is discarded.
func resetForTesting() {
state = .unknown
lastStatusCheckTime = nil
currentProduct = nil
availableProducts = []
// Explicitly clear cached subscription state to prevent async
// checkSubscriptionStatus from restoring stale .subscribed state.
GroupUserDefaults.groupDefaults.removeObject(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue)
GroupUserDefaults.groupDefaults.set(false, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
GroupUserDefaults.groupDefaults.synchronize()
updateTrialState()
}
#endif
private func updateTrialState() {
let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0
let daysRemaining = trialDays - daysSinceInstall
if daysRemaining > 0 {
state = .inTrial(daysRemaining: daysRemaining)
} else {
state = .trialExpired
}
syncSubscriptionStatusToUserDefaults()
}
// MARK: - Analytics
func trackSubscriptionAnalytics(source: String) {
let status: String
let isSubscribed: Bool
let hasFullAccess: Bool
let productId = currentProduct?.id
let type = subscriptionType(for: productId)
let willAutoRenew: Bool?
let isInGracePeriod: Bool?
let trialDaysRemaining: Int?
let expirationDate: Date?
switch state {
case .unknown:
status = "unknown"
isSubscribed = false
hasFullAccess = bypassSubscription
willAutoRenew = nil
isInGracePeriod = nil
trialDaysRemaining = nil
expirationDate = nil
case .subscribed(let expiration, let autoRenew):
status = "subscribed"
isSubscribed = true
hasFullAccess = true
willAutoRenew = autoRenew
isInGracePeriod = false
trialDaysRemaining = nil
expirationDate = expiration
case .billingRetry(let expiration, let autoRenew):
status = "billing_retry"
isSubscribed = true
hasFullAccess = true
willAutoRenew = autoRenew
isInGracePeriod = true
trialDaysRemaining = nil
expirationDate = expiration
case .gracePeriod(let expiration, let autoRenew):
status = "grace_period"
isSubscribed = true
hasFullAccess = true
willAutoRenew = autoRenew
isInGracePeriod = true
trialDaysRemaining = nil
expirationDate = expiration
case .inTrial(let daysRemaining):
status = "trial"
isSubscribed = false
hasFullAccess = true
willAutoRenew = nil
isInGracePeriod = nil
trialDaysRemaining = daysRemaining
expirationDate = nil
case .trialExpired:
status = "trial_expired"
isSubscribed = false
hasFullAccess = false
willAutoRenew = nil
isInGracePeriod = nil
trialDaysRemaining = nil
expirationDate = nil
case .expired:
status = "expired"
isSubscribed = false
hasFullAccess = false
willAutoRenew = nil
isInGracePeriod = nil
trialDaysRemaining = nil
expirationDate = nil
case .revoked:
status = "revoked"
isSubscribed = false
hasFullAccess = false
willAutoRenew = nil
isInGracePeriod = nil
trialDaysRemaining = nil
expirationDate = nil
}
AnalyticsManager.shared.trackSubscriptionStatusObserved(
status: status,
type: type,
source: source,
isSubscribed: isSubscribed,
hasFullAccess: hasFullAccess,
productId: productId,
willAutoRenew: willAutoRenew,
isInGracePeriod: isInGracePeriod,
trialDaysRemaining: trialDaysRemaining,
expirationDate: expirationDate
)
}
private func subscriptionType(for productID: String?) -> String {
guard let productID else { return "none" }
let id = productID.lowercased()
if id.contains("annual") || id.contains("year") {
return "yearly"
}
if id.contains("month") {
return "monthly"
}
return "unknown"
}
private func listenForTransactions() -> Task<Void, Error> {
Task.detached { [weak self] in
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
await transaction.finish()
await self?.checkSubscriptionStatus()
}
}
}
}