Files
honeyDueKMP/iosApp/iosApp/Subscription/StoreKitManager.swift
Trey t 63a54434ed Add 1-hour cache timeout and fix pull-to-refresh across iOS
- Add configurable cache timeout (CACHE_TIMEOUT_MS) to DataManager
- Fix cache to work with empty results (contractors, documents, residences)
- Change Documents/Warranties view to use client-side filtering for cache efficiency
- Add pull-to-refresh support for empty state views in ListAsyncContentView
- Fix ContractorsListView to pass forceRefresh parameter correctly
- Fix TaskViewModel loading spinner not stopping after refresh completes
- Remove duplicate cache checks in iOS ViewModels, delegate to Kotlin APILayer

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 09:50:57 -06:00

305 lines
10 KiB
Swift

import Foundation
import StoreKit
import ComposeApp
/// StoreKit 2 manager for in-app purchases
/// Handles product loading, purchases, transaction observation, and backend verification
class StoreKitManager: ObservableObject {
static let shared = StoreKitManager()
// Product IDs (must match App Store Connect and Configuration.storekit)
private let productIDs = [
"com.example.casera.pro.monthly",
"com.example.casera.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 init() {
// Start listening for transactions
transactionListener = listenForTransactions()
// Check for existing entitlements
Task {
await updatePurchasedProducts()
}
}
deinit {
transactionListener?.cancel()
}
/// Load available products from App Store
@MainActor
func loadProducts() async {
isLoading = true
defer { isLoading = false }
do {
let loadedProducts = try await Product.products(for: productIDs)
products = loadedProducts.sorted { $0.price < $1.price }
print("✅ StoreKit: Loaded \(products.count) products")
} catch {
print("❌ StoreKit: Failed to load products: \(error)")
purchaseError = "Failed to load products: \(error.localizedDescription)"
}
}
/// Purchase a product
func purchase(_ product: Product) async throws -> Transaction? {
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 {
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)"
}
}
}
/// 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
}
/// Refresh subscription status from StoreKit
/// Call this when app comes to foreground to ensure we have latest status
func refreshSubscriptionStatus() async {
await updatePurchasedProducts()
print("🔄 StoreKit: Subscription status refreshed")
}
/// Verify current entitlements with backend on app launch
/// This ensures the backend has up-to-date subscription info
func verifyEntitlementsOnLaunch() async {
print("🔄 StoreKit: Verifying entitlements on launch...")
// Get current entitlements from StoreKit
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue
}
// Skip if revoked
if transaction.revocationDate != nil {
continue
}
// Only process subscription products
if transaction.productType == .autoRenewable {
print("📦 StoreKit: Found active subscription: \(transaction.productID)")
// Verify this transaction with backend
await verifyTransactionWithBackend(transaction)
// Update local purchased products
await MainActor.run {
purchasedProductIDs.insert(transaction.productID)
}
}
}
// After verifying all entitlements, refresh subscription status from backend
await refreshSubscriptionFromBackend()
print("✅ StoreKit: Launch verification complete")
}
/// Fetch latest subscription status from backend and update cache
private func refreshSubscriptionFromBackend() async {
do {
let statusResult = try await APILayer.shared.getSubscriptionStatus(forceRefresh: true)
if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>,
let subscription = statusSuccess.data {
await MainActor.run {
SubscriptionCacheWrapper.shared.updateSubscription(subscription)
print("✅ StoreKit: Backend subscription status updated - Tier: \(subscription.limits)")
}
}
} catch {
print("❌ StoreKit: Failed to refresh subscription from backend: \(error)")
}
}
/// 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 transaction receipt data
let receiptData = String(transaction.id)
// Call backend verification endpoint via APILayer
let result = try await APILayer.shared.verifyIOSReceipt(
receiptData: receiptData,
transactionId: String(transaction.id)
)
// Handle result (Kotlin ApiResult type)
if let successResult = result as? ApiResultSuccess<VerificationResponse>,
let response = successResult.data,
response.success {
print("✅ StoreKit: Backend verification successful - Tier: \(response.tier ?? "unknown")")
// Fetch updated subscription status from backend via APILayer
let statusResult = try await APILayer.shared.getSubscriptionStatus(forceRefresh: true)
if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>,
let subscription = statusSuccess.data {
await MainActor.run {
SubscriptionCacheWrapper.shared.updateSubscription(subscription)
}
}
} else if let errorResult = result as? ApiResultError {
print("❌ StoreKit: Backend verification failed: \(errorResult.message)")
} else if let successResult = result as? ApiResultSuccess<VerificationResponse>,
let response = successResult.data,
!response.success {
print("❌ StoreKit: Backend verification failed: \(response.error ?? "Unknown error")")
}
} catch {
print("❌ StoreKit: Backend verification error: \(error)")
}
}
/// 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"
}
}
}
}