diff --git a/Example/OpenIapExample/Screens/AvailablePurchasesScreen.swift b/Example/OpenIapExample/Screens/AvailablePurchasesScreen.swift index a0ef9dd..00e09e3 100644 --- a/Example/OpenIapExample/Screens/AvailablePurchasesScreen.swift +++ b/Example/OpenIapExample/Screens/AvailablePurchasesScreen.swift @@ -10,6 +10,11 @@ struct AvailablePurchasesScreen: View { VStack(spacing: 24) { availablePurchasesSection purchaseHistorySection + + // Debug section for Sandbox testing + #if DEBUG + sandboxToolsSection + #endif } .padding(.vertical) } @@ -54,34 +59,32 @@ struct AvailablePurchasesScreen: View { private var uniqueActivePurchases: [OpenIapPurchase] { let allActivePurchases = store.purchases.filter { purchase in - // Show unconsumed consumables and active subscriptions - purchase.purchaseState == .purchased && ( - // Unconsumed consumables - (!purchase.id.contains("premium") && purchase.acknowledgementState != .acknowledged) || - // Active subscriptions - (purchase.id.contains("premium") && (purchase.isAutoRenewing || - (purchase.expiryTime != nil && purchase.expiryTime! > Date()))) - ) - } - - // Group by productId and take only the latest purchase for each subscription - var uniquePurchases: [OpenIapPurchase] = [] - var seenSubscriptions: Set = [] - - for purchase in allActivePurchases.sorted(by: { $0.purchaseTime > $1.purchaseTime }) { - if purchase.id.contains("premium") { - // For subscriptions, only add if we haven't seen this productId yet - if !seenSubscriptions.contains(purchase.id) { - uniquePurchases.append(purchase) - seenSubscriptions.insert(purchase.id) + // Show active purchases (purchased or restored state) + purchase.purchaseState == .purchased || purchase.purchaseState == .restored + }.filter { purchase in + // Check product type if we can determine it + let isSubscription = purchase.productId.contains("premium") || + purchase.productId.contains("subscription") + + if isSubscription { + // Active subscriptions: check auto-renewing or expiry time + if purchase.isAutoRenewing { + return true // Always show auto-renewing subscriptions } + // For non-auto-renewing, check expiry time + if let expiryTime = purchase.expirationDateIOS { + let expiryDate = Date(timeIntervalSince1970: expiryTime / 1000) + return expiryDate > Date() // Only show if not expired + } + return true // Show if no expiry info } else { - // For consumables, add all unconsumed items - uniquePurchases.append(purchase) + // Consumables: show if not acknowledged + return !purchase.purchaseState.isAcknowledged } } - return uniquePurchases + // Return sorted by date + return allActivePurchases.sorted(by: { $0.transactionDate > $1.transactionDate }) } @ViewBuilder @@ -94,7 +97,7 @@ struct AvailablePurchasesScreen: View { ) } else { VStack(spacing: 12) { - ForEach(uniqueActivePurchases, id: \.transactionId) { purchase in + ForEach(uniqueActivePurchases, id: \.id) { purchase in ActivePurchaseCard(purchase: purchase) { Task { await store.finishPurchase(purchase) @@ -129,13 +132,142 @@ struct AvailablePurchasesScreen: View { ) } else { VStack(spacing: 12) { - ForEach(store.purchases.sorted { $0.purchaseTime > $1.purchaseTime }, id: \.transactionId) { purchase in + ForEach(store.purchases.sorted { $0.transactionDate > $1.transactionDate }, id: \.id) { purchase in PurchaseHistoryCard(purchase: purchase) } } .padding(.horizontal) } } + + // MARK: - Sandbox Tools Section (Debug Only) + #if DEBUG + private var sandboxToolsSection: some View { + VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "wrench.and.screwdriver.fill") + .foregroundColor(AppColors.warning) + Text("πŸ§ͺ Sandbox Testing Tools") + .font(.headline) + Spacer() + } + + Text("Debug tools for testing in-app purchases in Sandbox environment") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(AppColors.warning.opacity(0.05)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(AppColors.warning.opacity(0.3), lineWidth: 1) + ) + .cornerRadius(12) + .padding(.horizontal) + + VStack(spacing: 12) { + // Clear All Transactions + Button(action: { + Task { + await store.clearAllTransactions() + } + }) { + HStack { + Image(systemName: "trash") + Text("Clear All Transactions") + Spacer() + Text("Reset") + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + .padding() + .background(Color.red.opacity(0.8)) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding(.horizontal) + + // Sync Subscription Status + Button(action: { + Task { + await store.syncSubscriptions() + } + }) { + HStack { + Image(systemName: "arrow.triangle.2.circlepath") + Text("Sync Subscription Status") + Spacer() + Text("Refresh") + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + .padding() + .background(Color.blue.opacity(0.8)) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding(.horizontal) + + // Finish Pending Transactions + Button(action: { + Task { + await store.finishUnfinishedTransactions() + } + }) { + HStack { + Image(systemName: "checkmark.circle") + Text("Finish Pending Transactions") + Spacer() + Text("Complete") + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + .padding() + .background(Color.orange.opacity(0.8)) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding(.horizontal) + } + + // Testing Tips + VStack(alignment: .leading, spacing: 8) { + Label("Testing Tips", systemImage: "lightbulb.fill") + .font(.caption.weight(.semibold)) + .foregroundColor(AppColors.warning) + .padding(.bottom, 4) + + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 8) { + Text("β€’") + Text("Use real device for best results") + } + HStack(alignment: .top, spacing: 8) { + Text("β€’") + Text("Sign in with Sandbox account in Settings > App Store") + } + HStack(alignment: .top, spacing: 8) { + Text("β€’") + Text("Clear transactions resets local cache") + } + HStack(alignment: .top, spacing: 8) { + Text("β€’") + Text("Subscriptions expire quickly (5 min = 1 month)") + } + } + .font(.caption2) + .foregroundColor(.secondary) + } + .padding() + .background(AppColors.warning.opacity(0.05)) + .cornerRadius(8) + .padding(.horizontal) + + Spacer(minLength: 20) + } + } + #endif } // MARK: - Active Purchase Card (For Available Purchases) @@ -144,7 +276,7 @@ struct ActivePurchaseCard: View { let onConsume: () -> Void private var isSubscription: Bool { - purchase.id.contains("premium") || purchase.isAutoRenewing + purchase.productId.contains("premium") } var body: some View { @@ -169,7 +301,7 @@ struct ActivePurchaseCard: View { .foregroundColor(AppColors.primary) } - if let expiryTime = purchase.expiryTime { + if let expiryTime = purchase.expirationDateIOS != nil ? Date(timeIntervalSince1970: purchase.expirationDateIOS! / 1000) : nil { Text("Expires: \(expiryTime, style: .relative)") .font(.caption) .foregroundColor(.secondary) @@ -179,15 +311,19 @@ struct ActivePurchaseCard: View { Spacer() // Action Button - if !isSubscription && purchase.acknowledgementState != .acknowledged { + if !isSubscription && !purchase.purchaseState.isAcknowledged { Button(action: onConsume) { - Text("Consume") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 6) - .background(AppColors.primary) - .cornerRadius(8) + HStack(spacing: 4) { + Image(systemName: "checkmark.circle") + .font(.system(size: 12)) + Text("Finish") + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(AppColors.primary) + .cornerRadius(8) } } else if isSubscription { Text("Active") @@ -213,21 +349,35 @@ struct PurchaseHistoryCard: View { private var statusColor: Color { switch purchase.purchaseState { - case .purchased: return AppColors.success - case .pending: return AppColors.warning - case .failed: return AppColors.error - case .restored: return AppColors.primary - case .deferred: return AppColors.secondary + case .purchased: + return AppColors.success + case .pending: + return AppColors.warning + case .failed: + return AppColors.error + case .restored: + return AppColors.primary + case .deferred: + return AppColors.secondary + case .unknown: + return AppColors.secondary } } private var statusText: String { switch purchase.purchaseState { - case .purchased: return "Purchased" - case .pending: return "Pending" - case .failed: return "Failed" - case .restored: return "Restored" - case .deferred: return "Deferred" + case .purchased: + return "Purchased" + case .pending: + return "Pending" + case .failed: + return "Failed" + case .restored: + return "Restored" + case .deferred: + return "Deferred" + case .unknown: + return "Unknown" } } @@ -239,7 +389,7 @@ struct PurchaseHistoryCard: View { .font(.headline) .fontWeight(.semibold) - Text("Transaction: \(String(purchase.transactionId.prefix(8)))...") + Text("Transaction: \(String(purchase.id.prefix(8)))...") .font(.caption) .foregroundColor(.secondary) } @@ -256,16 +406,16 @@ struct PurchaseHistoryCard: View { .foregroundColor(statusColor) .cornerRadius(4) - if purchase.acknowledgementState == .acknowledged { - Label("Consumed", systemImage: "checkmark.circle.fill") - .font(.caption2) - .foregroundColor(.secondary) - } + + Label(purchase.purchaseState.isAcknowledged ? "Consumed" : "Pending", + systemImage: purchase.purchaseState.isAcknowledged ? "checkmark.circle.fill" : "clock") + .font(.caption2) + .foregroundColor(.secondary) } } HStack(spacing: 16) { - Label("\(purchase.purchaseTime, style: .date)", systemImage: "calendar") + Label("\(Date(timeIntervalSince1970: purchase.transactionDate / 1000), style: .date)", systemImage: "calendar") .font(.caption) .foregroundColor(.secondary) @@ -304,6 +454,8 @@ struct PurchaseCard: View { return AppColors.primary case .deferred: return AppColors.secondary + case .unknown: + return AppColors.secondary } } @@ -319,6 +471,8 @@ struct PurchaseCard: View { return "Restored" case .deferred: return "Deferred" + case .unknown: + return "Unknown" } } @@ -330,7 +484,7 @@ struct PurchaseCard: View { .font(.headline) .font(.system(.body, design: .monospaced)) - Text("Transaction: \\(purchase.transactionId)") + Text("Transaction: \\(purchase.id)") .font(.caption) .foregroundColor(.secondary) } @@ -352,11 +506,11 @@ struct PurchaseCard: View { Text("Purchased:") .font(.caption) .foregroundColor(.secondary) - Text(purchase.purchaseTime, style: .date) + Text(Date(timeIntervalSince1970: purchase.transactionDate / 1000), style: .date) .font(.caption) } - if let expiryTime = purchase.expiryTime { + if let expiryTime = purchase.expirationDateIOS != nil ? Date(timeIntervalSince1970: purchase.expirationDateIOS! / 1000) : nil { HStack { Text("Expires:") .font(.caption) @@ -367,15 +521,8 @@ struct PurchaseCard: View { } } - HStack { - Text("Quantity:") - .font(.caption) - .foregroundColor(.secondary) - Text("\\(purchase.quantity)") - .font(.caption) - } - if isSubscription && purchase.isAutoRenewing { + if isSubscription { HStack { Image(systemName: "arrow.triangle.2.circlepath") .font(.caption) @@ -386,7 +533,7 @@ struct PurchaseCard: View { } } - if !isSubscription && purchase.acknowledgementState == .notAcknowledged { + if !isSubscription && !purchase.purchaseState.isAcknowledged { Button(action: onConsume) { HStack { Image(systemName: "checkmark.circle") @@ -398,7 +545,7 @@ struct PurchaseCard: View { .foregroundColor(.white) .cornerRadius(8) } - } else if purchase.acknowledgementState == .acknowledged { + } else { HStack { Image(systemName: "checkmark.seal.fill") .foregroundColor(AppColors.success) @@ -467,7 +614,7 @@ struct ProductListCard: View { .font(.headline) .fontWeight(.semibold) - Text(product.localizedDescription) + Text(product.description) .font(.caption) .foregroundColor(.secondary) .lineLimit(2) @@ -477,7 +624,7 @@ struct ProductListCard: View { // Price and Purchase Button VStack(spacing: 8) { - Text(product.localizedPrice) + Text(product.displayPrice) .font(.system(size: 16, weight: .bold)) .foregroundColor(AppColors.primary) @@ -518,7 +665,7 @@ struct ProductListCard: View { } else if product.id.contains("premium") { return "Premium Subscription" } else { - return product.localizedTitle + return product.title } } } @@ -543,7 +690,7 @@ struct ProductGridCard: View { .multilineTextAlignment(.center) .lineLimit(2) - Text(product.localizedDescription) + Text(product.description) .font(.caption) .foregroundColor(.secondary) .multilineTextAlignment(.center) @@ -553,7 +700,7 @@ struct ProductGridCard: View { Spacer() VStack(spacing: 8) { - Text(product.localizedPrice) + Text(product.displayPrice) .font(.title3) .fontWeight(.bold) .foregroundColor(AppColors.primary) @@ -598,7 +745,7 @@ struct ProductGridCard: View { } else if product.id.contains("premium") { return "Premium" } else { - return product.localizedTitle + return product.title } } } \ No newline at end of file diff --git a/Example/OpenIapExample/Screens/PurchaseFlowScreen.swift b/Example/OpenIapExample/Screens/PurchaseFlowScreen.swift index b3fc993..b757d55 100644 --- a/Example/OpenIapExample/Screens/PurchaseFlowScreen.swift +++ b/Example/OpenIapExample/Screens/PurchaseFlowScreen.swift @@ -67,7 +67,7 @@ struct ProductCard: View { let onPurchase: () -> Void private var productIcon: String { - switch product.productType { + switch product.typeIOS { case .consumable, .nonConsumable, .nonRenewingSubscription: return "bag.fill" case .autoRenewableSubscription: @@ -76,7 +76,7 @@ struct ProductCard: View { } private var productTypeText: String { - switch product.productType { + switch product.typeIOS { case .consumable, .nonConsumable: return "In-App Purchase" case .autoRenewableSubscription: @@ -96,7 +96,7 @@ struct ProductCard: View { .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 4) { - Text(product.localizedTitle) + Text(product.title) .font(.headline) .fontWeight(.semibold) @@ -111,7 +111,7 @@ struct ProductCard: View { Spacer() - Text(product.localizedPrice) + Text(product.displayPrice) .font(.title2) .fontWeight(.bold) .foregroundColor(AppColors.primary) @@ -120,7 +120,7 @@ struct ProductCard: View { } // Product description - Text(product.localizedDescription) + Text(product.description) .font(.subheadline) .foregroundColor(AppColors.secondaryText) .lineLimit(nil) @@ -149,7 +149,7 @@ struct ProductCard: View { Spacer() if !isLoading { - Text(product.localizedPrice) + Text(product.displayPrice) .fontWeight(.semibold) } } @@ -191,7 +191,7 @@ struct RecentPurchasesSection: View { } VStack(spacing: 12) { - ForEach(purchases.prefix(3), id: \.transactionId) { purchase in + ForEach(purchases.prefix(3), id: \.id) { purchase in RecentPurchaseRow(purchase: purchase) } @@ -237,7 +237,7 @@ struct RecentPurchaseRow: View { .font(.subheadline) .fontWeight(.medium) - Text(purchase.purchaseTime, style: .relative) + Text(Date(timeIntervalSince1970: purchase.transactionDate / 1000), style: .relative) .font(.caption) .foregroundColor(AppColors.secondaryText) } @@ -271,17 +271,22 @@ struct InstructionsCard: View { PurchaseInstructionRow( number: "2", - text: "Tap 'Purchase' on any product above to test the flow" + text: "Tap 'Purchase' to buy β†’ Receipt validation β†’ Finish transaction" ) PurchaseInstructionRow( number: "3", - text: "Use test card 4242 4242 4242 4242 in sandbox" + text: "Receipt is validated with server (see StoreViewModel example)" ) PurchaseInstructionRow( number: "4", - text: "Check Available Purchases screen for purchase history" + text: "After validation, transaction is finished automatically" + ) + + PurchaseInstructionRow( + number: "5", + text: "Check Available Purchases to manually finish if needed" ) } } @@ -412,8 +417,8 @@ struct ProductsContentView: View { var consumableProducts: [OpenIapProduct] { store.products.filter { product in - // Filter out premium subscription products - !product.id.contains("premium") && product.type == "inapp" + // Filter out subscription products + !product.typeIOS.isSubs } } diff --git a/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift b/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift index 32bd396..99e1f1e 100644 --- a/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift +++ b/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift @@ -42,7 +42,7 @@ struct SubscriptionFlowScreen: View { if store.isLoading { LoadingCard(text: "Loading subscriptions...") } else { - let subscriptionProducts = store.products.filter { $0.id.contains("premium") } + let subscriptionProducts = store.products.filter { $0.typeIOS.isSubs } if subscriptionProducts.isEmpty { EmptyStateCard( @@ -52,25 +52,63 @@ struct SubscriptionFlowScreen: View { ) } else { ForEach(subscriptionProducts, id: \.id) { product in - let isSubscribed = store.purchases.contains { purchase in - purchase.id == product.id && - purchase.purchaseState == .purchased && - (purchase.isAutoRenewing || (purchase.expiryTime != nil && purchase.expiryTime! > Date())) + let activePurchase = store.purchases.first { purchase in + purchase.productId == product.id } + let isSubscribed = activePurchase != nil + let isCancelled = activePurchase?.isAutoRenewing == false SubscriptionCard( product: product, isSubscribed: isSubscribed, - isLoading: store.purchasingProductIds.contains(product.id) - ) { - if !isSubscribed { - store.purchaseProduct(product) + isCancelled: isCancelled, + isLoading: store.purchasingProductIds.contains(product.id), + onSubscribe: { + if !isSubscribed || isCancelled { + store.purchaseProduct(product) + } + }, + onManage: { + Task { + await store.manageSubscriptions() + } } - } + ) } } } + // Instructions Card + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "info.circle") + .foregroundColor(AppColors.secondary) + Text("Subscription Flow") + .font(.headline) + } + .padding(.bottom, 8) + + VStack(alignment: .leading, spacing: 8) { + Label("Purchase β†’ Auto receipt validation", systemImage: "1.circle.fill") + .font(.subheadline) + Label("Server validates receipt (see StoreViewModel)", systemImage: "2.circle.fill") + .font(.subheadline) + Label("Subscriptions auto-finish (no manual finish needed)", systemImage: "3.circle.fill") + .font(.subheadline) + Label("Re-subscriptions may take 10-30 seconds in Sandbox", systemImage: "4.circle.fill") + .font(.subheadline) + } + .foregroundColor(AppColors.primaryText) + } + .padding() + .background(AppColors.secondary.opacity(0.05)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(AppColors.secondary.opacity(0.3), lineWidth: 1) + ) + .cornerRadius(8) + .padding(.horizontal) + Button(action: { Task { await store.restorePurchases() @@ -124,15 +162,17 @@ struct SubscriptionFlowScreen: View { struct SubscriptionCard: View { let product: OpenIapProduct let isSubscribed: Bool + let isCancelled: Bool let isLoading: Bool let onSubscribe: () -> Void + let onManage: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { VStack(alignment: .leading, spacing: 4) { HStack { - Text(product.localizedTitle) + Text(product.title) .font(.headline) if isSubscribed { @@ -155,12 +195,12 @@ struct SubscriptionCard: View { Spacer() VStack(alignment: .trailing, spacing: 2) { - Text(product.localizedPrice) + Text(product.displayPrice) .font(.title2) .fontWeight(.bold) .foregroundColor(isSubscribed ? AppColors.success : AppColors.secondary) - if product.type == "subs" { + if product.typeIOS.isSubs { Text("per month") .font(.caption) .foregroundColor(.secondary) @@ -172,7 +212,7 @@ struct SubscriptionCard: View { .font(.body) .foregroundColor(AppColors.primaryText) - if product.type == "subs" { + if product.typeIOS.isSubs { HStack { Image(systemName: "arrow.triangle.2.circlepath") .font(.caption) @@ -187,32 +227,87 @@ struct SubscriptionCard: View { } if isSubscribed { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(AppColors.success) - - Text("Currently Subscribed") - .fontWeight(.medium) - .foregroundColor(AppColors.success) - - Spacer() + VStack(spacing: 12) { + HStack { + Image(systemName: isCancelled ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") + .foregroundColor(isCancelled ? AppColors.warning : AppColors.success) + + Text(isCancelled ? "Subscription Cancelled" : "Currently Subscribed") + .fontWeight(.medium) + .foregroundColor(isCancelled ? AppColors.warning : AppColors.success) + + Spacer() + + Text(isCancelled ? "Expires Soon" : "Active") + .font(.caption) + .fontWeight(.semibold) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background((isCancelled ? AppColors.warning : AppColors.success).opacity(0.2)) + .foregroundColor(isCancelled ? AppColors.warning : AppColors.success) + .cornerRadius(4) + } + .padding() + .background((isCancelled ? AppColors.warning : AppColors.success).opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isCancelled ? AppColors.warning : AppColors.success, lineWidth: 1) + ) + .cornerRadius(8) - Text("Active") - .font(.caption) - .fontWeight(.semibold) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(AppColors.success.opacity(0.2)) - .foregroundColor(AppColors.success) - .cornerRadius(4) + if isCancelled { + // Re-subscribe button for cancelled subscriptions + Button(action: onSubscribe) { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + .tint(.white) + } else { + Image(systemName: "arrow.clockwise.circle") + } + Text(isLoading ? "Reactivating..." : "Reactivate Subscription") + .fontWeight(.medium) + Spacer() + Text(product.displayPrice) + .fontWeight(.semibold) + } + .padding() + .background(isLoading ? AppColors.secondary.opacity(0.7) : AppColors.secondary) + .foregroundColor(.white) + .cornerRadius(8) + } + .disabled(isLoading) + + Text("Subscription will remain active until expiry") + .font(.caption2) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } else { + // Manage/Cancel Subscription Button + Button(action: onManage) { + HStack { + Image(systemName: "gear") + .font(.system(size: 14)) + Text("Manage Subscription") + .fontWeight(.medium) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color.gray.opacity(0.15)) + .foregroundColor(AppColors.primaryText) + .cornerRadius(8) + } + + Text("Cancel anytime in Settings") + .font(.caption2) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } } - .padding() - .background(AppColors.success.opacity(0.1)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(AppColors.success, lineWidth: 1) - ) - .cornerRadius(8) } else { Button(action: onSubscribe) { HStack { @@ -230,7 +325,7 @@ struct SubscriptionCard: View { Spacer() if !isLoading { - Text(product.localizedPrice) + Text(product.displayPrice) .fontWeight(.semibold) } } diff --git a/Example/OpenIapExample/ViewModels/StoreViewModel.swift b/Example/OpenIapExample/ViewModels/StoreViewModel.swift index e72b1a0..bea593f 100644 --- a/Example/OpenIapExample/ViewModels/StoreViewModel.swift +++ b/Example/OpenIapExample/ViewModels/StoreViewModel.swift @@ -15,6 +15,8 @@ class StoreViewModel: ObservableObject { @Published var isConnectionInitialized = false private let iapModule = OpenIapModule.shared + private var purchaseSubscription: Subscription? + private var errorSubscription: Subscription? init() { print("πŸš€ StoreViewModel Initializing...") @@ -23,8 +25,8 @@ class StoreViewModel: ObservableObject { deinit { print("🧹 StoreViewModel Deinitializing - cleaning up listeners...") - iapModule.removeAllPurchaseUpdatedListeners() - iapModule.removeAllPurchaseErrorListeners() + // Note: Listeners will be automatically cleaned up when the module is deallocated + // We cannot call @MainActor methods from deinit } private func setupStoreKit() { @@ -47,28 +49,28 @@ class StoreViewModel: ObservableObject { private func setupPurchaseListeners() { // Add purchase updated listener - iapModule.addPurchaseUpdatedListener { [weak self] purchase in + purchaseSubscription = iapModule.purchaseUpdatedListener { [weak self] purchase in Task { @MainActor in print("🎯 Purchase Updated Event Received:") print(" β€’ Product ID: \(purchase.id)") - print(" β€’ Transaction ID: \(purchase.transactionId)") - print(" β€’ Purchase State: \(purchase.purchaseState)") - print(" β€’ Purchase Time: \(purchase.purchaseTime)") - print(" β€’ Is Auto Renewing: \(purchase.isAutoRenewing)") - print(" β€’ Acknowledgement State: \(purchase.acknowledgementState)") + print(" β€’ Transaction ID: \(purchase.id)") + print(" β€’ Transaction Date: \(purchase.transactionDate)") + print(" β€’ Purchase Time: \(purchase.transactionDate)") + print(" β€’ Product ID from Purchase: \(purchase.productId)") self?.handlePurchaseUpdated(purchase) } } // Add purchase error listener - iapModule.addPurchaseErrorListener { [weak self] error in + errorSubscription = iapModule.purchaseErrorListener { [weak self] error in Task { @MainActor in print("πŸ’₯ Purchase Error Event Received:") - print(" β€’ Error: \(error)") - print(" β€’ Description: \(error.localizedDescription)") + print(" β€’ Error Code: \(error.code)") + print(" β€’ Message: \(error.message)") + print(" β€’ Product ID: \(error.productId ?? "N/A")") - self?.handlePurchaseError(error, productId: nil) + self?.handlePurchaseError(error, productId: error.productId) } } @@ -78,19 +80,8 @@ class StoreViewModel: ObservableObject { private func handlePurchaseUpdated(_ purchase: OpenIapPurchase) { print("πŸ”„ Processing purchase update for: \(purchase.id)") - switch purchase.purchaseState { - case .purchased: - handlePurchaseSuccess(purchase.id) - case .failed: - handlePurchaseError(OpenIapError.purchaseFailed(reason: "Purchase failed"), productId: purchase.id) - case .pending: - print("⏳ Purchase pending for: \(purchase.id)") - case .restored: - print("♻️ Purchase restored for: \(purchase.id)") - handlePurchaseSuccess(purchase.id) - case .deferred: - print("⏸️ Purchase deferred for: \(purchase.id)") - } + // Since we receive this through the success event, treat as successful + handlePurchaseSuccess(purchase.productId) } private func handlePurchaseSuccess(_ productId: String) { @@ -103,7 +94,12 @@ class StoreViewModel: ObservableObject { if let purchasedProduct = products.first(where: { $0.id == productId }) { lastPurchasedProduct = purchasedProduct showPurchaseSuccess = true - print("πŸŽ‰ Purchase success dialog will show for: \(purchasedProduct.localizedTitle)") + print("πŸŽ‰ Purchase success dialog will show for: \(purchasedProduct.title)") + + // IMPORTANT: Server-side receipt validation should be done here + Task { + await validateAndFinishPurchase(productId: productId) + } } // Reload purchases to show the new purchase @@ -112,20 +108,90 @@ class StoreViewModel: ObservableObject { } } - private func handlePurchaseError(_ error: Error, productId: String?) { + private func validateAndFinishPurchase(productId: String) async { + print("πŸ“‹ Starting receipt validation for product: \(productId)") + + // STEP 1: Get the receipt data + do { + guard let receiptData = try await iapModule.getReceiptDataIOS() else { + print("⚠️ No receipt data available") + return + } + print("πŸ“¦ Receipt data obtained, length: \(receiptData.count) bytes") + + // STEP 2: Validate receipt with your own server + // IMPORTANT: Never validate receipts client-side in production! + // This is just an example. In production, send the receipt to your server. + /* + Example server validation: + + let validated = await validateWithServer(receiptData: receiptData, productId: productId) + + func validateWithServer(receiptData: String, productId: String) async -> Bool { + // Send receipt to your backend server + let url = URL(string: "https://your-server.com/api/validate-receipt")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = [ + "receipt": receiptData, + "productId": productId, + "platform": "ios" + ] + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + // Parse response and return validation result + return true // or false based on server response + } + */ + + // STEP 3: After successful validation, finish the transaction + // Find the transaction ID for this product + let purchases = try await iapModule.getAvailablePurchases(PurchaseOptions()) + if let purchase = purchases.first(where: { $0.productId == productId }) { + print("πŸ” Found purchase to finish: \(purchase.id)") + + // Check if it's a subscription or consumable + let isSubscription = products.first(where: { $0.id == productId })?.typeIOS.isSubs ?? false + + if isSubscription { + print("πŸ“… Subscription transaction - auto-renewed, no need to finish manually") + // Subscriptions are automatically finished by StoreKit + // You should still validate the receipt on your server + } else { + // For consumables and non-consumables, finish the transaction + let finished = try await iapModule.finishTransaction(transactionIdentifier: purchase.id) + if finished { + print("βœ… Transaction finished successfully: \(purchase.id)") + } else { + print("⚠️ Transaction could not be finished: \(purchase.id)") + } + } + } + + } catch { + print("❌ Receipt validation/finish error: \(error)") + showErrorMessage("Failed to validate receipt: \(error.localizedDescription)") + } + } + + private func handlePurchaseError(_ error: PurchaseError, productId: String?) { print("❌ Purchase Error Handler Called:") - print(" β€’ Error Type: \(type(of: error))") - print(" β€’ Error Description: \(error.localizedDescription)") - print(" β€’ Product ID: \(productId ?? "N/A")") + print(" β€’ Error Code: \(error.code)") + print(" β€’ Error Message: \(error.message)") + print(" β€’ Product ID: \(error.productId ?? productId ?? "N/A")") // Remove loading state for this product if available - if let productId = productId { - print(" β€’ Removing loading state for product: \(productId)") - purchasingProductIds.remove(productId) + let targetProductId = error.productId ?? productId + if let targetProductId = targetProductId { + print(" β€’ Removing loading state for product: \(targetProductId)") + purchasingProductIds.remove(targetProductId) } // Show error message to user - showErrorMessage(error.localizedDescription) + showErrorMessage(error.message) } func loadProducts() async { @@ -145,13 +211,14 @@ class StoreViewModel: ObservableObject { isLoading = true do { // Real product IDs configured in App Store Connect - let productIds: Set = [ + let productIds: [String] = [ "dev.hyo.martie.10bulbs", "dev.hyo.martie.30bulbs", "dev.hyo.martie.premium" ] - products = try await iapModule.fetchProducts(skus: Array(productIds)) + let request = ProductRequest(skus: productIds, type: .all) + products = try await iapModule.fetchProducts(request) if products.isEmpty { showErrorMessage("No products found. Please check your App Store Connect configuration for IDs: \(productIds.joined(separator: ", "))") @@ -163,10 +230,26 @@ class StoreViewModel: ObservableObject { } func loadPurchases() async { + // Ensure connection is initialized first + if !isConnectionInitialized { + do { + _ = try await iapModule.initConnection() + isConnectionInitialized = true + print("βœ… Connection initialized for purchases") + } catch { + showErrorMessage("Failed to initialize connection: \(error.localizedDescription)") + isLoading = false + return + } + } + isLoading = true do { - let purchaseHistory = try await iapModule.getAvailablePurchases(onlyIncludeActiveItems: false) - purchases = purchaseHistory.sorted { $0.purchaseTime > $1.purchaseTime } + // Only load ACTIVE purchases (not all history) + let options = PurchaseOptions(onlyIncludeActiveItemsIOS: true) + let activePurchases = try await iapModule.getAvailablePurchases(options) + purchases = activePurchases.sorted { $0.transactionDate > $1.transactionDate } + print("πŸ“¦ Loaded \(purchases.count) active purchases") } catch { showErrorMessage(error.localizedDescription) } @@ -179,36 +262,35 @@ class StoreViewModel: ObservableObject { print("πŸ›’ Purchase Process Started:") print(" β€’ Product ID: \(product.id)") - print(" β€’ Product Title: \(product.localizedTitle)") - print(" β€’ Product Price: \(product.localizedPrice)") - print(" β€’ Product Type: \(product.productType.rawValue)") + print(" β€’ Product Title: \(product.title)") + print(" β€’ Product Price: \(product.displayPrice)") + print(" β€’ Product Type: \(product.type) (iOS: \(product.typeIOS.rawValue))") + + // Check if this is a re-subscription + let wasPreviouslySubscribed = purchases.contains { $0.productId == product.id } + if wasPreviouslySubscribed { + print(" ⚠️ Re-subscribing to previously cancelled subscription") + print(" ⏳ This may take 10-30 seconds in Sandbox environment") + } Task { do { print("πŸ”„ Calling requestPurchase API...") - let transactionData = try await iapModule.requestPurchase( + let props = RequestPurchaseProps( sku: product.id, andDangerouslyFinishTransactionAutomatically: true, appAccountToken: nil, - quantity: 1, - discountOffer: nil + quantity: 1 ) + let transaction = try await iapModule.requestPurchase(props) print("πŸ“¦ Purchase API Response:") - if let transaction = transactionData { - print(" β€’ Transaction received: \(transaction.transactionId)") - print(" β€’ Product ID: \(transaction.id)") - print(" β€’ Purchase State: \(transaction.purchaseState)") - print("βœ… Purchase successful via API: \(product.localizedTitle)") - await MainActor.run { - handlePurchaseSuccess(product.id) - } - } else { - print(" β€’ No transaction data received") - print("❌ Purchase failed: No transaction data") - await MainActor.run { - handlePurchaseError(OpenIapError.purchaseFailed(reason: "No transaction data received"), productId: product.id) - } + print(" β€’ Transaction received: \(transaction.id)") + print(" β€’ Product ID: \(transaction.productId)") + print(" β€’ Transaction Date: \(transaction.transactionDate)") + print("βœ… Purchase successful via API: \(product.title)") + await MainActor.run { + handlePurchaseSuccess(product.id) } } catch { print("πŸ’₯ Purchase API Error:") @@ -216,7 +298,13 @@ class StoreViewModel: ObservableObject { print(" β€’ Error Description: \(error.localizedDescription)") print(" β€’ Product ID: \(product.id)") await MainActor.run { - handlePurchaseError(error, productId: product.id) + let purchaseError: PurchaseError + if let openIapError = error as? OpenIapError { + purchaseError = PurchaseError(from: openIapError, productId: product.id) + } else { + purchaseError = PurchaseError(from: error, productId: product.id) + } + handlePurchaseError(purchaseError, productId: product.id) } } } @@ -226,8 +314,8 @@ class StoreViewModel: ObservableObject { do { // In iOS, there's no distinction between consumable and non-consumable for finishing transactions // The product type is determined by App Store Connect configuration - _ = try await iapModule.finishTransaction(transactionIdentifier: purchase.transactionId) - if let index = purchases.firstIndex(where: { $0.transactionId == purchase.transactionId }) { + _ = try await iapModule.finishTransaction(transactionIdentifier: purchase.id) + if let index = purchases.firstIndex(where: { $0.id == purchase.id }) { purchases.remove(at: index) } } catch { @@ -236,19 +324,186 @@ class StoreViewModel: ObservableObject { } func restorePurchases() async { + // Ensure connection is initialized first + if !isConnectionInitialized { + do { + _ = try await iapModule.initConnection() + isConnectionInitialized = true + print("βœ… Connection initialized for restore") + } catch { + showErrorMessage("Failed to initialize connection: \(error.localizedDescription)") + return + } + } + do { - let restored = try await iapModule.getAvailablePurchases(onlyIncludeActiveItems: false) - purchases = restored.sorted { $0.purchaseTime > $1.purchaseTime } + // Only restore ACTIVE items, not entire history + let options = PurchaseOptions(onlyIncludeActiveItemsIOS: true) + let restored = try await iapModule.getAvailablePurchases(options) + purchases = restored.sorted { $0.transactionDate > $1.transactionDate } + print("πŸ“¦ Restored \(purchases.count) active purchases") + + if purchases.isEmpty { + showErrorMessage("No active purchases to restore") + } else { + showErrorMessage("Restored \(purchases.count) active purchase(s)") + } } catch { showErrorMessage(error.localizedDescription) } } + func manageSubscriptions() async { + print("πŸ”§ Opening subscription management...") + + // Ensure connection is initialized first + if !isConnectionInitialized { + do { + _ = try await iapModule.initConnection() + isConnectionInitialized = true + print("βœ… Connection initialized for manage subscriptions") + } catch { + showErrorMessage("Failed to initialize connection: \(error.localizedDescription)") + return + } + } + + do { + let shown = try await iapModule.showManageSubscriptionsIOS() + if shown { + print("βœ… Subscription management sheet presented") + + // Wait a moment and then refresh purchases to check for changes + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + await loadPurchases() + await loadProducts() + } else { + print("⚠️ Could not show subscription management") + showErrorMessage("Unable to open subscription settings") + } + } catch { + print("❌ Error showing subscription management: \(error)") + showErrorMessage("Failed to open subscription settings: \(error.localizedDescription)") + } + } + func presentOfferCodeRedemption() async { // This functionality is not available in the new API showErrorMessage("Offer code redemption not available in this version") } + // MARK: - Debug/Testing Methods + + #if DEBUG + /// Clear all transactions for testing (Sandbox only) + func clearAllTransactions() async { + print("πŸ§ͺ Clearing all transactions for testing...") + + // Clear local purchase cache + purchases.removeAll() + + // Also clear the products to force reload + products.removeAll() + + // Reinitialize connection to clear StoreKit cache + isConnectionInitialized = false + do { + _ = try await iapModule.initConnection() + isConnectionInitialized = true + print("βœ… Connection reinitialized") + } catch { + print("❌ Failed to reinitialize: \(error)") + } + + // Force refresh from StoreKit + await loadProducts() + await loadPurchases() + + print("βœ… Local transaction cache cleared and reloaded.") + } + + /// Sync subscription status with StoreKit + func syncSubscriptions() async { + print("πŸ”„ Syncing subscription status...") + + // Ensure connection + if !isConnectionInitialized { + do { + _ = try await iapModule.initConnection() + isConnectionInitialized = true + } catch { + showErrorMessage("Failed to initialize connection: \(error.localizedDescription)") + return + } + } + + // Try to sync with StoreKit (may fail on simulator or with network issues) + do { + #if targetEnvironment(simulator) + print("⚠️ Sync may not work properly on simulator") + showErrorMessage("Sync may not work on simulator. Please test on a real device.") + #else + _ = try await iapModule.syncIOS() + print("βœ… Synced with StoreKit") + #endif + } catch { + print("⚠️ Sync failed (this is normal on simulator): \(error)") + // Continue anyway - just reload what we have + } + + // Always reload purchases to reflect current state + await loadPurchases() + await loadProducts() + + print("βœ… Subscription status refreshed") + } + + /// Finish only unfinished transactions + func finishUnfinishedTransactions() async { + print("πŸ”„ Processing unfinished transactions...") + + do { + // Get pending transactions specifically + let pendingPurchases = try await iapModule.getPendingTransactionsIOS() + print("πŸ“‹ Found \(pendingPurchases.count) pending transactions") + + for purchase in pendingPurchases { + do { + _ = try await iapModule.finishTransaction(transactionIdentifier: purchase.id) + print("βœ… Finished pending transaction: \(purchase.id)") + } catch { + print("⚠️ Could not finish transaction \(purchase.id): \(error)") + } + } + + if pendingPurchases.isEmpty { + print("✨ No pending transactions to finish") + } + } catch { + print("❌ Error getting pending transactions: \(error)") + } + + // Reload after finishing + await loadPurchases() + print("βœ… Transaction processing completed") + } + + /// Clear transaction history (for testing) + func clearTransactionHistory() async { + print("πŸ—‘οΈ Clearing transaction history...") + + do { + try await iapModule.clearTransactionIOS() + print("βœ… Transaction history cleared") + } catch { + print("❌ Failed to clear transactions: \(error)") + } + + // Reload + await loadPurchases() + } + #endif + @MainActor private func showErrorMessage(_ message: String) { errorMessage = message diff --git a/Example/OpenIapExample/ViewModels/TransactionObserver.swift b/Example/OpenIapExample/ViewModels/TransactionObserver.swift index c4580d5..b567dd1 100644 --- a/Example/OpenIapExample/ViewModels/TransactionObserver.swift +++ b/Example/OpenIapExample/ViewModels/TransactionObserver.swift @@ -9,6 +9,8 @@ class TransactionObserver: ObservableObject { @Published var isPending = false private let iapModule = OpenIapModule.shared + private var purchaseSubscription: Subscription? + private var errorSubscription: Subscription? init() { setupListeners() @@ -16,20 +18,24 @@ class TransactionObserver: ObservableObject { deinit { // Clean up listeners - iapModule.removeAllPurchaseUpdatedListeners() - iapModule.removeAllPurchaseErrorListeners() + if let subscription = purchaseSubscription { + iapModule.removeListener(subscription) + } + if let subscription = errorSubscription { + iapModule.removeListener(subscription) + } } private func setupListeners() { // Add purchase updated listener - iapModule.addPurchaseUpdatedListener { [weak self] purchase in + purchaseSubscription = iapModule.purchaseUpdatedListener { [weak self] purchase in Task { @MainActor in self?.handlePurchaseUpdated(purchase) } } // Add purchase error listener - iapModule.addPurchaseErrorListener { [weak self] error in + errorSubscription = iapModule.purchaseErrorListener { [weak self] error in Task { @MainActor in self?.handlePurchaseError(error) } @@ -43,9 +49,9 @@ class TransactionObserver: ObservableObject { errorMessage = nil } - private func handlePurchaseError(_ error: OpenIapError) { - print("❌ Purchase failed: \(error)") - errorMessage = error.localizedDescription + private func handlePurchaseError(_ error: PurchaseError) { + print("❌ Purchase failed - Code: \(error.code), Message: \(error.message)") + errorMessage = error.message isPending = false } } @@ -68,7 +74,7 @@ struct TransactionObserverExampleView: View { Text("Latest Purchase:") .font(.headline) Text("Product: \(purchase.id)") - Text("Date: \(Date(timeIntervalSince1970: purchase.purchaseTime / 1000), formatter: dateFormatter)") + Text("Date: \(Date(timeIntervalSince1970: purchase.transactionDate / 1000), formatter: dateFormatter)") } .padding() .background(Color.green.opacity(0.1)) diff --git a/Sources/Models/ActiveSubscription.swift b/Sources/Models/ActiveSubscription.swift index 1376284..3b38921 100644 --- a/Sources/Models/ActiveSubscription.swift +++ b/Sources/Models/ActiveSubscription.swift @@ -1,17 +1,32 @@ import Foundation -public struct ActiveSubscription { +/// Represents an active subscription with platform-specific details +/// Following OpenIAP ActiveSubscription specification +public struct ActiveSubscription: Codable, Equatable { + /// Product identifier public let productId: String + + /// Always true for active subscriptions public let isActive: Bool + + /// Subscription expiration date (iOS only) public let expirationDateIOS: Date? + + /// Auto-renewal status (Android only) - Always nil on iOS public let autoRenewingAndroid: Bool? + + /// Environment: 'Sandbox' | 'Production' (iOS only) public let environmentIOS: String? + + /// True if subscription expires within 7 days public let willExpireSoon: Bool? + + /// Days remaining until expiration (iOS only) public let daysUntilExpirationIOS: Int? public init( productId: String, - isActive: Bool, + isActive: Bool = true, // Default to true for active subscriptions expirationDateIOS: Date? = nil, autoRenewingAndroid: Bool? = nil, environmentIOS: String? = nil, @@ -21,9 +36,59 @@ public struct ActiveSubscription { self.productId = productId self.isActive = isActive self.expirationDateIOS = expirationDateIOS - self.autoRenewingAndroid = autoRenewingAndroid + self.autoRenewingAndroid = autoRenewingAndroid // Always nil for iOS self.environmentIOS = environmentIOS - self.willExpireSoon = willExpireSoon - self.daysUntilExpirationIOS = daysUntilExpirationIOS + + // Calculate willExpireSoon if not provided + if let willExpireSoon = willExpireSoon { + self.willExpireSoon = willExpireSoon + } else if let expirationDate = expirationDateIOS { + let daysUntilExpiration = Calendar.current.dateComponents([.day], from: Date(), to: expirationDate).day ?? 0 + self.willExpireSoon = daysUntilExpiration <= 7 && daysUntilExpiration >= 0 + } else { + self.willExpireSoon = nil + } + + // Calculate daysUntilExpirationIOS if not provided + if let daysUntilExpirationIOS = daysUntilExpirationIOS { + self.daysUntilExpirationIOS = daysUntilExpirationIOS + } else if let expirationDate = expirationDateIOS { + self.daysUntilExpirationIOS = Calendar.current.dateComponents([.day], from: Date(), to: expirationDate).day + } else { + self.daysUntilExpirationIOS = nil + } + } +} + +// MARK: - StoreKit 2 Integration +import StoreKit + +@available(iOS 15.0, macOS 14.0, *) +extension ActiveSubscription { + /// Create ActiveSubscription from StoreKit 2 Transaction and Status + init(from transaction: Transaction, status: Product.SubscriptionInfo.Status, environment: String? = nil) { + self.productId = transaction.productID + self.isActive = true // Only called for active subscriptions + self.expirationDateIOS = transaction.expirationDate + self.autoRenewingAndroid = nil // Android-only field + + // Use provided environment or derive from transaction + if let environment = environment { + self.environmentIOS = environment + } else if #available(iOS 16.0, macOS 14.0, *) { + self.environmentIOS = transaction.environment.rawValue + } else { + self.environmentIOS = nil + } + + // Calculate days until expiration and willExpireSoon + if let expirationDate = transaction.expirationDate { + let daysUntilExpiration = Calendar.current.dateComponents([.day], from: Date(), to: expirationDate).day ?? 0 + self.daysUntilExpirationIOS = daysUntilExpiration + self.willExpireSoon = daysUntilExpiration <= 7 && daysUntilExpiration >= 0 + } else { + self.daysUntilExpirationIOS = nil + self.willExpireSoon = false + } } } \ No newline at end of file diff --git a/Sources/Models/AppTransaction.swift b/Sources/Models/AppTransaction.swift index 3b0b5f8..1f509e7 100644 --- a/Sources/Models/AppTransaction.swift +++ b/Sources/Models/AppTransaction.swift @@ -91,11 +91,4 @@ public struct OpenIapPriceLocale: Codable { public let currencyCode: String public let currencySymbol: String public let countryCode: String -} - -public struct OpenIapReceiptValidation: Codable { - public let isValid: Bool - public let receiptData: String - public let jwsRepresentation: String - public let latestTransaction: OpenIapPurchase? } \ No newline at end of file diff --git a/Sources/Models/OpenIapEvent.swift b/Sources/Models/OpenIapEvent.swift new file mode 100644 index 0000000..1ef6278 --- /dev/null +++ b/Sources/Models/OpenIapEvent.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Event types for IAP event system +/// Following OpenIAP specification +public enum OpenIapEvent: String, Codable { + case PURCHASE_UPDATED = "PURCHASE_UPDATED" + case PURCHASE_ERROR = "PURCHASE_ERROR" + case PROMOTED_PRODUCT_IOS = "PROMOTED_PRODUCT_IOS" +} + +/// Subscription token for event listeners +public struct Subscription { + public let id: UUID + public let eventType: OpenIapEvent + + public init(eventType: OpenIapEvent) { + self.id = UUID() + self.eventType = eventType + } +} \ No newline at end of file diff --git a/Sources/Models/Product.swift b/Sources/Models/Product.swift index 9335d6f..9edfde7 100644 --- a/Sources/Models/Product.swift +++ b/Sources/Models/Product.swift @@ -1,199 +1,291 @@ import Foundation import StoreKit +/// Product type for categorizing products +/// Maps to literal strings: "inapp", "subs" +public enum ProductType: String, Codable { + case inapp = "inapp" + case subs = "subs" +} + public struct OpenIapProduct: Codable, Equatable { - // Core properties (mapped from StoreKit) + // MARK: - ProductCommon fields public let id: String - public let productType: ProductType - public let localizedTitle: String - public let localizedDescription: String - public let price: Decimal - public let localizedPrice: String - public let currencyCode: String? - public let countryCode: String? - public let subscriptionPeriod: SubscriptionPeriod? - public let introductoryPrice: IntroductoryOffer? - public let discounts: [Discount]? - public let subscriptionGroupId: String? - - // iOS StoreKit 2 properties - public let platform: String - public let isFamilyShareable: Bool? - public let jsonRepresentation: String? + public let title: String + public let description: String + public let type: String // "inapp" or "subs" for Android compatibility + public let displayName: String? + public let displayPrice: String + public let currency: String + public let price: Double? + public let debugDescription: String? + public let platform: String // Always "ios" - // Additional fields for type compatibility + // MARK: - ProductIOS specific fields public let displayNameIOS: String public let isFamilyShareableIOS: Bool public let jsonRepresentationIOS: String - public let descriptionIOS: String - public let displayPriceIOS: String - public let priceIOS: Double - - // Computed properties for convenience - - public var title: String { - return localizedTitle - } + public let subscriptionInfoIOS: SubscriptionInfo? + public let typeIOS: ProductTypeIOS // Detailed iOS product type - public var displayName: String { - return localizedTitle - } + // MARK: - ProductSubscriptionIOS specific fields (when type == "subs") + public let discountsIOS: [Discount]? + public let introductoryPriceIOS: String? + public let introductoryPriceAsAmountIOS: String? + public let introductoryPricePaymentModeIOS: String? // PaymentMode as String + public let introductoryPriceNumberOfPeriodsIOS: String? + public let introductoryPriceSubscriptionPeriodIOS: String? // SubscriptionIosPeriod as String + public let subscriptionPeriodNumberIOS: String? + public let subscriptionPeriodUnitIOS: String? // SubscriptionIosPeriod as String - public var displayPrice: String { - return localizedPrice + // Discount structure for ProductSubscriptionIOS + public struct Discount: Codable, Equatable { + /// Discount identifier + public let identifier: String + + /// Discount type (introductory, subscription) + public let type: String + + /// Number of billing periods + public let numberOfPeriods: Int + + /// Formatted discount price + public let price: String + + /// Raw discount price value + public let priceAmount: Double + + /// Payment mode (payAsYouGo, payUpFront, freeTrial) + public let paymentMode: String + + /// Subscription period for discount + public let subscriptionPeriod: String } - public var description: String { - return localizedDescription + // SubscriptionInfo matching OpenIAP spec + public struct SubscriptionInfo: Codable, Equatable { + public let introductoryOffer: SubscriptionOffer? + public let promotionalOffers: [SubscriptionOffer]? + public let subscriptionGroupId: String + public let subscriptionPeriod: SubscriptionPeriod } - public var type: String { - switch productType { - case .consumable, .nonConsumable, .nonRenewingSubscription: - return "inapp" - case .autoRenewableSubscription: - return "subs" + public struct SubscriptionOffer: Codable, Equatable { + public let displayPrice: String + public let id: String + public let paymentMode: PaymentMode + public let period: SubscriptionPeriod + public let periodCount: Int + public let price: Double + public let type: OfferType + + public enum PaymentMode: String, Codable, Equatable { + case unknown = "" + case freeTrial = "FREETRIAL" + case payAsYouGo = "PAYASYOUGO" + case payUpFront = "PAYUPFRONT" + } + + public enum OfferType: String, Codable, Equatable { + case introductory = "introductory" + case promotional = "promotional" } - } - - public var formattedPrice: String { - return localizedPrice - } - - public enum ProductType: String, Codable { - case consumable - case nonConsumable - case autoRenewableSubscription - case nonRenewingSubscription } public struct SubscriptionPeriod: Codable, Equatable { public let unit: PeriodUnit public let value: Int - public enum PeriodUnit: String, Codable { - case day - case week - case month - case year + public enum PeriodUnit: String, Codable, Equatable { + case unknown = "" + case day = "DAY" + case week = "WEEK" + case month = "MONTH" + case year = "YEAR" } } - public struct IntroductoryOffer: Codable, Equatable { - public let id: String? - public let price: Decimal - public let localizedPrice: String - public let period: SubscriptionPeriod - public let numberOfPeriods: Int - public let paymentMode: PaymentMode - - public enum PaymentMode: String, Codable { - case payAsYouGo - case payUpFront - case freeTrial + /// Get the type as ProductType enum + public var productType: ProductType { + return ProductType(rawValue: type) ?? .inapp + } +} + +// MARK: - iOS Product Type Enum (Detailed) +public enum ProductTypeIOS: String, Codable, CaseIterable { + case consumable + case nonConsumable + case autoRenewableSubscription + case nonRenewingSubscription + + public var isSubs: Bool { + switch self { + case .autoRenewableSubscription: + return true + case .consumable, .nonConsumable, .nonRenewingSubscription: + return false } } - public struct Discount: Codable, Equatable { - public let identifier: String - public let type: DiscountType - public let price: Decimal - public let localizedPrice: String - public let period: SubscriptionPeriod? - public let numberOfPeriods: Int - public let paymentMode: String - - public enum DiscountType: String, Codable { - case introductory - case subscription + // Convert to common type for Android compatibility + public var commonType: String { + switch self { + case .autoRenewableSubscription: + return "subs" + case .consumable, .nonConsumable, .nonRenewingSubscription: + return "inapp" } } } + @available(iOS 15.0, macOS 12.0, *) extension OpenIapProduct { init(from product: Product) async { + + // Core ProductCommon properties self.id = product.id - self.localizedTitle = product.displayName - self.localizedDescription = product.description - self.price = product.price - self.localizedPrice = product.displayPrice - self.currencyCode = product.priceFormatStyle.currencyCode - self.countryCode = nil + self.title = product.displayName + self.description = product.description + self.displayName = product.displayName + self.displayPrice = product.displayPrice + self.currency = product.priceFormatStyle.currencyCode + self.price = NSDecimalNumber(decimal: product.price).doubleValue + self.debugDescription = nil self.platform = "ios" - self.isFamilyShareable = product.isFamilyShareable - self.jsonRepresentation = String(data: product.jsonRepresentation, encoding: .utf8) - // iOS-specific fields for TypeScript type compatibility + // iOS-specific required fields self.displayNameIOS = product.displayName self.isFamilyShareableIOS = product.isFamilyShareable self.jsonRepresentationIOS = String(data: product.jsonRepresentation, encoding: .utf8) ?? "" - self.descriptionIOS = product.description - self.displayPriceIOS = product.displayPrice - self.priceIOS = NSDecimalNumber(decimal: product.price).doubleValue + // Set detailed iOS product type + let detailedType: ProductTypeIOS switch product.type { case .consumable: - self.productType = .consumable + detailedType = .consumable case .nonConsumable: - self.productType = .nonConsumable - case .autoRenewable: - self.productType = .autoRenewableSubscription + detailedType = .nonConsumable case .nonRenewable: - self.productType = .nonRenewingSubscription + detailedType = .nonRenewingSubscription + case .autoRenewable: + detailedType = .autoRenewableSubscription default: - self.productType = .nonConsumable + detailedType = .consumable } + self.typeIOS = detailedType + self.type = detailedType.commonType // Set common type for Android compatibility + + // Handle subscription info and ProductSubscriptionIOS fields if let subscription = product.subscription { - self.subscriptionGroupId = subscription.subscriptionGroupID - self.subscriptionPeriod = SubscriptionPeriod( - unit: subscription.subscriptionPeriod.unit.toPeriodUnit(), - value: subscription.subscriptionPeriod.value - ) + var introOffer: SubscriptionOffer? = nil - if let introOffer = subscription.introductoryOffer { - self.introductoryPrice = IntroductoryOffer( - id: introOffer.id, - price: introOffer.price, - localizedPrice: introOffer.displayPrice, + if let intro = subscription.introductoryOffer { + introOffer = SubscriptionOffer( + displayPrice: intro.displayPrice, + id: intro.id ?? "", + paymentMode: intro.paymentMode.toOpenIapPaymentMode(), period: SubscriptionPeriod( - unit: introOffer.period.unit.toPeriodUnit(), - value: introOffer.period.value + unit: intro.period.unit.toOpenIapPeriodUnit(), + value: intro.period.value ), - numberOfPeriods: introOffer.periodCount, - paymentMode: introOffer.paymentMode.toPaymentMode() + periodCount: intro.periodCount, + price: NSDecimalNumber(decimal: intro.price).doubleValue, + type: .introductory ) - } else { - self.introductoryPrice = nil } - self.discounts = subscription.promotionalOffers.map { offer in - Discount( - identifier: offer.id ?? "", - type: .subscription, - price: offer.price, - localizedPrice: offer.displayPrice, + let promoOffers = subscription.promotionalOffers.map { offer in + SubscriptionOffer( + displayPrice: offer.displayPrice, + id: offer.id ?? "", + paymentMode: offer.paymentMode.toOpenIapPaymentMode(), period: SubscriptionPeriod( - unit: offer.period.unit.toPeriodUnit(), + unit: offer.period.unit.toOpenIapPeriodUnit(), value: offer.period.value ), - numberOfPeriods: offer.periodCount, - paymentMode: offer.paymentMode.toPaymentMode().rawValue + periodCount: offer.periodCount, + price: NSDecimalNumber(decimal: offer.price).doubleValue, + type: .promotional ) } + + let subInfo = SubscriptionInfo( + introductoryOffer: introOffer, + promotionalOffers: promoOffers.isEmpty ? nil : promoOffers, + subscriptionGroupId: subscription.subscriptionGroupID, + subscriptionPeriod: SubscriptionPeriod( + unit: subscription.subscriptionPeriod.unit.toOpenIapPeriodUnit(), + value: subscription.subscriptionPeriod.value + ) + ) + + self.subscriptionInfoIOS = subInfo + + // ProductSubscriptionIOS specific fields + if let intro = subscription.introductoryOffer { + self.introductoryPriceIOS = intro.displayPrice + self.introductoryPriceAsAmountIOS = String(NSDecimalNumber(decimal: intro.price).doubleValue) + self.introductoryPricePaymentModeIOS = intro.paymentMode.toOpenIapPaymentMode().rawValue + self.introductoryPriceNumberOfPeriodsIOS = String(intro.periodCount) + self.introductoryPriceSubscriptionPeriodIOS = intro.period.unit.toOpenIapPeriodUnit().rawValue + } else { + self.introductoryPriceIOS = nil + self.introductoryPriceAsAmountIOS = nil + self.introductoryPricePaymentModeIOS = nil + self.introductoryPriceNumberOfPeriodsIOS = nil + self.introductoryPriceSubscriptionPeriodIOS = nil + } + + self.subscriptionPeriodNumberIOS = String(subscription.subscriptionPeriod.value) + self.subscriptionPeriodUnitIOS = subscription.subscriptionPeriod.unit.toOpenIapPeriodUnit().rawValue + + // Build discounts array from all offers + var discounts: [Discount] = [] + + if let intro = subscription.introductoryOffer { + discounts.append(Discount( + identifier: intro.id ?? "", + type: "introductory", + numberOfPeriods: intro.periodCount, + price: intro.displayPrice, + priceAmount: NSDecimalNumber(decimal: intro.price).doubleValue, + paymentMode: intro.paymentMode.toOpenIapPaymentMode().rawValue, + subscriptionPeriod: intro.period.toISO8601Period() + )) + } + + for offer in subscription.promotionalOffers { + discounts.append(Discount( + identifier: offer.id ?? "", + type: "promotional", + numberOfPeriods: offer.periodCount, + price: offer.displayPrice, + priceAmount: NSDecimalNumber(decimal: offer.price).doubleValue, + paymentMode: offer.paymentMode.toOpenIapPaymentMode().rawValue, + subscriptionPeriod: offer.period.toISO8601Period() + )) + } + + self.discountsIOS = discounts.isEmpty ? nil : discounts } else { - self.subscriptionGroupId = nil - self.subscriptionPeriod = nil - self.introductoryPrice = nil - self.discounts = nil + self.subscriptionInfoIOS = nil + self.discountsIOS = nil + self.introductoryPriceIOS = nil + self.introductoryPriceAsAmountIOS = nil + self.introductoryPricePaymentModeIOS = nil + self.introductoryPriceNumberOfPeriodsIOS = nil + self.introductoryPriceSubscriptionPeriodIOS = nil + self.subscriptionPeriodNumberIOS = nil + self.subscriptionPeriodUnitIOS = nil } } } @available(iOS 15.0, macOS 12.0, *) extension Product.SubscriptionPeriod.Unit { - func toPeriodUnit() -> OpenIapProduct.SubscriptionPeriod.PeriodUnit { + func toOpenIapPeriodUnit() -> OpenIapProduct.SubscriptionPeriod.PeriodUnit { switch self { case .day: return .day @@ -204,14 +296,14 @@ extension Product.SubscriptionPeriod.Unit { case .year: return .year @unknown default: - return .month + return .unknown } } } @available(iOS 15.0, macOS 12.0, *) extension Product.SubscriptionOffer.PaymentMode { - func toPaymentMode() -> OpenIapProduct.IntroductoryOffer.PaymentMode { + func toOpenIapPaymentMode() -> OpenIapProduct.SubscriptionOffer.PaymentMode { switch self { case .payAsYouGo: return .payAsYouGo @@ -220,7 +312,28 @@ extension Product.SubscriptionOffer.PaymentMode { case .freeTrial: return .freeTrial default: - return .payAsYouGo + return .unknown + } + } +} + +// MARK: - ISO 8601 Period Conversion + +@available(iOS 15.0, macOS 12.0, *) +extension Product.SubscriptionPeriod { + /// Convert to ISO 8601 duration format (P1M, P3M, P1Y, etc.) + func toISO8601Period() -> String { + switch unit { + case .day: + return "P\(value)D" + case .week: + return "P\(value)W" + case .month: + return "P\(value)M" + case .year: + return "P\(value)Y" + @unknown default: + return "P0D" } } } \ No newline at end of file diff --git a/Sources/Models/ProductRequest.swift b/Sources/Models/ProductRequest.swift new file mode 100644 index 0000000..1d761c1 --- /dev/null +++ b/Sources/Models/ProductRequest.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Request product type for filtering when fetching products +/// Maps to literal strings: "inapp", "subs", "all" +public enum RequestProductType: String, Codable { + case inapp = "inapp" + case subs = "subs" + case all = "all" +} + +/// Product request parameters following OpenIAP specification +public struct ProductRequest: Codable, Equatable { + /// Product SKUs to fetch + public let skus: [String] + + /// Product type filter: "inapp" (default), "subs", or "all" + public let type: String + + public init(skus: [String], type: String = "inapp") { + self.skus = skus + self.type = type + } + + /// Convenience initializer with RequestProductType enum + public init(skus: [String], type: RequestProductType = .inapp) { + self.skus = skus + self.type = type.rawValue + } + + /// Get the type as RequestProductType enum + public var requestType: RequestProductType { + return RequestProductType(rawValue: type) ?? .inapp + } +} \ No newline at end of file diff --git a/Sources/Models/Purchase.swift b/Sources/Models/Purchase.swift index e4f1b18..9f1691d 100644 --- a/Sources/Models/Purchase.swift +++ b/Sources/Models/Purchase.swift @@ -2,37 +2,24 @@ import Foundation import StoreKit public struct OpenIapPurchase: Codable, Equatable { - // Core identification (PurchaseCommon required fields) - public let id: String - public let productId: String - public let purchaseToken: String - public let transactionId: String - public let originalTransactionId: String? - - // Platform identification - public let platform: String - - // Multiple product IDs support - public let ids: [String]? + // MARK: - PurchaseCommon fields + public let id: String // Transaction ID (primary identifier) + public let productId: String // Product identifier + public let ids: [String]? // Common field for both platforms + public let transactionDate: Double // Unix timestamp in milliseconds + public let transactionReceipt: String // Purchase receipt/token + public let purchaseToken: String? // Purchase token + public let platform: String // Always "ios" + public let quantity: Int // Purchase quantity (common field, defaults to 1) + public let purchaseState: PurchaseState // Purchase state (common field) + public let isAutoRenewing: Bool // Auto-renewable subscription flag (common field) - // Timing information - public let purchaseTime: Date - public let originalPurchaseTime: Date? - public let expiryTime: Date? - - // Purchase state - public let isAutoRenewing: Bool - public let purchaseState: PurchaseState - public let acknowledgementState: AcknowledgementState - public let quantity: Int - - // Legacy compatibility - public let developerPayload: String? - public let jwsRepresentation: String? - public let jsonRepresentation: String? + // MARK: - PurchaseIOS specific fields + public let quantityIOS: Int? + public let originalTransactionDateIOS: Double? + public let originalTransactionIdentifierIOS: String? public let appAccountToken: String? - - // iOS StoreKit 2 additional properties + public let expirationDateIOS: Double? public let webOrderLineItemIdIOS: Int? public let environmentIOS: String? public let storefrontCountryCodeIOS: String? @@ -43,42 +30,16 @@ public struct OpenIapPurchase: Codable, Equatable { public let ownershipTypeIOS: String? public let reasonIOS: String? public let reasonStringRepresentationIOS: String? - public let transactionReasonIOS: String? - public let revocationDateIOS: Date? + public let transactionReasonIOS: String? // 'PURCHASE' | 'RENEWAL' | string + public let revocationDateIOS: Double? public let revocationReasonIOS: String? - - // Offer information public let offerIOS: PurchaseOffer? - - // Price locale information public let currencyCodeIOS: String? public let currencySymbolIOS: String? public let countryCodeIOS: String? - - public enum PurchaseState: String, Codable { - case pending - case purchased - case failed - case restored - case deferred - } - - public enum AcknowledgementState: String, Codable { - case notAcknowledged - case acknowledged - } - - // Computed properties for TypeScript type compatibility - public var transactionDate: TimeInterval { - return purchaseTime.timeIntervalSince1970 * 1000 - } - - public var transactionReceipt: String { - return purchaseToken - } } -// Support structures +// MARK: - Support structures public struct PurchaseOffer: Codable, Equatable { public let id: String public let type: String @@ -91,48 +52,35 @@ public struct PurchaseOffer: Codable, Equatable { } } -// Options for purchase queries -public struct PurchaseOptions: Codable { - public let alsoPublishToEventListener: Bool? - public let onlyIncludeActiveItems: Bool? - - public init(alsoPublishToEventListener: Bool? = false, onlyIncludeActiveItems: Bool? = false) { - self.alsoPublishToEventListener = alsoPublishToEventListener - self.onlyIncludeActiveItems = onlyIncludeActiveItems - } -} - +// MARK: - StoreKit 2 Integration @available(iOS 15.0, macOS 14.0, *) extension OpenIapPurchase { init(from transaction: Transaction, jwsRepresentation: String? = nil) async { - // Core identification - self.id = String(transaction.id) + // PurchaseCommon fields + self.id = String(transaction.id) // Transaction ID is the primary identifier self.productId = transaction.productID - self.transactionId = String(transaction.id) - self.originalTransactionId = transaction.originalID != 0 ? String(transaction.originalID) : nil + self.ids = nil // Single product purchase + self.transactionDate = transaction.purchaseDate.timeIntervalSince1970 * 1000 // Unix timestamp in milliseconds + self.transactionReceipt = jwsRepresentation ?? String(transaction.id) self.purchaseToken = jwsRepresentation ?? String(transaction.id) - - // Platform and IDs self.platform = "ios" - self.ids = nil // Single product purchase - - // Timing information - self.purchaseTime = transaction.purchaseDate - self.originalPurchaseTime = transaction.originalPurchaseDate - self.expiryTime = transaction.expirationDate - - // Purchase state - self.isAutoRenewing = transaction.isUpgraded == false self.quantity = transaction.purchasedQuantity - self.acknowledgementState = .acknowledged + self.purchaseState = .purchased // StoreKit 2 transactions are verified and purchased - // Legacy compatibility - self.developerPayload = transaction.appAccountToken?.uuidString - self.jwsRepresentation = jwsRepresentation - self.jsonRepresentation = String(data: transaction.jsonRepresentation, encoding: .utf8) - self.appAccountToken = transaction.appAccountToken?.uuidString + // Check if it's an auto-renewable subscription + switch transaction.productType { + case .autoRenewable: + self.isAutoRenewing = true + default: + self.isAutoRenewing = false + } - // iOS StoreKit 2 additional properties + // PurchaseIOS specific fields + self.quantityIOS = transaction.purchasedQuantity + self.originalTransactionDateIOS = transaction.originalPurchaseDate.timeIntervalSince1970 * 1000 + self.originalTransactionIdentifierIOS = transaction.originalID != 0 ? String(transaction.originalID) : nil + self.appAccountToken = transaction.appAccountToken?.uuidString + self.expirationDateIOS = transaction.expirationDate.map { $0.timeIntervalSince1970 * 1000 } self.webOrderLineItemIdIOS = Int(transaction.webOrderLineItemID ?? "0") // Environment (iOS 16.0+) @@ -141,11 +89,11 @@ extension OpenIapPurchase { } else { self.environmentIOS = nil } + self.storefrontCountryCodeIOS = transaction.storefrontCountryCode self.appBundleIdIOS = transaction.appBundleID self.subscriptionGroupIdIOS = transaction.subscriptionGroupID self.isUpgradedIOS = transaction.isUpgraded - self.revocationDateIOS = transaction.revocationDate // Product type switch transaction.productType { @@ -164,17 +112,14 @@ extension OpenIapPurchase { // Ownership type switch transaction.ownershipType { case .purchased: - self.purchaseState = .purchased self.ownershipTypeIOS = "purchased" case .familyShared: - self.purchaseState = .restored self.ownershipTypeIOS = "family_shared" default: - self.purchaseState = .purchased self.ownershipTypeIOS = "purchased" } - // Reason and revocation (iOS 17.0+) + // Reason (iOS 17.0+) if #available(iOS 17.0, macOS 14.0, *) { switch transaction.reason { case .purchase: @@ -194,6 +139,8 @@ extension OpenIapPurchase { self.reasonStringRepresentationIOS = self.reasonIOS + // Revocation + self.revocationDateIOS = transaction.revocationDate.map { $0.timeIntervalSince1970 * 1000 } if let revocationReason = transaction.revocationReason { self.revocationReasonIOS = revocationReason.rawValue.description } else { @@ -215,18 +162,49 @@ extension OpenIapPurchase { self.offerIOS = nil } - // Price locale information - would need to get from Product if available + // Currency and country (not directly available from Transaction) self.currencyCodeIOS = nil self.currencySymbolIOS = nil self.countryCodeIOS = transaction.storefrontCountryCode } } -public struct OpenIapReceipt: Codable { - public let bundleId: String - public let applicationVersion: String - public let originalApplicationVersion: String? - public let creationDate: Date - public let expirationDate: Date? - public let inAppPurchases: [OpenIapPurchase] +// MARK: - Purchase State Enum (Common) +public enum PurchaseState: String, Codable, CaseIterable { + case pending = "pending" + case purchased = "purchased" + case failed = "failed" + case restored = "restored" + case deferred = "deferred" + case unknown = "unknown" + + public var isActive: Bool { + switch self { + case .purchased, .restored: + return true + case .pending, .failed, .deferred, .unknown: + return false + } + } + + public var isAcknowledged: Bool { + switch self { + case .purchased, .restored: + return true + case .pending, .failed, .deferred, .unknown: + return false + } + } +} + +// MARK: - Purchase Options +// Options for purchase queries following OpenIAP spec +public struct PurchaseOptions: Codable { + public let alsoPublishToEventListenerIOS: Bool? + public let onlyIncludeActiveItemsIOS: Bool? + + public init(alsoPublishToEventListenerIOS: Bool? = false, onlyIncludeActiveItemsIOS: Bool? = false) { + self.alsoPublishToEventListenerIOS = alsoPublishToEventListenerIOS + self.onlyIncludeActiveItemsIOS = onlyIncludeActiveItemsIOS + } } \ No newline at end of file diff --git a/Sources/Models/PurchaseError.swift b/Sources/Models/PurchaseError.swift new file mode 100644 index 0000000..821eb3b --- /dev/null +++ b/Sources/Models/PurchaseError.swift @@ -0,0 +1,319 @@ +import Foundation +import StoreKit + +/// Purchase error event payload +/// Following OpenIAP specification exactly +public struct PurchaseError: Codable, Equatable { + /// Error code constant (required) + public let code: String + + /// Human-readable message (required) + public let message: String + + /// Related product SKU (optional, if applicable) + public let productId: String? + + public init( + code: String, + message: String, + productId: String? = nil + ) { + self.code = code + self.message = message + self.productId = productId + } +} + +// MARK: - Error Codes (OpenIAP Specification) +extension PurchaseError { + // MARK: User Action Errors + public static let E_USER_CANCELLED = "E_USER_CANCELLED" + public static let E_USER_ERROR = "E_USER_ERROR" + public static let E_DEFERRED_PAYMENT = "E_DEFERRED_PAYMENT" + public static let E_INTERRUPTED = "E_INTERRUPTED" + + // MARK: Product Errors + public static let E_ITEM_UNAVAILABLE = "E_ITEM_UNAVAILABLE" + public static let E_SKU_NOT_FOUND = "E_SKU_NOT_FOUND" + public static let E_SKU_OFFER_MISMATCH = "E_SKU_OFFER_MISMATCH" + public static let E_QUERY_PRODUCT = "E_QUERY_PRODUCT" + public static let E_ALREADY_OWNED = "E_ALREADY_OWNED" + public static let E_ITEM_NOT_OWNED = "E_ITEM_NOT_OWNED" + + // MARK: Network & Service Errors + public static let E_NETWORK_ERROR = "E_NETWORK_ERROR" + public static let E_SERVICE_ERROR = "E_SERVICE_ERROR" + public static let E_REMOTE_ERROR = "E_REMOTE_ERROR" + public static let E_INIT_CONNECTION = "E_INIT_CONNECTION" + public static let E_SERVICE_DISCONNECTED = "E_SERVICE_DISCONNECTED" + public static let E_CONNECTION_CLOSED = "E_CONNECTION_CLOSED" + public static let E_IAP_NOT_AVAILABLE = "E_IAP_NOT_AVAILABLE" + public static let E_BILLING_UNAVAILABLE = "E_BILLING_UNAVAILABLE" + public static let E_FEATURE_NOT_SUPPORTED = "E_FEATURE_NOT_SUPPORTED" + public static let E_SYNC_ERROR = "E_SYNC_ERROR" + + // MARK: Validation Errors + public static let E_RECEIPT_FAILED = "E_RECEIPT_FAILED" + public static let E_RECEIPT_FINISHED = "E_RECEIPT_FINISHED" + public static let E_RECEIPT_FINISHED_FAILED = "E_RECEIPT_FINISHED_FAILED" + public static let E_TRANSACTION_VALIDATION_FAILED = "E_TRANSACTION_VALIDATION_FAILED" + public static let E_EMPTY_SKU_LIST = "E_EMPTY_SKU_LIST" + + // MARK: Generic Error + public static let E_UNKNOWN = "E_UNKNOWN" + + /// Create PurchaseError from OpenIapError + public init(from error: OpenIapError, productId: String? = nil) { + switch error { + case .purchaseCancelled: + self.init( + code: Self.E_USER_CANCELLED, + message: "User cancelled the purchase flow", + productId: productId + ) + case .purchaseDeferred: + self.init( + code: Self.E_DEFERRED_PAYMENT, + message: "Payment was deferred (pending family approval, etc.)", + productId: productId + ) + case .productNotFound(let id): + self.init( + code: Self.E_SKU_NOT_FOUND, + message: "SKU not found: \(id)", + productId: id + ) + case .purchaseFailed(let reason): + self.init( + code: Self.E_SERVICE_ERROR, + message: "Purchase failed: \(reason)", + productId: productId + ) + case .paymentNotAllowed: + self.init( + code: Self.E_IAP_NOT_AVAILABLE, + message: "In-app purchase not allowed on this device", + productId: productId + ) + case .invalidReceipt: + self.init( + code: Self.E_RECEIPT_FAILED, + message: "Receipt validation failed", + productId: productId + ) + case .networkError: + self.init( + code: Self.E_NETWORK_ERROR, + message: "Network connection error", + productId: productId + ) + case .verificationFailed(let reason): + self.init( + code: Self.E_TRANSACTION_VALIDATION_FAILED, + message: "Transaction validation failed: \(reason)", + productId: productId + ) + case .restoreFailed(let reason): + self.init( + code: Self.E_SERVICE_ERROR, + message: "Restore failed: \(reason)", + productId: productId + ) + case .storeKitError(let error): + self.init( + code: Self.E_SERVICE_ERROR, + message: "Store service error: \(error.localizedDescription)", + productId: productId + ) + case .notSupported: + self.init( + code: Self.E_FEATURE_NOT_SUPPORTED, + message: "Feature not supported on this platform", + productId: productId + ) + case .unknownError: + self.init( + code: Self.E_UNKNOWN, + message: "Unknown error occurred", + productId: productId + ) + } + } + + // MARK: - Retry Strategy + + /// Check if error can be retried + public var canRetry: Bool { + switch code { + case Self.E_NETWORK_ERROR, + Self.E_SERVICE_ERROR, + Self.E_REMOTE_ERROR, + Self.E_CONNECTION_CLOSED, + Self.E_SYNC_ERROR, + Self.E_INIT_CONNECTION, + Self.E_SERVICE_DISCONNECTED: + return true + default: + return false + } + } + + /// Get retry delay in seconds based on error type and attempt number + public func retryDelay(attempt: Int) -> TimeInterval? { + guard canRetry else { return nil } + + switch code { + case Self.E_NETWORK_ERROR, Self.E_SYNC_ERROR: + // Exponential backoff (2^n seconds) + return TimeInterval(pow(2.0, Double(attempt))) + case Self.E_SERVICE_ERROR: + // Linear backoff (n * 5 seconds) + return TimeInterval(attempt * 5) + case Self.E_REMOTE_ERROR: + // Fixed delay (10 seconds) + return 10 + case Self.E_CONNECTION_CLOSED, Self.E_INIT_CONNECTION, Self.E_SERVICE_DISCONNECTED: + // Reinitialize and retry with exponential backoff + return TimeInterval(pow(2.0, Double(attempt))) + default: + return nil + } + } + + // MARK: - Convenience Factory Methods + + /// Create error for empty SKU list + public static func emptySkuList() -> PurchaseError { + return PurchaseError( + code: E_EMPTY_SKU_LIST, + message: "Empty SKU list provided" + ) + } + + /// Create error for already owned item + public static func alreadyOwned(productId: String) -> PurchaseError { + return PurchaseError( + code: E_ALREADY_OWNED, + message: "Item already owned by user", + productId: productId + ) + } + + /// Create error for item not owned + public static func itemNotOwned(productId: String) -> PurchaseError { + return PurchaseError( + code: E_ITEM_NOT_OWNED, + message: "Item not owned by user", + productId: productId + ) + } + + /// Create error for connection initialization failure + public static func initConnectionFailed(message: String = "Failed to initialize store connection") -> PurchaseError { + return PurchaseError( + code: E_INIT_CONNECTION, + message: message + ) + } + + /// Create error for service disconnected + public static func serviceDisconnected() -> PurchaseError { + return PurchaseError( + code: E_SERVICE_DISCONNECTED, + message: "Store service disconnected" + ) + } + + // MARK: - StoreKit Error Mapping + + /// Create PurchaseError from StoreKit error + @available(iOS 15.0, macOS 14.0, *) + public init(from error: Error, productId: String? = nil) { + if let skError = error as? SKError { + switch skError.code { + case .paymentCancelled: + self = PurchaseError( + code: Self.E_USER_CANCELLED, + message: "User cancelled transaction", + productId: productId + ) + case .cloudServiceNetworkConnectionFailed, .cloudServicePermissionDenied: + self = PurchaseError( + code: Self.E_NETWORK_ERROR, + message: "Network unavailable", + productId: productId + ) + case .storeProductNotAvailable: + self = PurchaseError( + code: Self.E_ITEM_UNAVAILABLE, + message: "Product not available", + productId: productId + ) + case .paymentNotAllowed: + self = PurchaseError( + code: Self.E_IAP_NOT_AVAILABLE, + message: "In-app purchase not available", + productId: productId + ) + case .paymentInvalid: + self = PurchaseError( + code: Self.E_RECEIPT_FAILED, + message: "Receipt validation failed", + productId: productId + ) + default: + self = PurchaseError( + code: Self.E_SERVICE_ERROR, + message: "App Store service error: \(skError.localizedDescription)", + productId: productId + ) + } + } else if let storeKitError = error as? StoreKitError { + switch storeKitError { + case .userCancelled: + self = PurchaseError( + code: Self.E_USER_CANCELLED, + message: "User cancelled the purchase", + productId: productId + ) + case .networkError(_): + self = PurchaseError( + code: Self.E_NETWORK_ERROR, + message: "Network error occurred", + productId: productId + ) + case .systemError(_): + self = PurchaseError( + code: Self.E_SERVICE_ERROR, + message: "System error occurred", + productId: productId + ) + case .notAvailableInStorefront: + self = PurchaseError( + code: Self.E_ITEM_UNAVAILABLE, + message: "Product not available in storefront", + productId: productId + ) + case .notEntitled: + self = PurchaseError( + code: Self.E_ITEM_NOT_OWNED, + message: "User not entitled to this product", + productId: productId + ) + default: + self = PurchaseError( + code: Self.E_UNKNOWN, + message: "Unknown error: \(error.localizedDescription)", + productId: productId + ) + } + } else { + // Generic error + self = PurchaseError( + code: Self.E_UNKNOWN, + message: error.localizedDescription, + productId: productId + ) + } + } +} \ No newline at end of file diff --git a/Sources/Models/Receipt.swift b/Sources/Models/Receipt.swift new file mode 100644 index 0000000..5f5b068 --- /dev/null +++ b/Sources/Models/Receipt.swift @@ -0,0 +1,27 @@ +import Foundation + +// Simple receipt model for compatibility +public struct OpenIapReceipt: Codable, Equatable { + public let bundleId: String + public let applicationVersion: String + public let originalApplicationVersion: String? + public let creationDate: Date + public let expirationDate: Date? + public let inAppPurchases: [OpenIapPurchase] + + public init( + bundleId: String, + applicationVersion: String, + originalApplicationVersion: String? = nil, + creationDate: Date, + expirationDate: Date? = nil, + inAppPurchases: [OpenIapPurchase] + ) { + self.bundleId = bundleId + self.applicationVersion = applicationVersion + self.originalApplicationVersion = originalApplicationVersion + self.creationDate = creationDate + self.expirationDate = expirationDate + self.inAppPurchases = inAppPurchases + } +} \ No newline at end of file diff --git a/Sources/Models/ReceiptValidation.swift b/Sources/Models/ReceiptValidation.swift new file mode 100644 index 0000000..08a64d1 --- /dev/null +++ b/Sources/Models/ReceiptValidation.swift @@ -0,0 +1,42 @@ +import Foundation + +// MARK: - Receipt Validation Request + +/// Receipt validation properties following OpenIAP specification +public struct ReceiptValidationProps: Codable, Equatable { + /// Product SKU to validate + public let sku: String + + public init(sku: String) { + self.sku = sku + } +} + +// MARK: - Receipt Validation Result + +/// Receipt Validation Result for iOS +public struct ReceiptValidationResult: Codable, Equatable { + /// Whether the receipt is valid + public let isValid: Bool + + /// Receipt data string + public let receiptData: String + + /// JWS representation + public let jwsRepresentation: String + + /// Latest transaction if available + public let latestTransaction: OpenIapPurchase? + + public init( + isValid: Bool, + receiptData: String, + jwsRepresentation: String, + latestTransaction: OpenIapPurchase? = nil + ) { + self.isValid = isValid + self.receiptData = receiptData + self.jwsRepresentation = jwsRepresentation + self.latestTransaction = latestTransaction + } +} \ No newline at end of file diff --git a/Sources/Models/RequestPurchaseProps.swift b/Sources/Models/RequestPurchaseProps.swift new file mode 100644 index 0000000..6914557 --- /dev/null +++ b/Sources/Models/RequestPurchaseProps.swift @@ -0,0 +1,154 @@ +import Foundation +import StoreKit + +/// Purchase request parameters following OpenIAP specification +public struct RequestPurchaseProps: Codable, Equatable { + /// Product SKU + public let sku: String + + /// Auto-finish transaction (dangerous) + public let andDangerouslyFinishTransactionAutomatically: Bool? + + /// App account token for user tracking + public let appAccountToken: String? + + /// Purchase quantity + public let quantity: Int? + + /// Payment discount offer + public let withOffer: DiscountOffer? + + public init( + sku: String, + andDangerouslyFinishTransactionAutomatically: Bool? = nil, + appAccountToken: String? = nil, + quantity: Int? = nil, + withOffer: DiscountOffer? = nil + ) { + self.sku = sku + self.andDangerouslyFinishTransactionAutomatically = andDangerouslyFinishTransactionAutomatically + self.appAccountToken = appAccountToken + self.quantity = quantity + self.withOffer = withOffer + } + + /// Convenience init with legacy parameter names + public init( + sku: String, + andDangerouslyFinishTransactionAutomatically: Bool, + appAccountToken: String? = nil, + quantity: Int = 1, + discountOffer: [String: String]? = nil + ) { + self.sku = sku + self.andDangerouslyFinishTransactionAutomatically = andDangerouslyFinishTransactionAutomatically + self.appAccountToken = appAccountToken + self.quantity = quantity + + // Convert legacy discountOffer to DiscountOffer + if let discount = discountOffer { + self.withOffer = DiscountOffer( + identifier: discount["identifier"] ?? "", + keyIdentifier: discount["keyIdentifier"] ?? "", + nonce: discount["nonce"] ?? "", + signature: discount["signature"] ?? "", + timestamp: discount["timestamp"] ?? "" + ) + } else { + self.withOffer = nil + } + } +} + +/// Discount offer structure for promotional offers +public struct DiscountOffer: Codable, Equatable { + /// Discount identifier + public let identifier: String + + /// Key identifier for validation + public let keyIdentifier: String + + /// Cryptographic nonce + public let nonce: String + + /// Signature for validation + public let signature: String + + /// Timestamp of discount offer + public let timestamp: String + + public init( + identifier: String, + keyIdentifier: String, + nonce: String, + signature: String, + timestamp: String + ) { + self.identifier = identifier + self.keyIdentifier = keyIdentifier + self.nonce = nonce + self.signature = signature + self.timestamp = timestamp + } + + // Backward compatibility + public init( + id: String, + keyIdentifier: String, + nonce: String, + signature: String, + timestamp: String + ) { + self.identifier = id + self.keyIdentifier = keyIdentifier + self.nonce = nonce + self.signature = signature + self.timestamp = timestamp + } +} + +// MARK: - StoreKit 2 Integration + +@available(iOS 15.0, macOS 14.0, *) +extension DiscountOffer { + /// Convert to StoreKit 2 Product.PurchaseOption for promotional offers + func toPurchaseOption() -> Product.PurchaseOption? { + guard let nonceUUID = UUID(uuidString: nonce), + let signatureData = Data(base64Encoded: signature), + let timestampInt = Int(timestamp) else { + return nil + } + + return .promotionalOffer( + offerID: identifier, + keyID: keyIdentifier, + nonce: nonceUUID, + signature: signatureData, + timestamp: timestampInt + ) + } +} + +@available(iOS 15.0, macOS 14.0, *) +extension RequestPurchaseProps { + /// Convert to StoreKit 2 purchase options + func toPurchaseOptions() -> [Product.PurchaseOption] { + var options: [Product.PurchaseOption] = [] + + if let quantity = quantity, quantity > 1 { + options.append(.quantity(quantity)) + } + + if let token = appAccountToken, + let uuid = UUID(uuidString: token) { + options.append(.appAccountToken(uuid)) + } + + if let offer = withOffer, + let purchaseOption = offer.toPurchaseOption() { + options.append(purchaseOption) + } + + return options + } +} \ No newline at end of file diff --git a/Sources/OpenIapModule.swift b/Sources/OpenIapModule.swift index d241c0a..9e578af 100644 --- a/Sources/OpenIapModule.swift +++ b/Sources/OpenIapModule.swift @@ -8,13 +8,16 @@ import StoreKit // MARK: - Helper functions for ExpoModulesCore compatibility -// MARK: - Purchase Listeners +// MARK: - Event Listeners @available(iOS 15.0, macOS 14.0, *) public typealias PurchaseUpdatedListener = (OpenIapPurchase) -> Void @available(iOS 15.0, macOS 14.0, *) -public typealias PurchaseErrorListener = (OpenIapError) -> Void +public typealias PurchaseErrorListener = (PurchaseError) -> Void + +@available(iOS 15.0, macOS 14.0, *) +public typealias PromotedProductListener = (String) -> Void // MARK: - Protocol @@ -25,17 +28,11 @@ public protocol OpenIapModuleProtocol { func endConnection() async throws -> Bool // Product Management - func fetchProducts(skus: [String]) async throws -> [OpenIapProduct] - func getAvailableItems(alsoPublishToEventListenerIOS: Bool?, onlyIncludeActiveItemsIOS: Bool?) async throws -> [OpenIapPurchase] + func fetchProducts(_ params: ProductRequest) async throws -> [OpenIapProduct] + func getAvailablePurchases(_ options: PurchaseOptions?) async throws -> [OpenIapPurchase] // Purchase Operations - func requestPurchase( - sku: String, - andDangerouslyFinishTransactionAutomatically: Bool, - appAccountToken: String?, - quantity: Int, - discountOffer: [String: String]? - ) async throws -> OpenIapPurchase? + func requestPurchase(_ props: RequestPurchaseProps) async throws -> OpenIapPurchase // Transaction Management func finishTransaction(transactionIdentifier: String) async throws -> Bool @@ -46,7 +43,7 @@ public protocol OpenIapModuleProtocol { // Validation func getReceiptDataIOS() async throws -> String? func getTransactionJwsIOS(sku: String) async throws -> String? - func validateReceiptIOS(sku: String) async throws -> OpenIapReceiptValidation + func validateReceiptIOS(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult // Store Information func getStorefrontIOS() async throws -> String @@ -76,22 +73,24 @@ public protocol OpenIapModuleProtocol { // MARK: - OpenIapModule Implementation @available(iOS 15.0, macOS 14.0, *) +@MainActor public final class OpenIapModule: NSObject, OpenIapModuleProtocol { public static let shared = OpenIapModule() - // Transaction management + // Transaction management - all accessed on MainActor private var transactions: [String: Transaction] = [:] private var pendingTransactions: [String: Transaction] = [:] private var updateListenerTask: Task? - // Product caching + // Product caching - thread safe via actor private var productStore: ProductStore? - // Purchase listeners - private var purchaseUpdatedListeners: [PurchaseUpdatedListener] = [] - private var purchaseErrorListeners: [PurchaseErrorListener] = [] + // Event listeners - all accessed on MainActor + private var purchaseUpdatedListeners: [(id: UUID, listener: PurchaseUpdatedListener)] = [] + private var purchaseErrorListeners: [(id: UUID, listener: PurchaseErrorListener)] = [] + private var promotedProductListeners: [(id: UUID, listener: PromotedProductListener)] = [] - // State + // State - all accessed on MainActor private var isInitialized = false private override init() { @@ -111,9 +110,25 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // Initialize fresh state self.productStore = ProductStore() + // Check if IAP is available + guard AppStore.canMakePayments else { + let error = PurchaseError( + code: PurchaseError.E_IAP_NOT_AVAILABLE, + message: "In-app purchase not allowed on this device" + ) + emitPurchaseError(error) + self.isInitialized = false + return false + } + + // Start listening for transaction updates + startTransactionListener() + + // Process any unfinished transactions + await processUnfinishedTransactions() self.isInitialized = true - return AppStore.canMakePayments + return true } public func endConnection() async throws -> Bool { @@ -141,18 +156,26 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // MARK: - Product Management - public func fetchProducts(skus: [String]) async throws -> [OpenIapProduct] { + /// Fetch products following OpenIAP specification + public func fetchProducts(_ params: ProductRequest) async throws -> [OpenIapProduct] { + // Check for empty SKU list + guard !params.skus.isEmpty else { + let error = PurchaseError.emptySkuList() + emitPurchaseError(error) + throw OpenIapError.purchaseFailed(reason: error.message) + } + try ensureConnection() let productStore = self.productStore! do { - let fetchedProducts = try await Product.products(for: skus) + let fetchedProducts = try await Product.products(for: params.skus) fetchedProducts.forEach { product in productStore.addProduct(product) } let products = productStore.getAllProducts() - let openIapProducts = await withTaskGroup(of: OpenIapProduct.self) { group in + var openIapProducts = await withTaskGroup(of: OpenIapProduct.self) { group in for product in products { group.addTask { await OpenIapProduct(from: product) @@ -166,16 +189,37 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return results } + // Filter by type using enum + switch params.requestType { + case .inapp: + openIapProducts = openIapProducts.filter { product in + product.productType == .inapp + } + case .subs: + openIapProducts = openIapProducts.filter { product in + product.productType == .subs + } + case .all: + // Return all products without filtering + break + } + return openIapProducts } catch { - print("Error fetching items: \(error)") - throw error + let purchaseError = PurchaseError( + code: PurchaseError.E_QUERY_PRODUCT, + message: "Failed to query product details: \(error.localizedDescription)" + ) + emitPurchaseError(purchaseError) + throw OpenIapError.productNotFound(id: params.skus.joined(separator: ", ")) } } + @available(iOS 15.0, macOS 14.0, *) - public func getAvailableItems(alsoPublishToEventListenerIOS: Bool?, onlyIncludeActiveItemsIOS: Bool?) async throws -> [OpenIapPurchase] { + public func getAvailablePurchases(_ options: PurchaseOptions?) async throws -> [OpenIapPurchase] { + let onlyIncludeActiveItemsIOS = options?.onlyIncludeActiveItemsIOS ?? false try ensureConnection() var purchasedItems: [OpenIapPurchase] = [] @@ -210,82 +254,14 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return purchasedItems } - @available(iOS 15.0, macOS 14.0, *) - public func getAvailablePurchases(alsoPublishToEventListenerIOS: Bool? = false, onlyIncludeActiveItems: Bool? = false) async throws -> [OpenIapPurchase] { - try ensureConnection() - - var purchases: [OpenIapPurchase] = [] - - // Choose transaction source based on onlyIncludeActiveItems - let transactionSource = onlyIncludeActiveItems == true ? Transaction.currentEntitlements : Transaction.all - - for await result in transactionSource { - do { - let transaction = try checkVerified(result) as Transaction - - // If not filtering active items, add all transactions - if onlyIncludeActiveItems != true { - let purchase = await OpenIapPurchase(from: transaction, jwsRepresentation: result.jwsRepresentation) - purchases.append(purchase) - - // Event listeners removed - no notification needed - continue - } - - // Filter active items based on product type - var shouldInclude = false - - switch transaction.productType { - case .consumable, .nonConsumable, .autoRenewable: - // Check if product exists in store - if let store = productStore, store.getProduct(productID: transaction.productID) != nil { - shouldInclude = true - } else { - // Try to fetch if not in cache - if let _ = try? await Product.products(for: [transaction.productID]).first { - shouldInclude = true - } - } - - case .nonRenewable: - // Non-renewable subscriptions expire after 1 year - let currentDate = Date() - let calendar = Calendar(identifier: .gregorian) - if let expirationDate = calendar.date(byAdding: DateComponents(year: 1), to: transaction.purchaseDate) { - shouldInclude = currentDate < expirationDate - } - - default: - shouldInclude = false - } - - if shouldInclude { - let purchase = await OpenIapPurchase(from: transaction, jwsRepresentation: result.jwsRepresentation) - purchases.append(purchase) - - // Event listeners removed - no notification needed - } - - } catch { - // Handle verification errors silently for now - continue - } - } - - return purchases - } - @available(iOS 15.0, macOS 14.0, *) public func getPurchaseHistories(alsoPublishToEventListener: Bool? = false, onlyIncludeActiveItems: Bool? = false) async throws -> [OpenIapPurchase] { // iOS returns all purchase history - let purchases = try await getAvailablePurchases(onlyIncludeActiveItems: false) - - // Event listeners removed - no notification needed - - if onlyIncludeActiveItems == true { - // Filter only active items - return purchases.filter { $0.purchaseState == .purchased || $0.purchaseState == .restored } - } + let options = PurchaseOptions( + alsoPublishToEventListenerIOS: alsoPublishToEventListener, + onlyIncludeActiveItemsIOS: false // Always include all history + ) + let purchases = try await getAvailablePurchases(options) return purchases } @@ -293,19 +269,14 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // MARK: - Purchase Operations @available(iOS 15.0, macOS 14.0, *) - public func requestPurchase( - sku: String, - andDangerouslyFinishTransactionAutomatically: Bool, - appAccountToken: String?, - quantity: Int, - discountOffer: [String: String]? - ) async throws -> OpenIapPurchase? { + @available(iOS 15.0, macOS 14.0, *) + public func requestPurchase(_ props: RequestPurchaseProps) async throws -> OpenIapPurchase { try ensureConnection() // Get product from cache or fetch - var product = productStore!.getProduct(productID: sku) + var product = productStore!.getProduct(productID: props.sku) if product == nil { - let products = try await Product.products(for: [sku]) + let products = try await Product.products(for: [props.sku]) product = products.first if let product = product { productStore!.addProduct(product) @@ -313,42 +284,17 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } guard let product = product else { - throw OpenIapError.productNotFound(id: sku) - } - - // Build purchase options - var options: Set = [] - - // Add quantity option - if quantity > 1 { - options.insert(.quantity(quantity)) - } - - // Add promotional offer if provided - if let offerID = discountOffer?["identifier"], - let keyID = discountOffer?["keyIdentifier"], - let nonce = discountOffer?["nonce"], - let signature = discountOffer?["signature"], - let timestamp = discountOffer?["timestamp"], - let uuidNonce = UUID(uuidString: nonce), - let signatureData = Data(base64Encoded: signature), - let timestampInt = Int(timestamp) { - options.insert( - .promotionalOffer( - offerID: offerID, - keyID: keyID, - nonce: uuidNonce, - signature: signatureData, - timestamp: timestampInt - ) + let error = PurchaseError( + code: PurchaseError.E_SKU_NOT_FOUND, + message: "SKU not found: \(props.sku)", + productId: props.sku ) + emitPurchaseError(error) + throw OpenIapError.productNotFound(id: props.sku) } - // Add app account token if provided - if let appAccountToken = appAccountToken, - let appAccountUUID = UUID(uuidString: appAccountToken) { - options.insert(.appAccountToken(appAccountUUID)) - } + // Build purchase options using RequestPurchaseProps + let options = Set(props.toPurchaseOptions()) // Perform purchase with appropriate method based on iOS version let result: Product.PurchaseResult @@ -373,57 +319,84 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // Convert to OpenIapPurchase for listener and return let purchase = await OpenIapPurchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) - // Notify listeners - for listener in purchaseUpdatedListeners { - listener(purchase) - } + // Emit purchase update event + emitPurchaseUpdate(purchase) // Store transaction if not finishing automatically - if !andDangerouslyFinishTransactionAutomatically { - pendingTransactions[String(transaction.id)] = transaction - } else { + if props.andDangerouslyFinishTransactionAutomatically == true { await transaction.finish() // Still return the transaction data even when finishing automatically + } else { + pendingTransactions[String(transaction.id)] = transaction } return purchase case .userCancelled: - let error = OpenIapError.purchaseCancelled - for listener in purchaseErrorListeners { - listener(error) - } - throw error + let error = PurchaseError( + code: PurchaseError.E_USER_CANCELLED, + message: "Purchase cancelled by user", + productId: props.sku + ) + emitPurchaseError(error) + throw OpenIapError.purchaseCancelled case .pending: - // For deferred payments, we don't call error listeners + // For deferred payments, emit appropriate event + let error = PurchaseError( + code: PurchaseError.E_DEFERRED_PAYMENT, + message: "Payment was deferred (pending family approval, etc.)", + productId: props.sku + ) + emitPurchaseError(error) throw OpenIapError.purchaseDeferred @unknown default: - let error = OpenIapError.unknownError - for listener in purchaseErrorListeners { - listener(error) - } - throw error + let error = PurchaseError( + code: PurchaseError.E_UNKNOWN, + message: "Unknown error occurred", + productId: props.sku + ) + emitPurchaseError(error) + throw OpenIapError.unknownError } } // MARK: - Transaction Management public func finishTransaction(transactionIdentifier: String) async throws -> Bool { + // Thread-safe read of pending transactions + let transaction = await MainActor.run { + pendingTransactions[transactionIdentifier] + } + // Check pending transactions first - if let transaction = pendingTransactions[transactionIdentifier] { + if let transaction = transaction { await transaction.finish() pendingTransactions.removeValue(forKey: transactionIdentifier) return true } - // Otherwise search in all transactions + // Otherwise search in current entitlements (more efficient than Transaction.all) guard let id = UInt64(transactionIdentifier) else { throw OpenIapError.purchaseFailed(reason: "Invalid transaction ID") } - for await result in Transaction.all { + // Search in current entitlements first (active purchases) + for await result in Transaction.currentEntitlements { + do { + let transaction = try checkVerified(result) as Transaction + if transaction.id == id { + await transaction.finish() + return true + } + } catch { + continue + } + } + + // If not found in entitlements, search in unfinished + for await result in Transaction.unfinished { do { let transaction = try checkVerified(result) as Transaction if transaction.id == id { @@ -439,15 +412,6 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } @available(iOS 15.0, macOS 14.0, *) - public func getPendingTransactions() async -> [OpenIapPurchase] { - var purchases: [OpenIapPurchase] = [] - for (_, transaction) in pendingTransactions { - let purchase = await OpenIapPurchase(from: transaction) - purchases.append(purchase) - } - return purchases - } - @available(iOS 15.0, macOS 14.0, *) public func getPendingTransactionsIOS() async throws -> [OpenIapPurchase] { var purchaseArray: [OpenIapPurchase] = [] @@ -458,7 +422,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return purchaseArray } - public func clearTransactions() async throws { + public func clearTransactionIOS() async throws { // Clear all pending transactions for await result in Transaction.unfinished { do { @@ -471,10 +435,6 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } } - public func clearTransactionIOS() async throws { - try await clearTransactions() - } - public func isTransactionVerifiedIOS(sku: String) async -> Bool { guard let product = productStore!.getProduct(productID: sku) else { return false @@ -493,83 +453,41 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // MARK: - Validation - @available(iOS 15.0, macOS 14.0, *) - public func validateReceipt(productId: String? = nil) async throws -> OpenIapReceipt { - guard let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, - FileManager.default.fileExists(atPath: appStoreReceiptURL.path) else { - throw OpenIapError.invalidReceipt - } - - let receiptData = try? Data(contentsOf: appStoreReceiptURL) - - guard receiptData != nil else { - throw OpenIapError.invalidReceipt - } - - let purchases = try await getAvailablePurchases(onlyIncludeActiveItems: false) - - return OpenIapReceipt( - bundleId: Bundle.main.bundleIdentifier ?? "", - applicationVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "", - originalApplicationVersion: Bundle.main.infoDictionary?["CFBundleVersion"] as? String, - creationDate: Date(), - expirationDate: nil, - inAppPurchases: purchases - ) - } - - public func getReceiptData() async throws -> String { + public func getReceiptDataIOS() async throws -> String? { guard let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: appStoreReceiptURL.path) else { - throw OpenIapError.invalidReceipt + return nil } let receiptData = try Data(contentsOf: appStoreReceiptURL) return receiptData.base64EncodedString(options: []) } - public func getReceiptDataIOS() async throws -> String? { - do { - return try await getReceiptData() - } catch { - return nil - } - } - - public func getTransactionJws(productId: String) async throws -> String? { - var product = productStore!.getProduct(productID: productId) + public func getTransactionJwsIOS(sku: String) async throws -> String? { + var product = productStore!.getProduct(productID: sku) if product == nil { - product = try? await Product.products(for: [productId]).first + product = try? await Product.products(for: [sku]).first } guard let product = product, let result = await product.latestTransaction else { - throw OpenIapError.productNotFound(id: productId) + throw OpenIapError.productNotFound(id: sku) } return result.jwsRepresentation } - public func getTransactionJwsIOS(sku: String) async throws -> String? { - return try await getTransactionJws(productId: sku) - } - @available(iOS 15.0, macOS 14.0, *) - public func validateReceiptIOS(sku: String) async throws -> OpenIapReceiptValidation { - var receiptData: String = "" - do { - receiptData = try await getReceiptData() - } catch { - // Continue with validation even if receipt retrieval fails - } + public func validateReceiptIOS(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult { + let receiptData = (try? await getReceiptDataIOS()) ?? "" var isValid = false var jwsRepresentation: String = "" var latestTransaction: OpenIapPurchase? = nil - var product = productStore!.getProduct(productID: sku) + var product = productStore!.getProduct(productID: props.sku) if product == nil { - product = try? await Product.products(for: [sku]).first + product = try? await Product.products(for: [props.sku]).first } if let product = product, @@ -585,7 +503,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } } - return OpenIapReceiptValidation( + return ReceiptValidationResult( isValid: isValid, receiptData: receiptData, jwsRepresentation: jwsRepresentation, @@ -700,9 +618,6 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { #endif } - public func isEligibleForIntroOffer(groupId: String) async -> Bool { - return await Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupId) - } public func subscriptionStatusIOS(sku: String) async throws -> [OpenIapSubscriptionStatus]? { try ensureConnection() @@ -917,56 +832,50 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // MARK: - Listener Management - @available(iOS 15.0, macOS 14.0, *) - public func addPurchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener) { - purchaseUpdatedListeners.append(listener) - } - - @available(iOS 15.0, macOS 14.0, *) - public func removePurchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener) { - // Note: In Swift, comparing closures is not straightforward - // For now, we provide a method to clear all listeners - // In production, you might want to use a UUID-based system - } - - public func removeAllPurchaseUpdatedListeners() { - purchaseUpdatedListeners.removeAll() - } - - @available(iOS 15.0, macOS 14.0, *) - public func addPurchaseErrorListener(_ listener: @escaping PurchaseErrorListener) { - purchaseErrorListeners.append(listener) - } - - @available(iOS 15.0, macOS 14.0, *) - public func removePurchaseErrorListener(_ listener: @escaping PurchaseErrorListener) { - // Note: In Swift, comparing closures is not straightforward - // For now, we provide a method to clear all listeners - } - - public func removeAllPurchaseErrorListeners() { - purchaseErrorListeners.removeAll() - } - // MARK: - Private Methods private func ensureConnection() throws { guard isInitialized else { - throw OpenIapError.purchaseFailed(reason: "Connection not initialized. Call initConnection() first.") + let error = PurchaseError( + code: PurchaseError.E_INIT_CONNECTION, + message: "Connection not initialized. Call initConnection() first." + ) + emitPurchaseError(error) + throw OpenIapError.purchaseFailed(reason: error.message) } } private func startTransactionListener() { - updateListenerTask = Task { + updateListenerTask = Task { @MainActor [weak self] in + guard let self = self else { return } + for await result in Transaction.updates { do { - let transaction = try checkVerified(result) as Transaction + let transaction = try self.checkVerified(result) as Transaction + let transactionId = String(transaction.id) - // Store transaction temporarily for pending transactions tracking - pendingTransactions[String(transaction.id)] = transaction + // Store pending transaction - already on MainActor + self.pendingTransactions[transactionId] = transaction + + // Emit purchase updated event for real-time updates + let purchase = await OpenIapPurchase(from: transaction, jwsRepresentation: result.jwsRepresentation) + self.emitPurchaseUpdate(purchase) } catch { - // Silent error handling - transaction verification failed + // Emit purchase error when transaction verification fails + print("⚠️ Transaction verification failed: \(error)") + + let purchaseError: PurchaseError + if let openIapError = error as? OpenIapError { + purchaseError = PurchaseError(from: openIapError, productId: nil) + } else { + purchaseError = PurchaseError( + code: PurchaseError.E_TRANSACTION_VALIDATION_FAILED, + message: "Transaction verification failed: \(error.localizedDescription)", + productId: nil + ) + } + self.emitPurchaseError(purchaseError) } } } @@ -976,11 +885,31 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { for await result in Transaction.unfinished { do { let transaction = try checkVerified(result) as Transaction - // Store as pending - pendingTransactions[String(transaction.id)] = transaction + let transactionId = String(transaction.id) - // Simply store as pending - no notification needed + // Store pending transaction - method is already on MainActor + pendingTransactions[transactionId] = transaction + + // Auto-finish non-consumable transactions + if transaction.productType == .nonConsumable || + transaction.productType == .autoRenewable { + await transaction.finish() + } } catch { + print("⚠️ Failed to process unfinished transaction: \(error)") + + // Emit purchase error for unfinished transaction processing failure + let purchaseError: PurchaseError + if let openIapError = error as? OpenIapError { + purchaseError = PurchaseError(from: openIapError, productId: nil) + } else { + purchaseError = PurchaseError( + code: PurchaseError.E_TRANSACTION_VALIDATION_FAILED, + message: "Failed to process unfinished transaction: \(error.localizedDescription)", + productId: nil + ) + } + emitPurchaseError(purchaseError) continue } } @@ -1001,4 +930,66 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // transactionToIapTransactionData is deprecated - OpenIapPurchase.init(from:) is used instead // This provides better type safety and includes all StoreKit 2 properties + // MARK: - Event Listeners + + /// Register a listener for purchase updated events + public func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener) -> Subscription { + let subscription = Subscription(eventType: .PURCHASE_UPDATED) + purchaseUpdatedListeners.append((id: subscription.id, listener: listener)) + return subscription + } + + /// Register a listener for purchase error events + public func purchaseErrorListener(_ listener: @escaping PurchaseErrorListener) -> Subscription { + let subscription = Subscription(eventType: .PURCHASE_ERROR) + purchaseErrorListeners.append((id: subscription.id, listener: listener)) + return subscription + } + + /// Register a listener for promoted product events (iOS only) + public func promotedProductListenerIOS(_ listener: @escaping PromotedProductListener) -> Subscription { + let subscription = Subscription(eventType: .PROMOTED_PRODUCT_IOS) + promotedProductListeners.append((id: subscription.id, listener: listener)) + return subscription + } + + /// Remove a listener by subscription + public func removeListener(_ subscription: Subscription) { + switch subscription.eventType { + case .PURCHASE_UPDATED: + purchaseUpdatedListeners.removeAll { $0.id == subscription.id } + case .PURCHASE_ERROR: + purchaseErrorListeners.removeAll { $0.id == subscription.id } + case .PROMOTED_PRODUCT_IOS: + promotedProductListeners.removeAll { $0.id == subscription.id } + } + } + + /// Remove all listeners + public func removeAllListeners() { + purchaseUpdatedListeners.removeAll() + purchaseErrorListeners.removeAll() + promotedProductListeners.removeAll() + } + + // MARK: - Event Emission (Private) + + private func emitPurchaseUpdate(_ purchase: OpenIapPurchase) { + for (_, listener) in purchaseUpdatedListeners { + listener(purchase) + } + } + + private func emitPurchaseError(_ error: PurchaseError) { + for (_, listener) in purchaseErrorListeners { + listener(error) + } + } + + private func emitPromotedProduct(_ sku: String) { + for (_, listener) in promotedProductListeners { + listener(sku) + } + } + } \ No newline at end of file diff --git a/Tests/OpenIapTests.swift b/Tests/OpenIapTests.swift index f6eef24..8a6ec3c 100644 --- a/Tests/OpenIapTests.swift +++ b/Tests/OpenIapTests.swift @@ -4,65 +4,73 @@ import XCTest final class OpenIapTests: XCTestCase { func testProductModel() { - let product = OpenIapProduct( - id: "dev.hyo.premium", - productType: .autoRenewableSubscription, - localizedTitle: "Premium Subscription", - localizedDescription: "Get access to all premium features", - price: 9.99, - localizedPrice: "$9.99", - currencyCode: "USD", - countryCode: "US", - subscriptionPeriod: OpenIapProduct.SubscriptionPeriod(unit: .month, value: 1), - introductoryPrice: OpenIapProduct.IntroductoryOffer( + let subscriptionInfo = OpenIapProduct.SubscriptionInfo( + introductoryOffer: OpenIapProduct.SubscriptionOffer( + displayPrice: "Free", id: "intro1", - price: 0, - localizedPrice: "Free", + paymentMode: .freeTrial, period: OpenIapProduct.SubscriptionPeriod(unit: .week, value: 1), - numberOfPeriods: 1, - paymentMode: .freeTrial + periodCount: 1, + price: 0, + type: .introductory ), - discounts: nil, + promotionalOffers: nil, subscriptionGroupId: "group1", - platform: "iOS", - isFamilyShareable: true, - jsonRepresentation: nil, + subscriptionPeriod: OpenIapProduct.SubscriptionPeriod(unit: .month, value: 1) + ) + + let product = OpenIapProduct( + id: "dev.hyo.premium", + title: "Premium Subscription", + description: "Get access to all premium features", + type: "subs", + displayName: "Premium Subscription", + displayPrice: "$9.99", + currency: "USD", + price: 9.99, + debugDescription: nil, + platform: "ios", displayNameIOS: "Premium Subscription", isFamilyShareableIOS: true, jsonRepresentationIOS: "{}", - descriptionIOS: "Get access to all premium features", - displayPriceIOS: "$9.99", - priceIOS: 9.99 + subscriptionInfoIOS: subscriptionInfo, + typeIOS: .autoRenewableSubscription, + discountsIOS: nil, + introductoryPriceIOS: "Free", + introductoryPriceAsAmountIOS: "0", + introductoryPricePaymentModeIOS: "FREETRIAL", + introductoryPriceNumberOfPeriodsIOS: "1", + introductoryPriceSubscriptionPeriodIOS: "WEEK", + subscriptionPeriodNumberIOS: "1", + subscriptionPeriodUnitIOS: "MONTH" ) XCTAssertEqual(product.id, "dev.hyo.premium") - XCTAssertEqual(product.productType, .autoRenewableSubscription) + XCTAssertEqual(product.type, "subs") XCTAssertEqual(product.price, 9.99) - XCTAssertNotNil(product.subscriptionPeriod) - XCTAssertNotNil(product.introductoryPrice) - XCTAssertEqual(product.introductoryPrice?.paymentMode, .freeTrial) + XCTAssertNotNil(product.subscriptionInfoIOS) + XCTAssertNotNil(product.subscriptionInfoIOS?.introductoryOffer) + XCTAssertEqual(product.subscriptionInfoIOS?.introductoryOffer?.paymentMode, .freeTrial) } func testPurchaseModel() { + let now = Date() let purchase = OpenIapPurchase( id: "trans123", productId: "dev.hyo.premium", - purchaseToken: "token123", - transactionId: "trans123", - originalTransactionId: "original123", - platform: "iOS", ids: ["trans123"], - purchaseTime: Date(), - originalPurchaseTime: Date(), - expiryTime: Date().addingTimeInterval(86400 * 30), - isAutoRenewing: true, - purchaseState: .purchased, - acknowledgementState: .acknowledged, + transactionDate: now.timeIntervalSince1970 * 1000, + transactionReceipt: "receipt_data", + purchaseToken: "token123", + platform: "ios", quantity: 1, - developerPayload: nil, - jwsRepresentation: nil, - jsonRepresentation: nil, + purchaseState: .purchased, + isAutoRenewing: true, + quantityIOS: 1, + originalTransactionDateIOS: now.timeIntervalSince1970 * 1000, + originalTransactionIdentifierIOS: "original123", appAccountToken: nil, + expirationDateIOS: (now.timeIntervalSince1970 + 86400 * 30) * 1000, webOrderLineItemIdIOS: nil, environmentIOS: "Production", storefrontCountryCodeIOS: "US", @@ -83,10 +91,10 @@ final class OpenIapTests: XCTestCase { ) XCTAssertEqual(purchase.id, "trans123") - XCTAssertEqual(purchase.purchaseState, .purchased) - XCTAssertEqual(purchase.acknowledgementState, .acknowledged) - XCTAssertTrue(purchase.isAutoRenewing) - XCTAssertEqual(purchase.quantity, 1) + XCTAssertEqual(purchase.productId, "dev.hyo.premium") + XCTAssertEqual(purchase.platform, "ios") + XCTAssertEqual(purchase.quantityIOS, 1) + XCTAssertEqual(purchase.transactionReasonIOS, "PURCHASE") } func testOpenIapError() { @@ -112,42 +120,42 @@ final class OpenIapTests: XCTestCase { XCTAssertNotEqual(period1, period3) } - func testIntroductoryOffer() { - let offer = OpenIapProduct.IntroductoryOffer( + func testSubscriptionOffer() { + let offer = OpenIapProduct.SubscriptionOffer( + displayPrice: "$4.99", id: "intro2", - price: 4.99, - localizedPrice: "$4.99", + paymentMode: .payAsYouGo, period: OpenIapProduct.SubscriptionPeriod(unit: .month, value: 1), - numberOfPeriods: 3, - paymentMode: .payAsYouGo + periodCount: 3, + price: 4.99, + type: .introductory ) XCTAssertEqual(offer.price, 4.99) - XCTAssertEqual(offer.numberOfPeriods, 3) + XCTAssertEqual(offer.periodCount, 3) XCTAssertEqual(offer.paymentMode, .payAsYouGo) + XCTAssertEqual(offer.type, .introductory) } func testReceipt() { + let now = Date() let purchases = [ OpenIapPurchase( id: "trans1", productId: "product1", - purchaseToken: "token1", - transactionId: "trans1", - originalTransactionId: nil, - platform: "iOS", ids: ["trans1"], - purchaseTime: Date(), - originalPurchaseTime: nil, - expiryTime: nil, - isAutoRenewing: false, - purchaseState: .purchased, - acknowledgementState: .acknowledged, + transactionDate: now.timeIntervalSince1970 * 1000, + transactionReceipt: "receipt_data", + purchaseToken: "token1", + platform: "ios", quantity: 1, - developerPayload: nil, - jwsRepresentation: nil, - jsonRepresentation: nil, + purchaseState: .purchased, + isAutoRenewing: false, + quantityIOS: 1, + originalTransactionDateIOS: nil, + originalTransactionIdentifierIOS: nil, appAccountToken: nil, + expirationDateIOS: nil, webOrderLineItemIdIOS: nil, environmentIOS: "Production", storefrontCountryCodeIOS: "US",