|
|
@@ -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
|
|
|
+ }
|
|
|
+}
|