Browse Source

Sync paywall with bundled Products.storekit for dynamic plan discovery.

StoreKit configuration now drives available products, ordering, and pricing on the paywall so new plans like weekly appear without code changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 weeks ago
parent
commit
14b45a3699

+ 8 - 7
smart_printer/AppStoreConfig.swift

@@ -1,7 +1,7 @@
 import Foundation
 import Foundation
 
 
 /// Production App Store identifiers for Printer App - All Printers.
 /// Production App Store identifiers for Printer App - All Printers.
-/// Keep these aligned with Xcode, App Store Connect, and `Products.storekit`.
+/// Product IDs are sourced from bundled `Products.storekit`; marketing copy stays in `paywall.json`.
 enum AppStoreConfig {
 enum AppStoreConfig {
     static let displayName = "Printer App - All Printers"
     static let displayName = "Printer App - All Printers"
     static let proDisplayName = "\(displayName) Pro"
     static let proDisplayName = "\(displayName) Pro"
@@ -24,12 +24,13 @@ enum AppStoreConfig {
     }
     }
 
 
     enum ProductID {
     enum ProductID {
-        // ASC allows only alphanumeric characters, underscores, and periods.
-        // Keep aligned with `Products.storekit` and bundled `paywall.json` plan productIDs.
-        static let monthly = PaywallConfig.loadBundled().plans.monthly.productID
-        static let yearly = PaywallConfig.loadBundled().plans.yearly.productID
-        static let lifetime = PaywallConfig.loadBundled().plans.lifetime.productID
+        static let monthly = StoreKitCatalog.shared.productID(for: .monthly)
+            ?? PaywallConfig.loadBundled().plans.monthly.productID
+        static let yearly = StoreKitCatalog.shared.productID(for: .yearly)
+            ?? PaywallConfig.loadBundled().plans.yearly.productID
+        static let lifetime = StoreKitCatalog.shared.productID(for: .lifetime)
+            ?? PaywallConfig.loadBundled().plans.lifetime.productID
 
 
-        static let all: Set<String> = PaywallConfig.loadBundled().allProductIDs
+        static let all: Set<String> = StoreKitCatalog.allProductIDs
     }
     }
 }
 }

+ 21 - 29
smart_printer/Managers/StoreManager.swift

@@ -45,12 +45,12 @@ final class StoreManager {
     private(set) var purchaseError: PurchaseError?
     private(set) var purchaseError: PurchaseError?
 
 
     var isPro: Bool { isPremium }
     var isPro: Bool { isPremium }
-    var hasActiveSubscription: Bool { activePremiumPlan == .monthly || activePremiumPlan == .yearly }
-    var hasLifetimeAccess: Bool { activePremiumPlan == .lifetime }
+    var hasActiveSubscription: Bool { activePremiumPlan?.isSubscription == true }
+    var hasLifetimeAccess: Bool { activePremiumPlan?.isLifetime == true }
     var premiumAccessKind: PremiumAccessKind {
     var premiumAccessKind: PremiumAccessKind {
-        switch activePremiumPlan {
+        switch activePremiumPlan?.kind {
         case .lifetime: .lifetime
         case .lifetime: .lifetime
-        case .monthly, .yearly: .subscription
+        case .weekly, .monthly, .yearly: .subscription
         case nil: .none
         case nil: .none
         }
         }
     }
     }
@@ -103,9 +103,9 @@ final class StoreManager {
         let migratedPlan: String?
         let migratedPlan: String?
         switch legacyKind {
         switch legacyKind {
         case PremiumAccessKind.lifetime.rawValue:
         case PremiumAccessKind.lifetime.rawValue:
-            migratedPlan = PaywallPlan.lifetime.rawValue
+            migratedPlan = StoreKitCatalog.shared.productID(for: .lifetime)
         case PremiumAccessKind.subscription.rawValue:
         case PremiumAccessKind.subscription.rawValue:
-            migratedPlan = PaywallPlan.yearly.rawValue
+            migratedPlan = StoreKitCatalog.shared.productID(for: .yearly)
         default:
         default:
             migratedPlan = nil
             migratedPlan = nil
         }
         }
@@ -118,23 +118,23 @@ final class StoreManager {
     private func restoredCachedPremiumPlan() -> PaywallPlan? {
     private func restoredCachedPremiumPlan() -> PaywallPlan? {
         guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return nil }
         guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return nil }
 
 
-        if let raw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
-           let plan = PaywallPlan(rawValue: raw) {
+        if let stored = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
+           let plan = PaywallPlan.fromStoredValue(stored) {
             return plan
             return plan
         }
         }
 
 
         if let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") {
         if let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") {
             switch legacyKind {
             switch legacyKind {
             case PremiumAccessKind.lifetime.rawValue:
             case PremiumAccessKind.lifetime.rawValue:
-                return .lifetime
+                return StoreKitCatalog.shared.productID(for: .lifetime).map(PaywallPlan.init(productID:))
             case PremiumAccessKind.subscription.rawValue:
             case PremiumAccessKind.subscription.rawValue:
-                return .yearly
+                return StoreKitCatalog.shared.productID(for: .yearly).map(PaywallPlan.init(productID:))
             default:
             default:
                 break
                 break
             }
             }
         }
         }
 
 
-        return .yearly
+        return StoreKitCatalog.shared.productID(for: .yearly).map(PaywallPlan.init(productID:))
     }
     }
 
 
     func start() {
     func start() {
@@ -418,7 +418,7 @@ final class StoreManager {
             }
             }
         }
         }
 
 
