|
@@ -41,8 +41,6 @@ final class SubscriptionManager: ObservableObject {
|
|
|
case timeout
|
|
case timeout
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private static let cachedPremiumAccessKey = "subscription.cachedPremiumAccess"
|
|
|
|
|
-
|
|
|
|
|
@Published private(set) var productsByID: [String: Product] = [:]
|
|
@Published private(set) var productsByID: [String: Product] = [:]
|
|
|
@Published private(set) var isLoadingProducts = false
|
|
@Published private(set) var isLoadingProducts = false
|
|
|
@Published private(set) var purchasingPlan: PaywallPlan?
|
|
@Published private(set) var purchasingPlan: PaywallPlan?
|
|
@@ -66,8 +64,13 @@ final class SubscriptionManager: ObservableObject {
|
|
|
max(0, Self.maxFreeAIUses - freeAIUsesConsumed)
|
|
max(0, Self.maxFreeAIUses - freeAIUsesConsumed)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// Premium access confirmed by StoreKit after launch or purchase.
|
|
|
|
|
+ var isPremiumActive: Bool {
|
|
|
|
|
+ hasResolvedPremiumStatus && hasPremiumAccess
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
var canUseAIFeatures: Bool {
|
|
var canUseAIFeatures: Bool {
|
|
|
- hasPremiumAccess || remainingFreeAIUses > 0
|
|
|
|
|
|
|
+ isPremiumActive || remainingFreeAIUses > 0
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private var inFlightProductLoad: Task<Void, Never>?
|
|
private var inFlightProductLoad: Task<Void, Never>?
|
|
@@ -76,7 +79,6 @@ final class SubscriptionManager: ObservableObject {
|
|
|
|
|
|
|
|
init(defaults: UserDefaults = .standard) {
|
|
init(defaults: UserDefaults = .standard) {
|
|
|
self.defaults = defaults
|
|
self.defaults = defaults
|
|
|
- hasPremiumAccess = defaults.bool(forKey: Self.cachedPremiumAccessKey)
|
|
|
|
|
freeAIUsesConsumed = min(
|
|
freeAIUsesConsumed = min(
|
|
|
defaults.integer(forKey: Self.freeAIUsesConsumedKey),
|
|
defaults.integer(forKey: Self.freeAIUsesConsumedKey),
|
|
|
Self.maxFreeAIUses
|
|
Self.maxFreeAIUses
|
|
@@ -91,7 +93,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func recordSuccessfulAIUse() {
|
|
func recordSuccessfulAIUse() {
|
|
|
- guard !hasPremiumAccess else { return }
|
|
|
|
|
|
|
+ guard !isPremiumActive else { return }
|
|
|
guard freeAIUsesConsumed < Self.maxFreeAIUses else { return }
|
|
guard freeAIUsesConsumed < Self.maxFreeAIUses else { return }
|
|
|
|
|
|
|
|
freeAIUsesConsumed += 1
|
|
freeAIUsesConsumed += 1
|
|
@@ -239,7 +241,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
func purchase(_ plan: PaywallPlan) async -> Bool {
|
|
func purchase(_ plan: PaywallPlan) async -> Bool {
|
|
|
purchaseError = nil
|
|
purchaseError = nil
|
|
|
|
|
|
|
|
- if hasPremiumAccess {
|
|
|
|
|
|
|
+ if isPremiumActive {
|
|
|
if !hasManageableSubscription {
|
|
if !hasManageableSubscription {
|
|
|
return true
|
|
return true
|
|
|
}
|
|
}
|
|
@@ -324,7 +326,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
func restorePurchases() async {
|
|
func restorePurchases() async {
|
|
|
purchaseError = nil
|
|
purchaseError = nil
|
|
|
subscriptionSuccessMessage = nil
|
|
subscriptionSuccessMessage = nil
|
|
|
- let hadPremium = hasPremiumAccess
|
|
|
|
|
|
|
+ let hadPremium = isPremiumActive
|
|
|
do {
|
|
do {
|
|
|
try await AppStore.sync()
|
|
try await AppStore.sync()
|
|
|
await refreshPremiumAccess()
|
|
await refreshPremiumAccess()
|
|
@@ -431,7 +433,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
guard let plan = SubscriptionProductID.plan(for: transaction.productID) else { continue }
|
|
guard let plan = SubscriptionProductID.plan(for: transaction.productID) else { continue }
|
|
|
|
|
|
|
|
if SubscriptionProductID.recurring.contains(transaction.productID) {
|
|
if SubscriptionProductID.recurring.contains(transaction.productID) {
|
|
|
- guard await isRecurringSubscriptionEntitled(productID: transaction.productID) else {
|
|
|
|
|
|
|
+ guard await isRecurringSubscriptionEntitled(transaction: transaction) else {
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|
|
|
manageableSubscription = true
|
|
manageableSubscription = true
|
|
@@ -451,7 +453,6 @@ final class SubscriptionManager: ObservableObject {
|
|
|
activePlan = resolvedPlan
|
|
activePlan = resolvedPlan
|
|
|
activeRecurringPlan = resolvedRecurringPlan
|
|
activeRecurringPlan = resolvedRecurringPlan
|
|
|
hasResolvedPremiumStatus = true
|
|
hasResolvedPremiumStatus = true
|
|
|
- defaults.set(hasPremium, forKey: Self.cachedPremiumAccessKey)
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func applyOptimisticPremiumAccess(for transaction: Transaction) {
|
|
private func applyOptimisticPremiumAccess(for transaction: Transaction) {
|
|
@@ -460,12 +461,12 @@ final class SubscriptionManager: ObservableObject {
|
|
|
let plan = SubscriptionProductID.plan(for: transaction.productID) else { return }
|
|
let plan = SubscriptionProductID.plan(for: transaction.productID) else { return }
|
|
|
|
|
|
|
|
hasPremiumAccess = true
|
|
hasPremiumAccess = true
|
|
|
|
|
+ hasResolvedPremiumStatus = true
|
|
|
activePlan = plan
|
|
activePlan = plan
|
|
|
if SubscriptionProductID.recurring.contains(transaction.productID) {
|
|
if SubscriptionProductID.recurring.contains(transaction.productID) {
|
|
|
hasManageableSubscription = true
|
|
hasManageableSubscription = true
|
|
|
activeRecurringPlan = plan
|
|
activeRecurringPlan = plan
|
|
|
}
|
|
}
|
|
|
- defaults.set(true, forKey: Self.cachedPremiumAccessKey)
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func refreshIntroOfferEligibility() async {
|
|
private func refreshIntroOfferEligibility() async {
|
|
@@ -494,34 +495,46 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func isRecurringSubscriptionEntitled(productID: String) async -> Bool {
|
|
|
|
|
|
|
+ private func isRecurringSubscriptionEntitled(transaction: Transaction) async -> Bool {
|
|
|
|
|
+ let productID = transaction.productID
|
|
|
let product: Product?
|
|
let product: Product?
|
|
|
if let cached = productsByID[productID] {
|
|
if let cached = productsByID[productID] {
|
|
|
product = cached
|
|
product = cached
|
|
|
} else if let loaded = try? await Product.products(for: [productID]).first {
|
|
} else if let loaded = try? await Product.products(for: [productID]).first {
|
|
|
product = loaded
|
|
product = loaded
|
|
|
} else {
|
|
} else {
|
|
|
- // Entitlement is already in currentEntitlements; defer to StoreKit when product metadata is unavailable.
|
|
|
|
|
- return true
|
|
|
|
|
|
|
+ product = nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- guard let product, let subscription = product.subscription else { return true }
|
|
|
|
|
-
|
|
|
|
|
- do {
|
|
|
|
|
- let statuses = try await subscription.status
|
|
|
|
|
- return statuses.contains { status in
|
|
|
|
|
- switch status.state {
|
|
|
|
|
- case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
|
|
|
|
|
|
|
+ if let product, let subscription = product.subscription {
|
|
|
|
|
+ do {
|
|
|
|
|
+ let statuses = try await subscription.status
|
|
|
|
|
+ if statuses.contains(where: {
|
|
|
|
|
+ switch $0.state {
|
|
|
|
|
+ case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
|
|
|
|
|
+ return true
|
|
|
|
|
+ case .expired, .revoked:
|
|
|
|
|
+ return false
|
|
|
|
|
+ default:
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ }) {
|
|
|
return true
|
|
return true
|
|
|
- case .expired, .revoked:
|
|
|
|
|
- return false
|
|
|
|
|
- default:
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+ if statuses.contains(where: { $0.state == .expired || $0.state == .revoked }) {
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Fall through to expiration-date check when status is unavailable.
|
|
|
}
|
|
}
|
|
|
- } catch {
|
|
|
|
|
- return false
|
|
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // Verified entitlement with a future expiration is still active when metadata/status is unreachable.
|
|
|
|
|
+ if let expirationDate = transaction.expirationDate {
|
|
|
|
|
+ return expirationDate > Date()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func observeAppLifecycle() {
|
|
private func observeAppLifecycle() {
|