|
@@ -44,18 +44,25 @@ final class SubscriptionManager: ObservableObject {
|
|
|
@Published private(set) var hasPremiumAccess = false
|
|
@Published private(set) var hasPremiumAccess = false
|
|
|
@Published private(set) var hasManageableSubscription = false
|
|
@Published private(set) var hasManageableSubscription = false
|
|
|
@Published private(set) var hasResolvedPremiumStatus = false
|
|
@Published private(set) var hasResolvedPremiumStatus = false
|
|
|
|
|
+ @Published private(set) var activePlan: PaywallPlan?
|
|
|
|
|
+ @Published private(set) var activeRecurringPlan: PaywallPlan?
|
|
|
@Published var purchaseError: PurchaseError?
|
|
@Published var purchaseError: PurchaseError?
|
|
|
|
|
+ @Published var subscriptionSuccessMessage: String?
|
|
|
|
|
+ @Published var showsCancelSubscriptionReminder = false
|
|
|
|
|
+ @Published private(set) var recurringPlanPendingCancellation: PaywallPlan?
|
|
|
|
|
|
|
|
var hasAllProductsLoaded: Bool {
|
|
var hasAllProductsLoaded: Bool {
|
|
|
SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
|
|
SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private var inFlightProductLoad: Task<Void, Never>?
|
|
private var inFlightProductLoad: Task<Void, Never>?
|
|
|
|
|
+ private var cancellables = Set<AnyCancellable>()
|
|
|
|
|
|
|
|
init() {
|
|
init() {
|
|
|
Task { await listenForTransactionUpdates() }
|
|
Task { await listenForTransactionUpdates() }
|
|
|
Task { await refreshPremiumAccess() }
|
|
Task { await refreshPremiumAccess() }
|
|
|
Task { await loadProducts(presentLoadingUI: false) }
|
|
Task { await loadProducts(presentLoadingUI: false) }
|
|
|
|
|
+ observeAppLifecycle()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func loadProducts(presentLoadingUI: Bool? = nil) async {
|
|
func loadProducts(presentLoadingUI: Bool? = nil) async {
|
|
@@ -188,6 +195,18 @@ final class SubscriptionManager: ObservableObject {
|
|
|
func purchase(_ plan: PaywallPlan) async -> Bool {
|
|
func purchase(_ plan: PaywallPlan) async -> Bool {
|
|
|
purchaseError = nil
|
|
purchaseError = nil
|
|
|
|
|
|
|
|
|
|
+ if hasPremiumAccess {
|
|
|
|
|
+ if !hasManageableSubscription {
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ if let activePlan, plan == activePlan {
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ if let activeRecurringPlan, plan == activeRecurringPlan {
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
var product = productsByID[plan.productID]
|
|
var product = productsByID[plan.productID]
|
|
|
if product == nil {
|
|
if product == nil {
|
|
|
await loadProducts(presentLoadingUI: false)
|
|
await loadProducts(presentLoadingUI: false)
|
|
@@ -214,6 +233,9 @@ final class SubscriptionManager: ObservableObject {
|
|
|
purchasingPlan = plan
|
|
purchasingPlan = plan
|
|
|
defer { purchasingPlan = nil }
|
|
defer { purchasingPlan = nil }
|
|
|
|
|
|
|
|
|
|
+ let shouldRemindToCancelSubscription = plan == .lifetime && hasManageableSubscription
|
|
|
|
|
+ let recurringPlanBeforePurchase = activeRecurringPlan ?? activePlan
|
|
|
|
|
+
|
|
|
do {
|
|
do {
|
|
|
let result = try await product.purchase()
|
|
let result = try await product.purchase()
|
|
|
switch result {
|
|
switch result {
|
|
@@ -222,16 +244,25 @@ final class SubscriptionManager: ObservableObject {
|
|
|
purchaseError = .purchaseNotVerified
|
|
purchaseError = .purchaseNotVerified
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
- NotificationCenter.default.post(name: .subscriptionPurchased, object: nil)
|
|
|
|
|
await transaction.finish()
|
|
await transaction.finish()
|
|
|
- await refreshPremiumAccess()
|
|
|
|
|
- return hasPremiumAccess
|
|
|
|
|
|
|
+ await refreshPremiumAccessWithRetry()
|
|
|
|
|
+ if hasPremiumAccess {
|
|
|
|
|
+ NotificationCenter.default.post(name: .subscriptionPurchased, object: nil)
|
|
|
|
|
+ }
|
|
|
|
|
+ if shouldRemindToCancelSubscription {
|
|
|
|
|
+ recurringPlanPendingCancellation = recurringPlanBeforePurchase
|
|
|
|
|
+ showsCancelSubscriptionReminder = true
|
|
|
|
|
+ } else if !hasPremiumAccess {
|
|
|
|
|
+ subscriptionSuccessMessage = "Purchase successful. If premium features don't unlock right away, tap Restore Purchases."
|
|
|
|
|
+ }
|
|
|
|
|
+ return hasPremiumAccess || shouldRemindToCancelSubscription
|
|
|
case .userCancelled:
|
|
case .userCancelled:
|
|
|
return false
|
|
return false
|
|
|
case .pending:
|
|
case .pending:
|
|
|
purchaseError = .purchasePending
|
|
purchaseError = .purchasePending
|
|
|
return false
|
|
return false
|
|
|
@unknown default:
|
|
@unknown default:
|
|
|
|
|
+ purchaseError = .generic
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
@@ -247,10 +278,16 @@ final class SubscriptionManager: ObservableObject {
|
|
|
|
|
|
|
|
func restorePurchases() async {
|
|
func restorePurchases() async {
|
|
|
purchaseError = nil
|
|
purchaseError = nil
|
|
|
|
|
+ subscriptionSuccessMessage = nil
|
|
|
|
|
+ let hadPremium = hasPremiumAccess
|
|
|
do {
|
|
do {
|
|
|
try await AppStore.sync()
|
|
try await AppStore.sync()
|
|
|
await refreshPremiumAccess()
|
|
await refreshPremiumAccess()
|
|
|
- if !hasPremiumAccess {
|
|
|
|
|
|
|
+ if hasPremiumAccess {
|
|
|
|
|
+ if !hadPremium {
|
|
|
|
|
+ subscriptionSuccessMessage = "Your purchases have been restored."
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
purchaseError = .noActiveSubscriptions
|
|
purchaseError = .noActiveSubscriptions
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
@@ -258,6 +295,23 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ func clearSubscriptionSuccessMessage() {
|
|
|
|
|
+ subscriptionSuccessMessage = nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func clearCancelSubscriptionReminder() {
|
|
|
|
|
+ showsCancelSubscriptionReminder = false
|
|
|
|
|
+ recurringPlanPendingCancellation = nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var cancelSubscriptionReminderMessage: String {
|
|
|
|
|
+ if let plan = recurringPlanPendingCancellation {
|
|
|
|
|
+ let planName = displayTitle(for: plan)
|
|
|
|
|
+ return "Your lifetime access is active. Your \(planName) subscription is still active and will renew automatically. Cancel it in the App Store to avoid future charges."
|
|
|
|
|
+ }
|
|
|
|
|
+ return "Your lifetime access is active. Your recurring subscription is still active and will renew automatically. Cancel it in the App Store to avoid future charges."
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func performLoadProducts(presentLoadingUI: Bool?) async {
|
|
private func performLoadProducts(presentLoadingUI: Bool?) async {
|
|
|
let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
|
|
let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
|
|
|
let shouldShowLoading = presentLoadingUI ?? missingAny
|
|
let shouldShowLoading = presentLoadingUI ?? missingAny
|
|
@@ -319,74 +373,99 @@ final class SubscriptionManager: ObservableObject {
|
|
|
private func refreshPremiumAccess() async {
|
|
private func refreshPremiumAccess() async {
|
|
|
var hasPremium = false
|
|
var hasPremium = false
|
|
|
var manageableSubscription = false
|
|
var manageableSubscription = false
|
|
|
|
|
+ var resolvedPlan: PaywallPlan?
|
|
|
|
|
+ var resolvedRecurringPlan: PaywallPlan?
|
|
|
|
|
|
|
|
for await result in Transaction.currentEntitlements {
|
|
for await result in Transaction.currentEntitlements {
|
|
|
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 }
|
|
|
guard transaction.revocationDate == nil else { continue }
|
|
guard transaction.revocationDate == nil else { continue }
|
|
|
|
|
|
|
|
- if let expirationDate = transaction.expirationDate, expirationDate < Date() {
|
|
|
|
|
- continue
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ guard let plan = SubscriptionProductID.plan(for: transaction.productID) else { continue }
|
|
|
|
|
|
|
|
- hasPremium = true
|
|
|
|
|
if SubscriptionProductID.recurring.contains(transaction.productID) {
|
|
if SubscriptionProductID.recurring.contains(transaction.productID) {
|
|
|
|
|
+ guard await isRecurringSubscriptionEntitled(productID: transaction.productID) else {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
manageableSubscription = true
|
|
manageableSubscription = true
|
|
|
|
|
+ if resolvedRecurringPlan == nil {
|
|
|
|
|
+ resolvedRecurringPlan = plan
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- if !hasPremium {
|
|
|
|
|
- hasPremium = await hasActiveSubscriptionStatus()
|
|
|
|
|
- if hasPremium {
|
|
|
|
|
- manageableSubscription = true
|
|
|
|
|
|
|
+ hasPremium = true
|
|
|
|
|
+ if resolvedPlan == nil {
|
|
|
|
|
+ resolvedPlan = plan
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
hasPremiumAccess = hasPremium
|
|
hasPremiumAccess = hasPremium
|
|
|
hasManageableSubscription = manageableSubscription
|
|
hasManageableSubscription = manageableSubscription
|
|
|
|
|
+ activePlan = resolvedPlan
|
|
|
|
|
+ activeRecurringPlan = resolvedRecurringPlan
|
|
|
hasResolvedPremiumStatus = true
|
|
hasResolvedPremiumStatus = true
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func hasActiveSubscriptionStatus() async -> Bool {
|
|
|
|
|
- if productsByID.isEmpty {
|
|
|
|
|
- do {
|
|
|
|
|
- let loaded = try await Product.products(for: SubscriptionProductID.all)
|
|
|
|
|
- var map: [String: Product] = [:]
|
|
|
|
|
- for product in loaded { map[product.id] = product }
|
|
|
|
|
- productsByID = map
|
|
|
|
|
- } catch {
|
|
|
|
|
- return false
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ private func refreshPremiumAccessWithRetry() async {
|
|
|
|
|
+ await refreshPremiumAccess()
|
|
|
|
|
+ guard !hasPremiumAccess else { return }
|
|
|
|
|
+
|
|
|
|
|
+ for _ in 0..<3 {
|
|
|
|
|
+ try? await Task.sleep(nanoseconds: 400_000_000)
|
|
|
|
|
+ await refreshPremiumAccess()
|
|
|
|
|
+ if hasPremiumAccess { return }
|
|
|
}
|
|
}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func isRecurringSubscriptionEntitled(productID: String) async -> Bool {
|
|
|
|
|
+ let product: Product?
|
|
|
|
|
+ if let cached = productsByID[productID] {
|
|
|
|
|
+ product = cached
|
|
|
|
|
+ } else if let loaded = try? await Product.products(for: [productID]).first {
|
|
|
|
|
+ product = loaded
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ guard let product, let subscription = product.subscription else { return true }
|
|
|
|
|
|
|
|
- for product in productsByID.values {
|
|
|
|
|
- guard let subscription = product.subscription else { continue }
|
|
|
|
|
- do {
|
|
|
|
|
- let statuses = try await subscription.status
|
|
|
|
|
- let isActive = statuses.contains { status in
|
|
|
|
|
- switch status.state {
|
|
|
|
|
- case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
|
|
|
|
|
- return true
|
|
|
|
|
- case .expired, .revoked:
|
|
|
|
|
- return false
|
|
|
|
|
- default:
|
|
|
|
|
- return false
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ do {
|
|
|
|
|
+ let statuses = try await subscription.status
|
|
|
|
|
+ return statuses.contains { status in
|
|
|
|
|
+ switch status.state {
|
|
|
|
|
+ case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
|
|
|
|
|
+ return true
|
|
|
|
|
+ case .expired, .revoked:
|
|
|
|
|
+ return false
|
|
|
|
|
+ default:
|
|
|
|
|
+ return false
|
|
|
}
|
|
}
|
|
|
- if isActive { return true }
|
|
|
|
|
- } catch {
|
|
|
|
|
- continue
|
|
|
|
|
}
|
|
}
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return true
|
|
|
}
|
|
}
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- return false
|
|
|
|
|
|
|
+ private func observeAppLifecycle() {
|
|
|
|
|
+ NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
|
|
|
|
|
+ .sink { [weak self] _ in
|
|
|
|
|
+ Task { await self?.refreshPremiumAccess() }
|
|
|
|
|
+ }
|
|
|
|
|
+ .store(in: &cancellables)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func listenForTransactionUpdates() async {
|
|
private func listenForTransactionUpdates() async {
|
|
|
for await update in Transaction.updates {
|
|
for await update in Transaction.updates {
|
|
|
- guard case .verified(let transaction) = update else { continue }
|
|
|
|
|
- await transaction.finish()
|
|
|
|
|
- await refreshPremiumAccess()
|
|
|
|
|
|
|
+ switch update {
|
|
|
|
|
+ case .verified(let transaction):
|
|
|
|
|
+ await transaction.finish()
|
|
|
|
|
+ await refreshPremiumAccess()
|
|
|
|
|
+ case .unverified(_, let error):
|
|
|
|
|
+ #if DEBUG
|
|
|
|
|
+ print("[SubscriptionManager] Unverified transaction update: \(error)")
|
|
|
|
|
+ #endif
|
|
|
|
|
+ purchaseError = .purchaseNotVerified
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|