Ver código fonte

Update paywall packages to monthly/bi-yearly/yearly with StoreKit-driven pricing.

Align product identifiers with the new Products.storekit configuration so local testing and production resolve the same subscription products, remove legacy weekly/lifetime paths, default selection to yearly with 3-day trial CTA messaging, and compute dynamic save badges for bi-yearly and yearly plans against monthly pricing.

Co-authored-by: Cursor <cursoragent@cursor.com>
Uzair Tahir 2 meses atrás
pai
commit
905bde6388

+ 140 - 0
Products.storekit

@@ -0,0 +1,140 @@
+{
+  "appPolicies" : {
+    "eula" : "",
+    "policies" : [
+      {
+        "locale" : "en_US",
+        "policyText" : "",
+        "policyURL" : ""
+      }
+    ]
+  },
+  "identifier" : "98A1BE08",
+  "nonRenewingSubscriptions" : [
+
+  ],
+  "products" : [
+
+  ],
+  "settings" : {
+    "_applicationInternalID" : "6767797685",
+    "_askToBuyEnabled" : false,
+    "_billingGracePeriodEnabled" : false,
+    "_billingIssuesEnabled" : false,
+    "_developerTeamID" : "NNC7V99779",
+    "_disableDialogs" : false,
+    "_failTransactionsEnabled" : false,
+    "_lastSynchronizedDate" : 800013984.30057704,
+    "_locale" : "en_US",
+    "_renewalBillingIssuesEnabled" : false,
+    "_storefront" : "USA",
+    "_storeKitErrors" : [
+
+    ],
+    "_timeRate" : 0
+  },
+  "subscriptionGroups" : [
+    {
+      "id" : "22077591",
+      "localizations" : [
+
+      ],
+      "name" : "google meeting susbcription",
+      "subscriptions" : [
+        {
+          "adHocOffers" : [
+
+          ],
+          "codeOffers" : [
+
+          ],
+          "displayPrice" : "19.99",
+          "familyShareable" : false,
+          "groupNumber" : 3,
+          "internalID" : "6767831118",
+          "introductoryOffer" : null,
+          "localizations" : [
+            {
+              "description" : "Bi Yearly Subscription",
+              "displayName" : "AI Companion for Meet Bi Yearly",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "com.hwaccount.googlemeet.BiYearly",
+          "recurringSubscriptionPeriod" : "P6M",
+          "referenceName" : "AI Companion for Meet Bi-yearly",
+          "subscriptionGroupID" : "22077591",
+          "type" : "RecurringSubscription",
+          "winbackOffers" : [
+
+          ]
+        },
+        {
+          "adHocOffers" : [
+
+          ],
+          "codeOffers" : [
+
+          ],
+          "displayPrice" : "5.99",
+          "familyShareable" : false,
+          "groupNumber" : 1,
+          "internalID" : "6767830018",
+          "introductoryOffer" : null,
+          "localizations" : [
+            {
+              "description" : "Monthly Subscription",
+              "displayName" : "AI Companion for Meet Monthly",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "com.hwaccount.googlemeet.1month",
+          "recurringSubscriptionPeriod" : "P1M",
+          "referenceName" : "AI Companion for Meet Monthly",
+          "subscriptionGroupID" : "22077591",
+          "type" : "RecurringSubscription",
+          "winbackOffers" : [
+
+          ]
+        },
+        {
+          "adHocOffers" : [
+
+          ],
+          "codeOffers" : [
+
+          ],
+          "displayPrice" : "34.99",
+          "familyShareable" : false,
+          "groupNumber" : 2,
+          "internalID" : "6767830562",
+          "introductoryOffer" : {
+            "internalID" : "C7003FEA",
+            "numberOfPeriods" : 1,
+            "paymentMode" : "free",
+            "subscriptionPeriod" : "P3D"
+          },
+          "localizations" : [
+            {
+              "description" : "Yearly Subscription",
+              "displayName" : "AI companion for Meet Yearly",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "com.hwaccount.googlemeet.1year",
+          "recurringSubscriptionPeriod" : "P1Y",
+          "referenceName" : "Ai Companion for Meet Yearly",
+          "subscriptionGroupID" : "22077591",
+          "type" : "RecurringSubscription",
+          "winbackOffers" : [
+
+          ]
+        }
+      ]
+    }
+  ],
+  "version" : {
+    "major" : 4,
+    "minor" : 0
+  }
+}

+ 7 - 0
meetings_app.xcodeproj/project.pbxproj

@@ -6,8 +6,13 @@
 	objectVersion = 77;
 	objects = {
 
+/* Begin PBXBuildFile section */
+		165394742FAF3D3000A56710 /* Products.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 165394732FAF3D3000A56710 /* Products.storekit */; };
+/* End PBXBuildFile section */
+
 /* Begin PBXFileReference section */
 		13AF5DA22F83DE23001BE867 /* AI Companion for Meet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "AI Companion for Meet.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+		165394732FAF3D3000A56710 /* Products.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Products.storekit; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -32,6 +37,7 @@
 		13AF5D992F83DE22001BE867 = {
 			isa = PBXGroup;
 			children = (
+				165394732FAF3D3000A56710 /* Products.storekit */,
 				13AF5DA42F83DE23001BE867 /* meetings_app */,
 				13AF5DA32F83DE23001BE867 /* Products */,
 			);
@@ -109,6 +115,7 @@
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				165394742FAF3D3000A56710 /* Products.storekit in Resources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

+ 1 - 1
meetings_app.xcodeproj/xcshareddata/xcschemes/meetings_app.xcscheme

@@ -52,7 +52,7 @@
          </BuildableReference>
       </BuildableProductRunnable>
       <StoreKitConfigurationFileReference
-         identifier = "../../meetings_app/StoreKit.storekit">
+         identifier = "../../Products.storekit">
       </StoreKitConfigurationFileReference>
    </LaunchAction>
    <ProfileAction

+ 57 - 101
meetings_app/ViewController.swift

@@ -46,35 +46,33 @@ private struct SpeechLocaleOption {
 }
 
 private enum PremiumPlan: Int {
-    case weekly = 0
-    case monthly = 1
+    case monthly = 0
+    case biYearly = 1
     case yearly = 2
-    case lifetime = 3
 }
 
 private enum PremiumStoreProduct {
-    static let weekly = "com.mqldev.meetingsapp.premium.weekly"
-    static let monthly = "com.mqldev.meetingsapp.premium.monthly"
-    static let yearly = "com.mqldev.meetingsapp.premium.yearly"
-    static let lifetime = "com.mqldev.meetingsapp.premium.lifetime"
+    // These IDs must match App Store Connect products.
+    // In local StoreKit testing, keep them aligned with Products.storekit.
+    static let monthly = "com.hwaccount.googlemeet.1month"
+    static let biYearly = "com.hwaccount.googlemeet.BiYearly"
+    static let yearly = "com.hwaccount.googlemeet.1year"
 
-    static let allIDs = [weekly, monthly, yearly, lifetime]
+    static let allIDs = [monthly, biYearly, yearly]
 
     static func productID(for plan: PremiumPlan) -> String {
         switch plan {
-        case .weekly: return weekly
         case .monthly: return monthly
+        case .biYearly: return biYearly
         case .yearly: return yearly
-        case .lifetime: return lifetime
         }
     }
 
     static func plan(for productID: String) -> PremiumPlan? {
         switch productID {
-        case weekly: return .weekly
         case monthly: return .monthly
+        case biYearly: return .biYearly
         case yearly: return .yearly
-        case lifetime: return .lifetime
         default: return nil
         }
     }
@@ -97,11 +95,9 @@ private final class StoreKitCoordinator {
     var onEntitlementsChanged: ((Bool) -> Void)?
 
     var hasPremiumAccess: Bool { !activeEntitlementProductIDs.isEmpty }
-    var hasLifetimeAccess: Bool { activeEntitlementProductIDs.contains(PremiumStoreProduct.lifetime) }
-    var activeNonLifetimePlan: PremiumPlan? {
+    var activePlan: PremiumPlan? {
         activeEntitlementProductIDs
             .compactMap { PremiumStoreProduct.plan(for: $0) }
-            .filter { $0 != .lifetime }
             .max(by: { $0.rawValue < $1.rawValue })
     }
 
@@ -365,7 +361,7 @@ final class ViewController: NSViewController {
     private weak var paywallOverlayView: NSView?
     private let paywallContentWidth: CGFloat = 520
     private let launchWindowLeftOffset: CGFloat = 80
-    private var selectedPremiumPlan: PremiumPlan = .monthly
+    private var selectedPremiumPlan: PremiumPlan = .yearly
     private var paywallPlanViews: [PremiumPlan: NSView] = [:]
     private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
     private var paywallFooterActionByView = [ObjectIdentifier: PaywallFooterAction]()
@@ -562,7 +558,7 @@ final class ViewController: NSViewController {
         let popover = NSPopover()
         popover.behavior = .transient
         popover.animates = true
-        let showUpgradeInSettings = storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess
+        let showUpgradeInSettings = storeKitCoordinator.hasPremiumAccess
         let showRateUsInSettings = shouldShowRateUsInSettings
         let speechOptions = aiCompanionSupportedSpeechLocaleOptions()
         popover.contentViewController = SettingsMenuViewController(
@@ -834,10 +830,8 @@ private extension ViewController {
     }
 
     @objc private func premiumButtonClicked(_ sender: NSClickGestureRecognizer) {
-        if storeKitCoordinator.hasLifetimeAccess {
-            openManageSubscriptions()
-        } else if storeKitCoordinator.hasPremiumAccess {
-            showPaywall(upgradeFlow: true, preferredPlan: .lifetime)
+        if storeKitCoordinator.hasPremiumAccess {
+            showPaywall(upgradeFlow: true, preferredPlan: .yearly)
         } else {
             showPaywall()
         }
@@ -1378,7 +1372,7 @@ private extension ViewController {
         case .shareApp:
             shareAppFromSettingsMenu(sourceView: sourceView, clickLocationInSourceView: clickLocationInSourceView)
         case .upgrade:
-            showPaywall(upgradeFlow: true, preferredPlan: .lifetime)
+            showPaywall(upgradeFlow: true, preferredPlan: .yearly)
         }
     }
 
@@ -2386,15 +2380,9 @@ private extension ViewController {
 
     private func paywallOfferText(for plan: PremiumPlan) -> String {
         if storeKitCoordinator.hasPremiumAccess {
-            if storeKitCoordinator.hasLifetimeAccess {
-                return "Lifetime premium is active on this Apple ID."
-            }
             if paywallUpgradeFlowEnabled {
-                let currentPlanName = storeKitCoordinator.activeNonLifetimePlan?.displayName ?? "Premium"
-                if plan == .lifetime {
-                    return "Current plan: \(currentPlanName). Tap Continue to upgrade to Lifetime."
-                }
-                return "Current plan: \(currentPlanName). Select Lifetime to upgrade."
+                let currentPlanName = storeKitCoordinator.activePlan?.displayName ?? "Premium"
+                return "Current plan: \(currentPlanName). Choose a different package to switch."
             }
             return "Premium is active on this Apple ID."
         }
@@ -2416,14 +2404,12 @@ private extension ViewController {
         }
         let fallbackPrice = fallbackPriceText(for: plan)
         switch plan {
-        case .weekly:
-            return "\(fallbackPrice)/week"
         case .monthly:
             return "\(fallbackPrice)/month"
+        case .biYearly:
+            return "\(fallbackPrice)/6 months"
         case .yearly:
             return "\(fallbackPrice)/year (3 days free trial)"
-        case .lifetime:
-            return "\(fallbackPrice) one-time purchase"
         }
     }
 
@@ -2434,45 +2420,37 @@ private extension ViewController {
     private func fallbackPriceText(for plan: PremiumPlan) -> String {
         let fallbackAmount: Decimal
         switch plan {
-        case .weekly:
-            fallbackAmount = 1100
         case .monthly:
             fallbackAmount = 2500
+        case .biYearly:
+            fallbackAmount = 5900
         case .yearly:
             fallbackAmount = 9900
-        case .lifetime:
-            fallbackAmount = 14900
         }
         return localeCurrencyText(for: fallbackAmount)
     }
 
-    private func fallbackLifetimeStrikePriceText() -> String {
-        localeCurrencyText(for: 29_800)
-    }
-
     private func fallbackAmount(for plan: PremiumPlan) -> Decimal {
         switch plan {
-        case .weekly:
-            return 1100
         case .monthly:
             return 2500
+        case .biYearly:
+            return 5900
         case .yearly:
             return 9900
-        case .lifetime:
-            return 14900
         }
     }
 
-    private func yearlySavingsBadgeText() -> String {
-        let yearlyProduct = storeKitCoordinator.productsByID[PremiumStoreProduct.yearly]
+    private func savingsPercentText(for targetPlan: PremiumPlan, monthMultiplier: Decimal) -> String {
+        let targetProduct = storeKitCoordinator.productsByID[PremiumStoreProduct.productID(for: targetPlan)]
         let monthlyProduct = storeKitCoordinator.productsByID[PremiumStoreProduct.monthly]
 
-        let yearlyAmount = yearlyProduct?.price ?? fallbackAmount(for: .yearly)
+        let targetAmount = targetProduct?.price ?? fallbackAmount(for: targetPlan)
         let monthlyAmount = monthlyProduct?.price ?? fallbackAmount(for: .monthly)
-        let yearlyCostIfMonthly = monthlyAmount * Decimal(12)
-        guard yearlyCostIfMonthly > 0 else { return "Save 0%" }
+        let projectedMonthlyCost = monthlyAmount * monthMultiplier
+        guard projectedMonthlyCost > 0 else { return "Save 0%" }
 
-        let savingsRatio = (yearlyCostIfMonthly - yearlyAmount) / yearlyCostIfMonthly
+        let savingsRatio = (projectedMonthlyCost - targetAmount) / projectedMonthlyCost
         guard savingsRatio > 0 else { return "Save 0%" }
         let savingsPercentDecimal = savingsRatio * Decimal(100)
         let roundedPercent = Int(NSDecimalNumber(decimal: savingsPercentDecimal).doubleValue.rounded())
@@ -2480,6 +2458,14 @@ private extension ViewController {
         return "Save \(savingsPercent)%"
     }
 
+    private func biYearlySavingsBadgeText() -> String {
+        savingsPercentText(for: .biYearly, monthMultiplier: 6)
+    }
+
+    private func yearlySavingsBadgeText() -> String {
+        savingsPercentText(for: .yearly, monthMultiplier: 12)
+    }
+
     private func localeCurrencyText(for amount: Decimal) -> String {
         let formatter = NumberFormatter()
         formatter.numberStyle = .currency
@@ -2558,17 +2544,8 @@ private extension ViewController {
                 label.stringValue = recurringText
             }
         }
-        for (plan, label) in paywallStrikePriceLabels {
-            guard plan == .lifetime else { continue }
-            let productID = PremiumStoreProduct.productID(for: plan)
-            if let product = storeKitCoordinator.productsByID[productID] {
-                label.stringValue = strikePriceText(for: product)
-            } else {
-                label.stringValue = fallbackLifetimeStrikePriceText()
-            }
-        }
-        paywallBadgeLabels[.yearly]?.stringValue = yearlySavingsBadgeText()
-        paywallBadgeLabels[.lifetime]?.stringValue = "Best Value"
+        paywallBadgeLabels[.biYearly]?.stringValue = biYearlySavingsBadgeText()
+        paywallBadgeLabels[.yearly]?.stringValue = "\(yearlySavingsBadgeText()) • Most Popular"
         refreshSidebarPremiumButton()
         refreshInstantMeetPremiumState()
         updatePaywallPlanSelection()
@@ -2807,7 +2784,6 @@ private extension ViewController {
         }
         if paywallUpgradeFlowEnabled,
            storeKitCoordinator.hasPremiumAccess,
-           selectedPremiumPlan != .lifetime,
            !confirmPremiumUpgrade(for: selectedPremiumPlan) {
             return
         }
@@ -2825,16 +2801,7 @@ private extension ViewController {
                 Task { [weak self] in
                     await self?.loadSchedule()
                 }
-                if selectedPlan == .lifetime, self.storeKitCoordinator.activeNonLifetimePlan != nil {
-                    self.showSimpleAlert(
-                        title: "Lifetime Premium Activated",
-                        message: "You are premium for lifetime now. Please cancel your previous subscription in App Store Subscriptions to avoid future renewal charges."
-                    )
-                } else if selectedPlan == .lifetime {
-                    self.showSimpleAlert(title: "Purchase Complete", message: "Lifetime premium has been unlocked successfully.")
-                } else {
-                    self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
-                }
+                self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
                 self.dismissPaywallIfPresented()
                 self.scheduleRatingPromptAfterPremiumUpgrade()
             case .cancelled:
@@ -2858,7 +2825,7 @@ private extension ViewController {
             paywallContinueButton?.alphaValue = 0.75
             return
         }
-        if storeKitCoordinator.hasLifetimeAccess {
+        if storeKitCoordinator.hasPremiumAccess && !paywallUpgradeFlowEnabled {
             paywallContinueEnabled = false
             paywallContinueLabel?.stringValue = "Premium Active"
             paywallContinueButton?.alphaValue = 0.75
@@ -2868,7 +2835,7 @@ private extension ViewController {
             paywallContinueButton?.alphaValue = 1.0
         } else {
             paywallContinueEnabled = true
-            paywallContinueLabel?.stringValue = (selectedPremiumPlan == .yearly) ? "Start with 3 day trial" : "Continue"
+            paywallContinueLabel?.stringValue = (selectedPremiumPlan == .yearly) ? "Continue with 3 day trail" : "Continue"
             paywallContinueButton?.alphaValue = 1.0
         }
     }
@@ -4639,7 +4606,7 @@ private extension ViewController {
         let shareButton = makeSettingsActionButton(icon: "⤴︎", title: "Share App", action: .shareApp)
         stack.addArrangedSubview(shareButton)
         shareButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
-        if storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess {
+        if storeKitCoordinator.hasPremiumAccess {
             let upgradeButton = makeSettingsActionButton(icon: "⬆︎", title: "Upgrade", action: .upgrade)
             stack.addArrangedSubview(upgradeButton)
             upgradeButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
@@ -5940,46 +5907,36 @@ private extension ViewController {
         plansRow.distribution = .fillEqually
         plansRow.alignment = .centerY
 
-        let weeklyCard = paywallPlanCard(
-            title: "Weekly",
-            price: fallbackPriceText(for: .weekly),
-            badge: "Basic",
-            badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
-            subtitle: nil,
-            plan: .weekly,
-            strikePrice: nil
-        )
         let monthlyCard = paywallPlanCard(
             title: "Monthly",
             price: fallbackPriceText(for: .monthly),
             badge: "Popular",
-            badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
+            badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
             subtitle: nil,
             plan: .monthly,
             strikePrice: nil
         )
+        let biYearlyCard = paywallPlanCard(
+            title: "Bi Yearly",
+            price: fallbackPriceText(for: .biYearly),
+            badge: biYearlySavingsBadgeText(),
+            badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
+            subtitle: nil,
+            plan: .biYearly,
+            strikePrice: nil
+        )
         let yearlyCard = paywallPlanCard(
             title: "Yearly",
             price: fallbackPriceText(for: .yearly),
-            badge: yearlySavingsBadgeText(),
+            badge: "\(yearlySavingsBadgeText()) • Most Popular",
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
             subtitle: "3 days free trial",
             plan: .yearly,
             strikePrice: nil
         )
-        let lifetimeCard = paywallPlanCard(
-            title: "Lifetime",
-            price: fallbackPriceText(for: .lifetime),
-            badge: "Best Value",
-            badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
-            subtitle: nil,
-            plan: .lifetime,
-            strikePrice: nil
-        )
-        plansRow.addArrangedSubview(weeklyCard)
         plansRow.addArrangedSubview(monthlyCard)
+        plansRow.addArrangedSubview(biYearlyCard)
         plansRow.addArrangedSubview(yearlyCard)
-        plansRow.addArrangedSubview(lifetimeCard)
         contentStack.addArrangedSubview(plansRow)
         plansRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
         updatePaywallPlanSelection()
@@ -7329,10 +7286,9 @@ private extension ViewController {
 private extension PremiumPlan {
     var displayName: String {
         switch self {
-        case .weekly: return "Weekly"
         case .monthly: return "Monthly"
+        case .biYearly: return "Bi Yearly"
         case .yearly: return "Yearly"
-        case .lifetime: return "Lifetime"
         }
     }
 }