diff --git a/.claude/guides/04-apple-package.md b/.claude/guides/04-apple-package.md index 33d9f7cf..7bb7dcc6 100644 --- a/.claude/guides/04-apple-package.md +++ b/.claude/guides/04-apple-package.md @@ -40,7 +40,7 @@ public protocol OpenIapModuleProtocol { func fetchProducts(_ params: ProductRequest) async throws -> FetchProductsResult func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? func finishTransaction(purchase: PurchaseInput, isConsumable: Bool?) async throws -> Void - func verifyPurchase(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult + func verifyPurchase(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult // ... more methods } ``` diff --git a/.vscode/settings.json b/.vscode/settings.json index 2154c63e..98f8db23 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,9 @@ { "cSpell.words": [ "hyodotdev", - "openiap" + "Iapkit", + "openiap", + "Skus" ], "files.associations": { "*.podspec": "ruby" diff --git a/packages/apple/CONTRIBUTING.md b/packages/apple/CONTRIBUTING.md index edd0a80a..03a11aef 100644 --- a/packages/apple/CONTRIBUTING.md +++ b/packages/apple/CONTRIBUTING.md @@ -42,7 +42,7 @@ swift test #### OpenIap Prefix (Public Models) - Prefix all public model types with `OpenIap`. - - Examples: `ProductIOS`, `PurchaseIOS`, `ProductIOSRequest`, `RequestPurchaseProps`, `PurchaseIOSOptions`, `ReceiptValidationProps`, `ReceiptValidationResultIOS`, `ActiveSubscription`, `PurchaseIOSState`, `PurchaseIOSOffer`, `ProductIOSType`, `ProductIOSTypeIOS`. +- Examples: `ProductIOS`, `PurchaseIOS`, `ProductRequest`, `RequestPurchaseProps`, `PurchaseOptions`, `VerifyPurchaseProps`, `VerifyPurchaseResultIOS`, `ActiveSubscription`, `PurchaseState`, `PurchaseOfferIOS`, `ProductTypeIOS`. - Private/internal helper types do not need the prefix. - When renaming existing types, add a public `typealias` from the old name to the new name to preserve source compatibility, then migrate usages incrementally. diff --git a/packages/apple/Example/.gitignore b/packages/apple/Example/.gitignore index 0023a534..d2d6d91d 100644 --- a/packages/apple/Example/.gitignore +++ b/packages/apple/Example/.gitignore @@ -6,3 +6,6 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc + +# Local secrets (API keys) +OpenIapExample/Info.plist diff --git a/packages/apple/Example/Martie.xcodeproj/project.pbxproj b/packages/apple/Example/Martie.xcodeproj/project.pbxproj index 192ce99b..0422144d 100644 --- a/packages/apple/Example/Martie.xcodeproj/project.pbxproj +++ b/packages/apple/Example/Martie.xcodeproj/project.pbxproj @@ -418,6 +418,7 @@ DEVELOPMENT_ASSET_PATHS = "\"OpenIapExample/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OpenIapExample/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenIAP; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -448,6 +449,7 @@ DEVELOPMENT_ASSET_PATHS = "\"OpenIapExample/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OpenIapExample/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenIAP; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/packages/apple/Example/Martie.xcodeproj/xcshareddata/xcschemes/OpenIapExample.xcscheme b/packages/apple/Example/Martie.xcodeproj/xcshareddata/xcschemes/OpenIapExample.xcscheme new file mode 100644 index 00000000..e3891f55 --- /dev/null +++ b/packages/apple/Example/Martie.xcodeproj/xcshareddata/xcschemes/OpenIapExample.xcscheme @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/apple/Example/OpenIapExample/Info.plist.example b/packages/apple/Example/OpenIapExample/Info.plist.example new file mode 100644 index 00000000..c59ce5f7 --- /dev/null +++ b/packages/apple/Example/OpenIapExample/Info.plist.example @@ -0,0 +1,8 @@ + + + + + IAPKIT_API_KEY + YOUR_IAPKIT_API_KEY_HERE + + diff --git a/packages/apple/Example/OpenIapExample/Screens/PurchaseFlowScreen.swift b/packages/apple/Example/OpenIapExample/Screens/PurchaseFlowScreen.swift index 417cd451..88a2f170 100644 --- a/packages/apple/Example/OpenIapExample/Screens/PurchaseFlowScreen.swift +++ b/packages/apple/Example/OpenIapExample/Screens/PurchaseFlowScreen.swift @@ -1,10 +1,24 @@ import SwiftUI import OpenIAP +enum VerificationMethod: String, CaseIterable { + case none = "None" + case local = "Local" + case iapkit = "IAPKit" + + var displayName: String { + switch self { + case .none: return "❌ None (Skip)" + case .local: return "📱 Local (Device)" + case .iapkit: return "☁️ IAPKit (Server)" + } + } +} + @available(iOS 15.0, *) struct PurchaseFlowScreen: View { @StateObject private var iapStore = OpenIapStore() - + // UI State @State private var showPurchaseResult = false @State private var purchaseResultMessage = "" @@ -13,7 +27,16 @@ struct PurchaseFlowScreen: View { @State private var showError = false @State private var errorMessage = "" @State private var isInitialLoading = true - + @State private var verificationMethod: VerificationMethod = .none + @State private var isVerifying = false + @State private var processedPurchaseKey: String? + + // IAPKit API Key from environment (set in scheme or Info.plist) + private var iapkitApiKey: String? { + ProcessInfo.processInfo.environment["IAPKIT_API_KEY"] ?? + Bundle.main.object(forInfoDictionaryKey: "IAPKIT_API_KEY") as? String + } + // Product IDs configured in App Store Connect private let productIds: [String] = [ "dev.hyo.martie.10bulbs", @@ -26,6 +49,8 @@ struct PurchaseFlowScreen: View { VStack(spacing: 20) { HeaderCardView() + VerificationMethodCard() + if isInitialLoading { LoadingCard(text: "Loading products...") } else { @@ -35,9 +60,9 @@ struct PurchaseFlowScreen: View { PurchaseResultSection() } } - + InstructionsCard() - + Spacer(minLength: 20) } .padding(.vertical) @@ -175,6 +200,82 @@ struct PurchaseFlowScreen: View { .padding(.horizontal) } + @ViewBuilder + private func VerificationMethodCard() -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "checkmark.shield.fill") + .foregroundColor(AppColors.secondary) + Text("Purchase Verification") + .font(.headline) + Spacer() + } + + Menu { + ForEach(VerificationMethod.allCases, id: \.self) { method in + Button(method.displayName) { + verificationMethod = method + } + } + } label: { + HStack { + Text(verificationMethod.displayName) + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.down") + .foregroundColor(.secondary) + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + + if verificationMethod == .iapkit { + VStack(alignment: .leading, spacing: 8) { + if iapkitApiKey != nil { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(AppColors.success) + Text("API Key configured") + .font(.caption) + .foregroundColor(AppColors.success) + } + } else { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + VStack(alignment: .leading, spacing: 4) { + Text("API Key not configured") + .font(.caption) + .foregroundColor(.orange) + .fontWeight(.semibold) + Text("Set IAPKIT_API_KEY in:") + .font(.caption2) + .foregroundColor(.secondary) + Text("• Xcode Scheme → Environment Variables") + .font(.caption2) + .foregroundColor(.secondary) + Text("• Or Info.plist → IAPKIT_API_KEY") + .font(.caption2) + .foregroundColor(.secondary) + } + Spacer() + } + .frame(maxWidth: .infinity) + .padding(8) + .background(Color.orange.opacity(0.1)) + .cornerRadius(6) + } + } + } + } + .padding() + .background(AppColors.cardBackground) + .cornerRadius(12) + .shadow(radius: 2) + .padding(.horizontal) + } + @ViewBuilder private func InstructionsCard() -> some View { VStack(alignment: .leading, spacing: 16) { @@ -185,14 +286,14 @@ struct PurchaseFlowScreen: View { .font(.headline) Spacer() } - + VStack(alignment: .leading, spacing: 8) { InstructionRow( number: "1", text: "Products are loaded from App Store Connect" ) InstructionRow( - number: "2", + number: "2", text: "Tap Purchase to initiate transaction" ) InstructionRow( @@ -211,7 +312,7 @@ struct PurchaseFlowScreen: View { .shadow(radius: 2) .padding(.horizontal) } - + // using shared InstructionRow in Screens/uis/InstructionRow.swift // MARK: - OpenIapStore Setup @@ -296,21 +397,143 @@ struct PurchaseFlowScreen: View { private func handlePurchaseSuccess(_ purchase: OpenIapPurchase) { print("✅ [PurchaseFlow] Purchase successful: \(purchase.productId)") - + + // Create unique key for this purchase to prevent duplicate processing + let purchaseKey = "\(purchase.id)_\(purchase.transactionDate)" + + // Skip if we've already processed this exact purchase + if purchaseKey == processedPurchaseKey { + print("🔄 [PurchaseFlow] Skipping already processed purchase: \(purchaseKey)") + return + } + + // Mark as processed + processedPurchaseKey = purchaseKey + // Update UI state let transactionDate = Date(timeIntervalSince1970: purchase.transactionDate / 1000) - purchaseResultMessage = """ - ✅ Purchase successful - Product: \(purchase.productId) - Transaction ID: \(purchase.id) - Date: \(DateFormatter.localizedString(from: transactionDate, dateStyle: .short, timeStyle: .short)) - """ - showPurchaseResult = true latestPurchase = purchase - // In production, validate receipt on your server before finishing Task { + await verifyAndFinishPurchase(purchase, transactionDate: transactionDate) + } + } + + private func verifyAndFinishPurchase(_ purchase: OpenIapPurchase, transactionDate: Date) async { + let dateString = DateFormatter.localizedString(from: transactionDate, dateStyle: .short, timeStyle: .short) + + switch verificationMethod { + case .none: + await MainActor.run { + purchaseResultMessage = """ + ✅ Purchase successful (No verification) + Product: \(purchase.productId) + Transaction ID: \(purchase.id) + Date: \(dateString) + """ + showPurchaseResult = true + } await finishPurchase(purchase) + + case .local: + await MainActor.run { + isVerifying = true + purchaseResultMessage = "🔍 Verifying locally..." + showPurchaseResult = true + } + + do { + let result = try await iapStore.verifyPurchase(sku: purchase.productId) + await MainActor.run { + isVerifying = false + purchaseResultMessage = """ + ✅ Purchase verified locally + Product: \(purchase.productId) + Valid: \(result.isValid) + Date: \(dateString) + """ + } + await finishPurchase(purchase) + } catch { + await MainActor.run { + isVerifying = false + purchaseResultMessage = "❌ Local verification failed: \(error.localizedDescription)" + errorMessage = error.localizedDescription + showError = true + } + } + + case .iapkit: + guard let apiKey = iapkitApiKey else { + await MainActor.run { + purchaseResultMessage = "❌ IAPKit API Key not configured" + showPurchaseResult = true + errorMessage = "Set IAPKIT_API_KEY in Xcode Scheme or Info.plist" + showError = true + } + return + } + + await MainActor.run { + isVerifying = true + purchaseResultMessage = "☁️ Verifying with IAPKit..." + showPurchaseResult = true + } + + do { + // Get JWS token for verification + guard let jws = purchase.purchaseToken, !jws.isEmpty else { + await MainActor.run { + isVerifying = false + purchaseResultMessage = "❌ Missing JWS token" + errorMessage = "Missing JWS token" + showError = true + } + return + } + + let props = VerifyPurchaseWithProviderProps( + iapkit: RequestVerifyPurchaseWithIapkitProps( + apiKey: apiKey, + apple: RequestVerifyPurchaseWithIapkitAppleProps( + jws: jws + ), + google: nil + ), + provider: .iapkit + ) + + let results = try await iapStore.verifyPurchaseWithProvider(props) + let isValid = results.first?.isValid ?? false + let state = results.first?.state.rawValue ?? "unknown" + + print("📱 [PurchaseFlow] IAPKit verification result:") + print(" - Product: \(purchase.productId)") + print(" - isValid: \(isValid)") + print(" - state: \(state)") + print(" - Results count: \(results.count)") + + await MainActor.run { + isVerifying = false + purchaseResultMessage = """ + \(isValid ? "✅" : "❌") IAPKit verification \(isValid ? "passed" : "failed") + Product: \(purchase.productId) + isValid: \(isValid), state: \(state) + Date: \(dateString) + """ + } + + if isValid { + await finishPurchase(purchase) + } + } catch { + await MainActor.run { + isVerifying = false + purchaseResultMessage = "❌ IAPKit verification failed: \(error.localizedDescription)" + errorMessage = error.localizedDescription + showError = true + } + } } } diff --git a/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift b/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift index a79b46e2..84d678f7 100644 --- a/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift +++ b/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift @@ -4,7 +4,7 @@ import OpenIAP @available(iOS 15.0, *) struct SubscriptionFlowScreen: View { @StateObject private var iapStore = OpenIapStore() - + // UI State @State private var showError = false @State private var errorMessage = "" @@ -12,7 +12,17 @@ struct SubscriptionFlowScreen: View { @State private var selectedPurchase: OpenIapPurchase? @State private var isInitialLoading = true @State private var isRefreshing = false - + @State private var verificationMethod: VerificationMethod = .none + @State private var isVerifying = false + @State private var verificationResultMessage: String? + @State private var processedPurchaseKey: String? + + // IAPKit API Key from environment (set in scheme or Info.plist) + private var iapkitApiKey: String? { + ProcessInfo.processInfo.environment["IAPKIT_API_KEY"] ?? + Bundle.main.object(forInfoDictionaryKey: "IAPKIT_API_KEY") as? String + } + // Product IDs for subscription testing // Ordered from lowest to highest tier for upgrade scenarios private let subscriptionIds: [String] = [ @@ -53,7 +63,9 @@ struct SubscriptionFlowScreen: View { .cornerRadius(12) .shadow(radius: 2) .padding(.horizontal) - + + VerificationMethodCard() + if isInitialLoading { LoadingCard(text: "Loading subscriptions...") } else if isRefreshing { @@ -462,7 +474,187 @@ struct SubscriptionFlowScreen: View { } return 0 // Unknown tier } - + + // MARK: - Verification Method UI + + @ViewBuilder + private func VerificationMethodCard() -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "checkmark.shield.fill") + .foregroundColor(AppColors.secondary) + Text("Purchase Verification") + .font(.headline) + Spacer() + } + + Menu { + ForEach(VerificationMethod.allCases, id: \.self) { method in + Button(method.displayName) { + verificationMethod = method + } + } + } label: { + HStack { + Text(verificationMethod.displayName) + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.down") + .foregroundColor(.secondary) + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + + if verificationMethod == .iapkit { + VStack(alignment: .leading, spacing: 8) { + if iapkitApiKey != nil { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(AppColors.success) + Text("API Key configured") + .font(.caption) + .foregroundColor(AppColors.success) + } + } else { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + VStack(alignment: .leading, spacing: 4) { + Text("API Key not configured") + .font(.caption) + .foregroundColor(.orange) + .fontWeight(.semibold) + Text("Set IAPKIT_API_KEY in:") + .font(.caption2) + .foregroundColor(.secondary) + Text("• Xcode Scheme → Environment Variables") + .font(.caption2) + .foregroundColor(.secondary) + Text("• Or Info.plist → IAPKIT_API_KEY") + .font(.caption2) + .foregroundColor(.secondary) + } + Spacer() + } + .frame(maxWidth: .infinity) + .padding(8) + .background(Color.orange.opacity(0.1)) + .cornerRadius(6) + } + } + } + + if let resultMessage = verificationResultMessage { + Text(resultMessage) + .font(.caption) + .foregroundColor(.secondary) + .padding(8) + .background(Color.gray.opacity(0.1)) + .cornerRadius(6) + } + } + .padding() + .background(AppColors.cardBackground) + .cornerRadius(12) + .shadow(radius: 2) + .padding(.horizontal) + } + + // MARK: - Verification Logic + + private func verifyAndFinishPurchase(_ purchase: OpenIapPurchase) async { + switch verificationMethod { + case .none: + await MainActor.run { + verificationResultMessage = "✅ No verification (skipped)" + } + // Transaction already finished via autoFinish: true + + case .local: + await MainActor.run { + isVerifying = true + verificationResultMessage = "🔍 Verifying locally..." + } + + do { + let result = try await iapStore.verifyPurchase(sku: purchase.productId) + await MainActor.run { + isVerifying = false + verificationResultMessage = "✅ Local verification: Valid=\(result.isValid)" + } + } catch { + await MainActor.run { + isVerifying = false + verificationResultMessage = "❌ Local verification failed: \(error.localizedDescription)" + errorMessage = error.localizedDescription + showError = true + } + } + + case .iapkit: + guard let apiKey = iapkitApiKey else { + await MainActor.run { + verificationResultMessage = "❌ IAPKit API Key not configured" + errorMessage = "Set IAPKIT_API_KEY in Xcode Scheme or Info.plist" + showError = true + } + return + } + + await MainActor.run { + isVerifying = true + verificationResultMessage = "☁️ Verifying with IAPKit..." + } + + do { + guard let jws = purchase.purchaseToken, !jws.isEmpty else { + await MainActor.run { + isVerifying = false + verificationResultMessage = "❌ Missing JWS token" + errorMessage = "Missing JWS token" + showError = true + } + return + } + + let props = VerifyPurchaseWithProviderProps( + iapkit: RequestVerifyPurchaseWithIapkitProps( + apiKey: apiKey, + apple: RequestVerifyPurchaseWithIapkitAppleProps( + jws: jws + ), + google: nil + ), + provider: .iapkit + ) + + let results = try await iapStore.verifyPurchaseWithProvider(props) + let isValid = results.first?.isValid ?? false + let state = results.first?.state.rawValue ?? "unknown" + + print("📱 [SubscriptionFlow] IAPKit verification result:") + print(" - Product: \(purchase.productId)") + print(" - isValid: \(isValid)") + print(" - state: \(state)") + print(" - Results count: \(results.count)") + + await MainActor.run { + isVerifying = false + verificationResultMessage = "\(isValid ? "✅" : "❌") IAPKit: isValid=\(isValid), state=\(state)" + } + } catch { + await MainActor.run { + isVerifying = false + verificationResultMessage = "❌ IAPKit failed: \(error.localizedDescription)" + errorMessage = error.localizedDescription + showError = true + } + } + } + } + private func restorePurchases() async { await MainActor.run { isRefreshing = true @@ -502,6 +694,19 @@ struct SubscriptionFlowScreen: View { private func handlePurchaseSuccess(_ purchase: OpenIapPurchase) { print("✅ [SubscriptionFlow] Subscription successful: \(purchase.productId)") + + // Create unique key for this purchase to prevent duplicate processing + let purchaseKey = "\(purchase.id)_\(purchase.transactionDate)" + + // Skip if we've already processed this exact purchase + if purchaseKey == processedPurchaseKey { + print("🔄 [SubscriptionFlow] Skipping already processed purchase: \(purchaseKey)") + return + } + + // Mark as processed + processedPurchaseKey = purchaseKey + print("📦 [SubscriptionFlow] Purchase fired immediately - no need to call getActiveSubscriptions()") // Log detailed purchase info @@ -573,6 +778,11 @@ struct SubscriptionFlowScreen: View { // Show the recent purchase recentPurchase = purchase + // Perform verification if method is selected + Task { + await verifyAndFinishPurchase(purchase) + } + // DO NOT call getActiveSubscriptions() here - it causes infinite rendering // The store's handlePurchaseUpdate already updates activeSubscriptions directly from purchase data } diff --git a/packages/apple/README.md b/packages/apple/README.md index 6242e9fc..9ab7c6a9 100644 --- a/packages/apple/README.md +++ b/packages/apple/README.md @@ -65,7 +65,7 @@ Add OpenIAP to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/hyodotdev/openiap.git", from: "1.2.23") + .package(url: "https://github.com/hyodotdev/openiap.git", from: "$version") ] ``` @@ -80,7 +80,7 @@ Or through Xcode: Add to your `Podfile`: ```ruby -pod 'openiap', '~> 1.2.23' +pod 'openiap', '~> $version' ``` Then run: @@ -89,6 +89,8 @@ Then run: pod install ``` +> 📌 **Latest Version**: Check [`openiap-versions.json`](../../openiap-versions.json) for the current version, or see the badges above. + ## 🚀 Quick Start OpenIAP provides multiple ways to integrate in-app purchases, from super simple one-liners to advanced control. Choose the approach that fits your needs! diff --git a/packages/apple/Sources/Models/OpenIapError.swift b/packages/apple/Sources/Models/OpenIapError.swift index 3b38c915..62a026bb 100644 --- a/packages/apple/Sources/Models/OpenIapError.swift +++ b/packages/apple/Sources/Models/OpenIapError.swift @@ -13,9 +13,13 @@ public extension PurchaseError { case .remoteError: return "Remote service error" case .networkError: return "Network connection error" case .serviceError: return "Store service error" - case .receiptFailed: return "Receipt validation failed" - case .receiptFinished: return "Receipt already finished" - case .receiptFinishedFailed: return "Receipt finish failed" + // Deprecated - use purchaseVerification* variants instead + case .receiptFailed: return "Purchase verification failed" + case .receiptFinished: return "Transaction already finished" + case .receiptFinishedFailed: return "Transaction finish failed" + case .purchaseVerificationFailed: return "Purchase verification failed" + case .purchaseVerificationFinished: return "Transaction already finished" + case .purchaseVerificationFinishFailed: return "Transaction finish failed" case .notPrepared: return "Billing is not prepared" case .notEnded: return "Billing connection not ended" case .alreadyOwned: return "Item already owned" diff --git a/packages/apple/Sources/Models/OpenIapSerialization.swift b/packages/apple/Sources/Models/OpenIapSerialization.swift index 534cebc1..98f195f3 100644 --- a/packages/apple/Sources/Models/OpenIapSerialization.swift +++ b/packages/apple/Sources/Models/OpenIapSerialization.swift @@ -113,8 +113,8 @@ public enum OpenIapSerialization { try decode(object: object, as: PurchaseOptions.self) } - public static func receiptValidationProps(from object: Any) throws -> ReceiptValidationProps { - try decode(object: object, as: ReceiptValidationProps.self) + public static func verifyPurchaseProps(from object: Any) throws -> VerifyPurchaseProps { + try decode(object: object, as: VerifyPurchaseProps.self) } // MARK: - Discount Helpers diff --git a/packages/apple/Sources/Models/ReceiptValidationCompat.swift b/packages/apple/Sources/Models/ReceiptValidationCompat.swift new file mode 100644 index 00000000..f0ec1738 --- /dev/null +++ b/packages/apple/Sources/Models/ReceiptValidationCompat.swift @@ -0,0 +1,11 @@ +// Compatibility aliases for legacy receipt validation APIs +// These map old ReceiptValidation* names to the new VerifyPurchase* types. + +@available(*, deprecated, message: "Use VerifyPurchaseProps instead") +public typealias ReceiptValidationProps = VerifyPurchaseProps + +@available(*, deprecated, message: "Use VerifyPurchaseResult instead") +public typealias ReceiptValidationResult = VerifyPurchaseResult + +@available(*, deprecated, message: "Use VerifyPurchaseResultIOS instead") +public typealias ReceiptValidationResultIOS = VerifyPurchaseResultIOS diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index b7d7a9a5..70806266 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -31,6 +31,9 @@ public enum ErrorCode: String, Codable, CaseIterable { case receiptFailed = "receipt-failed" case receiptFinished = "receipt-finished" case receiptFinishedFailed = "receipt-finished-failed" + case purchaseVerificationFailed = "purchase-verification-failed" + case purchaseVerificationFinished = "purchase-verification-finished" + case purchaseVerificationFinishFailed = "purchase-verification-finish-failed" case notPrepared = "not-prepared" case notEnded = "not-ended" case alreadyOwned = "already-owned" @@ -76,11 +79,17 @@ public enum ErrorCode: String, Codable, CaseIterable { case "service-error", "ServiceError": self = .serviceError case "receipt-failed", "ReceiptFailed": - self = .receiptFailed + self = .purchaseVerificationFailed // Legacy alias case "receipt-finished", "ReceiptFinished": self = .receiptFinished case "receipt-finished-failed", "ReceiptFinishedFailed": self = .receiptFinishedFailed + case "purchase-verification-failed", "PurchaseVerificationFailed": + self = .purchaseVerificationFailed + case "purchase-verification-finished", "PurchaseVerificationFinished": + self = .purchaseVerificationFinished + case "purchase-verification-finish-failed", "PurchaseVerificationFinishFailed": + self = .purchaseVerificationFinishFailed case "not-prepared", "NotPrepared": self = .notPrepared case "not-ended", "NotEnded": @@ -150,6 +159,33 @@ public enum IapEvent: String, Codable, CaseIterable { case userChoiceBillingAndroid = "user-choice-billing-android" } +/// Unified purchase states from IAPKit verification response. +public enum IapkitPurchaseState: String, Codable, CaseIterable { + /// User is entitled to the product (purchase is complete and active). + case entitled = "entitled" + /// Receipt is valid but still needs server acknowledgment. + case pendingAcknowledgment = "pending-acknowledgment" + /// Purchase is in progress or awaiting confirmation. + case pending = "pending" + /// Purchase was cancelled or refunded. + case canceled = "canceled" + /// Subscription or entitlement has expired. + case expired = "expired" + /// Consumable purchase is ready to be fulfilled. + case readyToConsume = "ready-to-consume" + /// Consumable item has been fulfilled/consumed. + case consumed = "consumed" + /// Purchase state could not be determined. + case unknown = "unknown" + /// Purchase receipt is not authentic (fraudulent or tampered). + case inauthentic = "inauthentic" +} + +public enum IapkitStore: String, Codable, CaseIterable { + case apple = "apple" + case google = "google" +} + public enum IapPlatform: String, Codable, CaseIterable { case ios = "ios" case android = "android" @@ -189,6 +225,10 @@ public enum PurchaseState: String, Codable, CaseIterable { case unknown = "unknown" } +public enum PurchaseVerificationProvider: String, Codable, CaseIterable { + case iapkit = "iapkit" +} + public enum SubscriptionOfferTypeIOS: String, Codable, CaseIterable { case introductory = "introductory" case promotional = "promotional" @@ -505,38 +545,6 @@ public struct PurchaseOfferIOS: Codable { public var type: String } -public struct ReceiptValidationResultAndroid: Codable { - public var autoRenewing: Bool - public var betaProduct: Bool - public var cancelDate: Double? - public var cancelReason: String? - public var deferredDate: Double? - public var deferredSku: String? - public var freeTrialEndDate: Double - public var gracePeriodEndDate: Double - public var parentProductId: String - public var productId: String - public var productType: String - public var purchaseDate: Double - public var quantity: Int - public var receiptId: String - public var renewalDate: Double - public var term: String - public var termSku: String - public var testTransaction: Bool -} - -public struct ReceiptValidationResultIOS: Codable { - /// Whether the receipt is valid - public var isValid: Bool - /// JWS representation - public var jwsRepresentation: String - /// Latest transaction if available - public var latestTransaction: Purchase? - /// Receipt data string - public var receiptData: String -} - public struct RefundResultIOS: Codable { public var message: String? public var status: String @@ -578,6 +586,14 @@ public enum RequestPurchaseResult { case purchases([Purchase]?) } +public struct RequestVerifyPurchaseWithIapkitResult: Codable { + /// Whether the purchase is valid (not falsified). + public var isValid: Bool + /// The current state of the purchase. + public var state: IapkitPurchaseState + public var store: IapkitStore +} + public struct SubscriptionInfoIOS: Codable { public var introductoryOffer: SubscriptionOfferIOS? public var promotionalOffers: [SubscriptionOfferIOS]? @@ -614,6 +630,44 @@ public struct UserChoiceBillingDetails: Codable { public var products: [String] } +public struct VerifyPurchaseResultAndroid: Codable { + public var autoRenewing: Bool + public var betaProduct: Bool + public var cancelDate: Double? + public var cancelReason: String? + public var deferredDate: Double? + public var deferredSku: String? + public var freeTrialEndDate: Double + public var gracePeriodEndDate: Double + public var parentProductId: String + public var productId: String + public var productType: String + public var purchaseDate: Double + public var quantity: Int + public var receiptId: String + public var renewalDate: Double + public var term: String + public var termSku: String + public var testTransaction: Bool +} + +public struct VerifyPurchaseResultIOS: Codable { + /// Whether the receipt is valid + public var isValid: Bool + /// JWS representation + public var jwsRepresentation: String + /// Latest transaction if available + public var latestTransaction: Purchase? + /// Receipt data string + public var receiptData: String +} + +public struct VerifyPurchaseWithProviderResult: Codable { + /// IAPKit verification results (can include Apple and Google entries) + public var iapkit: [RequestVerifyPurchaseWithIapkitResult] + public var provider: PurchaseVerificationProvider +} + public typealias VoidResult = Void // MARK: - Input Objects @@ -742,40 +796,6 @@ public struct PurchaseOptions: Codable { } } -public struct ReceiptValidationAndroidOptions: Codable { - public var accessToken: String - public var isSub: Bool? - public var packageName: String - public var productToken: String - - public init( - accessToken: String, - isSub: Bool? = nil, - packageName: String, - productToken: String - ) { - self.accessToken = accessToken - self.isSub = isSub - self.packageName = packageName - self.productToken = productToken - } -} - -public struct ReceiptValidationProps: Codable { - /// Android-specific validation options - public var androidOptions: ReceiptValidationAndroidOptions? - /// Product SKU to validate - public var sku: String - - public init( - androidOptions: ReceiptValidationAndroidOptions? = nil, - sku: String - ) { - self.androidOptions = androidOptions - self.sku = sku - } -} - public struct RequestPurchaseAndroidProps: Codable { /// Personalized offer flag public var isOfferPersonalized: Bool? @@ -983,6 +1003,94 @@ public struct RequestSubscriptionPropsByPlatforms: Codable { } } +public struct RequestVerifyPurchaseWithIapkitAppleProps: Codable { + /// The JWS token returned with the purchase response. + public var jws: String + + public init( + jws: String + ) { + self.jws = jws + } +} + +public struct RequestVerifyPurchaseWithIapkitGoogleProps: Codable { + /// The token provided to the user's device when the product or subscription was purchased. + public var purchaseToken: String + + public init( + purchaseToken: String + ) { + self.purchaseToken = purchaseToken + } +} + +public struct RequestVerifyPurchaseWithIapkitProps: Codable { + /// API key used for the Authorization header (Bearer {apiKey}). + public var apiKey: String? + /// Apple verification parameters. + public var apple: RequestVerifyPurchaseWithIapkitAppleProps? + /// Google verification parameters. + public var google: RequestVerifyPurchaseWithIapkitGoogleProps? + + public init( + apiKey: String? = nil, + apple: RequestVerifyPurchaseWithIapkitAppleProps? = nil, + google: RequestVerifyPurchaseWithIapkitGoogleProps? = nil + ) { + self.apiKey = apiKey + self.apple = apple + self.google = google + } +} + +public struct VerifyPurchaseAndroidOptions: Codable { + public var accessToken: String + public var isSub: Bool? + public var packageName: String + public var productToken: String + + public init( + accessToken: String, + isSub: Bool? = nil, + packageName: String, + productToken: String + ) { + self.accessToken = accessToken + self.isSub = isSub + self.packageName = packageName + self.productToken = productToken + } +} + +public struct VerifyPurchaseProps: Codable { + /// Android-specific validation options + public var androidOptions: VerifyPurchaseAndroidOptions? + /// Product SKU to validate + public var sku: String + + public init( + androidOptions: VerifyPurchaseAndroidOptions? = nil, + sku: String + ) { + self.androidOptions = androidOptions + self.sku = sku + } +} + +public struct VerifyPurchaseWithProviderProps: Codable { + public var iapkit: RequestVerifyPurchaseWithIapkitProps? + public var provider: PurchaseVerificationProvider + + public init( + iapkit: RequestVerifyPurchaseWithIapkitProps? = nil, + provider: PurchaseVerificationProvider + ) { + self.iapkit = iapkit + self.provider = provider + } +} + // MARK: - Unions public enum Product: Codable, ProductCommon { @@ -1280,9 +1388,9 @@ public enum Purchase: Codable, PurchaseCommon { } } -public enum ReceiptValidationResult: Codable { - case receiptValidationResultAndroid(ReceiptValidationResultAndroid) - case receiptValidationResultIos(ReceiptValidationResultIOS) +public enum VerifyPurchaseResult: Codable { + case verifyPurchaseResultAndroid(VerifyPurchaseResultAndroid) + case verifyPurchaseResultIos(VerifyPurchaseResultIOS) } // MARK: - Root Operations @@ -1343,9 +1451,11 @@ public protocol MutationResolver { /// Force a StoreKit sync for transactions (iOS 15+) func syncIOS() async throws -> Bool /// Validate purchase receipts with the configured providers - func validateReceipt(_ options: ReceiptValidationProps) async throws -> ReceiptValidationResult + func validateReceipt(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResult /// Verify purchases with the configured providers - func verifyPurchase(_ options: ReceiptValidationProps) async throws -> ReceiptValidationResult + func verifyPurchase(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResult + /// Verify purchases with a specific provider (e.g., IAPKit) + func verifyPurchaseWithProvider(_ options: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult } /// GraphQL root query operations. @@ -1385,7 +1495,7 @@ public protocol QueryResolver { /// Get StoreKit 2 subscription status details (iOS 15+) func subscriptionStatusIOS(_ sku: String) async throws -> [SubscriptionStatusIOS] /// Validate a receipt for a specific product - func validateReceiptIOS(_ options: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS + func validateReceiptIOS(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS } /// GraphQL root subscription operations. @@ -1424,8 +1534,9 @@ public typealias MutationRestorePurchasesHandler = () async throws -> Void public typealias MutationShowAlternativeBillingDialogAndroidHandler = () async throws -> Bool public typealias MutationShowManageSubscriptionsIOSHandler = () async throws -> [PurchaseIOS] public typealias MutationSyncIOSHandler = () async throws -> Bool -public typealias MutationValidateReceiptHandler = (_ options: ReceiptValidationProps) async throws -> ReceiptValidationResult -public typealias MutationVerifyPurchaseHandler = (_ options: ReceiptValidationProps) async throws -> ReceiptValidationResult +public typealias MutationValidateReceiptHandler = (_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResult +public typealias MutationVerifyPurchaseHandler = (_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResult +public typealias MutationVerifyPurchaseWithProviderHandler = (_ options: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult public struct MutationHandlers { public var acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler? @@ -1449,6 +1560,7 @@ public struct MutationHandlers { public var syncIOS: MutationSyncIOSHandler? public var validateReceipt: MutationValidateReceiptHandler? public var verifyPurchase: MutationVerifyPurchaseHandler? + public var verifyPurchaseWithProvider: MutationVerifyPurchaseWithProviderHandler? public init( acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler? = nil, @@ -1471,7 +1583,8 @@ public struct MutationHandlers { showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? = nil, syncIOS: MutationSyncIOSHandler? = nil, validateReceipt: MutationValidateReceiptHandler? = nil, - verifyPurchase: MutationVerifyPurchaseHandler? = nil + verifyPurchase: MutationVerifyPurchaseHandler? = nil, + verifyPurchaseWithProvider: MutationVerifyPurchaseWithProviderHandler? = nil ) { self.acknowledgePurchaseAndroid = acknowledgePurchaseAndroid self.beginRefundRequestIOS = beginRefundRequestIOS @@ -1494,6 +1607,7 @@ public struct MutationHandlers { self.syncIOS = syncIOS self.validateReceipt = validateReceipt self.verifyPurchase = verifyPurchase + self.verifyPurchaseWithProvider = verifyPurchaseWithProvider } } @@ -1516,7 +1630,7 @@ public typealias QueryIsEligibleForIntroOfferIOSHandler = (_ groupID: String) as public typealias QueryIsTransactionVerifiedIOSHandler = (_ sku: String) async throws -> Bool public typealias QueryLatestTransactionIOSHandler = (_ sku: String) async throws -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = (_ sku: String) async throws -> [SubscriptionStatusIOS] -public typealias QueryValidateReceiptIOSHandler = (_ options: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS +public typealias QueryValidateReceiptIOSHandler = (_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS public struct QueryHandlers { public var canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index c37253e7..bd579a20 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -551,11 +551,11 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } @available(*, deprecated, message: "Use verifyPurchase") - public func validateReceiptIOS(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS { - try await performValidateReceiptIOS(props) + public func validateReceiptIOS(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS { + try await performVerifyPurchaseIOS(props) } - private func performValidateReceiptIOS(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS { + private func performVerifyPurchaseIOS(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS { let receiptData = (try? await getReceiptDataIOS()) ?? "" var latestPurchase: Purchase? = nil var jws: String = "" @@ -573,7 +573,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { isValid = false } - return ReceiptValidationResultIOS( + return VerifyPurchaseResultIOS( isValid: isValid, jwsRepresentation: jws, latestTransaction: latestPurchase, @@ -582,13 +582,188 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } @available(*, deprecated, message: "Use verifyPurchase") - public func validateReceipt(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult { + public func validateReceipt(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { try await verifyPurchase(props) } - public func verifyPurchase(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult { - let iosResult = try await performValidateReceiptIOS(props) - return .receiptValidationResultIos(iosResult) + public func verifyPurchase(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { + let iosResult = try await performVerifyPurchaseIOS(props) + return .verifyPurchaseResultIos(iosResult) + } + + public func verifyPurchaseWithProvider(_ props: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult { + guard props.provider == .iapkit else { + throw makePurchaseError(code: .featureNotSupported, message: "Provider \(props.provider.rawValue) is not supported") + } + guard let iapkit = props.iapkit else { + throw makePurchaseError(code: .developerError, message: "Missing IAPKit verification parameters") + } + let results = try await verifyPurchaseWithIapkit(props: iapkit) + return VerifyPurchaseWithProviderResult( + iapkit: results, + provider: props.provider + ) + } + + // NOTE: This Apple module intentionally sends only Apple payloads to IAPKit. + // The buildIapkitPayload function has a .google branch for type completeness, + // but it is never invoked from this module. + private func verifyPurchaseWithIapkit(props: RequestVerifyPurchaseWithIapkitProps) async throws -> [RequestVerifyPurchaseWithIapkitResult] { + // URL is a constant and cannot fail, so force unwrap is safe + let url = URL(string: "https://api.iapkit.com/v1/purchase/verify")! + + // On Apple, only Apple verification is supported + guard props.apple != nil else { + throw makePurchaseError(code: .developerError, message: "IAPKit verification on Apple requires an apple payload") + } + let targets: [(store: IapkitStore, body: Data)] = [ + (.apple, try buildIapkitPayload(props: props, store: .apple)) + ] + + return try await withThrowingTaskGroup(of: RequestVerifyPurchaseWithIapkitResult.self) { group in + for target in targets { + group.addTask { [self] in + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let apiKey = props.apiKey, apiKey.isEmpty == false { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + request.httpBody = target.body + + // Log request details for debugging + OpenIapLog.debug("IAPKit request URL: \(url.absoluteString)") + if let requestBody = String(data: target.body, encoding: .utf8) { + // Truncate JWS for readability (keep first/last 50 chars) + let truncatedBody = requestBody.count > 200 + ? String(requestBody.prefix(100)) + "..." + String(requestBody.suffix(50)) + : requestBody + OpenIapLog.debug("IAPKit request body: \(truncatedBody)") + } + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw self.makePurchaseError(code: .networkError, message: "Invalid response") + } + guard (200...299).contains(httpResponse.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "" + OpenIapLog.warn("verifyPurchaseWithProvider failed (HTTP \(httpResponse.statusCode)): \(body)") + // Extract concise error message from IAPKit response + var errorMessage = "HTTP \(httpResponse.statusCode)" + if let jsonData = body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { + errorMessage = self.extractIapkitErrorMessage(from: json) ?? errorMessage + } + throw self.makePurchaseError(code: .receiptFailed, message: errorMessage) + } + + // Log raw response for debugging + let jsonString = String(data: data, encoding: .utf8) ?? "" + OpenIapLog.info("IAPKit raw response: \(jsonString)") + + // Parse manually to handle extra fields from IAPKit + // API response format: { "store": "apple", "isValid": true, "state": "PURCHASED" } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + OpenIapLog.warn("Failed to parse IAPKit verification response. Raw: \(jsonString)") + throw self.makePurchaseError(code: .receiptFailed, message: "Unable to parse verification response") + } + + // Check for error response format: { "errors": [{ "code": "...", "message": "..." }] } + if let errors = json["errors"] as? [[String: Any]], let firstError = errors.first { + let errorMessage = firstError["message"] as? String ?? "Unknown error" + let errorCode = firstError["code"] as? String ?? "unknown" + OpenIapLog.warn("IAPKit verification error: \(errorCode) - \(errorMessage)") + throw self.makePurchaseError(code: .receiptFailed, message: errorMessage) + } + + let isValid = (json["isValid"] as? Bool) ?? false + let stateString = json["state"] as? String ?? "UNKNOWN" + // IAPKit API returns UPPER_SNAKE_CASE (e.g., "PURCHASED", "PENDING_ACKNOWLEDGMENT") + // Swift enum expects lower-kebab-case (e.g., "purchased", "pending-acknowledgment") + let normalizedState = stateString.lowercased().replacingOccurrences(of: "_", with: "-") + let parsedState = IapkitPurchaseState(rawValue: normalizedState) ?? .unknown + let storeString = json["store"] as? String + let parsedStore = storeString.flatMap { IapkitStore(rawValue: $0) } ?? target.store + OpenIapLog.info("IAPKit verification result: store=\(parsedStore.rawValue), isValid=\(isValid), state=\(parsedState.rawValue)") + return RequestVerifyPurchaseWithIapkitResult(isValid: isValid, state: parsedState, store: parsedStore) + } + } + + var results: [RequestVerifyPurchaseWithIapkitResult] = [] + for try await item in group { + results.append(item) + } + return results + } + } + + private struct IapkitApplePayload: Codable { + let store: IapkitStore + let jws: String + } + + private struct IapkitGooglePayload: Codable { + let store: IapkitStore + let purchaseToken: String + } + + private func buildIapkitPayload(props: RequestVerifyPurchaseWithIapkitProps, store: IapkitStore) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + switch store { + case .apple: + guard let apple = props.apple else { + throw makePurchaseError(code: .developerError, message: "Apple verification parameters are required") + } + guard apple.jws.isEmpty == false else { + throw makePurchaseError(code: .developerError, message: "JWS is required") + } + let payload = IapkitApplePayload( + store: store, + jws: apple.jws + ) + return try encoder.encode(payload) + case .google: + guard let google = props.google else { + throw makePurchaseError(code: .developerError, message: "Google verification parameters are required") + } + guard google.purchaseToken.isEmpty == false else { + throw makePurchaseError(code: .developerError, message: "purchaseToken is required") + } + let payload = IapkitGooglePayload( + store: store, + purchaseToken: google.purchaseToken + ) + return try encoder.encode(payload) + } + } + + /// Extract concise error message from IAPKit error response. + /// IAPKit returns nested error structures - we extract the deepest originalError for clarity. + private func extractIapkitErrorMessage(from json: [String: Any]) -> String? { + // Try to get details.originalError first (deepest level) + if let details = json["details"] as? [String: Any], + let originalError = details["originalError"] as? String { + // originalError might be a JSON string, try to parse it + if let data = originalError.data(using: .utf8), + let nested = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return extractIapkitErrorMessage(from: nested) ?? originalError + } + return originalError + } + + // Try errors array format: { "errors": [{ "message": "..." }] } + if let errors = json["errors"] as? [[String: Any]], let firstError = errors.first { + return extractIapkitErrorMessage(from: firstError) + } + + // Try message field, but avoid the verbose nested JSON string + if let message = json["message"] as? String, !message.contains("{\"error\"") { + return message + } + + // Fallback to error code + return json["error"] as? String } // MARK: - Store Information @@ -1166,9 +1341,13 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { case .remoteError: return "Remote service error" case .networkError: return "Network connection error" case .serviceError: return "Store service error" - case .receiptFailed: return "Receipt validation failed" - case .receiptFinished: return "Receipt already finished" - case .receiptFinishedFailed: return "Receipt finish failed" + // Deprecated - use purchaseVerification* variants instead + case .receiptFailed: return "Purchase verification failed" + case .receiptFinished: return "Transaction already finished" + case .receiptFinishedFailed: return "Transaction finish failed" + case .purchaseVerificationFailed: return "Purchase verification failed" + case .purchaseVerificationFinished: return "Transaction already finished" + case .purchaseVerificationFinishFailed: return "Transaction finish failed" case .notPrepared: return "Billing is not prepared" case .notEnded: return "Billing connection not ended" case .alreadyOwned: return "Item already owned" diff --git a/packages/apple/Sources/OpenIapProtocol.swift b/packages/apple/Sources/OpenIapProtocol.swift index ba77be86..083b799e 100644 --- a/packages/apple/Sources/OpenIapProtocol.swift +++ b/packages/apple/Sources/OpenIapProtocol.swift @@ -42,10 +42,11 @@ public protocol OpenIapModuleProtocol { // Validation func getReceiptDataIOS() async throws -> String? @available(*, deprecated, message: "Use verifyPurchase") - func validateReceiptIOS(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS + func validateReceiptIOS(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS @available(*, deprecated, message: "Use verifyPurchase") - func validateReceipt(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult - func verifyPurchase(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult + func validateReceipt(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult + func verifyPurchase(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult + func verifyPurchaseWithProvider(_ props: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult // Store Information func getStorefrontIOS() async throws -> String @@ -74,3 +75,29 @@ public protocol OpenIapModuleProtocol { func removeListener(_ subscription: Subscription) func removeAllListeners() } + +// Backward compatibility for legacy receipt validation APIs +public extension OpenIapModuleProtocol { + /// Default implementation that throws. Override in your module to provide actual verification. + func verifyPurchaseWithProvider(_ props: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult { + throw PurchaseError(code: .featureNotSupported, message: "verifyPurchaseWithProvider not supported") + } + + @available(*, deprecated, message: "Use verifyPurchase instead") + func validateReceiptIOS(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS { + let result = try await verifyPurchase(props) + if case let .verifyPurchaseResultIos(ios) = result { + return ios + } + throw PurchaseError( + code: .featureNotSupported, + message: "Expected iOS validation result", + productId: props.sku + ) + } + + @available(*, deprecated, message: "Use verifyPurchase instead") + func validateReceipt(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { + try await verifyPurchase(props) + } +} diff --git a/packages/apple/Sources/OpenIapStore.swift b/packages/apple/Sources/OpenIapStore.swift index da27b998..3a945cb3 100644 --- a/packages/apple/Sources/OpenIapStore.swift +++ b/packages/apple/Sources/OpenIapStore.swift @@ -369,13 +369,13 @@ public final class OpenIapStore: ObservableObject { // MARK: - Validation & Metadata @available(*, deprecated, message: "Use verifyPurchase") - public func validateReceipt(sku: String) async throws -> ReceiptValidationResultIOS { + public func validateReceipt(sku: String) async throws -> VerifyPurchaseResultIOS { try await verifyPurchase(sku: sku) } - public func verifyPurchase(sku: String) async throws -> ReceiptValidationResultIOS { - let result = try await module.verifyPurchase(ReceiptValidationProps(sku: sku)) - if case let .receiptValidationResultIos(iosResult) = result { + public func verifyPurchase(sku: String) async throws -> VerifyPurchaseResultIOS { + let result = try await module.verifyPurchase(VerifyPurchaseProps(sku: sku)) + if case let .verifyPurchaseResultIos(iosResult) = result { return iosResult } throw PurchaseError( @@ -385,6 +385,11 @@ public final class OpenIapStore: ObservableObject { ) } + public func verifyPurchaseWithProvider(_ props: VerifyPurchaseWithProviderProps) async throws -> [RequestVerifyPurchaseWithIapkitResult] { + let result = try await module.verifyPurchaseWithProvider(props) + return result.iapkit + } + public func getPromotedProductIOS() async throws -> ProductIOS? { try await module.getPromotedProductIOS() } diff --git a/packages/apple/Tests/OpenIapTests.swift b/packages/apple/Tests/OpenIapTests.swift index 3dd86985..308c0eb8 100644 --- a/packages/apple/Tests/OpenIapTests.swift +++ b/packages/apple/Tests/OpenIapTests.swift @@ -267,7 +267,8 @@ final class OpenIapTests: XCTestCase { ("remote-error", "RemoteError", .remoteError), ("network-error", "NetworkError", .networkError), ("service-error", "ServiceError", .serviceError), - ("receipt-failed", "ReceiptFailed", .receiptFailed), + ("purchase-verification-failed", "PurchaseVerificationFailed", .purchaseVerificationFailed), + ("receipt-failed", "ReceiptFailed", .purchaseVerificationFailed), // Legacy alias ("not-prepared", "NotPrepared", .notPrepared), ("already-owned", "AlreadyOwned", .alreadyOwned), ("developer-error", "DeveloperError", .developerError), diff --git a/packages/apple/Tests/OpenIapTests/ValidateReceiptTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift similarity index 80% rename from packages/apple/Tests/OpenIapTests/ValidateReceiptTests.swift rename to packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift index 57b267e1..1f0af3ab 100644 --- a/packages/apple/Tests/OpenIapTests/ValidateReceiptTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift @@ -2,17 +2,17 @@ import XCTest @testable import OpenIAP @available(iOS 15.0, macOS 14.0, *) -final class ValidateReceiptTests: XCTestCase { +final class VerifyPurchaseTests: XCTestCase { @MainActor func testVerifyPurchaseReturnsIOSResult() async throws { - let iosResult = ReceiptValidationResultIOS( + let iosResult = VerifyPurchaseResultIOS( isValid: true, jwsRepresentation: "jws-token", latestTransaction: nil, receiptData: "base64-receipt" ) - let module = FakeOpenIapModule(validateResult: .receiptValidationResultIos(iosResult)) + let module = FakeOpenIapModule(validateResult: .verifyPurchaseResultIos(iosResult)) let store = OpenIapStore(module: module) let result = try await store.verifyPurchase(sku: "test.sku") @@ -24,7 +24,7 @@ final class ValidateReceiptTests: XCTestCase { @MainActor func testVerifyPurchaseThrowsForAndroidVariant() async { - let androidPayload = ReceiptValidationResultAndroid( + let androidPayload = VerifyPurchaseResultAndroid( autoRenewing: false, betaProduct: false, cancelDate: nil, @@ -44,7 +44,7 @@ final class ValidateReceiptTests: XCTestCase { termSku: "plan-monthly", testTransaction: false ) - let module = FakeOpenIapModule(validateResult: .receiptValidationResultAndroid(androidPayload)) + let module = FakeOpenIapModule(validateResult: .verifyPurchaseResultAndroid(androidPayload)) let store = OpenIapStore(module: module) do { @@ -60,10 +60,18 @@ final class ValidateReceiptTests: XCTestCase { @available(iOS 15.0, macOS 14.0, *) private final class FakeOpenIapModule: OpenIapModuleProtocol { - private let validateResult: ReceiptValidationResult - - init(validateResult: ReceiptValidationResult) { + private let validateResult: VerifyPurchaseResult + private let providerResult: VerifyPurchaseWithProviderResult + + init( + validateResult: VerifyPurchaseResult, + providerResult: VerifyPurchaseWithProviderResult = VerifyPurchaseWithProviderResult( + iapkit: [], + provider: .iapkit + ) + ) { self.validateResult = validateResult + self.providerResult = providerResult } // MARK: - Connection Management @@ -91,21 +99,25 @@ private final class FakeOpenIapModule: OpenIapModuleProtocol { // MARK: - Validation func getReceiptDataIOS() async throws -> String? { "receipt" } - func validateReceiptIOS(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS { - guard case let .receiptValidationResultIos(ios) = validateResult else { + func validateReceiptIOS(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS { + guard case let .verifyPurchaseResultIos(ios) = validateResult else { throw PurchaseError(code: .featureNotSupported, message: "Android validation not supported", productId: props.sku) } return ios } - func validateReceipt(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult { + func validateReceipt(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { validateResult } - func verifyPurchase(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult { + func verifyPurchase(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { validateResult } + func verifyPurchaseWithProvider(_ props: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult { + providerResult + } + // MARK: - Store Information func getStorefrontIOS() async throws -> String { "US" } func getAppTransactionIOS() async throws -> AppTransaction? { nil } diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift new file mode 100644 index 00000000..c3c105f6 --- /dev/null +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift @@ -0,0 +1,173 @@ +import XCTest +@testable import OpenIAP + +@available(iOS 15.0, macOS 14.0, *) +final class VerifyPurchaseWithProviderTests: XCTestCase { + + @MainActor + func testStoreReturnsIapkitResult() async throws { + let iapkitResult = RequestVerifyPurchaseWithIapkitResult( + isValid: true, + state: .entitled, + store: .apple + ) + let module = FakeVerifyPurchaseModule( + validateResult: VerifyPurchaseResult.verifyPurchaseResultIos( + VerifyPurchaseResultIOS( + isValid: true, + jwsRepresentation: "", + latestTransaction: nil, + receiptData: "" + ) + ), + providerResult: VerifyPurchaseWithProviderResult( + iapkit: [iapkitResult], + provider: .iapkit + ) + ) + let store = OpenIapStore(module: module) + let props = VerifyPurchaseWithProviderProps( + iapkit: RequestVerifyPurchaseWithIapkitProps( + apiKey: "secret", + apple: RequestVerifyPurchaseWithIapkitAppleProps( + jws: "jws-token" + ), + google: nil + ), + provider: .iapkit + ) + + let results = try await store.verifyPurchaseWithProvider(props) + + XCTAssertEqual(1, results.count) + XCTAssertEqual(IapkitStore.apple, results.first?.store) + XCTAssertEqual(true, results.first?.isValid) + XCTAssertEqual(.entitled, results.first?.state) + } + + @MainActor + func testStoreReturnsEmptyArrayWhenProviderResultIsEmpty() async throws { + let module = FakeVerifyPurchaseModule( + validateResult: VerifyPurchaseResult.verifyPurchaseResultIos( + VerifyPurchaseResultIOS( + isValid: false, + jwsRepresentation: "", + latestTransaction: nil, + receiptData: "" + ) + ), + providerResult: VerifyPurchaseWithProviderResult( + iapkit: [], + provider: .iapkit + ) + ) + let store = OpenIapStore(module: module) + let props = VerifyPurchaseWithProviderProps( + iapkit: RequestVerifyPurchaseWithIapkitProps( + apiKey: nil, + apple: RequestVerifyPurchaseWithIapkitAppleProps( + jws: "jws-token" + ), + google: nil + ), + provider: .iapkit + ) + + let result = try await store.verifyPurchaseWithProvider(props) + + XCTAssertEqual(0, result.count) + } +} + +@available(iOS 15.0, macOS 14.0, *) +private final class FakeVerifyPurchaseModule: OpenIapModuleProtocol { + private let validateResult: VerifyPurchaseResult + private let providerResult: VerifyPurchaseWithProviderResult + + init(validateResult: VerifyPurchaseResult, providerResult: VerifyPurchaseWithProviderResult) { + self.validateResult = validateResult + self.providerResult = providerResult + } + + // MARK: - Connection Management + func initConnection() async throws -> Bool { true } + func endConnection() async throws -> Bool { true } + + // MARK: - Product Management + func fetchProducts(_ params: ProductRequest) async throws -> FetchProductsResult { .products(nil) } + func getPromotedProductIOS() async throws -> ProductIOS? { nil } + + // MARK: - Purchase Management + func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? { nil } + func requestPurchaseOnPromotedProductIOS() async throws -> Bool { false } + func restorePurchases() async throws -> Void { () } + func getAvailablePurchases(_ options: PurchaseOptions?) async throws -> [Purchase] { [] } + + // MARK: - Transaction Management + func finishTransaction(purchase: PurchaseInput, isConsumable: Bool?) async throws -> Void { () } + func getPendingTransactionsIOS() async throws -> [PurchaseIOS] { [] } + func clearTransactionIOS() async throws -> Bool { true } + func isTransactionVerifiedIOS(sku: String) async throws -> Bool { false } + func getTransactionJwsIOS(sku: String) async throws -> String? { nil } + func currentEntitlementIOS(sku: String) async throws -> PurchaseIOS? { nil } + func latestTransactionIOS(sku: String) async throws -> PurchaseIOS? { nil } + + // MARK: - Validation + func getReceiptDataIOS() async throws -> String? { "receipt" } + func validateReceiptIOS(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS { + guard case let .verifyPurchaseResultIos(ios) = validateResult else { + throw PurchaseError(code: .featureNotSupported, message: "Expected iOS validation result", productId: props.sku) + } + return ios + } + + func validateReceipt(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { + validateResult + } + + func verifyPurchase(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { + validateResult + } + + func verifyPurchaseWithProvider(_ props: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult { + providerResult + } + + // MARK: - Store Information + func getStorefrontIOS() async throws -> String { "US" } + func getAppTransactionIOS() async throws -> AppTransaction? { nil } + + // MARK: - Subscription Management + func getActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> [ActiveSubscription] { [] } + func hasActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> Bool { false } + func subscriptionStatusIOS(sku: String) async throws -> [SubscriptionStatusIOS] { [] } + func isEligibleForIntroOfferIOS(groupID: String) async throws -> Bool { true } + + // MARK: - Refunds (iOS 15+) + func beginRefundRequestIOS(sku: String) async throws -> String? { nil } + + // MARK: - Misc + func syncIOS() async throws -> Bool { true } + func presentCodeRedemptionSheetIOS() async throws -> Bool { true } + func showManageSubscriptionsIOS() async throws -> [PurchaseIOS] { [] } + func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void { () } + + // MARK: - Event Listeners + func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener) -> Subscription { + Subscription(eventType: .purchaseUpdated) + } + + func purchaseErrorListener(_ listener: @escaping PurchaseErrorListener) -> Subscription { + Subscription(eventType: .purchaseError) + } + + func promotedProductListenerIOS(_ listener: @escaping PromotedProductListener) -> Subscription { + Subscription(eventType: .promotedProductIos) + } + + func removeListener(_ subscription: Subscription) { + subscription.onRemove?() + } + + func removeAllListeners() {} +} diff --git a/packages/docs/package.json b/packages/docs/package.json index c6e179f7..e6a9afb2 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,7 +1,7 @@ { "name": "@hyodotdev/openiap-docs", "private": true, - "version": "1.2.2", + "version": "1.0.0", "type": "module", "scripts": { "dev": "bunx vite", diff --git a/packages/docs/public/examples/1. [Android] Example.png b/packages/docs/public/examples/1. [Android] Example.png new file mode 100644 index 00000000..26b40646 Binary files /dev/null and b/packages/docs/public/examples/1. [Android] Example.png differ diff --git a/packages/docs/public/examples/1. [IOS] Example.png b/packages/docs/public/examples/1. [IOS] Example.png new file mode 100644 index 00000000..a1838742 Binary files /dev/null and b/packages/docs/public/examples/1. [IOS] Example.png differ diff --git a/packages/docs/public/examples/2. [Android] Purchase Flow.png b/packages/docs/public/examples/2. [Android] Purchase Flow.png new file mode 100644 index 00000000..0bc718f5 Binary files /dev/null and b/packages/docs/public/examples/2. [Android] Purchase Flow.png differ diff --git a/packages/docs/public/examples/2. [IOS] Purchase Flow.png b/packages/docs/public/examples/2. [IOS] Purchase Flow.png new file mode 100644 index 00000000..24dbb3cd Binary files /dev/null and b/packages/docs/public/examples/2. [IOS] Purchase Flow.png differ diff --git a/packages/docs/public/examples/3. [Android] Subscription Flow.png b/packages/docs/public/examples/3. [Android] Subscription Flow.png new file mode 100644 index 00000000..5f483d2a Binary files /dev/null and b/packages/docs/public/examples/3. [Android] Subscription Flow.png differ diff --git a/packages/docs/public/examples/3. [IOS] Subscription Flow Upgrade.png b/packages/docs/public/examples/3. [IOS] Subscription Flow Upgrade.png new file mode 100644 index 00000000..5362867a Binary files /dev/null and b/packages/docs/public/examples/3. [IOS] Subscription Flow Upgrade.png differ diff --git a/packages/docs/public/examples/4. [Android] Subscription Flow Upgrade.png b/packages/docs/public/examples/4. [Android] Subscription Flow Upgrade.png new file mode 100644 index 00000000..02bee870 Binary files /dev/null and b/packages/docs/public/examples/4. [Android] Subscription Flow Upgrade.png differ diff --git a/packages/docs/public/examples/4. [IOS] Available Purchases.png b/packages/docs/public/examples/4. [IOS] Available Purchases.png new file mode 100644 index 00000000..b919db8a Binary files /dev/null and b/packages/docs/public/examples/4. [IOS] Available Purchases.png differ diff --git a/packages/docs/public/examples/5. [Android] Available Purchases.png b/packages/docs/public/examples/5. [Android] Available Purchases.png new file mode 100644 index 00000000..f07d0f1d Binary files /dev/null and b/packages/docs/public/examples/5. [Android] Available Purchases.png differ diff --git a/packages/docs/public/examples/5. [IOS] Offer Code.png b/packages/docs/public/examples/5. [IOS] Offer Code.png new file mode 100644 index 00000000..7f0f11d6 Binary files /dev/null and b/packages/docs/public/examples/5. [IOS] Offer Code.png differ diff --git a/packages/docs/public/examples/6. [Android] Redeem Offer Code.png b/packages/docs/public/examples/6. [Android] Redeem Offer Code.png new file mode 100644 index 00000000..0e96a5b0 Binary files /dev/null and b/packages/docs/public/examples/6. [Android] Redeem Offer Code.png differ diff --git a/packages/docs/public/examples/6. [IOS] Alternative Billing.png b/packages/docs/public/examples/6. [IOS] Alternative Billing.png new file mode 100644 index 00000000..930ed241 Binary files /dev/null and b/packages/docs/public/examples/6. [IOS] Alternative Billing.png differ diff --git a/packages/docs/src/pages/docs.tsx b/packages/docs/src/pages/docs.tsx index a08c46ea..327fc642 100644 --- a/packages/docs/src/pages/docs.tsx +++ b/packages/docs/src/pages/docs.tsx @@ -16,6 +16,7 @@ import SubscriptionUpgradeDowngrade from './docs/features/subscription-upgrade-d import IOSSetup from './docs/ios-setup'; import AndroidSetup from './docs/android-setup'; import HorizonSetup from './docs/horizon-setup'; +import Example from './docs/example'; import Announcements from './docs/updates/announcements'; import Notes from './docs/updates/notes'; import Versions from './docs/updates/versions'; @@ -139,6 +140,15 @@ function Docs() { items={[{ to: '/docs/horizon-setup', label: 'Horizon OS' }]} onItemClick={closeSidebar} /> +
  • + (isActive ? 'active' : '')} + onClick={closeSidebar} + > + Example + +
  • Features