|
@@ -102,21 +102,14 @@ final class SubscriptionManager: ObservableObject {
|
|
|
Task { await listenForTransactionUpdates() }
|
|
Task { await listenForTransactionUpdates() }
|
|
|
|
|
|
|
|
initialEntitlementTask = Task {
|
|
initialEntitlementTask = Task {
|
|
|
|
|
+ // Local StoreKit entitlements only on launch. AppStore.sync() prompts for
|
|
|
|
|
+ // Apple ID and fires on every relaunch when purchase flags are cached.
|
|
|
await refreshPremiumAccess()
|
|
await refreshPremiumAccess()
|
|
|
|
|
|
|
|
- let shouldReconcileWithAppStore = launchedWithCachedPremium
|
|
|
|
|
- || (!hasPremiumAccess && hasEverPurchasedPremium)
|
|
|
|
|
-
|
|
|
|
|
- if shouldReconcileWithAppStore {
|
|
|
|
|
- do {
|
|
|
|
|
- try await syncAppStoreWithTimeout(seconds: 15)
|
|
|
|
|
- } catch {
|
|
|
|
|
- #if DEBUG
|
|
|
|
|
- print("[SubscriptionManager] App Store sync failed during premium reconciliation")
|
|
|
|
|
- #endif
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if launchedWithCachedPremium {
|
|
|
launchedWithCachedPremium = false
|
|
launchedWithCachedPremium = false
|
|
|
- await refreshPremiumAccess()
|
|
|
|
|
|
|
+ hasResolvedPremiumStatus = true
|
|
|
|
|
+ persistPremiumCache()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
await loadProducts(presentLoadingUI: false)
|
|
await loadProducts(presentLoadingUI: false)
|
|
@@ -491,21 +484,26 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func markAsHavingPurchasedPremium() {
|
|
private func markAsHavingPurchasedPremium() {
|
|
|
- guard !hasEverPurchasedPremium else { return }
|
|
|
|
|
|
|
+ let wasAlreadyMarked = hasEverPurchasedPremium
|
|
|
hasEverPurchasedPremium = true
|
|
hasEverPurchasedPremium = true
|
|
|
trialDisplayByPlan = [:]
|
|
trialDisplayByPlan = [:]
|
|
|
UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
|
|
UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
|
|
|
- AIFreeUsageManager.shared.forfeitFreeUses()
|
|
|
|
|
|
|
+ if !wasAlreadyMarked {
|
|
|
|
|
+ AIFreeUsageManager.shared.forfeitFreeUses()
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /// Aligns local purchase flags with StoreKit history.
|
|
|
|
|
- /// Purchase history is sticky: once a user has bought, they keep that status even if
|
|
|
|
|
- /// StoreKit history is cleared (e.g. sandbox transaction delete).
|
|
|
|
|
|
|
+ /// Syncs `hasEverPurchasedPremium` with StoreKit history.
|
|
|
|
|
+ /// - Refund: revoked transactions remain in history → flag stays `true` → no trial badge.
|
|
|
|
|
+ /// - Sandbox reset: no transactions → flag clears → trial badge returns for testing.
|
|
|
private func syncPurchaseHistory() async {
|
|
private func syncPurchaseHistory() async {
|
|
|
|
|
+ var foundPurchase = false
|
|
|
|
|
+
|
|
|
for await result in Transaction.all {
|
|
for await result in Transaction.all {
|
|
|
guard case .verified(let transaction) = result else { continue }
|
|
guard case .verified(let transaction) = result else { continue }
|
|
|
guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
|
|
guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
|
|
|
|
|
|
|
|
|
|
+ foundPurchase = true
|
|
|
if !hasEverPurchasedPremium {
|
|
if !hasEverPurchasedPremium {
|
|
|
hasEverPurchasedPremium = true
|
|
hasEverPurchasedPremium = true
|
|
|
UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
|
|
UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
|
|
@@ -513,9 +511,15 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ if !foundPurchase, hasEverPurchasedPremium {
|
|
|
|
|
+ hasEverPurchasedPremium = false
|
|
|
|
|
+ UserDefaults.standard.set(false, forKey: Self.cachedHasEverPurchasedKey)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func refreshTrialOffers() async {
|
|
private func refreshTrialOffers() async {
|
|
|
|
|
+ // Trial badge is first-time users only — never after a purchase or refund.
|
|
|
guard isEligibleForIntroTrial else {
|
|
guard isEligibleForIntroTrial else {
|
|
|
trialDisplayByPlan = [:]
|
|
trialDisplayByPlan = [:]
|
|
|
return
|
|
return
|
|
@@ -534,15 +538,18 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
|
|
private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
|
|
|
- guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return nil }
|
|
|
|
|
- guard let product = product(for: plan),
|
|
|
|
|
- let subscription = product.subscription else {
|
|
|
|
|
- return nil
|
|
|
|
|
|
|
+ let config = PaywallConfigService.shared.config
|
|
|
|
|
+ guard plan == config.trialEligiblePlan else { return nil }
|
|
|
|
|
+ guard let fallback = config.trial.fallbackDuration else { return nil }
|
|
|
|
|
+
|
|
|
|
|
+ if let product = product(for: plan),
|
|
|
|
|
+ let subscription = product.subscription,
|
|
|
|
|
+ await subscription.isEligibleForIntroOffer,
|
|
|
|
|
+ let offer = subscription.introductoryOffer {
|
|
|
|
|
+ return PaywallTrialDisplay.from(offer: offer, config: config)
|
|
|
}
|
|
}
|
|
|
- guard await subscription.isEligibleForIntroOffer else { return nil }
|
|
|
|
|
|
|
|
|
|
- guard let offer = subscription.introductoryOffer else { return nil }
|
|
|
|
|
- return PaywallTrialDisplay.from(offer: offer, config: PaywallConfigService.shared.config)
|
|
|
|
|
|
|
+ return PaywallTrialDisplay.from(fallback: fallback, config: config)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func persistPremiumCache() {
|
|
private func persistPremiumCache() {
|