+
May 7, 2026 — Non-Godot SDK parity patch releases
@@ -168,12 +168,15 @@ function Releases() {
to the unified method for backward compatibility.
- openiap-google 2.1.3 — wires Play and Horizon
+ openiap-google 2.1.4 — wires Play and Horizon
handler bundles for getStorefront, legacy alternative
billing helpers, and Billing Programs APIs such as{' '}
isBillingProgramAvailableAndroid,{' '}
launchExternalLinkAndroid, and{' '}
- createBillingProgramReportingDetailsAndroid.
+ createBillingProgramReportingDetailsAndroid. This
+ release also improves Android QueryProduct failures
+ with Billing response code, debug message, queried product IDs,
+ product type, and empty-result diagnostics.
Example parity — Expo, React Native classic,
@@ -236,11 +239,11 @@ function Releases() {
- openiap-google 2.1.3
+ openiap-google 2.1.4
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 f267b057..56778879 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
@@ -574,7 +574,13 @@ class OpenIapModule(
client.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
- val err = OpenIapError.QueryProduct
+ val err = OpenIapError.QueryProduct.withDiagnostics(
+ responseCode = billingResult.responseCode,
+ debugMessage = billingResult.debugMessage,
+ productIds = missing,
+ productType = desiredType,
+ isEmptyProductList = productDetailsList.isNullOrEmpty()
+ )
purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } }
consumePurchaseCallback(Result.success(emptyList()))
return@queryProductDetailsAsync
diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt
index 08d50ba2..299e8afb 100644
--- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt
+++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt
@@ -15,7 +15,7 @@ sealed class OpenIapError : Exception() {
*/
open val debugMessage: String? = null
- fun toJSON(): Map = mapOf(
+ open fun toJSON(): Map = mapOf(
"code" to toCode(this),
"message" to (this.message ?: ""),
"platform" to "android",
@@ -160,12 +160,46 @@ sealed class OpenIapError : Exception() {
const val MESSAGE = "Failed to initialize billing connection"
}
- object QueryProduct : OpenIapError() {
- val CODE = ErrorCode.QueryProduct.rawValue
- override val code = CODE
+ open class QueryProduct(
+ val responseCode: Int? = null,
+ override val debugMessage: String? = null,
+ val productIds: List = emptyList(),
+ val productType: String? = null,
+ val isEmptyProductList: Boolean? = null,
+ ) : OpenIapError() {
+ override val code = ErrorCode.QueryProduct.rawValue
override val message = MESSAGE
- const val MESSAGE = "Failed to query product"
+ companion object : QueryProduct() {
+ val CODE = ErrorCode.QueryProduct.rawValue
+ const val MESSAGE = "Failed to query product"
+
+ fun withDiagnostics(
+ responseCode: Int? = null,
+ debugMessage: String? = null,
+ productIds: List = emptyList(),
+ productType: String? = null,
+ isEmptyProductList: Boolean? = null,
+ ): QueryProduct = QueryProduct(
+ responseCode = responseCode,
+ debugMessage = debugMessage,
+ productIds = productIds,
+ productType = productType,
+ isEmptyProductList = isEmptyProductList,
+ )
+ }
+
+ override fun toJSON(): Map {
+ if (responseCode == null && productIds.isEmpty() && productType == null && isEmptyProductList == null) {
+ return super.toJSON()
+ }
+ return super.toJSON() + mapOf(
+ "responseCode" to responseCode,
+ "productIds" to productIds,
+ "productType" to productType,
+ "isEmptyProductList" to isEmptyProductList,
+ )
+ }
}
object EmptySkuList : OpenIapError() {
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 de782cf0..edc23e07 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
@@ -2953,8 +2953,12 @@ public data class PurchaseAndroid(
public data class PurchaseError(
val code: ErrorCode,
val debugMessage: String? = null,
+ val isEmptyProductList: Boolean? = null,
val message: String,
- val productId: String? = null
+ val productId: String? = null,
+ val productIds: List? = null,
+ val productType: String? = null,
+ val responseCode: Int? = null
) {
companion object {
@@ -2962,8 +2966,12 @@ public data class PurchaseError(
return PurchaseError(
code = (json["code"] as? String)?.let { ErrorCode.fromJson(it) } ?: ErrorCode.Unknown,
debugMessage = json["debugMessage"] as? String,
+ isEmptyProductList = json["isEmptyProductList"] as? Boolean,
message = json["message"] as? String ?: "",
productId = json["productId"] as? String,
+ productIds = (json["productIds"] as? List<*>)?.mapNotNull { it as? String },
+ productType = json["productType"] as? String,
+ responseCode = (json["responseCode"] as? Number)?.toInt(),
)
}
}
@@ -2972,8 +2980,12 @@ public data class PurchaseError(
"__typename" to "PurchaseError",
"code" to code.toJson(),
"debugMessage" to debugMessage,
+ "isEmptyProductList" to isEmptyProductList,
"message" to message,
"productId" to productId,
+ "productIds" to productIds,
+ "productType" to productType,
+ "responseCode" to responseCode,
)
}
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 d481832d..83d7685d 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
@@ -1139,7 +1139,13 @@ class OpenIapModule(
}
buildAndLaunch(ordered)
} else {
- val err = OpenIapError.QueryProduct
+ val err = OpenIapError.QueryProduct.withDiagnostics(
+ responseCode = billingResult.responseCode,
+ debugMessage = billingResult.debugMessage,
+ productIds = missing,
+ productType = desiredType,
+ isEmptyProductList = productDetailsList.isNullOrEmpty()
+ )
for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
consumePurchaseCallback(Result.success(emptyList()))
}
diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt
index 45c54db9..51a70cd3 100644
--- a/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt
+++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt
@@ -5,7 +5,6 @@ import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.ProductDetails
import dev.hyo.openiap.OpenIapError
import dev.hyo.openiap.OpenIapLog
-import dev.hyo.openiap.fromBillingResponseCode
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -102,9 +101,12 @@ internal class ProductManager {
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
cont.resumeWithException(
- OpenIapError.fromBillingResponseCode(
- billingResult.responseCode,
- billingResult.debugMessage
+ OpenIapError.QueryProduct.withDiagnostics(
+ responseCode = billingResult.responseCode,
+ debugMessage = billingResult.debugMessage,
+ productIds = needsQuery.toList(),
+ productType = productType,
+ isEmptyProductList = result.productDetailsList.isNullOrEmpty()
)
)
return@queryProductDetailsAsync
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 21b0332f..8beb7a94 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
@@ -2,6 +2,7 @@ package dev.hyo.openiap
import com.android.billingclient.api.BillingClient
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -115,6 +116,46 @@ class OpenIapErrorTest {
assertEquals("Failed to query product", error.message)
}
+ @Test
+ fun `QueryProduct carries billing diagnostics when provided`() {
+ val error: OpenIapError = OpenIapError.QueryProduct.withDiagnostics(
+ responseCode = BillingClient.BillingResponseCode.DEVELOPER_ERROR,
+ debugMessage = "Invalid product ID",
+ productIds = listOf("premium_monthly", "lifetime"),
+ productType = BillingClient.ProductType.SUBS,
+ isEmptyProductList = true,
+ )
+ val json = error.toJSON()
+
+ assertTrue(error is OpenIapError.QueryProduct)
+ assertFalse(error === OpenIapError.QueryProduct)
+ assertEquals(ErrorCode.QueryProduct.rawValue, error.code)
+ assertEquals("Failed to query product", error.message)
+ val queryError = error as OpenIapError.QueryProduct
+ assertEquals(BillingClient.BillingResponseCode.DEVELOPER_ERROR, queryError.responseCode)
+ assertEquals("Invalid product ID", error.debugMessage)
+ assertEquals(listOf("premium_monthly", "lifetime"), queryError.productIds)
+ assertEquals(BillingClient.ProductType.SUBS, queryError.productType)
+ assertEquals(true, queryError.isEmptyProductList)
+ assertEquals(BillingClient.BillingResponseCode.DEVELOPER_ERROR, json["responseCode"])
+ assertEquals("Invalid product ID", json["debugMessage"])
+ assertEquals(listOf("premium_monthly", "lifetime"), json["productIds"])
+ assertEquals(BillingClient.ProductType.SUBS, json["productType"])
+ assertEquals(true, json["isEmptyProductList"])
+ }
+
+ @Test
+ fun `non QueryProduct errors do not serialize query diagnostics`() {
+ val json = OpenIapError.PurchaseFailed("Billing failed").toJSON()
+
+ assertEquals(ErrorCode.PurchaseError.rawValue, json["code"])
+ assertEquals("Billing failed", json["debugMessage"])
+ assertFalse(json.containsKey("responseCode"))
+ assertFalse(json.containsKey("productIds"))
+ assertFalse(json.containsKey("productType"))
+ assertFalse(json.containsKey("isEmptyProductList"))
+ }
+
@Test
fun `EmptySkuList has correct code and message`() {
val error = OpenIapError.EmptySkuList
@@ -445,4 +486,4 @@ class OpenIapErrorTest {
assertEquals("Both formats should parse to same ErrorCode", fromKebab, fromCamel)
}
}
-}
\ No newline at end of file
+}
diff --git a/packages/gql/src/error.graphql b/packages/gql/src/error.graphql
index 5cec9ca2..04869d8d 100644
--- a/packages/gql/src/error.graphql
+++ b/packages/gql/src/error.graphql
@@ -55,4 +55,10 @@ type PurchaseError {
# On Android this mirrors BillingResult.debugMessage; on iOS this is the
# StoreKit error's localizedDescription (or equivalent). May be null.
debugMessage: String
+ # Android QueryProduct diagnostics. Present for openiap-google QueryProduct
+ # failures when the native billing layer exposes the values.
+ responseCode: Int
+ productIds: [String!]
+ productType: String
+ isEmptyProductList: Boolean
}
diff --git a/packages/gql/src/generated-purchase-error.test.ts b/packages/gql/src/generated-purchase-error.test.ts
new file mode 100644
index 00000000..38258e2a
--- /dev/null
+++ b/packages/gql/src/generated-purchase-error.test.ts
@@ -0,0 +1,35 @@
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const currentDir = dirname(fileURLToPath(import.meta.url));
+const generatedDir = resolve(currentDir, "generated");
+
+function readGenerated(fileName: string): string {
+ return readFileSync(resolve(generatedDir, fileName), "utf8");
+}
+
+describe("generated PurchaseError diagnostics", () => {
+ it("keeps Android QueryProduct diagnostics in generated TypeScript types", () => {
+ const source = readGenerated("types.ts");
+
+ expect(source).toContain("responseCode?: (number | null);");
+ expect(source).toContain("productIds?: (string[] | null);");
+ expect(source).toContain("productType?: (string | null);");
+ expect(source).toContain("isEmptyProductList?: (boolean | null);");
+ });
+
+ it("keeps Android QueryProduct diagnostics in generated C# types", () => {
+ const source = readGenerated("Types.cs");
+
+ expect(source).toContain('[JsonPropertyName("responseCode")]');
+ expect(source).toContain("public int? ResponseCode { get; init; }");
+ expect(source).toContain('[JsonPropertyName("productIds")]');
+ expect(source).toContain("public IReadOnlyList? ProductIds { get; init; }");
+ expect(source).toContain('[JsonPropertyName("productType")]');
+ expect(source).toContain("public string? ProductType { get; init; }");
+ expect(source).toContain('[JsonPropertyName("isEmptyProductList")]');
+ expect(source).toContain("public bool? IsEmptyProductList { get; init; }");
+ });
+});
diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs
index 45069b86..3a44df54 100644
--- a/packages/gql/src/generated/Types.cs
+++ b/packages/gql/src/generated/Types.cs
@@ -3121,10 +3121,18 @@ public sealed record PurchaseError
public required ErrorCode Code { get; init; }
[JsonPropertyName("debugMessage")]
public string? DebugMessage { get; init; }
+ [JsonPropertyName("isEmptyProductList")]
+ public bool? IsEmptyProductList { get; init; }
[JsonPropertyName("message")]
public required string Message { get; init; }
[JsonPropertyName("productId")]
public string? ProductId { get; init; }
+ [JsonPropertyName("productIds")]
+ public IReadOnlyList? ProductIds { get; init; }
+ [JsonPropertyName("productType")]
+ public string? ProductType { get; init; }
+ [JsonPropertyName("responseCode")]
+ public int? ResponseCode { get; init; }
}
public sealed record PurchaseIOS : Purchase, PurchaseCommon
diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt
index 0d073c5d..b393cf86 100644
--- a/packages/gql/src/generated/Types.kt
+++ b/packages/gql/src/generated/Types.kt
@@ -3072,8 +3072,12 @@ public data class PurchaseAndroid(
public data class PurchaseError(
val code: ErrorCode,
val debugMessage: String? = null,
+ val isEmptyProductList: Boolean? = null,
val message: String,
- val productId: String? = null
+ val productId: String? = null,
+ val productIds: List? = null,
+ val productType: String? = null,
+ val responseCode: Int? = null
) {
companion object {
@@ -3081,8 +3085,12 @@ public data class PurchaseError(
return PurchaseError(
code = (json["code"] as? String)?.let { ErrorCode.fromJson(it) } ?: ErrorCode.Unknown,
debugMessage = json["debugMessage"] as? String,
+ isEmptyProductList = json["isEmptyProductList"] as? Boolean,
message = json["message"] as? String ?: "",
productId = json["productId"] as? String,
+ productIds = (json["productIds"] as? List<*>)?.mapNotNull { it as? String },
+ productType = json["productType"] as? String,
+ responseCode = (json["responseCode"] as? Number)?.toInt(),
)
}
}
@@ -3091,8 +3099,12 @@ public data class PurchaseError(
"__typename" to "PurchaseError",
"code" to code.toJson(),
"debugMessage" to debugMessage,
+ "isEmptyProductList" to isEmptyProductList,
"message" to message,
"productId" to productId,
+ "productIds" to productIds,
+ "productType" to productType,
+ "responseCode" to responseCode,
)
}
diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift
index 79115757..4b6a09da 100644
--- a/packages/gql/src/generated/Types.swift
+++ b/packages/gql/src/generated/Types.swift
@@ -1143,8 +1143,12 @@ public struct PurchaseAndroid: Codable, PurchaseCommon {
public struct PurchaseError: Codable {
public var code: ErrorCode
public var debugMessage: String? = nil
+ public var isEmptyProductList: Bool? = nil
public var message: String
public var productId: String? = nil
+ public var productIds: [String]? = nil
+ public var productType: String? = nil
+ public var responseCode: Int? = nil
}
public struct PurchaseIOS: Codable, PurchaseCommon {
diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart
index 210dd596..93997a8c 100644
--- a/packages/gql/src/generated/types.dart
+++ b/packages/gql/src/generated/types.dart
@@ -2961,21 +2961,33 @@ class PurchaseError {
const PurchaseError({
required this.code,
this.debugMessage,
+ this.isEmptyProductList,
required this.message,
this.productId,
+ this.productIds,
+ this.productType,
+ this.responseCode,
});
final ErrorCode code;
final String? debugMessage;
+ final bool? isEmptyProductList;
final String message;
final String? productId;
+ final List? productIds;
+ final String? productType;
+ final int? responseCode;
factory PurchaseError.fromJson(Map json) {
return PurchaseError(
code: ErrorCode.fromJson(json['code'] as String),
debugMessage: json['debugMessage'] as String?,
+ isEmptyProductList: json['isEmptyProductList'] as bool?,
message: json['message'] as String,
productId: json['productId'] as String?,
+ productIds: (json['productIds'] as List?) == null ? null : (json['productIds'] as List?)!.map((e) => e as String).toList(),
+ productType: json['productType'] as String?,
+ responseCode: json['responseCode'] as int?,
);
}
@@ -2984,8 +2996,12 @@ class PurchaseError {
'__typename': 'PurchaseError',
'code': code.toJson(),
'debugMessage': debugMessage,
+ 'isEmptyProductList': isEmptyProductList,
'message': message,
'productId': productId,
+ 'productIds': productIds,
+ 'productType': productType,
+ 'responseCode': responseCode,
};
}
}
diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd
index 416cd5fb..29d37286 100644
--- a/packages/gql/src/generated/types.gd
+++ b/packages/gql/src/generated/types.gd
@@ -2287,6 +2287,10 @@ class PurchaseError:
var message: String = ""
var product_id: Variant = null
var debug_message: Variant = null
+ var response_code: Variant = null
+ var product_ids: Array[String] = []
+ var product_type: Variant = null
+ var is_empty_product_list: Variant = null
static func from_dict(data: Dictionary) -> PurchaseError:
var obj = PurchaseError.new()
@@ -2302,6 +2306,14 @@ class PurchaseError:
obj.product_id = data["productId"]
if data.has("debugMessage") and data["debugMessage"] != null:
obj.debug_message = data["debugMessage"]
+ if data.has("responseCode") and data["responseCode"] != null:
+ obj.response_code = data["responseCode"]
+ if data.has("productIds") and data["productIds"] != null:
+ obj.product_ids = data["productIds"]
+ if data.has("productType") and data["productType"] != null:
+ obj.product_type = data["productType"]
+ if data.has("isEmptyProductList") and data["isEmptyProductList"] != null:
+ obj.is_empty_product_list = data["isEmptyProductList"]
return obj
func to_dict() -> Dictionary:
@@ -2315,6 +2327,13 @@ class PurchaseError:
dict["productId"] = product_id
if debug_message != null:
dict["debugMessage"] = debug_message
+ if response_code != null:
+ dict["responseCode"] = response_code
+ dict["productIds"] = product_ids
+ if product_type != null:
+ dict["productType"] = product_type
+ if is_empty_product_list != null:
+ dict["isEmptyProductList"] = is_empty_product_list
return dict
class PurchaseIOS:
diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts
index 2647f51b..7eb00860 100644
--- a/packages/gql/src/generated/types.ts
+++ b/packages/gql/src/generated/types.ts
@@ -1215,8 +1215,12 @@ export interface PurchaseCommon {
export interface PurchaseError {
code: ErrorCode;
debugMessage?: (string | null);
+ isEmptyProductList?: (boolean | null);
message: string;
productId?: (string | null);
+ productIds?: (string[] | null);
+ productType?: (string | null);
+ responseCode?: (number | null);
}
export interface PurchaseIOS extends PurchaseCommon {
diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts
index d59d379f..cf45d8fb 100644
--- a/packages/kit/convex/_generated/api.d.ts
+++ b/packages/kit/convex/_generated/api.d.ts
@@ -70,6 +70,7 @@ import type * as userProfiles_query from "../userProfiles/query.js";
import type * as users_internal from "../users/internal.js";
import type * as users_query from "../users/query.js";
import type * as utils_concurrency from "../utils/concurrency.js";
+import type * as utils_currency from "../utils/currency.js";
import type * as utils_errors from "../utils/errors.js";
import type * as utils_helpers from "../utils/helpers.js";
import type * as utils_validation from "../utils/validation.js";
@@ -149,6 +150,7 @@ declare const fullApi: ApiFromModules<{
"users/internal": typeof users_internal;
"users/query": typeof users_query;
"utils/concurrency": typeof utils_concurrency;
+ "utils/currency": typeof utils_currency;
"utils/errors": typeof utils_errors;
"utils/helpers": typeof utils_helpers;
"utils/validation": typeof utils_validation;