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/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 c570d8e7..c37253e7 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -550,7 +550,12 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return data.base64EncodedString() } + @available(*, deprecated, message: "Use verifyPurchase") public func validateReceiptIOS(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS { + try await performValidateReceiptIOS(props) + } + + private func performValidateReceiptIOS(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS { let receiptData = (try? await getReceiptDataIOS()) ?? "" var latestPurchase: Purchase? = nil var jws: String = "" @@ -576,8 +581,13 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { ) } + @available(*, deprecated, message: "Use verifyPurchase") public func validateReceipt(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult { - let iosResult = try await validateReceiptIOS(props) + try await verifyPurchase(props) + } + + public func verifyPurchase(_ props: ReceiptValidationProps) async throws -> ReceiptValidationResult { + let iosResult = try await performValidateReceiptIOS(props) return .receiptValidationResultIos(iosResult) } @@ -631,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 ebb4a00d..ba77be86 100644 --- a/packages/apple/Sources/OpenIapProtocol.swift +++ b/packages/apple/Sources/OpenIapProtocol.swift @@ -41,8 +41,11 @@ public protocol OpenIapModuleProtocol { // Validation func getReceiptDataIOS() async throws -> String? + @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 7a9e4952..da27b998 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() } @@ -366,8 +368,21 @@ public final class OpenIapStore: ObservableObject { // MARK: - Validation & Metadata + @available(*, deprecated, message: "Use verifyPurchase") public func validateReceipt(sku: String) async throws -> ReceiptValidationResultIOS { - try await module.validateReceiptIOS(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 + } + throw PurchaseError( + code: .featureNotSupported, + message: "Android receipt validation is not available on Apple platforms", + productId: sku + ) } public func getPromotedProductIOS() async throws -> ProductIOS? { @@ -689,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 new file mode 100644 index 00000000..57b267e1 --- /dev/null +++ b/packages/apple/Tests/OpenIapTests/ValidateReceiptTests.swift @@ -0,0 +1,146 @@ +import XCTest +@testable import OpenIAP + +@available(iOS 15.0, macOS 14.0, *) +final class ValidateReceiptTests: XCTestCase { + + @MainActor + func testVerifyPurchaseReturnsIOSResult() 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.verifyPurchase(sku: "test.sku") + + XCTAssertTrue(result.isValid) + XCTAssertEqual("jws-token", result.jwsRepresentation) + XCTAssertEqual("base64-receipt", result.receiptData) + } + + @MainActor + func testVerifyPurchaseThrowsForAndroidVariant() 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.verifyPurchase(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 + } + + func verifyPurchase(_ 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/docs/src/components/SearchModal.tsx b/packages/docs/src/components/SearchModal.tsx index acb20b31..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: 'Validate a receipt for a specific product', + 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 09ef5414..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
validateReceipt (for ALL
- types)
+ Verify the purchase with verifyPurchase (for ALL types)
- 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 {
See:{' '}
@@ -593,7 +592,21 @@ validateReceipt(options: ReceiptValidationProps!): Future`}
- Validates purchase receipts with the appropriate validation service.
+ Verifies purchases with the appropriate validation service.
+
+ The legacy
+ On iOS this routes through the StoreKit-backed validation flow (the
+ legacy Validate a receipt for a specific product.
+ Deprecated: Use
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..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
@@ -33,6 +33,9 @@ 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.MutationVerifyPurchaseHandler
+import dev.hyo.openiap.MutationValidateReceiptHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -642,7 +645,14 @@ class OpenIapModule(
}
}
- override val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.FeatureNotSupported }
+ @Deprecated("Use verifyPurchase")
+ override val validateReceipt: MutationValidateReceiptHandler = { props ->
+ verifyPurchase(props)
+ }
+
+ override val verifyPurchase: MutationVerifyPurchaseHandler = { props ->
+ validateReceiptWithGooglePlay(props, TAG)
+ }
private val purchaseError: SubscriptionPurchaseErrorHandler = {
onPurchaseError(this::addPurchaseErrorListener, this::removePurchaseErrorListener)
@@ -660,6 +670,7 @@ class OpenIapModule(
hasActiveSubscriptions = hasActiveSubscriptions
)
+ @Suppress("DEPRECATION")
override val mutationHandlers: MutationHandlers = MutationHandlers(
acknowledgePurchaseAndroid = acknowledgePurchaseAndroid,
consumePurchaseAndroid = consumePurchaseAndroid,
@@ -669,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 () -> ListvalidateReceipt mutation remains available but
+ is deprecated; migrate to verifyPurchase for future
+ updates.
+ validateReceipt and{' '}
+ validateReceiptIOS endpoints are now deprecated). On
+ Android, pass androidOptions with{' '}
+ packageName, productToken,{' '}
+ accessToken, and optional isSub so the SDK
+ can validate against the Google Play developer API.
verifyPurchase{' '}
+ instead for both platforms.
+