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>
326 lines
12 KiB
Swift
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)"
|
|
}
|
|
}
|
|
}
|
|
}
|