Przeglądaj źródła

Fix StoreKit entitlements and paywall trial messaging

Remove cached premium fallback, only finish/refresh our IAP transactions, and drive paywall trial/subtitle/CTA text from StoreKit metadata. Add 3-day free trial introductory offer to yearly StoreKit config.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 miesięcy temu
rodzic
commit
70573e4dd6
2 zmienionych plików z 112 dodań i 89 usunięć
  1. 6 1
      zoom_app/StoreKit.storekit
  2. 106 88
      zoom_app/ViewController.swift

+ 6 - 1
zoom_app/StoreKit.storekit

@@ -84,7 +84,12 @@
           "familyShareable" : false,
           "familyShareable" : false,
           "groupNumber" : 1,
           "groupNumber" : 1,
           "internalID" : "CE6E8B9A-65D4-4E3A-9EB2-5B81A410F1E0",
           "internalID" : "CE6E8B9A-65D4-4E3A-9EB2-5B81A410F1E0",
-          "introductoryOffer" : null,
+          "introductoryOffer" : {
+            "internalID" : "A0B9C9EE-8F33-4E3D-9D68-7C3E3DDE52A4",
+            "numberOfPeriods" : 1,
+            "paymentMode" : "free",
+            "subscriptionPeriod" : "P3D"
+          },
           "localizations" : [
           "localizations" : [
             {
             {
               "description" : "Unlock premium features with yearly access.",
               "description" : "Unlock premium features with yearly access.",

+ 106 - 88
zoom_app/ViewController.swift

@@ -368,11 +368,6 @@ class ViewController: NSViewController {
             case failed(String)
             case failed(String)
         }
         }
 
 
-        private enum CacheKey {
-            static let verificationTimes = "storekit.cachedVerificationTimes"
-        }
-        private let subscriptionFallbackWindow: TimeInterval = 72 * 60 * 60
-
         private(set) var productsByID: [String: Product] = [:]
         private(set) var productsByID: [String: Product] = [:]
         private(set) var activeProductIDs = Set<String>()
         private(set) var activeProductIDs = Set<String>()
 
 
@@ -382,6 +377,10 @@ class ViewController: NSViewController {
         private var transactionUpdatesTask: Task<Void, Never>?
         private var transactionUpdatesTask: Task<Void, Never>?
         var onEntitlementsChanged: ((Bool) -> Void)?
         var onEntitlementsChanged: ((Bool) -> Void)?
 
 
+        deinit {
+            transactionUpdatesTask?.cancel()
+        }
+
         func start() async {
         func start() async {
             await refreshProducts()
             await refreshProducts()
             await refreshEntitlements()
             await refreshEntitlements()
@@ -444,7 +443,6 @@ class ViewController: NSViewController {
                 switch result {
                 switch result {
                 case .success(let verification):
                 case .success(let verification):
                     guard case .verified(let transaction) = verification else { return .failed("Purchase verification failed.") }
                     guard case .verified(let transaction) = verification else { return .failed("Purchase verification failed.") }
-                    cacheVerifiedPremiumTransaction(transaction)
                     await transaction.finish()
                     await transaction.finish()
                     await refreshEntitlements()
                     await refreshEntitlements()
                     return .success
                     return .success
@@ -465,9 +463,8 @@ class ViewController: NSViewController {
             transactionUpdatesTask = Task { [weak self] in
             transactionUpdatesTask = Task { [weak self] in
                 for await update in Transaction.updates {
                 for await update in Transaction.updates {
                     guard case .verified(let transaction) = update else { continue }
                     guard case .verified(let transaction) = update else { continue }
-                    if PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID) {
-                        await self?.refreshEntitlements()
-                    }
+                    guard PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID) else { continue }
+                    await self?.refreshEntitlements()
                     await transaction.finish()
                     await transaction.finish()
                 }
                 }
             }
             }
@@ -478,7 +475,6 @@ class ViewController: NSViewController {
             let previousHasPremiumAccess = hasPremiumAccess
             let previousHasPremiumAccess = hasPremiumAccess
             let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
             let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
             var active = Set<String>()
             var active = Set<String>()
-            var latestInactiveIDs = Set<String>()
 
 
             for await entitlement in Transaction.currentEntitlements {
             for await entitlement in Transaction.currentEntitlements {
                 guard case .verified(let transaction) = entitlement else { continue }
                 guard case .verified(let transaction) = entitlement else { continue }
@@ -495,21 +491,9 @@ class ViewController: NSViewController {
                       case .verified(let transaction) = latest else { continue }
                       case .verified(let transaction) = latest else { continue }
                 if Self.isTransactionActive(transaction) {
                 if Self.isTransactionActive(transaction) {
                     active.insert(productID)
                     active.insert(productID)
-                } else {
-                    latestInactiveIDs.insert(productID)
                 }
                 }
             }
             }
 
 
-            if latestInactiveIDs.isEmpty == false {
-                removeCachedProductIDs(latestInactiveIDs)
-            }
-
-            if active.isEmpty {
-                active = cachedFallbackActiveProductIDs(validProductIDs: allIDs)
-            } else {
-                cacheActiveProductIDs(active)
-            }
-
             activeProductIDs = active
             activeProductIDs = active
             let newHasPremiumAccess = hasPremiumAccess
             let newHasPremiumAccess = hasPremiumAccess
             if newHasPremiumAccess != previousHasPremiumAccess {
             if newHasPremiumAccess != previousHasPremiumAccess {
@@ -517,53 +501,6 @@ class ViewController: NSViewController {
             }
             }
         }
         }
 
 
-        private func cacheVerifiedPremiumTransaction(_ transaction: Transaction) {
-            let productID = transaction.productID
-            guard PremiumPlan.allCases.map(\.rawValue).contains(productID) else { return }
-            cacheActiveProductIDs([productID])
-        }
-
-        private func cacheActiveProductIDs(_ ids: Set<String>) {
-            var cached = cachedVerificationTimes()
-            let now = Date().timeIntervalSince1970
-            for id in ids {
-                cached[id] = now
-            }
-            saveCachedVerificationTimes(cached)
-        }
-
-        private func removeCachedProductIDs(_ ids: Set<String>) {
-            var cached = cachedVerificationTimes()
-            for id in ids {
-                cached.removeValue(forKey: id)
-            }
-            saveCachedVerificationTimes(cached)
-        }
-
-        private func cachedFallbackActiveProductIDs(validProductIDs: Set<String>) -> Set<String> {
-            let cached = cachedVerificationTimes()
-            let now = Date().timeIntervalSince1970
-            let lifetimeID = PremiumPlan.lifetime.rawValue
-            return Set(
-                cached.compactMap { (productID, verifiedAt) in
-                    guard validProductIDs.contains(productID) else { return nil }
-                    if productID == lifetimeID {
-                        return productID
-                    }
-                    let age = now - verifiedAt
-                    return age <= subscriptionFallbackWindow ? productID : nil
-                }
-            )
-        }
-
-        private func cachedVerificationTimes() -> [String: TimeInterval] {
-            UserDefaults.standard.dictionary(forKey: CacheKey.verificationTimes) as? [String: TimeInterval] ?? [:]
-        }
-
-        private func saveCachedVerificationTimes(_ map: [String: TimeInterval]) {
-            UserDefaults.standard.set(map, forKey: CacheKey.verificationTimes)
-        }
-
         private static func isTransactionActive(_ transaction: Transaction) -> Bool {
         private static func isTransactionActive(_ transaction: Transaction) -> Bool {
             if transaction.revocationDate != nil { return false }
             if transaction.revocationDate != nil { return false }
             if let expirationDate = transaction.expirationDate {
             if let expirationDate = transaction.expirationDate {
@@ -3816,7 +3753,7 @@ class ViewController: NSViewController {
             price: paywallDisplayPrice(for: .weekly),
             price: paywallDisplayPrice(for: .weekly),
             badge: "Basic",
             badge: "Basic",
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
-            subtitle: "Billed weekly",
+            subtitle: paywallPlanSubtitle(for: .weekly),
             plan: .weekly,
             plan: .weekly,
             strikePrice: nil
             strikePrice: nil
         )
         )
@@ -3825,7 +3762,7 @@ class ViewController: NSViewController {
             price: paywallDisplayPrice(for: .monthly),
             price: paywallDisplayPrice(for: .monthly),
             badge: "Popular",
             badge: "Popular",
             badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
             badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
-            subtitle: "Billed monthly",
+            subtitle: paywallPlanSubtitle(for: .monthly),
             plan: .monthly,
             plan: .monthly,
             strikePrice: nil
             strikePrice: nil
         )
         )
@@ -3834,7 +3771,7 @@ class ViewController: NSViewController {
             price: paywallDisplayPrice(for: .yearly),
             price: paywallDisplayPrice(for: .yearly),
             badge: "Save 67%",
             badge: "Save 67%",
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
-            subtitle: "Billed yearly • 3 days free trial",
+            subtitle: paywallPlanSubtitle(for: .yearly),
             plan: .yearly,
             plan: .yearly,
             strikePrice: nil
             strikePrice: nil
         )
         )
@@ -3843,7 +3780,7 @@ class ViewController: NSViewController {
             price: paywallDisplayPrice(for: .lifetime),
             price: paywallDisplayPrice(for: .lifetime),
             badge: "Best Value",
             badge: "Best Value",
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
-            subtitle: "One-time purchase",
+            subtitle: paywallPlanSubtitle(for: .lifetime),
             plan: .lifetime,
             plan: .lifetime,
             strikePrice: nil
             strikePrice: nil
         )
         )
@@ -3880,16 +3817,16 @@ class ViewController: NSViewController {
             price: paywallDisplayPrice(for: .weekly),
             price: paywallDisplayPrice(for: .weekly),
             badge: "Basic Deal",
             badge: "Basic Deal",
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
-            subtitle: "Billed weekly",
+            subtitle: paywallPlanSubtitle(for: .weekly),
             plan: .weekly,
             plan: .weekly,
             strikePrice: nil
             strikePrice: nil
         ))
         ))
         plansRow1.addArrangedSubview(paywallPlanCard(
         plansRow1.addArrangedSubview(paywallPlanCard(
             title: "Monthly",
             title: "Monthly",
             price: paywallDisplayPrice(for: .monthly),
             price: paywallDisplayPrice(for: .monthly),
-            badge: "Free Trial",
+            badge: "Popular",
             badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
             badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
-            subtitle: "7-day free trial, then monthly",
+            subtitle: paywallPlanSubtitle(for: .monthly),
             plan: .monthly,
             plan: .monthly,
             strikePrice: nil
             strikePrice: nil
         ))
         ))
@@ -3905,7 +3842,7 @@ class ViewController: NSViewController {
             price: paywallDisplayPrice(for: .yearly),
             price: paywallDisplayPrice(for: .yearly),
             badge: "Best Deal",
             badge: "Best Deal",
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
-            subtitle: "Billed yearly",
+            subtitle: paywallPlanSubtitle(for: .yearly),
             plan: .yearly,
             plan: .yearly,
             strikePrice: nil
             strikePrice: nil
         ))
         ))
@@ -3914,7 +3851,7 @@ class ViewController: NSViewController {
             price: paywallDisplayPrice(for: .lifetime),
             price: paywallDisplayPrice(for: .lifetime),
             badge: "Save 50%",
             badge: "Save 50%",
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
-            subtitle: "One-time purchase",
+            subtitle: paywallPlanSubtitle(for: .lifetime),
             plan: .lifetime,
             plan: .lifetime,
             strikePrice: nil
             strikePrice: nil
         ))
         ))