-        if resolvedPlan == nil || resolvedPlan != .lifetime {
+        if resolvedPlan == nil || resolvedPlan?.isLifetime != true {
             if let subscriptionPlan = await activeSubscriptionPlan() {
             if let subscriptionPlan = await activeSubscriptionPlan() {
                 resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
                 resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
             }
             }
@@ -591,9 +591,9 @@ final class StoreManager {
     private func applyPremiumStatus(plan: PaywallPlan?) {
     private func applyPremiumStatus(plan: PaywallPlan?) {
         let hasPremium = plan != nil
         let hasPremium = plan != nil
         let kind: PremiumAccessKind
         let kind: PremiumAccessKind
-        switch plan {
+        switch plan?.kind {
         case .lifetime: kind = .lifetime
         case .lifetime: kind = .lifetime
-        case .monthly, .yearly: kind = .subscription
+        case .weekly, .monthly, .yearly: kind = .subscription
         case nil: kind = .none
         case nil: kind = .none
         }
         }
 
 
@@ -605,7 +605,7 @@ final class StoreManager {
         activePremiumPlan = plan
         activePremiumPlan = plan
         UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
         UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
         if let plan {
         if let plan {
-            UserDefaults.standard.set(plan.rawValue, forKey: Self.cachedPremiumPlanKey)
+            UserDefaults.standard.set(plan.productID, forKey: Self.cachedPremiumPlanKey)
         } else {
         } else {
             UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
             UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
         }
         }
@@ -631,20 +631,13 @@ final class StoreManager {
     }
     }
 
 
     private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
     private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
-        if current == .lifetime || candidate == .lifetime {
-            return .lifetime
-        }
-        if current == .yearly || candidate == .yearly {
-            return .yearly
-        }
-        if current == .monthly || candidate == .monthly {
-            return .monthly
-        }
-        return candidate
+        StoreKitCatalog.shared.preferredPlan(current, candidate)
     }
     }
 
 
     private func activeLifetimePlan() async -> PaywallPlan? {
     private func activeLifetimePlan() async -> PaywallPlan? {
-        let lifetimeProductID = PaywallPlan.lifetime.productID
+        guard let lifetimeProductID = StoreKitCatalog.shared.productID(for: .lifetime) else {
+            return nil
+        }
         guard let result = await Transaction.latest(for: lifetimeProductID) else {
         guard let result = await Transaction.latest(for: lifetimeProductID) else {
             return nil
             return nil
         }
         }
@@ -657,7 +650,7 @@ final class StoreManager {
 
 
         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 let plan = planGrantingPremium(from: transaction), plan != .lifetime else { continue }
+            guard let plan = planGrantingPremium(from: transaction), !plan.isLifetime else { continue }
             resolvedPlan = preferredPlan(resolvedPlan, plan)
             resolvedPlan = preferredPlan(resolvedPlan, plan)
         }
         }
 
 
@@ -665,8 +658,7 @@ final class StoreManager {
     }
     }
 
 
     private func activePlanFromSubscriptionStatus() async -> PaywallPlan? {
     private func activePlanFromSubscriptionStatus() async -> PaywallPlan? {
-        let subscriptionPlans = [PaywallPlan.monthly, .yearly]
-        for plan in subscriptionPlans {
+        for plan in StoreKitCatalog.shared.subscriptionPlans {
             let productID = plan.productID
             let productID = plan.productID
             let product: Product?
             let product: Product?
             if let cached = productsByID[productID] {
             if let cached = productsByID[productID] {

+ 68 - 6
smart_printer/PaywallConfig.swift

@@ -279,10 +279,6 @@ extension PaywallConfig {
         return formatDuration(count: fallback.count, unit: unit, lowercase: lowercase)
         return formatDuration(count: fallback.count, unit: unit, lowercase: lowercase)
     }
     }
 
 
-    var trialEligiblePlan: PaywallPlan? {
-        PaywallPlan(rawValue: trial.eligiblePlan)
-    }
-
     private func subscriptionPeriodUnit(from value: String) -> Product.SubscriptionPeriod.Unit? {
     private func subscriptionPeriodUnit(from value: String) -> Product.SubscriptionPeriod.Unit? {
         switch value {
         switch value {
         case "day": .day
         case "day": .day
@@ -293,12 +289,78 @@ extension PaywallConfig {
         }
         }
     }
     }
 
 
+    var trialEligiblePlan: PaywallPlan? {
+        StoreKitCatalog.shared.plan(matching: trial.eligiblePlan)
+    }
+
     var allProductIDs: Set<String> {
     var allProductIDs: Set<String> {
-        Set(PaywallPlan.displayOrder.map(\.productID))
+        StoreKitCatalog.allProductIDs
     }
     }
 
 
     func plan(for productID: String) -> PaywallPlan? {
     func plan(for productID: String) -> PaywallPlan? {
-        PaywallPlan.displayOrder.first { $0.productID == productID }
+        guard StoreKitCatalog.shared.contains(productID: productID) else { return nil }
+        return PaywallPlan(productID: productID)
+    }
+
+    func planCopy(for productID: String) -> PlanCopy? {
+        if productID == plans.monthly.productID { return plans.monthly }
+        if productID == plans.yearly.productID { return plans.yearly }
+        if productID == plans.lifetime.productID { return plans.lifetime }
+        return nil
+    }
+
+    func synthesizedPlanCopy(for product: Product) -> PlanCopy {
+        let title = product.displayName
+
+        if product.type == .nonConsumable {
+            return PlanCopy(
+                productID: product.id,
+                title: title,
+                subtitleTemplate: plans.lifetime.subtitleTemplate,
+                ctaTemplate: plans.lifetime.ctaTemplate,
+                fallbackPrice: loadingPrice,
+                priceSuffix: plans.lifetime.priceSuffix,
+                fallbackBillingDescription: plans.lifetime.fallbackBillingDescription,
+                features: plans.lifetime.features
+            )
+        }
+
+        guard let subscription = product.subscription else {
+            return placeholderPlanCopy(for: product.id)
+        }
+
+        let periodLower = subscriptionPeriodDescription(from: subscription.subscriptionPeriod)
+        let periodTitle = formatDuration(
+            count: subscription.subscriptionPeriod.value,
+            unit: subscription.subscriptionPeriod.unit,
+            lowercase: false
+        )
+
+        return PlanCopy(
+            productID: product.id,
+            title: title,
+            subtitleTemplate: "{price} / \(periodLower), cancel anytime",
+            trialSubtitleTemplate: "{trial} free, then {price} / \(periodLower)",
+            ctaTemplate: "Subscribe for {price} / \(periodTitle)",
+            fallbackPrice: loadingPrice,
+            priceSuffix: "/\(periodLower)",
+            fallbackBillingDescription: "Billed at {price} every \(periodLower)",
+            features: plans.monthly.features
+        )
+    }
+
+    func placeholderPlanCopy(for productID: String) -> PlanCopy {
+        let title = StoreKitCatalog.shared.entry(for: productID)?.displayName ?? "Premium"
+        return PlanCopy(
+            productID: productID,
+            title: title,
+            subtitleTemplate: "{price}, cancel anytime",
+            ctaTemplate: "Subscribe for {price}",
+            fallbackPrice: loadingPrice,
+            priceSuffix: nil,
+            fallbackBillingDescription: "Billed at {price}",
+            features: plans.monthly.features
+        )
     }
     }
 }
 }
 
 

+ 48 - 31
smart_printer/PaywallModels.swift

@@ -1,23 +1,41 @@
 import Foundation
 import Foundation
 import StoreKit
 import StoreKit
 
 
-enum PaywallPlan: String, CaseIterable {
-    case monthly
-    case yearly
-    case lifetime
+struct PaywallPlan: Hashable, Sendable {
+    let productID: String
 
 
-    static let displayOrder: [PaywallPlan] = [.monthly, .yearly, .lifetime]
+    init(productID: String) {
+        self.productID = productID
+    }
+
+    static var displayOrder: [PaywallPlan] {
+        StoreKitCatalog.orderedPlans
+    }
+
+    var kind: PaywallPlanKind? {
+        StoreKitCatalog.shared.kind(for: productID)
+    }
+
+    var isLifetime: Bool {
+        kind == .lifetime
+    }
+
+    var isSubscription: Bool {
+        kind != .lifetime && kind != nil
+    }
 
 
-    var productID: String {
-        planCopy(from: PaywallConfig.loadBundled()).productID
+    static func fromStoredValue(_ value: String) -> PaywallPlan? {
+        StoreKitCatalog.shared.plan(matching: value)
     }
     }
 
 
-    func planCopy(from config: PaywallConfig) -> PaywallConfig.PlanCopy {
-        switch self {
-        case .monthly: config.plans.monthly
-        case .yearly: config.plans.yearly
-        case .lifetime: config.plans.lifetime
+    func planCopy(from config: PaywallConfig, product: Product? = nil) -> PaywallConfig.PlanCopy {
+        if let copy = config.planCopy(for: productID) {
+            return copy
         }
         }
+        if let product {
+            return config.synthesizedPlanCopy(for: product)
+        }
+        return config.placeholderPlanCopy(for: productID)
     }
     }
 
 
     func localizedPrice(from product: Product?, config: PaywallConfig) -> String {
     func localizedPrice(from product: Product?, config: PaywallConfig) -> String {
@@ -30,7 +48,7 @@ enum PaywallPlan: String, CaseIterable {
         trial: PaywallTrialDisplay? = nil
         trial: PaywallTrialDisplay? = nil
     ) -> String {
     ) -> String {
         guard let product else { return config.loadingPrice }
         guard let product else { return config.loadingPrice }
-        let copy = planCopy(from: config)
+        let copy = planCopy(from: config, product: product)
         let price = product.displayPrice
         let price = product.displayPrice
 
 
         if let trial, let trialTemplate = copy.trialSubtitleTemplate {
         if let trial, let trialTemplate = copy.trialSubtitleTemplate {
@@ -51,7 +69,7 @@ enum PaywallPlan: String, CaseIterable {
         if let trial {
         if let trial {
             return trial.ctaText
             return trial.ctaText
         }
         }
-        let copy = planCopy(from: config)
+        let copy = planCopy(from: config, product: product)
         return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: product.displayPrice)
         return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: product.displayPrice)
     }
     }
 
 
@@ -60,27 +78,26 @@ enum PaywallPlan: String, CaseIterable {
         config: PaywallConfig,
         config: PaywallConfig,
         trial: PaywallTrialDisplay? = nil
         trial: PaywallTrialDisplay? = nil
     ) -> String {
     ) -> String {
-        switch self {
-        case .lifetime:
+        if isLifetime {
             return config.lifetimeDisclosure
             return config.lifetimeDisclosure
-        case .monthly, .yearly:
-            guard let product else { return config.loadingDisclosure }
-            let price = product.displayPrice
-            let period = product.subscription.map {
-                config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
-            } ?? config.messages.periodFallback
-
-            if let trial {
-                return config.trialDisclosureTemplate
-                    .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
-                    .replacingOccurrences(of: "{price}", with: price)
-                    .replacingOccurrences(of: "{period}", with: period)
-            }
-
-            return config.subscriptionDisclosureTemplate
+        }
+
+        guard let product else { return config.loadingDisclosure }
+        let price = product.displayPrice
+        let period = product.subscription.map {
+            config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
+        } ?? config.messages.periodFallback
+
+        if let trial {
+            return config.trialDisclosureTemplate
+                .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
                 .replacingOccurrences(of: "{price}", with: price)
                 .replacingOccurrences(of: "{price}", with: price)
                 .replacingOccurrences(of: "{period}", with: period)
                 .replacingOccurrences(of: "{period}", with: period)
         }
         }
+
+        return config.subscriptionDisclosureTemplate
+            .replacingOccurrences(of: "{price}", with: price)
+            .replacingOccurrences(of: "{period}", with: period)
     }
     }
 }
 }
 
 

+ 7 - 6
smart_printer/PaywallView.swift

@@ -175,7 +175,8 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
         layer?.masksToBounds = false
         layer?.masksToBounds = false
 
 
         let config = PaywallConfigService.shared.config
         let config = PaywallConfigService.shared.config
-        titleLabel.stringValue = plan.planCopy(from: config).title
+        titleLabel.stringValue = StoreKitCatalog.shared.entry(for: plan.productID)?.displayName
+            ?? plan.planCopy(from: config).title
         titleLabel.font = AppTheme.semiboldFont(size: 15)
         titleLabel.font = AppTheme.semiboldFont(size: 15)
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
 
 
@@ -224,7 +225,7 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
         config: PaywallConfig,
         config: PaywallConfig,
         trial: PaywallTrialDisplay? = nil
         trial: PaywallTrialDisplay? = nil
     ) {
     ) {
-        titleLabel.stringValue = plan.planCopy(from: config).title
+        titleLabel.stringValue = plan.planCopy(from: config, product: product).title
         subtitleLabel.stringValue = plan.localizedSubtitle(from: product, config: config, trial: trial)
         subtitleLabel.stringValue = plan.localizedSubtitle(from: product, config: config, trial: trial)
         priceLabel.stringValue = plan.localizedPrice(from: product, config: config)
         priceLabel.stringValue = plan.localizedPrice(from: product, config: config)
         updateTrialBadge(trial: trial)
         updateTrialBadge(trial: trial)
@@ -623,7 +624,7 @@ private final class PaywallCTAButton: NSButton, AppearanceRefreshable {
 final class PaywallView: NSView, AppearanceRefreshable {
 final class PaywallView: NSView, AppearanceRefreshable {
     var onClose: (() -> Void)?
     var onClose: (() -> Void)?
 
 
-    private var selectedPlan: PaywallPlan = .yearly
+    private var selectedPlan: PaywallPlan = StoreKitCatalog.shared.defaultSelectedPlan()
     private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
     private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
     private let ctaButton = PaywallCTAButton()
     private let ctaButton = PaywallCTAButton()
     private let renewalDisclosureLabel = NSTextField(wrappingLabelWithString: "")
     private let renewalDisclosureLabel = NSTextField(wrappingLabelWithString: "")
@@ -823,7 +824,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
                     ? trialPlan
                     ? trialPlan
                     : nil
                     : nil
             }
             }
-            ?? availablePlans.first(where: { $0 == .yearly })
+            ?? availablePlans.first(where: { $0.kind == .yearly })
             ?? availablePlans[0]
             ?? availablePlans[0]
 
 
         selectPlan(preferred)
         selectPlan(preferred)
@@ -845,7 +846,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
 
 
     private func refreshSecurePaymentNote() {
     private func refreshSecurePaymentNote() {
         let config = paywallConfig
         let config = paywallConfig
-        securePaymentLabel.stringValue = selectedPlan == .lifetime
+        securePaymentLabel.stringValue = selectedPlan.isLifetime
             ? config.lifetimeSecurePaymentNote
             ? config.lifetimeSecurePaymentNote
             : config.securePaymentNote
             : config.securePaymentNote
     }
     }
@@ -903,7 +904,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
         guard trustItemViews.count > 1, paywallConfig.trustItems.count > 1 else { return }
         guard trustItemViews.count > 1, paywallConfig.trustItems.count > 1 else { return }
 
 
         let cancelItem = paywallConfig.trustItems[1]
         let cancelItem = paywallConfig.trustItems[1]
-        if selectedPlan == .lifetime {
+        if selectedPlan.isLifetime {
             let item = paywallConfig.lifetimeTrustItem
             let item = paywallConfig.lifetimeTrustItem
             trustItemViews[1].update(
             trustItemViews[1].update(
                 iconName: item.icon,
                 iconName: item.icon,

+ 237 - 0
smart_printer/Services/StoreKitCatalog.swift

@@ -0,0 +1,237 @@
+import Foundation
+import StoreKit
+
+enum PaywallPlanKind: String, CaseIterable, Sendable {
+    case weekly
+    case monthly
+    case yearly
+    case lifetime
+}
+
+struct StoreKitCatalogEntry: Sendable {
+    let productID: String
+    let displayName: String?
+    let kind: PaywallPlanKind
+    let sortOrder: Int
+}
+
+enum StoreKitCatalog {
+    static let shared: StoreKitCatalogSnapshot = StoreKitCatalogSnapshot()
+
+    static var orderedPlans: [PaywallPlan] { shared.orderedPlans }
+    static var allProductIDs: Set<String> { shared.allProductIDs }
+}
+
+struct StoreKitCatalogSnapshot: Sendable {
+    let entries: [StoreKitCatalogEntry]
+    let orderedPlans: [PaywallPlan]
+    let allProductIDs: Set<String>
+    private let entriesByProductID: [String: StoreKitCatalogEntry]
+
+    init() {
+        let loadedEntries = Self.loadEntriesFromBundle() ?? Self.fallbackEntries()
+        entries = loadedEntries
+        orderedPlans = loadedEntries.map { PaywallPlan(productID: $0.productID) }
+        allProductIDs = Set(loadedEntries.map(\.productID))
+        entriesByProductID = Dictionary(uniqueKeysWithValues: loadedEntries.map { ($0.productID, $0) })
+    }
+
+    func contains(productID: String) -> Bool {
+        allProductIDs.contains(productID)
+    }
+
+    func entry(for productID: String) -> StoreKitCatalogEntry? {
+        entriesByProductID[productID]
+    }
+
+    func kind(for productID: String) -> PaywallPlanKind? {
+        entry(for: productID)?.kind
+    }
+
+    func productID(for kind: PaywallPlanKind) -> String? {
+        entries.first(where: { $0.kind == kind })?.productID
+    }
+
+    func plan(matching storedValue: String) -> PaywallPlan? {
+        if contains(productID: storedValue) {
+            return PaywallPlan(productID: storedValue)
+        }
+        if let kind = PaywallPlanKind(rawValue: storedValue),
+           let productID = productID(for: kind) {
+            return PaywallPlan(productID: productID)
+        }
+        return nil
+    }
+
+    func defaultSelectedPlan() -> PaywallPlan {
+        if let yearlyID = productID(for: .yearly) {
+            return PaywallPlan(productID: yearlyID)
+        }
+        if let firstSubscription = entries.first(where: { $0.kind != .lifetime }) {
+            return PaywallPlan(productID: firstSubscription.productID)
+        }
+        if let first = entries.first {
+            return PaywallPlan(productID: first.productID)
+        }
+        let bundled = PaywallConfig.loadBundled()
+        return PaywallPlan(productID: bundled.plans.yearly.productID)
+    }
+
+    func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
+        guard let current else { return candidate }
+        let currentTier = tier(for: current.productID)
+        let candidateTier = tier(for: candidate.productID)
+        return candidateTier >= currentTier ? candidate : current
+    }
+
+    func tier(for productID: String) -> Int {
+        entry(for: productID)?.sortOrder ?? 0
+    }
+
+    var subscriptionPlans: [PaywallPlan] {
+        orderedPlans.filter { kind(for: $0.productID) != .lifetime }
+    }
+
+    private static func loadEntriesFromBundle() -> [StoreKitCatalogEntry]? {
+        guard let url = Bundle.main.url(forResource: "Products", withExtension: "storekit"),
+              let data = try? Data(contentsOf: url),
+              let file = try? JSONDecoder().decode(StoreKitConfigurationFile.self, from: data) else {
+            return nil
+        }
+
+        var entries: [StoreKitCatalogEntry] = []
+
+        for product in file.products {
+            entries.append(
+                StoreKitCatalogEntry(
+                    productID: product.productID,
+                    displayName: product.preferredDisplayName,
+                    kind: kind(forProductID: product.productID, type: product.type),
+                    sortOrder: 1_000
+                )
+            )
+        }
+
+        for group in file.subscriptionGroups {
+            for subscription in group.subscriptions {
+                entries.append(
+                    StoreKitCatalogEntry(
+                        productID: subscription.productID,
+                        displayName: subscription.preferredDisplayName,
+                        kind: kind(
+                            forProductID: subscription.productID,
+                            subscriptionPeriod: subscription.recurringSubscriptionPeriod
+                        ),
+                        sortOrder: sortOrder(for: subscription.recurringSubscriptionPeriod)
+                    )
+                )
+            }
+        }
+
+        guard !entries.isEmpty else { return nil }
+
+        return entries.sorted { lhs, rhs in
+            if lhs.sortOrder == rhs.sortOrder {
+                return lhs.productID < rhs.productID
+            }
+            return lhs.sortOrder < rhs.sortOrder
+        }
+    }
+
+    private static func fallbackEntries() -> [StoreKitCatalogEntry] {
+        let config = PaywallConfig.loadBundled()
+        return [
+            StoreKitCatalogEntry(
+                productID: config.plans.monthly.productID,
+                displayName: config.plans.monthly.title,
+                kind: .monthly,
+                sortOrder: 20
+            ),
+            StoreKitCatalogEntry(
+                productID: config.plans.yearly.productID,
+                displayName: config.plans.yearly.title,
+                kind: .yearly,
+                sortOrder: 30
+            ),
+            StoreKitCatalogEntry(
+                productID: config.plans.lifetime.productID,
+                displayName: config.plans.lifetime.title,
+                kind: .lifetime,
+                sortOrder: 1_000
+            ),
+        ]
+    }
+
+    private static func kind(
+        forProductID productID: String,
+        type: String? = nil,
+        subscriptionPeriod: String? = nil
+    ) -> PaywallPlanKind {
+        if let suffixKind = PaywallPlanKind.allCases.first(where: { productID.hasSuffix(".\($0.rawValue)") }) {
+            return suffixKind
+        }
+        if type == "NonConsumable" {
+            return .lifetime
+        }
+        switch subscriptionPeriod {
+        case "P1W": return .weekly
+        case "P1M": return .monthly
+        case "P1Y": return .yearly
+        default: return .monthly
+        }
+    }
+
+    private static func sortOrder(for subscriptionPeriod: String?) -> Int {
+        switch subscriptionPeriod {
+        case "P1W": return 10
+        case "P1M": return 20
+        case "P1Y": return 30
+        default: return 40
+        }
+    }
+}
+
+private struct StoreKitConfigurationFile: Decodable {
+    struct Product: Decodable {
+        struct Localization: Decodable {
+            let displayName: String
+            let locale: String
+        }
+
+        let productID: String
+        let type: String
+        let localizations: [Localization]?
+    }
+
+    struct SubscriptionGroup: Decodable {
+        struct Subscription: Decodable {
+            struct Localization: Decodable {
+                let displayName: String
+                let locale: String
+            }
+
+            let productID: String
+            let recurringSubscriptionPeriod: String?
+            let localizations: [Localization]?
+        }
+
+        let subscriptions: [Subscription]
+    }
+
+    let products: [Product]
+    let subscriptionGroups: [SubscriptionGroup]
+}
+
+private extension StoreKitConfigurationFile.Product {
+    var preferredDisplayName: String? {
+        localizations?.first(where: { $0.locale.hasPrefix("en") })?.displayName
+            ?? localizations?.first?.displayName
+    }
+}
+
+private extension StoreKitConfigurationFile.SubscriptionGroup.Subscription {
+    var preferredDisplayName: String? {
+        localizations?.first(where: { $0.locale.hasPrefix("en") })?.displayName
+            ?? localizations?.first?.displayName
+    }
+}

+ 1 - 1
smart_printer/UIComponents.swift

@@ -263,7 +263,7 @@ final class SidebarPremiumCardView: NSView, AppearanceRefreshable {
 
 
         let actionTitle: String
         let actionTitle: String
         if isPremium {
         if isPremium {
-            actionTitle = activePlan == .lifetime
+            actionTitle = activePlan?.isLifetime == true
                 ? sidebar.lifetimeAction
                 ? sidebar.lifetimeAction
                 : sidebar.manageSubscriptionAction
                 : sidebar.manageSubscriptionAction
         } else {
         } else {