Fix subscription state not updating after purchase or cancellation
- Add force parameter to checkSubscriptionStatus to bypass 5-minute throttle when called from transaction listener, purchase completion, and restore purchases - Remove early return for expired/revoked states that prevented fallback to trial - Only trust cached subscription expiration when offline (products failed to load); when StoreKit returns products successfully, treat the live entitlement check as authoritative - Add debug logging throughout IAP state machine for diagnostics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -159,11 +159,16 @@ class IAPManager: ObservableObject {
|
||||
|
||||
/// 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,
|
||||
/// 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
|
||||
}
|
||||
|
||||
@@ -172,6 +177,7 @@ class IAPManager: ObservableObject {
|
||||
defer {
|
||||
if isLoading { isLoading = false }
|
||||
lastStatusCheckTime = Date()
|
||||
AppLogger.iap.debug("checkSubscriptionStatus: DONE — final state=\(String(describing: self.state))")
|
||||
}
|
||||
|
||||
// Fetch available products
|
||||
@@ -179,6 +185,7 @@ class IAPManager: ObservableObject {
|
||||
|
||||
// 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
|
||||
@@ -195,29 +202,24 @@ class IAPManager: ObservableObject {
|
||||
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
|
||||
// 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")
|
||||
@@ -237,16 +239,24 @@ class IAPManager: ObservableObject {
|
||||
/// 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
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +264,7 @@ class IAPManager: ObservableObject {
|
||||
func restore(source: String = "settings") async {
|
||||
do {
|
||||
try await AppStore.sync()
|
||||
await checkSubscriptionStatus()
|
||||
await checkSubscriptionStatus(force: true)
|
||||
AnalyticsManager.shared.trackPurchaseRestored(source: source)
|
||||
trackSubscriptionAnalytics(source: "restore")
|
||||
} catch {
|
||||
@@ -377,10 +387,13 @@ class IAPManager: ObservableObject {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -499,7 +512,7 @@ class IAPManager: ObservableObject {
|
||||
guard case .verified(let transaction) = result else { continue }
|
||||
|
||||
await transaction.finish()
|
||||
await self?.checkSubscriptionStatus()
|
||||
await self?.checkSubscriptionStatus(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user