krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

dcf4135ec376d357b8fafc0101a75b674e76329a

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-22T17:39:23Z

feat(v3​.1​.0): add ​Store​Kit integration and ​Pro tier

• implement Purchase​Service using StoreKit 2
• activate feature tiers based on entitlement
• add minimal paywall and restore flow
• enforce free-tier limits with upgrade paths
• integrate Pro status into settings
• ensure clean and non-intrusive monetization flow
 DomainDig/AppVersion.swift           |   2 +-
 DomainDig/ContentView.swift          | 114 ++++++++--------
 DomainDig/DomainDigApp.swift         |   5 +
 DomainDig/DomainViewModel.swift      |  23 +++-
 DomainDig/FeatureAccessService.swift |  96 +++++++++++---
 DomainDig/PaywallView.swift          | 123 ++++++++++++++++++
 DomainDig/PurchaseService.swift      | 245 +++++++++++++++++++++++++++++++++++
 DomainDig/RootTabView.swift          |  25 ++++
 DomainDig/WatchlistView.swift        |   3 +
 DomainDig/WorkflowsView.swift        |  53 ++++----
 10 files changed, 586 insertions(+), 103 deletions(-)

diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift
index 1a6861a..d9f3fc7 100644
--- a/DomainDig/AppVersion.swift
+++ b/DomainDig/AppVersion.swift
@@ -2,6 +2,6 @@ import Foundation
 
 enum AppVersion {
     static var current: String {
-        "3.0.0"
+        "3.1.0"
     }
 }
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 9802f77..e6e7a41 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -25,20 +25,21 @@ private struct WorkflowNavigationTarget: Hashable {
 struct ContentView: View {
     @Environment(\.appDensity) private var appDensity
     @Bindable var viewModel: DomainViewModel
+    @State private var purchaseService = PurchaseService.shared
     @State private var navigationPath = NavigationPath()
     @FocusState private var domainFieldFocused: Bool
     @State private var customPortInput = ""
     @State private var customPortsExpanded = false
     @State private var trackingNoteDraft = ""
     @State private var editingTrackedDomain: TrackedDomain?
-    @State private var showTrackLimitAlert = false
-    @State private var featureGateMessage: String?
     @State private var inputMode: LookupInputMode = .single
     @State private var collapsedSections: Set<ResultSection> = [.network]
     @State private var showingCurrentDomainWorkflowSheet = false
     @State private var showingBatchWorkflowSheet = false
 
     var body: some View {
+        let _ = purchaseService.currentTier
+
         NavigationStack(path: $navigationPath) {
             ScrollView(.vertical) {
                 VStack(spacing: 0) {
@@ -84,9 +85,7 @@ struct ContentView: View {
                             workflows: viewModel.currentDomainWorkflows,
                             trackingLimitMessage: viewModel.trackingLimitMessage,
                             onTrack: {
-                                if !viewModel.trackCurrentDomain() {
-                                    showTrackLimitAlert = true
-                                }
+                                _ = viewModel.trackCurrentDomain()
                             },
                             onTogglePinned: {
                                 guard let trackedDomain = viewModel.currentTrackedDomain else { return }
@@ -248,23 +247,6 @@ struct ContentView: View {
             navigationPath = NavigationPath()
             domainFieldFocused = false
         }
-        .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) {
-            Button("OK", role: .cancel) {}
-        } message: {
-            Text(viewModel.trackingLimitMessage ?? "Tracking limit reached.")
-        }
-        .alert("Feature unavailable", isPresented: Binding(
-            get: { featureGateMessage != nil },
-            set: { newValue in
-                if !newValue {
-                    featureGateMessage = nil
-                }
-            }
-        )) {
-            Button("OK", role: .cancel) {}
-        } message: {
-            Text(featureGateMessage ?? "")
-        }
         .sheet(item: $editingTrackedDomain) { trackedDomain in
             NavigationStack {
                 Form {
@@ -346,6 +328,12 @@ struct ContentView: View {
                             .font(appDensity.font(.caption))
                             .foregroundStyle(.secondary)
 
+                        if let batchAllowanceSummary = FeatureAccessService.batchAllowanceSummary() {
+                            Text(batchAllowanceSummary)
+                                .font(appDensity.font(.caption2))
+                                .foregroundStyle(.secondary)
+                        }
+
                         TextField(
                             "example.com\napple.com, openai.com",
                             text: $viewModel.bulkInput,
@@ -391,25 +379,25 @@ struct ContentView: View {
                 Menu {
                     if !viewModel.isCurrentDomainTracked {
                         Button("Track this domain") {
-                            if !viewModel.trackCurrentDomain() {
-                                showTrackLimitAlert = true
-                            }
+                            _ = viewModel.trackCurrentDomain()
                         }
                     }
                     Button("Add to workflow") {
-                        if FeatureAccessService.hasAccess(to: .workflows) {
-                            showingCurrentDomainWorkflowSheet = true
-                        } else {
-                            featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows)
-                        }
+                        showingCurrentDomainWorkflowSheet = true
                     }
-                    Button("Copy report JSON") {
-                        guard let json = viewModel.exportJSONString() else { return }
-                        AppClipboard.copy(json)
-                        AppHaptics.copy()
+                    if FeatureAccessService.hasAccess(to: .advancedExports) {
+                        Button("Copy report JSON") {
+                            guard let json = viewModel.exportJSONString() else { return }
+                            AppClipboard.copy(json)
+                            AppHaptics.copy()
+                        }
+                    } else {
+                        Button("Copy report JSON") {
+                            viewModel.upgradePrompt = FeatureAccessService.upgradePrompt(for: .advancedExports)
+                        }
                     }
                     Button("Export report") {
-                        shareSingleResults(format: .json)
+                        shareSingleResults(format: .text)
                     }
                 } label: {
                     Image(systemName: "bolt.circle")
@@ -463,11 +451,7 @@ struct ContentView: View {
                 if !viewModel.currentBatchResultEntries.isEmpty {
                     Menu {
                         Button("Add to Workflow") {
-                            if FeatureAccessService.hasAccess(to: .workflows) {
-                                showingBatchWorkflowSheet = true
-                            } else {
-                                featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows)
-                            }
+                            showingBatchWorkflowSheet = true
                         }
                         Divider()
                         Button("Export Batch TXT") {
@@ -2165,6 +2149,7 @@ private extension String {
 struct SettingsView: View {
     @Environment(\.appDensity) private var appDensity
     @Bindable var viewModel: DomainViewModel
+    @State private var purchaseService = PurchaseService.shared
     @AppStorage(DNSResolverOption.userDefaultsKey)
     private var storedResolverURL = DNSResolverOption.defaultURLString
     @AppStorage(AppDensity.userDefaultsKey)
@@ -2185,6 +2170,8 @@ struct SettingsView: View {
     }
 
     var body: some View {
+        let _ = purchaseService.currentTier
+
         Form {
             Section("Display") {
                 Picker("Density", selection: $storedDensity) {
@@ -2233,28 +2220,51 @@ struct SettingsView: View {
                 }
             }
 
-            Section("Features") {
-                LabeledContent("Tier", value: FeatureAccessService.currentTier.title)
+            Section("Pro") {
+                LabeledContent("Status", value: purchaseService.currentTier.title)
 
-                if FeatureAccessService.enabledFeatureLabels().isEmpty {
-                    Text("No features enabled.")
-                        .font(appDensity.font(.caption, design: .default))
-                        .foregroundStyle(.secondary)
+                if purchaseService.currentTier == .free {
+                    Button("Upgrade to Pro") {
+                        viewModel.isPaywallPresented = true
+                    }
                 } else {
-                    ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in
-                        Text(label)
+                    Button("Manage Subscription") {
+                        Task {
+                            await purchaseService.manageSubscription()
+                        }
                     }
                 }
 
-                Text("Workflows, batch operations, and advanced exports are prepared for future Pro unlocks. Extended historical datasets are reserved for Data+ scaffolding.")
-                    .font(appDensity.font(.caption, design: .default))
-                    .foregroundStyle(.secondary)
+                Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
+                    Task {
+                        await purchaseService.restorePurchases()
+                    }
+                }
+                .disabled(purchaseService.isRestoring || purchaseService.isPurchasing)
+
+                if let statusMessage = purchaseService.statusMessage {
+                    Text(statusMessage)
+                        .font(appDensity.font(.caption, design: .default))
+                        .foregroundStyle(.secondary)
+                }
+
+                if let errorMessage = purchaseService.errorMessage {
+                    Text(errorMessage)
+                        .font(appDensity.font(.caption, design: .default))
+                        .foregroundStyle(.red)
+                }
+            }
+
+            Section("Features") {
+                ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in
+                    Text(label)
+                }
             }
 
             Section("About") {
                 LabeledContent("Version", value: appVersion)
                 LabeledContent("Storage", value: "Local-only")
-                LabeledContent("Report Schema", value: "3.0.0")
+                LabeledContent("Report Schema", value: "3.1.0")
             }
         }
         .navigationTitle("Settings")
diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift
index 5eeed32..ae81654 100644
--- a/DomainDig/DomainDigApp.swift
+++ b/DomainDig/DomainDigApp.swift
@@ -11,6 +11,7 @@ import SwiftUI
 struct DomainDigApp: App {
     @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue
     @State private var viewModel = DomainViewModel()
+    @State private var purchaseService = PurchaseService.shared
 
     init() {
         LocalNotificationService.shared.configureForegroundPresentation()
@@ -20,6 +21,10 @@ struct DomainDigApp: App {
         WindowGroup {
             RootTabView(viewModel: viewModel)
                 .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact)
+                .task {
+                    let _ = purchaseService.currentTier
+                    await purchaseService.refreshEntitlements()
+                }
         }
     }
 }
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 5288eaa..6ec7e64 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -226,6 +226,8 @@ final class DomainViewModel {
     var watchlistSearchText = ""
     var watchlistFilter: WatchlistFilterOption = .all
     var watchlistSortOption: WatchlistSortOption = .pinned
+    var upgradePrompt: UpgradePromptContext?
+    var isPaywallPresented = false
 
     var trimmedDomain: String {
         domain
@@ -578,6 +580,7 @@ final class DomainViewModel {
         }
 
         guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else {
+            upgradePrompt = FeatureAccessService.upgradePromptForTrackedDomains(currentCount: trackedDomains.count)
             return false
         }
 
@@ -737,12 +740,18 @@ final class DomainViewModel {
     func runBulkLookup() {
         let domains = parsedDomains(from: bulkInput)
         guard !domains.isEmpty else { return }
-        guard FeatureAccessService.canRunBatch(domainCount: domains.count) else { return }
+        guard FeatureAccessService.canRunBatch(domainCount: domains.count) else {
+            upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: domains.count)
+            return
+        }
         startBatchLookup(domains: domains, source: .manual)
     }
 
     func refreshAllTrackedDomains() {
-        guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else { return }
+        guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else {
+            upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: sortedTrackedDomains.count)
+            return
+        }
         startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh)
     }
 
@@ -791,7 +800,10 @@ final class DomainViewModel {
         let normalizedDomains = normalizedDomains(domains)
         let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
         guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil }
-        guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { return nil }
+        guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else {
+            upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count)
+            return nil
+        }
 
         let workflow = DomainWorkflow(
             name: trimmedName,
@@ -855,7 +867,10 @@ final class DomainViewModel {
 
     func runWorkflow(_ workflow: DomainWorkflow) {
         guard !workflow.domains.isEmpty else { return }
-        guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { return }
+        guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else {
+            upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count)
+            return
+        }
         startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow)
     }
 
diff --git a/DomainDig/FeatureAccessService.swift b/DomainDig/FeatureAccessService.swift
index 5f380bb..d23f348 100644
--- a/DomainDig/FeatureAccessService.swift
+++ b/DomainDig/FeatureAccessService.swift
@@ -64,34 +64,45 @@ struct FeatureEntitlements: Equatable {
     let batchSizeLimit: Int?
 }
 
+struct UpgradePromptContext: Identifiable, Equatable {
+    let id = UUID()
+    let title: String
+    let message: String
+    let capability: FeatureCapability
+}
+
 enum FeatureAccessService {
-    static let currentTier: FeatureTier = .free
+    private static let effectivelyUnlimitedTrackedDomains = 5_000
+
+    static var currentTier: FeatureTier {
+        PurchaseService.cachedTier
+    }
 
     static var entitlements: FeatureEntitlements {
         switch currentTier {
         case .free:
             return FeatureEntitlements(
                 tier: .free,
-                capabilities: [.singleLookup, .basicHistory, .limitedTracking],
-                trackedDomainLimit: 3,
-                workflowLimit: 0,
-                batchSizeLimit: 0
+                capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations],
+                trackedDomainLimit: 5,
+                workflowLimit: 1,
+                batchSizeLimit: 10
             )
         case .pro:
             return FeatureEntitlements(
                 tier: .pro,
                 capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports],
-                trackedDomainLimit: 250,
-                workflowLimit: 50,
-                batchSizeLimit: 100
+                trackedDomainLimit: effectivelyUnlimitedTrackedDomains,
+                workflowLimit: nil,
+                batchSizeLimit: nil
             )
         case .dataPlus:
             return FeatureEntitlements(
                 tier: .dataPlus,
                 capabilities: Set(FeatureCapability.allCases),
-                trackedDomainLimit: 1_000,
-                workflowLimit: 200,
-                batchSizeLimit: 250
+                trackedDomainLimit: effectivelyUnlimitedTrackedDomains,
+                workflowLimit: nil,
+                batchSizeLimit: nil
             )
         }
     }
@@ -106,9 +117,10 @@ enum FeatureAccessService {
 
     static func trackedDomainLimitMessage(currentCount: Int) -> String? {
         guard currentTier == .free else { return nil }
-        return currentCount >= entitlements.trackedDomainLimit
-            ? "Free includes up to \(entitlements.trackedDomainLimit) tracked domains."
-            : "Free includes up to \(entitlements.trackedDomainLimit) tracked domains."
+        if currentCount >= entitlements.trackedDomainLimit {
+            return "Free includes up to \(entitlements.trackedDomainLimit) tracked domains. Available in Pro."
+        }
+        return "Free includes up to \(entitlements.trackedDomainLimit) tracked domains."
     }
 
     static func canCreateWorkflow(currentCount: Int) -> Bool {
@@ -137,19 +149,61 @@ enum FeatureAccessService {
     }
 
     static func workflowLimitMessage(currentCount: Int) -> String? {
-        guard hasAccess(to: .workflows) else {
-            return upgradeMessage(for: .workflows)
-        }
         guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil }
-        return "Workflow limit reached."
+        return "Free includes up to \(limit) workflow."
     }
 
     static func batchLimitMessage(domainCount: Int) -> String? {
-        guard hasAccess(to: .batchOperations) else {
-            return upgradeMessage(for: .batchOperations)
+        guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil }
+        return "Free runs batches up to \(limit) domains."
+    }
+
+    static func workflowAllowanceSummary(currentCount: Int) -> String? {
+        guard currentTier == .free, let limit = entitlements.workflowLimit else { return nil }
+        if currentCount >= limit {
+            return "Free includes up to \(limit) workflow. Available in Pro."
         }
+        return "Free includes up to \(limit) workflow."
+    }
+
+    static func batchAllowanceSummary() -> String? {
+        guard currentTier == .free, let limit = entitlements.batchSizeLimit else { return nil }
+        return "Free runs batches up to \(limit) domains."
+    }
+
+    static func upgradePromptForTrackedDomains(currentCount: Int) -> UpgradePromptContext? {
+        guard currentCount >= entitlements.trackedDomainLimit else { return nil }
+        return UpgradePromptContext(
+            title: "Available in Pro",
+            message: "Free includes up to \(entitlements.trackedDomainLimit) tracked domains. You can keep your current watchlist, but adding more requires Pro.",
+            capability: .limitedTracking
+        )
+    }
+
+    static func upgradePromptForWorkflows(currentCount: Int) -> UpgradePromptContext? {
+        guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil }
+        return UpgradePromptContext(
+            title: "Available in Pro",
+            message: "Free includes up to \(limit) workflow. You can keep your current workflows, but creating more requires Pro.",
+            capability: .workflows
+        )
+    }
+
+    static func upgradePromptForBatch(domainCount: Int) -> UpgradePromptContext? {
         guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil }
-        return "Batch limit is \(limit) domains on \(currentTier.title)."
+        return UpgradePromptContext(
+            title: "Available in Pro",
+            message: "Free runs batches up to \(limit) domains at a time. You can continue with smaller batches or upgrade to Pro.",
+            capability: .batchOperations
+        )
+    }
+
+    static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext {
+        UpgradePromptContext(
+            title: "Available in Pro",
+            message: upgradeMessage(for: capability),
+            capability: capability
+        )
     }
 
     static func enabledFeatureLabels() -> [String] {
diff --git a/DomainDig/PaywallView.swift b/DomainDig/PaywallView.swift
new file mode 100644
index 0000000..a21448f
--- /dev/null
+++ b/DomainDig/PaywallView.swift
@@ -0,0 +1,123 @@
+import StoreKit
+import SwiftUI
+
+struct PaywallView: View {
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.appDensity) private var appDensity
+    @State private var purchaseService = PurchaseService.shared
+
+    var body: some View {
+        NavigationStack {
+            List {
+                Section {
+                    Text("Pro unlocks automation, larger runs, and export convenience while keeping the core lookup experience free.")
+                        .font(appDensity.font(.body, design: .default))
+                        .foregroundStyle(.secondary)
+                        .fixedSize(horizontal: false, vertical: true)
+                }
+
+                Section("What Pro Unlocks") {
+                    featureRow("Unlimited tracked domains")
+                    featureRow("Workflows")
+                    featureRow("Larger batch sizes")
+                    featureRow("Advanced exports")
+                }
+
+                Section("Subscription") {
+                    if purchaseService.isLoadingProducts {
+                        ProgressView("Loading pricing…")
+                    } else if purchaseService.products.isEmpty {
+                        Text("Pricing is unavailable right now.")
+                            .font(appDensity.font(.caption, design: .default))
+                            .foregroundStyle(.secondary)
+
+                        Button("Retry") {
+                            Task {
+                                await purchaseService.refreshProducts()
+                            }
+                        }
+                    } else {
+                        ForEach(purchaseService.products, id: \.id) { product in
+                            Button {
+                                Task {
+                                    await purchaseService.purchase(product)
+                                }
+                            } label: {
+                                VStack(alignment: .leading, spacing: 6) {
+                                    Text(subscriptionTitle(for: product))
+                                        .font(appDensity.font(.headline, design: .default, weight: .semibold))
+                                        .foregroundStyle(.primary)
+                                    Text(product.displayPrice)
+                                        .font(appDensity.font(.callout, design: .default))
+                                        .foregroundStyle(.secondary)
+                                }
+                                .frame(maxWidth: .infinity, alignment: .leading)
+                            }
+                            .disabled(purchaseService.isPurchasing || purchaseService.isRestoring)
+                        }
+                    }
+
+                    if let statusMessage = purchaseService.statusMessage {
+                        Text(statusMessage)
+                            .font(appDensity.font(.caption, design: .default))
+                            .foregroundStyle(.secondary)
+                    }
+
+                    if let errorMessage = purchaseService.errorMessage {
+                        Text(errorMessage)
+                            .font(appDensity.font(.caption, design: .default))
+                            .foregroundStyle(.red)
+                    }
+                }
+
+                Section("Account") {
+                    Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
+                        Task {
+                            await purchaseService.restorePurchases()
+                        }
+                    }
+                    .disabled(purchaseService.isPurchasing || purchaseService.isRestoring)
+
+                    if purchaseService.hasProAccess {
+                        Button("Manage Subscription") {
+                            Task {
+                                await purchaseService.manageSubscription()
+                            }
+                        }
+                    }
+                }
+            }
+            .navigationTitle("DomainDig Pro")
+            .toolbar {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button("Done") {
+                        dismiss()
+                    }
+                }
+            }
+        }
+        .task {
+            await purchaseService.refreshProducts()
+            await purchaseService.refreshEntitlements()
+        }
+        .onDisappear {
+            purchaseService.clearMessages()
+        }
+    }
+
+    private func featureRow(_ title: String) -> some View {
+        Text(title)
+            .font(appDensity.font(.body, design: .default))
+    }
+
+    private func subscriptionTitle(for product: Product) -> String {
+        switch product.id {
+        case PurchaseService.monthlyProductID:
+            return "Pro Monthly"
+        case PurchaseService.yearlyProductID:
+            return "Pro Yearly"
+        default:
+            return product.displayName
+        }
+    }
+}
diff --git a/DomainDig/PurchaseService.swift b/DomainDig/PurchaseService.swift
new file mode 100644
index 0000000..3d19165
--- /dev/null
+++ b/DomainDig/PurchaseService.swift
@@ -0,0 +1,245 @@
+import Foundation
+import StoreKit
+
+#if canImport(UIKit)
+import UIKit
+#endif
+
+@MainActor
+@Observable
+final class PurchaseService {
+    struct CachedEntitlement: Codable {
+        let tier: FeatureTier
+        let activeProductID: String?
+        let updatedAt: Date
+    }
+
+    static let shared = PurchaseService()
+    static let monthlyProductID = "domaindig.pro.monthly"
+    static let yearlyProductID = "domaindig.pro.yearly"
+    static let productIDs = [monthlyProductID, yearlyProductID]
+
+    private static let entitlementCacheKey = "purchase.cachedEntitlement"
+
+    static var cachedEntitlement: CachedEntitlement? {
+        guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil }
+        return try? JSONDecoder().decode(CachedEntitlement.self, from: data)
+    }
+
+    static var cachedTier: FeatureTier {
+        cachedEntitlement?.tier ?? .free
+    }
+
+    var products: [Product] = []
+    var currentTier: FeatureTier
+    var activeProductID: String?
+    var isLoadingProducts = false
+    var isPurchasing = false
+    var isRestoring = false
+    var statusMessage: String?
+    var errorMessage: String?
+
+    private var updatesTask: Task<Void, Never>?
+
+    private init() {
+        currentTier = Self.cachedTier
+        activeProductID = Self.cachedEntitlement?.activeProductID
+        updatesTask = observeTransactionUpdates()
+        Task {
+            await refreshProducts()
+            await refreshEntitlements()
+        }
+    }
+
+    var hasProAccess: Bool {
+        currentTier == .pro
+    }
+
+    func refreshProducts() async {
+        isLoadingProducts = true
+        errorMessage = nil
+
+        do {
+            let fetchedProducts = try await Product.products(for: Self.productIDs)
+            products = fetchedProducts.sorted { lhs, rhs in
+                productSortIndex(for: lhs.id) < productSortIndex(for: rhs.id)
+            }
+        } catch {
+            products = []
+            errorMessage = storeMessage(for: error, fallback: "Pricing is unavailable right now.")
+        }
+
+        isLoadingProducts = false
+    }
+
+    func refreshEntitlements() async {
+        var activeTransactions: [Transaction] = []
+
+        for await result in Transaction.currentEntitlements {
+            guard case .verified(let transaction) = result else {
+                continue
+            }
+            guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
+                continue
+            }
+            activeTransactions.append(transaction)
+        }
+
+        let activeProductID = activeTransactions
+            .sorted { $0.purchaseDate > $1.purchaseDate }
+            .first?
+            .productID
+
+        self.activeProductID = activeProductID
+        currentTier = activeProductID == nil ? .free : .pro
+        persistCurrentEntitlement()
+    }
+
+    func purchase(_ product: Product) async {
+        isPurchasing = true
+        statusMessage = nil
+        errorMessage = nil
+
+        do {
+            let result = try await product.purchase()
+
+            switch result {
+            case .success(let verification):
+                let transaction = try verifiedTransaction(from: verification)
+                apply(transaction: transaction)
+                await transaction.finish()
+                await refreshEntitlements()
+                statusMessage = "Pro is active."
+            case .userCancelled:
+                break
+            case .pending:
+                statusMessage = "Purchase is pending approval."
+            @unknown default:
+                errorMessage = "The purchase could not be completed."
+            }
+        } catch {
+            errorMessage = storeMessage(for: error, fallback: "The purchase could not be completed.")
+        }
+
+        isPurchasing = false
+    }
+
+    func restorePurchases() async {
+        isRestoring = true
+        statusMessage = nil
+        errorMessage = nil
+
+        do {
+            try await AppStore.sync()
+            await refreshEntitlements()
+            statusMessage = hasProAccess ? "Purchases restored." : "No previous Pro purchase was found."
+        } catch {
+            errorMessage = storeMessage(for: error, fallback: "Restore failed. Try again when the App Store is available.")
+        }
+
+        isRestoring = false
+    }
+
+    func manageSubscription() async {
+        errorMessage = nil
+
+        #if canImport(UIKit)
+        if ProcessInfo.processInfo.isiOSAppOnMac {
+            errorMessage = "Manage Subscription is not available on this device."
+            return
+        }
+
+        guard let scene = UIApplication.shared.connectedScenes
+            .compactMap({ $0 as? UIWindowScene })
+            .first(where: { $0.activationState == .foregroundActive }) else {
+            errorMessage = "Manage Subscription is not available right now."
+            return
+        }
+
+        do {
+            try await AppStore.showManageSubscriptions(in: scene)
+        } catch {
+            errorMessage = storeMessage(for: error, fallback: "Manage Subscription is not available right now.")
+        }
+        #else
+        errorMessage = "Manage Subscription is not available on this platform."
+        #endif
+    }
+
+    func clearMessages() {
+        statusMessage = nil
+        errorMessage = nil
+    }
+
+    private func apply(transaction: Transaction) {
+        guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
+            return
+        }
+
+        activeProductID = transaction.productID
+        currentTier = .pro
+        persistCurrentEntitlement()
+    }
+
+    private func observeTransactionUpdates() -> Task<Void, Never> {
+        Task.detached(priority: .background) { [weak self] in
+            for await result in Transaction.updates {
+                guard let self else { return }
+                await self.handleTransactionUpdate(result)
+            }
+        }
+    }
+
+    private func handleTransactionUpdate(_ result: VerificationResult<Transaction>) async {
+        guard case .verified(let transaction) = result else { return }
+        apply(transaction: transaction)
+        await transaction.finish()
+        await refreshEntitlements()
+    }
+
+    private func persistCurrentEntitlement() {
+        let cachedEntitlement = CachedEntitlement(
+            tier: currentTier,
+            activeProductID: activeProductID,
+            updatedAt: Date()
+        )
+
+        if let data = try? JSONEncoder().encode(cachedEntitlement) {
+            UserDefaults.standard.set(data, forKey: Self.entitlementCacheKey)
+        }
+    }
+
+    private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction {
+        switch result {
+        case .verified(let transaction):
+            return transaction
+        case .unverified:
+            throw StoreKitError.notEntitled
+        }
+    }
+
+    private func productSortIndex(for productID: String) -> Int {
+        switch productID {
+        case Self.monthlyProductID:
+            return 0
+        case Self.yearlyProductID:
+            return 1
+        default:
+            return Int.max
+        }
+    }
+
+    private func storeMessage(for error: Error, fallback: String) -> String {
+        if let storeKitError = error as? StoreKitError {
+            switch storeKitError {
+            case .networkError:
+                return "The App Store is offline right now."
+            default:
+                return fallback
+            }
+        }
+
+        let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
+        return message.isEmpty ? fallback : message
+    }
+}
diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift
index 19ea444..2d2b8ed 100644
--- a/DomainDig/RootTabView.swift
+++ b/DomainDig/RootTabView.swift
@@ -2,8 +2,11 @@ import SwiftUI
 
 struct RootTabView: View {
     @Bindable var viewModel: DomainViewModel
+    @State private var purchaseService = PurchaseService.shared
 
     var body: some View {
+        let _ = purchaseService.currentTier
+
         TabView {
             ContentView(viewModel: viewModel)
                 .tabItem {
@@ -38,5 +41,27 @@ struct RootTabView: View {
                 Label("Settings", systemImage: "gearshape")
             }
         }
+        .sheet(isPresented: Binding(
+            get: { viewModel.isPaywallPresented },
+            set: { viewModel.isPaywallPresented = $0 }
+        )) {
+            PaywallView()
+        }
+        .alert(item: Binding(
+            get: { viewModel.upgradePrompt },
+            set: { viewModel.upgradePrompt = $0 }
+        )) { prompt in
+            Alert(
+                title: Text(prompt.title),
+                message: Text(prompt.message),
+                primaryButton: .default(Text("Open Paywall")) {
+                    viewModel.upgradePrompt = nil
+                    viewModel.isPaywallPresented = true
+                },
+                secondaryButton: .cancel(Text("Continue")) {
+                    viewModel.upgradePrompt = nil
+                }
+            )
+        }
     }
 }
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index fcb5a16..b4e5011 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -4,6 +4,7 @@ struct WatchlistView: View {
     @Environment(\.appDensity) private var appDensity
     @Bindable var viewModel: DomainViewModel
     @Environment(\.dismiss) private var dismiss
+    @State private var purchaseService = PurchaseService.shared
     @State private var showWorkflowAddSheet = false
 
     private var pinnedDomains: [TrackedDomain] {
@@ -15,6 +16,8 @@ struct WatchlistView: View {
     }
 
     var body: some View {
+        let _ = purchaseService.currentTier
+
         List {
             if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
                 Section("Refresh Progress") {
diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift
index 9d99b02..5025a0b 100644
--- a/DomainDig/WorkflowsView.swift
+++ b/DomainDig/WorkflowsView.swift
@@ -10,24 +10,24 @@ private enum WorkflowDestinationMode: String, CaseIterable, Identifiable {
 struct WorkflowsView: View {
     @Environment(\.appDensity) private var appDensity
     @Bindable var viewModel: DomainViewModel
+    @State private var purchaseService = PurchaseService.shared
 
     @State private var showingCreateWorkflow = false
 
     var body: some View {
+        let _ = purchaseService.currentTier
+
         List {
-            if FeatureAccessService.hasAccess(to: .workflows) {
-                workflowContent
-            } else {
+            if let limitMessage = FeatureAccessService.workflowAllowanceSummary(currentCount: viewModel.workflows.count) {
                 Section {
-                    EmptyStateCardView(
-                        title: "Workflows",
-                        message: "Reusable workflow sets are scaffolded for this release and will unlock with Pro.",
-                        suggestion: FeatureAccessService.upgradeMessage(for: .workflows),
-                        systemImage: "square.stack.3d.down.right"
-                    )
+                    Text(limitMessage)
+                        .font(appDensity.font(.caption))
+                        .foregroundStyle(.secondary)
                 }
                 .listRowBackground(Color(.systemGray6).opacity(0.5))
             }
+
+            workflowContent
         }
         .scrollContentBackground(.hidden)
         .background(Color.black)
@@ -36,17 +36,15 @@ struct WorkflowsView: View {
         }
         .navigationTitle("Workflows")
         .toolbar {
-            if FeatureAccessService.hasAccess(to: .workflows) {
-                ToolbarItemGroup(placement: .topBarTrailing) {
-                    Button {
-                        showingCreateWorkflow = true
-                    } label: {
-                        Image(systemName: "plus.circle")
-                    }
+            ToolbarItemGroup(placement: .topBarTrailing) {
+                Button {
+                    showingCreateWorkflow = true
+                } label: {
+                    Image(systemName: "plus.circle")
+                }
 
-                    if !viewModel.workflows.isEmpty {
-                        EditButton()
-                    }
+                if !viewModel.workflows.isEmpty {
+                    EditButton()
                 }
             }
         }
@@ -429,10 +427,13 @@ struct WorkflowComposerView: View {
     private func saveWorkflow() {
         if let workflow {
             viewModel.updateWorkflow(workflow, name: name, domains: parsedDomains, notes: notes)
-        } else {
-            _ = viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes)
+            dismiss()
+            return
+        }
+
+        if viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes) != nil {
+            dismiss()
         }
-        dismiss()
     }
 }
 
@@ -678,10 +679,12 @@ struct WorkflowBulkAddSheet: View {
         case .existing:
             guard let selectedWorkflowID, let workflow = viewModel.workflow(withID: selectedWorkflowID) else { return }
             viewModel.addDomains(selectedDomainList, to: workflow)
+            dismiss()
         case .new:
-            _ = viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes)
+            guard viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes) != nil else {
+                return
+            }
+            dismiss()
         }
-
-        dismiss()
     }
 }