@@ -4378,12 +4315,95 @@ class ViewController: NSViewController {
         if storeKitCoordinator.hasLifetimePremiumAccess {
         if storeKitCoordinator.hasLifetimePremiumAccess {
             return "Lifetime premium is active on this Apple ID."
             return "Lifetime premium is active on this Apple ID."
         }
         }
-        let price = paywallDisplayPrice(for: plan)
+        guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
+            let price = paywallDisplayPrice(for: plan)
+            switch plan {
+            case .weekly: return "\(price)/week"
+            case .monthly: return "\(price)/month"
+            case .yearly: return "\(price)/year"
+            case .lifetime: return "\(price) one-time purchase"
+            }
+        }
+
+        if let offer = product.subscription?.introductoryOffer, offer.paymentMode == .freeTrial {
+            let trial = paywallSubscriptionPeriodDescription(offer.period)
+            let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod)
+            if let renewal {
+                return "Free for \(trial), then \(product.displayPrice)/\(renewal)"
+            }
+            return "Free for \(trial), then \(product.displayPrice)"
+        }
+
+        if plan == .lifetime {
+            return "\(product.displayPrice) one-time purchase"
+        }
+
+        if let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod) {
+            return "\(product.displayPrice)/\(renewal)"
+        }
+
+        let fallbackPrice = paywallDisplayPrice(for: plan)
+        switch plan {
+        case .weekly: return "\(fallbackPrice)/week"
+        case .monthly: return "\(fallbackPrice)/month"
+        case .yearly: return "\(fallbackPrice)/year"
+        case .lifetime: return "\(fallbackPrice) one-time purchase"
+        }
+    }
+
+    private func paywallHasFreeTrial(for plan: PremiumPlan) -> Bool {
+        guard let product = storeKitCoordinator.productsByID[plan.rawValue] else { return false }
+        return product.subscription?.introductoryOffer?.paymentMode == .freeTrial
+    }
+
+    private func paywallPlanSubtitle(for plan: PremiumPlan) -> String {
+        if plan == .lifetime { return "One-time purchase" }
+
+        guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
+            switch plan {
+            case .weekly: return "Billed weekly"
+            case .monthly: return "Billed monthly"
+            case .yearly: return "Billed yearly"
+            case .lifetime: return "One-time purchase"
+            }
+        }
+
+        if let offer = product.subscription?.introductoryOffer, offer.paymentMode == .freeTrial {
+            let trial = paywallSubscriptionPeriodDescription(offer.period)
+            if let trial {
+                if let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod) {
+                    return "Free for \(trial), then \(renewal)"
+                }
+                return "Free for \(trial)"
+            }
+        }
+
+        if let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod) {
+            return "Billed \(renewal)"
+        }
+
         switch plan {
         switch plan {
-        case .weekly: return "\(price)/week"
-        case .monthly: return "\(price)/month"
-        case .yearly: return "Free for 3 days, then \(price)/year"
-        case .lifetime: return "\(price) one-time purchase"
+        case .weekly: return "Billed weekly"
+        case .monthly: return "Billed monthly"
+        case .yearly: return "Billed yearly"
+        case .lifetime: return "One-time purchase"
+        }
+    }
+
+    private func paywallSubscriptionPeriodDescription(_ period: Product.SubscriptionPeriod?) -> String? {
+        guard let period else { return nil }
+        let value = period.value
+        switch period.unit {
+        case .day:
+            return value == 1 ? "1 day" : "\(value) days"
+        case .week:
+            return value == 1 ? "1 week" : "\(value) weeks"
+        case .month:
+            return value == 1 ? "1 month" : "\(value) months"
+        case .year:
+            return value == 1 ? "1 year" : "\(value) years"
+        @unknown default:
+            return nil
         }
         }
     }
     }
 
 
