|
@@ -38,6 +38,8 @@ 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?
|
|
@@ -46,6 +48,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
@Published private(set) var hasResolvedPremiumStatus = false
|
|
@Published private(set) var hasResolvedPremiumStatus = false
|
|
|
@Published private(set) var activePlan: PaywallPlan?
|
|
@Published private(set) var activePlan: PaywallPlan?
|
|
|
@Published private(set) var activeRecurringPlan: PaywallPlan?
|
|
@Published private(set) var activeRecurringPlan: PaywallPlan?
|
|
|
|
|
+ @Published private(set) var introOfferEligibleByPlan: [PaywallPlan: Bool] = [:]
|
|
|
@Published var purchaseError: PurchaseError?
|
|
@Published var purchaseError: PurchaseError?
|
|
|
@Published var subscriptionSuccessMessage: String?
|
|
@Published var subscriptionSuccessMessage: String?
|
|
|
@Published var showsCancelSubscriptionReminder = false
|
|
@Published var showsCancelSubscriptionReminder = false
|
|
@@ -59,6 +62,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
|
|
|
|
init() {
|
|
init() {
|
|
|
|
|
+ hasPremiumAccess = UserDefaults.standard.bool(forKey: Self.cachedPremiumAccessKey)
|
|
|
Task { await listenForTransactionUpdates() }
|
|
Task { await listenForTransactionUpdates() }
|
|
|
Task { await refreshPremiumAccess() }
|
|
Task { await refreshPremiumAccess() }
|
|
|
Task { await loadProducts(presentLoadingUI: false) }
|
|
Task { await loadProducts(presentLoadingUI: false) }
|
|
@@ -114,11 +118,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func tagText(for plan: PaywallPlan) -> String? {
|
|
func tagText(for plan: PaywallPlan) -> String? {
|
|
|
- guard let product = product(for: plan) else { return nil }
|
|
|
|
|
-
|
|
|
|
|
- if let subscription = product.subscription,
|
|
|
|
|
- let intro = subscription.introductoryOffer,
|
|
|
|
|
- intro.paymentMode == .freeTrial {
|
|
|
|
|
|
|
+ if hasFreeTrialOffer(for: plan) {
|
|
|
return "Free Trial"
|
|
return "Free Trial"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -135,10 +135,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func ctaTitle(for plan: PaywallPlan) -> String {
|
|
func ctaTitle(for plan: PaywallPlan) -> String {
|
|
|
- if let product = product(for: plan),
|
|
|
|
|
- let subscription = product.subscription,
|
|
|
|
|
- let intro = subscription.introductoryOffer,
|
|
|
|
|
- intro.paymentMode == .freeTrial {
|
|
|
|
|
|
|
+ if hasFreeTrialOffer(for: plan) {
|
|
|
return "START FOR FREE"
|
|
return "START FOR FREE"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -155,9 +152,9 @@ final class SubscriptionManager: ObservableObject {
|
|
|
return "Loading pricing…"
|
|
return "Loading pricing…"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if let subscription = product.subscription,
|
|
|
|
|
- let intro = subscription.introductoryOffer,
|
|
|
|
|
- intro.paymentMode == .freeTrial {
|
|
|
|
|
|
|
+ if hasFreeTrialOffer(for: plan),
|
|
|
|
|
+ let subscription = product.subscription,
|
|
|
|
|
+ let intro = subscription.introductoryOffer {
|
|
|
let trialPeriod = formattedPeriod(intro.period)
|
|
let trialPeriod = formattedPeriod(intro.period)
|
|
|
let billingPeriod = formattedSubscriptionPeriod(subscription.subscriptionPeriod)
|
|
let billingPeriod = formattedSubscriptionPeriod(subscription.subscriptionPeriod)
|
|
|
return "\(trialPeriod) Free Trial, then \(product.displayPrice) per \(billingPeriod)"
|
|
return "\(trialPeriod) Free Trial, then \(product.displayPrice) per \(billingPeriod)"
|
|
@@ -192,6 +189,15 @@ final class SubscriptionManager: ObservableObject {
|
|
|
return "Save \(rounded)%"
|
|
return "Save \(rounded)%"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ func hasFreeTrialOffer(for plan: PaywallPlan) -> Bool {
|
|
|
|
|
+ guard introOfferEligibleByPlan[plan] == true else { return false }
|
|
|
|
|
+ guard let product = product(for: plan),
|
|
|
|
|
+ let subscription = product.subscription,
|
|
|
|
|
+ let intro = subscription.introductoryOffer,
|
|
|
|
|
+ intro.paymentMode == .freeTrial else { return false }
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
func purchase(_ plan: PaywallPlan) async -> Bool {
|
|
func purchase(_ plan: PaywallPlan) async -> Bool {
|
|
|
purchaseError = nil
|
|
purchaseError = nil
|
|
|
|
|
|
|
@@ -245,6 +251,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
await transaction.finish()
|
|
await transaction.finish()
|
|
|
|
|
+ applyOptimisticPremiumAccess(for: transaction)
|
|
|
await refreshPremiumAccessWithRetry()
|
|
await refreshPremiumAccessWithRetry()
|
|
|
if hasPremiumAccess {
|
|
if hasPremiumAccess {
|
|
|
NotificationCenter.default.post(name: .subscriptionPurchased, object: nil)
|
|
NotificationCenter.default.post(name: .subscriptionPurchased, object: nil)
|
|
@@ -318,6 +325,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
|
|
|
|
|
if !missingAny, presentLoadingUI != true {
|
|
if !missingAny, presentLoadingUI != true {
|
|
|
purchaseError = nil
|
|
purchaseError = nil
|
|
|
|
|
+ await refreshIntroOfferEligibility()
|
|
|
await refreshPremiumAccess()
|
|
await refreshPremiumAccess()
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -339,6 +347,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
map[product.id] = product
|
|
map[product.id] = product
|
|
|
}
|
|
}
|
|
|
productsByID = map
|
|
productsByID = map
|
|
|
|
|
+ await refreshIntroOfferEligibility()
|
|
|
await refreshPremiumAccess()
|
|
await refreshPremiumAccess()
|
|
|
} catch is ProductLoadError {
|
|
} catch is ProductLoadError {
|
|
|
if presentLoadingUI == true {
|
|
if presentLoadingUI == true {
|
|
@@ -404,14 +413,44 @@ final class SubscriptionManager: ObservableObject {
|
|
|
activePlan = resolvedPlan
|
|
activePlan = resolvedPlan
|
|
|
activeRecurringPlan = resolvedRecurringPlan
|
|
activeRecurringPlan = resolvedRecurringPlan
|
|
|
hasResolvedPremiumStatus = true
|
|
hasResolvedPremiumStatus = true
|
|
|
|
|
+ UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumAccessKey)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func applyOptimisticPremiumAccess(for transaction: Transaction) {
|
|
|
|
|
+ guard SubscriptionProductID.all.contains(transaction.productID),
|
|
|
|
|
+ transaction.revocationDate == nil,
|
|
|
|
|
+ let plan = SubscriptionProductID.plan(for: transaction.productID) else { return }
|
|
|
|
|
+
|
|
|
|
|
+ hasPremiumAccess = true
|
|
|
|
|
+ activePlan = plan
|
|
|
|
|
+ if SubscriptionProductID.recurring.contains(transaction.productID) {
|
|
|
|
|
+ hasManageableSubscription = true
|
|
|
|
|
+ activeRecurringPlan = plan
|
|
|
|
|
+ }
|
|
|
|
|
+ UserDefaults.standard.set(true, forKey: Self.cachedPremiumAccessKey)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func refreshIntroOfferEligibility() async {
|
|
|
|
|
+ var eligibility: [PaywallPlan: Bool] = [:]
|
|
|
|
|
+
|
|
|
|
|
+ for plan in PaywallPlan.allCases {
|
|
|
|
|
+ guard let product = productsByID[plan.productID],
|
|
|
|
|
+ let subscription = product.subscription,
|
|
|
|
|
+ let intro = subscription.introductoryOffer,
|
|
|
|
|
+ intro.paymentMode == .freeTrial else { continue }
|
|
|
|
|
+
|
|
|
|
|
+ eligibility[plan] = await subscription.isEligibleForIntroOffer
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ introOfferEligibleByPlan = eligibility
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func refreshPremiumAccessWithRetry() async {
|
|
private func refreshPremiumAccessWithRetry() async {
|
|
|
await refreshPremiumAccess()
|
|
await refreshPremiumAccess()
|
|
|
guard !hasPremiumAccess else { return }
|
|
guard !hasPremiumAccess else { return }
|
|
|
|
|
|
|
|
- for _ in 0..<3 {
|
|
|
|
|
- try? await Task.sleep(nanoseconds: 400_000_000)
|
|
|
|
|
|
|
+ for _ in 0..<6 {
|
|
|
|
|
+ try? await Task.sleep(nanoseconds: 500_000_000)
|
|
|
await refreshPremiumAccess()
|
|
await refreshPremiumAccess()
|
|
|
if hasPremiumAccess { return }
|
|
if hasPremiumAccess { return }
|
|
|
}
|
|
}
|
|
@@ -424,6 +463,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
} 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
|
|
return true
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -442,7 +482,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
- return true
|
|
|
|
|
|
|
+ return false
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|