diff --git a/.claude/commands/audit-code.md b/.claude/commands/audit-code.md
index 02ab33d38..d829146bd 100644
--- a/.claude/commands/audit-code.md
+++ b/.claude/commands/audit-code.md
@@ -1,3 +1,8 @@
+---
+name: audit-code
+description: Audit OpenIAP code against the knowledge-base rules and current platform APIs, then fix the violations it finds. Use when the user asks to audit code, check convention compliance, or verify sources against knowledge/internal.
+---
+
# Audit Code Against Knowledge Rules
Automated workflow to check and fix code based on knowledge rules and latest platform APIs.
diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md
index 5fdb37984..9230f5d5c 100644
--- a/.claude/commands/commit.md
+++ b/.claude/commands/commit.md
@@ -1,3 +1,8 @@
+---
+name: commit
+description: Branch, commit, push, and optionally open a pull request for the current changes. Use when the user asks to commit, push, or create a PR, including forms like `/commit --all --pr`.
+---
+
# Commit Changes
Complete workflow: branch → commit → push → PR
diff --git a/.claude/commands/compile-knowledge.md b/.claude/commands/compile-knowledge.md
index 614a96ee9..df28bac3e 100644
--- a/.claude/commands/compile-knowledge.md
+++ b/.claude/commands/compile-knowledge.md
@@ -1,3 +1,8 @@
+---
+name: compile-knowledge
+description: Compile the OpenIAP knowledge base into the context files AI assistants load. Use after editing anything under knowledge/, or when the user asks to compile, recompile, or refresh the knowledge base or agent context.
+---
+
# Compile Knowledge Base
Compile the OpenIAP knowledge base to generate context files for AI assistants.
diff --git a/.claude/commands/e2e-tests.md b/.claude/commands/e2e-tests.md
index d2e4a1e17..5ad85fda7 100644
--- a/.claude/commands/e2e-tests.md
+++ b/.claude/commands/e2e-tests.md
@@ -1,3 +1,8 @@
+---
+name: e2e-tests
+description: Run device-backed OpenIAP regression across native packages and framework examples using real devices and store accounts. Use when the user asks for e2e tests, device testing, store purchase-flow verification, or a full PR regression on hardware.
+---
+
# E2E Tests — Device-Backed OpenIAP Regression
Run this when a PR or release candidate needs real-device regression across
@@ -607,8 +612,10 @@ FireOS/Amazon Android build and launch smoke:
cd libraries/flutter_inapp_purchase/example/android
./gradlew :app:assembleDebug -PfireOsEnabled=true
# Build-only regression can stop here.
+# Flutter redirects its gradle output to `example/build/app/outputs/flutter-apk/`,
+# so this path is not the `android/app/build/...` layout the other examples use.
: "${FIREOS_SERIAL:?Set FIREOS_SERIAL to the target FireOS device serial}"
-adb -s "$FIREOS_SERIAL" install -r app/build/outputs/apk/debug/app-debug.apk
+adb -s "$FIREOS_SERIAL" install -r ../build/app/outputs/flutter-apk/app-debug.apk
adb -s "$FIREOS_SERIAL" shell monkey -p dev.hyo.martie 1
```
diff --git a/.claude/commands/release.md b/.claude/commands/release.md
index 700cc70a2..8279bae7b 100644
--- a/.claude/commands/release.md
+++ b/.claude/commands/release.md
@@ -1,3 +1,8 @@
+---
+name: release
+description: Release OpenIAP packages (stable or prerelease) one at a time, verifying the public registry before continuing. Use when the user asks to release, publish, or deploy a package or run a release train.
+---
+
# Release Packages
Use this workflow for stable or prerelease package deployment. Release one
diff --git a/.claude/commands/resolve-issue.md b/.claude/commands/resolve-issue.md
index a77f3f816..b2eee2500 100644
--- a/.claude/commands/resolve-issue.md
+++ b/.claude/commands/resolve-issue.md
@@ -1,3 +1,8 @@
+---
+name: resolve-issue
+description: Analyze a GitHub issue, apply labels, then either fix it with a PR or reply with the analysis. Use when the user names an issue number and asks to resolve, triage, fix, or investigate it.
+---
+
# Resolve Issue
Analyze a GitHub issue, add labels, and either fix it with a PR or comment with analysis.
diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md
index 0e6699d92..9b642966d 100644
--- a/.claude/commands/review-pr.md
+++ b/.claude/commands/review-pr.md
@@ -1,3 +1,8 @@
+---
+name: review-pr
+description: Review and address pull-request review comments by fixing valid findings, replying to invalid ones, running lint/typecheck/tests, then resolving the threads. Use when the user asks to review a PR, handle review feedback, or address reviewer comments on a pull request.
+---
+
# Review PR Comments
Review and address PR review comments for this repository.
diff --git a/.claude/commands/verify-all.md b/.claude/commands/verify-all.md
index 76c82a082..719ff1f7b 100644
--- a/.claude/commands/verify-all.md
+++ b/.claude/commands/verify-all.md
@@ -1,3 +1,8 @@
+---
+name: verify-all
+description: Run the full monorepo health check across every package and library. Use before committing or opening a PR, or when the user asks to verify everything, run all checks, or confirm the repo is healthy.
+---
+
# Verify All — Full Monorepo Health Check
Run this before committing or creating a PR to verify the entire monorepo is healthy.
diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt
index d30d8d69e..9e11f929a 100644
--- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt
+++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt
@@ -27,6 +27,7 @@ import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -58,6 +59,20 @@ internal suspend fun endExpoConnectionWithCleanup(
internal fun endConnectionErrorCode(error: Exception): String =
(error as? OpenIapError)?.code ?: OpenIapError.ServiceDisconnected.CODE
+internal fun deliverPurchaseRequestFailure(
+ reachedOpenIapRequest: Boolean,
+ isCancellation: Boolean = false,
+ errorCode: String,
+ errorEnvelope: String,
+ emitLocalError: () -> Unit,
+ rejectPendingPromises: (String, String) -> Unit,
+) {
+ if (!reachedOpenIapRequest && !isCancellation) {
+ emitLocalError()
+ }
+ rejectPendingPromises(errorCode, errorEnvelope)
+}
+
class ExpoIapModule : Module() {
companion object {
const val TAG = "ExpoIapModule"
@@ -387,9 +402,11 @@ class ExpoIapModule : Module() {
ExpoIapHelper.addPurchasePromise(promise)
scope.launch {
+ var reachedOpenIapRequest = false
try {
val activity = currentActivity
openIap.setActivity(activity)
+ reachedOpenIapRequest = true
val result = openIap.requestPurchase(requestProps)
val purchases =
when (result) {
@@ -415,23 +432,33 @@ class ExpoIapModule : Module() {
)
}
val errorCode = errorMap["code"] as? String ?: OpenIapError.PurchaseFailed.CODE
- runCatching {
- ExpoIapHelper.emitOrQueue(
- this@ExpoIapModule,
- scope,
- connectionReady,
- pendingEvents,
- EVENT_PURCHASE_ERROR,
- errorMap,
- )
- }.onFailure { ex ->
- ExpoIapLog.failure("send PURCHASE_ERROR event requestPurchase", ex)
- }
- ExpoIapHelper.rejectPurchasePromises(
- errorCode,
- ExpoIapHelper.serializeErrorEnvelope(errorMap),
- null,
+ val errorEnvelope = ExpoIapHelper.serializeErrorEnvelope(errorMap)
+ deliverPurchaseRequestFailure(
+ reachedOpenIapRequest = reachedOpenIapRequest,
+ isCancellation = e is CancellationException,
+ errorCode = errorCode,
+ errorEnvelope = errorEnvelope,
+ emitLocalError = {
+ runCatching {
+ ExpoIapHelper.emitOrQueue(
+ this@ExpoIapModule,
+ scope,
+ connectionReady,
+ pendingEvents,
+ EVENT_PURCHASE_ERROR,
+ errorMap,
+ )
+ }.onFailure { ex ->
+ ExpoIapLog.failure("send PURCHASE_ERROR event requestPurchase", ex)
+ }
+ },
+ rejectPendingPromises = { code, message ->
+ ExpoIapHelper.rejectPurchasePromises(code, message, null)
+ },
)
+ if (e is CancellationException) {
+ throw e
+ }
}
}
}
diff --git a/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt b/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt
index 16420fdfa..c1e4dec9a 100644
--- a/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt
+++ b/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt
@@ -137,6 +137,58 @@ class ExpoIapHelperTest {
assertEquals(OpenIapError.NetworkError.CODE, endConnectionErrorCode(OpenIapError.NetworkError))
}
+ @Test
+ fun `purchase failure before core emits and rejects`() {
+ var emitted = 0
+ var rejected: Pair? = null
+
+ deliverPurchaseRequestFailure(
+ reachedOpenIapRequest = false,
+ errorCode = "purchase-error",
+ errorEnvelope = "envelope",
+ emitLocalError = { emitted += 1 },
+ rejectPendingPromises = { code, message -> rejected = code to message },
+ )
+
+ assertEquals(1, emitted)
+ assertEquals("purchase-error" to "envelope", rejected)
+ }
+
+ @Test
+ fun `purchase failure after core suppresses duplicate event but still rejects`() {
+ var emitted = 0
+ var rejected: Pair? = null
+
+ deliverPurchaseRequestFailure(
+ reachedOpenIapRequest = true,
+ errorCode = "user-cancelled",
+ errorEnvelope = "envelope",
+ emitLocalError = { emitted += 1 },
+ rejectPendingPromises = { code, message -> rejected = code to message },
+ )
+
+ assertEquals(0, emitted)
+ assertEquals("user-cancelled" to "envelope", rejected)
+ }
+
+ @Test
+ fun `purchase coroutine cancellation rejects without publishing a purchase error`() {
+ var emitted = 0
+ var rejected: Pair? = null
+
+ deliverPurchaseRequestFailure(
+ reachedOpenIapRequest = false,
+ isCancellation = true,
+ errorCode = "service-disconnected",
+ errorEnvelope = "envelope",
+ emitLocalError = { emitted += 1 },
+ rejectPendingPromises = { code, message -> rejected = code to message },
+ )
+
+ assertEquals(0, emitted)
+ assertEquals("service-disconnected" to "envelope", rejected)
+ }
+
@Test
fun `end connection falls back to service disconnected`() {
assertEquals(
diff --git a/libraries/expo-iap/ios/ExpoIapHelper.swift b/libraries/expo-iap/ios/ExpoIapHelper.swift
index 8cf73ae85..8eb2d3667 100644
--- a/libraries/expo-iap/ios/ExpoIapHelper.swift
+++ b/libraries/expo-iap/ios/ExpoIapHelper.swift
@@ -64,6 +64,33 @@ enum ExpoIapHelper {
array
}
+ // Keep Expo IAP compatible with the currently published OpenIAP native
+ // package while treating authoritative query serialization atomically.
+ // Its non-throwing helpers use an empty dictionary as the failure sentinel.
+ static func encodeRequired(_ value: T) throws -> [String: Any] {
+ let encoded = OpenIapSerialization.encode(value)
+ guard !encoded.isEmpty else {
+ throw PurchaseError.make(
+ code: .billingResponseJsonParseError,
+ message: "Failed to serialize native \(T.self) payload"
+ )
+ }
+ return encoded
+ }
+
+ static func purchasesRequired(_ purchases: [Purchase]) throws -> [[String: Any]] {
+ try purchases.map { purchase in
+ let encoded = OpenIapSerialization.purchase(purchase)
+ guard !encoded.isEmpty else {
+ throw PurchaseError.make(
+ code: .billingResponseJsonParseError,
+ message: "Failed to serialize native purchase payload"
+ )
+ }
+ return encoded
+ }
+ }
+
static func parseProductQueryType(_ rawValue: String?) throws -> ProductQueryType {
guard let raw = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty
else {
diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift
index 48b3e5733..ddcc90892 100644
--- a/libraries/expo-iap/ios/ExpoIapModule.swift
+++ b/libraries/expo-iap/ios/ExpoIapModule.swift
@@ -142,7 +142,7 @@ public final class ExpoIapModule: Module {
ExpoIapLog.payload("getAvailablePurchases", payload: options ?? [:])
let purchaseOptions = try options.map { try OpenIapSerialization.purchaseOptions(from: $0) }
let purchases = try await OpenIapModule.shared.getAvailablePurchases(purchaseOptions)
- let sanitized = ExpoIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases))
+ let sanitized = ExpoIapHelper.sanitizeArray(try ExpoIapHelper.purchasesRequired(purchases))
ExpoIapLog.result("getAvailablePurchases", value: sanitized)
return sanitized
}
@@ -162,7 +162,7 @@ public final class ExpoIapModule: Module {
]
let options = try OpenIapSerialization.purchaseOptions(from: optionsDictionary)
let purchases = try await OpenIapModule.shared.getAvailablePurchases(options)
- let sanitized = ExpoIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases))
+ let sanitized = ExpoIapHelper.sanitizeArray(try ExpoIapHelper.purchasesRequired(purchases))
ExpoIapLog.result("getAvailableItems", value: sanitized)
return sanitized
}
@@ -170,7 +170,9 @@ public final class ExpoIapModule: Module {
AsyncFunction("getPendingTransactionsIOS") { () async throws -> [[String: Any]] in
ExpoIapLog.payload("getPendingTransactionsIOS", payload: nil)
let pending = try await OpenIapModule.shared.getPendingTransactionsIOS()
- let sanitized = pending.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) }
+ let sanitized = try pending.map {
+ ExpoIapHelper.sanitizeDictionary(try ExpoIapHelper.encodeRequired($0))
+ }
ExpoIapLog.result("getPendingTransactionsIOS", value: sanitized)
return sanitized
}
@@ -178,7 +180,9 @@ public final class ExpoIapModule: Module {
AsyncFunction("getAllTransactionsIOS") { () async throws -> [[String: Any]] in
ExpoIapLog.payload("getAllTransactionsIOS", payload: nil)
let all = try await OpenIapModule.shared.getAllTransactionsIOS()
- let sanitized = all.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) }
+ let sanitized = try all.map {
+ ExpoIapHelper.sanitizeDictionary(try ExpoIapHelper.encodeRequired($0))
+ }
ExpoIapLog.result("getAllTransactionsIOS", value: sanitized)
return sanitized
}
@@ -270,7 +274,9 @@ public final class ExpoIapModule: Module {
AsyncFunction("showManageSubscriptionsIOS") { () async throws -> [[String: Any]] in
ExpoIapLog.payload("showManageSubscriptionsIOS", payload: nil)
let purchases = try await OpenIapModule.shared.showManageSubscriptionsIOS()
- let sanitized = purchases.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) }
+ let sanitized = try purchases.map {
+ ExpoIapHelper.sanitizeDictionary(try ExpoIapHelper.encodeRequired($0))
+ }
ExpoIapLog.result("showManageSubscriptionsIOS", value: sanitized)
return sanitized
}
@@ -384,7 +390,9 @@ public final class ExpoIapModule: Module {
AsyncFunction("getActiveSubscriptions") { (subscriptionIds: [String]?) async throws -> [[String: Any]] in
ExpoIapLog.payload("getActiveSubscriptions", payload: subscriptionIds.map { ["subscriptionIds": $0] } ?? [:])
let subscriptions = try await OpenIapModule.shared.getActiveSubscriptions(subscriptionIds)
- let sanitized = subscriptions.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) }
+ let sanitized = try subscriptions.map {
+ ExpoIapHelper.sanitizeDictionary(try ExpoIapHelper.encodeRequired($0))
+ }
ExpoIapLog.result("getActiveSubscriptions", value: sanitized)
return sanitized
}
diff --git a/libraries/expo-iap/src/__tests__/index.kepler.test.ts b/libraries/expo-iap/src/__tests__/index.kepler.test.ts
index 438d60278..899a5ab51 100644
--- a/libraries/expo-iap/src/__tests__/index.kepler.test.ts
+++ b/libraries/expo-iap/src/__tests__/index.kepler.test.ts
@@ -1,5 +1,6 @@
import {
fetchProducts,
+ getAvailablePurchases,
openRedeemOfferCodeAndroid,
requestPurchase,
} from '../index.kepler';
@@ -11,12 +12,14 @@ jest.mock('../vega', () => ({
describe('Amazon Vega public API', () => {
const fetchProductsNative = jest.fn().mockResolvedValue([]);
+ const getAvailablePurchasesNative = jest.fn().mockResolvedValue([]);
const requestPurchaseNative = jest.fn().mockResolvedValue([]);
beforeEach(() => {
jest.clearAllMocks();
(getVegaIapModule as jest.Mock).mockReturnValue({
fetchProducts: fetchProductsNative,
+ getAvailableItems: getAvailablePurchasesNative,
requestPurchase: requestPurchaseNative,
});
});
@@ -67,4 +70,23 @@ describe('Amazon Vega public API', () => {
expect(requestPurchaseNative).not.toHaveBeenCalled();
});
+
+ it('rejects an Apple purchase returned by the Vega bridge', async () => {
+ getAvailablePurchasesNative.mockResolvedValueOnce([
+ {
+ id: 'foreign',
+ transactionId: 'foreign',
+ productId: 'premium',
+ transactionDate: Date.now(),
+ store: 'apple',
+ quantity: 1,
+ purchaseState: 'purchased',
+ isAutoRenewing: false,
+ },
+ ]);
+
+ await expect(getAvailablePurchases()).rejects.toMatchObject({
+ code: 'billing-response-json-parse-error',
+ });
+ });
});
diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts
index 85701acc7..40ada0ba2 100644
--- a/libraries/expo-iap/src/__tests__/index.test.ts
+++ b/libraries/expo-iap/src/__tests__/index.test.ts
@@ -43,6 +43,20 @@ import {Platform} from 'react-native';
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
+const nativePurchase = (
+ id: string,
+ overrides: Record = {},
+) => ({
+ id,
+ productId: `product.${id}`,
+ transactionDate: 1720000000000,
+ store: 'google',
+ quantity: 1,
+ purchaseState: 'purchased',
+ isAutoRenewing: false,
+ ...overrides,
+});
+
afterEach(() => {
consoleLogSpy.mockClear();
});
@@ -1168,8 +1182,8 @@ describe('Public API (index.ts)', () => {
(ExpoIapModule.getAvailableItems as jest.Mock) = jest
.fn()
.mockResolvedValueOnce([
- {id: 'p1', transactionId: 'txn-1'},
- {id: 's1', transactionId: 'txn-2'},
+ nativePurchase('p1', {transactionId: 'txn-1'}),
+ nativePurchase('s1', {transactionId: 'txn-2'}),
]);
const res = await getAvailablePurchases();
expect(ExpoIapModule.getAvailableItems).toHaveBeenCalledWith({
@@ -1187,12 +1201,11 @@ describe('Public API (index.ts)', () => {
(ExpoIapModule.getAvailableItems as jest.Mock) = jest
.fn()
.mockResolvedValueOnce([
- {id: 'active-sub', transactionId: 'txn-1'},
- {
- id: 'suspended-sub',
+ nativePurchase('active-sub', {transactionId: 'txn-1'}),
+ nativePurchase('suspended-sub', {
transactionId: 'txn-2',
isSuspendedAndroid: true,
- },
+ }),
]);
const res = await getAvailablePurchases({includeSuspendedAndroid: true});
expect(ExpoIapModule.getAvailableItems).toHaveBeenCalledWith({
@@ -1208,10 +1221,15 @@ describe('Public API (index.ts)', () => {
(Platform as any).select = (obj: any) => obj.ios;
const syncSpy = jest
.spyOn(iosMod as any, 'syncIOS')
- .mockResolvedValue(undefined as any);
+ .mockResolvedValue(true);
(ExpoIapModule.getAvailableItems as jest.Mock) = jest
.fn()
- .mockResolvedValue([{id: 'legacy', transactionId: 'txn-restore'}]);
+ .mockResolvedValue([
+ nativePurchase('legacy', {
+ store: 'apple',
+ transactionId: 'txn-restore',
+ }),
+ ]);
await restorePurchases();
expect(syncSpy).toHaveBeenCalledTimes(1);
expect(ExpoIapModule.getAvailableItems).toHaveBeenCalledWith(false, true);
@@ -1222,7 +1240,7 @@ describe('Public API (index.ts)', () => {
(Platform as any).select = (obj: any) => obj.ios;
const syncSpy = jest
.spyOn(iosMod as any, 'syncIOS')
- .mockResolvedValue(undefined as any);
+ .mockResolvedValue(true);
Object.defineProperty(ExpoIapModule, 'USING_ONSIDE_SDK', {
configurable: true,
value: true,
@@ -1232,7 +1250,12 @@ describe('Public API (index.ts)', () => {
.mockResolvedValue(true);
(ExpoIapModule.getAvailableItems as jest.Mock) = jest
.fn()
- .mockResolvedValue([{id: 'onside', transactionId: 'txn-onside'}]);
+ .mockResolvedValue([
+ nativePurchase('onside', {
+ store: 'apple',
+ transactionId: 'txn-onside',
+ }),
+ ]);
try {
await restorePurchases();
@@ -1247,6 +1270,70 @@ describe('Public API (index.ts)', () => {
delete (ExpoIapModule as any).USING_ONSIDE_SDK;
}
});
+
+ it('getAvailablePurchases rejects mixed malformed results atomically', async () => {
+ (Platform as any).OS = 'android';
+ (ExpoIapModule.getAvailableItems as jest.Mock) = jest
+ .fn()
+ .mockResolvedValue([nativePurchase('valid'), {id: 'malformed'}]);
+
+ await expect(getAvailablePurchases()).rejects.toMatchObject({
+ code: ErrorCode.BillingResponseJsonParseError,
+ });
+ });
+
+ it('getAvailablePurchases rejects a foreign store on iOS', async () => {
+ (Platform as any).OS = 'ios';
+ (ExpoIapModule.getAvailableItems as jest.Mock) = jest
+ .fn()
+ .mockResolvedValue([
+ nativePurchase('foreign', {
+ store: 'google',
+ transactionId: 'foreign',
+ }),
+ ]);
+
+ await expect(getAvailablePurchases()).rejects.toMatchObject({
+ code: ErrorCode.BillingResponseJsonParseError,
+ });
+ });
+
+ it('getAvailablePurchases rejects a foreign store on Android', async () => {
+ (Platform as any).OS = 'android';
+ (ExpoIapModule.getAvailableItems as jest.Mock) = jest
+ .fn()
+ .mockResolvedValue([
+ nativePurchase('foreign', {
+ store: 'apple',
+ transactionId: 'foreign',
+ }),
+ ]);
+
+ await expect(getAvailablePurchases()).rejects.toMatchObject({
+ code: ErrorCode.BillingResponseJsonParseError,
+ });
+ });
+
+ it('restorePurchases propagates iOS sync failure without querying', async () => {
+ (Platform as any).OS = 'ios';
+ const syncError = new Error('sync failed');
+ jest.spyOn(iosMod as any, 'syncIOS').mockRejectedValue(syncError);
+ (ExpoIapModule.getAvailableItems as jest.Mock) = jest.fn();
+
+ await expect(restorePurchases()).rejects.toBe(syncError);
+ expect(ExpoIapModule.getAvailableItems).not.toHaveBeenCalled();
+ });
+
+ it('restorePurchases rejects a false iOS sync result', async () => {
+ (Platform as any).OS = 'ios';
+ jest.spyOn(iosMod as any, 'syncIOS').mockResolvedValue(false);
+ (ExpoIapModule.getAvailableItems as jest.Mock) = jest.fn();
+
+ await expect(restorePurchases()).rejects.toMatchObject({
+ code: ErrorCode.SyncError,
+ });
+ expect(ExpoIapModule.getAvailableItems).not.toHaveBeenCalled();
+ });
});
describe('finishTransaction', () => {
diff --git a/libraries/expo-iap/src/__tests__/useIAP.test.tsx b/libraries/expo-iap/src/__tests__/useIAP.test.tsx
index b71406070..a45309a35 100644
--- a/libraries/expo-iap/src/__tests__/useIAP.test.tsx
+++ b/libraries/expo-iap/src/__tests__/useIAP.test.tsx
@@ -69,6 +69,7 @@ describe('useIAP hook', () => {
(ExpoIapModule.endConnection as jest.Mock) = jest
.fn()
.mockResolvedValue(true);
+ (ExpoIapModule.syncIOS as jest.Mock) = jest.fn().mockResolvedValue(true);
(ExpoIapModule.addListener as jest.Mock) = jest.fn().mockReturnValue({
remove: jest.fn(),
});
@@ -282,6 +283,41 @@ describe('useIAP hook', () => {
expect(onError).toHaveBeenCalledWith(mockError);
});
+ it('calls onError and rethrows when hasActiveSubscriptions fails', async () => {
+ const mockError = new Error('Entitlement status unavailable');
+ (ExpoIapModule.hasActiveSubscriptions as jest.Mock) = jest
+ .fn()
+ .mockRejectedValue(mockError);
+
+ const onError = jest.fn();
+ let hookResult: ReturnType | null = null;
+
+ await ReactTestRenderer.act(async () => {
+ ReactTestRenderer.create(
+ {
+ hookResult = hook;
+ }}
+ />,
+ );
+ await flushPromises();
+ });
+
+ let thrown: unknown;
+ await ReactTestRenderer.act(async () => {
+ try {
+ await hookResult!.hasActiveSubscriptions();
+ } catch (error) {
+ thrown = error;
+ }
+ });
+
+ expect(onError).toHaveBeenCalledTimes(1);
+ expect(onError).toHaveBeenCalledWith(mockError);
+ expect(thrown).toBe(mockError);
+ });
+
it('calls onError when restorePurchases fails', async () => {
const mockError = new Error('Restore failed');
(ExpoIapModule.getAvailableItems as jest.Mock) = jest
@@ -319,6 +355,36 @@ describe('useIAP hook', () => {
expect(onError).toHaveBeenCalledWith(mockError);
});
+ it('stops restore after an iOS sync rejection', async () => {
+ const mockError = new Error('App Store sync failed');
+ (ExpoIapModule.syncIOS as jest.Mock) = jest
+ .fn()
+ .mockRejectedValue(mockError);
+ (ExpoIapModule.getAvailableItems as jest.Mock) = jest.fn();
+
+ const onError = jest.fn();
+ let hookResult: ReturnType | null = null;
+ await ReactTestRenderer.act(async () => {
+ ReactTestRenderer.create(
+ {
+ hookResult = hook;
+ }}
+ />,
+ );
+ await flushPromises();
+ });
+
+ await ReactTestRenderer.act(async () => {
+ await expect(hookResult!.restorePurchases()).rejects.toBe(mockError);
+ });
+
+ expect(ExpoIapModule.getAvailableItems).not.toHaveBeenCalled();
+ expect(onError).toHaveBeenCalledTimes(1);
+ expect(onError).toHaveBeenCalledWith(mockError);
+ });
+
it('does not call onError when fetchProducts succeeds', async () => {
(ExpoIapModule.fetchProducts as jest.Mock) = jest
.fn()
diff --git a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts
index 4bd4a5161..5ee96feab 100644
--- a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts
+++ b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts
@@ -65,7 +65,7 @@ const createService = (): jest.Mocked =>
notifyFulfillment: jest.fn(async () => ({
responseCode: 1,
})),
- }) as unknown as jest.Mocked;
+ } as unknown as jest.Mocked);
describe('Amazon Vega Expo adapter', () => {
it('initializes without fetching Amazon user data', async () => {
@@ -893,7 +893,7 @@ describe('Amazon Vega Expo adapter', () => {
]);
});
- it('treats Amazon parser-only purchase update errors as no updates', async () => {
+ it('rejects Amazon parser-only purchase update errors atomically', async () => {
const service = createService();
service.getPurchaseUpdates.mockRejectedValueOnce(
new Error(
@@ -902,7 +902,35 @@ describe('Amazon Vega Expo adapter', () => {
);
const module = createExpoIapVegaModule(service);
- await expect(module.getAvailableItems()).resolves.toEqual([]);
+ await expect(module.getAvailableItems()).rejects.toMatchObject({
+ code: ErrorCode.BillingResponseJsonParseError,
+ });
+ });
+
+ it('rejects all pages when a later Amazon purchase update page is malformed', async () => {
+ const service = createService();
+ service.getPurchaseUpdates
+ .mockResolvedValueOnce({
+ responseCode: 1,
+ hasMore: true,
+ receiptList: [
+ {
+ receiptId: 'receipt-page-1',
+ sku: 'coins_100',
+ productType: 1,
+ },
+ ],
+ })
+ .mockRejectedValueOnce(
+ new Error(
+ '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json',
+ ),
+ );
+ const module = createExpoIapVegaModule(service);
+
+ await expect(module.getAvailableItems()).rejects.toMatchObject({
+ code: ErrorCode.BillingResponseJsonParseError,
+ });
});
it('retries failed Amazon purchase update responses', async () => {
@@ -942,7 +970,7 @@ describe('Amazon Vega Expo adapter', () => {
}
});
- it('ignores parser-only product type hydration errors for purchase updates', async () => {
+ it('rejects parser-only product type hydration errors for purchase updates', async () => {
const service = createService();
service.getPurchaseUpdates.mockResolvedValueOnce({
responseCode: 1,
@@ -963,7 +991,9 @@ describe('Amazon Vega Expo adapter', () => {
await expect(
module.getActiveSubscriptions(['premium_monthly']),
- ).resolves.toEqual([]);
+ ).rejects.toMatchObject({
+ code: ErrorCode.BillingResponseJsonParseError,
+ });
});
it('limits paginated Amazon purchase updates', async () => {
diff --git a/libraries/expo-iap/src/index.kepler.ts b/libraries/expo-iap/src/index.kepler.ts
index f3c41de52..17d6420c7 100644
--- a/libraries/expo-iap/src/index.kepler.ts
+++ b/libraries/expo-iap/src/index.kepler.ts
@@ -12,6 +12,7 @@ import type {
RequestPurchasePropsByPlatforms,
RequestSubscriptionPropsByPlatforms,
} from './types';
+import {decodeAndroidPurchases} from './utils/availablePurchases';
export * from './types';
export * from './vega';
@@ -72,7 +73,8 @@ const normalizeProductType = (
);
};
-const normalizePurchaseArray = (purchases: Purchase[]): Purchase[] => purchases;
+const normalizePurchaseArray = (purchases: Purchase[]): Purchase[] =>
+ decodeAndroidPurchases(purchases);
const getAndroidRequest = (
request?:
diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts
index e81db1ddb..920bb7db2 100644
--- a/libraries/expo-iap/src/index.ts
+++ b/libraries/expo-iap/src/index.ts
@@ -4,12 +4,17 @@ import {Platform} from 'react-native';
// Internal modules
import ExpoIapModule, {getNativeModule} from './ExpoIapModule';
import {isVegaOS} from './vega';
-import {isProductIOS, deepLinkToSubscriptionsIOS, syncIOS} from './modules/ios';
+import {isProductIOS, deepLinkToSubscriptionsIOS} from './modules/ios';
import {
isProductAndroid,
deepLinkToSubscriptionsAndroid,
} from './modules/android';
import {ExpoIapConsole} from './utils/debug';
+import {restorePurchasesIOSNative} from './utils/restorePurchases';
+import {
+ decodeAndroidPurchases,
+ decodeApplePurchases,
+} from './utils/availablePurchases';
// Types
import type {
@@ -73,7 +78,9 @@ type ExpoIapEventPayloads = {
[OpenIapEvent.PurchaseUpdated]: Purchase;
[OpenIapEvent.PurchaseError]: PurchaseError;
[OpenIapEvent.PromotedProductIOS]:
- Product | string | {id?: string; productId?: string};
+ | Product
+ | string
+ | {id?: string; productId?: string};
[OpenIapEvent.UserChoiceBillingAndroid]: UserChoiceBillingDetails;
[OpenIapEvent.DeveloperProvidedBillingAndroid]: DeveloperProvidedBillingDetailsAndroid;
[OpenIapEvent.SubscriptionBillingIssue]: Purchase;
@@ -379,7 +386,8 @@ export const promotedProductListenerIOS = (
let pendingProduct: Promise | undefined;
try {
pendingProduct = ExpoIapModule.getPromotedProductIOS() as
- Promise | undefined;
+ | Promise
+ | undefined;
} catch {
return Promise.resolve();
}
@@ -594,8 +602,8 @@ const invokeNativeWithPurchaseError = async (
typeof nativeError?.message === 'string'
? nativeError.message
: typeof error === 'string'
- ? error
- : '';
+ ? error
+ : '';
const hasCanonicalFields =
nativeMessage.includes(OPENIAP_ERROR_ENVELOPE_PREFIX) ||
nativeError?.code !== undefined ||
@@ -752,7 +760,7 @@ export const getAvailablePurchases: QueryField<
if (isVegaOS()) {
const purchases = await ExpoIapModule.getAvailableItems(normalizedOptions);
- return purchases as Purchase[];
+ return decodeAndroidPurchases(purchases);
}
let purchases: Purchase[];
@@ -770,7 +778,9 @@ export const getAvailablePurchases: QueryField<
throw unsupportedPlatformError();
}
- return purchases as Purchase[];
+ return Platform.OS === 'ios'
+ ? decodeApplePurchases(purchases)
+ : decodeAndroidPurchases(purchases);
};
/**
@@ -909,7 +919,8 @@ function normalizeRequestProps(
): RequestSubscriptionAndroidProps | null | undefined;
function normalizeRequestProps(
request:
- RequestPurchasePropsByPlatforms | RequestSubscriptionPropsByPlatforms,
+ | RequestPurchasePropsByPlatforms
+ | RequestSubscriptionPropsByPlatforms,
platform: 'ios' | 'android',
) {
if (platform === 'ios') {
@@ -1200,16 +1211,7 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ({
*/
export const restorePurchases: MutationField<'restorePurchases'> = async () => {
if (Platform.OS === 'ios') {
- const nativeModule = ExpoIapModule as any;
-
- if (
- nativeModule.USING_ONSIDE_SDK &&
- typeof nativeModule.restorePurchases === 'function'
- ) {
- await nativeModule.restorePurchases().catch(() => undefined);
- } else {
- await syncIOS().catch(() => undefined);
- }
+ await restorePurchasesIOSNative();
}
await getAvailablePurchases({
@@ -1340,8 +1342,9 @@ export const verifyPurchaseWithProvider: MutationField<
}
}
- const result =
- await ExpoIapModule.verifyPurchaseWithProvider(resolvedOptions);
+ const result = await ExpoIapModule.verifyPurchaseWithProvider(
+ resolvedOptions,
+ );
if (result.iapkit == null) {
return result;
}
diff --git a/libraries/expo-iap/src/modules/__tests__/ios.test.ts b/libraries/expo-iap/src/modules/__tests__/ios.test.ts
index bf38d4f4b..365117b9c 100644
--- a/libraries/expo-iap/src/modules/__tests__/ios.test.ts
+++ b/libraries/expo-iap/src/modules/__tests__/ios.test.ts
@@ -18,6 +18,7 @@ jest.mock('../../ExpoIapModule', () => ({
getAppTransactionIOS: jest.fn(),
getPromotedProductIOS: jest.fn(),
getPendingTransactionsIOS: jest.fn(),
+ getAllTransactionsIOS: jest.fn(),
clearTransactionIOS: jest.fn(),
canPresentExternalPurchaseNoticeIOS: jest.fn(),
presentExternalPurchaseNoticeSheetIOS: jest.fn(),
@@ -64,6 +65,7 @@ import {
deepLinkToSubscriptionsIOS,
isProductIOS,
getPendingTransactionsIOS,
+ getAllTransactionsIOS,
clearTransactionIOS,
canPresentExternalPurchaseNoticeIOS,
presentExternalPurchaseNoticeSheetIOS,
@@ -74,6 +76,17 @@ import {
} from '../ios';
/* eslint-enable import/first */
+const validPurchase = (id: string) => ({
+ id,
+ transactionId: id,
+ productId: 'premium',
+ transactionDate: 1720000000000,
+ store: 'apple',
+ quantity: 1,
+ purchaseState: 'purchased',
+ isAutoRenewing: false,
+});
+
describe('iOS Module Functions', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -372,9 +385,7 @@ describe('iOS Module Functions', () => {
});
it('should call showManageSubscriptionsIOS', async () => {
- const mockPurchases: any[] = [
- {id: 'legacy', transactionId: 'txn-77', platform: 'ios'},
- ];
+ const mockPurchases: any[] = [validPurchase('txn-77')];
(ExpoIapModule.showManageSubscriptionsIOS as jest.Mock).mockResolvedValue(
mockPurchases,
);
@@ -383,18 +394,18 @@ describe('iOS Module Functions', () => {
expect(ExpoIapModule.showManageSubscriptionsIOS).toHaveBeenCalledTimes(1);
expect(Array.isArray(result)).toBe(true);
- expect(result[0]?.id).toBe('legacy');
+ expect(result[0]?.id).toBe('txn-77');
expect(result[0]?.transactionId).toBe('txn-77');
});
- it('showManageSubscriptionsIOS returns empty array when native returns null', async () => {
+ it('showManageSubscriptionsIOS rejects when native returns null', async () => {
(ExpoIapModule.showManageSubscriptionsIOS as jest.Mock).mockResolvedValue(
null,
);
- const result = await showManageSubscriptionsIOS();
-
- expect(result).toEqual([]);
+ await expect(showManageSubscriptionsIOS()).rejects.toMatchObject({
+ code: 'billing-response-json-parse-error',
+ });
});
it('should call getReceiptDataIOS', async () => {
@@ -526,13 +537,13 @@ describe('iOS Module Functions', () => {
it('normalizes pending transactions list', async () => {
(ExpoIapModule.getPendingTransactionsIOS as jest.Mock).mockResolvedValue([
- {id: 'legacy-id', transactionId: 'txn-pending', platform: 'ios'},
+ validPurchase('txn-pending'),
]);
const result = await getPendingTransactionsIOS();
expect(ExpoIapModule.getPendingTransactionsIOS).toHaveBeenCalledTimes(1);
- expect(result[0].id).toBe('legacy-id');
+ expect(result[0].id).toBe('txn-pending');
});
it('clears iOS transactions', async () => {
@@ -544,14 +555,22 @@ describe('iOS Module Functions', () => {
expect(result).toBe(true);
});
- it('getPendingTransactionsIOS returns empty list when native returns null', async () => {
+ it('transaction list APIs reject null and mixed foreign batches', async () => {
(ExpoIapModule.getPendingTransactionsIOS as jest.Mock).mockResolvedValue(
null,
);
+ await expect(getPendingTransactionsIOS()).rejects.toMatchObject({
+ code: 'billing-response-json-parse-error',
+ });
- const result = await getPendingTransactionsIOS();
-
- expect(result).toEqual([]);
+ const valid = validPurchase('valid');
+ (ExpoIapModule.getAllTransactionsIOS as jest.Mock).mockResolvedValue([
+ valid,
+ {...valid, id: 'foreign', store: 'google'},
+ ]);
+ await expect(getAllTransactionsIOS()).rejects.toMatchObject({
+ code: 'billing-response-json-parse-error',
+ });
});
it('clearTransactionIOS returns false when native resolves undefined', async () => {
@@ -734,8 +753,9 @@ describe('iOS Module Functions', () => {
ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS as jest.Mock
).mockResolvedValue(mockResult);
- const result =
- await getExternalPurchaseCustomLinkTokenIOS('acquisition');
+ const result = await getExternalPurchaseCustomLinkTokenIOS(
+ 'acquisition',
+ );
expect(
ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS,
@@ -765,8 +785,9 @@ describe('iOS Module Functions', () => {
ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS as jest.Mock
).mockResolvedValue(mockResult);
- const result =
- await getExternalPurchaseCustomLinkTokenIOS('acquisition');
+ const result = await getExternalPurchaseCustomLinkTokenIOS(
+ 'acquisition',
+ );
expect(result.error).toBe('App not eligible');
expect(result.token).toBeUndefined();
diff --git a/libraries/expo-iap/src/modules/ios.ts b/libraries/expo-iap/src/modules/ios.ts
index 9e3a0ecf0..b57228e4c 100644
--- a/libraries/expo-iap/src/modules/ios.ts
+++ b/libraries/expo-iap/src/modules/ios.ts
@@ -18,6 +18,7 @@ import type {
SubscriptionStatusIOS,
} from '../types';
import {type PurchaseError} from '../utils/errorMapping';
+import {decodeApplePurchases} from '../utils/availablePurchases';
import {Linking, Platform} from 'react-native';
/**
@@ -194,7 +195,7 @@ export const showManageSubscriptionsIOS: MutationField<
> = async () => {
requireIosPlatform('showManageSubscriptionsIOS');
const purchases = await ExpoIapModule.showManageSubscriptionsIOS();
- return (purchases ?? []) as PurchaseIOS[];
+ return decodeApplePurchases(purchases);
};
/**
@@ -352,7 +353,7 @@ export const getPendingTransactionsIOS: QueryField<
> = async () => {
requireIosPlatform('getPendingTransactionsIOS');
const transactions = await ExpoIapModule.getPendingTransactionsIOS();
- return (transactions ?? []) as PurchaseIOS[];
+ return decodeApplePurchases(transactions);
};
/**
@@ -365,7 +366,7 @@ export const getAllTransactionsIOS: QueryField<
> = async () => {
requireIosPlatform('getAllTransactionsIOS');
const transactions = await ExpoIapModule.getAllTransactionsIOS();
- return (transactions ?? []) as PurchaseIOS[];
+ return decodeApplePurchases(transactions);
};
/**
@@ -483,8 +484,9 @@ export const getExternalPurchaseCustomLinkTokenIOS: QueryField<
"getExternalPurchaseCustomLinkTokenIOS requires a tokenType ('acquisition' or 'services')",
);
}
- const result =
- await ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS(tokenType);
+ const result = await ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS(
+ tokenType,
+ );
return result as ExternalPurchaseCustomLinkTokenResultIOS;
};
@@ -509,8 +511,9 @@ export const showExternalPurchaseCustomLinkNoticeIOS: MutationField<
"showExternalPurchaseCustomLinkNoticeIOS requires a noticeType ('browser')",
);
}
- const result =
- await ExpoIapModule.showExternalPurchaseCustomLinkNoticeIOS(noticeType);
+ const result = await ExpoIapModule.showExternalPurchaseCustomLinkNoticeIOS(
+ noticeType,
+ );
return result as ExternalPurchaseCustomLinkNoticeResultIOS;
};
diff --git a/libraries/expo-iap/src/useIAP.ts b/libraries/expo-iap/src/useIAP.ts
index 9141c2345..3f4ab80fc 100644
--- a/libraries/expo-iap/src/useIAP.ts
+++ b/libraries/expo-iap/src/useIAP.ts
@@ -25,10 +25,8 @@ import {
type ProductTypeInput,
} from './index';
import {ExpoIapConsole} from './utils/debug';
-import {
- getPromotedProductIOS,
- syncIOS,
-} from './modules/ios';
+import {getPromotedProductIOS} from './modules/ios';
+import {restorePurchasesIOSNative} from './utils/restorePurchases';
import {
getBillingChoiceInfoAndroid,
isBillingProgramAvailableAndroid,
@@ -459,10 +457,11 @@ export function useIAP(options?: UseIAPOptions): UseIap {
return await hasActiveSubscriptions(subscriptionIds);
} catch (error) {
ExpoIapConsole.error('Error checking active subscriptions:', error);
- return false;
+ invokeOnError(error);
+ throw error;
}
},
- [],
+ [invokeOnError],
);
/**
@@ -556,8 +555,8 @@ export function useIAP(options?: UseIAPOptions): UseIap {
const purchases = Array.isArray(purchaseResult)
? purchaseResult
: purchaseResult
- ? [purchaseResult]
- : [];
+ ? [purchaseResult]
+ : [];
for (const purchase of purchases ?? []) {
if (!markPurchaseDelivered(purchase)) {
@@ -585,9 +584,8 @@ export function useIAP(options?: UseIAPOptions): UseIap {
const restorePurchasesInternal = useCallback(
async (options?: PurchaseOptions): Promise => {
try {
- // iOS: Try to sync first, but don't fail if sync errors occur
if (Platform.OS === 'ios') {
- await syncIOS().catch(() => undefined); // syncIOS returns Promise, we don't need the result
+ await restorePurchasesIOSNative();
}
const purchases = await getAvailablePurchases({
@@ -629,7 +627,8 @@ export function useIAP(options?: UseIAPOptions): UseIap {
// Build the canonical billing-program connection config.
const buildConnectionConfig = useCallback(():
- InitConnectionConfig | undefined => {
+ | InitConnectionConfig
+ | undefined => {
if (optionsRef.current?.enableBillingProgramAndroid) {
return {
enableBillingProgramAndroid:
diff --git a/libraries/expo-iap/src/utils/availablePurchases.ts b/libraries/expo-iap/src/utils/availablePurchases.ts
new file mode 100644
index 000000000..e68d946cd
--- /dev/null
+++ b/libraries/expo-iap/src/utils/availablePurchases.ts
@@ -0,0 +1,105 @@
+import {ErrorCode, type Purchase, type PurchaseIOS} from '../types';
+import {createPurchaseError} from './errorMapping';
+
+const ANDROID_STORES = new Set(['google', 'amazon', 'horizon']);
+
+const malformedPurchaseError = (message: string) =>
+ createPurchaseError({
+ code: ErrorCode.BillingResponseJsonParseError,
+ message,
+ });
+
+/** Decode an authoritative native purchase list without partial success. */
+export const decodeAvailablePurchases = (value: unknown): Purchase[] => {
+ if (!Array.isArray(value)) {
+ throw malformedPurchaseError(
+ 'Native bridge returned a malformed purchase list',
+ );
+ }
+
+ value.forEach((item, index) => {
+ if (item == null || typeof item !== 'object') {
+ throw malformedPurchaseError(
+ `Native bridge returned a malformed purchase at index ${index}`,
+ );
+ }
+ const purchase = item as Partial;
+ if (
+ typeof purchase.id !== 'string' ||
+ purchase.id.length === 0 ||
+ typeof purchase.productId !== 'string' ||
+ purchase.productId.length === 0 ||
+ typeof purchase.transactionDate !== 'number' ||
+ !Number.isFinite(purchase.transactionDate) ||
+ typeof purchase.store !== 'string' ||
+ typeof purchase.quantity !== 'number' ||
+ !Number.isInteger(purchase.quantity) ||
+ typeof purchase.purchaseState !== 'string' ||
+ typeof purchase.isAutoRenewing !== 'boolean' ||
+ (purchase.store === 'apple' &&
+ (typeof purchase.transactionId !== 'string' ||
+ purchase.transactionId.length === 0))
+ ) {
+ throw malformedPurchaseError(
+ `Native bridge returned a purchase missing required fields at index ${index}`,
+ );
+ }
+ const candidate = purchase as Record;
+ if (
+ candidate.ids != null &&
+ (!Array.isArray(candidate.ids) ||
+ candidate.ids.some((id) => typeof id !== 'string'))
+ ) {
+ throw malformedPurchaseError(
+ `Native bridge returned malformed purchase ids at index ${index}`,
+ );
+ }
+ for (const field of [
+ 'pendingPurchaseUpdateAndroid',
+ 'offerIOS',
+ 'renewalInfoIOS',
+ 'commitmentInfoIOS',
+ 'advancedCommerceInfoIOS',
+ ]) {
+ const nested = candidate[field];
+ if (
+ nested != null &&
+ (typeof nested !== 'object' || Array.isArray(nested))
+ ) {
+ throw malformedPurchaseError(
+ `Native bridge returned a malformed ${field} at index ${index}`,
+ );
+ }
+ }
+ });
+
+ return value as Purchase[];
+};
+
+/** Decode an authoritative StoreKit list without filtering foreign entries. */
+export const decodeApplePurchases = (value: unknown): PurchaseIOS[] => {
+ const decoded = decodeAvailablePurchases(value);
+ const invalidIndex = decoded.findIndex(
+ (purchase) => purchase.store !== 'apple',
+ );
+ if (invalidIndex !== -1) {
+ throw malformedPurchaseError(
+ `Native StoreKit bridge returned a non-Apple purchase at index ${invalidIndex}`,
+ );
+ }
+ return decoded as PurchaseIOS[];
+};
+
+/** Decode an authoritative Android-family list without foreign stores. */
+export const decodeAndroidPurchases = (value: unknown): Purchase[] => {
+ const decoded = decodeAvailablePurchases(value);
+ const invalidIndex = decoded.findIndex(
+ (purchase) => !ANDROID_STORES.has(purchase.store),
+ );
+ if (invalidIndex !== -1) {
+ throw malformedPurchaseError(
+ `Native Android bridge returned a foreign purchase at index ${invalidIndex}`,
+ );
+ }
+ return decoded;
+};
diff --git a/libraries/expo-iap/src/utils/restorePurchases.ts b/libraries/expo-iap/src/utils/restorePurchases.ts
new file mode 100644
index 000000000..22b826187
--- /dev/null
+++ b/libraries/expo-iap/src/utils/restorePurchases.ts
@@ -0,0 +1,31 @@
+import ExpoIapModule from '../ExpoIapModule';
+import {syncIOS} from '../modules/ios';
+import {ErrorCode} from '../types';
+import {createPurchaseError} from './errorMapping';
+
+/**
+ * Run the native iOS restore/sync phase without querying purchases.
+ *
+ * Both native implementations promise an authoritative boolean. A rejection
+ * or false result must reach the caller so restore cannot report success from
+ * a subsequent empty purchase query.
+ */
+export const restorePurchasesIOSNative = async (): Promise => {
+ const nativeModule = ExpoIapModule as any;
+ const usingOnside =
+ nativeModule.USING_ONSIDE_SDK &&
+ typeof nativeModule.restorePurchases === 'function';
+ const restored = usingOnside
+ ? await nativeModule.restorePurchases()
+ : await syncIOS();
+
+ if (restored !== true) {
+ throw createPurchaseError({
+ code: ErrorCode.SyncError,
+ message: usingOnside
+ ? 'Onside purchase restore did not complete'
+ : 'App Store purchase sync did not complete',
+ platform: 'ios',
+ });
+ }
+};
diff --git a/libraries/expo-iap/src/vega-adapter.ts b/libraries/expo-iap/src/vega-adapter.ts
index 26b7c01a1..81e10bc92 100644
--- a/libraries/expo-iap/src/vega-adapter.ts
+++ b/libraries/expo-iap/src/vega-adapter.ts
@@ -617,6 +617,14 @@ function isVegaParserError(error: unknown): boolean {
);
}
+function malformedVegaResponse(error: unknown, operation: string): Error {
+ const detail = error instanceof Error ? error.message : String(error);
+ return createVegaError(
+ ErrorCode.BillingResponseJsonParseError,
+ `${operation} returned a malformed response: ${detail}`,
+ );
+}
+
function createPricingPhase(product: VegaProduct) {
return {
billingCycleCount: 0,
@@ -861,7 +869,7 @@ export function createExpoIapVegaModule(
response = await service.getPurchaseUpdates({reset});
} catch (error) {
if (isVegaParserError(error)) {
- return receipts;
+ throw malformedVegaResponse(error, 'Amazon Vega purchase updates');
}
throw error;
}
@@ -940,7 +948,10 @@ export function createExpoIapVegaModule(
);
} catch (error) {
if (isVegaParserError(error)) {
- return;
+ throw malformedVegaResponse(
+ error,
+ 'Amazon Vega purchase product metadata',
+ );
}
throw error;
}
diff --git a/libraries/flutter_inapp_purchase/analysis_options.yaml b/libraries/flutter_inapp_purchase/analysis_options.yaml
index 26fd229e8..c7b618b4b 100644
--- a/libraries/flutter_inapp_purchase/analysis_options.yaml
+++ b/libraries/flutter_inapp_purchase/analysis_options.yaml
@@ -33,6 +33,10 @@ linter:
analyzer:
exclude:
+ # Flutter 3.44 resolves Swift Packages into build/ios/SourcePackages.
+ # Without this guard, `flutter analyze` recursively analyzes a second
+ # checkout of this monorepo and reports false cross-checkout type errors.
+ - build/**
- lib/types.dart
- example/**
diff --git a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift
index f4ff99768..89425b88b 100644
--- a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift
+++ b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift
@@ -66,6 +66,34 @@ enum FlutterIapHelper {
return String(data: data, encoding: .utf8)
}
+ // Keep the framework compatible with the currently published OpenIAP
+ // native package while still treating serialization as all-or-nothing.
+ // OpenIapSerialization.encode/purchase return an empty dictionary when
+ // encoding fails, so reject that sentinel instead of reporting success.
+ static func encodeRequired(_ value: T) throws -> [String: Any] {
+ let encoded = OpenIapSerialization.encode(value)
+ guard !encoded.isEmpty else {
+ throw PurchaseError.make(
+ code: .billingResponseJsonParseError,
+ message: "Failed to serialize native \(T.self) payload"
+ )
+ }
+ return encoded
+ }
+
+ static func purchasesRequired(_ purchases: [Purchase]) throws -> [[String: Any]] {
+ try purchases.map { purchase in
+ let encoded = OpenIapSerialization.purchase(purchase)
+ guard !encoded.isEmpty else {
+ throw PurchaseError.make(
+ code: .billingResponseJsonParseError,
+ message: "Failed to serialize native purchase payload"
+ )
+ }
+ return encoded
+ }
+ }
+
// MARK: - Parsing helpers
static func parseProductQueryType(_ rawValue: String?) throws -> ProductQueryType {
diff --git a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift
index 900f7c17c..b4c7bcfb8 100644
--- a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift
+++ b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift
@@ -563,7 +563,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin {
onlyIncludeActive: onlyIncludeActiveItems
)
let purchases = try await OpenIapModule.shared.getAvailablePurchases(opts)
- let serialized = FlutterIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases))
+ let serialized = FlutterIapHelper.sanitizeArray(try FlutterIapHelper.purchasesRequired(purchases))
FlutterIapLog.result("getAvailableItems", value: serialized)
await MainActor.run { result(serialized) }
} catch let purchaseError as PurchaseError {
@@ -587,7 +587,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin {
Task { @MainActor in
do {
let subscriptions = try await OpenIapModule.shared.getActiveSubscriptions(subscriptionIds)
- let serialized = subscriptions.map { OpenIapSerialization.encode($0) }
+ let serialized = try subscriptions.map { try FlutterIapHelper.encodeRequired($0) }
let sanitized = FlutterIapHelper.sanitizeArray(serialized)
FlutterIapLog.result("getActiveSubscriptions", value: sanitized)
await MainActor.run { result(sanitized) }
@@ -745,7 +745,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin {
Task { @MainActor in
do {
let purchases = try await OpenIapModule.shared.showManageSubscriptionsIOS()
- let serialized = purchases.map { OpenIapSerialization.encode($0) }
+ let serialized = try purchases.map { try FlutterIapHelper.encodeRequired($0) }
let sanitized = FlutterIapHelper.sanitizeArray(serialized)
FlutterIapLog.result("showManageSubscriptionsIOS", value: sanitized)
result(sanitized)
@@ -827,7 +827,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin {
do {
let pending = try await OpenIapModule.shared.getPendingTransactionsIOS()
let purchases = pending.map { Purchase.purchaseIos($0) }
- let serialized = FlutterIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases))
+ let serialized = FlutterIapHelper.sanitizeArray(try FlutterIapHelper.purchasesRequired(purchases))
FlutterIapLog.result("getPendingTransactionsIOS", value: serialized)
result(serialized)
} catch let purchaseError as PurchaseError {
@@ -847,7 +847,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin {
do {
let all = try await OpenIapModule.shared.getAllTransactionsIOS()
let purchases = all.map { Purchase.purchaseIos($0) }
- let serialized = FlutterIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases))
+ let serialized = FlutterIapHelper.sanitizeArray(try FlutterIapHelper.purchasesRequired(purchases))
FlutterIapLog.result("getAllTransactionsIOS", value: serialized)
result(serialized)
} catch let purchaseError as PurchaseError {
diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
index c58dd565b..5db595a55 100644
--- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
+++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
@@ -811,6 +811,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
platformIsIOS: true,
acknowledgedAndroidPurchaseTokens:
_acknowledgedAndroidPurchaseTokens,
+ rejectMalformed: true,
);
} else if (_platform.isAndroid) {
final args = {
@@ -827,13 +828,19 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
platformIsIOS: false,
acknowledgedAndroidPurchaseTokens:
_acknowledgedAndroidPurchaseTokens,
+ rejectMalformed: true,
);
}
- return raw
- .where((purchase) => purchase.productId.isNotEmpty)
- .where(hasResolvableIdentifier)
- .toList(growable: false);
+ for (final purchase in raw) {
+ if (purchase.productId.isEmpty ||
+ !hasResolvableIdentifier(purchase)) {
+ throw const FormatException(
+ 'Native bridge returned a purchase without a required identity',
+ );
+ }
+ }
+ return raw;
}
return await resolvePurchases();
@@ -842,6 +849,11 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
error,
'get available purchases',
);
+ } on FormatException catch (error) {
+ throw PurchaseError(
+ code: gentype.ErrorCode.BillingResponseJsonParseError,
+ message: 'Failed to decode available purchases: ${error.message}',
+ );
} catch (error) {
if (error is PurchaseError) rethrow;
throw PurchaseError(
@@ -1202,15 +1214,26 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
platformIsIOS: true,
acknowledgedAndroidPurchaseTokens:
_acknowledgedAndroidPurchaseTokens,
+ rejectMalformed: true,
);
- return purchases.whereType().toList(
- growable: false,
- );
+ if (purchases
+ .any((purchase) => purchase is! gentype.PurchaseIOS)) {
+ throw const FormatException(
+ 'Native bridge returned a non-iOS purchase',
+ );
+ }
+ return purchases.cast();
} on PlatformException catch (error) {
throw _purchaseErrorFromPlatformException(
error,
'show manage subscriptions',
);
+ } on FormatException catch (error) {
+ throw PurchaseError(
+ code: gentype.ErrorCode.BillingResponseJsonParseError,
+ message:
+ 'Failed to decode changed subscriptions: ${error.message}',
+ );
} catch (error) {
if (error is PurchaseError) rethrow;
throw PurchaseError(
@@ -1239,15 +1262,25 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
platformIsIOS: _platform.isIOS || _platform.isMacOS,
acknowledgedAndroidPurchaseTokens:
_acknowledgedAndroidPurchaseTokens,
+ rejectMalformed: true,
);
- return purchases.whereType().toList(
- growable: false,
- );
+ if (purchases.any((purchase) => purchase is! gentype.PurchaseIOS)) {
+ throw const FormatException(
+ 'Native bridge returned a non-iOS purchase',
+ );
+ }
+ return purchases.cast();
} on PlatformException catch (error) {
throw _purchaseErrorFromPlatformException(
error,
'fetch pending transactions',
);
+ } on FormatException catch (error) {
+ throw PurchaseError(
+ code: gentype.ErrorCode.BillingResponseJsonParseError,
+ message:
+ 'Failed to decode pending transactions: ${error.message}',
+ );
} catch (error) {
if (error is PurchaseError) rethrow;
throw PurchaseError(
@@ -1275,15 +1308,24 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
platformIsIOS: _platform.isIOS || _platform.isMacOS,
acknowledgedAndroidPurchaseTokens:
_acknowledgedAndroidPurchaseTokens,
+ rejectMalformed: true,
);
- return purchases.whereType().toList(
- growable: false,
- );
+ if (purchases.any((purchase) => purchase is! gentype.PurchaseIOS)) {
+ throw const FormatException(
+ 'Native bridge returned a non-iOS purchase',
+ );
+ }
+ return purchases.cast();
} on PlatformException catch (error) {
throw _purchaseErrorFromPlatformException(
error,
'fetch all transactions',
);
+ } on FormatException catch (error) {
+ throw PurchaseError(
+ code: gentype.ErrorCode.BillingResponseJsonParseError,
+ message: 'Failed to decode all transactions: ${error.message}',
+ );
} catch (error) {
if (error is PurchaseError) rethrow;
throw PurchaseError(
@@ -2298,43 +2340,73 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
List _parseActiveSubscriptions(dynamic result) {
List list;
if (result is String) {
- list = json.decode(result) as List;
+ final dynamic decoded;
+ try {
+ decoded = json.decode(result);
+ } on FormatException catch (error) {
+ // Malformed JSON and a non-list payload are the same class of failure,
+ // so report one code instead of letting FormatException fall through
+ // to the generic catch and surface as ServiceError.
+ throw PurchaseError(
+ code: gentype.ErrorCode.BillingResponseJsonParseError,
+ message: 'Failed to decode native active-subscription response: '
+ '${error.message}',
+ );
+ }
+ if (decoded is! List) {
+ throw PurchaseError(
+ code: gentype.ErrorCode.BillingResponseJsonParseError,
+ message: 'Native active-subscription response was not a list',
+ );
+ }
+ list = decoded;
} else if (result is List) {
list = result;
} else if (result is Map) {
// Some platforms/libs may return a single map; normalize to list
list = [result];
} else {
- try {
- list = json.decode(result.toString()) as List;
- } catch (_) {
- debugPrint(
- '[flutter_inapp_purchase] Unexpected getActiveSubscriptions result type: ${result.runtimeType}',
- );
- list = const [];
- }
+ throw PurchaseError(
+ code: gentype.ErrorCode.BillingResponseJsonParseError,
+ message: 'Unexpected active-subscription response type: '
+ '${result.runtimeType}',
+ );
}
- final subscriptions = [];
- for (final dynamic item in list) {
+ return list.map((dynamic item) {
+ if (item is! Map) {
+ throw PurchaseError(
+ code: gentype.ErrorCode.BillingResponseJsonParseError,
+ message:
+ 'Native active-subscription response contained a non-map item',
+ );
+ }
+ final map = _deepConvertMap(item) as Map;
+ final transactionDate = map['transactionDate'];
+ final renewalInfo = map['renewalInfoIOS'];
+ if (map['productId'] is! String ||
+ (map['productId'] as String).isEmpty ||
+ map['transactionId'] is! String ||
+ (map['transactionId'] as String).isEmpty ||
+ map['isActive'] is! bool ||
+ transactionDate is! num ||
+ !transactionDate.toDouble().isFinite ||
+ renewalInfo != null && renewalInfo is! Map) {
+ throw PurchaseError(
+ code: gentype.ErrorCode.BillingResponseJsonParseError,
+ message:
+ 'Native active-subscription response contained malformed fields',
+ );
+ }
try {
- if (item is! Map) {
- debugPrint(
- '[flutter_inapp_purchase] Skipping subscription with unexpected type: ${item.runtimeType}',
- );
- continue;
- }
- // Recursively convert map to Map
- final map = _deepConvertMap(item) as Map;
- subscriptions.add(gentype.ActiveSubscription.fromJson(map));
+ return gentype.ActiveSubscription.fromJson(map);
} catch (error) {
- debugPrint(
- '[flutter_inapp_purchase] Skipping subscription due to parse error: $error',
+ throw PurchaseError(
+ code: gentype.ErrorCode.BillingResponseJsonParseError,
+ message: 'Failed to decode native active-subscription response',
);
}
- }
-
- return subscriptions;
+ }).toList();
}
/// Get details of all currently active subscriptions.
@@ -2360,7 +2432,10 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
);
if (result == null) {
- return [];
+ throw PurchaseError(
+ code: gentype.ErrorCode.BillingResponseJsonParseError,
+ message: 'Native active-subscription response was null',
+ );
}
return _parseActiveSubscriptions(result);
@@ -2384,27 +2459,21 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
/// See: https://openiap.dev/docs/apis/has-active-subscriptions
gentype.QueryHasActiveSubscriptionsHandler get hasActiveSubscriptions =>
([subscriptionIds]) async {
- try {
- final activeSubscriptions = await getActiveSubscriptions(
- subscriptionIds,
- );
- // For Android, also call native with explicit type for parity/logging
- if (_platform.isAndroid) {
- try {
- await _channel.invokeMethod(
- 'getAvailableItems',
- {'type': 'subs'},
- );
- } catch (_) {
- // Ignore; this is for logging/compatibility only
- }
+ final activeSubscriptions = await getActiveSubscriptions(
+ subscriptionIds,
+ );
+ // For Android, also call native with explicit type for parity/logging
+ if (_platform.isAndroid) {
+ try {
+ await _channel.invokeMethod(
+ 'getAvailableItems',
+ {'type': 'subs'},
+ );
+ } catch (_) {
+ // Ignore; this is for logging/compatibility only
}
- return activeSubscriptions.isNotEmpty;
- } catch (error) {
- // If there's an error getting subscriptions, return false
- debugPrint('Error checking active subscriptions: $error');
- return false;
}
+ return activeSubscriptions.isNotEmpty;
};
// MARK: - Billing Programs API (Android 8.2.0+)
diff --git a/libraries/flutter_inapp_purchase/lib/helpers.dart b/libraries/flutter_inapp_purchase/lib/helpers.dart
index 3465b5b24..b0e9ea450 100644
--- a/libraries/flutter_inapp_purchase/lib/helpers.dart
+++ b/libraries/flutter_inapp_purchase/lib/helpers.dart
@@ -232,7 +232,7 @@ gentype.Purchase convertToPurchase(
if (purchaseId == null || purchaseId.isEmpty) {
debugPrint(
- '[flutter_inapp_purchase] Skipping purchase with missing identifiers',
+ '[flutter_inapp_purchase] Invalid purchase payload: missing identifier',
);
throw const FormatException('Missing purchase identifier');
}
@@ -354,20 +354,36 @@ List extractPurchases(
required bool platformIsAndroid,
required bool platformIsIOS,
required Map acknowledgedAndroidPurchaseTokens,
+ bool rejectMalformed = false,
}) {
List list;
- if (result is String) {
- list = json.decode(result) as List;
- } else if (result is List) {
- list = result;
- } else {
- list = json.decode(result.toString()) as List;
+ try {
+ if (result is String) {
+ list = json.decode(result) as List;
+ } else if (result is List) {
+ list = result;
+ } else {
+ list = json.decode(result.toString()) as List;
+ }
+ } catch (_) {
+ if (rejectMalformed) {
+ throw const FormatException(
+ 'Native bridge returned a malformed purchase list',
+ );
+ }
+ return const [];
}
final purchases = [];
- for (final dynamic product in list) {
+ for (var index = 0; index < list.length; index += 1) {
+ final dynamic product = list[index];
try {
if (product is! Map) {
+ if (rejectMalformed) {
+ throw FormatException(
+ 'Native bridge returned a malformed purchase at index $index',
+ );
+ }
debugPrint(
'[flutter_inapp_purchase] Skipping purchase with unexpected type: ${product.runtimeType}',
);
@@ -377,12 +393,27 @@ List extractPurchases(
// return maps with non-string keys (e.g., Map
+
+ Failure semantics
+
+
+ In SDK APIs that surface failures and in Godot's result-bearing
+ API, an empty purchase list is an authoritative store result: the store
+ query completed and found no purchases. Native transaction verification,
+ serialization, or bridge decoding failures reject the whole query (or
+ return success = false) with an error such as{' '}
+ billing-response-json-parse-error; OpenIAP does not return
+ a partial list.
+
+
+ Godot keeps get_available_purchases() for compatibility, so
+ that array-only method still maps a failure to an empty array. Code that
+ restores purchases, grants entitlements, or clears cached ownership must
+ call get_available_purchases_result() and check{' '}
+ success before using purchases. Never revoke
+ or clear entitlements after a failed query.
+
+
Example
{{
@@ -201,7 +227,13 @@ using OpenIap.Maui;
var purchases = await ((QueryResolver)OpenIapClient.Instance).GetAvailablePurchasesAsync();`}
),
gdscript: (
- {`var purchases = await iap.get_available_purchases()`}
+ {`var result = await iap.get_available_purchases_result()
+if not result.success:
+ push_error("Purchase query failed: %s (%s)" % [result.error, result.code])
+ return
+
+# An empty array here is a confirmed, successful store result.
+var purchases: Array = result.purchases`}
),
}}
diff --git a/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx b/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx
index c05b9f5e4..bc69c5a50 100644
--- a/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx
+++ b/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx
@@ -68,7 +68,12 @@ function HasActiveSubscriptions() {
);`}
),
gdscript: (
- {`func has_active_subscriptions(subscription_ids: Array[String] = []) -> bool`}
+ {`func has_active_subscriptions(subscription_ids: Array[String] = []) -> bool
+
+# Godot failure-aware variant:
+func has_active_subscriptions_result(subscription_ids: Array[String] = []) -> Dictionary
+# { "success": true, "hasActive": bool }
+# { "success": false, "code": String, "error": String }`}
),
}}
@@ -98,6 +103,14 @@ function HasActiveSubscriptions() {
{' '}
when you only need a yes/no answer.
+
+ A store or bridge failure is not false. Promise-based SDKs
+ reject, and their React Native/Expo hooks call onError{' '}
+ before rethrowing. Godot entitlement code must use{' '}
+ has_active_subscriptions_result(); the compatibility
+ boolean helper still maps failure to false and is not safe
+ for granting or revoking access.
+
Example
@@ -144,7 +157,11 @@ using OpenIap.Maui;
var isPremium = await ((QueryResolver)OpenIapClient.Instance).HasActiveSubscriptionsAsync();`}
),
gdscript: (
- {`var is_premium = await iap.has_active_subscriptions()`}
+ {`var result = await iap.has_active_subscriptions_result()
+if not result.success:
+ push_error("Subscription status failed: %s (%s)" % [result.error, result.code])
+ return
+var is_premium: bool = result.hasActive`}
),
}}
diff --git a/packages/docs/src/pages/docs/apis/restore-purchases.tsx b/packages/docs/src/pages/docs/apis/restore-purchases.tsx
index b3759c569..16728dead 100644
--- a/packages/docs/src/pages/docs/apis/restore-purchases.tsx
+++ b/packages/docs/src/pages/docs/apis/restore-purchases.tsx
@@ -71,7 +71,7 @@ function RestorePurchases() {
{`Task RestorePurchasesAsync();`}
),
gdscript: (
- {`func restore_purchases() -> void`}
+ {`func restore_purchases() -> Types.VoidResult`}
),
}}
@@ -85,7 +85,9 @@ function RestorePurchases() {
purchaseUpdatedListener / surface as{' '}
getAvailablePurchases results, depending on platform. In
MAUI/C#, RestorePurchasesAsync returns{' '}
- Task<VoidResult>.
+ Task<VoidResult>. Godot returns a{' '}
+ VoidResult; check success because a failed
+ store query must not be treated as a successful restore.