@@ -4397,6 +4417,8 @@ class ViewController: NSViewController {
         for plan in PremiumPlan.allCases {
         for plan in PremiumPlan.allCases {
             let price = paywallDisplayPrice(for: plan)
             let price = paywallDisplayPrice(for: plan)
             paywallPlanPriceLabels[plan]?.forEach { $0.stringValue = price }
             paywallPlanPriceLabels[plan]?.forEach { $0.stringValue = price }
+            let subtitle = paywallPlanSubtitle(for: plan)
+            paywallPlanSubtitleLabels[plan]?.forEach { $0.stringValue = subtitle }
         }
         }
     }
     }
 
 
@@ -4528,11 +4550,7 @@ class ViewController: NSViewController {
             paywallContinueButton?.alphaValue = 0.75
             paywallContinueButton?.alphaValue = 0.75
         } else {
         } else {
             paywallContinueEnabled = true
             paywallContinueEnabled = true
-            if selectedPremiumPlan == .monthly {
-                paywallContinueLabel?.stringValue = "Start with free trial"
-            } else {
-                paywallContinueLabel?.stringValue = "Continue"
-            }
+            paywallContinueLabel?.stringValue = paywallHasFreeTrial(for: selectedPremiumPlan) ? "Start with free trial" : "Continue"
             paywallContinueButton?.alphaValue = 1.0
             paywallContinueButton?.alphaValue = 1.0
         }
         }
     }
     }