520 lines
20 KiB
Swift
520 lines
20 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: [Product.SubscriptionInfo.Status]
|
|
do {
|
|
statuses = try await subscription.status
|
|
} catch {
|
|
AppLogger.iap.error("Failed to fetch subscription status for \(product.id): \(error)")
|
|
// Fallback handled below
|
|
state = .subscribed(expirationDate: transaction.expirationDate, willAutoRenew: false)
|
|
return true
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|