Kaynağa Gözat

Make paywall plans config-driven via product IDs and add weekly subscription support.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 hafta önce
ebeveyn
işleme
6e16c91a4c

+ 64 - 53
App AI for Reddit/Managers/SubscriptionManager.swift

@@ -31,6 +31,7 @@ final class SubscriptionManager: ObservableObject {
 
 
     @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 availablePaywallPlans: [PaywallPlan] = []
     @Published private(set) var purchasingPlan: PaywallPlan?
     @Published private(set) var purchasingPlan: PaywallPlan?
     @Published private(set) var isRestoringPurchases = false
     @Published private(set) var isRestoringPurchases = false
     @Published private(set) var hasPremiumAccess = false
     @Published private(set) var hasPremiumAccess = false
@@ -41,24 +42,21 @@ final class SubscriptionManager: ObservableObject {
     @Published private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:]
     @Published private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:]
     @Published var purchaseError: PurchaseError?
     @Published var purchaseError: PurchaseError?
 
 
-    var availablePaywallPlans: [PaywallPlan] {
-        PaywallPlan.allCases
-    }
-
     var isResolvingEntitlements: Bool {
     var isResolvingEntitlements: Bool {
         !hasResolvedPremiumStatus
         !hasResolvedPremiumStatus
     }
     }
 
 
     var hasLifetimeAccess: Bool {
     var hasLifetimeAccess: Bool {
-        activePremiumPlan == .lifetime
+        activePremiumPlan?.isLifetime == true
     }
     }
 
 
     var hasActiveSubscription: Bool {
     var hasActiveSubscription: Bool {
-        activePremiumPlan == .monthly || activePremiumPlan == .yearly
+        guard let activePremiumPlan else { return false }
+        return !activePremiumPlan.isLifetime
     }
     }
 
 
     var hasAllProductsLoaded: Bool {
     var hasAllProductsLoaded: Bool {
-        SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
+        !availablePaywallPlans.isEmpty
     }
     }
 
 
     var isEligibleForFreeAITier: Bool {
     var isEligibleForFreeAITier: Bool {
@@ -88,7 +86,7 @@ final class SubscriptionManager: ObservableObject {
             launchedWithCachedPremium = true
             launchedWithCachedPremium = true
             hasPremiumAccess = true
             hasPremiumAccess = true
             if let planRaw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
             if let planRaw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
-               let plan = PaywallPlan(rawValue: planRaw) {
+               let plan = PaywallPlan.from(rawValue: planRaw) {
                 activePremiumPlan = plan
                 activePremiumPlan = plan
             }
             }
         }
         }
@@ -178,7 +176,9 @@ final class SubscriptionManager: ObservableObject {
     }
     }
 
 
     func priceSuffix(for plan: PaywallPlan) -> String? {
     func priceSuffix(for plan: PaywallPlan) -> String? {
-        plan.planCopy(from: PaywallConfigService.shared.config).priceSuffix
+        let config = PaywallConfigService.shared.config
+        let product = product(for: plan)
+        return plan.planCopy(from: config, product: product).priceSuffix
     }
     }
 
 
     func billingDescription(for plan: PaywallPlan) -> String {
     func billingDescription(for plan: PaywallPlan) -> String {
@@ -187,20 +187,20 @@ final class SubscriptionManager: ObservableObject {
             return config.loadingDisclosure
             return config.loadingDisclosure
         }
         }
 
 
-        switch plan {
-        case .monthly:
-            return "Billed at \(product.displayPrice) every month"
-        case .yearly:
-            return "Billed at \(product.displayPrice) every year"
-        case .lifetime:
+        if product.type == .nonConsumable {
             return "One-time payment of \(product.displayPrice)"
             return "One-time payment of \(product.displayPrice)"
         }
         }
+
+        let period = product.subscription.map {
+            config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
+        } ?? config.messages.periodFallback
+        return "Billed at \(product.displayPrice) every \(period)"
     }
     }
 
 
     func purchase(_ plan: PaywallPlan) async -> Bool {
     func purchase(_ plan: PaywallPlan) async -> Bool {
         purchaseError = nil
         purchaseError = nil
 
 
-        guard availablePaywallPlans.contains(plan) else {
+        guard availablePaywallPlans.contains(where: { $0 == plan }) else {
             purchaseError = .planUnavailable
             purchaseError = .planUnavailable
             return false
             return false
         }
         }
@@ -212,21 +212,7 @@ final class SubscriptionManager: ObservableObject {
         }
         }
 
 
         guard let product else {
         guard let product else {
-            let missingProductIDs = SubscriptionProductID.all.filter { productsByID[$0] == nil }
-            if missingProductIDs.isEmpty {
-                purchaseError = .subscriptionUnavailable
-            } else {
-                purchaseError = .planUnavailable
-                #if DEBUG
-                print(
-                    """
-                    [SubscriptionManager] Missing StoreKit products: \(missingProductIDs.joined(separator: ", "))
-                    To test locally, attach `Paywall.storekit` in Xcode:
-                    Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration.
-                    """
-                )
-                #endif
-            }
+            purchaseError = .planUnavailable
             return false
             return false
         }
         }
 
 
@@ -292,7 +278,9 @@ final class SubscriptionManager: ObservableObject {
     }
     }
 
 
     private func performLoadProducts(presentLoadingUI: Bool?) async {
     private func performLoadProducts(presentLoadingUI: Bool?) async {
-        let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
+        let config = PaywallConfigService.shared.config
+        let requestedProductIDs = Set(SubscriptionProductID.productIDs(from: config))
+        let missingAny = availablePaywallPlans.isEmpty
         let shouldShowLoading = presentLoadingUI ?? missingAny
         let shouldShowLoading = presentLoadingUI ?? missingAny
 
 
         if !missingAny, presentLoadingUI != true {
         if !missingAny, presentLoadingUI != true {
@@ -314,10 +302,14 @@ final class SubscriptionManager: ObservableObject {
         purchaseError = nil
         purchaseError = nil
         productLoadError = nil
         productLoadError = nil
         do {
         do {
-            let loaded = try await loadProductsWithTimeout(seconds: 12)
+            let loaded = try await loadProductsWithTimeout(
+                productIDs: requestedProductIDs,
+                seconds: 12
+            )
             guard !loaded.isEmpty else {
             guard !loaded.isEmpty else {
                 productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
                 productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
                 productsByID = [:]
                 productsByID = [:]
+                availablePaywallPlans = []
                 return
                 return
             }
             }
             var map: [String: Product] = [:]
             var map: [String: Product] = [:]
@@ -325,6 +317,7 @@ final class SubscriptionManager: ObservableObject {
                 map[product.id] = product
                 map[product.id] = product
             }
             }
             productsByID = map
             productsByID = map
+            updateAvailablePaywallPlans(from: map, requestedProductIDs: requestedProductIDs)
             productLoadError = nil
             productLoadError = nil
             await refreshPremiumAccess()
             await refreshPremiumAccess()
             await refreshTrialOffers()
             await refreshTrialOffers()
@@ -337,10 +330,21 @@ final class SubscriptionManager: ObservableObject {
         }
         }
     }
     }
 
 
-    private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
+    private func updateAvailablePaywallPlans(
+        from productsByID: [String: Product],
+        requestedProductIDs: Set<String>
+    ) {
+        availablePaywallPlans = requestedProductIDs
+            .compactMap { productID in
+                productsByID[productID].map { _ in PaywallPlan(productID: productID) }
+            }
+            .sorted(by: PaywallPlan.sortOrder)
+    }
+
+    private func loadProductsWithTimeout(productIDs: Set<String>, seconds: TimeInterval) async throws -> [Product] {
         try await withThrowingTaskGroup(of: [Product].self) { group in
         try await withThrowingTaskGroup(of: [Product].self) { group in
             group.addTask {
             group.addTask {
-                try await Product.products(for: SubscriptionProductID.all)
+                try await Product.products(for: productIDs)
             }
             }
             group.addTask {
             group.addTask {
                 let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
                 let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
@@ -371,7 +375,7 @@ final class SubscriptionManager: ObservableObject {
             }
             }
         }
         }
 
 
-        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)
             }
             }
@@ -501,7 +505,7 @@ final class SubscriptionManager: ObservableObject {
 
 
         for await result in Transaction.all {
         for await result in Transaction.all {
             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.productIDs(from: PaywallConfigService.shared.config).contains(transaction.productID) else { continue }
 
 
             foundPurchase = true
             foundPurchase = true
             if !hasEverPurchasedPremium {
             if !hasEverPurchasedPremium {
@@ -539,7 +543,8 @@ final class SubscriptionManager: ObservableObject {
 
 
     private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
     private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
         let config = PaywallConfigService.shared.config
         let config = PaywallConfigService.shared.config
-        guard plan == config.trialEligiblePlan else { return nil }
+        guard let eligiblePlan = config.trialEligiblePlan,
+              plan.productID == eligiblePlan.productID else { return nil }
         guard let fallback = config.trial.fallbackDuration else { return nil }
         guard let fallback = config.trial.fallbackDuration else { return nil }
 
 
         if let product = product(for: plan),
         if let product = product(for: plan),
@@ -554,8 +559,8 @@ final class SubscriptionManager: ObservableObject {
 
 
     private func persistPremiumCache() {
     private func persistPremiumCache() {
         UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
         UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
-        if let plan = activePremiumPlan?.rawValue {
-            UserDefaults.standard.set(plan, forKey: Self.cachedPremiumPlanKey)
+        if let plan = activePremiumPlan {
+            UserDefaults.standard.set(plan.productID, forKey: Self.cachedPremiumPlanKey)
         } else {
         } else {
             UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
             UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
         }
         }
@@ -567,7 +572,8 @@ final class SubscriptionManager: ObservableObject {
     }
     }
 
 
     private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
     private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
-        guard SubscriptionProductID.all.contains(transaction.productID) else { return false }
+        let knownProductIDs = Set(SubscriptionProductID.productIDs(from: PaywallConfigService.shared.config))
+        guard knownProductIDs.contains(transaction.productID) else { return false }
         guard transaction.revocationDate == nil else { return false }
         guard transaction.revocationDate == nil else { return false }
         if let expirationDate = transaction.expirationDate, expirationDate < Date() {
         if let expirationDate = transaction.expirationDate, expirationDate < Date() {
             return false
             return false
@@ -576,22 +582,26 @@ final class SubscriptionManager: ObservableObject {
     }
     }
 
 
     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
+        let order = [
+            SubscriptionProductID.lifetime,
+            SubscriptionProductID.yearly,
+            SubscriptionProductID.monthly,
+            SubscriptionProductID.weekly,
+        ]
+
+        func rank(for plan: PaywallPlan) -> Int {
+            order.firstIndex(of: plan.productID) ?? Int.max
         }
         }
-        return candidate
+
+        guard let current else { return candidate }
+        return rank(for: candidate) < rank(for: current) ? candidate : current
     }
     }
 
 
     private func activePlanFromLatestTransactions() async -> PaywallPlan? {
     private func activePlanFromLatestTransactions() async -> PaywallPlan? {
         var resolvedPlan: PaywallPlan?
         var resolvedPlan: PaywallPlan?
+        let productIDs = SubscriptionProductID.productIDs(from: PaywallConfigService.shared.config)
 
 
-        for productID in SubscriptionProductID.all {
+        for productID in productIDs {
             guard let result = await Transaction.latest(for: productID) else { continue }
             guard let result = await Transaction.latest(for: productID) else { continue }
             guard case .verified(let transaction) = result else { continue }
             guard case .verified(let transaction) = result else { continue }
             guard let plan = planGrantingPremium(from: transaction) else { continue }
             guard let plan = planGrantingPremium(from: transaction) else { continue }
@@ -614,7 +624,7 @@ final class SubscriptionManager: ObservableObject {
 
 
         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)
         }
         }
 
 
@@ -648,6 +658,7 @@ final class SubscriptionManager: ObservableObject {
     }
     }
 
 
     private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
     private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
-        SubscriptionProductID.all.contains(transaction.productID) && transaction.revocationDate != nil
+        let knownProductIDs = Set(SubscriptionProductID.productIDs(from: PaywallConfigService.shared.config))
+        return knownProductIDs.contains(transaction.productID) && transaction.revocationDate != nil
     }
     }
 }
 }

+ 82 - 4
App AI for Reddit/Models/PaywallConfig.swift

@@ -59,9 +59,24 @@ struct PaywallConfig: Codable, Sendable {
     }
     }
 
 
     struct Plans: Codable, Sendable {
     struct Plans: Codable, Sendable {
-        let monthly: PlanCopy
-        let yearly: PlanCopy
-        let lifetime: PlanCopy
+        private let byKey: [String: PlanCopy]
+
+        init(byKey: [String: PlanCopy]) {
+            self.byKey = byKey
+        }
+
+        init(from decoder: Decoder) throws {
+            byKey = try decoder.singleValueContainer().decode([String: PlanCopy].self)
+        }
+
+        func encode(to encoder: Encoder) throws {
+            var container = encoder.singleValueContainer()
+            try container.encode(byKey)
+        }
+
+        func planCopy(for key: String) -> PlanCopy? {
+            byKey[key]
+        }
     }
     }
 
 
     struct DurationUnit: Codable, Sendable {
     struct DurationUnit: Codable, Sendable {
@@ -128,6 +143,7 @@ struct PaywallConfig: Codable, Sendable {
         let lifetimeAction: String
         let lifetimeAction: String
     }
     }
 
 
+    let productIDs: [String]?
     let leftPanelTitle: String
     let leftPanelTitle: String
     let rightTitle: String
     let rightTitle: String
     let rightSubtitle: String
     let rightSubtitle: String
@@ -154,6 +170,7 @@ struct PaywallConfig: Codable, Sendable {
 extension PaywallConfig {
 extension PaywallConfig {
     func mergingMarketing(from remote: PaywallConfig) -> PaywallConfig {
     func mergingMarketing(from remote: PaywallConfig) -> PaywallConfig {
         PaywallConfig(
         PaywallConfig(
+            productIDs: productIDs ?? remote.productIDs,
             leftPanelTitle: remote.leftPanelTitle,
             leftPanelTitle: remote.leftPanelTitle,
             rightTitle: remote.rightTitle,
             rightTitle: remote.rightTitle,
             rightSubtitle: remote.rightSubtitle,
             rightSubtitle: remote.rightSubtitle,
@@ -274,7 +291,68 @@ extension PaywallConfig {
     }
     }
 
 
     var trialEligiblePlan: PaywallPlan? {
     var trialEligiblePlan: PaywallPlan? {
-        PaywallPlan(rawValue: trial.eligiblePlan)
+        PaywallPlan.from(rawValue: trial.eligiblePlan)
+    }
+
+    var fallbackPlanCopy: PlanCopy {
+        PlanCopy(
+            title: "Premium",
+            subtitleTemplate: "{price}",
+            ctaTemplate: "Get Premium for {price}",
+            fallbackPrice: loadingPrice,
+            fallbackBillingDescription: "Billed at {price}",
+            features: Array(features.prefix(3))
+        )
+    }
+
+    func generatedPlanCopy(for product: Product) -> PlanCopy {
+        let title = product.displayName
+        let cardFeatures = Array(features.prefix(3))
+
+        if product.type == .nonConsumable {
+            return PlanCopy(
+                title: title,
+                badge: plans.planCopy(for: "lifetime")?.badge,
+                subtitleTemplate: "{price} once, lifetime access",
+                ctaTemplate: "Unlock Lifetime for {price}",
+                fallbackPrice: product.displayPrice,
+                fallbackBillingDescription: "One-time payment of {price}",
+                features: cardFeatures
+            )
+        }
+
+        if let period = product.subscription?.subscriptionPeriod {
+            let periodLabel = subscriptionPeriodDescription(from: period)
+            let suffix = shortPeriodSuffix(from: period)
+            return PlanCopy(
+                title: title,
+                subtitleTemplate: "{price} / \(periodLabel), cancel anytime",
+                ctaTemplate: "Get Premium for {price}",
+                fallbackPrice: product.displayPrice,
+                priceSuffix: suffix,
+                fallbackBillingDescription: "Billed at {price} every \(periodLabel)",
+                features: cardFeatures
+            )
+        }
+
+        return PlanCopy(
+            title: title,
+            subtitleTemplate: "{price}",
+            ctaTemplate: "Get Premium for {price}",
+            fallbackPrice: product.displayPrice,
+            fallbackBillingDescription: "Billed at {price}",
+            features: cardFeatures
+        )
+    }
+
+    private func shortPeriodSuffix(from period: Product.SubscriptionPeriod) -> String {
+        switch period.unit {
+        case .day: "/day"
+        case .week: "/wk"
+        case .month: "/mo"
+        case .year: "/yr"
+        @unknown default: ""
+        }
     }
     }
 
 
     private func subscriptionPeriodUnit(from value: String) -> Product.SubscriptionPeriod.Unit? {
     private func subscriptionPeriodUnit(from value: String) -> Product.SubscriptionPeriod.Unit? {

+ 73 - 37
App AI for Reddit/Models/PaywallModels.swift

@@ -29,31 +29,45 @@ struct PaywallTrialDisplay: Equatable, Sendable {
     }
     }
 }
 }
 
 
-enum PaywallPlan: String, CaseIterable, Identifiable {
-    case monthly
-    case yearly
-    case lifetime
-
-    var id: String { rawValue }
-
-    var productID: String {
-        switch self {
-        case .monthly: SubscriptionProductID.monthly
-        case .yearly: SubscriptionProductID.yearly
-        case .lifetime: SubscriptionProductID.lifetime
+struct PaywallPlan: Identifiable, Hashable, Sendable {
+    let productID: String
+
+    var id: String { productID }
+
+    var planKey: String {
+        SubscriptionProductID.planKey(for: productID)
+    }
+
+    init(productID: String) {
+        self.productID = productID
+    }
+
+    static func from(rawValue: String) -> PaywallPlan? {
+        if rawValue.contains(".") {
+            return PaywallPlan(productID: rawValue)
+        }
+
+        switch rawValue {
+        case "weekly": return PaywallPlan(productID: SubscriptionProductID.weekly)
+        case "monthly": return PaywallPlan(productID: SubscriptionProductID.monthly)
+        case "yearly": return PaywallPlan(productID: SubscriptionProductID.yearly)
+        case "lifetime": return PaywallPlan(productID: SubscriptionProductID.lifetime)
+        default: return nil
         }
         }
     }
     }
 
 
-    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 configured = config.plans.planCopy(for: planKey) {
+            return configured
+        }
+        if let product {
+            return config.generatedPlanCopy(for: product)
         }
         }
+        return config.fallbackPlanCopy
     }
     }
 
 
     func localizedPrice(from product: Product?, config: PaywallConfig) -> String {
     func localizedPrice(from product: Product?, config: PaywallConfig) -> String {
-        product?.displayPrice ?? config.loadingPrice
+        product?.displayPrice ?? planCopy(from: config, product: product).fallbackPrice
     }
     }
 
 
     func localizedSubtitle(
     func localizedSubtitle(
@@ -62,7 +76,7 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
         trial: PaywallTrialDisplay? = nil
         trial: PaywallTrialDisplay? = nil
     ) -> String {
     ) -> String {
         guard let product else { return "" }
         guard let product else { return "" }
-        let copy = planCopy(from: config)
+        let copy = planCopy(from: config, product: product)
         if let trial, let template = copy.trialSubtitleTemplate {
         if let trial, let template = copy.trialSubtitleTemplate {
             return template
             return template
                 .replacingOccurrences(of: "{price}", with: product.displayPrice)
                 .replacingOccurrences(of: "{price}", with: product.displayPrice)
@@ -80,7 +94,7 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
         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)
     }
     }
 
 
@@ -89,30 +103,52 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
         config: PaywallConfig,
         config: PaywallConfig,
         trial: PaywallTrialDisplay? = nil
         trial: PaywallTrialDisplay? = nil
     ) -> String {
     ) -> String {
-        switch self {
-        case .lifetime:
+        if product?.type == .nonConsumable {
             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)
     }
     }
 
 
-    func fallbackBillingDescription(config: PaywallConfig) -> String {
-        let copy = planCopy(from: config)
+    func fallbackBillingDescription(config: PaywallConfig, product: Product? = nil) -> String {
+        let copy = planCopy(from: config, product: product)
         return copy.fallbackBillingDescription
         return copy.fallbackBillingDescription
             .replacingOccurrences(of: "{price}", with: copy.fallbackPrice)
             .replacingOccurrences(of: "{price}", with: copy.fallbackPrice)
     }
     }
+
+    var isLifetime: Bool {
+        productID == SubscriptionProductID.lifetime
+    }
+}
+
+extension PaywallPlan {
+    static func sortOrder(_ lhs: PaywallPlan, _ rhs: PaywallPlan) -> Bool {
+        let order = [
+            SubscriptionProductID.weekly,
+            SubscriptionProductID.monthly,
+            SubscriptionProductID.yearly,
+            SubscriptionProductID.lifetime,
+        ]
+        let leftIndex = order.firstIndex(of: lhs.productID) ?? Int.max
+        let rightIndex = order.firstIndex(of: rhs.productID) ?? Int.max
+        if leftIndex != rightIndex {
+            return leftIndex < rightIndex
+        }
+        return lhs.productID < rhs.productID
+    }
 }
 }

+ 14 - 7
App AI for Reddit/Utilities/SubscriptionProductID.swift

@@ -1,18 +1,25 @@
 import Foundation
 import Foundation
 
 
 enum SubscriptionProductID {
 enum SubscriptionProductID {
+    static let weekly = "com.app-for-reddit.pro.weekly"
     static let monthly = "com.app-for-reddit.pro.monthly"
     static let monthly = "com.app-for-reddit.pro.monthly"
     static let yearly = "com.app-for-reddit.pro.yearly"
     static let yearly = "com.app-for-reddit.pro.yearly"
     static let lifetime = "com.app-for-reddit.pro.lifetime"
     static let lifetime = "com.app-for-reddit.pro.lifetime"
 
 
-    static let all: Set<String> = [monthly, yearly, lifetime]
+    static let catalog: [String] = [weekly, monthly, yearly, lifetime]
 
 
-    static func plan(for productID: String) -> PaywallPlan? {
-        switch productID {
-        case monthly: .monthly
-        case yearly: .yearly
-        case lifetime: .lifetime
-        default: nil
+    static func productIDs(from config: PaywallConfig) -> [String] {
+        if let ids = config.productIDs, !ids.isEmpty {
+            return ids
         }
         }
+        return catalog
+    }
+
+    static func planKey(for productID: String) -> String {
+        productID.split(separator: ".").last.map(String.init) ?? productID
+    }
+
+    static func plan(for productID: String) -> PaywallPlan? {
+        PaywallPlan(productID: productID)
     }
     }
 }
 }

+ 3 - 2
App AI for Reddit/ViewModels/PaywallViewModel.swift

@@ -3,7 +3,7 @@ import SwiftUI
 @MainActor
 @MainActor
 @Observable
 @Observable
 final class PaywallViewModel {
 final class PaywallViewModel {
-    var selectedPlan: PaywallPlan = .yearly
+    var selectedPlan = PaywallPlan(productID: SubscriptionProductID.yearly)
 
 
     func selectPlan(_ plan: PaywallPlan) {
     func selectPlan(_ plan: PaywallPlan) {
         selectedPlan = plan
         selectedPlan = plan
@@ -12,7 +12,8 @@ final class PaywallViewModel {
     func syncSelectedPlan(with availablePlans: [PaywallPlan]) {
     func syncSelectedPlan(with availablePlans: [PaywallPlan]) {
         guard !availablePlans.isEmpty else { return }
         guard !availablePlans.isEmpty else { return }
         if !availablePlans.contains(selectedPlan) {
         if !availablePlans.contains(selectedPlan) {
-            selectedPlan = availablePlans.contains(.yearly) ? .yearly : availablePlans[0]
+            let yearly = PaywallPlan(productID: SubscriptionProductID.yearly)
+            selectedPlan = availablePlans.contains(yearly) ? yearly : availablePlans[0]
         }
         }
     }
     }
 }
 }

+ 12 - 3
App AI for Reddit/Views/PaywallView.swift

@@ -107,7 +107,16 @@ struct PaywallView: View {
                 viewModel.selectPlan(eligiblePlan)
                 viewModel.selectPlan(eligiblePlan)
             }
             }
         }
         }
-        .onReceive(NotificationCenter.default.publisher(for: .paywallConfigDidUpdate)) { _ in }
+        .onChange(of: subscriptions.availablePaywallPlans) { _, plans in
+            viewModel.syncSelectedPlan(with: plans)
+            if let eligiblePlan = config.trialEligiblePlan,
+               subscriptions.trialDisplay(for: eligiblePlan) != nil {
+                viewModel.selectPlan(eligiblePlan)
+            }
+        }
+        .onReceive(NotificationCenter.default.publisher(for: .paywallConfigDidUpdate)) { _ in
+            Task { await subscriptions.loadProducts(presentLoadingUI: false) }
+        }
         .alert(
         .alert(
             config.messages.purchaseFailedTitle,
             config.messages.purchaseFailedTitle,
             isPresented: Binding(
             isPresented: Binding(
@@ -428,7 +437,7 @@ private struct PaywallPricingCard: View {
     let onSelect: () -> Void
     let onSelect: () -> Void
 
 
     private var planCopy: PaywallConfig.PlanCopy {
     private var planCopy: PaywallConfig.PlanCopy {
-        plan.planCopy(from: config)
+        plan.planCopy(from: config, product: product)
     }
     }
 
 
     var body: some View {
     var body: some View {
@@ -499,7 +508,7 @@ private struct PaywallPricingCard: View {
             .overlay(alignment: .topTrailing) {
             .overlay(alignment: .topTrailing) {
                 if let trial {
                 if let trial {
                     cardBadge(trial.badgeText)
                     cardBadge(trial.badgeText)
-                } else if let badge = planCopy.badge, plan == .lifetime {
+                } else if let badge = planCopy.badge, plan.isLifetime {
                     cardBadge(badge)
                     cardBadge(badge)
                 }
                 }
             }
             }

+ 19 - 0
App AI for Reddit/paywall.json

@@ -1,4 +1,10 @@
 {
 {
+  "productIDs": [
+    "com.app-for-reddit.pro.weekly",
+    "com.app-for-reddit.pro.monthly",
+    "com.app-for-reddit.pro.yearly",
+    "com.app-for-reddit.pro.lifetime"
+  ],
   "leftPanelTitle": "Unlock Full Pro Power",
   "leftPanelTitle": "Unlock Full Pro Power",
   "rightTitle": "Go Premium",
   "rightTitle": "Go Premium",
   "rightSubtitle": "Generate unlimited posts, optimize titles, and craft smarter replies with advanced AI.",
   "rightSubtitle": "Generate unlimited posts, optimize titles, and craft smarter replies with advanced AI.",
@@ -38,6 +44,19 @@
     "subtitle": "Pay once, no recurring charges."
     "subtitle": "Pay once, no recurring charges."
   },
   },
   "plans": {
   "plans": {
+    "weekly": {
+      "title": "Weekly",
+      "subtitleTemplate": "{price} / week, cancel anytime",
+      "ctaTemplate": "Get Premium for {price} / Week",
+      "fallbackPrice": "$2.99",
+      "priceSuffix": "/wk",
+      "fallbackBillingDescription": "Billed at {price} every week",
+      "features": [
+        "Unlimited AI Generations",
+        "Post Generator & Title Optimizer",
+        "Comment Writer with smart replies"
+      ]
+    },
     "monthly": {
     "monthly": {
       "title": "Monthly",
       "title": "Monthly",
       "subtitleTemplate": "{price} / month, cancel anytime",
       "subtitleTemplate": "{price} / month, cancel anytime",