Files
Reflect/Shared/IAPManager.swift
Trey t 329fb7c671 Remove #if DEBUG guards for TestFlight, polish weekly digest and insights UX
- Remove #if DEBUG from all debug settings, exporters, and IAP bypass so
  debug options are available in TestFlight builds
- Weekly digest card: replace dismiss X with collapsible chevron caret
- Weekly digest: generate on-demand when opening Insights tab if no cached
  digest exists (BGTask + notification kept as bonus path)
- Fix digest intention text color (was .secondary, now uses theme textColor)
- Add "Generate Weekly Digest" debug button in Settings
- Add generating overlay on Insights tab with pulsing sparkles icon that
  stays visible until all sections finish loading (content at 0.2 opacity)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:15:23 -05:00

512 lines
19 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
@Published var bypassSubscription: Bool {
didSet { UserDefaults.standard.set(bypassSubscription, forKey: "debug_bypassSubscription") }
}
// MARK: - Constants
static let subscriptionGroupID = "21951685"
static 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() {
self.bypassSubscription = UserDefaults.standard.bool(forKey: "debug_bypassSubscription")
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
/// Pass `force: true` to bypass throttle (e.g. after a transaction update)
func checkSubscriptionStatus(force: Bool = false) async {
AppLogger.iap.debug("checkSubscriptionStatus: called (force=\(force), currentState=\(String(describing: self.state)))")
// Throttle: skip if we checked recently (unless state is unknown or forced)
if !force,
state != .unknown,
let lastCheck = lastStatusCheckTime,
Date().timeIntervalSince(lastCheck) < statusCheckInterval {
AppLogger.iap.debug("checkSubscriptionStatus: THROTTLED (last check \(Date().timeIntervalSince(lastCheck))s ago)")
return
}
// Only update isLoading if value actually changes to avoid unnecessary view updates
if !isLoading { isLoading = true }
defer {
if isLoading { isLoading = false }
lastStatusCheckTime = Date()
AppLogger.iap.debug("checkSubscriptionStatus: DONE — final state=\(String(describing: self.state))")
}
// Fetch available products
await loadProducts()
// Check for active subscription
let hasActiveSubscription = await checkForActiveSubscription()
AppLogger.iap.debug("checkSubscriptionStatus: hasActiveSubscription=\(hasActiveSubscription), state after check=\(String(describing: self.state))")
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
}
// Live check found no active subscription.
// Only trust cached expiration if we're offline (products failed to load).
// If products loaded successfully, StoreKit had server access and the live result is authoritative.
if availableProducts.isEmpty {
let cachedExpiration = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) as? Date
AppLogger.iap.debug("checkSubscriptionStatus: offline (no products) — cachedExpiration=\(cachedExpiration?.description ?? "nil")")
if let expiration = cachedExpiration, expiration > Date() {
AppLogger.iap.debug("checkSubscriptionStatus: using cached expiration (offline, still valid)")
state = .subscribed(expirationDate: expiration, willAutoRenew: false)
syncSubscriptionStatusToUserDefaults()
trackSubscriptionAnalytics(source: "status_check_cached")
return
}
}
// Subscription genuinely gone clear cache and fall back to trial
AppLogger.iap.debug("checkSubscriptionStatus: falling back to trial — firstLaunchDate=\(self.firstLaunchDate), trialDays=\(self.trialDays)")
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)
let cachedExpiration = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) as? Date
AppLogger.iap.debug("restoreCachedSubscriptionState: hasActive=\(hasActive), cachedExpiration=\(cachedExpiration?.description ?? "nil")")
guard hasActive else {
AppLogger.iap.debug("restoreCachedSubscriptionState: no cached active state, skipping")
return
}
// If we have a cached expiration and it's still in the future, restore subscribed state
if let expiration = cachedExpiration, expiration > Date() {
AppLogger.iap.debug("restoreCachedSubscriptionState: restoring .subscribed (cached expiration in future)")
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
AppLogger.iap.debug("restoreCachedSubscriptionState: restoring .subscribed (no expiration, trusting hasActive)")
state = .subscribed(expirationDate: nil, willAutoRenew: false)
} else {
AppLogger.iap.debug("restoreCachedSubscriptionState: cached expiration in past, not restoring")
}
}
/// Restore purchases
func restore(source: String = "settings") async {
do {
try await AppStore.sync()
await checkSubscriptionStatus(force: true)
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 {
AppLogger.iap.info("loadProducts() called — requesting: \(Self.productIdentifiers.sorted().joined(separator: ", "))")
do {
let products = try await Product.products(for: Self.productIdentifiers)
AppLogger.iap.info("loadProducts() returned \(products.count) products total")
for product in products {
AppLogger.iap.info(" Product: \(product.id) — type: \(product.type.rawValue), displayName: \(product.displayName), price: \(product.displayPrice)")
}
availableProducts = products.filter { $0.type == .autoRenewable }
AppLogger.iap.info("loadProducts() filtered to \(self.availableProducts.count) autoRenewable products")
} catch {
AppLogger.iap.error("loadProducts() FAILED: \(error.localizedDescription) — full error: \(String(describing: error))")
}
}
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 Self.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
}
/// 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()
}
private func updateTrialState() {
let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0
let daysRemaining = trialDays - daysSinceInstall
AppLogger.iap.debug("updateTrialState: firstLaunchDate=\(self.firstLaunchDate), daysSinceInstall=\(daysSinceInstall), daysRemaining=\(daysRemaining)")
if daysRemaining > 0 {
AppLogger.iap.debug("updateTrialState: setting .inTrial(daysRemaining: \(daysRemaining))")
state = .inTrial(daysRemaining: daysRemaining)
} else {
AppLogger.iap.debug("updateTrialState: setting .trialExpired")
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(force: true)
}
}
}
}