From 20dd40bc50b6c8475fa3186ab65baef10f48800c Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 22 Nov 2025 21:08:58 +0900 Subject: [PATCH 1/3] refactor: deprecate validateReceiptIOS and unify with validateReceipt --- packages/apple/Sources/OpenIapModule.swift | 15 ++-- packages/apple/Sources/OpenIapProtocol.swift | 1 + packages/apple/Sources/OpenIapStore.swift | 10 ++- packages/docs/src/components/SearchModal.tsx | 2 +- packages/docs/src/pages/docs/apis.tsx | 15 +++- .../java/dev/hyo/openiap/OpenIapModule.kt | 5 +- .../dev/hyo/openiap/utils/ReceiptValidator.kt | 75 +++++++++++++++++++ .../java/dev/hyo/openiap/OpenIapModule.kt | 5 +- packages/gql/src/api-ios.graphql | 2 +- 9 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 packages/google/openiap/src/main/java/dev/hyo/openiap/utils/ReceiptValidator.kt diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index c570d8e7..5a97824e 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -550,7 +550,17 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return data.base64EncodedString() } + @available(*, deprecated, message: "Use validateReceipt") public func validateReceiptIOS(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS { + try await performValidateReceiptIOS(props) + } + + public func validateReceipt(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult { + let iosResult = try await performValidateReceiptIOS(props) + return .receiptValidationResultIos(iosResult) + } + + private func performValidateReceiptIOS(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS { let receiptData = (try? await getReceiptDataIOS()) ?? "" var latestPurchase: Purchase? = nil var jws: String = "" @@ -576,11 +586,6 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { ) } - public func validateReceipt(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult { - let iosResult = try await validateReceiptIOS(props) - return .receiptValidationResultIos(iosResult) - } - // MARK: - Store Information public func getStorefrontIOS() async throws -> String { diff --git a/packages/apple/Sources/OpenIapProtocol.swift b/packages/apple/Sources/OpenIapProtocol.swift index ebb4a00d..0c85d334 100644 --- a/packages/apple/Sources/OpenIapProtocol.swift +++ b/packages/apple/Sources/OpenIapProtocol.swift @@ -41,6 +41,7 @@ public protocol OpenIapModuleProtocol { // Validation func getReceiptDataIOS() async throws -> String? + @available(*, deprecated, message: "Use validateReceipt") func validateReceiptIOS(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS func validateReceipt(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult diff --git a/packages/apple/Sources/OpenIapStore.swift b/packages/apple/Sources/OpenIapStore.swift index 7a9e4952..4adeb9fa 100644 --- a/packages/apple/Sources/OpenIapStore.swift +++ b/packages/apple/Sources/OpenIapStore.swift @@ -367,7 +367,15 @@ public final class OpenIapStore: ObservableObject { // MARK: - Validation & Metadata public func validateReceipt(sku: String) async throws -> ReceiptValidationResultIOS { - try await module.validateReceiptIOS(ReceiptValidationProps(sku: sku)) + let result = try await module.validateReceipt(ReceiptValidationProps(sku: sku)) + if case let .receiptValidationResultIos(iosResult) = result { + return iosResult + } + throw PurchaseError( + code: .featureNotSupported, + message: "Android receipt validation is not available on Apple platforms", + productId: sku + ) } public func getPromotedProductIOS() async throws -> ProductIOS? { diff --git a/packages/docs/src/components/SearchModal.tsx b/packages/docs/src/components/SearchModal.tsx index acb20b31..37c23dab 100644 --- a/packages/docs/src/components/SearchModal.tsx +++ b/packages/docs/src/components/SearchModal.tsx @@ -313,7 +313,7 @@ const apiData: ApiItem[] = [ id: 'validate-receipt-ios', title: 'validateReceiptIOS', category: 'iOS APIs', - description: 'Validate a receipt for a specific product', + description: 'Deprecated: use validateReceipt for receipt validation', parameters: 'options: ReceiptValidationProps!', returns: 'ReceiptValidationResultIOS!', path: '/docs/apis#validate-receipt-ios', diff --git a/packages/docs/src/pages/docs/apis.tsx b/packages/docs/src/pages/docs/apis.tsx index 09ef5414..a0f3376a 100644 --- a/packages/docs/src/pages/docs/apis.tsx +++ b/packages/docs/src/pages/docs/apis.tsx @@ -595,6 +595,14 @@ validateReceipt(options: ReceiptValidationProps!): Future`}

Validates purchase receipts with the appropriate validation service.

+

+ On iOS this routes through the StoreKit-backed validation flow (the + legacy validateReceiptIOS endpoint is now deprecated). On + Android, pass androidOptions with{' '} + packageName, productToken,{' '} + accessToken, and optional isSub so the SDK + can validate against the Google Play developer API. +

Purchase Identifier Usage @@ -1002,12 +1010,15 @@ getAppTransactionIOS: AppTransaction`} validateReceiptIOS -

Validate a receipt for a specific product.

+

+ Deprecated: Use validateReceipt{' '} + instead for both platforms. +

{`""" Validate a receipt for a specific product """ # Future -validateReceiptIOS(sku: String!): ReceiptValidationResultIOS!`} +validateReceiptIOS(options: ReceiptValidationProps!): ReceiptValidationResultIOS! @deprecated(reason: "Use validateReceipt")`}

Validates a receipt payload against the App Store using the provided validation options. Returns the parsed validation 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 c2724867..063b28c9 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,6 +33,7 @@ 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -642,7 +643,9 @@ class OpenIapModule( } } - override val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.FeatureNotSupported } + override val validateReceipt: MutationValidateReceiptHandler = { props -> + validateReceiptWithGooglePlay(props, TAG) + } private val purchaseError: SubscriptionPurchaseErrorHandler = { onPurchaseError(this::addPurchaseErrorListener, this::removePurchaseErrorListener) 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 new file mode 100644 index 00000000..7519ff2e --- /dev/null +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/ReceiptValidator.kt @@ -0,0 +1,75 @@ +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() + +suspend fun validateReceiptWithGooglePlay( + props: ReceiptValidationProps, + tag: String +): 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 = (URL(url).openConnection() as HttpURLConnection).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("validateReceipt 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 76559c7d..0867401a 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 @@ -59,6 +59,7 @@ 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext @@ -794,7 +795,9 @@ class OpenIapModule( } } - override val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.FeatureNotSupported } + override val validateReceipt: MutationValidateReceiptHandler = { props -> + validateReceiptWithGooglePlay(props, TAG) + } private val purchaseError: SubscriptionPurchaseErrorHandler = { onPurchaseError(this::addPurchaseErrorListener, this::removePurchaseErrorListener) diff --git a/packages/gql/src/api-ios.graphql b/packages/gql/src/api-ios.graphql index 447d543e..7719c676 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! + validateReceiptIOS(options: ReceiptValidationProps!): ReceiptValidationResultIOS! @deprecated(reason: "Use validateReceipt") } extend type Mutation { From c859521670b3982f6b9b6949dda0869eec5e37ef Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 22 Nov 2025 21:28:11 +0900 Subject: [PATCH 2/3] tests: add cases on velidateReceipt --- .vscode/launch.json | 14 ++ packages/apple/Sources/OpenIapStore.swift | 6 +- .../OpenIapTests/ValidateReceiptTests.swift | 142 ++++++++++++++++++ .../dev/hyo/openiap/utils/ReceiptValidator.kt | 9 +- .../dev/hyo/openiap/ReceiptValidatorTest.kt | 110 ++++++++++++++ 5 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 packages/apple/Tests/OpenIapTests/ValidateReceiptTests.swift create mode 100644 packages/google/openiap/src/test/java/dev/hyo/openiap/ReceiptValidatorTest.kt diff --git a/.vscode/launch.json b/.vscode/launch.json index 3922575f..e66ec3d3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -33,6 +33,20 @@ "name": "๐Ÿ“š Docs: Dev Server", "command": "bun run dev", "cwd": "${workspaceFolder}/packages/docs" + }, + { + "type": "node-terminal", + "request": "launch", + "name": "๐Ÿงช Test: Apple (Swift)", + "command": "swift test", + "cwd": "${workspaceFolder}/packages/apple" + }, + { + "type": "node-terminal", + "request": "launch", + "name": "๐Ÿงช Test: Google (Android)", + "command": "./gradlew :openiap:test", + "cwd": "${workspaceFolder}/packages/google" } ] } diff --git a/packages/apple/Sources/OpenIapStore.swift b/packages/apple/Sources/OpenIapStore.swift index 4adeb9fa..5145affa 100644 --- a/packages/apple/Sources/OpenIapStore.swift +++ b/packages/apple/Sources/OpenIapStore.swift @@ -29,7 +29,7 @@ public final class OpenIapStore: ObservableObject { // MARK: - Private Properties - private let module = OpenIapModule.shared + private let module: OpenIapModuleProtocol private var listenerTokens: [Subscription] = [] // MARK: - Callbacks @@ -43,11 +43,13 @@ public final class OpenIapStore: ObservableObject { public init( onPurchaseSuccess: ((OpenIAP.Purchase) -> Void)? = nil, onPurchaseError: ((PurchaseError) -> Void)? = nil, - onPromotedProduct: ((String) -> Void)? = nil + onPromotedProduct: ((String) -> Void)? = nil, + module: OpenIapModuleProtocol = OpenIapModule.shared ) { self.onPurchaseSuccess = onPurchaseSuccess self.onPurchaseError = onPurchaseError self.onPromotedProduct = onPromotedProduct + self.module = module setupListeners() } diff --git a/packages/apple/Tests/OpenIapTests/ValidateReceiptTests.swift b/packages/apple/Tests/OpenIapTests/ValidateReceiptTests.swift new file mode 100644 index 00000000..b0f67d8a --- /dev/null +++ b/packages/apple/Tests/OpenIapTests/ValidateReceiptTests.swift @@ -0,0 +1,142 @@ +import XCTest +@testable import OpenIAP + +@available(iOS 15.0, macOS 14.0, *) +final class ValidateReceiptTests: XCTestCase { + + @MainActor + func testValidateReceiptReturnsIOSResult() async throws { + let iosResult = ReceiptValidationResultIOS( + isValid: true, + jwsRepresentation: "jws-token", + latestTransaction: nil, + receiptData: "base64-receipt" + ) + let module = FakeOpenIapModule(validateResult: .receiptValidationResultIos(iosResult)) + let store = OpenIapStore(module: module) + + let result = try await store.validateReceipt(sku: "test.sku") + + XCTAssertTrue(result.isValid) + XCTAssertEqual("jws-token", result.jwsRepresentation) + XCTAssertEqual("base64-receipt", result.receiptData) + } + + @MainActor + func testValidateReceiptThrowsForAndroidVariant() async { + let androidPayload = ReceiptValidationResultAndroid( + autoRenewing: false, + betaProduct: false, + cancelDate: nil, + cancelReason: nil, + deferredDate: nil, + deferredSku: nil, + freeTrialEndDate: 0, + gracePeriodEndDate: 0, + parentProductId: "parent", + productId: "android.sku", + productType: "subs", + purchaseDate: 0, + quantity: 1, + receiptId: "receipt-123", + renewalDate: 0, + term: "P1M", + termSku: "plan-monthly", + testTransaction: false + ) + let module = FakeOpenIapModule(validateResult: .receiptValidationResultAndroid(androidPayload)) + let store = OpenIapStore(module: module) + + do { + _ = try await store.validateReceipt(sku: "android.sku") + XCTFail("Expected featureNotSupported when Android result is returned on Apple platform") + } catch let error as PurchaseError { + XCTAssertEqual(.featureNotSupported, error.code) + } catch { + XCTFail("Unexpected error type: \(error)") + } + } +} + +@available(iOS 15.0, macOS 14.0, *) +private final class FakeOpenIapModule: OpenIapModuleProtocol { + private let validateResult: ReceiptValidationResult + + init(validateResult: ReceiptValidationResult) { + self.validateResult = validateResult + } + + // 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: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS { + guard case let .receiptValidationResultIos(ios) = validateResult else { + throw PurchaseError(code: .featureNotSupported, message: "Android validation not supported", productId: props.sku) + } + return ios + } + + func validateReceipt(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult { + validateResult + } + + // 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/google/openiap/src/main/java/dev/hyo/openiap/utils/ReceiptValidator.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/ReceiptValidator.kt index 7519ff2e..ea2f6df8 100644 --- 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 @@ -16,9 +16,14 @@ 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 + tag: String, + connectionFactory: (String) -> HttpURLConnection = ::openConnection ): ReceiptValidationResultAndroid = withContext(Dispatchers.IO) { val options = props.androidOptions ?: throw IllegalArgumentException( @@ -39,7 +44,7 @@ suspend fun validateReceiptWithGooglePlay( val url = "$VALIDATION_BASE_URL/${options.packageName}/purchases/$typeSegment/${props.sku}/tokens/${options.productToken}" - val connection = (URL(url).openConnection() as HttpURLConnection).apply { + val connection = connectionFactory(url).apply { requestMethod = "GET" setRequestProperty("Content-Type", "application/json") setRequestProperty("Authorization", "Bearer ${options.accessToken}") 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 new file mode 100644 index 00000000..2a3ba198 --- /dev/null +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/ReceiptValidatorTest.kt @@ -0,0 +1,110 @@ +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 */ } +} From 5686a9ca22d78ee5485666aa3b074fa07c11f364 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 22 Nov 2025 21:49:03 +0900 Subject: [PATCH 3/3] refactor: deprecate validateReceipt in favor of verifyPurchase --- .../Sources/Helpers/StoreKitTypesBridge.swift | 6 +- packages/apple/Sources/Models/Types.swift | 8 ++- packages/apple/Sources/OpenIapModule.swift | 19 +++--- packages/apple/Sources/OpenIapProtocol.swift | 4 +- packages/apple/Sources/OpenIapStore.swift | 9 ++- .../OpenIapTests/ValidateReceiptTests.swift | 12 ++-- packages/docs/src/components/SearchModal.tsx | 10 +-- packages/docs/src/pages/docs/apis.tsx | 25 +++++--- .../java/dev/hyo/openiap/OpenIapModule.kt | 11 +++- .../java/dev/hyo/openiap/OpenIapProtocol.kt | 2 + .../src/main/java/dev/hyo/openiap/Types.kt | 8 ++- .../dev/hyo/openiap/utils/ReceiptValidator.kt | 2 +- .../java/dev/hyo/openiap/OpenIapModule.kt | 10 ++- .../java/dev/hyo/openiap/OpenIapErrorTest.kt | 1 + packages/gql/scripts/fix-generated-types.mjs | 63 +++++++++++++++++-- packages/gql/src/api-ios.graphql | 10 +-- packages/gql/src/api.graphql | 7 ++- packages/gql/src/generated/Types.kt | 8 ++- packages/gql/src/generated/Types.swift | 8 ++- packages/gql/src/generated/types.dart | 11 ++++ packages/gql/src/generated/types.ts | 21 +++++-- 21 files changed, 200 insertions(+), 55 deletions(-) diff --git a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift index c93c4e5d..1f84ebb8 100644 --- a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift +++ b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift @@ -109,7 +109,7 @@ enum StoreKitTypesBridge { // Default to false if renewalInfo unavailable - safer to underreport than falsely claim auto-renewal let autoRenewing = renewalInfoIOS?.willAutoRenew ?? false let environment: String? - if #available(iOS 16.0, macOS 14.0, tvOS 16.0, watchOS 9.0, *) { + if #available(iOS 16.0, tvOS 16.0, watchOS 9.0, *) { environment = transaction.environment.rawValue } else { environment = nil @@ -128,7 +128,7 @@ enum StoreKitTypesBridge { appAccountToken: transaction.appAccountToken?.uuidString, appBundleIdIOS: transaction.appBundleID, countryCodeIOS: { - if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { + if #available(iOS 17.0, tvOS 17.0, watchOS 10.0, *) { transaction.storefront.countryCode } else { transaction.storefrontCountryCode @@ -158,7 +158,7 @@ enum StoreKitTypesBridge { revocationDateIOS: revocationDate, revocationReasonIOS: transaction.revocationReason?.rawValue.description, storefrontCountryCodeIOS: { - if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { + if #available(iOS 17.0, tvOS 17.0, watchOS 10.0, *) { transaction.storefront.countryCode } else { transaction.storefrontCountryCode diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 660e9c1f..af595828 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -1341,6 +1341,8 @@ public protocol MutationResolver { func syncIOS() async throws -> Bool /// Validate purchase receipts with the configured providers func validateReceipt(_ options: ReceiptValidationProps) async throws -> ReceiptValidationResult + /// Verify purchases with the configured providers + func verifyPurchase(_ options: ReceiptValidationProps) async throws -> ReceiptValidationResult } /// GraphQL root query operations. @@ -1420,6 +1422,7 @@ public typealias MutationShowAlternativeBillingDialogAndroidHandler = () async t 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 struct MutationHandlers { public var acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler? @@ -1442,6 +1445,7 @@ public struct MutationHandlers { public var showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? public var syncIOS: MutationSyncIOSHandler? public var validateReceipt: MutationValidateReceiptHandler? + public var verifyPurchase: MutationVerifyPurchaseHandler? public init( acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler? = nil, @@ -1463,7 +1467,8 @@ public struct MutationHandlers { showAlternativeBillingDialogAndroid: MutationShowAlternativeBillingDialogAndroidHandler? = nil, showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? = nil, syncIOS: MutationSyncIOSHandler? = nil, - validateReceipt: MutationValidateReceiptHandler? = nil + validateReceipt: MutationValidateReceiptHandler? = nil, + verifyPurchase: MutationVerifyPurchaseHandler? = nil ) { self.acknowledgePurchaseAndroid = acknowledgePurchaseAndroid self.beginRefundRequestIOS = beginRefundRequestIOS @@ -1485,6 +1490,7 @@ public struct MutationHandlers { self.showManageSubscriptionsIOS = showManageSubscriptionsIOS self.syncIOS = syncIOS self.validateReceipt = validateReceipt + self.verifyPurchase = verifyPurchase } } diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 5a97824e..c37253e7 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -550,16 +550,11 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return data.base64EncodedString() } - @available(*, deprecated, message: "Use validateReceipt") + @available(*, deprecated, message: "Use verifyPurchase") public func validateReceiptIOS(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS { try await performValidateReceiptIOS(props) } - public func validateReceipt(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult { - let iosResult = try await performValidateReceiptIOS(props) - return .receiptValidationResultIos(iosResult) - } - private func performValidateReceiptIOS(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS { let receiptData = (try? await getReceiptDataIOS()) ?? "" var latestPurchase: Purchase? = nil @@ -586,6 +581,16 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { ) } + @available(*, deprecated, message: "Use verifyPurchase") + public func validateReceipt(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult { + try await verifyPurchase(props) + } + + public func verifyPurchase(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult { + let iosResult = try await performValidateReceiptIOS(props) + return .receiptValidationResultIos(iosResult) + } + // MARK: - Store Information public func getStorefrontIOS() async throws -> String { @@ -636,7 +641,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { let daysUntilExpiration = dayDelta.map { Double($0) } let willExpireSoon = dayDelta.map { $0 < 7 } ?? false let environment: String? - if #available(iOS 16.0, macOS 14.0, tvOS 16.0, watchOS 9.0, *) { + if #available(iOS 16.0, tvOS 16.0, watchOS 9.0, *) { environment = transaction.environment.rawValue } else { environment = nil diff --git a/packages/apple/Sources/OpenIapProtocol.swift b/packages/apple/Sources/OpenIapProtocol.swift index 0c85d334..ba77be86 100644 --- a/packages/apple/Sources/OpenIapProtocol.swift +++ b/packages/apple/Sources/OpenIapProtocol.swift @@ -41,9 +41,11 @@ public protocol OpenIapModuleProtocol { // Validation func getReceiptDataIOS() async throws -> String? - @available(*, deprecated, message: "Use validateReceipt") + @available(*, deprecated, message: "Use verifyPurchase") func validateReceiptIOS(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS + @available(*, deprecated, message: "Use verifyPurchase") func validateReceipt(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult + func verifyPurchase(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult // Store Information func getStorefrontIOS() async throws -> String diff --git a/packages/apple/Sources/OpenIapStore.swift b/packages/apple/Sources/OpenIapStore.swift index 5145affa..da27b998 100644 --- a/packages/apple/Sources/OpenIapStore.swift +++ b/packages/apple/Sources/OpenIapStore.swift @@ -368,8 +368,13 @@ public final class OpenIapStore: ObservableObject { // MARK: - Validation & Metadata + @available(*, deprecated, message: "Use verifyPurchase") public func validateReceipt(sku: String) async throws -> ReceiptValidationResultIOS { - let result = try await module.validateReceipt(ReceiptValidationProps(sku: sku)) + 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 { return iosResult } @@ -699,7 +704,7 @@ public extension OpenIapStore { case restorePurchases case requestPurchase case finishTransaction - case validateReceipt + case verifyPurchase case custom } } diff --git a/packages/apple/Tests/OpenIapTests/ValidateReceiptTests.swift b/packages/apple/Tests/OpenIapTests/ValidateReceiptTests.swift index b0f67d8a..57b267e1 100644 --- a/packages/apple/Tests/OpenIapTests/ValidateReceiptTests.swift +++ b/packages/apple/Tests/OpenIapTests/ValidateReceiptTests.swift @@ -5,7 +5,7 @@ import XCTest final class ValidateReceiptTests: XCTestCase { @MainActor - func testValidateReceiptReturnsIOSResult() async throws { + func testVerifyPurchaseReturnsIOSResult() async throws { let iosResult = ReceiptValidationResultIOS( isValid: true, jwsRepresentation: "jws-token", @@ -15,7 +15,7 @@ final class ValidateReceiptTests: XCTestCase { let module = FakeOpenIapModule(validateResult: .receiptValidationResultIos(iosResult)) let store = OpenIapStore(module: module) - let result = try await store.validateReceipt(sku: "test.sku") + let result = try await store.verifyPurchase(sku: "test.sku") XCTAssertTrue(result.isValid) XCTAssertEqual("jws-token", result.jwsRepresentation) @@ -23,7 +23,7 @@ final class ValidateReceiptTests: XCTestCase { } @MainActor - func testValidateReceiptThrowsForAndroidVariant() async { + func testVerifyPurchaseThrowsForAndroidVariant() async { let androidPayload = ReceiptValidationResultAndroid( autoRenewing: false, betaProduct: false, @@ -48,7 +48,7 @@ final class ValidateReceiptTests: XCTestCase { let store = OpenIapStore(module: module) do { - _ = try await store.validateReceipt(sku: "android.sku") + _ = try await store.verifyPurchase(sku: "android.sku") XCTFail("Expected featureNotSupported when Android result is returned on Apple platform") } catch let error as PurchaseError { XCTAssertEqual(.featureNotSupported, error.code) @@ -102,6 +102,10 @@ private final class FakeOpenIapModule: OpenIapModuleProtocol { validateResult } + func verifyPurchase(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult { + validateResult + } + // MARK: - Store Information func getStorefrontIOS() async throws -> String { "US" } func getAppTransactionIOS() async throws -> AppTransaction? { nil } diff --git a/packages/docs/src/components/SearchModal.tsx b/packages/docs/src/components/SearchModal.tsx index 37c23dab..91f06eea 100644 --- a/packages/docs/src/components/SearchModal.tsx +++ b/packages/docs/src/components/SearchModal.tsx @@ -128,13 +128,13 @@ const apiData: ApiItem[] = [ // Validation { - id: 'validate-receipt', - title: 'validateReceipt', + id: 'verify-purchase', + title: 'verifyPurchase', category: 'Receipt Validation', - description: 'Validate a receipt with your server or platform servers', + description: 'Verify purchases with your server or platform providers', parameters: 'ReceiptValidationProps!', returns: 'ReceiptValidationResult!', - path: '/docs/apis#validate-receipt', + path: '/docs/apis#verify-purchase', }, // iOS APIs { @@ -313,7 +313,7 @@ const apiData: ApiItem[] = [ id: 'validate-receipt-ios', title: 'validateReceiptIOS', category: 'iOS APIs', - description: 'Deprecated: use validateReceipt for receipt validation', + description: 'Deprecated: use verifyPurchase for receipt validation', parameters: 'options: ReceiptValidationProps!', returns: 'ReceiptValidationResultIOS!', path: '/docs/apis#validate-receipt-ios', diff --git a/packages/docs/src/pages/docs/apis.tsx b/packages/docs/src/pages/docs/apis.tsx index a0f3376a..3f88e789 100644 --- a/packages/docs/src/pages/docs/apis.tsx +++ b/packages/docs/src/pages/docs/apis.tsx @@ -403,8 +403,7 @@ finishTransaction(purchase: Purchase!, isConsumable: Boolean?): Future`}purchaseUpdatedListener

  • - Validate the receipt with validateReceipt (for ALL - types) + Verify the purchase with verifyPurchase (for ALL types)
  • Grant entitlements to the user
  • @@ -568,11 +567,11 @@ type DeepLinkOptions { Validation - - validateReceipt + + verifyPurchase

    - Validate a receipt with your server or platform servers. + Verify a purchase with your server or platform providers. All purchase types (consumables, non-consumables, and subscriptions) should be validated before granting entitlements. @@ -581,7 +580,7 @@ type DeepLinkOptions { {`""" Returns: ReceiptValidationResult! """ -validateReceipt(options: ReceiptValidationProps!): Future`} +verifyPurchase(options: ReceiptValidationProps!): Future`}

    See:{' '} @@ -593,11 +592,17 @@ validateReceipt(options: ReceiptValidationProps!): Future`}

    - Validates purchase receipts with the appropriate validation service. + Verifies purchases with the appropriate validation service. +

    +

    + The legacy validateReceipt mutation remains available but + is deprecated; migrate to verifyPurchase for future + updates.

    On iOS this routes through the StoreKit-backed validation flow (the - legacy validateReceiptIOS endpoint is now deprecated). On + legacy validateReceipt and{' '} + validateReceiptIOS endpoints are now deprecated). On Android, pass androidOptions with{' '} packageName, productToken,{' '} accessToken, and optional isSub so the SDK @@ -1011,14 +1016,14 @@ getAppTransactionIOS: AppTransaction`} validateReceiptIOS

    - Deprecated: Use validateReceipt{' '} + Deprecated: Use verifyPurchase{' '} instead for both platforms.

    {`""" Validate a receipt for a specific product """ # Future -validateReceiptIOS(options: ReceiptValidationProps!): ReceiptValidationResultIOS! @deprecated(reason: "Use validateReceipt")`} +validateReceiptIOS(options: ReceiptValidationProps!): ReceiptValidationResultIOS! @deprecated(reason: "Use verifyPurchase")`}

    Validates a receipt payload against the App Store using the provided validation options. Returns the parsed validation 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 063b28c9..2f986464 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 @@ -34,6 +34,8 @@ 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.MutationVerifyPurchaseHandler +import dev.hyo.openiap.MutationValidateReceiptHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -643,7 +645,12 @@ class OpenIapModule( } } + @Deprecated("Use verifyPurchase") override val validateReceipt: MutationValidateReceiptHandler = { props -> + verifyPurchase(props) + } + + override val verifyPurchase: MutationVerifyPurchaseHandler = { props -> validateReceiptWithGooglePlay(props, TAG) } @@ -663,6 +670,7 @@ class OpenIapModule( hasActiveSubscriptions = hasActiveSubscriptions ) + @Suppress("DEPRECATION") override val mutationHandlers: MutationHandlers = MutationHandlers( acknowledgePurchaseAndroid = acknowledgePurchaseAndroid, consumePurchaseAndroid = consumePurchaseAndroid, @@ -672,7 +680,8 @@ class OpenIapModule( initConnection = initConnection, requestPurchase = requestPurchase, restorePurchases = restorePurchases, - validateReceipt = validateReceipt + validateReceipt = validateReceipt, + verifyPurchase = verifyPurchase ) override val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers( 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 f31faeff..562efc07 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 @@ -24,7 +24,9 @@ interface OpenIapProtocol { val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler val restorePurchases: MutationRestorePurchasesHandler val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler + @Deprecated("Use verifyPurchase") val validateReceipt: MutationValidateReceiptHandler + val verifyPurchase: MutationVerifyPurchaseHandler 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 109c28a1..29fdc9ce 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 @@ -2330,6 +2330,10 @@ public interface MutationResolver { * Validate purchase receipts with the configured providers */ suspend fun validateReceipt(options: ReceiptValidationProps): ReceiptValidationResult + /** + * Verify purchases with the configured providers + */ + suspend fun verifyPurchase(options: ReceiptValidationProps): ReceiptValidationResult } /** @@ -2457,6 +2461,7 @@ public typealias MutationShowAlternativeBillingDialogAndroidHandler = suspend () 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 data class MutationHandlers( val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler? = null, @@ -2478,7 +2483,8 @@ public data class MutationHandlers( val showAlternativeBillingDialogAndroid: MutationShowAlternativeBillingDialogAndroidHandler? = null, val showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? = null, val syncIOS: MutationSyncIOSHandler? = null, - val validateReceipt: MutationValidateReceiptHandler? = null + val validateReceipt: MutationValidateReceiptHandler? = null, + val verifyPurchase: MutationVerifyPurchaseHandler? = null ) // MARK: - Query Helpers 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 index ea2f6df8..0a71b0f8 100644 --- 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 @@ -58,7 +58,7 @@ suspend fun validateReceiptWithGooglePlay( .orElse("") if (statusCode !in 200..299) { - OpenIapLog.warn("validateReceipt failed (HTTP $statusCode): $responseBody", tag) + OpenIapLog.warn("verifyPurchase failed (HTTP $statusCode): $responseBody", tag) throw OpenIapError.InvalidReceipt } 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 0867401a..16cc93a5 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 @@ -32,6 +32,7 @@ import dev.hyo.openiap.MutationInitConnectionHandler import dev.hyo.openiap.MutationRequestPurchaseHandler import dev.hyo.openiap.MutationRestorePurchasesHandler import dev.hyo.openiap.MutationValidateReceiptHandler +import dev.hyo.openiap.MutationVerifyPurchaseHandler import dev.hyo.openiap.MutationHandlers import dev.hyo.openiap.QueryHandlers import dev.hyo.openiap.SubscriptionHandlers @@ -795,7 +796,12 @@ class OpenIapModule( } } + @Deprecated("Use verifyPurchase") override val validateReceipt: MutationValidateReceiptHandler = { props -> + verifyPurchase(props) + } + + override val verifyPurchase: MutationVerifyPurchaseHandler = { props -> validateReceiptWithGooglePlay(props, TAG) } @@ -815,6 +821,7 @@ class OpenIapModule( hasActiveSubscriptions = hasActiveSubscriptions ) + @Suppress("DEPRECATION") override val mutationHandlers: MutationHandlers = MutationHandlers( acknowledgePurchaseAndroid = acknowledgePurchaseAndroid, consumePurchaseAndroid = consumePurchaseAndroid, @@ -824,7 +831,8 @@ class OpenIapModule( initConnection = initConnection, requestPurchase = requestPurchase, restorePurchases = restorePurchases, - validateReceipt = validateReceipt + validateReceipt = validateReceipt, + verifyPurchase = verifyPurchase ) 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 e0d609d2..934d7818 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 @@ -246,6 +246,7 @@ class OpenIapErrorTest { } @Test + @Suppress("DEPRECATION") fun `fromBillingResponseCode returns correct error for known response codes`() { assertTrue(OpenIapError.fromBillingResponseCode(BillingClient.BillingResponseCode.USER_CANCELED) is OpenIapError.UserCancelled) assertTrue(OpenIapError.fromBillingResponseCode(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) is OpenIapError.ServiceUnavailable) diff --git a/packages/gql/scripts/fix-generated-types.mjs b/packages/gql/scripts/fix-generated-types.mjs index 3d079bda..4809d247 100644 --- a/packages/gql/scripts/fix-generated-types.mjs +++ b/packages/gql/scripts/fix-generated-types.mjs @@ -202,8 +202,8 @@ content = content.replace(/export enum [^{]+\{[\s\S]*?\}/g, (block) => { return block.replace(/= '([^']+)'/g, (_, value) => `= '${toConstantCase(value)}'`); }); -// Convert platform and type fields to literal types for proper discriminated unions -// This enables TypeScript narrowing when checking platform === 'ios' && type === 'subs' +// Convert platform/type fields to literals and introduce a shared base for products +// This keeps ProductCommon android-focused while reusing field definitions const productTypeMapping = { ProductIOS: { platform: "'ios'", type: "'in-app'" }, ProductAndroid: { platform: "'android'", type: "'in-app'" }, @@ -212,12 +212,11 @@ const productTypeMapping = { }; for (const [typeName, literals] of Object.entries(productTypeMapping)) { - // Match the interface definition and replace platform/type fields with literals const interfacePattern = new RegExp( `(export interface ${typeName} extends ProductCommon \\{[\\s\\S]*?)` + - `(platform: IapPlatform;)` + + `(platform: [^;]+;)` + `([\\s\\S]*?)` + - `(type: ProductType;)`, + `(type: [^;]+;)`, 'g' ); @@ -226,6 +225,60 @@ for (const [typeName, literals] of Object.entries(productTypeMapping)) { }); } +// Normalize ProductCommon to a single definition with literal union platform/type +const productCommonMatch = content.match(/export interface ProductCommon \{([\s\S]*?)\}\n/); +if (productCommonMatch) { + const body = productCommonMatch[1] + .replace(/platform: 'android';/, "platform: 'android' | 'ios';") + .replace(/platform: IapPlatform;/, "platform: 'android' | 'ios';") + .replace(/type: 'in-app' \| 'subs';/, "type: 'in-app' | 'subs';") + .replace(/type: ProductType;/, "type: 'in-app' | 'subs';"); + content = content.replace(productCommonMatch[0], `export interface ProductCommon {${body}} \n`); +} + +// Collapse ProductCommonBase/ProductCommon into a single ProductCommon interface +const productCommonTypePattern = /export type ProductCommon = ProductCommonBase & \{[\s\S]*?platform: 'android';[\s\S]*?type: 'in-app' \| 'subs';[\s\S]*?\};\s*\n/; +const productCommonBasePattern = /export type ProductCommonBase = \{([\s\S]*?)\};\s*\n/; +const productCommonBaseMatch = content.match(productCommonBasePattern); +if (productCommonTypePattern.test(content)) { + const baseBody = (productCommonBaseMatch ? productCommonBaseMatch[1] : ` + currency: string; + debugDescription?: (string | null); + description: string; + displayName?: (string | null); + displayPrice: string; + id: string; + price?: (number | null); + title: string; +`).trimEnd(); + const merged = [ + 'export interface ProductCommon {', + baseBody, + " platform: 'android' | 'ios';", + " type: 'in-app' | 'subs';", + '}', + '', + ].join('\n'); + content = content.replace(productCommonTypePattern, merged); + if (productCommonBaseMatch) { + content = content.replace(productCommonBasePattern, ''); + } +} + +// Drop any generated ProductCommonIOS types +content = content.replace(/export type ProductCommonIOS = [\s\S]*?\};\s*\n/g, ''); +content = content.replace(/export interface ProductCommonIOS \{[\s\S]*?\}\s*\n/g, ''); + +// Ensure product interfaces extend ProductCommon directly +content = content.replace( + /export interface ProductIOS extends ProductCommonIOS \{/g, + 'export interface ProductIOS extends ProductCommon {' +); +content = content.replace( + /export interface ProductSubscriptionIOS extends ProductCommonIOS \{/g, + 'export interface ProductSubscriptionIOS extends ProductCommon {' +); + content = content.replace( /export interface RequestPurchaseProps \{[\s\S]*?\}\n\n/, [ diff --git a/packages/gql/src/api-ios.graphql b/packages/gql/src/api-ios.graphql index 7719c676..594ad778 100644 --- a/packages/gql/src/api-ios.graphql +++ b/packages/gql/src/api-ios.graphql @@ -61,11 +61,11 @@ extend type Query { """ # Future getAppTransactionIOS: AppTransaction - """ - Validate a receipt for a specific product - """ - # Future - validateReceiptIOS(options: ReceiptValidationProps!): ReceiptValidationResultIOS! @deprecated(reason: "Use validateReceipt") +""" +Validate a receipt for a specific product +""" +# Future +validateReceiptIOS(options: ReceiptValidationProps!): ReceiptValidationResultIOS! @deprecated(reason: "Use verifyPurchase") } extend type Mutation { diff --git a/packages/gql/src/api.graphql b/packages/gql/src/api.graphql index 27ba7497..58440c1b 100644 --- a/packages/gql/src/api.graphql +++ b/packages/gql/src/api.graphql @@ -68,5 +68,10 @@ extend type Mutation { Validate purchase receipts with the configured providers """ # Future - validateReceipt(options: ReceiptValidationProps!): ReceiptValidationResult! + validateReceipt(options: ReceiptValidationProps!): ReceiptValidationResult! @deprecated(reason: "Use verifyPurchase") + """ + Verify purchases with the configured providers + """ + # Future + verifyPurchase(options: ReceiptValidationProps!): ReceiptValidationResult! } diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index d11cf9f6..4b66e808 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -2392,6 +2392,10 @@ public interface MutationResolver { * Validate purchase receipts with the configured providers */ suspend fun validateReceipt(options: ReceiptValidationProps): ReceiptValidationResult + /** + * Verify purchases with the configured providers + */ + suspend fun verifyPurchase(options: ReceiptValidationProps): ReceiptValidationResult } /** @@ -2519,6 +2523,7 @@ public typealias MutationShowAlternativeBillingDialogAndroidHandler = suspend () 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 data class MutationHandlers( val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler? = null, @@ -2540,7 +2545,8 @@ public data class MutationHandlers( val showAlternativeBillingDialogAndroid: MutationShowAlternativeBillingDialogAndroidHandler? = null, val showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? = null, val syncIOS: MutationSyncIOSHandler? = null, - val validateReceipt: MutationValidateReceiptHandler? = null + val validateReceipt: MutationValidateReceiptHandler? = null, + val verifyPurchase: MutationVerifyPurchaseHandler? = null ) // MARK: - Query Helpers diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 660e9c1f..af595828 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -1341,6 +1341,8 @@ public protocol MutationResolver { func syncIOS() async throws -> Bool /// Validate purchase receipts with the configured providers func validateReceipt(_ options: ReceiptValidationProps) async throws -> ReceiptValidationResult + /// Verify purchases with the configured providers + func verifyPurchase(_ options: ReceiptValidationProps) async throws -> ReceiptValidationResult } /// GraphQL root query operations. @@ -1420,6 +1422,7 @@ public typealias MutationShowAlternativeBillingDialogAndroidHandler = () async t 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 struct MutationHandlers { public var acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler? @@ -1442,6 +1445,7 @@ public struct MutationHandlers { public var showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? public var syncIOS: MutationSyncIOSHandler? public var validateReceipt: MutationValidateReceiptHandler? + public var verifyPurchase: MutationVerifyPurchaseHandler? public init( acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler? = nil, @@ -1463,7 +1467,8 @@ public struct MutationHandlers { showAlternativeBillingDialogAndroid: MutationShowAlternativeBillingDialogAndroidHandler? = nil, showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? = nil, syncIOS: MutationSyncIOSHandler? = nil, - validateReceipt: MutationValidateReceiptHandler? = nil + validateReceipt: MutationValidateReceiptHandler? = nil, + verifyPurchase: MutationVerifyPurchaseHandler? = nil ) { self.acknowledgePurchaseAndroid = acknowledgePurchaseAndroid self.beginRefundRequestIOS = beginRefundRequestIOS @@ -1485,6 +1490,7 @@ public struct MutationHandlers { self.showManageSubscriptionsIOS = showManageSubscriptionsIOS self.syncIOS = syncIOS self.validateReceipt = validateReceipt + self.verifyPurchase = verifyPurchase } } diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index bca38f9b..98de021b 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -2908,6 +2908,11 @@ abstract class MutationResolver { ReceiptValidationAndroidOptions? androidOptions, required String sku, }); + /// Verify purchases with the configured providers + Future verifyPurchase({ + ReceiptValidationAndroidOptions? androidOptions, + required String sku, + }); } /// GraphQL root query operations. @@ -3007,6 +3012,10 @@ typedef MutationValidateReceiptHandler = Future Functio ReceiptValidationAndroidOptions? androidOptions, required String sku, }); +typedef MutationVerifyPurchaseHandler = Future Function({ + ReceiptValidationAndroidOptions? androidOptions, + required String sku, +}); class MutationHandlers { const MutationHandlers({ @@ -3030,6 +3039,7 @@ class MutationHandlers { this.showManageSubscriptionsIOS, this.syncIOS, this.validateReceipt, + this.verifyPurchase, }); final MutationAcknowledgePurchaseAndroidHandler? acknowledgePurchaseAndroid; @@ -3052,6 +3062,7 @@ class MutationHandlers { final MutationShowManageSubscriptionsIOSHandler? showManageSubscriptionsIOS; final MutationSyncIOSHandler? syncIOS; final MutationValidateReceiptHandler? validateReceipt; + final MutationVerifyPurchaseHandler? verifyPurchase; } // MARK: - Query Helpers diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 5d0698e9..028c8d64 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -241,8 +241,13 @@ export interface Mutation { showManageSubscriptionsIOS: Promise; /** Force a StoreKit sync for transactions (iOS 15+) */ syncIOS: Promise; - /** Validate purchase receipts with the configured providers */ + /** + * Validate purchase receipts with the configured providers + * @deprecated Use verifyPurchase + */ validateReceipt: Promise; + /** Verify purchases with the configured providers */ + verifyPurchase: Promise; } @@ -284,6 +289,8 @@ export type MutationRequestPurchaseArgs = export type MutationValidateReceiptArgs = ReceiptValidationProps; +export type MutationVerifyPurchaseArgs = ReceiptValidationProps; + export type PaymentModeIOS = 'empty' | 'free-trial' | 'pay-as-you-go' | 'pay-up-front'; export interface PricingPhaseAndroid { @@ -330,11 +337,11 @@ export interface ProductCommon { displayName?: (string | null); displayPrice: string; id: string; - platform: IapPlatform; + platform: 'android' | 'ios'; price?: (number | null); title: string; - type: ProductType; -} + type: 'in-app' | 'subs'; +} export interface ProductIOS extends ProductCommon { currency: string; @@ -560,7 +567,10 @@ export interface Query { latestTransactionIOS?: Promise<(PurchaseIOS | null)>; /** Get StoreKit 2 subscription status details (iOS 15+) */ subscriptionStatusIOS: Promise; - /** Validate a receipt for a specific product */ + /** + * Validate a receipt for a specific product + * @deprecated Use verifyPurchase + */ validateReceiptIOS: Promise; } @@ -884,6 +894,7 @@ export type MutationArgsMap = { showManageSubscriptionsIOS: never; syncIOS: never; validateReceipt: MutationValidateReceiptArgs; + verifyPurchase: MutationVerifyPurchaseArgs; }; export type MutationField =