Implement StoreKit 2 purchase flow with backend verification

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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-24 13:46:33 -06:00
parent e838eecc64
commit 90c3858c90
2 changed files with 418 additions and 79 deletions

View File

@@ -1,75 +1,264 @@
import Foundation import Foundation
import StoreKit import StoreKit
import ComposeApp
/// StoreKit manager for in-app purchases /// StoreKit 2 manager for in-app purchases
/// NOTE: Requires App Store Connect configuration and product IDs /// Handles product loading, purchases, transaction observation, and backend verification
class StoreKitManager: ObservableObject { class StoreKitManager: ObservableObject {
static let shared = StoreKitManager() static let shared = StoreKitManager()
// Product ID for Pro subscription (configure in App Store Connect) // Product IDs (must match App Store Connect and Configuration.storekit)
private let proSubscriptionProductID = "com.example.mycrib.pro.monthly" private let productIDs = [
"com.example.mycrib.pro.monthly",
"com.example.mycrib.pro.annual"
]
@Published var products: [Product] = [] @Published var products: [Product] = []
@Published var purchasedProductIDs: Set<String> = [] @Published var purchasedProductIDs: Set<String> = []
@Published var isLoading = false @Published var isLoading = false
@Published var purchaseError: String?
private var transactionListener: Task<Void, Error>?
private let subscriptionApi = SubscriptionApi()
private init() { private init() {
// Start listening for transactions // Start listening for transactions
transactionListener = listenForTransactions()
// Check for existing entitlements
Task { Task {
await observeTransactions() await updatePurchasedProducts()
} }
} }
deinit {
transactionListener?.cancel()
}
/// Load available products from App Store /// Load available products from App Store
@MainActor
func loadProducts() async { func loadProducts() async {
isLoading = true isLoading = true
defer { isLoading = false } defer { isLoading = false }
do { do {
// In production, this would fetch real products let loadedProducts = try await Product.products(for: productIDs)
// products = try await Product.products(for: [proSubscriptionProductID]) products = loadedProducts.sorted { $0.price < $1.price }
print("✅ StoreKit: Loaded \(products.count) products")
// Placeholder: Simulate loading
print("StoreKit: Would load products here")
} catch { } catch {
print("Failed to load products: \(error)") print("❌ StoreKit: Failed to load products: \(error)")
purchaseError = "Failed to load products: \(error.localizedDescription)"
} }
} }
/// Purchase a product /// Purchase a product
func purchase(_ product: Product) async throws -> Transaction? { func purchase(_ product: Product) async throws -> Transaction? {
// In production, this would trigger actual purchase print("🛒 StoreKit: Initiating purchase for \(product.id)")
// let result = try await product.purchase()
// Start the purchase
// Placeholder let result = try await product.purchase()
print("StoreKit: Would purchase product: \(product)")
return nil 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 /// Restore previous purchases
func restorePurchases() async { func restorePurchases() async {
// In production, this would restore purchases print("🔄 StoreKit: Restoring purchases")
// try await AppStore.sync()
do {
print("StoreKit: Would restore purchases here") // 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 /// Check if user has an active subscription
func verifyReceiptWithBackend(receiptData: String) async { func hasActiveSubscription() async -> Bool {
// TODO: Call backend API to verify receipt // Check current entitlements
// let api = SubscriptionApi() for await result in Transaction.currentEntitlements {
// let result = await api.verifyIOSReceipt(token: token, receiptData: receiptData) if let transaction = try? checkVerified(result),
transaction.productType == .autoRenewable,
print("StoreKit: Would verify receipt with backend") !transaction.isUpgraded {
return true
}
}
return false
} }
/// Observe transaction updates /// Update purchased product IDs
private func observeTransactions() async { @MainActor
// In production, this would observe transaction updates private func updatePurchasedProducts() async {
// for await result in Transaction.updates { var purchasedIDs: Set<String> = []
// // Handle transaction
// } // Iterate through all current entitlements
for await result in Transaction.currentEntitlements {
print("StoreKit: Observing transactions") 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<Void, Error> {
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<T>(_ result: VerificationResult<T>) 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"
}
}
} }
} }

View File

@@ -1,18 +1,23 @@
import SwiftUI import SwiftUI
import ComposeApp import ComposeApp
import StoreKit
struct UpgradePromptView: View { struct UpgradePromptView: View {
let triggerKey: String let triggerKey: String
@Binding var isPresented: Bool @Binding var isPresented: Bool
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@StateObject private var storeKit = StoreKitManager.shared
@State private var showFeatureComparison = false @State private var showFeatureComparison = false
@State private var isProcessing = false @State private var isProcessing = false
@State private var selectedProduct: Product?
@State private var errorMessage: String?
@State private var showSuccessAlert = false
var triggerData: UpgradeTriggerData? { var triggerData: UpgradeTriggerData? {
subscriptionCache.upgradeTriggers[triggerKey] subscriptionCache.upgradeTriggers[triggerKey]
} }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
@@ -22,21 +27,21 @@ struct UpgradePromptView: View {
.font(.system(size: 60)) .font(.system(size: 60))
.foregroundStyle(Color.appAccent.gradient) .foregroundStyle(Color.appAccent.gradient)
.padding(.top, AppSpacing.xl) .padding(.top, AppSpacing.xl)
// Title // Title
Text(triggerData?.title ?? "Upgrade to Pro") Text(triggerData?.title ?? "Upgrade to Pro")
.font(.title2.weight(.bold)) .font(.title2.weight(.bold))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal) .padding(.horizontal)
// Message // Message
Text(triggerData?.message ?? "Unlock unlimited access to all features") Text(triggerData?.message ?? "Unlock unlimited access to all features")
.font(.body) .font(.body)
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal) .padding(.horizontal)
// Pro Features Preview // Pro Features Preview
VStack(alignment: .leading, spacing: AppSpacing.md) { VStack(alignment: .leading, spacing: AppSpacing.md) {
FeatureRow(icon: "house.fill", text: "Unlimited properties") FeatureRow(icon: "house.fill", text: "Unlimited properties")
@@ -48,29 +53,66 @@ struct UpgradePromptView: View {
.background(Color.appBackgroundSecondary) .background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg) .cornerRadius(AppRadius.lg)
.padding(.horizontal) .padding(.horizontal)
// Upgrade Button // Subscription Products
Button(action: { if storeKit.isLoading {
handleUpgrade() ProgressView()
}) { .tint(Color.appPrimary)
HStack { .padding()
if isProcessing { } else if !storeKit.products.isEmpty {
ProgressView() VStack(spacing: AppSpacing.md) {
.tint(Color.appTextOnPrimary) ForEach(storeKit.products, id: \.id) { product in
} else { SubscriptionProductButton(
Text(triggerData?.buttonText ?? "Upgrade to Pro") product: product,
.fontWeight(.semibold) isSelected: selectedProduct?.id == product.id,
isProcessing: isProcessing,
onSelect: {
selectedProduct = product
handlePurchase(product)
}
)
} }
} }
.frame(maxWidth: .infinity) .padding(.horizontal)
.foregroundColor(Color.appTextOnPrimary) } else {
.padding() // Fallback upgrade button if products fail to load
.background(Color.appPrimary) Button(action: {
.cornerRadius(AppRadius.md) 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 // Compare Plans
Button(action: { Button(action: {
showFeatureComparison = true showFeatureComparison = true
@@ -79,6 +121,15 @@ struct UpgradePromptView: View {
.font(.subheadline) .font(.subheadline)
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
} }
// Restore Purchases
Button(action: {
handleRestore()
}) {
Text("Restore Purchases")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
.padding(.bottom, AppSpacing.xl) .padding(.bottom, AppSpacing.xl)
} }
} }
@@ -94,19 +145,118 @@ struct UpgradePromptView: View {
.sheet(isPresented: $showFeatureComparison) { .sheet(isPresented: $showFeatureComparison) {
FeatureComparisonView(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() { private func handlePurchase(_ product: Product) {
// TODO: Implement StoreKit purchase flow
isProcessing = true isProcessing = true
errorMessage = nil
// Placeholder: In production, this would trigger StoreKit purchase
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { Task {
isProcessing = false do {
// Show success/error 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 { struct FeatureRow: View {