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 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()
|
|
||||||
|
|
||||||
// Placeholder
|
// Start the purchase
|
||||||
print("StoreKit: Would purchase product: \(product)")
|
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
|
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()
|
|
||||||
|
|
||||||
print("StoreKit: Would restore purchases here")
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify receipt with backend
|
print("✅ StoreKit: Purchases restored")
|
||||||
func verifyReceiptWithBackend(receiptData: String) async {
|
} catch {
|
||||||
// TODO: Call backend API to verify receipt
|
print("❌ StoreKit: Failed to restore purchases: \(error)")
|
||||||
// let api = SubscriptionApi()
|
await MainActor.run {
|
||||||
// let result = await api.verifyIOSReceipt(token: token, receiptData: receiptData)
|
purchaseError = "Failed to restore purchases: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
print("StoreKit: Would verify receipt with backend")
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Observe transaction updates
|
/// Check if user has an active subscription
|
||||||
private func observeTransactions() async {
|
func hasActiveSubscription() async -> Bool {
|
||||||
// In production, this would observe transaction updates
|
// Check current entitlements
|
||||||
// for await result in Transaction.updates {
|
for await result in Transaction.currentEntitlements {
|
||||||
// // Handle transaction
|
if let transaction = try? checkVerified(result),
|
||||||
// }
|
transaction.productType == .autoRenewable,
|
||||||
|
!transaction.isUpgraded {
|
||||||
print("StoreKit: Observing transactions")
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,13 +1,18 @@
|
|||||||
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]
|
||||||
@@ -49,16 +54,37 @@ struct UpgradePromptView: View {
|
|||||||
.cornerRadius(AppRadius.lg)
|
.cornerRadius(AppRadius.lg)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
// Upgrade Button
|
// 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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
} else {
|
||||||
|
// Fallback upgrade button if products fail to load
|
||||||
Button(action: {
|
Button(action: {
|
||||||
handleUpgrade()
|
Task { await storeKit.loadProducts() }
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
if isProcessing {
|
if isProcessing {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(Color.appTextOnPrimary)
|
.tint(Color.appTextOnPrimary)
|
||||||
} else {
|
} else {
|
||||||
Text(triggerData?.buttonText ?? "Upgrade to Pro")
|
Text("Retry Loading Products")
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +96,22 @@ struct UpgradePromptView: View {
|
|||||||
}
|
}
|
||||||
.disabled(isProcessing)
|
.disabled(isProcessing)
|
||||||
.padding(.horizontal)
|
.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: {
|
||||||
@@ -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
|
Task {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
do {
|
||||||
|
let transaction = try await storeKit.purchase(product)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
isProcessing = false
|
isProcessing = false
|
||||||
// Show success/error
|
|
||||||
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user