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 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<String> = []
@Published var isLoading = false
@Published var purchaseError: String?
private var transactionListener: Task<Void, Error>?
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<String> = []
// 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<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 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 {