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:
Trey t
2026-03-13 09:07:08 -05:00
parent 75a3d7b448
commit c3e70d34b2
2 changed files with 40 additions and 27 deletions

View File

@@ -159,11 +159,16 @@ class IAPManager: ObservableObject {
/// Check subscription status - call on app launch and when becoming active /// Check subscription status - call on app launch and when becoming active
/// Throttled to avoid excessive StoreKit calls on rapid foreground transitions /// Throttled to avoid excessive StoreKit calls on rapid foreground transitions
func checkSubscriptionStatus() async { /// Pass `force: true` to bypass throttle (e.g. after a transaction update)
// Throttle: skip if we checked recently (unless state is unknown) func checkSubscriptionStatus(force: Bool = false) async {
if state != .unknown, 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, let lastCheck = lastStatusCheckTime,
Date().timeIntervalSince(lastCheck) < statusCheckInterval { Date().timeIntervalSince(lastCheck) < statusCheckInterval {
AppLogger.iap.debug("checkSubscriptionStatus: THROTTLED (last check \(Date().timeIntervalSince(lastCheck))s ago)")
return return
} }
@@ -172,6 +177,7 @@ class IAPManager: ObservableObject {
defer { defer {
if isLoading { isLoading = false } if isLoading { isLoading = false }
lastStatusCheckTime = Date() lastStatusCheckTime = Date()
AppLogger.iap.debug("checkSubscriptionStatus: DONE — final state=\(String(describing: self.state))")
} }
// Fetch available products // Fetch available products
@@ -179,6 +185,7 @@ class IAPManager: ObservableObject {
// Check for active subscription // Check for active subscription
let hasActiveSubscription = await checkForActiveSubscription() let hasActiveSubscription = await checkForActiveSubscription()
AppLogger.iap.debug("checkSubscriptionStatus: hasActiveSubscription=\(hasActiveSubscription), state after check=\(String(describing: self.state))")
if hasActiveSubscription { if hasActiveSubscription {
// State already set in checkForActiveSubscription cache it // State already set in checkForActiveSubscription cache it
@@ -195,29 +202,24 @@ class IAPManager: ObservableObject {
return 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. // Live check found no active subscription.
// Before downgrading, check if cached expiration is still valid (covers offline/transient failures). // Only trust cached expiration if we're offline (products failed to load).
let cachedExpiration = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) as? Date // If products loaded successfully, StoreKit had server access and the live result is authoritative.
if let expiration = cachedExpiration, expiration > Date() { if availableProducts.isEmpty {
state = .subscribed(expirationDate: expiration, willAutoRenew: false) let cachedExpiration = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) as? Date
syncSubscriptionStatusToUserDefaults() AppLogger.iap.debug("checkSubscriptionStatus: offline (no products) — cachedExpiration=\(cachedExpiration?.description ?? "nil")")
trackSubscriptionAnalytics(source: "status_check_cached")
return 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 // 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) cacheSubscriptionExpiration(nil)
updateTrialState() updateTrialState()
trackSubscriptionAnalytics(source: "status_check_fallback") trackSubscriptionAnalytics(source: "status_check_fallback")
@@ -237,16 +239,24 @@ class IAPManager: ObservableObject {
/// Restore cached subscription state on launch (before async check completes) /// Restore cached subscription state on launch (before async check completes)
private func restoreCachedSubscriptionState() { private func restoreCachedSubscriptionState() {
let hasActive = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) 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 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 we have a cached expiration and it's still in the future, restore subscribed state
if let expiration = cachedExpiration, expiration > Date() { if let expiration = cachedExpiration, expiration > Date() {
AppLogger.iap.debug("restoreCachedSubscriptionState: restoring .subscribed (cached expiration in future)")
state = .subscribed(expirationDate: expiration, willAutoRenew: false) state = .subscribed(expirationDate: expiration, willAutoRenew: false)
} else if cachedExpiration == nil && hasActive { } else if cachedExpiration == nil && hasActive {
// Had access but no expiration cached (e.g., upgraded from older version) trust it // 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) 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 { func restore(source: String = "settings") async {
do { do {
try await AppStore.sync() try await AppStore.sync()
await checkSubscriptionStatus() await checkSubscriptionStatus(force: true)
AnalyticsManager.shared.trackPurchaseRestored(source: source) AnalyticsManager.shared.trackPurchaseRestored(source: source)
trackSubscriptionAnalytics(source: "restore") trackSubscriptionAnalytics(source: "restore")
} catch { } catch {
@@ -377,10 +387,13 @@ class IAPManager: ObservableObject {
private func updateTrialState() { private func updateTrialState() {
let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0 let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0
let daysRemaining = trialDays - daysSinceInstall let daysRemaining = trialDays - daysSinceInstall
AppLogger.iap.debug("updateTrialState: firstLaunchDate=\(self.firstLaunchDate), daysSinceInstall=\(daysSinceInstall), daysRemaining=\(daysRemaining)")
if daysRemaining > 0 { if daysRemaining > 0 {
AppLogger.iap.debug("updateTrialState: setting .inTrial(daysRemaining: \(daysRemaining))")
state = .inTrial(daysRemaining: daysRemaining) state = .inTrial(daysRemaining: daysRemaining)
} else { } else {
AppLogger.iap.debug("updateTrialState: setting .trialExpired")
state = .trialExpired state = .trialExpired
} }
@@ -499,7 +512,7 @@ class IAPManager: ObservableObject {
guard case .verified(let transaction) = result else { continue } guard case .verified(let transaction) = result else { continue }
await transaction.finish() await transaction.finish()
await self?.checkSubscriptionStatus() await self?.checkSubscriptionStatus(force: true)
} }
} }
} }

View File

@@ -71,7 +71,7 @@ struct ReflectSubscriptionStoreView: View {
case .success(.success(_)): case .success(.success(_)):
AnalyticsManager.shared.trackPurchaseCompleted(productId: product.id, source: source) AnalyticsManager.shared.trackPurchaseCompleted(productId: product.id, source: source)
Task { @MainActor in Task { @MainActor in
await iapManager.checkSubscriptionStatus() await iapManager.checkSubscriptionStatus(force: true)
iapManager.trackSubscriptionAnalytics(source: "purchase_success") iapManager.trackSubscriptionAnalytics(source: "purchase_success")
} }
dismiss() dismiss()