Files
honeyDueKMP/iosApp/iosApp/Subscription/StoreKitManager.swift
Trey t 1e2adf7660 Rebrand from Casera/MyCrib to honeyDue
Total rebrand across KMM project:
- Kotlin package: com.example.casera -> com.tt.honeyDue (dirs + declarations)
- Gradle: rootProject.name, namespace, applicationId
- Android: manifest, strings.xml (all languages), widget resources
- iOS: pbxproj bundle IDs, Info.plist, entitlements, xcconfig
- iOS directories: Casera/ -> HoneyDue/, CaseraTests/ -> HoneyDueTests/, etc.
- Swift source: all class/struct/enum renames
- Deep links: casera:// -> honeydue://, .casera -> .honeydue
- App icons replaced with honeyDue honeycomb icon
- Domains: casera.treytartt.com -> honeyDue.treytartt.com
- Bundle IDs: com.tt.casera -> com.tt.honeyDue
- Database table names preserved

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 06:33:57 -06:00

326 lines
12 KiB
Swift

import Foundation
import StoreKit
import ComposeApp
/// StoreKit 2 manager for in-app purchases
/// Handles product loading, purchases, transaction observation, and backend verification
@MainActor
class StoreKitManager: ObservableObject {
static let shared = StoreKitManager()
// Product IDs can be configured via Info.plist keys:
// HONEYDUE_IAP_MONTHLY_PRODUCT_ID / HONEYDUE_IAP_ANNUAL_PRODUCT_ID.
// Falls back to local StoreKit config IDs for development.
// Canonical source: SubscriptionProducts in commonMain (Kotlin shared code).
// Keep these in sync with SubscriptionProducts.MONTHLY / SubscriptionProducts.ANNUAL.
private let fallbackProductIDs = [
"com.tt.honeyDue.pro.monthly",
"com.tt.honeyDue.pro.annual"
]
private var configuredProductIDs: [String] {
let monthly = Bundle.main.object(forInfoDictionaryKey: "HONEYDUE_IAP_MONTHLY_PRODUCT_ID") as? String
let annual = Bundle.main.object(forInfoDictionaryKey: "HONEYDUE_IAP_ANNUAL_PRODUCT_ID") as? String
return [monthly, annual]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
private var productIDs: [String] {
if configuredProductIDs.isEmpty {
return fallbackProductIDs
}
return configuredProductIDs
}
@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
func loadProducts() async {
isLoading = true
defer { isLoading = false }
do {
if configuredProductIDs.isEmpty {
print("⚠️ StoreKit: Using fallback product IDs (configure HONEYDUE_IAP_MONTHLY_PRODUCT_ID/HONEYDUE_IAP_ANNUAL_PRODUCT_ID in Info.plist for production)")
}
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 only finish the transaction if verification succeeds
do {
try await verifyTransactionWithBackend(transaction)
await transaction.finish()
print("✅ StoreKit: Purchase successful for \(product.id)")
} catch {
print("⚠️ StoreKit: Backend verification failed for \(product.id), transaction NOT finished so it can be retried: \(error)")
self.purchaseError = "Purchase successful but verification is pending. It will complete automatically."
}
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)
try await verifyTransactionWithBackend(transaction)
}
print("✅ StoreKit: Purchases restored")
} catch {
print("❌ StoreKit: Failed to restore purchases: \(error)")
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 (best-effort on launch)
do {
try await verifyTransactionWithBackend(transaction)
} catch {
print("⚠️ StoreKit: Backend verification failed on launch for \(transaction.productID): \(error)")
}
// Update local purchased products
_ = 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 {
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
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 {
for await result in Transaction.updates {
do {
let transaction = try checkVerified(result)
await updatePurchasedProducts()
do {
try await verifyTransactionWithBackend(transaction)
await transaction.finish()
print("✅ StoreKit: Transaction updated: \(transaction.productID)")
} catch {
print("⚠️ StoreKit: Backend verification failed for \(transaction.productID), transaction NOT finished so it can be retried: \(error)")
}
} catch {
print("❌ StoreKit: Transaction verification failed: \(error)")
}
}
}
}
/// Verify transaction with backend API
/// Throws if backend verification fails so callers can decide whether to finish the transaction
private func verifyTransactionWithBackend(_ transaction: Transaction) async throws {
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 {
SubscriptionCacheWrapper.shared.updateSubscription(subscription)
}
} else if let errorResult = ApiResultBridge.error(from: result) {
let message = errorResult.message
print("❌ StoreKit: Backend verification failed: \(message)")
throw StoreKitError.backendVerificationFailed(message)
} else if let successResult = result as? ApiResultSuccess<VerificationResponse>,
let response = successResult.data,
!response.success {
let message = response.message
print("❌ StoreKit: Backend verification failed: \(message)")
throw StoreKitError.backendVerificationFailed(message)
}
}
/// 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
case backendVerificationFailed(String)
var errorDescription: String? {
switch self {
case .verificationFailed:
return "Transaction verification failed"
case .noProducts:
return "No products available"
case .purchaseFailed:
return "Purchase failed"
case .backendVerificationFailed(let message):
return "Backend verification failed: \(message)"
}
}
}
}