From 90c3858c90f036385b3f3b166180a299e41fa3c8 Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 24 Nov 2025 13:46:33 -0600 Subject: [PATCH] Implement StoreKit 2 purchase flow with backend verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fully implemented StoreKit 2 in-app purchase system: StoreKitManager.swift: - Product loading from App Store with Product.products() API - Complete purchase flow with result handling (success/cancelled/pending) - Transaction verification using VerificationResult - Backend receipt verification via SubscriptionApi - Automatic transaction observation with Transaction.updates - Current entitlements checking with Transaction.currentEntitlements - Restore purchases with AppStore.sync() - Transaction finishing to acknowledge purchases - Subscription cache updates after successful verification - Error handling with custom StoreKitError enum UpgradePromptView.swift: - Integration with StoreKitManager singleton - Automatic product loading on view appear - Display of subscription options with real pricing - Product selection with loading states - Purchase flow with try/catch error handling - Success alert on purchase completion - Error message display for failed purchases - Restore purchases button - SubscriptionProductButton component for product display - Annual subscription highlighted with "Save 17%" badge - Retry loading if products fail to fetch Key features: - Async/await pattern throughout - MainActor dispatching for UI updates - Transaction cryptographic verification - Backend verification sends transaction ID - Purchased product IDs tracking - Transaction listener cleanup in deinit - Products sorted by price (monthly first) Ready for testing with Configuration.storekit in simulator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../iosApp/Subscription/StoreKitManager.swift | 279 +++++++++++++++--- .../Subscription/UpgradePromptView.swift | 218 +++++++++++--- 2 files changed, 418 insertions(+), 79 deletions(-) diff --git a/iosApp/iosApp/Subscription/StoreKitManager.swift b/iosApp/iosApp/Subscription/StoreKitManager.swift index 1cfd72e..f4e42de 100644 --- a/iosApp/iosApp/Subscription/StoreKitManager.swift +++ b/iosApp/iosApp/Subscription/StoreKitManager.swift @@ -1,75 +1,264 @@ import Foundation import StoreKit +import ComposeApp -/// StoreKit manager for in-app purchases -/// NOTE: Requires App Store Connect configuration and product IDs +/// StoreKit 2 manager for in-app purchases +/// Handles product loading, purchases, transaction observation, and backend verification class StoreKitManager: ObservableObject { static let shared = StoreKitManager() - - // Product ID for Pro subscription (configure in App Store Connect) - private let proSubscriptionProductID = "com.example.mycrib.pro.monthly" - + + // Product IDs (must match App Store Connect and Configuration.storekit) + private let productIDs = [ + "com.example.mycrib.pro.monthly", + "com.example.mycrib.pro.annual" + ] + @Published var products: [Product] = [] @Published var purchasedProductIDs: Set = [] @Published var isLoading = false - + @Published var purchaseError: String? + + private var transactionListener: Task? + private let subscriptionApi = SubscriptionApi() + private init() { // Start listening for transactions + transactionListener = listenForTransactions() + + // Check for existing entitlements Task { - await observeTransactions() + await updatePurchasedProducts() } } - + + deinit { + transactionListener?.cancel() + } + /// Load available products from App Store + @MainActor func loadProducts() async { isLoading = true defer { isLoading = false } - + do { - // In production, this would fetch real products - // products = try await Product.products(for: [proSubscriptionProductID]) - - // Placeholder: Simulate loading - print("StoreKit: Would load products here") + let loadedProducts = try await Product.products(for: productIDs) + products = loadedProducts.sorted { $0.price < $1.price } + print("✅ StoreKit: Loaded \(products.count) products") } catch { - print("Failed to load products: \(error)") + print("❌ StoreKit: Failed to load products: \(error)") + purchaseError = "Failed to load products: \(error.localizedDescription)" } } - + /// Purchase a product func purchase(_ product: Product) async throws -> Transaction? { - // In production, this would trigger actual purchase - // let result = try await product.purchase() - - // Placeholder - print("StoreKit: Would purchase product: \(product)") - return nil + print("🛒 StoreKit: Initiating purchase for \(product.id)") + + // Start the purchase + let result = try await product.purchase() + + switch result { + case .success(let verification): + // Verify the transaction + let transaction = try checkVerified(verification) + + // Update purchased products + await updatePurchasedProducts() + + // Verify with backend + await verifyTransactionWithBackend(transaction) + + // Finish the transaction + await transaction.finish() + + print("✅ StoreKit: Purchase successful for \(product.id)") + return transaction + + case .userCancelled: + print("⚠️ StoreKit: User cancelled purchase") + return nil + + case .pending: + print("⏳ StoreKit: Purchase pending (requires approval)") + return nil + + @unknown default: + print("❌ StoreKit: Unknown purchase result") + return nil + } } - + /// Restore previous purchases func restorePurchases() async { - // In production, this would restore purchases - // try await AppStore.sync() - - print("StoreKit: Would restore purchases here") + print("🔄 StoreKit: Restoring purchases") + + do { + // Sync with App Store + try await AppStore.sync() + + // Update purchased products + await updatePurchasedProducts() + + // Verify all current entitlements with backend + for await result in Transaction.currentEntitlements { + let transaction = try checkVerified(result) + await verifyTransactionWithBackend(transaction) + } + + print("✅ StoreKit: Purchases restored") + } catch { + print("❌ StoreKit: Failed to restore purchases: \(error)") + await MainActor.run { + purchaseError = "Failed to restore purchases: \(error.localizedDescription)" + } + } } - - /// Verify receipt with backend - func verifyReceiptWithBackend(receiptData: String) async { - // TODO: Call backend API to verify receipt - // let api = SubscriptionApi() - // let result = await api.verifyIOSReceipt(token: token, receiptData: receiptData) - - print("StoreKit: Would verify receipt with backend") + + /// Check if user has an active subscription + func hasActiveSubscription() async -> Bool { + // Check current entitlements + for await result in Transaction.currentEntitlements { + if let transaction = try? checkVerified(result), + transaction.productType == .autoRenewable, + !transaction.isUpgraded { + return true + } + } + return false } - - /// Observe transaction updates - private func observeTransactions() async { - // In production, this would observe transaction updates - // for await result in Transaction.updates { - // // Handle transaction - // } - - print("StoreKit: Observing transactions") + + /// Update purchased product IDs + @MainActor + private func updatePurchasedProducts() async { + var purchasedIDs: Set = [] + + // Iterate through all current entitlements + for await result in Transaction.currentEntitlements { + guard case .verified(let transaction) = result else { + continue + } + + // Add to purchased set if not revoked or expired + if transaction.revocationDate == nil { + purchasedIDs.insert(transaction.productID) + } + } + + purchasedProductIDs = purchasedIDs + print("📦 StoreKit: Updated purchased products: \(purchasedIDs)") + } + + /// Listen for transaction updates + private func listenForTransactions() -> Task { + return Task.detached { + // Listen for transaction updates + for await result in Transaction.updates { + do { + let transaction = try self.checkVerified(result) + + // Update purchased products + await self.updatePurchasedProducts() + + // Verify with backend + await self.verifyTransactionWithBackend(transaction) + + // Finish the transaction + await transaction.finish() + + print("✅ StoreKit: Transaction updated: \(transaction.productID)") + } catch { + print("❌ StoreKit: Transaction verification failed: \(error)") + } + } + } + } + + /// Verify transaction with backend API + private func verifyTransactionWithBackend(_ transaction: Transaction) async { + do { + // Get auth token + guard let token = TokenStorage.shared.getToken() else { + print("⚠️ StoreKit: No auth token, skipping backend verification") + return + } + + // Get transaction receipt data + guard let receiptData = try? await getReceiptData(for: transaction) else { + print("❌ StoreKit: Failed to get receipt data") + return + } + + // Call backend verification endpoint + let result = await subscriptionApi.verifyIOSReceipt( + token: token, + receiptData: receiptData, + transactionId: String(transaction.id) + ) + + switch result { + case .success(let response): + print("✅ StoreKit: Backend verification successful - Tier: \(response.tier)") + + // Update subscription cache + await MainActor.run { + let subscription = SubscriptionStatus( + tier: response.tier, + usage: response.usage, + limits: response.limits, + limitationsEnabled: response.limitationsEnabled + ) + SubscriptionCacheWrapper.shared.updateSubscription(subscription) + } + + case .failure(let error): + print("❌ StoreKit: Backend verification failed: \(error)") + + case .loading: + break + + case .idle: + break + } + } catch { + print("❌ StoreKit: Backend verification error: \(error)") + } + } + + /// Get receipt data for transaction + private func getReceiptData(for transaction: Transaction) async throws -> String { + // In StoreKit 2, we send the transaction ID instead of the legacy receipt + // The backend should verify the transaction with Apple's servers + return String(transaction.id) + } + + /// Verify the cryptographic signature of a transaction + private func checkVerified(_ result: VerificationResult) throws -> T { + switch result { + case .unverified: + throw StoreKitError.verificationFailed + case .verified(let safe): + return safe + } + } +} + +// MARK: - StoreKit Errors +extension StoreKitManager { + enum StoreKitError: LocalizedError { + case verificationFailed + case noProducts + case purchaseFailed + + var errorDescription: String? { + switch self { + case .verificationFailed: + return "Transaction verification failed" + case .noProducts: + return "No products available" + case .purchaseFailed: + return "Purchase failed" + } + } } } diff --git a/iosApp/iosApp/Subscription/UpgradePromptView.swift b/iosApp/iosApp/Subscription/UpgradePromptView.swift index 3f0c221..3635ac6 100644 --- a/iosApp/iosApp/Subscription/UpgradePromptView.swift +++ b/iosApp/iosApp/Subscription/UpgradePromptView.swift @@ -1,18 +1,23 @@ import SwiftUI import ComposeApp +import StoreKit struct UpgradePromptView: View { let triggerKey: String @Binding var isPresented: Bool - + @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared + @StateObject private var storeKit = StoreKitManager.shared @State private var showFeatureComparison = false @State private var isProcessing = false - + @State private var selectedProduct: Product? + @State private var errorMessage: String? + @State private var showSuccessAlert = false + var triggerData: UpgradeTriggerData? { subscriptionCache.upgradeTriggers[triggerKey] } - + var body: some View { NavigationStack { ScrollView { @@ -22,21 +27,21 @@ struct UpgradePromptView: View { .font(.system(size: 60)) .foregroundStyle(Color.appAccent.gradient) .padding(.top, AppSpacing.xl) - + // Title Text(triggerData?.title ?? "Upgrade to Pro") .font(.title2.weight(.bold)) .foregroundColor(Color.appTextPrimary) .multilineTextAlignment(.center) .padding(.horizontal) - + // Message Text(triggerData?.message ?? "Unlock unlimited access to all features") .font(.body) .foregroundColor(Color.appTextSecondary) .multilineTextAlignment(.center) .padding(.horizontal) - + // Pro Features Preview VStack(alignment: .leading, spacing: AppSpacing.md) { FeatureRow(icon: "house.fill", text: "Unlimited properties") @@ -48,29 +53,66 @@ struct UpgradePromptView: View { .background(Color.appBackgroundSecondary) .cornerRadius(AppRadius.lg) .padding(.horizontal) - - // Upgrade Button - Button(action: { - handleUpgrade() - }) { - HStack { - if isProcessing { - ProgressView() - .tint(Color.appTextOnPrimary) - } else { - Text(triggerData?.buttonText ?? "Upgrade to Pro") - .fontWeight(.semibold) + + // Subscription Products + if storeKit.isLoading { + ProgressView() + .tint(Color.appPrimary) + .padding() + } else if !storeKit.products.isEmpty { + VStack(spacing: AppSpacing.md) { + ForEach(storeKit.products, id: \.id) { product in + SubscriptionProductButton( + product: product, + isSelected: selectedProduct?.id == product.id, + isProcessing: isProcessing, + onSelect: { + selectedProduct = product + handlePurchase(product) + } + ) } } - .frame(maxWidth: .infinity) - .foregroundColor(Color.appTextOnPrimary) - .padding() - .background(Color.appPrimary) - .cornerRadius(AppRadius.md) + .padding(.horizontal) + } else { + // Fallback upgrade button if products fail to load + Button(action: { + Task { await storeKit.loadProducts() } + }) { + HStack { + if isProcessing { + ProgressView() + .tint(Color.appTextOnPrimary) + } else { + Text("Retry Loading Products") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .foregroundColor(Color.appTextOnPrimary) + .padding() + .background(Color.appPrimary) + .cornerRadius(AppRadius.md) + } + .disabled(isProcessing) + .padding(.horizontal) } - .disabled(isProcessing) - .padding(.horizontal) - + + // Error Message + if let error = errorMessage { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(Color.appError) + Text(error) + .font(.subheadline) + .foregroundColor(Color.appError) + } + .padding() + .background(Color.appError.opacity(0.1)) + .cornerRadius(AppRadius.md) + .padding(.horizontal) + } + // Compare Plans Button(action: { showFeatureComparison = true @@ -79,6 +121,15 @@ struct UpgradePromptView: View { .font(.subheadline) .foregroundColor(Color.appPrimary) } + + // Restore Purchases + Button(action: { + handleRestore() + }) { + Text("Restore Purchases") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } .padding(.bottom, AppSpacing.xl) } } @@ -94,19 +145,118 @@ struct UpgradePromptView: View { .sheet(isPresented: $showFeatureComparison) { FeatureComparisonView(isPresented: $showFeatureComparison) } + .alert("Subscription Active", isPresented: $showSuccessAlert) { + Button("Done") { + isPresented = false + } + } message: { + Text("You now have full access to all Pro features!") + } + .task { + await storeKit.loadProducts() + } } } - - private func handleUpgrade() { - // TODO: Implement StoreKit purchase flow + + private func handlePurchase(_ product: Product) { isProcessing = true - - // Placeholder: In production, this would trigger StoreKit purchase - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - isProcessing = false - // Show success/error + errorMessage = nil + + Task { + do { + let transaction = try await storeKit.purchase(product) + + await MainActor.run { + isProcessing = false + + if transaction != nil { + // Purchase successful + showSuccessAlert = true + } + } + } catch { + await MainActor.run { + isProcessing = false + errorMessage = "Purchase failed: \(error.localizedDescription)" + } + } } } + + private func handleRestore() { + isProcessing = true + errorMessage = nil + + Task { + await storeKit.restorePurchases() + + await MainActor.run { + isProcessing = false + + if !storeKit.purchasedProductIDs.isEmpty { + showSuccessAlert = true + } else { + errorMessage = "No purchases found to restore" + } + } + } + } +} + +struct SubscriptionProductButton: View { + let product: Product + let isSelected: Bool + let isProcessing: Bool + let onSelect: () -> Void + + var isAnnual: Bool { + product.id.contains("annual") + } + + var savingsText: String? { + if isAnnual { + return "Save 17%" + } + return nil + } + + var body: some View { + Button(action: onSelect) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(product.displayName) + .font(.headline) + .foregroundColor(Color.appTextPrimary) + + if let savings = savingsText { + Text(savings) + .font(.caption) + .foregroundColor(Color.appPrimary) + } + } + + Spacer() + + if isProcessing && isSelected { + ProgressView() + .tint(Color.appTextOnPrimary) + } else { + Text(product.displayPrice) + .font(.title3.weight(.bold)) + .foregroundColor(Color.appTextOnPrimary) + } + } + .padding() + .frame(maxWidth: .infinity) + .background(isAnnual ? Color.appPrimary : Color.appSecondary) + .cornerRadius(AppRadius.md) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.md) + .stroke(isAnnual ? Color.appAccent : Color.clear, lineWidth: 2) + ) + } + .disabled(isProcessing) + } } struct FeatureRow: View {