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
@@ -250,6 +260,7 @@ function Docs() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/packages/docs/src/pages/docs/apis.tsx b/packages/docs/src/pages/docs/apis.tsx
index 3be564b4..bf0e27eb 100644
--- a/packages/docs/src/pages/docs/apis.tsx
+++ b/packages/docs/src/pages/docs/apis.tsx
@@ -171,12 +171,16 @@ await FlutterInappPurchase.instance.initConnection();
// With user choice billing
await FlutterInappPurchase.instance.initConnection(
- alternativeBillingModeAndroid: AlternativeBillingModeAndroid.userChoice,
+ config: InitConnectionConfig(
+ alternativeBillingModeAndroid: AlternativeBillingModeAndroid.userChoice,
+ ),
);
// With alternative billing only
await FlutterInappPurchase.instance.initConnection(
- alternativeBillingModeAndroid: AlternativeBillingModeAndroid.alternativeOnly,
+ config: InitConnectionConfig(
+ alternativeBillingModeAndroid: AlternativeBillingModeAndroid.alternativeOnly,
+ ),
);`}
),
}}
@@ -591,7 +595,7 @@ if (subscription?.renewalInfoIOS?.willAutoRenew === false) {
try {
await requestPurchase({
- params: { ios: { sku: 'premium_monthly' } },
+ request: { ios: { sku: 'premium_monthly' } },
type: 'subs'
});
} catch (error) {
@@ -1120,24 +1124,24 @@ Future deepLinkToSubscriptions({
{{
typescript: (
{`// Function signature
-verifyPurchase(options: ReceiptValidationProps): Promise`}
+verifyPurchase(options: VerifyPurchaseProps): Promise`}
),
swift: (
{`// Function signature
func verifyPurchase(
- options: ReceiptValidationProps
-) async throws -> ReceiptValidationResult`}
+ options: VerifyPurchaseProps
+) async throws -> VerifyPurchaseResult`}
),
kotlin: (
{`// Function signature
suspend fun verifyPurchase(
- options: ReceiptValidationProps
-): ReceiptValidationResult`}
+ options: VerifyPurchaseProps
+): VerifyPurchaseResult`}
),
dart: (
{`// Function signature
-Future verifyPurchase(
- ReceiptValidationProps options,
+Future verifyPurchase(
+ VerifyPurchaseProps options,
);`}
),
}}
@@ -1145,11 +1149,11 @@ Future verifyPurchase(
See:{' '}
- ReceiptValidationProps
+ VerifyPurchaseProps
,{' '}
- ReceiptValidationResult
+ VerifyPurchaseResult
Verifies purchases with the appropriate validation service.
@@ -1168,6 +1172,176 @@ Future verifyPurchase(
can validate against the Google Play developer API.
+
+ verifyPurchaseWithProvider
+
+
+ Verify a purchase using a specific provider like{' '}
+
+ IAPKit
+
+ . This method sends purchase data directly to the provider's API for
+ server-side validation.
+
+
+ {{
+ typescript: (
+ {`// Function signature
+verifyPurchaseWithProvider(
+ props: VerifyPurchaseWithProviderProps
+): Promise
+
+// Props
+interface VerifyPurchaseWithProviderProps {
+ provider: PurchaseVerificationProvider; // Currently: 'iapkit'
+ iapkit?: RequestVerifyPurchaseWithIapkitProps;
+}
+
+interface RequestVerifyPurchaseWithIapkitProps {
+ apiKey?: string; // API key for Authorization header
+ apple?: { jws: string }; // iOS: JWS token from purchase
+ google?: { purchaseToken: string }; // Android: purchase token
+}
+
+// Result
+interface VerifyPurchaseWithProviderResult {
+ provider: PurchaseVerificationProvider;
+ iapkit: RequestVerifyPurchaseWithIapkitResult[];
+}
+
+interface RequestVerifyPurchaseWithIapkitResult {
+ isValid: boolean; // Whether the purchase is valid
+ state: IapkitPurchaseState; // Purchase state
+ store: IapkitStore; // 'apple' | 'google'
+}
+
+// IapkitPurchaseState values:
+// 'entitled' | 'pending-acknowledgment' | 'pending' | 'canceled' |
+// 'expired' | 'ready-to-consume' | 'consumed' | 'unknown' | 'inauthentic'`}
+ ),
+ swift: (
+ {`// Function signature
+func verifyPurchaseWithProvider(
+ _ props: VerifyPurchaseWithProviderProps
+) async throws -> VerifyPurchaseWithProviderResult
+
+// Usage
+let props = VerifyPurchaseWithProviderProps(
+ iapkit: RequestVerifyPurchaseWithIapkitProps(
+ apiKey: "your-iapkit-api-key",
+ apple: RequestVerifyPurchaseWithIapkitAppleProps(
+ jws: purchase.jwsRepresentationIOS ?? ""
+ ),
+ google: nil
+ ),
+ provider: .iapkit
+)
+
+let result = try await store.verifyPurchaseWithProvider(props)
+
+for item in result.iapkit {
+ if item.isValid && item.state == .entitled {
+ // Grant entitlement
+ }
+}`}
+ ),
+ kotlin: (
+ {`// Function signature
+suspend fun verifyPurchaseWithProvider(
+ props: VerifyPurchaseWithProviderProps
+): VerifyPurchaseWithProviderResult
+
+// Usage
+val props = VerifyPurchaseWithProviderProps(
+ iapkit = RequestVerifyPurchaseWithIapkitProps(
+ apiKey = "your-iapkit-api-key",
+ apple = null,
+ google = RequestVerifyPurchaseWithIapkitGoogleProps(
+ purchaseToken = purchase.purchaseToken
+ )
+ ),
+ provider = PurchaseVerificationProvider.Iapkit
+)
+
+val result = module.verifyPurchaseWithProvider(props)
+
+result.iapkit.forEach { item ->
+ if (item.isValid && item.state == IapkitPurchaseState.Entitled) {
+ // Grant entitlement
+ }
+}`}
+ ),
+ dart: (
+ {`// Function signature
+Future verifyPurchaseWithProvider(
+ VerifyPurchaseWithProviderProps props,
+);
+
+// Usage
+final props = VerifyPurchaseWithProviderProps(
+ provider: PurchaseVerificationProvider.iapkit,
+ iapkit: RequestVerifyPurchaseWithIapkitProps(
+ apiKey: 'your-iapkit-api-key',
+ apple: RequestVerifyPurchaseWithIapkitAppleProps(
+ jws: purchase.jwsRepresentationIOS ?? '',
+ ),
+ ),
+);
+
+final result = await iap.verifyPurchaseWithProvider(props);
+
+for (final item in result.iapkit) {
+ if (item.isValid && item.state == IapkitPurchaseState.entitled) {
+ // Grant entitlement
+ }
+}`}
+ ),
+ }}
+
+
+ See:{' '}
+
+ VerifyPurchaseWithProviderProps
+
+ ,{' '}
+
+ VerifyPurchaseWithProviderResult
+
+
+
+ IAPKit Purchase States:
+
+
+
+ entitled - User is entitled to the product
+
+
+ pending-acknowledgment - Purchase needs acknowledgment
+ (Android)
+
+
+ pending - Purchase is pending
+
+
+ canceled - Purchase was canceled
+
+
+ expired - Subscription has expired
+
+
+ ready-to-consume - Consumable ready for consumption
+
+
+ consumed - Consumable has been consumed
+
+
+ unknown - Unknown state
+
+
+ inauthentic - Purchase failed authenticity check
+
+
+
Purchase Identifier Usage
@@ -1957,9 +2131,13 @@ const handlePurchase = async (basePlanId: string) => {
purchasedBasePlanId = basePlanId;
await requestPurchase({
- android: {
- skus: [subscriptionGroupId],
- subscriptionOffers: [{ sku: subscriptionGroupId, offerToken: offer.offerToken }],
+ request: {
+ android: {
+ skus: [subscriptionGroupId],
+ subscriptionOffers: [
+ { sku: subscriptionGroupId, offerToken: offer.offerToken },
+ ],
+ },
},
type: 'subs',
});
diff --git a/packages/docs/src/pages/docs/errors.tsx b/packages/docs/src/pages/docs/errors.tsx
index a8bc0193..0515de93 100644
--- a/packages/docs/src/pages/docs/errors.tsx
+++ b/packages/docs/src/pages/docs/errors.tsx
@@ -253,23 +253,23 @@ function Errors() {
- ReceiptFailed
+ PurchaseVerificationFailed
- Receipt validation failed
- Check receipt validation logic, retry validation
+ Purchase verification failed
+ Check verification logic, retry validation
- ReceiptFinished
+ TransactionFinished
- Receipt already processed/finished
+ Transaction already processed/finished
Transaction already completed, check records
- ReceiptFinishedFailed
+ TransactionFinishFailed
- Failed to finish receipt processing
+ Failed to finish transaction processing
Check transaction state and retry
@@ -425,8 +425,8 @@ function Errors() {
5
- ReceiptFailed
- Receipt validation failed
+ PurchaseVerificationFailed
+ Purchase verification failed
6
@@ -516,9 +516,9 @@ function Errors() {
RemoteError = 'E_REMOTE_ERROR',
NetworkError = 'E_NETWORK_ERROR',
ServiceError = 'E_SERVICE_ERROR',
- ReceiptFailed = 'E_RECEIPT_FAILED',
- ReceiptFinished = 'E_RECEIPT_FINISHED',
- ReceiptFinishedFailed = 'E_RECEIPT_FINISHED_FAILED',
+ PurchaseVerificationFailed = 'E_PURCHASE_VERIFICATION_FAILED',
+ TransactionFinished = 'E_TRANSACTION_FINISHED',
+ TransactionFinishFailed = 'E_TRANSACTION_FINISH_FAILED',
NotPrepared = 'E_NOT_PREPARED',
NotEnded = 'E_NOT_ENDED',
AlreadyOwned = 'E_ALREADY_OWNED',
@@ -554,9 +554,9 @@ function Errors() {
case remoteError
case networkError
case serviceError
- case receiptFailed
- case receiptFinished
- case receiptFinishedFailed
+ case purchaseVerificationFailed
+ case transactionFinished
+ case transactionFinishFailed
case notPrepared
case notEnded
case alreadyOwned
@@ -592,9 +592,9 @@ function Errors() {
RemoteError,
NetworkError,
ServiceError,
- ReceiptFailed,
- ReceiptFinished,
- ReceiptFinishedFailed,
+ PurchaseVerificationFailed,
+ TransactionFinished,
+ TransactionFinishFailed,
NotPrepared,
NotEnded,
AlreadyOwned,
@@ -630,9 +630,9 @@ function Errors() {
remoteError,
networkError,
serviceError,
- receiptFailed,
- receiptFinished,
- receiptFinishedFailed,
+ purchaseVerificationFailed,
+ transactionFinished,
+ transactionFinishFailed,
notPrepared,
notEnded,
alreadyOwned,
@@ -678,7 +678,7 @@ function Errors() {
test.purchase.failed@example.com
- ReceiptFailed
+ PurchaseVerificationFailed
test.purchase.cancelled@example.com
diff --git a/packages/docs/src/pages/docs/example.tsx b/packages/docs/src/pages/docs/example.tsx
new file mode 100644
index 00000000..b3f281ef
--- /dev/null
+++ b/packages/docs/src/pages/docs/example.tsx
@@ -0,0 +1,801 @@
+import PlatformTabs from '../../components/PlatformTabs';
+import SEO from '../../components/SEO';
+
+function Example() {
+ return (
+
+
+
Example
+
+ OpenIAP provides example applications for both iOS and Android that
+ demonstrate the complete in-app purchase lifecycle.
+
+
+
+
+ Features
+
+ #
+
+
+
+
+ Purchase Flow - Buy consumable and non-consumable
+ products with verification options
+
+
+ Subscription Flow - Subscribe to auto-renewable
+ subscriptions with upgrade/downgrade support
+
+
+ My Purchases - View and restore previously
+ purchased items
+
+
+ Offer Code - Redeem promotional offer codes (iOS
+ only)
+
+
+ Alternative Billing - Test user-choice billing flow
+ (Android only)
+
+
+
+
+
+ {{
+ ios: (
+ <>
+
+
+ Overview
+
+ #
+
+
+
+
+
+
Main Screen
+
+ The home screen provides navigation to all example
+ features including Purchase Flow, Subscription Flow, My
+ Purchases, and platform-specific options like Offer Code
+ redemption.
+
+
+
+
+
+
+
+ Location
+
+ #
+
+
+ packages/apple/Example/
+
+
+
+
+ Build and Run
+
+ #
+
+
+
+ Using Xcode (Recommended)
+
+
+ Open packages/apple/Example/Martie.xcodeproj in
+ Xcode
+
+
+ Select your development team in Signing & Capabilities
+
+
+ Select your target device (real device recommended for IAP
+ testing)
+
+ Click Run (⌘R)
+
+
+ Using Command Line
+ {`# Build the Swift package first
+cd packages/apple
+swift build
+
+# Build and run on simulator
+cd Example
+xcodebuild -project Martie.xcodeproj \\
+ -scheme OpenIapExample \\
+ -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \\
+ build
+
+# Or build for a real device (replace with your device ID)
+xcodebuild -project Martie.xcodeproj \\
+ -scheme OpenIapExample \\
+ -destination 'id=YOUR_DEVICE_UDID' \\
+ build`}
+
+
+ ⚠️ Important: For actual in-app purchase
+ testing, you must run on a real device. The iOS simulator has
+ limited StoreKit functionality.
+
+
+
+
+
+ Testing Purchases
+
+ #
+
+
+
+
+
+
Purchase Flow
+
+ Test purchasing consumable and non-consumable products.
+ Select from three verification methods: IAPKit
+ (server-side), Local (StoreKit verification), or None
+ (skip verification).
+
+
+
+
+
+ Sign in with a sandbox Apple ID at{' '}
+
+ Settings → Developer → Sandbox Apple Account
+
+
+ Launch the example app
+
+ Navigate to "Purchase Flow" or "Subscription Flow" screen
+
+ Select a verification method (IAPKit, Local, or None)
+ Tap on a product to initiate a purchase
+
+
+
+
+
+ Purchase Verification
+
+ #
+
+
+ The example app supports three verification methods:
+
+ IAPKit (Server-Side)
+
+ Sends purchase data to IAPKit API for verification
+
+ Returns isValid: true/false and purchase state
+
+ Recommended for production apps
+
+
+
+
Note: To use IAPKit verification, get your
+ API key from{' '}
+
+ iapkit.com
+ {' '}
+ and configure it in
Info.plist:
+
{`# Copy the template
+cp OpenIapExample/Info.plist.example OpenIapExample/Info.plist
+
+# Edit Info.plist with your API key
+IAPKIT_API_KEY
+iapkit_your_api_key_here `}
+
+
+ Local
+
+ Verifies purchase locally using StoreKit APIs
+ Checks transaction verification status
+ Good for development and testing
+
+
+ None
+
+ Skips verification entirely
+ Immediately finishes the transaction
+ Only for testing purchase flow UI
+
+
+
+
+
+ Git Security
+
+ #
+
+
+
+ The Info.plist file is automatically excluded
+ from git to protect your API key. Only the{' '}
+ Info.plist.example template is committed.
+
+
+
+
+
+ Troubleshooting
+
+ #
+
+
+
+ "IAPKIT_API_KEY not configured"
+
+
+ Ensure Info.plist exists in{' '}
+ OpenIapExample/ directory
+
+
+ Verify the file contains the IAPKIT_API_KEY key
+
+ Clean build folder (⇧⌘K) and rebuild
+
+
+ Products Not Loading
+
+ See{' '}
+
+ iOS Setup - Common Issues
+
+
+
+
+
+
+ Subscription Upgrade
+
+ #
+
+
+
+
+
+
Subscription Flow
+
+ Manage auto-renewable subscriptions with upgrade/downgrade
+ support. When upgrading, select proration mode to control
+ how the billing transition is handled.
+
+
+
+
+
+
+
+ My Purchases
+
+ #
+
+
+
+
+
+
Available Purchases
+
+ View all previously purchased items and active
+ subscriptions. Restore purchases to recover entitlements
+ after reinstalling the app or switching devices.
+
+
+
+
+
+
+
+ Offer Code
+
+ #
+
+
+
+
+
+
Offer Code Redemption
+
+ Present the iOS offer code redemption sheet to let users
+ redeem promotional codes for subscriptions or products.
+ This feature uses the native StoreKit redemption UI.
+
+
+
+
+
+
+
+ External Purchase
+
+ #
+
+
+
+
+
+
Alternative Billing
+
+ Test external purchase links that redirect users to
+ complete purchases outside of the App Store. This
+ demonstrates compliance with alternative payment
+ requirements in supported regions.
+
+
+
+
+
+
+ >
+ ),
+ android: (
+ <>
+
+
+ Overview
+
+ #
+
+
+
+
+
+
Main Screen
+
+ The Android version offers all core features with an
+ additional Alternative Billing option for testing Google
+ Play's user-choice billing flow.
+
+
+
+
+
+
+
+ Location
+
+ #
+
+
+ packages/google/Example/
+
+
+
+
+ Build and Run
+
+ #
+
+
+
+ Using Android Studio (Recommended)
+
+
+ Open packages/google directory in Android
+ Studio
+
+ Wait for Gradle sync to complete
+ Select "Example" from the run configurations dropdown
+
+ Select your target device (real device required for IAP
+ testing)
+
+ Click Run (▶️)
+
+
+ Using Command Line
+ {`# Navigate to the google package
+cd packages/google
+
+# Build the example app
+./gradlew :Example:assembleDebug
+
+# Install on connected device
+./gradlew :Example:installDebug
+
+# Or use adb directly
+adb install Example/build/outputs/apk/debug/Example-debug.apk`}
+
+
+
⚠️ Important: For actual in-app purchase
+ testing, the app must be:
+
+
+ Installed from Google Play (internal/closed/open testing
+ track)
+
+
+ Signed with the same key as uploaded to Play Console
+
+ Tested with a license tester account
+
+
+
+
+
+
+ Testing Purchases
+
+ #
+
+
+
+
+
+
Purchase Flow
+
+ Purchase products using Google Play Billing. The
+ verification dropdown lets you choose between IAPKit
+ server verification, local validation, or skipping
+ verification entirely.
+
+
+
+
+
+ Add your Google account as a license tester in{' '}
+ Play Console → Setup → License testing
+
+ Download the app from the Play Store test track
+
+ Launch the app and go to "Purchase Flow" or "Subscription
+ Flow"
+
+ Select a verification method (IAPKit, Local, or None)
+ Tap on a product to initiate a purchase
+
+
+
+
+
+ Purchase Verification
+
+ #
+
+
+ The example app supports three verification methods:
+
+ IAPKit (Server-Side)
+
+ Sends purchase data to IAPKit API for verification
+
+ Returns isValid: true/false and purchase state
+
+ Recommended for production apps
+
+
+
+
Note: To use IAPKit verification, get your
+ API key from{' '}
+
+ iapkit.com
+ {' '}
+ and configure it in
local.properties:
+
{`# Copy the template
+cp local.properties.example local.properties
+
+# Add your API key
+iapkit.api.key=iapkit_your_api_key_here`}
+
+
+ Local
+
+
+ Verifies purchase locally using Google Play Billing APIs
+
+ Checks purchase state
+ Good for development and testing
+
+
+ None
+
+ Skips verification entirely
+ Immediately finishes the transaction
+ Only for testing purchase flow UI
+
+
+
+
+
+ How API Key is Loaded
+
+ #
+
+
+
+ The API key from local.properties is injected
+ into the app via BuildConfig during the build
+ process. The Example app's build.gradle.kts{' '}
+ includes:
+
+ {`// In Example/build.gradle.kts
+val localProperties = Properties()
+val localPropertiesFile = rootProject.file("local.properties")
+if (localPropertiesFile.exists()) {
+ localProperties.load(localPropertiesFile.inputStream())
+}
+
+android {
+ defaultConfig {
+ buildConfigField(
+ "String",
+ "IAPKIT_API_KEY",
+ ""${'$'}{localProperties.getProperty("iapkit.api.key", "")}""
+ )
+ }
+}`}
+
+
+
+
+ Git Security
+
+ #
+
+
+
+ The local.properties file is automatically
+ excluded from git (standard Android convention). Only the{' '}
+ local.properties.example template is committed.
+
+
+
+
+
+ Troubleshooting
+
+ #
+
+
+
+ "IAPKit API Key not configured"
+
+
+ Ensure local.properties exists in{' '}
+ packages/google/ directory
+
+
+ Verify it contains iapkit.api.key=your_key
+
+
+ Clean and rebuild:{' '}
+ ./gradlew clean :Example:assembleDebug
+
+
+
+ Products Not Loading
+
+ See{' '}
+
+ Android Setup - Common Issues
+
+
+
+
+
+
+ Subscription Flow
+
+ #
+
+
+
+
+
+
Subscription Flow
+
+ Subscribe to auto-renewable subscriptions via Google Play.
+ View available subscription tiers and initiate new
+ subscriptions with selected verification options.
+
+
+
+
+
+
+
+ Subscription Upgrade
+
+ #
+
+
+
+
+
+
Upgrade/Downgrade
+
+ Upgrade or downgrade existing subscriptions with proration
+ mode selection. Choose how the remaining balance should be
+ applied to the new subscription tier.
+
+
+
+
+
+
+
+ My Purchases
+
+ #
+
+
+
+
+
+
Available Purchases
+
+ View purchased products and active subscriptions from
+ Google Play. Restore purchases to sync entitlements across
+ devices or after app reinstallation.
+
+
+
+
+
+
+
+ Redeem Offer Code
+
+ #
+
+
+
+
+
+
Offer Code Redemption
+
+ Open the Google Play redemption flow where users can enter
+ promo codes. The app launches the Play Store's native code
+ redemption interface.
+
+
+
+
+
+
+ >
+ ),
+ }}
+
+
+ );
+}
+
+export default Example;
diff --git a/packages/docs/src/styles/pages.css b/packages/docs/src/styles/pages.css
index a427a199..d6f7eab9 100644
--- a/packages/docs/src/styles/pages.css
+++ b/packages/docs/src/styles/pages.css
@@ -370,3 +370,93 @@
.platform-link:hover {
background: var(--primary-dark);
}
+
+/* Screenshot Card */
+.screenshot-card {
+ display: flex;
+ align-items: flex-start;
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+ padding: 1.5rem;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 0.75rem;
+}
+
+.screenshot-card img {
+ width: 180px;
+ height: auto;
+ flex-shrink: 0;
+ border-radius: 0.5rem;
+ border: 1px solid var(--border-color);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.screenshot-card-content {
+ flex: 1;
+}
+
+.screenshot-card-content h4 {
+ margin: 0 0 0.5rem 0;
+ color: var(--text-primary);
+ font-size: 1.1rem;
+}
+
+.screenshot-card-content p {
+ margin: 0;
+ color: var(--text-secondary);
+ font-size: 0.95rem;
+ line-height: 1.6;
+}
+
+.screenshot-card-content .platform-badge {
+ display: inline-block;
+ padding: 0.2rem 0.5rem;
+ margin-bottom: 0.5rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ border-radius: 0.25rem;
+ text-transform: uppercase;
+}
+
+.screenshot-card-content .platform-badge.ios {
+ background: rgba(0, 122, 255, 0.1);
+ color: #007aff;
+}
+
+.screenshot-card-content .platform-badge.android {
+ background: rgba(61, 220, 132, 0.1);
+ color: #3ddc84;
+}
+
+:root.dark .screenshot-card {
+ background: var(--bg-secondary);
+ border-color: var(--border-color);
+}
+
+:root.dark .screenshot-card img {
+ border-color: var(--border-color);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+}
+
+:root.dark .screenshot-card-content .platform-badge.ios {
+ background: rgba(10, 132, 255, 0.2);
+ color: #0a84ff;
+}
+
+:root.dark .screenshot-card-content .platform-badge.android {
+ background: rgba(61, 220, 132, 0.2);
+ color: #3ddc84;
+}
+
+@media (max-width: 600px) {
+ .screenshot-card {
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ }
+
+ .screenshot-card img {
+ width: 150px;
+ }
+}
diff --git a/packages/google/CONTRIBUTING.md b/packages/google/CONTRIBUTING.md
index 13445ac8..7773e54b 100644
--- a/packages/google/CONTRIBUTING.md
+++ b/packages/google/CONTRIBUTING.md
@@ -103,7 +103,7 @@ adb logcat | grep -E "OpenIap|Horizon"
- **OpenIap prefix for public models (Android)**
- Prefix all public model types with `OpenIap`.
- - Examples: `OpenIapProduct`, `OpenIapPurchase`, `OpenIapActiveSubscription`, `OpenIapRequestPurchaseProps`, `OpenIapProductRequest`, `OpenIapReceiptValidationProps`, `OpenIapReceiptValidationResult`.
+- Examples: `OpenIapProduct`, `OpenIapPurchase`, `OpenIapActiveSubscription`, `OpenIapRequestPurchaseProps`, `OpenIapProductRequest`, `OpenIapVerifyPurchaseProps`, `OpenIapVerifyPurchaseResult`.
- Private/internal helper types do not need the prefix.
- When renaming existing types, provide a public typealias from the old name to the new name to preserve source compatibility and migrate usages incrementally when feasible.
diff --git a/packages/google/Example/build.gradle.kts b/packages/google/Example/build.gradle.kts
index 8b961afe..963c9572 100644
--- a/packages/google/Example/build.gradle.kts
+++ b/packages/google/Example/build.gradle.kts
@@ -35,6 +35,12 @@ android {
buildConfigField("String", "HORIZON_APP_ID", "\"${appId}\"")
// Ensure placeholder exists for all variants (play included)
manifestPlaceholders["OCULUS_APP_ID"] = appId
+
+ // IAPKit API Key for purchase verification
+ val iapkitApiKey = localProperties.getProperty("iapkit.api.key")
+ ?: (project.findProperty("IAPKIT_API_KEY") as String?)
+ ?: ""
+ buildConfigField("String", "IAPKIT_API_KEY", "\"${iapkitApiKey}\"")
}
flavorDimensions += "platform"
diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt
index 652714a4..6656b349 100644
--- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt
+++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt
@@ -40,13 +40,24 @@ import dev.hyo.openiap.ProductType
import dev.hyo.openiap.Purchase
import dev.hyo.openiap.PurchaseAndroid
import dev.hyo.openiap.PurchaseInput
+import dev.hyo.openiap.PurchaseState
import dev.hyo.openiap.RequestPurchaseProps
import dev.hyo.openiap.RequestPurchaseAndroidProps
import dev.hyo.openiap.RequestPurchasePropsByPlatforms
import dev.hyo.openiap.RequestSubscriptionAndroidProps
import dev.hyo.openiap.RequestSubscriptionPropsByPlatforms
import dev.hyo.openiap.utils.toPurchaseInput
+import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitGoogleProps
+import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitProps
+import dev.hyo.openiap.utils.verifyPurchaseWithIapkit
import dev.hyo.martie.util.findActivity
+import dev.hyo.martie.BuildConfig
+
+enum class VerificationMethod(val displayName: String) {
+ None("❌ None (Skip)"),
+ Local("📱 Local (Device)"),
+ IAPKit("☁️ IAPKit (Server)")
+}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -81,6 +92,19 @@ fun PurchaseFlowScreen(
var selectedPurchase by remember { mutableStateOf(null) }
var isInitializing by remember { mutableStateOf(true) }
+ // Verification states
+ var verificationMethod by remember { mutableStateOf(VerificationMethod.None) }
+ var isVerifying by remember { mutableStateOf(false) }
+ var verificationResultMessage by remember { mutableStateOf(null) }
+ var verificationDropdownExpanded by remember { mutableStateOf(false) }
+ // Track which purchase IDs have been processed (to allow re-purchase after failure)
+ var processedPurchaseKey by remember { mutableStateOf(null) }
+
+ // IAPKit API Key from BuildConfig
+ val iapkitApiKey: String? = remember {
+ runCatching { BuildConfig.IAPKIT_API_KEY.takeIf { it.isNotBlank() } }.getOrNull()
+ }
+
// Use a dedicated scope for cleanup that won't be cancelled with composition
val cleanupScope = remember { CoroutineScope(Dispatchers.Main + SupervisorJob()) }
@@ -92,6 +116,9 @@ fun PurchaseFlowScreen(
// Initialize and connect on first composition (spec-aligned names)
LaunchedEffect(Unit) {
+ // Enable OpenIapLog for debugging
+ dev.hyo.openiap.OpenIapLog.isEnabled = true
+
try {
val connected = iapStore.initConnection()
if (connected) {
@@ -220,7 +247,154 @@ fun PurchaseFlowScreen(
}
}
}
-
+
+ // Verification Method Card
+ item {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(containerColor = AppColors.cardBackground),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.VerifiedUser,
+ contentDescription = null,
+ tint = AppColors.secondary
+ )
+ Text(
+ "Purchase Verification",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+
+ ExposedDropdownMenuBox(
+ expanded = verificationDropdownExpanded,
+ onExpandedChange = { verificationDropdownExpanded = it }
+ ) {
+ OutlinedTextField(
+ value = verificationMethod.displayName,
+ onValueChange = {},
+ readOnly = true,
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(expanded = verificationDropdownExpanded)
+ },
+ colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .menuAnchor()
+ )
+ ExposedDropdownMenu(
+ expanded = verificationDropdownExpanded,
+ onDismissRequest = { verificationDropdownExpanded = false }
+ ) {
+ VerificationMethod.entries.forEach { method ->
+ DropdownMenuItem(
+ text = { Text(method.displayName) },
+ onClick = {
+ verificationMethod = method
+ verificationDropdownExpanded = false
+ }
+ )
+ }
+ }
+ }
+
+ if (verificationMethod == VerificationMethod.IAPKit) {
+ if (iapkitApiKey != null) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.CheckCircle,
+ contentDescription = null,
+ tint = AppColors.success,
+ modifier = Modifier.size(16.dp)
+ )
+ Text(
+ "API Key configured",
+ style = MaterialTheme.typography.bodySmall,
+ color = AppColors.success
+ )
+ }
+ } else {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = AppColors.warning.copy(alpha = 0.1f)
+ ),
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.Warning,
+ contentDescription = null,
+ tint = AppColors.warning,
+ modifier = Modifier.size(16.dp)
+ )
+ Text(
+ "API Key not configured",
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.SemiBold,
+ color = AppColors.warning
+ )
+ }
+ Text(
+ "Set IAPKIT_API_KEY in:",
+ style = MaterialTheme.typography.labelSmall,
+ color = AppColors.textSecondary
+ )
+ Text(
+ "• local.properties → iapkit.api.key=xxx",
+ style = MaterialTheme.typography.labelSmall,
+ color = AppColors.textSecondary
+ )
+ Text(
+ "• Or build.gradle → buildConfigField",
+ style = MaterialTheme.typography.labelSmall,
+ color = AppColors.textSecondary
+ )
+ }
+ }
+ }
+ }
+
+ verificationResultMessage?.let { message ->
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = AppColors.background
+ ),
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Text(
+ message,
+ style = MaterialTheme.typography.bodySmall,
+ color = AppColors.textSecondary,
+ modifier = Modifier.padding(8.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+
// Loading State
if (isInitializing || status.isLoading) {
item {
@@ -383,28 +557,123 @@ fun PurchaseFlowScreen(
}
}
- // Simple helper: simulate server-side receipt validation
- suspend fun validateReceiptOnServer(purchase: PurchaseAndroid): Boolean {
- // TODO: Replace with your real backend API call
- // e.g., POST purchase.purchaseToken to your server and verify
- return true
+ // Verification helper functions
+ suspend fun verifyWithIapkit(purchase: PurchaseAndroid, apiKey: String): Boolean {
+ val token = purchase.purchaseToken
+ ?: throw IllegalStateException("Purchase token is required for IAPKit verification")
+
+ println("PurchaseFlow: IAPKit verification params:")
+ println(" - purchaseToken: ${token.take(6)}… (redacted)")
+
+ val props = RequestVerifyPurchaseWithIapkitProps(
+ apiKey = apiKey,
+ apple = null,
+ google = RequestVerifyPurchaseWithIapkitGoogleProps(
+ purchaseToken = token
+ )
+ )
+ val results = verifyPurchaseWithIapkit(props, "PurchaseFlowScreen")
+ return results.firstOrNull()?.isValid == true
+ }
+
+ // Local verification: For Android, we just check if the purchase state is authentic
+ // Real local verification should use Google Play Billing's acknowledgment
+ fun verifyLocally(purchase: PurchaseAndroid): Boolean {
+ return purchase.purchaseState == PurchaseState.Purchased
}
- // Auto-handle purchase: validate on server then finish
- // IMPORTANT: Implement real server-side receipt validation in validateReceiptOnServer()
- LaunchedEffect(lastPurchaseAndroid?.id) {
+ // Auto-handle purchase: validate then finish
+ // Use a unique key combining purchase ID and transaction date to ensure re-trigger on new purchases
+ // This fixes the issue where Buy button doesn't work after verification failure
+ val purchaseKey = lastPurchaseAndroid?.let { "${it.id}_${it.transactionDate}" }
+ LaunchedEffect(purchaseKey) {
val purchase = lastPurchaseAndroid ?: return@LaunchedEffect
+
+ // Skip if we've already processed this exact purchase
+ if (purchaseKey == processedPurchaseKey) {
+ println("PurchaseFlow: Skipping already processed purchase: $purchaseKey")
+ return@LaunchedEffect
+ }
+
+ // Clear any premature "success" message from purchase listener
+ // We will only show the final result after verification completes
+ iapStore.clearStatusMessage()
+
try {
- // 1) Server-side validation (replace with your backend call)
- val valid = validateReceiptOnServer(purchase)
- if (!valid) {
- iapStore.postStatusMessage(
- message = "Receipt validation failed",
- status = PurchaseResultStatus.Error,
- productId = purchase.productId
- )
+ // 1) Perform verification based on selected method
+ val isValid = when (verificationMethod) {
+ VerificationMethod.None -> {
+ verificationResultMessage = "✅ No verification (skipped)"
+ true
+ }
+ VerificationMethod.Local -> {
+ isVerifying = true
+ verificationResultMessage = "🔍 Verifying locally..."
+ try {
+ val result = verifyLocally(purchase)
+ verificationResultMessage = if (result) "✅ Local verification passed" else "❌ Local verification failed"
+ result
+ } catch (e: Exception) {
+ verificationResultMessage = "❌ Local verification error: ${e.message}"
+ false
+ } finally {
+ isVerifying = false
+ }
+ }
+ VerificationMethod.IAPKit -> {
+ val apiKey = iapkitApiKey
+ if (apiKey == null) {
+ verificationResultMessage = "❌ IAPKit API Key not configured"
+ iapStore.postStatusMessage(
+ message = "IAPKit API Key not configured. Set iapkit.api.key in local.properties",
+ status = PurchaseResultStatus.Error,
+ productId = purchase.productId
+ )
+ // Mark as processed so user can retry
+ processedPurchaseKey = purchaseKey
+ return@LaunchedEffect
+ }
+ isVerifying = true
+ verificationResultMessage = "☁️ Verifying with IAPKit..."
+ println("PurchaseFlow: Starting IAPKit verification for ${purchase.productId}")
+ try {
+ val result = verifyWithIapkit(purchase, apiKey)
+ println("PurchaseFlow: IAPKit verification result: $result")
+ verificationResultMessage = if (result) "✅ IAPKit verification passed" else "❌ IAPKit verification failed"
+ if (!result) {
+ // Post error with auto-refund notice
+ iapStore.postStatusMessage(
+ message = "Verification failed. Purchase not acknowledged - will be auto-refunded within 3 days.",
+ status = PurchaseResultStatus.Error,
+ productId = purchase.productId
+ )
+ }
+ result
+ } catch (e: Exception) {
+ println("PurchaseFlow: IAPKit verification error: ${e.message}")
+ e.printStackTrace()
+ verificationResultMessage = "❌ IAPKit verification error: ${e.message}"
+ iapStore.postStatusMessage(
+ message = "Verification error: ${e.message}. Finishing transaction anyway for testing.",
+ status = PurchaseResultStatus.Error,
+ productId = purchase.productId
+ )
+ // For testing: return true to continue with finishTransaction
+ println("PurchaseFlow: [TEST MODE] Continuing with finishTransaction despite verification error")
+ true
+ } finally {
+ isVerifying = false
+ }
+ }
+ }
+
+ if (!isValid) {
+ println("PurchaseFlow: Verification failed – not finishing transaction")
+ // Mark as processed so the same purchase isn't re-processed
+ processedPurchaseKey = purchaseKey
return@LaunchedEffect
}
+
// 2) Determine consumable vs non-consumable
val product = products.find { it.id == purchase.productId }
val isConsumable = product?.let {
@@ -445,6 +714,9 @@ fun PurchaseFlowScreen(
status = PurchaseResultStatus.Error,
productId = purchase.productId
)
+ } finally {
+ // Mark as processed so user can retry if needed
+ processedPurchaseKey = purchaseKey
}
}
diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt
index f9b0a30a..a3910ab1 100644
--- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt
+++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt
@@ -41,6 +41,9 @@ import dev.hyo.openiap.RequestPurchasePropsByPlatforms
import dev.hyo.openiap.RequestSubscriptionAndroidProps
import dev.hyo.openiap.RequestSubscriptionPropsByPlatforms
import dev.hyo.openiap.AndroidSubscriptionOfferInput
+import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitGoogleProps
+import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitProps
+import dev.hyo.openiap.utils.verifyPurchaseWithIapkit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -129,6 +132,19 @@ fun SubscriptionFlowScreen(
var isInitializing by remember { mutableStateOf(true) }
+ // Verification states
+ var verificationMethod by remember { mutableStateOf(VerificationMethod.None) }
+ var isVerifying by remember { mutableStateOf(false) }
+ var verificationResultMessage by remember { mutableStateOf(null) }
+ var verificationDropdownExpanded by remember { mutableStateOf(false) }
+ // Track which purchase IDs have been processed (to allow re-purchase after failure)
+ var processedPurchaseKey by remember { mutableStateOf(null) }
+
+ // IAPKit API Key from BuildConfig
+ val iapkitApiKey: String? = remember {
+ runCatching { BuildConfig.IAPKIT_API_KEY.takeIf { it.isNotBlank() } }.getOrNull()
+ }
+
// Use a dedicated scope for cleanup that won't be cancelled with composition
val cleanupScope = remember { CoroutineScope(Dispatchers.Main + SupervisorJob()) }
@@ -140,6 +156,9 @@ fun SubscriptionFlowScreen(
// Load subscription data on screen entry
LaunchedEffect(Unit) {
+ // Enable OpenIapLog for debugging
+ dev.hyo.openiap.OpenIapLog.isEnabled = true
+
try {
println("SubscriptionFlow: Loading subscription products and purchases")
println("SubscriptionFlow: Is Horizon = $isHorizon")
@@ -237,7 +256,7 @@ fun SubscriptionFlowScreen(
// TODO: Replace with your backend call to Play Developer API
suspend fun fetchSubStatusFromServer(productId: String, purchaseToken: String): SubscriptionUiInfo? {
- // Expected mapping of your server response (ReceiptValidationResultAndroid)
+ // Expected mapping of your server response (VerifyPurchaseResultAndroid)
// return SubscriptionUiInfo(
// renewalDate = result.renewalDate,
// autoRenewing = result.autoRenewing,
@@ -390,6 +409,154 @@ fun SubscriptionFlowScreen(
}
}
}
+
+ // Verification Method Card
+ item {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(containerColor = AppColors.cardBackground),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.VerifiedUser,
+ contentDescription = null,
+ tint = AppColors.secondary
+ )
+ Text(
+ "Purchase Verification",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+
+ ExposedDropdownMenuBox(
+ expanded = verificationDropdownExpanded,
+ onExpandedChange = { verificationDropdownExpanded = it }
+ ) {
+ OutlinedTextField(
+ value = verificationMethod.displayName,
+ onValueChange = {},
+ readOnly = true,
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(expanded = verificationDropdownExpanded)
+ },
+ colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .menuAnchor()
+ )
+ ExposedDropdownMenu(
+ expanded = verificationDropdownExpanded,
+ onDismissRequest = { verificationDropdownExpanded = false }
+ ) {
+ VerificationMethod.entries.forEach { method ->
+ DropdownMenuItem(
+ text = { Text(method.displayName) },
+ onClick = {
+ verificationMethod = method
+ verificationDropdownExpanded = false
+ }
+ )
+ }
+ }
+ }
+
+ if (verificationMethod == VerificationMethod.IAPKit) {
+ if (iapkitApiKey != null) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.CheckCircle,
+ contentDescription = null,
+ tint = AppColors.success,
+ modifier = Modifier.size(16.dp)
+ )
+ Text(
+ "API Key configured",
+ style = MaterialTheme.typography.bodySmall,
+ color = AppColors.success
+ )
+ }
+ } else {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = AppColors.warning.copy(alpha = 0.1f)
+ ),
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.Warning,
+ contentDescription = null,
+ tint = AppColors.warning,
+ modifier = Modifier.size(16.dp)
+ )
+ Text(
+ "API Key not configured",
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.SemiBold,
+ color = AppColors.warning
+ )
+ }
+ Text(
+ "Set IAPKIT_API_KEY in:",
+ style = MaterialTheme.typography.labelSmall,
+ color = AppColors.textSecondary
+ )
+ Text(
+ "• local.properties → iapkit.api.key=xxx",
+ style = MaterialTheme.typography.labelSmall,
+ color = AppColors.textSecondary
+ )
+ Text(
+ "• Or build.gradle → buildConfigField",
+ style = MaterialTheme.typography.labelSmall,
+ color = AppColors.textSecondary
+ )
+ }
+ }
+ }
+ }
+
+ verificationResultMessage?.let { message ->
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = AppColors.background
+ ),
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Text(
+ message,
+ style = MaterialTheme.typography.bodySmall,
+ color = AppColors.textSecondary,
+ modifier = Modifier.padding(8.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+
statusMessage?.let { result ->
item("status-message") {
PurchaseResultCard(
@@ -409,8 +576,11 @@ fun SubscriptionFlowScreen(
}
// Active Subscriptions Section
- // Treat any purchase with matching subscription SKU as subscribed
- val activeSubscriptions = androidPurchases.filter { it.productId in subscriptionSkus }
+ // Only show purchased subscriptions (filter out pending, failed, etc.)
+ val activeSubscriptions = androidPurchases.filter {
+ it.productId in subscriptionSkus &&
+ it.purchaseState == PurchaseState.Purchased
+ }
if (activeSubscriptions.isNotEmpty()) {
item {
SectionHeaderView(title = "Active Subscriptions")
@@ -638,23 +808,8 @@ fun SubscriptionFlowScreen(
prefs.savePremiumOffer(IapConstants.PREMIUM_PRODUCT_ID, newOfferBasePlanId)
println("SubscriptionFlow: Subscription change successful, saved offer: $newOfferBasePlanId")
- iapStore.postStatusMessage(
- message = if (isMonthlyPlan) "Upgraded to yearly plan successfully" else "Switched to monthly plan",
- status = PurchaseResultStatus.Success,
- productId = IapConstants.PREMIUM_PRODUCT_ID
- )
- // Refresh purchases
- iapStore.getAvailablePurchases(null)
- delay(2000)
- iapStore.getAvailablePurchases(null)
- // Refresh products
- scope.launch {
- val request = ProductRequest(
- skus = subscriptionSkus,
- type = ProductQueryType.Subs
- )
- iapStore.fetchProducts(request)
- }
+ // Don't post success message here - let LaunchedEffect handle verification
+ // The purchase listener will update lastPurchaseAndroid which triggers verification
}
} catch (e: Exception) {
println("SubscriptionFlow: Error changing subscription: ${e.message}")
@@ -882,24 +1037,8 @@ fun SubscriptionFlowScreen(
prefs.savePremiumOffer(PREMIUM_SUBSCRIPTION_PRODUCT_ID, newOfferBasePlanId)
println("SubscriptionFlow: Subscription change successful, saved offer: $newOfferBasePlanId")
- iapStore.postStatusMessage(
- message = if (isMonthly) "Upgraded to yearly plan successfully" else "Switched to monthly plan",
- status = PurchaseResultStatus.Success,
- productId = PREMIUM_SUBSCRIPTION_PRODUCT_ID
- )
- // Immediately refresh purchases
- iapStore.getAvailablePurchases(null)
- // Also refresh after a delay to catch any delayed updates
- delay(2000)
- iapStore.getAvailablePurchases(null)
- // Refresh products to update subscription status
- scope.launch {
- val request = ProductRequest(
- skus = IapConstants.SUBS_SKUS,
- type = ProductQueryType.Subs
- )
- iapStore.fetchProducts(request)
- }
+ // Don't post success message here - let LaunchedEffect handle verification
+ // The purchase listener will update lastPurchaseAndroid which triggers verification
}
} catch (e: Exception) {
println("SubscriptionFlow: Error changing subscription: ${e.message}")
@@ -1127,27 +1266,122 @@ fun SubscriptionFlowScreen(
}
}
- // Auto-handle purchase: validate on server then finish (expo-iap style)
- // IMPORTANT: Implement real server-side receipt validation in validateReceiptOnServer()
- suspend fun validateReceiptOnServer(purchase: PurchaseAndroid): Boolean {
- // TODO: Replace with your real backend API call
- // e.g., POST purchase.purchaseToken to your server and verify
- return true
+ // Verification helper functions
+ suspend fun verifyWithIapkit(purchase: PurchaseAndroid, apiKey: String): Boolean {
+ val token = purchase.purchaseToken
+ ?: throw IllegalStateException("Purchase token is required for IAPKit verification")
+
+ println("SubscriptionFlow: IAPKit verification params:")
+ println(" - purchaseToken: $token")
+
+ val props = RequestVerifyPurchaseWithIapkitProps(
+ apiKey = apiKey,
+ apple = null,
+ google = RequestVerifyPurchaseWithIapkitGoogleProps(
+ purchaseToken = token
+ )
+ )
+ val results = verifyPurchaseWithIapkit(props, "SubscriptionFlowScreen")
+ return results.firstOrNull()?.isValid == true
+ }
+
+ // Local verification: For Android, we just check if the purchase state is authentic
+ fun verifyLocally(purchase: PurchaseAndroid): Boolean {
+ return purchase.purchaseState == PurchaseState.Purchased
}
- LaunchedEffect(lastPurchaseAndroid?.id) {
+ // Auto-handle purchase: validate then finish
+ // Use a unique key combining purchase ID and transaction date to ensure re-trigger on new purchases
+ // This fixes the issue where Buy button doesn't work after verification failure
+ val purchaseKey = lastPurchaseAndroid?.let { "${it.id}_${it.transactionDate}" }
+ LaunchedEffect(purchaseKey) {
val purchase = lastPurchaseAndroid ?: return@LaunchedEffect
+
+ // Skip if we've already processed this exact purchase
+ if (purchaseKey == processedPurchaseKey) {
+ println("SubscriptionFlow: Skipping already processed purchase: $purchaseKey")
+ return@LaunchedEffect
+ }
+
+ // Clear any premature "success" message from purchase listener
+ // We will only show the final result after verification completes
+ iapStore.clearStatusMessage()
+
try {
- // 1) Server-side validation (replace with your backend call)
- val valid = validateReceiptOnServer(purchase)
- if (!valid) {
- iapStore.postStatusMessage(
- message = "Receipt validation failed",
- status = PurchaseResultStatus.Error,
- productId = purchase.productId
- )
+ // 1) Perform verification based on selected method
+ val isValid = when (verificationMethod) {
+ VerificationMethod.None -> {
+ verificationResultMessage = "✅ No verification (skipped)"
+ true
+ }
+ VerificationMethod.Local -> {
+ isVerifying = true
+ verificationResultMessage = "🔍 Verifying locally..."
+ try {
+ val result = verifyLocally(purchase)
+ verificationResultMessage = if (result) "✅ Local verification passed" else "❌ Local verification failed"
+ result
+ } catch (e: Exception) {
+ verificationResultMessage = "❌ Local verification error: ${e.message}"
+ false
+ } finally {
+ isVerifying = false
+ }
+ }
+ VerificationMethod.IAPKit -> {
+ val apiKey = iapkitApiKey
+ if (apiKey == null) {
+ verificationResultMessage = "❌ IAPKit API Key not configured"
+ iapStore.postStatusMessage(
+ message = "IAPKit API Key not configured. Set iapkit.api.key in local.properties",
+ status = PurchaseResultStatus.Error,
+ productId = purchase.productId
+ )
+ // Mark as processed so user can retry
+ processedPurchaseKey = purchaseKey
+ return@LaunchedEffect
+ }
+ isVerifying = true
+ verificationResultMessage = "☁️ Verifying with IAPKit..."
+ println("SubscriptionFlow: Starting IAPKit verification for ${purchase.productId}")
+ try {
+ val result = verifyWithIapkit(purchase, apiKey)
+ println("SubscriptionFlow: IAPKit verification result: $result")
+ verificationResultMessage = if (result) "✅ IAPKit verification passed" else "❌ IAPKit verification failed"
+ if (!result) {
+ // Post error with auto-refund notice
+ iapStore.postStatusMessage(
+ message = "Verification failed. Purchase not acknowledged - will be auto-refunded within 3 days.",
+ status = PurchaseResultStatus.Error,
+ productId = purchase.productId
+ )
+ }
+ result
+ } catch (e: Exception) {
+ println("SubscriptionFlow: IAPKit verification error: ${e.message}")
+ e.printStackTrace()
+ verificationResultMessage = "❌ IAPKit verification error: ${e.message}"
+ iapStore.postStatusMessage(
+ message = "Verification error: ${e.message}. Finishing transaction anyway for testing.",
+ status = PurchaseResultStatus.Error,
+ productId = purchase.productId
+ )
+ // For testing: return true to continue with finishTransaction
+ println("SubscriptionFlow: [TEST MODE] Continuing with finishTransaction despite verification error")
+ true
+ } finally {
+ isVerifying = false
+ }
+ }
+ }
+
+ if (!isValid) {
+ println("SubscriptionFlow: Verification failed – not finishing transaction")
+ // Mark as processed so the same purchase isn't re-processed
+ processedPurchaseKey = purchaseKey
return@LaunchedEffect
}
+
// 2) Determine consumable vs non-consumable (subs -> false)
val product = products.find { it.id == purchase.productId }
val isConsumable = product?.let {
@@ -1186,6 +1420,9 @@ fun SubscriptionFlowScreen(
status = PurchaseResultStatus.Error,
productId = purchase.productId
)
+ } finally {
+ // Mark as processed so user can retry if needed
+ processedPurchaseKey = purchaseKey
}
}
diff --git a/packages/google/README.md b/packages/google/README.md
index 99043d7a..f3846edb 100644
--- a/packages/google/README.md
+++ b/packages/google/README.md
@@ -48,7 +48,7 @@ Add to your module's `build.gradle.kts`:
```kotlin
dependencies {
- implementation("io.github.hyochan.openiap:openiap-google:1.2.12")
+ implementation("io.github.hyochan.openiap:openiap-google:$version")
}
```
@@ -56,10 +56,12 @@ Or `build.gradle`:
```groovy
dependencies {
- implementation 'io.github.hyochan.openiap:openiap-google:1.2.12'
+ implementation 'io.github.hyochan.openiap:openiap-google:$version'
}
```
+> 📌 **Latest Version**: Check [`openiap-versions.json`](../../openiap-versions.json) for the current version, or see the [Maven Central badge](https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-google) above.
+
## 🚀 Quick Start
### 1. Initialize in Application
diff --git a/packages/google/local.properties.example b/packages/google/local.properties.example
new file mode 100644
index 00000000..6f276a3b
--- /dev/null
+++ b/packages/google/local.properties.example
@@ -0,0 +1,11 @@
+## This file must *NOT* be checked into Version Control Systems,
+# as it contains information specific to your local configuration.
+#
+# Location of the SDK. This is only used by Gradle.
+sdk.dir=/path/to/your/Android/sdk
+
+# Horizon OS App ID (Meta Quest)
+horizon.app.id=YOUR_HORIZON_APP_ID
+
+# IAPKit API Key for purchase verification
+iapkit.api.key=YOUR_IAPKIT_API_KEY_HERE
diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
index 2f986464..f4e87503 100644
--- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
@@ -33,9 +33,12 @@ import dev.hyo.openiap.utils.HorizonBillingConverters.toInAppProduct
import dev.hyo.openiap.utils.HorizonBillingConverters.toPurchase
import dev.hyo.openiap.utils.HorizonBillingConverters.toSubscriptionProduct
import dev.hyo.openiap.utils.toProduct
-import dev.hyo.openiap.utils.validateReceiptWithGooglePlay
+import dev.hyo.openiap.utils.verifyPurchaseWithGooglePlay
import dev.hyo.openiap.MutationVerifyPurchaseHandler
import dev.hyo.openiap.MutationValidateReceiptHandler
+import dev.hyo.openiap.MutationVerifyPurchaseWithProviderHandler
+import dev.hyo.openiap.PurchaseVerificationProvider
+import dev.hyo.openiap.utils.verifyPurchaseWithIapkit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -651,7 +654,18 @@ class OpenIapModule(
}
override val verifyPurchase: MutationVerifyPurchaseHandler = { props ->
- validateReceiptWithGooglePlay(props, TAG)
+ verifyPurchaseWithGooglePlay(props, TAG)
+ }
+
+ override val verifyPurchaseWithProvider: MutationVerifyPurchaseWithProviderHandler = { props ->
+ if (props.provider != PurchaseVerificationProvider.Iapkit) {
+ throw OpenIapError.FeatureNotSupported
+ }
+ val options = props.iapkit ?: throw OpenIapError.DeveloperError
+ VerifyPurchaseWithProviderResult(
+ iapkit = verifyPurchaseWithIapkit(options, TAG),
+ provider = props.provider
+ )
}
private val purchaseError: SubscriptionPurchaseErrorHandler = {
@@ -681,7 +695,8 @@ class OpenIapModule(
requestPurchase = requestPurchase,
restorePurchases = restorePurchases,
validateReceipt = validateReceipt,
- verifyPurchase = verifyPurchase
+ verifyPurchase = verifyPurchase,
+ verifyPurchaseWithProvider = verifyPurchaseWithProvider
)
override val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers(
diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt
index 65b3a7e5..983ffb57 100644
--- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt
+++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt
@@ -64,12 +64,39 @@ sealed class OpenIapError : Exception() {
const val MESSAGE = "Billing error"
}
+ /**
+ * @deprecated Use [InvalidPurchaseVerification] instead
+ */
+ @Deprecated("Use InvalidPurchaseVerification instead", ReplaceWith("InvalidPurchaseVerification"))
object InvalidReceipt : OpenIapError() {
- val CODE = ErrorCode.ReceiptFailed.rawValue
+ val CODE = ErrorCode.PurchaseVerificationFailed.rawValue
override val code = CODE
override val message = MESSAGE
- const val MESSAGE = "Invalid receipt"
+ const val MESSAGE = "Purchase verification failed"
+ }
+
+ /**
+ * Purchase verification failed (general error without specific provider message)
+ */
+ object InvalidPurchaseVerification : OpenIapError() {
+ val CODE = ErrorCode.PurchaseVerificationFailed.rawValue
+ override val code = CODE
+ override val message = MESSAGE
+
+ const val MESSAGE = "Purchase verification failed"
+ }
+
+ /**
+ * Purchase verification failed with a specific error from the verification provider (e.g., IAPKit)
+ */
+ class PurchaseVerificationFailed(val providerError: String) : OpenIapError() {
+ override val code = ErrorCode.PurchaseVerificationFailed.rawValue
+ override val message = "Purchase verification failed: $providerError"
+
+ companion object {
+ val CODE = ErrorCode.PurchaseVerificationFailed.rawValue
+ }
}
object NetworkError : OpenIapError() {
@@ -274,7 +301,7 @@ sealed class OpenIapError : Exception() {
ServiceTimeout.CODE to ServiceTimeout.MESSAGE,
PaymentNotAllowed.CODE to PaymentNotAllowed.MESSAGE,
BillingError.CODE to BillingError.MESSAGE,
- InvalidReceipt.CODE to InvalidReceipt.MESSAGE,
+ InvalidPurchaseVerification.CODE to InvalidPurchaseVerification.MESSAGE,
VerificationFailed.CODE to VerificationFailed.MESSAGE,
RestoreFailed.CODE to RestoreFailed.MESSAGE,
MissingCurrentActivity.CODE to MissingCurrentActivity.MESSAGE
diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt
index 562efc07..2579cd91 100644
--- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt
+++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt
@@ -27,6 +27,7 @@ interface OpenIapProtocol {
@Deprecated("Use verifyPurchase")
val validateReceipt: MutationValidateReceiptHandler
val verifyPurchase: MutationVerifyPurchaseHandler
+ val verifyPurchaseWithProvider: MutationVerifyPurchaseWithProviderHandler
val queryHandlers: QueryHandlers
val mutationHandlers: MutationHandlers
diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
index 9122ac37..69726825 100644
--- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
+++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
@@ -54,6 +54,9 @@ public enum class ErrorCode(val rawValue: String) {
ReceiptFailed("receipt-failed"),
ReceiptFinished("receipt-finished"),
ReceiptFinishedFailed("receipt-finished-failed"),
+ PurchaseVerificationFailed("purchase-verification-failed"),
+ PurchaseVerificationFinished("purchase-verification-finished"),
+ PurchaseVerificationFinishFailed("purchase-verification-finish-failed"),
NotPrepared("not-prepared"),
NotEnded("not-ended"),
AlreadyOwned("already-owned"),
@@ -101,6 +104,12 @@ public enum class ErrorCode(val rawValue: String) {
"ReceiptFinished" -> ErrorCode.ReceiptFinished
"receipt-finished-failed" -> ErrorCode.ReceiptFinishedFailed
"ReceiptFinishedFailed" -> ErrorCode.ReceiptFinishedFailed
+ "purchase-verification-failed" -> ErrorCode.PurchaseVerificationFailed
+ "PurchaseVerificationFailed" -> ErrorCode.PurchaseVerificationFailed
+ "purchase-verification-finished" -> ErrorCode.PurchaseVerificationFinished
+ "PurchaseVerificationFinished" -> ErrorCode.PurchaseVerificationFinished
+ "purchase-verification-finish-failed" -> ErrorCode.PurchaseVerificationFinishFailed
+ "PurchaseVerificationFinishFailed" -> ErrorCode.PurchaseVerificationFinishFailed
"not-prepared" -> ErrorCode.NotPrepared
"NotPrepared" -> ErrorCode.NotPrepared
"not-ended" -> ErrorCode.NotEnded
@@ -193,13 +202,16 @@ public enum class IapEvent(val rawValue: String) {
companion object {
fun fromJson(value: String): IapEvent = when (value) {
"purchase-updated" -> IapEvent.PurchaseUpdated
+ "PURCHASE_UPDATED" -> IapEvent.PurchaseUpdated
"PurchaseUpdated" -> IapEvent.PurchaseUpdated
"purchase-error" -> IapEvent.PurchaseError
+ "PURCHASE_ERROR" -> IapEvent.PurchaseError
"PurchaseError" -> IapEvent.PurchaseError
"promoted-product-ios" -> IapEvent.PromotedProductIos
- "PromotedProductIos" -> IapEvent.PromotedProductIos
+ "PROMOTED_PRODUCT_IOS" -> IapEvent.PromotedProductIos
"PromotedProductIOS" -> IapEvent.PromotedProductIos
"user-choice-billing-android" -> IapEvent.UserChoiceBillingAndroid
+ "USER_CHOICE_BILLING_ANDROID" -> IapEvent.UserChoiceBillingAndroid
"UserChoiceBillingAndroid" -> IapEvent.UserChoiceBillingAndroid
else -> throw IllegalArgumentException("Unknown IapEvent value: $value")
}
@@ -208,6 +220,91 @@ public enum class IapEvent(val rawValue: String) {
fun toJson(): String = rawValue
}
+/**
+ * Unified purchase states from IAPKit verification response.
+ */
+public enum class IapkitPurchaseState(val rawValue: String) {
+ /**
+ * User is entitled to the product (purchase is complete and active).
+ */
+ Entitled("entitled"),
+ /**
+ * Receipt is valid but still needs server acknowledgment.
+ */
+ PendingAcknowledgment("pending-acknowledgment"),
+ /**
+ * Purchase is in progress or awaiting confirmation.
+ */
+ Pending("pending"),
+ /**
+ * Purchase was cancelled or refunded.
+ */
+ Canceled("canceled"),
+ /**
+ * Subscription or entitlement has expired.
+ */
+ Expired("expired"),
+ /**
+ * Consumable purchase is ready to be fulfilled.
+ */
+ ReadyToConsume("ready-to-consume"),
+ /**
+ * Consumable item has been fulfilled/consumed.
+ */
+ Consumed("consumed"),
+ /**
+ * Purchase state could not be determined.
+ */
+ Unknown("unknown"),
+ /**
+ * Purchase receipt is not authentic (fraudulent or tampered).
+ */
+ Inauthentic("inauthentic");
+
+ companion object {
+ fun fromJson(value: String): IapkitPurchaseState = when (value) {
+ "entitled" -> IapkitPurchaseState.Entitled
+ "Entitled" -> IapkitPurchaseState.Entitled
+ "pending-acknowledgment" -> IapkitPurchaseState.PendingAcknowledgment
+ "PendingAcknowledgment" -> IapkitPurchaseState.PendingAcknowledgment
+ "pending" -> IapkitPurchaseState.Pending
+ "Pending" -> IapkitPurchaseState.Pending
+ "canceled" -> IapkitPurchaseState.Canceled
+ "Canceled" -> IapkitPurchaseState.Canceled
+ "expired" -> IapkitPurchaseState.Expired
+ "Expired" -> IapkitPurchaseState.Expired
+ "ready-to-consume" -> IapkitPurchaseState.ReadyToConsume
+ "ReadyToConsume" -> IapkitPurchaseState.ReadyToConsume
+ "consumed" -> IapkitPurchaseState.Consumed
+ "Consumed" -> IapkitPurchaseState.Consumed
+ "unknown" -> IapkitPurchaseState.Unknown
+ "Unknown" -> IapkitPurchaseState.Unknown
+ "inauthentic" -> IapkitPurchaseState.Inauthentic
+ "Inauthentic" -> IapkitPurchaseState.Inauthentic
+ else -> throw IllegalArgumentException("Unknown IapkitPurchaseState value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
+public enum class IapkitStore(val rawValue: String) {
+ Apple("apple"),
+ Google("google");
+
+ companion object {
+ fun fromJson(value: String): IapkitStore = when (value) {
+ "apple" -> IapkitStore.Apple
+ "Apple" -> IapkitStore.Apple
+ "google" -> IapkitStore.Google
+ "Google" -> IapkitStore.Google
+ else -> throw IllegalArgumentException("Unknown IapkitStore value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
public enum class IapPlatform(val rawValue: String) {
Ios("ios"),
Android("android");
@@ -338,6 +435,20 @@ public enum class PurchaseState(val rawValue: String) {
fun toJson(): String = rawValue
}
+public enum class PurchaseVerificationProvider(val rawValue: String) {
+ Iapkit("iapkit");
+
+ companion object {
+ fun fromJson(value: String): PurchaseVerificationProvider = when (value) {
+ "iapkit" -> PurchaseVerificationProvider.Iapkit
+ "Iapkit" -> PurchaseVerificationProvider.Iapkit
+ else -> throw IllegalArgumentException("Unknown PurchaseVerificationProvider value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
public enum class SubscriptionOfferTypeIOS(val rawValue: String) {
Introductory("introductory"),
Promotional("promotional");
@@ -1316,114 +1427,6 @@ public data class PurchaseOfferIOS(
)
}
-public data class ReceiptValidationResultAndroid(
- val autoRenewing: Boolean,
- val betaProduct: Boolean,
- val cancelDate: Double? = null,
- val cancelReason: String? = null,
- val deferredDate: Double? = null,
- val deferredSku: String? = null,
- val freeTrialEndDate: Double,
- val gracePeriodEndDate: Double,
- val parentProductId: String,
- val productId: String,
- val productType: String,
- val purchaseDate: Double,
- val quantity: Int,
- val receiptId: String,
- val renewalDate: Double,
- val term: String,
- val termSku: String,
- val testTransaction: Boolean
-) : ReceiptValidationResult {
-
- companion object {
- fun fromJson(json: Map): ReceiptValidationResultAndroid {
- return ReceiptValidationResultAndroid(
- autoRenewing = json["autoRenewing"] as Boolean,
- betaProduct = json["betaProduct"] as Boolean,
- cancelDate = (json["cancelDate"] as Number?)?.toDouble(),
- cancelReason = json["cancelReason"] as String?,
- deferredDate = (json["deferredDate"] as Number?)?.toDouble(),
- deferredSku = json["deferredSku"] as String?,
- freeTrialEndDate = (json["freeTrialEndDate"] as Number).toDouble(),
- gracePeriodEndDate = (json["gracePeriodEndDate"] as Number).toDouble(),
- parentProductId = json["parentProductId"] as String,
- productId = json["productId"] as String,
- productType = json["productType"] as String,
- purchaseDate = (json["purchaseDate"] as Number).toDouble(),
- quantity = (json["quantity"] as Number).toInt(),
- receiptId = json["receiptId"] as String,
- renewalDate = (json["renewalDate"] as Number).toDouble(),
- term = json["term"] as String,
- termSku = json["termSku"] as String,
- testTransaction = json["testTransaction"] as Boolean,
- )
- }
- }
-
- override fun toJson(): Map = mapOf(
- "__typename" to "ReceiptValidationResultAndroid",
- "autoRenewing" to autoRenewing,
- "betaProduct" to betaProduct,
- "cancelDate" to cancelDate,
- "cancelReason" to cancelReason,
- "deferredDate" to deferredDate,
- "deferredSku" to deferredSku,
- "freeTrialEndDate" to freeTrialEndDate,
- "gracePeriodEndDate" to gracePeriodEndDate,
- "parentProductId" to parentProductId,
- "productId" to productId,
- "productType" to productType,
- "purchaseDate" to purchaseDate,
- "quantity" to quantity,
- "receiptId" to receiptId,
- "renewalDate" to renewalDate,
- "term" to term,
- "termSku" to termSku,
- "testTransaction" to testTransaction,
- )
-}
-
-public data class ReceiptValidationResultIOS(
- /**
- * Whether the receipt is valid
- */
- val isValid: Boolean,
- /**
- * JWS representation
- */
- val jwsRepresentation: String,
- /**
- * Latest transaction if available
- */
- val latestTransaction: Purchase? = null,
- /**
- * Receipt data string
- */
- val receiptData: String
-) : ReceiptValidationResult {
-
- companion object {
- fun fromJson(json: Map): ReceiptValidationResultIOS {
- return ReceiptValidationResultIOS(
- isValid = json["isValid"] as Boolean,
- jwsRepresentation = json["jwsRepresentation"] as String,
- latestTransaction = (json["latestTransaction"] as Map?)?.let { Purchase.fromJson(it) },
- receiptData = json["receiptData"] as String,
- )
- }
- }
-
- override fun toJson(): Map = mapOf(
- "__typename" to "ReceiptValidationResultIOS",
- "isValid" to isValid,
- "jwsRepresentation" to jwsRepresentation,
- "latestTransaction" to latestTransaction?.toJson(),
- "receiptData" to receiptData,
- )
-}
-
public data class RefundResultIOS(
val message: String? = null,
val status: String
@@ -1534,6 +1537,36 @@ public data class RequestPurchaseResultPurchase(val value: Purchase?) : RequestP
public data class RequestPurchaseResultPurchases(val value: List?) : RequestPurchaseResult
+public data class RequestVerifyPurchaseWithIapkitResult(
+ /**
+ * Whether the purchase is valid (not falsified).
+ */
+ val isValid: Boolean,
+ /**
+ * The current state of the purchase.
+ */
+ val state: IapkitPurchaseState,
+ val store: IapkitStore
+) {
+
+ companion object {
+ fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitResult {
+ return RequestVerifyPurchaseWithIapkitResult(
+ isValid = json["isValid"] as Boolean,
+ state = IapkitPurchaseState.fromJson(json["state"] as String),
+ store = IapkitStore.fromJson(json["store"] as String),
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "__typename" to "RequestVerifyPurchaseWithIapkitResult",
+ "isValid" to isValid,
+ "state" to state.toJson(),
+ "store" to store.toJson(),
+ )
+}
+
public data class SubscriptionInfoIOS(
val introductoryOffer: SubscriptionOfferIOS? = null,
val promotionalOffers: List? = null,
@@ -1670,6 +1703,138 @@ public data class UserChoiceBillingDetails(
)
}
+public data class VerifyPurchaseResultAndroid(
+ val autoRenewing: Boolean,
+ val betaProduct: Boolean,
+ val cancelDate: Double? = null,
+ val cancelReason: String? = null,
+ val deferredDate: Double? = null,
+ val deferredSku: String? = null,
+ val freeTrialEndDate: Double,
+ val gracePeriodEndDate: Double,
+ val parentProductId: String,
+ val productId: String,
+ val productType: String,
+ val purchaseDate: Double,
+ val quantity: Int,
+ val receiptId: String,
+ val renewalDate: Double,
+ val term: String,
+ val termSku: String,
+ val testTransaction: Boolean
+) : VerifyPurchaseResult {
+
+ companion object {
+ fun fromJson(json: Map): VerifyPurchaseResultAndroid {
+ return VerifyPurchaseResultAndroid(
+ autoRenewing = json["autoRenewing"] as Boolean,
+ betaProduct = json["betaProduct"] as Boolean,
+ cancelDate = (json["cancelDate"] as Number?)?.toDouble(),
+ cancelReason = json["cancelReason"] as String?,
+ deferredDate = (json["deferredDate"] as Number?)?.toDouble(),
+ deferredSku = json["deferredSku"] as String?,
+ freeTrialEndDate = (json["freeTrialEndDate"] as Number).toDouble(),
+ gracePeriodEndDate = (json["gracePeriodEndDate"] as Number).toDouble(),
+ parentProductId = json["parentProductId"] as String,
+ productId = json["productId"] as String,
+ productType = json["productType"] as String,
+ purchaseDate = (json["purchaseDate"] as Number).toDouble(),
+ quantity = (json["quantity"] as Number).toInt(),
+ receiptId = json["receiptId"] as String,
+ renewalDate = (json["renewalDate"] as Number).toDouble(),
+ term = json["term"] as String,
+ termSku = json["termSku"] as String,
+ testTransaction = json["testTransaction"] as Boolean,
+ )
+ }
+ }
+
+ override fun toJson(): Map = mapOf(
+ "__typename" to "VerifyPurchaseResultAndroid",
+ "autoRenewing" to autoRenewing,
+ "betaProduct" to betaProduct,
+ "cancelDate" to cancelDate,
+ "cancelReason" to cancelReason,
+ "deferredDate" to deferredDate,
+ "deferredSku" to deferredSku,
+ "freeTrialEndDate" to freeTrialEndDate,
+ "gracePeriodEndDate" to gracePeriodEndDate,
+ "parentProductId" to parentProductId,
+ "productId" to productId,
+ "productType" to productType,
+ "purchaseDate" to purchaseDate,
+ "quantity" to quantity,
+ "receiptId" to receiptId,
+ "renewalDate" to renewalDate,
+ "term" to term,
+ "termSku" to termSku,
+ "testTransaction" to testTransaction,
+ )
+}
+
+public data class VerifyPurchaseResultIOS(
+ /**
+ * Whether the receipt is valid
+ */
+ val isValid: Boolean,
+ /**
+ * JWS representation
+ */
+ val jwsRepresentation: String,
+ /**
+ * Latest transaction if available
+ */
+ val latestTransaction: Purchase? = null,
+ /**
+ * Receipt data string
+ */
+ val receiptData: String
+) : VerifyPurchaseResult {
+
+ companion object {
+ fun fromJson(json: Map): VerifyPurchaseResultIOS {
+ return VerifyPurchaseResultIOS(
+ isValid = json["isValid"] as Boolean,
+ jwsRepresentation = json["jwsRepresentation"] as String,
+ latestTransaction = (json["latestTransaction"] as Map?)?.let { Purchase.fromJson(it) },
+ receiptData = json["receiptData"] as String,
+ )
+ }
+ }
+
+ override fun toJson(): Map = mapOf(
+ "__typename" to "VerifyPurchaseResultIOS",
+ "isValid" to isValid,
+ "jwsRepresentation" to jwsRepresentation,
+ "latestTransaction" to latestTransaction?.toJson(),
+ "receiptData" to receiptData,
+ )
+}
+
+public data class VerifyPurchaseWithProviderResult(
+ /**
+ * IAPKit verification results (can include Apple and Google entries)
+ */
+ val iapkit: List,
+ val provider: PurchaseVerificationProvider
+) {
+
+ companion object {
+ fun fromJson(json: Map): VerifyPurchaseWithProviderResult {
+ return VerifyPurchaseWithProviderResult(
+ iapkit = (json["iapkit"] as List<*>).map { RequestVerifyPurchaseWithIapkitResult.fromJson((it as Map)) },
+ provider = PurchaseVerificationProvider.fromJson(json["provider"] as String),
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "__typename" to "VerifyPurchaseWithProviderResult",
+ "iapkit" to iapkit.map { it.toJson() },
+ "provider" to provider.toJson(),
+ )
+}
+
public typealias VoidResult = Unit
// MARK: - Input Objects
@@ -1836,56 +2001,6 @@ public data class PurchaseOptions(
)
}
-public data class ReceiptValidationAndroidOptions(
- val accessToken: String,
- val isSub: Boolean? = null,
- val packageName: String,
- val productToken: String
-) {
- companion object {
- fun fromJson(json: Map): ReceiptValidationAndroidOptions {
- return ReceiptValidationAndroidOptions(
- accessToken = json["accessToken"] as String,
- isSub = json["isSub"] as Boolean?,
- packageName = json["packageName"] as String,
- productToken = json["productToken"] as String,
- )
- }
- }
-
- fun toJson(): Map = mapOf(
- "accessToken" to accessToken,
- "isSub" to isSub,
- "packageName" to packageName,
- "productToken" to productToken,
- )
-}
-
-public data class ReceiptValidationProps(
- /**
- * Android-specific validation options
- */
- val androidOptions: ReceiptValidationAndroidOptions? = null,
- /**
- * Product SKU to validate
- */
- val sku: String
-) {
- companion object {
- fun fromJson(json: Map): ReceiptValidationProps {
- return ReceiptValidationProps(
- androidOptions = (json["androidOptions"] as Map?)?.let { ReceiptValidationAndroidOptions.fromJson(it) },
- sku = json["sku"] as String,
- )
- }
- }
-
- fun toJson(): Map = mapOf(
- "androidOptions" to androidOptions?.toJson(),
- "sku" to sku,
- )
-}
-
public data class RequestPurchaseAndroidProps(
/**
* Personalized offer flag
@@ -2152,6 +2267,144 @@ public data class RequestSubscriptionPropsByPlatforms(
)
}
+public data class RequestVerifyPurchaseWithIapkitAppleProps(
+ /**
+ * The JWS token returned with the purchase response.
+ */
+ val jws: String
+) {
+ companion object {
+ fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitAppleProps {
+ return RequestVerifyPurchaseWithIapkitAppleProps(
+ jws = json["jws"] as String,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "jws" to jws,
+ )
+}
+
+public data class RequestVerifyPurchaseWithIapkitGoogleProps(
+ /**
+ * The token provided to the user's device when the product or subscription was purchased.
+ */
+ val purchaseToken: String
+) {
+ companion object {
+ fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitGoogleProps {
+ return RequestVerifyPurchaseWithIapkitGoogleProps(
+ purchaseToken = json["purchaseToken"] as String,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "purchaseToken" to purchaseToken,
+ )
+}
+
+public data class RequestVerifyPurchaseWithIapkitProps(
+ /**
+ * API key used for the Authorization header (Bearer {apiKey}).
+ */
+ val apiKey: String? = null,
+ /**
+ * Apple verification parameters.
+ */
+ val apple: RequestVerifyPurchaseWithIapkitAppleProps? = null,
+ /**
+ * Google verification parameters.
+ */
+ val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null
+) {
+ companion object {
+ fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitProps {
+ return RequestVerifyPurchaseWithIapkitProps(
+ apiKey = json["apiKey"] as String?,
+ apple = (json["apple"] as Map?)?.let { RequestVerifyPurchaseWithIapkitAppleProps.fromJson(it) },
+ google = (json["google"] as Map?)?.let { RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(it) },
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "apiKey" to apiKey,
+ "apple" to apple?.toJson(),
+ "google" to google?.toJson(),
+ )
+}
+
+public data class VerifyPurchaseAndroidOptions(
+ val accessToken: String,
+ val isSub: Boolean? = null,
+ val packageName: String,
+ val productToken: String
+) {
+ companion object {
+ fun fromJson(json: Map): VerifyPurchaseAndroidOptions {
+ return VerifyPurchaseAndroidOptions(
+ accessToken = json["accessToken"] as String,
+ isSub = json["isSub"] as Boolean?,
+ packageName = json["packageName"] as String,
+ productToken = json["productToken"] as String,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "accessToken" to accessToken,
+ "isSub" to isSub,
+ "packageName" to packageName,
+ "productToken" to productToken,
+ )
+}
+
+public data class VerifyPurchaseProps(
+ /**
+ * Android-specific validation options
+ */
+ val androidOptions: VerifyPurchaseAndroidOptions? = null,
+ /**
+ * Product SKU to validate
+ */
+ val sku: String
+) {
+ companion object {
+ fun fromJson(json: Map): VerifyPurchaseProps {
+ return VerifyPurchaseProps(
+ androidOptions = (json["androidOptions"] as Map?)?.let { VerifyPurchaseAndroidOptions.fromJson(it) },
+ sku = json["sku"] as String,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "androidOptions" to androidOptions?.toJson(),
+ "sku" to sku,
+ )
+}
+
+public data class VerifyPurchaseWithProviderProps(
+ val iapkit: RequestVerifyPurchaseWithIapkitProps? = null,
+ val provider: PurchaseVerificationProvider
+) {
+ companion object {
+ fun fromJson(json: Map): VerifyPurchaseWithProviderProps {
+ return VerifyPurchaseWithProviderProps(
+ iapkit = (json["iapkit"] as Map?)?.let { RequestVerifyPurchaseWithIapkitProps.fromJson(it) },
+ provider = PurchaseVerificationProvider.fromJson(json["provider"] as String),
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "iapkit" to iapkit?.toJson(),
+ "provider" to provider.toJson(),
+ )
+}
+
// MARK: - Unions
public sealed interface Product : ProductCommon {
@@ -2220,15 +2473,15 @@ public sealed interface Purchase : PurchaseCommon {
}
}
-public sealed interface ReceiptValidationResult {
+public sealed interface VerifyPurchaseResult {
fun toJson(): Map
companion object {
- fun fromJson(json: Map): ReceiptValidationResult {
+ fun fromJson(json: Map): VerifyPurchaseResult {
return when (json["__typename"] as String?) {
- "ReceiptValidationResultAndroid" -> ReceiptValidationResultAndroid.fromJson(json)
- "ReceiptValidationResultIOS" -> ReceiptValidationResultIOS.fromJson(json)
- else -> throw IllegalArgumentException("Unknown __typename for ReceiptValidationResult: ${json["__typename"]}")
+ "VerifyPurchaseResultAndroid" -> VerifyPurchaseResultAndroid.fromJson(json)
+ "VerifyPurchaseResultIOS" -> VerifyPurchaseResultIOS.fromJson(json)
+ else -> throw IllegalArgumentException("Unknown __typename for VerifyPurchaseResult: ${json["__typename"]}")
}
}
}
@@ -2334,11 +2587,15 @@ public interface MutationResolver {
/**
* Validate purchase receipts with the configured providers
*/
- suspend fun validateReceipt(options: ReceiptValidationProps): ReceiptValidationResult
+ suspend fun validateReceipt(options: VerifyPurchaseProps): VerifyPurchaseResult
/**
* Verify purchases with the configured providers
*/
- suspend fun verifyPurchase(options: ReceiptValidationProps): ReceiptValidationResult
+ suspend fun verifyPurchase(options: VerifyPurchaseProps): VerifyPurchaseResult
+ /**
+ * Verify purchases with a specific provider (e.g., IAPKit)
+ */
+ suspend fun verifyPurchaseWithProvider(options: VerifyPurchaseWithProviderProps): VerifyPurchaseWithProviderResult
}
/**
@@ -2416,7 +2673,7 @@ public interface QueryResolver {
/**
* Validate a receipt for a specific product
*/
- suspend fun validateReceiptIOS(options: ReceiptValidationProps): ReceiptValidationResultIOS
+ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS
}
/**
@@ -2465,8 +2722,9 @@ public typealias MutationRestorePurchasesHandler = suspend () -> Unit
public typealias MutationShowAlternativeBillingDialogAndroidHandler = suspend () -> Boolean
public typealias MutationShowManageSubscriptionsIOSHandler = suspend () -> List
public typealias MutationSyncIOSHandler = suspend () -> Boolean
-public typealias MutationValidateReceiptHandler = suspend (options: ReceiptValidationProps) -> ReceiptValidationResult
-public typealias MutationVerifyPurchaseHandler = suspend (options: ReceiptValidationProps) -> ReceiptValidationResult
+public typealias MutationValidateReceiptHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResult
+public typealias MutationVerifyPurchaseHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResult
+public typealias MutationVerifyPurchaseWithProviderHandler = suspend (options: VerifyPurchaseWithProviderProps) -> VerifyPurchaseWithProviderResult
public data class MutationHandlers(
val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler? = null,
@@ -2489,7 +2747,8 @@ public data class MutationHandlers(
val showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? = null,
val syncIOS: MutationSyncIOSHandler? = null,
val validateReceipt: MutationValidateReceiptHandler? = null,
- val verifyPurchase: MutationVerifyPurchaseHandler? = null
+ val verifyPurchase: MutationVerifyPurchaseHandler? = null,
+ val verifyPurchaseWithProvider: MutationVerifyPurchaseWithProviderHandler? = null
)
// MARK: - Query Helpers
@@ -2511,7 +2770,7 @@ public typealias QueryIsEligibleForIntroOfferIOSHandler = suspend (groupID: Stri
public typealias QueryIsTransactionVerifiedIOSHandler = suspend (sku: String) -> Boolean
public typealias QueryLatestTransactionIOSHandler = suspend (sku: String) -> PurchaseIOS?
public typealias QuerySubscriptionStatusIOSHandler = suspend (sku: String) -> List
-public typealias QueryValidateReceiptIOSHandler = suspend (options: ReceiptValidationProps) -> ReceiptValidationResultIOS
+public typealias QueryValidateReceiptIOSHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResultIOS
public data class QueryHandlers(
val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null,
diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/compat/ReceiptValidationCompat.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/compat/ReceiptValidationCompat.kt
new file mode 100644
index 00000000..b073204a
--- /dev/null
+++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/compat/ReceiptValidationCompat.kt
@@ -0,0 +1,23 @@
+package dev.hyo.openiap.compat
+
+import dev.hyo.openiap.VerifyPurchaseProps
+import dev.hyo.openiap.VerifyPurchaseResult
+import dev.hyo.openiap.VerifyPurchaseResultIOS
+
+@Deprecated(
+ message = "Use VerifyPurchaseProps instead",
+ replaceWith = ReplaceWith("VerifyPurchaseProps", "dev.hyo.openiap.VerifyPurchaseProps")
+)
+typealias ReceiptValidationProps = VerifyPurchaseProps
+
+@Deprecated(
+ message = "Use VerifyPurchaseResult instead",
+ replaceWith = ReplaceWith("VerifyPurchaseResult", "dev.hyo.openiap.VerifyPurchaseResult")
+)
+typealias ReceiptValidationResult = VerifyPurchaseResult
+
+@Deprecated(
+ message = "Use VerifyPurchaseResultIOS instead",
+ replaceWith = ReplaceWith("VerifyPurchaseResultIOS", "dev.hyo.openiap.VerifyPurchaseResultIOS")
+)
+typealias ReceiptValidationResultIOS = VerifyPurchaseResultIOS
diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt
new file mode 100644
index 00000000..0cf91cb2
--- /dev/null
+++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt
@@ -0,0 +1,248 @@
+package dev.hyo.openiap.utils
+
+import com.google.gson.Gson
+import com.google.gson.JsonSyntaxException
+import com.google.gson.reflect.TypeToken
+import dev.hyo.openiap.IapkitStore
+import dev.hyo.openiap.OpenIapError
+import dev.hyo.openiap.OpenIapLog
+import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitProps
+import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitResult
+import dev.hyo.openiap.VerifyPurchaseProps
+import dev.hyo.openiap.VerifyPurchaseResultAndroid
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
+
+private const val DEFAULT_IAPKIT_ENDPOINT = "https://api.iapkit.com/v1/purchase/verify"
+private val gson = Gson()
+
+private fun openConnection(url: String): HttpURLConnection {
+ return URL(url).openConnection() as HttpURLConnection
+}
+
+suspend fun verifyPurchaseWithGooglePlay(
+ props: VerifyPurchaseProps,
+ tag: String,
+ connectionFactory: (String) -> HttpURLConnection = ::openConnection
+): VerifyPurchaseResultAndroid = withContext(Dispatchers.IO) {
+ val options = props.androidOptions
+ ?: throw IllegalArgumentException(
+ "Android validation requires packageName, productToken, and accessToken"
+ )
+
+ if (
+ options.packageName.isBlank() ||
+ options.productToken.isBlank() ||
+ options.accessToken.isBlank()
+ ) {
+ throw IllegalArgumentException(
+ "Android validation requires packageName, productToken, and accessToken"
+ )
+ }
+
+ val typeSegment = if (options.isSub == true) "subscriptions" else "products"
+ val baseUrl = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
+ val url = "$baseUrl/${options.packageName}/purchases/$typeSegment/${props.sku}/tokens/${options.productToken}"
+
+ val connection = connectionFactory(url).apply {
+ requestMethod = "GET"
+ setRequestProperty("Content-Type", "application/json")
+ setRequestProperty("Authorization", "Bearer ${options.accessToken}")
+ }
+
+ try {
+ val statusCode = connection.responseCode
+ val responseBody = (if (statusCode in 200..299) connection.inputStream else connection.errorStream)
+ ?.bufferedReader()
+ ?.use { it.readText() }
+ .orElse("")
+
+ if (statusCode !in 200..299) {
+ OpenIapLog.warn("verifyPurchase failed (HTTP $statusCode): $responseBody", tag)
+ throw OpenIapError.InvalidPurchaseVerification
+ }
+
+ try {
+ gson.fromJson(responseBody, VerifyPurchaseResultAndroid::class.java)
+ ?: throw OpenIapError.InvalidPurchaseVerification
+ } catch (jsonError: JsonSyntaxException) {
+ OpenIapLog.warn("Failed to parse purchase verification response: ${jsonError.message}", tag)
+ throw OpenIapError.InvalidPurchaseVerification
+ }
+ } catch (io: IOException) {
+ OpenIapLog.warn("Network error during purchase verification: ${io.message}", tag)
+ throw OpenIapError.NetworkError
+ } finally {
+ connection.disconnect()
+ }
+}
+
+suspend fun verifyPurchaseWithIapkit(
+ props: RequestVerifyPurchaseWithIapkitProps,
+ tag: String,
+ connectionFactory: (String) -> HttpURLConnection = ::openConnection
+): List = withContext(Dispatchers.IO) {
+ val endpoint = DEFAULT_IAPKIT_ENDPOINT
+
+ // On Android, only Google verification is supported
+ if (props.google == null) {
+ throw IllegalArgumentException("IAPKit verification on Android requires google payload")
+ }
+
+ val requests: List>> = listOf(
+ IapkitStore.Google to buildPayload(props, IapkitStore.Google)
+ )
+
+ requests.map { (store, payload) ->
+ val connection = connectionFactory(endpoint).apply {
+ requestMethod = "POST"
+ doOutput = true
+ setRequestProperty("Content-Type", "application/json")
+ props.apiKey?.takeIf { it.isNotBlank() }?.let { apiKey ->
+ setRequestProperty("Authorization", "Bearer $apiKey")
+ }
+ }
+
+ try {
+ val body = gson.toJson(payload)
+
+ // Log request details for debugging
+ OpenIapLog.debug("IAPKit request URL: $endpoint", tag)
+ OpenIapLog.debug("IAPKit request body: $body", tag)
+
+ connection.outputStream.use { stream ->
+ stream.write(body.toByteArray())
+ }
+
+ val statusCode = connection.responseCode
+ val responseBody = (if (statusCode in 200..299) connection.inputStream else connection.errorStream)
+ ?.bufferedReader()
+ ?.use { it.readText() }
+ .orElse("")
+
+ OpenIapLog.debug("IAPKit response (HTTP $statusCode): $responseBody", tag)
+
+ if (statusCode !in 200..299) {
+ OpenIapLog.warn("verifyPurchaseWithProvider failed (HTTP $statusCode) [$store]: $responseBody", tag)
+ // Extract concise error message from IAPKit response
+ // IAPKit returns nested error format - extract the deepest originalError
+ val errorMessage = try {
+ val mapType = object : TypeToken>() {}.type
+ val errorJson = gson.fromJson>(responseBody, mapType)
+ extractIapkitErrorMessage(errorJson) ?: "HTTP $statusCode"
+ } catch (e: Exception) {
+ "HTTP $statusCode"
+ }
+ throw OpenIapError.PurchaseVerificationFailed(errorMessage)
+ }
+
+ try {
+ val mapType = object : TypeToken>() {}.type
+ val parsed = gson.fromJson>(responseBody, mapType)
+ // IAPKit API returns UPPER_SNAKE_CASE (e.g., "PURCHASED", "PENDING_ACKNOWLEDGMENT")
+ // Types.kt expects lower-kebab-case (e.g., "purchased", "pending-acknowledgment")
+ val normalizedParsed = parsed.toMutableMap().apply {
+ val state = this["state"] as? String
+ if (state != null) {
+ this["state"] = state.lowercase().replace("_", "-")
+ }
+ // IAPKit response doesn't include store, add it from request
+ if (this["store"] == null) {
+ this["store"] = store.toJson()
+ }
+ }
+ OpenIapLog.debug("IAPKit normalized response: $normalizedParsed", tag)
+ RequestVerifyPurchaseWithIapkitResult.fromJson(normalizedParsed)
+ } catch (jsonError: Exception) {
+ OpenIapLog.warn("Failed to parse IAPKit verification response: ${jsonError.message}", tag)
+ throw OpenIapError.PurchaseVerificationFailed("Failed to parse response")
+ }
+ } catch (io: IOException) {
+ OpenIapLog.warn("Network error during IAPKit verification: ${io.message}", tag)
+ throw OpenIapError.PurchaseVerificationFailed("Network error: ${io.message}")
+ } finally {
+ connection.disconnect()
+ }
+ }
+}
+
+private fun buildPayload(
+ props: RequestVerifyPurchaseWithIapkitProps,
+ store: IapkitStore
+): Map {
+ return when (store) {
+ IapkitStore.Google -> {
+ val google = props.google
+ ?: throw IllegalArgumentException("IAPKit Google verification requires google options")
+ if (google.purchaseToken.isBlank()) {
+ throw IllegalArgumentException(
+ "IAPKit Google verification requires purchaseToken"
+ )
+ }
+ mutableMapOf(
+ "store" to store.toJson(),
+ "purchaseToken" to google.purchaseToken
+ )
+ }
+ else -> throw IllegalArgumentException("IAPKit verification on Android supports Google payloads only")
+ }
+}
+
+private fun String?.orElse(fallback: String): String = this ?: fallback
+
+/**
+ * Extract concise error message from IAPKit error response.
+ * IAPKit returns nested error structures - we extract the deepest originalError for clarity.
+ *
+ * Example input:
+ * {"error":"PLAY_STORE_VERIFICATION_ERROR","message":"Failed to verify Google Play purchase: {...}",
+ * "details":{"originalError":"..."}}
+ *
+ * Returns: "The purchase token is no longer valid." (the deepest originalError)
+ */
+@Suppress("UNCHECKED_CAST")
+private fun extractIapkitErrorMessage(json: Map): String? {
+ // Try errors array format first: { "errors": [{ "code": "...", "message": "..." }] }
+ val errorsRaw = json["errors"]
+ if (errorsRaw is List<*>) {
+ val firstError = errorsRaw.firstOrNull()
+ if (firstError is Map<*, *>) {
+ val errorMap = firstError as Map
+ // Recursively extract from first error
+ return extractIapkitErrorMessage(errorMap)
+ }
+ }
+
+ // Try to get details.originalError (deepest level)
+ val detailsRaw = json["details"]
+ if (detailsRaw is Map<*, *>) {
+ val details = detailsRaw as Map
+ val originalError = details["originalError"]
+ if (originalError is String) {
+ // originalError might be a JSON string, try to parse it
+ return try {
+ val nested = gson.fromJson(originalError, Map::class.java) as? Map
+ if (nested != null) {
+ extractIapkitErrorMessage(nested) ?: originalError
+ } else {
+ originalError
+ }
+ } catch (e: Exception) {
+ // Not JSON, use as-is
+ originalError
+ }
+ }
+ }
+
+ // Try message field, but avoid the verbose nested JSON string
+ val message = json["message"] as? String
+ if (message != null && !message.contains("{\"error\"")) {
+ return message
+ }
+
+ // Fallback to error code
+ return json["error"] as? String
+}
diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/ReceiptValidator.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/ReceiptValidator.kt
deleted file mode 100644
index 0a71b0f8..00000000
--- a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/ReceiptValidator.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-package dev.hyo.openiap.utils
-
-import com.google.gson.Gson
-import com.google.gson.JsonSyntaxException
-import dev.hyo.openiap.OpenIapError
-import dev.hyo.openiap.OpenIapLog
-import dev.hyo.openiap.ReceiptValidationProps
-import dev.hyo.openiap.ReceiptValidationResultAndroid
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import java.io.IOException
-import java.net.HttpURLConnection
-import java.net.URL
-
-private const val VALIDATION_BASE_URL =
- "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
-private val gson = Gson()
-
-private fun openConnection(url: String): HttpURLConnection {
- return URL(url).openConnection() as HttpURLConnection
-}
-
-suspend fun validateReceiptWithGooglePlay(
- props: ReceiptValidationProps,
- tag: String,
- connectionFactory: (String) -> HttpURLConnection = ::openConnection
-): ReceiptValidationResultAndroid = withContext(Dispatchers.IO) {
- val options = props.androidOptions
- ?: throw IllegalArgumentException(
- "Android validation requires packageName, productToken, and accessToken"
- )
-
- if (
- options.packageName.isBlank() ||
- options.productToken.isBlank() ||
- options.accessToken.isBlank()
- ) {
- throw IllegalArgumentException(
- "Android validation requires packageName, productToken, and accessToken"
- )
- }
-
- val typeSegment = if (options.isSub == true) "subscriptions" else "products"
- val url =
- "$VALIDATION_BASE_URL/${options.packageName}/purchases/$typeSegment/${props.sku}/tokens/${options.productToken}"
-
- val connection = connectionFactory(url).apply {
- requestMethod = "GET"
- setRequestProperty("Content-Type", "application/json")
- setRequestProperty("Authorization", "Bearer ${options.accessToken}")
- }
-
- try {
- val statusCode = connection.responseCode
- val responseBody = (if (statusCode in 200..299) connection.inputStream else connection.errorStream)
- ?.bufferedReader()
- ?.use { it.readText() }
- .orElse("")
-
- if (statusCode !in 200..299) {
- OpenIapLog.warn("verifyPurchase failed (HTTP $statusCode): $responseBody", tag)
- throw OpenIapError.InvalidReceipt
- }
-
- try {
- gson.fromJson(responseBody, ReceiptValidationResultAndroid::class.java)
- ?: throw OpenIapError.InvalidReceipt
- } catch (jsonError: JsonSyntaxException) {
- OpenIapLog.warn("Failed to parse receipt validation response: ${jsonError.message}", tag)
- throw OpenIapError.InvalidReceipt
- }
- } catch (io: IOException) {
- OpenIapLog.warn("Network error during receipt validation: ${io.message}", tag)
- throw OpenIapError.NetworkError
- } finally {
- connection.disconnect()
- }
-}
-
-private fun String?.orElse(fallback: String): String = this ?: fallback
diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
index 16cc93a5..1744950d 100644
--- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
@@ -33,7 +33,9 @@ import dev.hyo.openiap.MutationRequestPurchaseHandler
import dev.hyo.openiap.MutationRestorePurchasesHandler
import dev.hyo.openiap.MutationValidateReceiptHandler
import dev.hyo.openiap.MutationVerifyPurchaseHandler
+import dev.hyo.openiap.MutationVerifyPurchaseWithProviderHandler
import dev.hyo.openiap.MutationHandlers
+import dev.hyo.openiap.PurchaseVerificationProvider
import dev.hyo.openiap.QueryHandlers
import dev.hyo.openiap.SubscriptionHandlers
import dev.hyo.openiap.QueryFetchProductsHandler
@@ -43,7 +45,7 @@ import dev.hyo.openiap.QueryHasActiveSubscriptionsHandler
import dev.hyo.openiap.RequestPurchaseResultPurchases
import dev.hyo.openiap.SubscriptionPurchaseErrorHandler
import dev.hyo.openiap.SubscriptionPurchaseUpdatedHandler
-import dev.hyo.openiap.ReceiptValidationProps
+import dev.hyo.openiap.VerifyPurchaseProps
import dev.hyo.openiap.helpers.AndroidPurchaseArgs
import dev.hyo.openiap.helpers.onPurchaseError
import dev.hyo.openiap.helpers.onPurchaseUpdated
@@ -60,7 +62,8 @@ import dev.hyo.openiap.utils.BillingConverters.toSubscriptionProduct
import dev.hyo.openiap.utils.fromBillingState
import dev.hyo.openiap.utils.toActiveSubscription
import dev.hyo.openiap.utils.toProduct
-import dev.hyo.openiap.utils.validateReceiptWithGooglePlay
+import dev.hyo.openiap.utils.verifyPurchaseWithGooglePlay
+import dev.hyo.openiap.utils.verifyPurchaseWithIapkit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
@@ -802,7 +805,18 @@ class OpenIapModule(
}
override val verifyPurchase: MutationVerifyPurchaseHandler = { props ->
- validateReceiptWithGooglePlay(props, TAG)
+ verifyPurchaseWithGooglePlay(props, TAG)
+ }
+
+ override val verifyPurchaseWithProvider: MutationVerifyPurchaseWithProviderHandler = { props ->
+ if (props.provider != PurchaseVerificationProvider.Iapkit) {
+ throw OpenIapError.FeatureNotSupported
+ }
+ val options = props.iapkit ?: throw OpenIapError.DeveloperError
+ VerifyPurchaseWithProviderResult(
+ iapkit = verifyPurchaseWithIapkit(options, TAG),
+ provider = props.provider
+ )
}
private val purchaseError: SubscriptionPurchaseErrorHandler = {
@@ -832,7 +846,8 @@ class OpenIapModule(
requestPurchase = requestPurchase,
restorePurchases = restorePurchases,
validateReceipt = validateReceipt,
- verifyPurchase = verifyPurchase
+ verifyPurchase = verifyPurchase,
+ verifyPurchaseWithProvider = verifyPurchaseWithProvider
)
override val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers(
diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt
index 934d7818..0181eaf9 100644
--- a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt
+++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt
@@ -51,9 +51,10 @@ class OpenIapErrorTest {
@Test
fun `InvalidReceipt has correct code and message`() {
+ @Suppress("DEPRECATION")
val error = OpenIapError.InvalidReceipt
- assertEquals(ErrorCode.ReceiptFailed.rawValue, error.code)
- assertEquals("Invalid receipt", error.message)
+ assertEquals(ErrorCode.PurchaseVerificationFailed.rawValue, error.code)
+ assertEquals("Purchase verification failed", error.message)
}
@Test
@@ -319,7 +320,7 @@ class OpenIapErrorTest {
OpenIapError.PurchaseDeferred to ErrorCode.DeferredPayment.rawValue,
OpenIapError.PaymentNotAllowed to ErrorCode.UserError.rawValue,
OpenIapError.BillingError to ErrorCode.ServiceError.rawValue,
- OpenIapError.InvalidReceipt to ErrorCode.ReceiptFailed.rawValue,
+ @Suppress("DEPRECATION") OpenIapError.InvalidReceipt to ErrorCode.PurchaseVerificationFailed.rawValue,
OpenIapError.NetworkError to ErrorCode.NetworkError.rawValue,
OpenIapError.VerificationFailed to ErrorCode.TransactionValidationFailed.rawValue,
OpenIapError.RestoreFailed to ErrorCode.SyncError.rawValue,
diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt
new file mode 100644
index 00000000..131b7b75
--- /dev/null
+++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt
@@ -0,0 +1,259 @@
+package dev.hyo.openiap
+
+import com.google.gson.Gson
+import dev.hyo.openiap.utils.verifyPurchaseWithGooglePlay
+import dev.hyo.openiap.utils.verifyPurchaseWithIapkit
+import dev.hyo.openiap.IapkitStore
+import dev.hyo.openiap.IapkitPurchaseState
+import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitGoogleProps
+import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitProps
+import dev.hyo.openiap.VerifyPurchaseAndroidOptions
+import dev.hyo.openiap.VerifyPurchaseProps
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.HttpURLConnection
+import java.net.URL
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PurchaseVerificationValidatorTest {
+
+ @Test
+ fun `verifyPurchaseWithGooglePlay throws without androidOptions`() = runTest {
+ val props = VerifyPurchaseProps(androidOptions = null, sku = "product.sku")
+
+ try {
+ verifyPurchaseWithGooglePlay(props, "TEST_TAG") { _ ->
+ throw AssertionError("Connection should not be created when options are missing")
+ }
+ throw AssertionError("Expected IllegalArgumentException for missing androidOptions")
+ } catch (expected: IllegalArgumentException) {
+ // Expected path
+ }
+ }
+
+ @Test
+ fun `verifyPurchaseWithGooglePlay parses successful response`() = runTest {
+ val options = VerifyPurchaseAndroidOptions(
+ accessToken = "token",
+ isSub = true,
+ packageName = "dev.hyo.app",
+ productToken = "purchaseToken"
+ )
+ val props = VerifyPurchaseProps(androidOptions = options, sku = "premium_monthly")
+ val body = """
+ {
+ "autoRenewing": true,
+ "betaProduct": false,
+ "cancelDate": null,
+ "cancelReason": null,
+ "deferredDate": null,
+ "deferredSku": null,
+ "freeTrialEndDate": 1.0,
+ "gracePeriodEndDate": 2.0,
+ "parentProductId": "parent",
+ "productId": "premium_monthly",
+ "productType": "subs",
+ "purchaseDate": 3.0,
+ "quantity": 1,
+ "receiptId": "rid-123",
+ "renewalDate": 4.0,
+ "term": "P1M",
+ "termSku": "plan_monthly",
+ "testTransaction": false
+ }
+ """.trimIndent()
+
+ val result = verifyPurchaseWithGooglePlay(
+ props,
+ "TEST_TAG"
+ ) { _ -> FakeHttpURLConnection(200, body) }
+
+ assertEquals("premium_monthly", result.productId)
+ assertEquals("plan_monthly", result.termSku)
+ assertEquals(true, result.autoRenewing)
+ assertEquals(false, result.betaProduct)
+ assertEquals(1, result.quantity)
+ }
+
+ @Test
+ fun `verifyPurchaseWithGooglePlay wraps non-2xx as InvalidPurchaseVerification`() = runTest {
+ val options = VerifyPurchaseAndroidOptions(
+ accessToken = "token",
+ isSub = false,
+ packageName = "dev.hyo.app",
+ productToken = "purchaseToken"
+ )
+ val props = VerifyPurchaseProps(androidOptions = options, sku = "premium_monthly")
+
+ try {
+ verifyPurchaseWithGooglePlay(
+ props,
+ "TEST_TAG"
+ ) { _ -> FakeHttpURLConnection(401, """{"error":"unauthorized"}""") }
+ throw AssertionError("Expected InvalidPurchaseVerification for non-2xx response")
+ } catch (error: OpenIapError.InvalidPurchaseVerification) {
+ // InvalidPurchaseVerification is the expected exception
+ assertTrue(true)
+ }
+ }
+
+ @Test
+ fun `verifyPurchaseWithIapkit throws without google props`() = runTest {
+ val props = RequestVerifyPurchaseWithIapkitProps(
+ apiKey = null,
+ apple = null,
+ google = null
+ )
+
+ try {
+ verifyPurchaseWithIapkit(props, "TEST") { _ ->
+ throw AssertionError("Connection should not be created when google props are missing")
+ }
+ throw AssertionError("Expected IllegalArgumentException for missing google props")
+ } catch (expected: IllegalArgumentException) {
+ // Expected path
+ }
+ }
+
+ @Test
+ fun `verifyPurchaseWithIapkit uses default endpoint`() = runTest {
+ val props = RequestVerifyPurchaseWithIapkitProps(
+ apiKey = null,
+ apple = null,
+ google = RequestVerifyPurchaseWithIapkitGoogleProps(
+ purchaseToken = "token-abc"
+ )
+ )
+
+ verifyPurchaseWithIapkit(props, "TEST") { _ ->
+ FakeHttpURLConnection(200, """{"store":"google","isValid":true,"state":"ENTITLED"}""")
+ }
+ }
+
+ @Test
+ fun `verifyPurchaseWithIapkit throws when google payload missing purchaseToken`() = runTest {
+ val props = RequestVerifyPurchaseWithIapkitProps(
+ apiKey = null,
+ apple = null,
+ google = RequestVerifyPurchaseWithIapkitGoogleProps(
+ purchaseToken = ""
+ )
+ )
+
+ try {
+ verifyPurchaseWithIapkit(props, "TEST") { _ ->
+ throw AssertionError("Connection should not be created when google payload is invalid")
+ }
+ throw AssertionError("Expected IllegalArgumentException for invalid google payload")
+ } catch (expected: IllegalArgumentException) {
+ // Expected path
+ }
+ }
+
+ @Test
+ fun `verifyPurchaseWithIapkit posts google receipt with api key`() = runTest {
+ val props = RequestVerifyPurchaseWithIapkitProps(
+ apiKey = "secret",
+ apple = null,
+ google = RequestVerifyPurchaseWithIapkitGoogleProps(
+ purchaseToken = "token-123"
+ )
+ )
+
+ val connection = FakeHttpURLConnection(200, """{"store":"google","isValid":true,"state":"ENTITLED"}""")
+ val result = verifyPurchaseWithIapkit(props, "TEST") { _ -> connection }
+
+ assertEquals(1, result.size)
+ assertEquals(IapkitStore.Google, result.first().store)
+ assertTrue(result.first().isValid)
+ assertEquals("Bearer secret", connection.headers["Authorization"])
+
+ val bodyMap = Gson().fromJson(requireNotNull(connection.writtenBody), Map::class.java) as Map<*, *>
+ assertEquals("google", bodyMap["store"])
+ assertEquals("token-123", bodyMap["purchaseToken"])
+ }
+
+ @Test
+ fun `verifyPurchaseWithIapkit posts google purchase details`() = runTest {
+ val props = RequestVerifyPurchaseWithIapkitProps(
+ apiKey = null,
+ apple = null,
+ google = RequestVerifyPurchaseWithIapkitGoogleProps(
+ purchaseToken = "token-123"
+ )
+ )
+
+ val connection = FakeHttpURLConnection(200, """{"store":"google","isValid":false,"state":"INAUTHENTIC"}""")
+ val result = verifyPurchaseWithIapkit(props, "TEST") { _ -> connection }
+
+ assertEquals(1, result.size)
+ assertEquals(IapkitStore.Google, result.first().store)
+ assertEquals(false, result.first().isValid)
+
+ val bodyMap = Gson().fromJson(requireNotNull(connection.writtenBody), Map::class.java) as Map<*, *>
+ assertEquals("google", bodyMap["store"])
+ assertEquals("token-123", bodyMap["purchaseToken"])
+ }
+
+ @Test
+ fun `verifyPurchaseWithIapkit wraps non-2xx as PurchaseVerificationFailed`() = runTest {
+ val props = RequestVerifyPurchaseWithIapkitProps(
+ apiKey = null,
+ apple = null,
+ google = RequestVerifyPurchaseWithIapkitGoogleProps(
+ purchaseToken = "token-123"
+ )
+ )
+
+ try {
+ verifyPurchaseWithIapkit(
+ props,
+ "TEST"
+ ) { _ -> FakeHttpURLConnection(500, """{"error":"server"}""") }
+ throw AssertionError("Expected PurchaseVerificationFailed for non-2xx response")
+ } catch (error: OpenIapError.PurchaseVerificationFailed) {
+ // PurchaseVerificationFailed is the expected exception
+ assertTrue(true)
+ }
+ }
+}
+
+private class FakeHttpURLConnection(
+ private val statusCode: Int,
+ private val body: String
+) : HttpURLConnection(URL("https://example.com")) {
+ val headers: MutableMap = mutableMapOf()
+ var writtenBody: String? = null
+
+ override fun getResponseCode(): Int = statusCode
+
+ override fun getInputStream(): InputStream = ByteArrayInputStream(body.toByteArray())
+
+ override fun getErrorStream(): InputStream = ByteArrayInputStream(body.toByteArray())
+
+ override fun setRequestProperty(key: String?, value: String?) {
+ if (key != null && value != null) {
+ headers[key] = value
+ }
+ }
+
+ override fun getOutputStream(): OutputStream {
+ return object : ByteArrayOutputStream() {
+ override fun close() {
+ super.close()
+ writtenBody = toString()
+ }
+ }
+ }
+
+ override fun disconnect() { /* no-op */ }
+
+ override fun usingProxy(): Boolean = false
+
+ override fun connect() { /* no-op */ }
+}
diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/ReceiptValidatorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/ReceiptValidatorTest.kt
deleted file mode 100644
index 2a3ba198..00000000
--- a/packages/google/openiap/src/test/java/dev/hyo/openiap/ReceiptValidatorTest.kt
+++ /dev/null
@@ -1,110 +0,0 @@
-package dev.hyo.openiap
-
-import dev.hyo.openiap.utils.validateReceiptWithGooglePlay
-import java.io.ByteArrayInputStream
-import java.io.InputStream
-import java.net.HttpURLConnection
-import java.net.URL
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-class ReceiptValidatorTest {
-
- @Test
- fun `validateReceiptWithGooglePlay throws without androidOptions`() = runTest {
- val props = ReceiptValidationProps(androidOptions = null, sku = "product.sku")
-
- try {
- validateReceiptWithGooglePlay(props, "TEST_TAG") { _ ->
- throw AssertionError("Connection should not be created when options are missing")
- }
- throw AssertionError("Expected IllegalArgumentException for missing androidOptions")
- } catch (expected: IllegalArgumentException) {
- // Expected path
- }
- }
-
- @Test
- fun `validateReceiptWithGooglePlay parses successful response`() = runTest {
- val options = ReceiptValidationAndroidOptions(
- accessToken = "token",
- isSub = true,
- packageName = "dev.hyo.app",
- productToken = "purchaseToken"
- )
- val props = ReceiptValidationProps(androidOptions = options, sku = "premium_monthly")
- val body = """
- {
- "autoRenewing": true,
- "betaProduct": false,
- "cancelDate": null,
- "cancelReason": null,
- "deferredDate": null,
- "deferredSku": null,
- "freeTrialEndDate": 1.0,
- "gracePeriodEndDate": 2.0,
- "parentProductId": "parent",
- "productId": "premium_monthly",
- "productType": "subs",
- "purchaseDate": 3.0,
- "quantity": 1,
- "receiptId": "rid-123",
- "renewalDate": 4.0,
- "term": "P1M",
- "termSku": "plan_monthly",
- "testTransaction": false
- }
- """.trimIndent()
-
- val result = validateReceiptWithGooglePlay(
- props,
- "TEST_TAG"
- ) { _ -> FakeHttpURLConnection(200, body) }
-
- assertEquals("premium_monthly", result.productId)
- assertEquals("plan_monthly", result.termSku)
- assertEquals(true, result.autoRenewing)
- assertEquals(false, result.betaProduct)
- assertEquals(1, result.quantity)
- }
-
- @Test
- fun `validateReceiptWithGooglePlay wraps non-2xx as InvalidReceipt`() = runTest {
- val options = ReceiptValidationAndroidOptions(
- accessToken = "token",
- isSub = false,
- packageName = "dev.hyo.app",
- productToken = "purchaseToken"
- )
- val props = ReceiptValidationProps(androidOptions = options, sku = "premium_monthly")
-
- try {
- validateReceiptWithGooglePlay(
- props,
- "TEST_TAG"
- ) { _ -> FakeHttpURLConnection(401, """{"error":"unauthorized"}""") }
- throw AssertionError("Expected InvalidReceipt for non-2xx response")
- } catch (error: OpenIapError.InvalidReceipt) {
- assertEquals("Invalid receipt", error.message)
- }
- }
-}
-
-private class FakeHttpURLConnection(
- private val statusCode: Int,
- private val body: String
-) : HttpURLConnection(URL("https://example.com")) {
-
- override fun getResponseCode(): Int = statusCode
-
- override fun getInputStream(): InputStream = ByteArrayInputStream(body.toByteArray())
-
- override fun getErrorStream(): InputStream = ByteArrayInputStream(body.toByteArray())
-
- override fun disconnect() { /* no-op */ }
-
- override fun usingProxy(): Boolean = false
-
- override fun connect() { /* no-op */ }
-}
diff --git a/packages/gql/CONVENTION.md b/packages/gql/CONVENTION.md
index bf3def20..c29611ea 100644
--- a/packages/gql/CONVENTION.md
+++ b/packages/gql/CONVENTION.md
@@ -11,7 +11,7 @@ This repo standardizes schema and identifier naming to improve clarity across pl
## Platform Suffix Rules
- iOS‑specific identifiers include `IOS` when it appears as the final suffix.
- - Example: `buyProductIOS`, `SubscriptionPeriodIOS`, `ReceiptValidationResultIOS`.
+- Example: `buyProductIOS`, `SubscriptionPeriodIOS`, `VerifyPurchaseResultIOS`.
- If the iOS marker appears mid‑identifier (i.e., more words follow), use `Ios`.
- Example: `ProductIosType`, `RequestPurchaseIosProps`.
- Android‑specific identifiers use `Android` (PascalCase) and typically as a suffix.
diff --git a/packages/gql/package.json b/packages/gql/package.json
index 28e2ba23..046b078d 100644
--- a/packages/gql/package.json
+++ b/packages/gql/package.json
@@ -1,6 +1,6 @@
{
"name": "@hyodotdev/openiap-gql",
- "version": "1.2.2",
+ "version": "1.0.0",
"type": "module",
"main": "src/generated/types.ts",
"exports": {
diff --git a/packages/gql/scripts/generate-swift-types.mjs b/packages/gql/scripts/generate-swift-types.mjs
index ba444c3b..effea7a0 100644
--- a/packages/gql/scripts/generate-swift-types.mjs
+++ b/packages/gql/scripts/generate-swift-types.mjs
@@ -309,6 +309,12 @@ const printEnum = (enumType) => {
// Add custom initializer for ErrorCode to handle both kebab-case and camelCase
if (enumType.name === 'ErrorCode') {
+ // Define legacy aliases: old error codes that map to new ones
+ const legacyAliases = {
+ 'receipt-failed': 'purchaseVerificationFailed',
+ 'ReceiptFailed': 'purchaseVerificationFailed',
+ };
+
lines.push('');
lines.push(' /// Custom initializer to handle both kebab-case and camelCase error codes');
lines.push(' /// This ensures compatibility with react-native-iap and other libraries that may send camelCase');
@@ -319,8 +325,21 @@ const printEnum = (enumType) => {
const caseName = escapeSwiftName(lowerCamelCase(value.name));
const rawValue = toKebabCase(value.name);
const camelCaseName = value.name.charAt(0).toUpperCase() + value.name.slice(1);
- lines.push(` case "${rawValue}", "${camelCaseName}":`);
- lines.push(` self = .${caseName}`);
+ // Check if this case has legacy aliases that should map to it
+ const aliasTarget = legacyAliases[rawValue] || legacyAliases[camelCaseName];
+ if (aliasTarget && aliasTarget === caseName) {
+ // This case is a legacy alias target - already handled by the main case
+ lines.push(` case "${rawValue}", "${camelCaseName}":`);
+ lines.push(` self = .${caseName}`);
+ } else if (legacyAliases[rawValue] || legacyAliases[camelCaseName]) {
+ // This is a legacy alias - map to the new case
+ const targetCase = legacyAliases[rawValue] || legacyAliases[camelCaseName];
+ lines.push(` case "${rawValue}", "${camelCaseName}":`);
+ lines.push(` self = .${targetCase} // Legacy alias`);
+ } else {
+ lines.push(` case "${rawValue}", "${camelCaseName}":`);
+ lines.push(` self = .${caseName}`);
+ }
});
lines.push(' default:');
lines.push(' return nil');
diff --git a/packages/gql/src/api-ios.graphql b/packages/gql/src/api-ios.graphql
index 594ad778..4642fe6d 100644
--- a/packages/gql/src/api-ios.graphql
+++ b/packages/gql/src/api-ios.graphql
@@ -65,7 +65,7 @@ extend type Query {
Validate a receipt for a specific product
"""
# Future
-validateReceiptIOS(options: ReceiptValidationProps!): ReceiptValidationResultIOS! @deprecated(reason: "Use verifyPurchase")
+validateReceiptIOS(options: VerifyPurchaseProps!): VerifyPurchaseResultIOS! @deprecated(reason: "Use verifyPurchase")
}
extend type Mutation {
diff --git a/packages/gql/src/api.graphql b/packages/gql/src/api.graphql
index 58440c1b..a4e56824 100644
--- a/packages/gql/src/api.graphql
+++ b/packages/gql/src/api.graphql
@@ -68,10 +68,17 @@ extend type Mutation {
Validate purchase receipts with the configured providers
"""
# Future
- validateReceipt(options: ReceiptValidationProps!): ReceiptValidationResult! @deprecated(reason: "Use verifyPurchase")
+ validateReceipt(options: VerifyPurchaseProps!): VerifyPurchaseResult! @deprecated(reason: "Use verifyPurchase")
"""
Verify purchases with the configured providers
"""
# Future
- verifyPurchase(options: ReceiptValidationProps!): ReceiptValidationResult!
+ verifyPurchase(options: VerifyPurchaseProps!): VerifyPurchaseResult!
+ """
+ Verify purchases with a specific provider (e.g., IAPKit)
+ """
+ # Future
+ verifyPurchaseWithProvider(
+ options: VerifyPurchaseWithProviderProps!
+ ): VerifyPurchaseWithProviderResult!
}
diff --git a/packages/gql/src/error.graphql b/packages/gql/src/error.graphql
index 1916e7b1..5179180c 100644
--- a/packages/gql/src/error.graphql
+++ b/packages/gql/src/error.graphql
@@ -9,9 +9,15 @@ enum ErrorCode {
RemoteError
NetworkError
ServiceError
+ # @deprecated Use PurchaseVerificationFailed instead
ReceiptFailed
+ # @deprecated Use PurchaseVerificationFinished instead
ReceiptFinished
+ # @deprecated Use PurchaseVerificationFinishFailed instead
ReceiptFinishedFailed
+ PurchaseVerificationFailed
+ PurchaseVerificationFinished
+ PurchaseVerificationFinishFailed
NotPrepared
NotEnded
AlreadyOwned
diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt
index e7ee64d3..4cd8a3b6 100644
--- a/packages/gql/src/generated/Types.kt
+++ b/packages/gql/src/generated/Types.kt
@@ -53,6 +53,9 @@ public enum class ErrorCode(val rawValue: String) {
ReceiptFailed("receipt-failed"),
ReceiptFinished("receipt-finished"),
ReceiptFinishedFailed("receipt-finished-failed"),
+ PurchaseVerificationFailed("purchase-verification-failed"),
+ PurchaseVerificationFinished("purchase-verification-finished"),
+ PurchaseVerificationFinishFailed("purchase-verification-finish-failed"),
NotPrepared("not-prepared"),
NotEnded("not-ended"),
AlreadyOwned("already-owned"),
@@ -110,6 +113,15 @@ public enum class ErrorCode(val rawValue: String) {
"receipt-finished-failed" -> ErrorCode.ReceiptFinishedFailed
"RECEIPT_FINISHED_FAILED" -> ErrorCode.ReceiptFinishedFailed
"ReceiptFinishedFailed" -> ErrorCode.ReceiptFinishedFailed
+ "purchase-verification-failed" -> ErrorCode.PurchaseVerificationFailed
+ "PURCHASE_VERIFICATION_FAILED" -> ErrorCode.PurchaseVerificationFailed
+ "PurchaseVerificationFailed" -> ErrorCode.PurchaseVerificationFailed
+ "purchase-verification-finished" -> ErrorCode.PurchaseVerificationFinished
+ "PURCHASE_VERIFICATION_FINISHED" -> ErrorCode.PurchaseVerificationFinished
+ "PurchaseVerificationFinished" -> ErrorCode.PurchaseVerificationFinished
+ "purchase-verification-finish-failed" -> ErrorCode.PurchaseVerificationFinishFailed
+ "PURCHASE_VERIFICATION_FINISH_FAILED" -> ErrorCode.PurchaseVerificationFinishFailed
+ "PurchaseVerificationFinishFailed" -> ErrorCode.PurchaseVerificationFinishFailed
"not-prepared" -> ErrorCode.NotPrepared
"NOT_PREPARED" -> ErrorCode.NotPrepared
"NotPrepared" -> ErrorCode.NotPrepared
@@ -244,6 +256,93 @@ public enum class IapEvent(val rawValue: String) {
fun toJson(): String = rawValue
}
+/**
+ * Unified purchase states from IAPKit verification response.
+ */
+public enum class IapkitPurchaseState(val rawValue: String) {
+ /**
+ * User is entitled to the product (purchase is complete and active).
+ */
+ Entitled("entitled"),
+ /**
+ * Receipt is valid but still needs server acknowledgment.
+ */
+ PendingAcknowledgment("pending-acknowledgment"),
+ /**
+ * Purchase is in progress or awaiting confirmation.
+ */
+ Pending("pending"),
+ /**
+ * Purchase was cancelled or refunded.
+ */
+ Canceled("canceled"),
+ /**
+ * Subscription or entitlement has expired.
+ */
+ Expired("expired"),
+ /**
+ * Consumable purchase is ready to be fulfilled.
+ */
+ ReadyToConsume("ready-to-consume"),
+ /**
+ * Consumable item has been fulfilled/consumed.
+ */
+ Consumed("consumed"),
+ /**
+ * Purchase state could not be determined.
+ */
+ Unknown("unknown"),
+ /**
+ * Purchase receipt is not authentic (fraudulent or tampered).
+ */
+ Inauthentic("inauthentic")
+
+ companion object {
+ fun fromJson(value: String): IapkitPurchaseState = when (value) {
+ "entitled" -> IapkitPurchaseState.Entitled
+ "ENTITLED" -> IapkitPurchaseState.Entitled
+ "pending-acknowledgment" -> IapkitPurchaseState.PendingAcknowledgment
+ "PENDING_ACKNOWLEDGMENT" -> IapkitPurchaseState.PendingAcknowledgment
+ "pending" -> IapkitPurchaseState.Pending
+ "PENDING" -> IapkitPurchaseState.Pending
+ "canceled" -> IapkitPurchaseState.Canceled
+ "CANCELED" -> IapkitPurchaseState.Canceled
+ "expired" -> IapkitPurchaseState.Expired
+ "EXPIRED" -> IapkitPurchaseState.Expired
+ "ready-to-consume" -> IapkitPurchaseState.ReadyToConsume
+ "READY_TO_CONSUME" -> IapkitPurchaseState.ReadyToConsume
+ "consumed" -> IapkitPurchaseState.Consumed
+ "CONSUMED" -> IapkitPurchaseState.Consumed
+ "unknown" -> IapkitPurchaseState.Unknown
+ "UNKNOWN" -> IapkitPurchaseState.Unknown
+ "inauthentic" -> IapkitPurchaseState.Inauthentic
+ "INAUTHENTIC" -> IapkitPurchaseState.Inauthentic
+ else -> throw IllegalArgumentException("Unknown IapkitPurchaseState value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
+public enum class IapkitStore(val rawValue: String) {
+ Apple("apple"),
+ Google("google")
+
+ companion object {
+ fun fromJson(value: String): IapkitStore = when (value) {
+ "apple" -> IapkitStore.Apple
+ "APPLE" -> IapkitStore.Apple
+ "Apple" -> IapkitStore.Apple
+ "google" -> IapkitStore.Google
+ "GOOGLE" -> IapkitStore.Google
+ "Google" -> IapkitStore.Google
+ else -> throw IllegalArgumentException("Unknown IapkitStore value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
public enum class IapPlatform(val rawValue: String) {
Ios("ios"),
Android("android")
@@ -393,6 +492,21 @@ public enum class PurchaseState(val rawValue: String) {
fun toJson(): String = rawValue
}
+public enum class PurchaseVerificationProvider(val rawValue: String) {
+ Iapkit("iapkit")
+
+ companion object {
+ fun fromJson(value: String): PurchaseVerificationProvider = when (value) {
+ "iapkit" -> PurchaseVerificationProvider.Iapkit
+ "IAPKIT" -> PurchaseVerificationProvider.Iapkit
+ "Iapkit" -> PurchaseVerificationProvider.Iapkit
+ else -> throw IllegalArgumentException("Unknown PurchaseVerificationProvider value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
public enum class SubscriptionOfferTypeIOS(val rawValue: String) {
Introductory("introductory"),
Promotional("promotional")
@@ -1378,114 +1492,6 @@ public data class PurchaseOfferIOS(
)
}
-public data class ReceiptValidationResultAndroid(
- val autoRenewing: Boolean,
- val betaProduct: Boolean,
- val cancelDate: Double? = null,
- val cancelReason: String? = null,
- val deferredDate: Double? = null,
- val deferredSku: String? = null,
- val freeTrialEndDate: Double,
- val gracePeriodEndDate: Double,
- val parentProductId: String,
- val productId: String,
- val productType: String,
- val purchaseDate: Double,
- val quantity: Int,
- val receiptId: String,
- val renewalDate: Double,
- val term: String,
- val termSku: String,
- val testTransaction: Boolean
-) : ReceiptValidationResult {
-
- companion object {
- fun fromJson(json: Map): ReceiptValidationResultAndroid {
- return ReceiptValidationResultAndroid(
- autoRenewing = json["autoRenewing"] as Boolean,
- betaProduct = json["betaProduct"] as Boolean,
- cancelDate = (json["cancelDate"] as Number?)?.toDouble(),
- cancelReason = json["cancelReason"] as String?,
- deferredDate = (json["deferredDate"] as Number?)?.toDouble(),
- deferredSku = json["deferredSku"] as String?,
- freeTrialEndDate = (json["freeTrialEndDate"] as Number).toDouble(),
- gracePeriodEndDate = (json["gracePeriodEndDate"] as Number).toDouble(),
- parentProductId = json["parentProductId"] as String,
- productId = json["productId"] as String,
- productType = json["productType"] as String,
- purchaseDate = (json["purchaseDate"] as Number).toDouble(),
- quantity = (json["quantity"] as Number).toInt(),
- receiptId = json["receiptId"] as String,
- renewalDate = (json["renewalDate"] as Number).toDouble(),
- term = json["term"] as String,
- termSku = json["termSku"] as String,
- testTransaction = json["testTransaction"] as Boolean,
- )
- }
- }
-
- override fun toJson(): Map = mapOf(
- "__typename" to "ReceiptValidationResultAndroid",
- "autoRenewing" to autoRenewing,
- "betaProduct" to betaProduct,
- "cancelDate" to cancelDate,
- "cancelReason" to cancelReason,
- "deferredDate" to deferredDate,
- "deferredSku" to deferredSku,
- "freeTrialEndDate" to freeTrialEndDate,
- "gracePeriodEndDate" to gracePeriodEndDate,
- "parentProductId" to parentProductId,
- "productId" to productId,
- "productType" to productType,
- "purchaseDate" to purchaseDate,
- "quantity" to quantity,
- "receiptId" to receiptId,
- "renewalDate" to renewalDate,
- "term" to term,
- "termSku" to termSku,
- "testTransaction" to testTransaction,
- )
-}
-
-public data class ReceiptValidationResultIOS(
- /**
- * Whether the receipt is valid
- */
- val isValid: Boolean,
- /**
- * JWS representation
- */
- val jwsRepresentation: String,
- /**
- * Latest transaction if available
- */
- val latestTransaction: Purchase? = null,
- /**
- * Receipt data string
- */
- val receiptData: String
-) : ReceiptValidationResult {
-
- companion object {
- fun fromJson(json: Map): ReceiptValidationResultIOS {
- return ReceiptValidationResultIOS(
- isValid = json["isValid"] as Boolean,
- jwsRepresentation = json["jwsRepresentation"] as String,
- latestTransaction = (json["latestTransaction"] as Map?)?.let { Purchase.fromJson(it) },
- receiptData = json["receiptData"] as String,
- )
- }
- }
-
- override fun toJson(): Map = mapOf(
- "__typename" to "ReceiptValidationResultIOS",
- "isValid" to isValid,
- "jwsRepresentation" to jwsRepresentation,
- "latestTransaction" to latestTransaction?.toJson(),
- "receiptData" to receiptData,
- )
-}
-
public data class RefundResultIOS(
val message: String? = null,
val status: String
@@ -1596,6 +1602,36 @@ public data class RequestPurchaseResultPurchase(val value: Purchase?) : RequestP
public data class RequestPurchaseResultPurchases(val value: List?) : RequestPurchaseResult
+public data class RequestVerifyPurchaseWithIapkitResult(
+ /**
+ * Whether the purchase is valid (not falsified).
+ */
+ val isValid: Boolean,
+ /**
+ * The current state of the purchase.
+ */
+ val state: IapkitPurchaseState,
+ val store: IapkitStore
+) {
+
+ companion object {
+ fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitResult {
+ return RequestVerifyPurchaseWithIapkitResult(
+ isValid = json["isValid"] as Boolean,
+ state = IapkitPurchaseState.fromJson(json["state"] as String),
+ store = IapkitStore.fromJson(json["store"] as String),
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "__typename" to "RequestVerifyPurchaseWithIapkitResult",
+ "isValid" to isValid,
+ "state" to state.toJson(),
+ "store" to store.toJson(),
+ )
+}
+
public data class SubscriptionInfoIOS(
val introductoryOffer: SubscriptionOfferIOS? = null,
val promotionalOffers: List? = null,
@@ -1732,6 +1768,138 @@ public data class UserChoiceBillingDetails(
)
}
+public data class VerifyPurchaseResultAndroid(
+ val autoRenewing: Boolean,
+ val betaProduct: Boolean,
+ val cancelDate: Double? = null,
+ val cancelReason: String? = null,
+ val deferredDate: Double? = null,
+ val deferredSku: String? = null,
+ val freeTrialEndDate: Double,
+ val gracePeriodEndDate: Double,
+ val parentProductId: String,
+ val productId: String,
+ val productType: String,
+ val purchaseDate: Double,
+ val quantity: Int,
+ val receiptId: String,
+ val renewalDate: Double,
+ val term: String,
+ val termSku: String,
+ val testTransaction: Boolean
+) : VerifyPurchaseResult {
+
+ companion object {
+ fun fromJson(json: Map): VerifyPurchaseResultAndroid {
+ return VerifyPurchaseResultAndroid(
+ autoRenewing = json["autoRenewing"] as Boolean,
+ betaProduct = json["betaProduct"] as Boolean,
+ cancelDate = (json["cancelDate"] as Number?)?.toDouble(),
+ cancelReason = json["cancelReason"] as String?,
+ deferredDate = (json["deferredDate"] as Number?)?.toDouble(),
+ deferredSku = json["deferredSku"] as String?,
+ freeTrialEndDate = (json["freeTrialEndDate"] as Number).toDouble(),
+ gracePeriodEndDate = (json["gracePeriodEndDate"] as Number).toDouble(),
+ parentProductId = json["parentProductId"] as String,
+ productId = json["productId"] as String,
+ productType = json["productType"] as String,
+ purchaseDate = (json["purchaseDate"] as Number).toDouble(),
+ quantity = (json["quantity"] as Number).toInt(),
+ receiptId = json["receiptId"] as String,
+ renewalDate = (json["renewalDate"] as Number).toDouble(),
+ term = json["term"] as String,
+ termSku = json["termSku"] as String,
+ testTransaction = json["testTransaction"] as Boolean,
+ )
+ }
+ }
+
+ override fun toJson(): Map = mapOf(
+ "__typename" to "VerifyPurchaseResultAndroid",
+ "autoRenewing" to autoRenewing,
+ "betaProduct" to betaProduct,
+ "cancelDate" to cancelDate,
+ "cancelReason" to cancelReason,
+ "deferredDate" to deferredDate,
+ "deferredSku" to deferredSku,
+ "freeTrialEndDate" to freeTrialEndDate,
+ "gracePeriodEndDate" to gracePeriodEndDate,
+ "parentProductId" to parentProductId,
+ "productId" to productId,
+ "productType" to productType,
+ "purchaseDate" to purchaseDate,
+ "quantity" to quantity,
+ "receiptId" to receiptId,
+ "renewalDate" to renewalDate,
+ "term" to term,
+ "termSku" to termSku,
+ "testTransaction" to testTransaction,
+ )
+}
+
+public data class VerifyPurchaseResultIOS(
+ /**
+ * Whether the receipt is valid
+ */
+ val isValid: Boolean,
+ /**
+ * JWS representation
+ */
+ val jwsRepresentation: String,
+ /**
+ * Latest transaction if available
+ */
+ val latestTransaction: Purchase? = null,
+ /**
+ * Receipt data string
+ */
+ val receiptData: String
+) : VerifyPurchaseResult {
+
+ companion object {
+ fun fromJson(json: Map): VerifyPurchaseResultIOS {
+ return VerifyPurchaseResultIOS(
+ isValid = json["isValid"] as Boolean,
+ jwsRepresentation = json["jwsRepresentation"] as String,
+ latestTransaction = (json["latestTransaction"] as Map?)?.let { Purchase.fromJson(it) },
+ receiptData = json["receiptData"] as String,
+ )
+ }
+ }
+
+ override fun toJson(): Map = mapOf(
+ "__typename" to "VerifyPurchaseResultIOS",
+ "isValid" to isValid,
+ "jwsRepresentation" to jwsRepresentation,
+ "latestTransaction" to latestTransaction?.toJson(),
+ "receiptData" to receiptData,
+ )
+}
+
+public data class VerifyPurchaseWithProviderResult(
+ /**
+ * IAPKit verification results (can include Apple and Google entries)
+ */
+ val iapkit: List,
+ val provider: PurchaseVerificationProvider
+) {
+
+ companion object {
+ fun fromJson(json: Map): VerifyPurchaseWithProviderResult {
+ return VerifyPurchaseWithProviderResult(
+ iapkit = (json["iapkit"] as List<*>).map { RequestVerifyPurchaseWithIapkitResult.fromJson((it as Map)) },
+ provider = PurchaseVerificationProvider.fromJson(json["provider"] as String),
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "__typename" to "VerifyPurchaseWithProviderResult",
+ "iapkit" to iapkit.map { it.toJson() },
+ "provider" to provider.toJson(),
+ )
+}
+
public typealias VoidResult = Unit
// MARK: - Input Objects
@@ -1898,56 +2066,6 @@ public data class PurchaseOptions(
)
}
-public data class ReceiptValidationAndroidOptions(
- val accessToken: String,
- val isSub: Boolean? = null,
- val packageName: String,
- val productToken: String
-) {
- companion object {
- fun fromJson(json: Map): ReceiptValidationAndroidOptions {
- return ReceiptValidationAndroidOptions(
- accessToken = json["accessToken"] as String,
- isSub = json["isSub"] as Boolean?,
- packageName = json["packageName"] as String,
- productToken = json["productToken"] as String,
- )
- }
- }
-
- fun toJson(): Map = mapOf(
- "accessToken" to accessToken,
- "isSub" to isSub,
- "packageName" to packageName,
- "productToken" to productToken,
- )
-}
-
-public data class ReceiptValidationProps(
- /**
- * Android-specific validation options
- */
- val androidOptions: ReceiptValidationAndroidOptions? = null,
- /**
- * Product SKU to validate
- */
- val sku: String
-) {
- companion object {
- fun fromJson(json: Map): ReceiptValidationProps {
- return ReceiptValidationProps(
- androidOptions = (json["androidOptions"] as Map?)?.let { ReceiptValidationAndroidOptions.fromJson(it) },
- sku = json["sku"] as String,
- )
- }
- }
-
- fun toJson(): Map = mapOf(
- "androidOptions" to androidOptions?.toJson(),
- "sku" to sku,
- )
-}
-
public data class RequestPurchaseAndroidProps(
/**
* Personalized offer flag
@@ -2214,6 +2332,144 @@ public data class RequestSubscriptionPropsByPlatforms(
)
}
+public data class RequestVerifyPurchaseWithIapkitAppleProps(
+ /**
+ * The JWS token returned with the purchase response.
+ */
+ val jws: String
+) {
+ companion object {
+ fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitAppleProps {
+ return RequestVerifyPurchaseWithIapkitAppleProps(
+ jws = json["jws"] as String,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "jws" to jws,
+ )
+}
+
+public data class RequestVerifyPurchaseWithIapkitGoogleProps(
+ /**
+ * The token provided to the user's device when the product or subscription was purchased.
+ */
+ val purchaseToken: String
+) {
+ companion object {
+ fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitGoogleProps {
+ return RequestVerifyPurchaseWithIapkitGoogleProps(
+ purchaseToken = json["purchaseToken"] as String,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "purchaseToken" to purchaseToken,
+ )
+}
+
+public data class RequestVerifyPurchaseWithIapkitProps(
+ /**
+ * API key used for the Authorization header (Bearer {apiKey}).
+ */
+ val apiKey: String? = null,
+ /**
+ * Apple verification parameters.
+ */
+ val apple: RequestVerifyPurchaseWithIapkitAppleProps? = null,
+ /**
+ * Google verification parameters.
+ */
+ val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null
+) {
+ companion object {
+ fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitProps {
+ return RequestVerifyPurchaseWithIapkitProps(
+ apiKey = json["apiKey"] as String?,
+ apple = (json["apple"] as Map?)?.let { RequestVerifyPurchaseWithIapkitAppleProps.fromJson(it) },
+ google = (json["google"] as Map?)?.let { RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(it) },
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "apiKey" to apiKey,
+ "apple" to apple?.toJson(),
+ "google" to google?.toJson(),
+ )
+}
+
+public data class VerifyPurchaseAndroidOptions(
+ val accessToken: String,
+ val isSub: Boolean? = null,
+ val packageName: String,
+ val productToken: String
+) {
+ companion object {
+ fun fromJson(json: Map): VerifyPurchaseAndroidOptions {
+ return VerifyPurchaseAndroidOptions(
+ accessToken = json["accessToken"] as String,
+ isSub = json["isSub"] as Boolean?,
+ packageName = json["packageName"] as String,
+ productToken = json["productToken"] as String,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "accessToken" to accessToken,
+ "isSub" to isSub,
+ "packageName" to packageName,
+ "productToken" to productToken,
+ )
+}
+
+public data class VerifyPurchaseProps(
+ /**
+ * Android-specific validation options
+ */
+ val androidOptions: VerifyPurchaseAndroidOptions? = null,
+ /**
+ * Product SKU to validate
+ */
+ val sku: String
+) {
+ companion object {
+ fun fromJson(json: Map): VerifyPurchaseProps {
+ return VerifyPurchaseProps(
+ androidOptions = (json["androidOptions"] as Map?)?.let { VerifyPurchaseAndroidOptions.fromJson(it) },
+ sku = json["sku"] as String,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "androidOptions" to androidOptions?.toJson(),
+ "sku" to sku,
+ )
+}
+
+public data class VerifyPurchaseWithProviderProps(
+ val iapkit: RequestVerifyPurchaseWithIapkitProps? = null,
+ val provider: PurchaseVerificationProvider
+) {
+ companion object {
+ fun fromJson(json: Map): VerifyPurchaseWithProviderProps {
+ return VerifyPurchaseWithProviderProps(
+ iapkit = (json["iapkit"] as Map?)?.let { RequestVerifyPurchaseWithIapkitProps.fromJson(it) },
+ provider = PurchaseVerificationProvider.fromJson(json["provider"] as String),
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "iapkit" to iapkit?.toJson(),
+ "provider" to provider.toJson(),
+ )
+}
+
// MARK: - Unions
public sealed interface Product : ProductCommon {
@@ -2282,15 +2538,15 @@ public sealed interface Purchase : PurchaseCommon {
}
}
-public sealed interface ReceiptValidationResult {
+public sealed interface VerifyPurchaseResult {
fun toJson(): Map
companion object {
- fun fromJson(json: Map): ReceiptValidationResult {
+ fun fromJson(json: Map): VerifyPurchaseResult {
return when (json["__typename"] as String?) {
- "ReceiptValidationResultAndroid" -> ReceiptValidationResultAndroid.fromJson(json)
- "ReceiptValidationResultIOS" -> ReceiptValidationResultIOS.fromJson(json)
- else -> throw IllegalArgumentException("Unknown __typename for ReceiptValidationResult: ${json["__typename"]}")
+ "VerifyPurchaseResultAndroid" -> VerifyPurchaseResultAndroid.fromJson(json)
+ "VerifyPurchaseResultIOS" -> VerifyPurchaseResultIOS.fromJson(json)
+ else -> throw IllegalArgumentException("Unknown __typename for VerifyPurchaseResult: ${json["__typename"]}")
}
}
}
@@ -2396,11 +2652,15 @@ public interface MutationResolver {
/**
* Validate purchase receipts with the configured providers
*/
- suspend fun validateReceipt(options: ReceiptValidationProps): ReceiptValidationResult
+ suspend fun validateReceipt(options: VerifyPurchaseProps): VerifyPurchaseResult
/**
* Verify purchases with the configured providers
*/
- suspend fun verifyPurchase(options: ReceiptValidationProps): ReceiptValidationResult
+ suspend fun verifyPurchase(options: VerifyPurchaseProps): VerifyPurchaseResult
+ /**
+ * Verify purchases with a specific provider (e.g., IAPKit)
+ */
+ suspend fun verifyPurchaseWithProvider(options: VerifyPurchaseWithProviderProps): VerifyPurchaseWithProviderResult
}
/**
@@ -2478,7 +2738,7 @@ public interface QueryResolver {
/**
* Validate a receipt for a specific product
*/
- suspend fun validateReceiptIOS(options: ReceiptValidationProps): ReceiptValidationResultIOS
+ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS
}
/**
@@ -2527,8 +2787,9 @@ public typealias MutationRestorePurchasesHandler = suspend () -> Unit
public typealias MutationShowAlternativeBillingDialogAndroidHandler = suspend () -> Boolean
public typealias MutationShowManageSubscriptionsIOSHandler = suspend () -> List
public typealias MutationSyncIOSHandler = suspend () -> Boolean
-public typealias MutationValidateReceiptHandler = suspend (options: ReceiptValidationProps) -> ReceiptValidationResult
-public typealias MutationVerifyPurchaseHandler = suspend (options: ReceiptValidationProps) -> ReceiptValidationResult
+public typealias MutationValidateReceiptHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResult
+public typealias MutationVerifyPurchaseHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResult
+public typealias MutationVerifyPurchaseWithProviderHandler = suspend (options: VerifyPurchaseWithProviderProps) -> VerifyPurchaseWithProviderResult
public data class MutationHandlers(
val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler? = null,
@@ -2551,7 +2812,8 @@ public data class MutationHandlers(
val showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? = null,
val syncIOS: MutationSyncIOSHandler? = null,
val validateReceipt: MutationValidateReceiptHandler? = null,
- val verifyPurchase: MutationVerifyPurchaseHandler? = null
+ val verifyPurchase: MutationVerifyPurchaseHandler? = null,
+ val verifyPurchaseWithProvider: MutationVerifyPurchaseWithProviderHandler? = null
)
// MARK: - Query Helpers
@@ -2573,7 +2835,7 @@ public typealias QueryIsEligibleForIntroOfferIOSHandler = suspend (groupID: Stri
public typealias QueryIsTransactionVerifiedIOSHandler = suspend (sku: String) -> Boolean
public typealias QueryLatestTransactionIOSHandler = suspend (sku: String) -> PurchaseIOS?
public typealias QuerySubscriptionStatusIOSHandler = suspend (sku: String) -> List
-public typealias QueryValidateReceiptIOSHandler = suspend (options: ReceiptValidationProps) -> ReceiptValidationResultIOS
+public typealias QueryValidateReceiptIOSHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResultIOS
public data class QueryHandlers(
val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null,
diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift
index b7d7a9a5..70806266 100644
--- a/packages/gql/src/generated/Types.swift
+++ b/packages/gql/src/generated/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/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart
index a9bd2cc4..92ef5488 100644
--- a/packages/gql/src/generated/types.dart
+++ b/packages/gql/src/generated/types.dart
@@ -53,6 +53,9 @@ enum ErrorCode {
ReceiptFailed('receipt-failed'),
ReceiptFinished('receipt-finished'),
ReceiptFinishedFailed('receipt-finished-failed'),
+ PurchaseVerificationFailed('purchase-verification-failed'),
+ PurchaseVerificationFinished('purchase-verification-finished'),
+ PurchaseVerificationFinishFailed('purchase-verification-finish-failed'),
NotPrepared('not-prepared'),
NotEnded('not-ended'),
AlreadyOwned('already-owned'),
@@ -123,6 +126,18 @@ enum ErrorCode {
case 'RECEIPT_FINISHED_FAILED':
case 'ReceiptFinishedFailed':
return ErrorCode.ReceiptFinishedFailed;
+ case 'purchase-verification-failed':
+ case 'PURCHASE_VERIFICATION_FAILED':
+ case 'PurchaseVerificationFailed':
+ return ErrorCode.PurchaseVerificationFailed;
+ case 'purchase-verification-finished':
+ case 'PURCHASE_VERIFICATION_FINISHED':
+ case 'PurchaseVerificationFinished':
+ return ErrorCode.PurchaseVerificationFinished;
+ case 'purchase-verification-finish-failed':
+ case 'PURCHASE_VERIFICATION_FINISH_FAILED':
+ case 'PurchaseVerificationFinishFailed':
+ return ErrorCode.PurchaseVerificationFinishFailed;
case 'not-prepared':
case 'NOT_PREPARED':
case 'NotPrepared':
@@ -287,6 +302,90 @@ enum IapEvent {
String toJson() => value;
}
+/// Unified purchase states from IAPKit verification response.
+enum IapkitPurchaseState {
+ /// User is entitled to the product (purchase is complete and active).
+ Entitled('entitled'),
+ /// Receipt is valid but still needs server acknowledgment.
+ PendingAcknowledgment('pending-acknowledgment'),
+ /// Purchase is in progress or awaiting confirmation.
+ Pending('pending'),
+ /// Purchase was cancelled or refunded.
+ Canceled('canceled'),
+ /// Subscription or entitlement has expired.
+ Expired('expired'),
+ /// Consumable purchase is ready to be fulfilled.
+ ReadyToConsume('ready-to-consume'),
+ /// Consumable item has been fulfilled/consumed.
+ Consumed('consumed'),
+ /// Purchase state could not be determined.
+ Unknown('unknown'),
+ /// Purchase receipt is not authentic (fraudulent or tampered).
+ Inauthentic('inauthentic');
+
+ const IapkitPurchaseState(this.value);
+ final String value;
+
+ factory IapkitPurchaseState.fromJson(String value) {
+ switch (value) {
+ case 'entitled':
+ case 'ENTITLED':
+ return IapkitPurchaseState.Entitled;
+ case 'pending-acknowledgment':
+ case 'PENDING_ACKNOWLEDGMENT':
+ return IapkitPurchaseState.PendingAcknowledgment;
+ case 'pending':
+ case 'PENDING':
+ return IapkitPurchaseState.Pending;
+ case 'canceled':
+ case 'CANCELED':
+ return IapkitPurchaseState.Canceled;
+ case 'expired':
+ case 'EXPIRED':
+ return IapkitPurchaseState.Expired;
+ case 'ready-to-consume':
+ case 'READY_TO_CONSUME':
+ return IapkitPurchaseState.ReadyToConsume;
+ case 'consumed':
+ case 'CONSUMED':
+ return IapkitPurchaseState.Consumed;
+ case 'unknown':
+ case 'UNKNOWN':
+ return IapkitPurchaseState.Unknown;
+ case 'inauthentic':
+ case 'INAUTHENTIC':
+ return IapkitPurchaseState.Inauthentic;
+ }
+ throw ArgumentError('Unknown IapkitPurchaseState value: $value');
+ }
+
+ String toJson() => value;
+}
+
+enum IapkitStore {
+ Apple('apple'),
+ Google('google');
+
+ const IapkitStore(this.value);
+ final String value;
+
+ factory IapkitStore.fromJson(String value) {
+ switch (value) {
+ case 'apple':
+ case 'APPLE':
+ case 'Apple':
+ return IapkitStore.Apple;
+ case 'google':
+ case 'GOOGLE':
+ case 'Google':
+ return IapkitStore.Google;
+ }
+ throw ArgumentError('Unknown IapkitStore value: $value');
+ }
+
+ String toJson() => value;
+}
+
enum IapPlatform {
IOS('ios'),
Android('android');
@@ -475,6 +574,25 @@ enum PurchaseState {
String toJson() => value;
}
+enum PurchaseVerificationProvider {
+ Iapkit('iapkit');
+
+ const PurchaseVerificationProvider(this.value);
+ final String value;
+
+ factory PurchaseVerificationProvider.fromJson(String value) {
+ switch (value) {
+ case 'iapkit':
+ case 'IAPKIT':
+ case 'Iapkit':
+ return PurchaseVerificationProvider.Iapkit;
+ }
+ throw ArgumentError('Unknown PurchaseVerificationProvider value: $value');
+ }
+
+ String toJson() => value;
+}
+
enum SubscriptionOfferTypeIOS {
Introductory('introductory'),
Promotional('promotional');
@@ -1705,138 +1823,6 @@ class PurchaseOfferIOS {
}
}
-class ReceiptValidationResultAndroid extends ReceiptValidationResult {
- const ReceiptValidationResultAndroid({
- required this.autoRenewing,
- required this.betaProduct,
- this.cancelDate,
- this.cancelReason,
- this.deferredDate,
- this.deferredSku,
- required this.freeTrialEndDate,
- required this.gracePeriodEndDate,
- required this.parentProductId,
- required this.productId,
- required this.productType,
- required this.purchaseDate,
- required this.quantity,
- required this.receiptId,
- required this.renewalDate,
- required this.term,
- required this.termSku,
- required this.testTransaction,
- });
-
- final bool autoRenewing;
- final bool betaProduct;
- final double? cancelDate;
- final String? cancelReason;
- final double? deferredDate;
- final String? deferredSku;
- final double freeTrialEndDate;
- final double gracePeriodEndDate;
- final String parentProductId;
- final String productId;
- final String productType;
- final double purchaseDate;
- final int quantity;
- final String receiptId;
- final double renewalDate;
- final String term;
- final String termSku;
- final bool testTransaction;
-
- factory ReceiptValidationResultAndroid.fromJson(Map json) {
- return ReceiptValidationResultAndroid(
- autoRenewing: json['autoRenewing'] as bool,
- betaProduct: json['betaProduct'] as bool,
- cancelDate: (json['cancelDate'] as num?)?.toDouble(),
- cancelReason: json['cancelReason'] as String?,
- deferredDate: (json['deferredDate'] as num?)?.toDouble(),
- deferredSku: json['deferredSku'] as String?,
- freeTrialEndDate: (json['freeTrialEndDate'] as num).toDouble(),
- gracePeriodEndDate: (json['gracePeriodEndDate'] as num).toDouble(),
- parentProductId: json['parentProductId'] as String,
- productId: json['productId'] as String,
- productType: json['productType'] as String,
- purchaseDate: (json['purchaseDate'] as num).toDouble(),
- quantity: json['quantity'] as int,
- receiptId: json['receiptId'] as String,
- renewalDate: (json['renewalDate'] as num).toDouble(),
- term: json['term'] as String,
- termSku: json['termSku'] as String,
- testTransaction: json['testTransaction'] as bool,
- );
- }
-
- @override
- Map toJson() {
- return {
- '__typename': 'ReceiptValidationResultAndroid',
- 'autoRenewing': autoRenewing,
- 'betaProduct': betaProduct,
- 'cancelDate': cancelDate,
- 'cancelReason': cancelReason,
- 'deferredDate': deferredDate,
- 'deferredSku': deferredSku,
- 'freeTrialEndDate': freeTrialEndDate,
- 'gracePeriodEndDate': gracePeriodEndDate,
- 'parentProductId': parentProductId,
- 'productId': productId,
- 'productType': productType,
- 'purchaseDate': purchaseDate,
- 'quantity': quantity,
- 'receiptId': receiptId,
- 'renewalDate': renewalDate,
- 'term': term,
- 'termSku': termSku,
- 'testTransaction': testTransaction,
- };
- }
-}
-
-class ReceiptValidationResultIOS extends ReceiptValidationResult {
- const ReceiptValidationResultIOS({
- /// Whether the receipt is valid
- required this.isValid,
- /// JWS representation
- required this.jwsRepresentation,
- /// Latest transaction if available
- this.latestTransaction,
- /// Receipt data string
- required this.receiptData,
- });
-
- /// Whether the receipt is valid
- final bool isValid;
- /// JWS representation
- final String jwsRepresentation;
- /// Latest transaction if available
- final Purchase? latestTransaction;
- /// Receipt data string
- final String receiptData;
-
- factory ReceiptValidationResultIOS.fromJson(Map json) {
- return ReceiptValidationResultIOS(
- isValid: json['isValid'] as bool,
- jwsRepresentation: json['jwsRepresentation'] as String,
- latestTransaction: json['latestTransaction'] != null ? Purchase.fromJson(json['latestTransaction'] as Map) : null,
- receiptData: json['receiptData'] as String,
- );
- }
-
- @override
- Map toJson() {
- return {
- '__typename': 'ReceiptValidationResultIOS',
- 'isValid': isValid,
- 'jwsRepresentation': jwsRepresentation,
- 'latestTransaction': latestTransaction?.toJson(),
- 'receiptData': receiptData,
- };
- }
-}
-
class RefundResultIOS {
const RefundResultIOS({
this.message,
@@ -1969,6 +1955,39 @@ class RequestPurchaseResultPurchases extends RequestPurchaseResult {
final List? value;
}
+class RequestVerifyPurchaseWithIapkitResult {
+ const RequestVerifyPurchaseWithIapkitResult({
+ /// Whether the purchase is valid (not falsified).
+ required this.isValid,
+ /// The current state of the purchase.
+ required this.state,
+ required this.store,
+ });
+
+ /// Whether the purchase is valid (not falsified).
+ final bool isValid;
+ /// The current state of the purchase.
+ final IapkitPurchaseState state;
+ final IapkitStore store;
+
+ factory RequestVerifyPurchaseWithIapkitResult.fromJson(Map json) {
+ return RequestVerifyPurchaseWithIapkitResult(
+ isValid: json['isValid'] as bool,
+ state: IapkitPurchaseState.fromJson(json['state'] as String),
+ store: IapkitStore.fromJson(json['store'] as String),
+ );
+ }
+
+ Map toJson() {
+ return {
+ '__typename': 'RequestVerifyPurchaseWithIapkitResult',
+ 'isValid': isValid,
+ 'state': state.toJson(),
+ 'store': store.toJson(),
+ };
+ }
+}
+
class SubscriptionInfoIOS {
const SubscriptionInfoIOS({
this.introductoryOffer,
@@ -2128,6 +2147,165 @@ class UserChoiceBillingDetails {
}
}
+class VerifyPurchaseResultAndroid extends VerifyPurchaseResult {
+ const VerifyPurchaseResultAndroid({
+ required this.autoRenewing,
+ required this.betaProduct,
+ this.cancelDate,
+ this.cancelReason,
+ this.deferredDate,
+ this.deferredSku,
+ required this.freeTrialEndDate,
+ required this.gracePeriodEndDate,
+ required this.parentProductId,
+ required this.productId,
+ required this.productType,
+ required this.purchaseDate,
+ required this.quantity,
+ required this.receiptId,
+ required this.renewalDate,
+ required this.term,
+ required this.termSku,
+ required this.testTransaction,
+ });
+
+ final bool autoRenewing;
+ final bool betaProduct;
+ final double? cancelDate;
+ final String? cancelReason;
+ final double? deferredDate;
+ final String? deferredSku;
+ final double freeTrialEndDate;
+ final double gracePeriodEndDate;
+ final String parentProductId;
+ final String productId;
+ final String productType;
+ final double purchaseDate;
+ final int quantity;
+ final String receiptId;
+ final double renewalDate;
+ final String term;
+ final String termSku;
+ final bool testTransaction;
+
+ factory VerifyPurchaseResultAndroid.fromJson(Map json) {
+ return VerifyPurchaseResultAndroid(
+ autoRenewing: json['autoRenewing'] as bool,
+ betaProduct: json['betaProduct'] as bool,
+ cancelDate: (json['cancelDate'] as num?)?.toDouble(),
+ cancelReason: json['cancelReason'] as String?,
+ deferredDate: (json['deferredDate'] as num?)?.toDouble(),
+ deferredSku: json['deferredSku'] as String?,
+ freeTrialEndDate: (json['freeTrialEndDate'] as num).toDouble(),
+ gracePeriodEndDate: (json['gracePeriodEndDate'] as num).toDouble(),
+ parentProductId: json['parentProductId'] as String,
+ productId: json['productId'] as String,
+ productType: json['productType'] as String,
+ purchaseDate: (json['purchaseDate'] as num).toDouble(),
+ quantity: json['quantity'] as int,
+ receiptId: json['receiptId'] as String,
+ renewalDate: (json['renewalDate'] as num).toDouble(),
+ term: json['term'] as String,
+ termSku: json['termSku'] as String,
+ testTransaction: json['testTransaction'] as bool,
+ );
+ }
+
+ @override
+ Map toJson() {
+ return {
+ '__typename': 'VerifyPurchaseResultAndroid',
+ 'autoRenewing': autoRenewing,
+ 'betaProduct': betaProduct,
+ 'cancelDate': cancelDate,
+ 'cancelReason': cancelReason,
+ 'deferredDate': deferredDate,
+ 'deferredSku': deferredSku,
+ 'freeTrialEndDate': freeTrialEndDate,
+ 'gracePeriodEndDate': gracePeriodEndDate,
+ 'parentProductId': parentProductId,
+ 'productId': productId,
+ 'productType': productType,
+ 'purchaseDate': purchaseDate,
+ 'quantity': quantity,
+ 'receiptId': receiptId,
+ 'renewalDate': renewalDate,
+ 'term': term,
+ 'termSku': termSku,
+ 'testTransaction': testTransaction,
+ };
+ }
+}
+
+class VerifyPurchaseResultIOS extends VerifyPurchaseResult {
+ const VerifyPurchaseResultIOS({
+ /// Whether the receipt is valid
+ required this.isValid,
+ /// JWS representation
+ required this.jwsRepresentation,
+ /// Latest transaction if available
+ this.latestTransaction,
+ /// Receipt data string
+ required this.receiptData,
+ });
+
+ /// Whether the receipt is valid
+ final bool isValid;
+ /// JWS representation
+ final String jwsRepresentation;
+ /// Latest transaction if available
+ final Purchase? latestTransaction;
+ /// Receipt data string
+ final String receiptData;
+
+ factory VerifyPurchaseResultIOS.fromJson(Map json) {
+ return VerifyPurchaseResultIOS(
+ isValid: json['isValid'] as bool,
+ jwsRepresentation: json['jwsRepresentation'] as String,
+ latestTransaction: json['latestTransaction'] != null ? Purchase.fromJson(json['latestTransaction'] as Map) : null,
+ receiptData: json['receiptData'] as String,
+ );
+ }
+
+ @override
+ Map toJson() {
+ return {
+ '__typename': 'VerifyPurchaseResultIOS',
+ 'isValid': isValid,
+ 'jwsRepresentation': jwsRepresentation,
+ 'latestTransaction': latestTransaction?.toJson(),
+ 'receiptData': receiptData,
+ };
+ }
+}
+
+class VerifyPurchaseWithProviderResult {
+ const VerifyPurchaseWithProviderResult({
+ /// IAPKit verification results (can include Apple and Google entries)
+ required this.iapkit,
+ required this.provider,
+ });
+
+ /// IAPKit verification results (can include Apple and Google entries)
+ final List iapkit;
+ final PurchaseVerificationProvider provider;
+
+ factory VerifyPurchaseWithProviderResult.fromJson(Map json) {
+ return VerifyPurchaseWithProviderResult(
+ iapkit: (json['iapkit'] as List).map((e) => RequestVerifyPurchaseWithIapkitResult.fromJson(e as Map)).toList(),
+ provider: PurchaseVerificationProvider.fromJson(json['provider'] as String),
+ );
+ }
+
+ Map toJson() {
+ return {
+ '__typename': 'VerifyPurchaseWithProviderResult',
+ 'iapkit': iapkit.map((e) => e.toJson()).toList(),
+ 'provider': provider.toJson(),
+ };
+ }
+}
+
typedef VoidResult = void;
// MARK: - Input Objects
@@ -2313,66 +2491,6 @@ class PurchaseOptions {
}
}
-class ReceiptValidationAndroidOptions {
- const ReceiptValidationAndroidOptions({
- required this.accessToken,
- this.isSub,
- required this.packageName,
- required this.productToken,
- });
-
- final String accessToken;
- final bool? isSub;
- final String packageName;
- final String productToken;
-
- factory ReceiptValidationAndroidOptions.fromJson(Map json) {
- return ReceiptValidationAndroidOptions(
- accessToken: json['accessToken'] as String,
- isSub: json['isSub'] as bool?,
- packageName: json['packageName'] as String,
- productToken: json['productToken'] as String,
- );
- }
-
- Map toJson() {
- return {
- 'accessToken': accessToken,
- 'isSub': isSub,
- 'packageName': packageName,
- 'productToken': productToken,
- };
- }
-}
-
-class ReceiptValidationProps {
- const ReceiptValidationProps({
- /// Android-specific validation options
- this.androidOptions,
- /// Product SKU to validate
- required this.sku,
- });
-
- /// Android-specific validation options
- final ReceiptValidationAndroidOptions? androidOptions;
- /// Product SKU to validate
- final String sku;
-
- factory ReceiptValidationProps.fromJson(Map json) {
- return ReceiptValidationProps(
- androidOptions: json['androidOptions'] != null ? ReceiptValidationAndroidOptions.fromJson(json['androidOptions'] as Map) : null,
- sku: json['sku'] as String,
- );
- }
-
- Map toJson() {
- return {
- 'androidOptions': androidOptions?.toJson(),
- 'sku': sku,
- };
- }
-}
-
class RequestPurchaseAndroidProps {
const RequestPurchaseAndroidProps({
/// Personalized offer flag
@@ -2669,6 +2787,168 @@ class RequestSubscriptionPropsByPlatforms {
}
}
+class RequestVerifyPurchaseWithIapkitAppleProps {
+ const RequestVerifyPurchaseWithIapkitAppleProps({
+ /// The JWS token returned with the purchase response.
+ required this.jws,
+ });
+
+ /// The JWS token returned with the purchase response.
+ final String jws;
+
+ factory RequestVerifyPurchaseWithIapkitAppleProps.fromJson(Map json) {
+ return RequestVerifyPurchaseWithIapkitAppleProps(
+ jws: json['jws'] as String,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'jws': jws,
+ };
+ }
+}
+
+class RequestVerifyPurchaseWithIapkitGoogleProps {
+ const RequestVerifyPurchaseWithIapkitGoogleProps({
+ /// The token provided to the user's device when the product or subscription was purchased.
+ required this.purchaseToken,
+ });
+
+ /// The token provided to the user's device when the product or subscription was purchased.
+ final String purchaseToken;
+
+ factory RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(Map json) {
+ return RequestVerifyPurchaseWithIapkitGoogleProps(
+ purchaseToken: json['purchaseToken'] as String,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'purchaseToken': purchaseToken,
+ };
+ }
+}
+
+class RequestVerifyPurchaseWithIapkitProps {
+ const RequestVerifyPurchaseWithIapkitProps({
+ /// API key used for the Authorization header (Bearer {apiKey}).
+ this.apiKey,
+ /// Apple verification parameters.
+ this.apple,
+ /// Google verification parameters.
+ this.google,
+ });
+
+ /// API key used for the Authorization header (Bearer {apiKey}).
+ final String? apiKey;
+ /// Apple verification parameters.
+ final RequestVerifyPurchaseWithIapkitAppleProps? apple;
+ /// Google verification parameters.
+ final RequestVerifyPurchaseWithIapkitGoogleProps? google;
+
+ factory RequestVerifyPurchaseWithIapkitProps.fromJson(Map json) {
+ return RequestVerifyPurchaseWithIapkitProps(
+ apiKey: json['apiKey'] as String?,
+ apple: json['apple'] != null ? RequestVerifyPurchaseWithIapkitAppleProps.fromJson(json['apple'] as Map) : null,
+ google: json['google'] != null ? RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(json['google'] as Map) : null,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'apiKey': apiKey,
+ 'apple': apple?.toJson(),
+ 'google': google?.toJson(),
+ };
+ }
+}
+
+class VerifyPurchaseAndroidOptions {
+ const VerifyPurchaseAndroidOptions({
+ required this.accessToken,
+ this.isSub,
+ required this.packageName,
+ required this.productToken,
+ });
+
+ final String accessToken;
+ final bool? isSub;
+ final String packageName;
+ final String productToken;
+
+ factory VerifyPurchaseAndroidOptions.fromJson(Map json) {
+ return VerifyPurchaseAndroidOptions(
+ accessToken: json['accessToken'] as String,
+ isSub: json['isSub'] as bool?,
+ packageName: json['packageName'] as String,
+ productToken: json['productToken'] as String,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'accessToken': accessToken,
+ 'isSub': isSub,
+ 'packageName': packageName,
+ 'productToken': productToken,
+ };
+ }
+}
+
+class VerifyPurchaseProps {
+ const VerifyPurchaseProps({
+ /// Android-specific validation options
+ this.androidOptions,
+ /// Product SKU to validate
+ required this.sku,
+ });
+
+ /// Android-specific validation options
+ final VerifyPurchaseAndroidOptions? androidOptions;
+ /// Product SKU to validate
+ final String sku;
+
+ factory VerifyPurchaseProps.fromJson(Map json) {
+ return VerifyPurchaseProps(
+ androidOptions: json['androidOptions'] != null ? VerifyPurchaseAndroidOptions.fromJson(json['androidOptions'] as Map) : null,
+ sku: json['sku'] as String,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'androidOptions': androidOptions?.toJson(),
+ 'sku': sku,
+ };
+ }
+}
+
+class VerifyPurchaseWithProviderProps {
+ const VerifyPurchaseWithProviderProps({
+ this.iapkit,
+ required this.provider,
+ });
+
+ final RequestVerifyPurchaseWithIapkitProps? iapkit;
+ final PurchaseVerificationProvider provider;
+
+ factory VerifyPurchaseWithProviderProps.fromJson(Map json) {
+ return VerifyPurchaseWithProviderProps(
+ iapkit: json['iapkit'] != null ? RequestVerifyPurchaseWithIapkitProps.fromJson(json['iapkit'] as Map) : null,
+ provider: PurchaseVerificationProvider.fromJson(json['provider'] as String),
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'iapkit': iapkit?.toJson(),
+ 'provider': provider.toJson(),
+ };
+ }
+}
+
// MARK: - Unions
sealed class Product implements ProductCommon {
@@ -2827,18 +3107,18 @@ sealed class Purchase implements PurchaseCommon {
Map toJson();
}
-sealed class ReceiptValidationResult {
- const ReceiptValidationResult();
+sealed class VerifyPurchaseResult {
+ const VerifyPurchaseResult();
- factory ReceiptValidationResult.fromJson(Map json) {
+ factory VerifyPurchaseResult.fromJson(Map json) {
final typeName = json['__typename'] as String?;
switch (typeName) {
- case 'ReceiptValidationResultAndroid':
- return ReceiptValidationResultAndroid.fromJson(json);
- case 'ReceiptValidationResultIOS':
- return ReceiptValidationResultIOS.fromJson(json);
+ case 'VerifyPurchaseResultAndroid':
+ return VerifyPurchaseResultAndroid.fromJson(json);
+ case 'VerifyPurchaseResultIOS':
+ return VerifyPurchaseResultIOS.fromJson(json);
}
- throw ArgumentError('Unknown __typename for ReceiptValidationResult: $typeName');
+ throw ArgumentError('Unknown __typename for VerifyPurchaseResult: $typeName');
}
Map toJson();
@@ -2910,15 +3190,20 @@ abstract class MutationResolver {
/// Force a StoreKit sync for transactions (iOS 15+)
Future syncIOS();
/// Validate purchase receipts with the configured providers
- Future validateReceipt({
- ReceiptValidationAndroidOptions? androidOptions,
+ Future validateReceipt({
+ VerifyPurchaseAndroidOptions? androidOptions,
required String sku,
});
/// Verify purchases with the configured providers
- Future verifyPurchase({
- ReceiptValidationAndroidOptions? androidOptions,
+ Future verifyPurchase({
+ VerifyPurchaseAndroidOptions? androidOptions,
required String sku,
});
+ /// Verify purchases with a specific provider (e.g., IAPKit)
+ Future verifyPurchaseWithProvider({
+ RequestVerifyPurchaseWithIapkitProps? iapkit,
+ required PurchaseVerificationProvider provider,
+ });
}
/// GraphQL root query operations.
@@ -2964,8 +3249,8 @@ abstract class QueryResolver {
/// Get StoreKit 2 subscription status details (iOS 15+)
Future> subscriptionStatusIOS(String sku);
/// Validate a receipt for a specific product
- Future validateReceiptIOS({
- ReceiptValidationAndroidOptions? androidOptions,
+ Future validateReceiptIOS({
+ VerifyPurchaseAndroidOptions? androidOptions,
required String sku,
});
}
@@ -3014,14 +3299,18 @@ typedef MutationRestorePurchasesHandler = Future Function();
typedef MutationShowAlternativeBillingDialogAndroidHandler = Future Function();
typedef MutationShowManageSubscriptionsIOSHandler = Future> Function();
typedef MutationSyncIOSHandler = Future Function();
-typedef MutationValidateReceiptHandler = Future Function({
- ReceiptValidationAndroidOptions? androidOptions,
+typedef MutationValidateReceiptHandler = Future Function({
+ VerifyPurchaseAndroidOptions? androidOptions,
required String sku,
});
-typedef MutationVerifyPurchaseHandler = Future Function({
- ReceiptValidationAndroidOptions? androidOptions,
+typedef MutationVerifyPurchaseHandler = Future Function({
+ VerifyPurchaseAndroidOptions? androidOptions,
required String sku,
});
+typedef MutationVerifyPurchaseWithProviderHandler = Future Function({
+ RequestVerifyPurchaseWithIapkitProps? iapkit,
+ required PurchaseVerificationProvider provider,
+});
class MutationHandlers {
const MutationHandlers({
@@ -3046,6 +3335,7 @@ class MutationHandlers {
this.syncIOS,
this.validateReceipt,
this.verifyPurchase,
+ this.verifyPurchaseWithProvider,
});
final MutationAcknowledgePurchaseAndroidHandler? acknowledgePurchaseAndroid;
@@ -3069,6 +3359,7 @@ class MutationHandlers {
final MutationSyncIOSHandler? syncIOS;
final MutationValidateReceiptHandler? validateReceipt;
final MutationVerifyPurchaseHandler? verifyPurchase;
+ final MutationVerifyPurchaseWithProviderHandler? verifyPurchaseWithProvider;
}
// MARK: - Query Helpers
@@ -3096,8 +3387,8 @@ typedef QueryIsEligibleForIntroOfferIOSHandler = Future Function(String gr
typedef QueryIsTransactionVerifiedIOSHandler = Future Function(String sku);
typedef QueryLatestTransactionIOSHandler = Future Function(String sku);
typedef QuerySubscriptionStatusIOSHandler = Future> Function(String sku);
-typedef QueryValidateReceiptIOSHandler = Future Function({
- ReceiptValidationAndroidOptions? androidOptions,
+typedef QueryValidateReceiptIOSHandler = Future Function({
+ VerifyPurchaseAndroidOptions? androidOptions,
required String sku,
});
diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts
index 35217f57..c90d0b72 100644
--- a/packages/gql/src/generated/types.ts
+++ b/packages/gql/src/generated/types.ts
@@ -137,6 +137,9 @@ export enum ErrorCode {
NotPrepared = 'not-prepared',
Pending = 'pending',
PurchaseError = 'purchase-error',
+ PurchaseVerificationFailed = 'purchase-verification-failed',
+ PurchaseVerificationFinishFailed = 'purchase-verification-finish-failed',
+ PurchaseVerificationFinished = 'purchase-verification-finished',
QueryProduct = 'query-product',
ReceiptFailed = 'receipt-failed',
ReceiptFinished = 'receipt-finished',
@@ -178,6 +181,11 @@ export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product
export type IapPlatform = 'ios' | 'android';
+/** Unified purchase states from IAPKit verification response. */
+export type IapkitPurchaseState = 'entitled' | 'pending-acknowledgment' | 'pending' | 'canceled' | 'expired' | 'ready-to-consume' | 'consumed' | 'unknown' | 'inauthentic';
+
+export type IapkitStore = 'apple' | 'google';
+
/** Connection initialization configuration */
export interface InitConnectionConfig {
/**
@@ -251,9 +259,11 @@ export interface Mutation {
* Validate purchase receipts with the configured providers
* @deprecated Use verifyPurchase
*/
- validateReceipt: Promise;
+ validateReceipt: Promise;
/** Verify purchases with the configured providers */
- verifyPurchase: Promise;
+ verifyPurchase: Promise;
+ /** Verify purchases with a specific provider (e.g., IAPKit) */
+ verifyPurchaseWithProvider: Promise;
}
@@ -293,9 +303,11 @@ export type MutationRequestPurchaseArgs =
};
-export type MutationValidateReceiptArgs = ReceiptValidationProps;
+export type MutationValidateReceiptArgs = VerifyPurchaseProps;
-export type MutationVerifyPurchaseArgs = ReceiptValidationProps;
+export type MutationVerifyPurchaseArgs = VerifyPurchaseProps;
+
+export type MutationVerifyPurchaseWithProviderArgs = VerifyPurchaseWithProviderProps;
export type PaymentModeIOS = 'empty' | 'free-trial' | 'pay-as-you-go' | 'pay-up-front';
@@ -535,6 +547,8 @@ export interface PurchaseOptions {
export type PurchaseState = 'pending' | 'purchased' | 'failed' | 'restored' | 'deferred' | 'unknown';
+export type PurchaseVerificationProvider = 'iapkit';
+
export interface Query {
/** Check if external purchase notice sheet can be presented (iOS 18.2+) */
canPresentExternalPurchaseNoticeIOS: Promise;
@@ -577,7 +591,7 @@ export interface Query {
* Validate a receipt for a specific product
* @deprecated Use verifyPurchase
*/
- validateReceiptIOS: Promise;
+ validateReceiptIOS: Promise;
}
@@ -602,55 +616,7 @@ export type QueryLatestTransactionIosArgs = string;
export type QuerySubscriptionStatusIosArgs = string;
-export type QueryValidateReceiptIosArgs = ReceiptValidationProps;
-
-export interface ReceiptValidationAndroidOptions {
- accessToken: string;
- isSub?: (boolean | null);
- packageName: string;
- productToken: string;
-}
-
-export interface ReceiptValidationProps {
- /** Android-specific validation options */
- androidOptions?: (ReceiptValidationAndroidOptions | null);
- /** Product SKU to validate */
- sku: string;
-}
-
-export type ReceiptValidationResult = ReceiptValidationResultAndroid | ReceiptValidationResultIOS;
-
-export interface ReceiptValidationResultAndroid {
- autoRenewing: boolean;
- betaProduct: boolean;
- cancelDate?: (number | null);
- cancelReason?: (string | null);
- deferredDate?: (number | null);
- deferredSku?: (string | null);
- freeTrialEndDate: number;
- gracePeriodEndDate: number;
- parentProductId: string;
- productId: string;
- productType: string;
- purchaseDate: number;
- quantity: number;
- receiptId: string;
- renewalDate: number;
- term: string;
- termSku: string;
- testTransaction: boolean;
-}
-
-export interface ReceiptValidationResultIOS {
- /** Whether the receipt is valid */
- isValid: boolean;
- /** JWS representation */
- jwsRepresentation: string;
- /** Latest transaction if available */
- latestTransaction?: (Purchase | null);
- /** Receipt data string */
- receiptData: string;
-}
+export type QueryValidateReceiptIosArgs = VerifyPurchaseProps;
export interface RefundResultIOS {
message?: (string | null);
@@ -785,6 +751,33 @@ export interface RequestSubscriptionPropsByPlatforms {
ios?: (RequestSubscriptionIosProps | null);
}
+export interface RequestVerifyPurchaseWithIapkitAppleProps {
+ /** The JWS token returned with the purchase response. */
+ jws: string;
+}
+
+export interface RequestVerifyPurchaseWithIapkitGoogleProps {
+ /** The token provided to the user's device when the product or subscription was purchased. */
+ purchaseToken: string;
+}
+
+export interface RequestVerifyPurchaseWithIapkitProps {
+ /** API key used for the Authorization header (Bearer {apiKey}). */
+ apiKey?: (string | null);
+ /** Apple verification parameters. */
+ apple?: (RequestVerifyPurchaseWithIapkitAppleProps | null);
+ /** Google verification parameters. */
+ google?: (RequestVerifyPurchaseWithIapkitGoogleProps | null);
+}
+
+export interface RequestVerifyPurchaseWithIapkitResult {
+ /** Whether the purchase is valid (not falsified). */
+ isValid: boolean;
+ /** The current state of the purchase. */
+ state: IapkitPurchaseState;
+ store: IapkitStore;
+}
+
export interface Subscription {
/** Fires when the App Store surfaces a promoted product (iOS only) */
promotedProductIOS: string;
@@ -842,6 +835,65 @@ export interface UserChoiceBillingDetails {
products: string[];
}
+export interface VerifyPurchaseAndroidOptions {
+ accessToken: string;
+ isSub?: (boolean | null);
+ packageName: string;
+ productToken: string;
+}
+
+export interface VerifyPurchaseProps {
+ /** Android-specific validation options */
+ androidOptions?: (VerifyPurchaseAndroidOptions | null);
+ /** Product SKU to validate */
+ sku: string;
+}
+
+export type VerifyPurchaseResult = VerifyPurchaseResultAndroid | VerifyPurchaseResultIOS;
+
+export interface VerifyPurchaseResultAndroid {
+ autoRenewing: boolean;
+ betaProduct: boolean;
+ cancelDate?: (number | null);
+ cancelReason?: (string | null);
+ deferredDate?: (number | null);
+ deferredSku?: (string | null);
+ freeTrialEndDate: number;
+ gracePeriodEndDate: number;
+ parentProductId: string;
+ productId: string;
+ productType: string;
+ purchaseDate: number;
+ quantity: number;
+ receiptId: string;
+ renewalDate: number;
+ term: string;
+ termSku: string;
+ testTransaction: boolean;
+}
+
+export interface VerifyPurchaseResultIOS {
+ /** Whether the receipt is valid */
+ isValid: boolean;
+ /** JWS representation */
+ jwsRepresentation: string;
+ /** Latest transaction if available */
+ latestTransaction?: (Purchase | null);
+ /** Receipt data string */
+ receiptData: string;
+}
+
+export interface VerifyPurchaseWithProviderProps {
+ iapkit?: (RequestVerifyPurchaseWithIapkitProps | null);
+ provider: PurchaseVerificationProvider;
+}
+
+export interface VerifyPurchaseWithProviderResult {
+ /** IAPKit verification results (can include Apple and Google entries) */
+ iapkit: RequestVerifyPurchaseWithIapkitResult[];
+ provider: PurchaseVerificationProvider;
+}
+
export type VoidResult = void;
// -- Query helper types (auto-generated)
@@ -901,6 +953,7 @@ export type MutationArgsMap = {
syncIOS: never;
validateReceipt: MutationValidateReceiptArgs;
verifyPurchase: MutationVerifyPurchaseArgs;
+ verifyPurchaseWithProvider: MutationVerifyPurchaseWithProviderArgs;
};
export type MutationField =
diff --git a/packages/gql/src/type-android.graphql b/packages/gql/src/type-android.graphql
index 4ea4282b..cd8859a7 100644
--- a/packages/gql/src/type-android.graphql
+++ b/packages/gql/src/type-android.graphql
@@ -153,14 +153,14 @@ input AndroidSubscriptionOfferInput {
offerToken: String!
}
-input ReceiptValidationAndroidOptions {
+input VerifyPurchaseAndroidOptions {
packageName: String!
productToken: String!
accessToken: String!
isSub: Boolean
}
-type ReceiptValidationResultAndroid {
+type VerifyPurchaseResultAndroid {
autoRenewing: Boolean!
betaProduct: Boolean!
cancelDate: Float
@@ -220,4 +220,3 @@ type UserChoiceBillingDetails {
"""
products: [String!]!
}
-
diff --git a/packages/gql/src/type-ios.graphql b/packages/gql/src/type-ios.graphql
index 7d424e3b..b8fa6852 100644
--- a/packages/gql/src/type-ios.graphql
+++ b/packages/gql/src/type-ios.graphql
@@ -223,7 +223,7 @@ input DiscountOfferInputIOS {
timestamp: Float!
}
-type ReceiptValidationResultIOS {
+type VerifyPurchaseResultIOS {
"""
Whether the receipt is valid
"""
diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql
index f3219166..21dda843 100644
--- a/packages/gql/src/type.graphql
+++ b/packages/gql/src/type.graphql
@@ -30,6 +30,16 @@ enum IapEvent {
UserChoiceBillingAndroid
}
+# Purchase verification providers
+enum PurchaseVerificationProvider {
+ Iapkit
+}
+
+enum IapkitStore {
+ Apple
+ Google
+}
+
# Common product fields
interface ProductCommon {
id: ID!
@@ -188,7 +198,7 @@ input RequestSubscriptionPropsByPlatforms {
}
# Receipt validation inputs and results
-input ReceiptValidationProps {
+input VerifyPurchaseProps {
"""
Product SKU to validate
"""
@@ -196,12 +206,108 @@ input ReceiptValidationProps {
"""
Android-specific validation options
"""
- androidOptions: ReceiptValidationAndroidOptions
+ androidOptions: VerifyPurchaseAndroidOptions
+}
+
+union VerifyPurchaseResult =
+ VerifyPurchaseResultAndroid
+ | VerifyPurchaseResultIOS
+
+input RequestVerifyPurchaseWithIapkitAppleProps {
+ """
+ The JWS token returned with the purchase response.
+ """
+ jws: String!
+}
+
+input RequestVerifyPurchaseWithIapkitGoogleProps {
+ """
+ The token provided to the user's device when the product or subscription was purchased.
+ """
+ purchaseToken: String!
+}
+
+input RequestVerifyPurchaseWithIapkitProps {
+ """
+ API key used for the Authorization header (Bearer {apiKey}).
+ """
+ apiKey: String
+ """
+ Apple verification parameters.
+ """
+ apple: RequestVerifyPurchaseWithIapkitAppleProps
+ """
+ Google verification parameters.
+ """
+ google: RequestVerifyPurchaseWithIapkitGoogleProps
+}
+
+"""
+Unified purchase states from IAPKit verification response.
+"""
+enum IapkitPurchaseState {
+ """
+ User is entitled to the product (purchase is complete and active).
+ """
+ ENTITLED
+ """
+ Receipt is valid but still needs server acknowledgment.
+ """
+ PENDING_ACKNOWLEDGMENT
+ """
+ Purchase is in progress or awaiting confirmation.
+ """
+ PENDING
+ """
+ Purchase was cancelled or refunded.
+ """
+ CANCELED
+ """
+ Subscription or entitlement has expired.
+ """
+ EXPIRED
+ """
+ Consumable purchase is ready to be fulfilled.
+ """
+ READY_TO_CONSUME
+ """
+ Consumable item has been fulfilled/consumed.
+ """
+ CONSUMED
+ """
+ Purchase state could not be determined.
+ """
+ UNKNOWN
+ """
+ Purchase receipt is not authentic (fraudulent or tampered).
+ """
+ INAUTHENTIC
}
-union ReceiptValidationResult =
- ReceiptValidationResultAndroid
- | ReceiptValidationResultIOS
+type RequestVerifyPurchaseWithIapkitResult {
+ store: IapkitStore!
+ """
+ Whether the purchase is valid (not falsified).
+ """
+ isValid: Boolean!
+ """
+ The current state of the purchase.
+ """
+ state: IapkitPurchaseState!
+}
+
+input VerifyPurchaseWithProviderProps {
+ provider: PurchaseVerificationProvider!
+ iapkit: RequestVerifyPurchaseWithIapkitProps
+}
+
+type VerifyPurchaseWithProviderResult {
+ provider: PurchaseVerificationProvider!
+ """
+ IAPKit verification results (can include Apple and Google entries)
+ """
+ iapkit: [RequestVerifyPurchaseWithIapkitResult!]!
+}
# Aggregated active subscription data across platforms
type ActiveSubscription {