Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
6 changes: 3 additions & 3 deletions packages/apple/Sources/Helpers/StoreKitTypesBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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, *) {

@coderabbitai coderabbitai Bot Nov 22, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify removal of macOS from availability checks.

The availability checks for environment, countryCodeIOS, and storefrontCountryCodeIOS have been modified to exclude macOS, while other parts of the codebase (such as getAppTransactionIOS() and mapAppTransaction() in the relevant code snippets) still declare @available(iOS 16.0, macOS 14.0, tvOS 16.0, watchOS 9.0, *). This inconsistency could indicate either:

  1. A correct fix if these specific Transaction properties are unavailable on macOS
  2. An unintended removal that breaks macOS support

On macOS, these properties will now always fall back to nil (for environment) or transaction.storefrontCountryCode (for the country code fields), potentially causing data loss or inconsistent behavior.

Please verify whether Transaction.environment and Transaction.storefront.countryCode are actually available on macOS 14.0+ in StoreKit 2:

Also applies to: 131-136, 161-166


🌐 Web query:

StoreKit 2 Transaction environment property availability macOS

💡 Result:

The StoreKit 2 Transaction.environment property is available on macOS 13.0 and later. [1][2]

Sources:

  • WWDC/StoreKit 2 summaries and API notes showing Transaction.environment availability on macOS 13.0+. [1]
  • Community/WWDC recaps describing the new Transaction.environment (sandbox/production/Xcode) and its macOS availability. [2]

🌐 Web query:

StoreKit 2 Transaction storefront countryCode availability macOS

💡 Result:

Short answer: Transaction.storefront (the storefront identifier / 3‑letter countryCode) is part of StoreKit 2 and is available on Apple platforms that support StoreKit 2 — i.e. use the Storefront APIs introduced with StoreKit 2 (see StoreKit docs). In practice StoreKit 2 requires modern OS versions (StoreKit2 generally targets iOS 15 / macOS 12+; AppTransaction types were documented as @available starting macOS 13 for some signed-receipt APIs), so expect storefront info to be available on macOS builds running the StoreKit 2–capable OS. [1][2][3]

Sources

  • Apple StoreKit overview (StoreKit 2). [1]
  • Storefront / SKStorefront usage and countryCode examples (Stack Overflow). [2]
  • StoreKit 2 AppTransaction availability notes (macOS availability shown in docs). [3]

Restore macOS availability declarations that were incorrectly removed.

Verification confirms that both Transaction.environment and Transaction.storefront.countryCode are available on macOS 13.0+ in StoreKit 2. The removal of macOS support from these three availability checks unnecessarily breaks functionality on macOS.

  • Line 112: environment should include macOS 13.0 (or macOS 14.0 to match the enum's declared platform)
  • Lines 131, 161: countryCodeIOS and storefrontCountryCodeIOS should include the appropriate macOS version

Without these declarations, these properties will incorrectly fall back to nil or alternative values on macOS, causing data loss and inconsistent behavior.

Also applies to: 131–136, 161–166

🤖 Prompt for AI Agents
packages/apple/Sources/Helpers/StoreKitTypesBridge.swift lines 112, 131–136, and
161–166: the availability checks for Transaction.environment, countryCodeIOS,
and storefrontCountryCodeIOS accidentally removed macOS support; restore macOS
availability to these checks (use macOS 13.0 where StoreKit 2 exposes
Transaction.environment and storefront.countryCode, or macOS 14.0 if you prefer
to match the enum's declared platform) so each `if #available(...)` includes the
appropriate `macOS` version alongside iOS/tvOS/watchOS, ensuring these
properties are used on macOS instead of falling back to nil or alternatives.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

macOS guarded by enclosing @available; check remaining platforms

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

environment = transaction.environment.rawValue
} else {
environment = nil
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion packages/apple/Sources/Models/Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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?
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -1485,6 +1490,7 @@ public struct MutationHandlers {
self.showManageSubscriptionsIOS = showManageSubscriptionsIOS
self.syncIOS = syncIOS
self.validateReceipt = validateReceipt
self.verifyPurchase = verifyPurchase
}
}

Expand Down
14 changes: 12 additions & 2 deletions packages/apple/Sources/OpenIapModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/apple/Sources/OpenIapProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions packages/apple/Sources/OpenIapStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}

Expand Down Expand Up @@ -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? {
Expand Down Expand Up @@ -689,7 +704,7 @@ public extension OpenIapStore {
case restorePurchases
case requestPurchase
case finishTransaction
case validateReceipt
case verifyPurchase
case custom
}
}
146 changes: 146 additions & 0 deletions packages/apple/Tests/OpenIapTests/ValidateReceiptTests.swift
Original file line number Diff line number Diff line change
@@ -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() {}
}
10 changes: 5 additions & 5 deletions packages/docs/src/components/SearchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading