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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user