- 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>
305 lines
10 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|
|
}
|