fix: preserve purchase query failures across sdks - #276
Conversation
Entitlement queries could not distinguish "the user owns nothing" from
"the lookup failed". Every SDK collapsed failures into an empty list using
its own idiom, so a transient store or bridge error looked identical to an
authoritative empty result. Apps that revoke entitlements on an empty list
could therefore strip content from paying users.
Three failure shapes existed:
- empty list on failure (`return []`, `resumeIfActive(emptyList())`,
`(purchases ?? [])`, `catch { ... continue }`)
- partial success, the most dangerous, where `mapNotNull` or an
`is Dictionary` guard silently dropped unparseable entries and returned a
plausible but incomplete list
- silence, where a terminal failure returned null and emitted no event
packages/apple and packages/google amplified this: both are consumed by all
six framework SDKs, so a single swallow-and-continue propagated everywhere.
Apple additionally emitted purchase updates inside the verification loop, so
a mid-loop failure leaked partial state through events that could not be
retracted.
Queries are now atomic: either every entry decodes or the call fails with a
typed error. Apple publishes only after the whole sequence verifies. Godot
gains failure-aware `*_result` APIs while the array-returning methods stay
for compatibility, and terminal purchase failures always emit purchase_error.
Android publishes each purchase error exactly once via an AtomicBoolean gate.
Required fields match the GraphQL schema, where id, productId,
transactionDate, store, quantity, purchaseState and isAutoRenewing are all
non-null; a bridge omitting them is the defect, so the decoders are not
relaxed. audit-purchase-payload-parity enforces the contract in CI so a
single SDK cannot regress to the lossy idioms.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The eight files under .claude/commands/ had no YAML frontmatter, so they surfaced with only their H1 heading as a description and could not be matched reliably from natural language. Skills under .claude/skills/ already carry name and description, which is why they triggered and the commands did not. Each command now declares a name and a description that states when to use it, matching the existing skill wording. The codex side is unchanged: the openiap-workflows router already maps all eight command files, and duplicating them under .codex/skills/ would split the workflow SSOT. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running the FireOS rows end to end showed two install paths in the e2e command did not match where the builds actually land. The Expo FireOS block pointed at `../build/app/outputs/apk/debug/`, which resolves to `example/build/...` and does not exist. Its own normal-Android block six lines earlier already used the correct `app/build/outputs/apk/debug/` from the same `example/android` working directory, so the file contradicted itself. The Flutter FireOS block had the opposite problem: it used the `android/app/build/...` layout the other examples use, but Flutter redirects gradle output to `example/build/app/outputs/flutter-apk/`. Both paths are now what the builds produce, verified by installing from them on the FireOS device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (9)
🚧 Files skipped from review as they are similar to previous changes (8)
📝 WalkthroughWalkthroughThe pull request standardizes purchase failure propagation across IAP libraries. It adds strict payload validation, throwing serialization, structured Godot results, cancellation handling, restore failure propagation, duplicate-error suppression, parity audits, tests, and API documentation updates. ChangesPurchase validation and serialization
Godot structured result flow
Validation and parity coverage
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Caller
participant IAP_API
participant NativeBridge
participant Decoder
Caller->>IAP_API: Request purchases or restore
IAP_API->>NativeBridge: Query store
NativeBridge-->>IAP_API: Payload or failure envelope
IAP_API->>Decoder: Validate and decode payload
Decoder-->>IAP_API: Purchases or parse error
IAP_API-->>Caller: Success result or propagated error
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
libraries/godot-iap/Example/iap_manager.gd (1)
470-475: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winUse the existing accessor helpers for the result purchases.
_clear_pending_purchases()reads purchases from the sameget_available_purchases_result()payload, but it normalizes each entry with_purchase_to_dict()and_purchase_product_id()(Lines 91-93). This loop instead accessespurchase.product_iddirectly. If any entry arrives as a Dictionary, the property access fails at runtime and the premium check crashes. Reuse the helpers so both paths accept both shapes.🐛 Proposed fix
var purchases: Array = available_result.get("purchases", []) for purchase in purchases: - # Access typed property directly - if purchase.product_id == PRODUCT_PREMIUM: + var purchase_dict := _purchase_to_dict(purchase) + if _purchase_product_id(purchase, purchase_dict) == PRODUCT_PREMIUM: return true return false🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libraries/godot-iap/Example/iap_manager.gd` around lines 470 - 475, Update the premium purchase check to normalize each entry with _purchase_to_dict() and retrieve its product identifier through _purchase_product_id(), matching _clear_pending_purchases() and supporting both Dictionary and typed purchase shapes. Replace the direct purchase.product_id access while preserving the existing PRODUCT_PREMIUM comparison and return behavior.libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs (1)
204-209: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
GetActiveSubscriptionsAsyncstill uses the lenient decoder; it should use the strict decoder likeGetAvailablePurchasesAsyncand iOS's counterpart.
GetActiveSubscriptionsAsynccallsDecodeItems<ActiveSubscription>(result)(Line 276-281), which returns an empty list on a missing payload and silently drops any item that fails to deserialize. This PR updatesGetAvailablePurchasesAsyncin the same file, andGetActiveSubscriptionsAsyncinOpenIapIOS.cs, to the strictBridgePayloadDecoderpath that throwsBillingResponseJsonParseErroron malformed or missing data. Leaving Android'sGetActiveSubscriptionsAsyncon the old path means a malformed subscription entry from the native bridge silently disappears from the result instead of failing the call, so a caller can observe an empty or partial active-subscription list without any error, exactly the failure mode this PR sets out to fix.Use
BridgePayloadDecoder.DecodeRequiredItems<ActiveSubscription>here to match the iOS implementation andGetAvailablePurchasesAsync.🐛 Proposed fix for Android active-subscription decoding parity
public async Task<IReadOnlyList<ActiveSubscription>> GetActiveSubscriptionsAsync(IReadOnlyList<string>? subscriptionIds = null) { var json = subscriptionIds is null ? null : JsonSerializer.Serialize(subscriptionIds, JsonOptions.Default); var result = await Invoke(cb => _module.GetActiveSubscriptions(json, cb)); - return DecodeItems<ActiveSubscription>(result); + return BridgePayloadDecoder.DecodeRequiredItems<ActiveSubscription>(result, "getActiveSubscriptions"); }As per path instructions, "Platform purchase implementations must complete the SDK parity layers: expose the API, bridge it to the native platform, wire concrete resolver handlers, and preserve the platform-specific implementation."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs` around lines 204 - 209, Update GetActiveSubscriptionsAsync to replace DecodeItems<ActiveSubscription>(result) with the strict BridgePayloadDecoder.DecodeRequiredItems<ActiveSubscription> path, matching the iOS implementation and GetAvailablePurchasesAsync so missing or malformed bridge payloads throw BillingResponseJsonParseError instead of returning partial or empty results.Source: Path instructions
libraries/react-native-iap/src/hooks/useIAP.ts (1)
494-505: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
hasActiveSubscriptionsInternal's rethrow is unreachable because the root API swallows errors.
hasActiveSubscriptionsInternalnow callsinvokeOnError(error)and rethrows on failure. This hook delegates to the root-levelhasActiveSubscriptionsinindex.ts(lines 2523-2534), which catches every error internally and always resolves tofalse:} catch (error) { // If there's an error getting subscriptions, return false RnIapConsole.warn('Error checking active subscriptions:', error); return false; }Because of this,
await hasActiveSubscriptions(subscriptionIds)at line 497 never rejects in production. The catch block here is dead code, and the new test athooks/useIAP.test.tslines 337-347 only exercises this path because it mockshasActiveSubscriptionsdirectly, bypassing the real swallow behavior.More importantly, this means a native decode failure inside
getActiveSubscriptions(which this PR hardens to throw atomically) is reported to callers asfalse("no active subscription") instead of as a typed error. This contradicts the PR's goal of atomic, typed failure propagation and risks incorrectly denying entitlements after a transient parse error.Update the root-level
hasActiveSubscriptionsinindex.tsto propagate the error instead of swallowing it:export const hasActiveSubscriptions: QueryField< 'hasActiveSubscriptions' > = async (subscriptionIds) => { const activeSubscriptions = await getActiveSubscriptions(subscriptionIds); return activeSubscriptions.length > 0; };Do you want me to open an issue to track this?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libraries/react-native-iap/src/hooks/useIAP.ts` around lines 494 - 505, Update the root-level hasActiveSubscriptions implementation in index.ts to let getActiveSubscriptions errors propagate instead of catching them and returning false. Preserve the existing activeSubscriptions.length > 0 result for successful calls so hasActiveSubscriptionsInternal can invokeOnError and rethrow typed failures.
🧹 Nitpick comments (14)
packages/docs/src/pages/docs/apis/restore-purchases.tsx (1)
176-179: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winLog the failure code and message, like the other examples.
The other three pages print
result.errorandresult.code. This example discards both. Align it so developers learn the same diagnostic pattern.♻️ Proposed docs example change
- <CodeBlock language="gdscript">{`var result = await iap.restore_purchases() -if not result.success: - push_error("Purchase restore failed") - return`}</CodeBlock> + <CodeBlock language="gdscript">{`var result = await iap.restore_purchases() +if not result.success: + push_error("Purchase restore failed: %s (%s)" % [result.error, result.code]) + return`}</CodeBlock>Confirm that the Godot
VoidResultfailure envelope carrieserrorandcodebefore applying this change.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/docs/src/pages/docs/apis/restore-purchases.tsx` around lines 176 - 179, Update the Godot restore-purchases example around the `result.success` check to log both `result.error` and `result.code` before returning on failure, matching the diagnostic pattern used by the other examples. Confirm that the Godot `VoidResult` failure envelope exposes these fields and preserve the existing success flow.scripts/audit-purchase-payload-parity.mjs (2)
1323-1339: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winFormatting-encoded needles make these audits fragile. Both sites embed exact source indentation inside literal needles, so a formatter run on the audited file reports a missing contract instead of a formatting change.
scripts/audit-purchase-payload-parity.mjs#L1323-L1339: replace the 20-space continuation needle at line 1327 and the\n\t\tneedle at line 1334 with whitespace-tolerantexpectMatchpatterns, and pin the executable statement instead of the source comment at line 1326.scripts/audit-purchase-payload-parity.mjs#L2127-L2134: replace the 12-space needle for theTransaction.currentEntitlements/try checkVerified(verification)pair with anexpectMatchpattern that allows any whitespace between the two statements.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/audit-purchase-payload-parity.mjs` around lines 1323 - 1339, Update the expectIncludes audits in scripts/audit-purchase-payload-parity.mjs at lines 1323-1339 and 2127-2134: use whitespace-tolerant expectMatch patterns instead of indentation-dependent literal needles, pin the executable iOS purchase-error statement rather than its comment, and allow arbitrary whitespace between Transaction.currentEntitlements and try checkVerified(verification).
2179-2203: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winStrengthen the MAUI and Godot Android needles.
Two assertions are too generic to prove the contract in their labels:
- Line 2182 matches only
operation: "getActiveSubscriptions". The label claims it verifies active-subscription list decoding. The string appears whenever the operation name is passed anywhere inOpenIapIOS.cs, including a log or an unrelated call. Pin the decoder call and the store or failure check instead.- Lines 2199-2200 match
put("success", false)andput("code", code).GodotIap.ktbuilds many envelopes, so these match regardless of whethergetActiveSubscriptionsResultproduces the failure envelope. Scope the assertion to that function body, for example withextractBalancedAfterMarkeronfun getActiveSubscriptionsResult(.An assertion that cannot fail implies coverage that does not exist.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/audit-purchase-payload-parity.mjs` around lines 2179 - 2203, Strengthen the assertions in the audit script: update the MAUI `expectIncludes` needles to require the active-subscription decoder call together with the relevant store or failure check, not just the operation string. For the Godot Android `getActiveSubscriptionsResult` assertion, use `extractBalancedAfterMarker` anchored to that method and check `put("success", false)` and `put("code", code)` within the extracted body so unrelated envelopes cannot satisfy it.scripts/audit-non-godot-parity.mjs (1)
1521-1525: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUnanchored
[\s\S]*?regexes can match across unrelated functions. Both sites chain lazy wildcards between markers that live in large files, so a match can begin in one function and end in another. The audit then passes even when the intended call site loses the behavior, and it fails misleadingly when unrelated declarations move.
scripts/audit-non-godot-parity.mjs#L1521-L1525: scope the cancellation-rethrow assertion to therequestPurchasefunction body withextractBalancedAfterMarkerbefore matchingcatch (e: CancellationException).scripts/audit-non-godot-parity.mjs#L2243-L2324: stop encoding declaration order acrossgetAvailablePurchasesInternal,getActiveSubscriptionsInternal, andhasActiveSubscriptionsInternal. Extract each function body first, then assertinvokeOnError(error)andthrow errorinside that body.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/audit-non-godot-parity.mjs` around lines 1521 - 1525, Scope both audit checks to individual function bodies instead of relying on unanchored cross-file wildcards. In scripts/audit-non-godot-parity.mjs at lines 1521-1525, use extractBalancedAfterMarker on requestPurchase before asserting CancellationException rethrow behavior; at lines 2243-2324, extract each of getAvailablePurchasesInternal, getActiveSubscriptionsInternal, and hasActiveSubscriptionsInternal separately, then assert invokeOnError(error) and throw error within the corresponding body without enforcing declaration order.libraries/godot-iap/Example/iap_manager.gd (1)
457-459: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd a runtime test for
is_premium_purchased()before using it as public API.The method now changes the await contract, but no current caller references it. If this method remains on a public/example API surface, add coverage that non-awaited calls are unsafe and that
nullfailure results preserve existing entitlement state.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libraries/godot-iap/Example/iap_manager.gd` around lines 457 - 459, Add runtime coverage for the public is_premium_purchased() method verifying its await-based contract, including that non-awaited use is unsafe and that a null query-failure result leaves the existing premium entitlement unchanged. Keep the test focused on this method’s behavior and use the existing entitlement/query test fixtures.libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt (1)
554-559: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMerge the two identical failure builders.
availablePurchasesFailureandactiveSubscriptionsFailurehave identical bodies. Replace both with one private helper.♻️ Suggested change
- private fun availablePurchasesFailure(code: String, message: String): String = - JSONObject().apply { - put("success", false) - put("code", code) - put("error", message) - }.toString() + private fun structuredFailure(code: String, message: String): String = + JSONObject().apply { + put("success", false) + put("code", code) + put("error", message) + }.toString()Also applies to: 675-680
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt` around lines 554 - 559, Replace the duplicate availablePurchasesFailure and activeSubscriptionsFailure implementations with a single private failure-builder helper, preserving the existing JSON fields and return format. Update both call sites to use the shared helper with their respective code and message arguments.libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift (2)
684-686: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winThe new serialization failures lose their error code in these three methods.
encodeRequiredthrows aPurchaseErrorwith codebillingResponseJsonParseError. The catch blocks forgetPendingTransactionsIOS,getAllTransactionsIOS, andshowManageSubscriptionsIOSbuild the failure dictionary inline and set onlyerror, notcode. The structured code thatgetAvailablePurchasesandgetActiveSubscriptionsnow preserve is dropped here. Route these three catch blocks throughemitAsyncFailurewitherror.code.rawValue, matching the pattern at lines 451-458.Also applies to: 733-735, 821-823
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift` around lines 684 - 686, The catch blocks in getPendingTransactionsIOS, getAllTransactionsIOS, and showManageSubscriptionsIOS currently omit the PurchaseError code for serialization failures. Route each failure through emitAsyncFailure, passing the caught error message and error.code.rawValue, matching the existing pattern used by getAvailablePurchases and getActiveSubscriptions.
1661-1676: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winPass explicit codes from the argument-validation call sites.
emitAsyncFailurenow stamps every failure with a code, and the default isErrorCode.serviceError. Several call sites report argument-validation problems:fetchProductsfor"Invalid arguments"and for theparseProductQueryTypefailure, andfinishTransactionfor"Invalid arguments". Those now surface asservice-errorto GDScript. PassErrorCode.developerError.rawValueat those sites so the app can distinguish a caller mistake from a store outage.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift` around lines 1661 - 1676, Update the argument-validation failure call sites in fetchProducts and finishTransaction to pass ErrorCode.developerError.rawValue to emitAsyncFailure, including the “Invalid arguments” paths and the parseProductQueryType failure; leave service-related failures using the default service error code.libraries/godot-iap/addons/godot-iap/godot_iap.gd (2)
205-213: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRemove the unreachable terminal recheck.
Line 206 returns when
cache_keyis terminal. The condition on line 212 therefore always evaluates totrue. Use a plainelse.♻️ Suggested simplification
if _ios_async_waiters.has(cache_key): var waiter = _ios_async_waiters[cache_key] if waiter is IosAsyncWaiter: waiter.complete(result) - elif not _ios_async_terminal_keys.has(cache_key): + else: _cache_ios_async_result(cache_key, result)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libraries/godot-iap/addons/godot-iap/godot_iap.gd` around lines 205 - 213, In the async result handling block around _ios_async_result_key, remove the redundant terminal-key condition from the fallback branch. Since the earlier _ios_async_terminal_keys check already returns, replace the `elif not _ios_async_terminal_keys.has(cache_key)` branch with a plain else while preserving _cache_ios_async_result behavior.
1012-1012: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueExtract the duplicated subscription-id JSON encoding.
Both lines build the same platform-dependent
ids_jsonvalue. Extract a small helper so the two call sites cannot diverge.♻️ Suggested helper
+func _subscription_ids_json(subscription_ids: Array) -> Variant: + if subscription_ids.size() > 0: + return JSON.stringify(subscription_ids) + return "" if _platform == "iOS" else nullAlso applies to: 1103-1103
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libraries/godot-iap/addons/godot-iap/godot_iap.gd` at line 1012, Extract the platform-dependent subscription ID JSON construction into a shared helper, then replace both ids_json assignments around the subscription flows with calls to it. Preserve the existing behavior: stringify non-empty subscription_ids, and return an empty string on iOS or null on other platforms when empty.libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift (1)
67-93: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDeduplicate the guard, and track removal of this shim.
Two points.
First, both helpers repeat the same guard and throw.
purchasesRequiredcan reuse a single throwing check.Second,
packages/apple/Tests/OpenIapTests.swift(lines 15-26) shows thatOpenIapSerializationitself now providesencodeRequiredandpurchasesRequiredwith the samebillingResponseJsonParseErrorcontract. The same shim also exists in the Expo helper. Once the published native OpenIAP package includes the throwing helpers, remove this local copy so the two implementations cannot diverge.♻️ Suggested deduplication
+ private static func requireNonEmpty(_ encoded: [String: Any]) throws -> [String: Any] { + guard !encoded.isEmpty else { + throw PurchaseError.make( + code: .billingResponseJsonParseError, + message: "Failed to serialize native purchase payload" + ) + } + return encoded + } + static func encodeRequired<T: Encodable>(_ value: T) throws -> [String: Any] { - let encoded = OpenIapSerialization.encode(value) - guard !encoded.isEmpty else { - throw PurchaseError.make( - code: .billingResponseJsonParseError, - message: "Failed to serialize native purchase payload" - ) - } - return encoded + try requireNonEmpty(OpenIapSerialization.encode(value)) } 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 - } + try purchases.map { try requireNonEmpty(OpenIapSerialization.purchase($0)) } }Do you want me to open an issue to track removal of the local shims once the published package exposes the throwing helpers?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift` around lines 67 - 93, Update purchasesRequired to reuse the shared throwing serialization check instead of duplicating its empty-result guard and PurchaseError construction, and make encodeRequired delegate to OpenIapSerialization.encodeRequired where available. Add a removal-tracking issue or TODO for these local shims, including the corresponding Expo helper, so they are deleted once the published native OpenIAP package exposes the throwing helpers.libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt (1)
31-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a positive assertion that a complete purchase list decodes.
The new tests cover rejection paths and the empty list. No test asserts that
decodePurchaseListPayloadIOSreturns a decoded purchase forvalidPurchase(). Such a test pins the required-field set and fails fast if a later guard becomes too strict.💚 Proposed test addition
`@Test` fun `strict purchase list preserves explicit empty result`() { assertEquals(emptyList(), decodePurchaseListPayloadIOS(emptyList<Any?>())) } + + `@Test` + fun `strict purchase list decodes a complete native payload`() { + val purchases = decodePurchaseListPayloadIOS(listOf(validPurchase())) + + assertEquals(listOf("transaction-1"), purchases.map { it.id }) + assertEquals(listOf("premium.monthly"), purchases.map { it.productId }) + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt` around lines 31 - 34, Add a positive test alongside `strict purchase list preserves explicit empty result` that passes `validPurchase()` through `decodePurchaseListPayloadIOS` and asserts the expected decoded purchase list, covering successful decoding and the required fields.libraries/expo-iap/src/utils/availablePurchases.ts (1)
80-105: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRename to match the platform-suffix naming convention.
decodeApplePurchasesanddecodeAndroidPurchasesare single-platform functions. The path guideline requires anIOSorAndroidsuffix at the end of the name for single-platform functions, matching the existing convention elsewhere in this file set (for exampleisProductAndroid,getPendingTransactionsIOS).decodeApplePurchasesuses "Apple" instead of "IOS", anddecodeAndroidPurchasesplaces "Android" before "Purchases" instead of at the end.Rename to
decodePurchasesIOSanddecodePurchasesAndroid(and update the corresponding imports inindex.tsandmodules/ios.ts).As per path instructions,
libraries/expo-iap/**/*.{ts,tsx,js,jsx}: "Functions that operate on only one platform must use anIOSorAndroidsuffix; cross-platform helpers must use one shared name and branch internally throughPlatform.selector an equivalent mechanism."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libraries/expo-iap/src/utils/availablePurchases.ts` around lines 80 - 105, Rename the single-platform helpers `decodeApplePurchases` and `decodeAndroidPurchases` to `decodePurchasesIOS` and `decodePurchasesAndroid`, respectively, preserving their validation behavior. Update all corresponding imports and references in `index.ts` and `modules/ios.ts` to use the new platform-suffix names.Source: Path instructions
libraries/expo-iap/src/utils/restorePurchases.ts (1)
14-14: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider a typed interface instead of
as any.
ExpoIapModule as anybypasses type checking forUSING_ONSIDE_SDKandrestorePurchases. Define an interface with these fields as optional properties on the native module type instead, so a typo in either name is caught at compile time.As per path instructions,
libraries/expo-iap/**/*.{ts,tsx}: "Use type annotations and maintain type safety in TypeScript projects."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libraries/expo-iap/src/utils/restorePurchases.ts` at line 14, Replace the any-cast in the nativeModule declaration with a typed interface describing optional USING_ONSIDE_SDK and restorePurchases properties, and apply that interface to ExpoIapModule. Update the surrounding restore-purchases logic to use the typed native module so both property names remain compile-time checked.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@libraries/expo-iap/ios/ExpoIapHelper.swift`:
- Around line 70-79: Update encodeRequired<T: Encodable> to include the generic
type in its serialization failure message, matching the canonical
OpenIapSerialization pattern: identify the payload using T.self instead of
always saying “purchase.”
In `@libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart`:
- Around line 2343-2350: Update the JSON parsing in _parseActiveSubscriptions so
FormatException thrown by json.decode(result) is caught and converted to
PurchaseError with gentype.ErrorCode.BillingResponseJsonParseError, matching the
existing non-list validation; preserve the current handling for valid list
responses.
In `@libraries/flutter_inapp_purchase/lib/helpers.dart`:
- Around line 442-483: Add positive getAvailablePurchases tests for both Android
and iOS using complete purchase payloads that include quantity, isAutoRenewing,
purchaseState, and finite transactionDate, along with each platform’s required
fields. Assert the calls succeed and return the expected purchases,
complementing the existing incomplete-payload rejection coverage.
In `@libraries/godot-iap/addons/godot-iap/godot_iap.gd`:
- Around line 729-748: Update the iOS branch in the restore-purchases method to
call _purchase_failure when the restore payload reports failure, using its code
and error fields with the same defaults as the non-iOS path. Preserve returning
the Types.VoidResult with the payload’s success value after reporting the
failure.
- Around line 19-24: Update the IosAsyncWaiter class declaration to place the
RefCounted inheritance on the same header line as the class name, preserving the
existing signal, state, and timer members.
In `@packages/docs/src/pages/docs/apis/restore-purchases.tsx`:
- Line 74: Update the documented GDScript signature in the restore_purchases()
CodeBlock to use Types.VoidResult instead of bare VoidResult, matching the
wrapper annotation and OpenIAP type conventions.
---
Outside diff comments:
In `@libraries/godot-iap/Example/iap_manager.gd`:
- Around line 470-475: Update the premium purchase check to normalize each entry
with _purchase_to_dict() and retrieve its product identifier through
_purchase_product_id(), matching _clear_pending_purchases() and supporting both
Dictionary and typed purchase shapes. Replace the direct purchase.product_id
access while preserving the existing PRODUCT_PREMIUM comparison and return
behavior.
In
`@libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs`:
- Around line 204-209: Update GetActiveSubscriptionsAsync to replace
DecodeItems<ActiveSubscription>(result) with the strict
BridgePayloadDecoder.DecodeRequiredItems<ActiveSubscription> path, matching the
iOS implementation and GetAvailablePurchasesAsync so missing or malformed bridge
payloads throw BillingResponseJsonParseError instead of returning partial or
empty results.
In `@libraries/react-native-iap/src/hooks/useIAP.ts`:
- Around line 494-505: Update the root-level hasActiveSubscriptions
implementation in index.ts to let getActiveSubscriptions errors propagate
instead of catching them and returning false. Preserve the existing
activeSubscriptions.length > 0 result for successful calls so
hasActiveSubscriptionsInternal can invokeOnError and rethrow typed failures.
---
Nitpick comments:
In `@libraries/expo-iap/src/utils/availablePurchases.ts`:
- Around line 80-105: Rename the single-platform helpers `decodeApplePurchases`
and `decodeAndroidPurchases` to `decodePurchasesIOS` and
`decodePurchasesAndroid`, respectively, preserving their validation behavior.
Update all corresponding imports and references in `index.ts` and
`modules/ios.ts` to use the new platform-suffix names.
In `@libraries/expo-iap/src/utils/restorePurchases.ts`:
- Line 14: Replace the any-cast in the nativeModule declaration with a typed
interface describing optional USING_ONSIDE_SDK and restorePurchases properties,
and apply that interface to ExpoIapModule. Update the surrounding
restore-purchases logic to use the typed native module so both property names
remain compile-time checked.
In `@libraries/godot-iap/addons/godot-iap/godot_iap.gd`:
- Around line 205-213: In the async result handling block around
_ios_async_result_key, remove the redundant terminal-key condition from the
fallback branch. Since the earlier _ios_async_terminal_keys check already
returns, replace the `elif not _ios_async_terminal_keys.has(cache_key)` branch
with a plain else while preserving _cache_ios_async_result behavior.
- Line 1012: Extract the platform-dependent subscription ID JSON construction
into a shared helper, then replace both ids_json assignments around the
subscription flows with calls to it. Preserve the existing behavior: stringify
non-empty subscription_ids, and return an empty string on iOS or null on other
platforms when empty.
In `@libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt`:
- Around line 554-559: Replace the duplicate availablePurchasesFailure and
activeSubscriptionsFailure implementations with a single private failure-builder
helper, preserving the existing JSON fields and return format. Update both call
sites to use the shared helper with their respective code and message arguments.
In `@libraries/godot-iap/Example/iap_manager.gd`:
- Around line 457-459: Add runtime coverage for the public
is_premium_purchased() method verifying its await-based contract, including that
non-awaited use is unsafe and that a null query-failure result leaves the
existing premium entitlement unchanged. Keep the test focused on this method’s
behavior and use the existing entitlement/query test fixtures.
In `@libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift`:
- Around line 684-686: The catch blocks in getPendingTransactionsIOS,
getAllTransactionsIOS, and showManageSubscriptionsIOS currently omit the
PurchaseError code for serialization failures. Route each failure through
emitAsyncFailure, passing the caught error message and error.code.rawValue,
matching the existing pattern used by getAvailablePurchases and
getActiveSubscriptions.
- Around line 1661-1676: Update the argument-validation failure call sites in
fetchProducts and finishTransaction to pass ErrorCode.developerError.rawValue to
emitAsyncFailure, including the “Invalid arguments” paths and the
parseProductQueryType failure; leave service-related failures using the default
service error code.
In `@libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift`:
- Around line 67-93: Update purchasesRequired to reuse the shared throwing
serialization check instead of duplicating its empty-result guard and
PurchaseError construction, and make encodeRequired delegate to
OpenIapSerialization.encodeRequired where available. Add a removal-tracking
issue or TODO for these local shims, including the corresponding Expo helper, so
they are deleted once the published native OpenIAP package exposes the throwing
helpers.
In
`@libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt`:
- Around line 31-34: Add a positive test alongside `strict purchase list
preserves explicit empty result` that passes `validPurchase()` through
`decodePurchaseListPayloadIOS` and asserts the expected decoded purchase list,
covering successful decoding and the required fields.
In `@packages/docs/src/pages/docs/apis/restore-purchases.tsx`:
- Around line 176-179: Update the Godot restore-purchases example around the
`result.success` check to log both `result.error` and `result.code` before
returning on failure, matching the diagnostic pattern used by the other
examples. Confirm that the Godot `VoidResult` failure envelope exposes these
fields and preserve the existing success flow.
In `@scripts/audit-non-godot-parity.mjs`:
- Around line 1521-1525: Scope both audit checks to individual function bodies
instead of relying on unanchored cross-file wildcards. In
scripts/audit-non-godot-parity.mjs at lines 1521-1525, use
extractBalancedAfterMarker on requestPurchase before asserting
CancellationException rethrow behavior; at lines 2243-2324, extract each of
getAvailablePurchasesInternal, getActiveSubscriptionsInternal, and
hasActiveSubscriptionsInternal separately, then assert invokeOnError(error) and
throw error within the corresponding body without enforcing declaration order.
In `@scripts/audit-purchase-payload-parity.mjs`:
- Around line 1323-1339: Update the expectIncludes audits in
scripts/audit-purchase-payload-parity.mjs at lines 1323-1339 and 2127-2134: use
whitespace-tolerant expectMatch patterns instead of indentation-dependent
literal needles, pin the executable iOS purchase-error statement rather than its
comment, and allow arbitrary whitespace between Transaction.currentEntitlements
and try checkVerified(verification).
- Around line 2179-2203: Strengthen the assertions in the audit script: update
the MAUI `expectIncludes` needles to require the active-subscription decoder
call together with the relevant store or failure check, not just the operation
string. For the Godot Android `getActiveSubscriptionsResult` assertion, use
`extractBalancedAfterMarker` anchored to that method and check `put("success",
false)` and `put("code", code)` within the extracted body so unrelated envelopes
cannot satisfy it.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 86854c4a-da1d-4df0-a508-81b6d7540d8f
📒 Files selected for processing (78)
.claude/commands/audit-code.md.claude/commands/commit.md.claude/commands/compile-knowledge.md.claude/commands/e2e-tests.md.claude/commands/release.md.claude/commands/resolve-issue.md.claude/commands/review-pr.md.claude/commands/verify-all.mdlibraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.ktlibraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.ktlibraries/expo-iap/ios/ExpoIapHelper.swiftlibraries/expo-iap/ios/ExpoIapModule.swiftlibraries/expo-iap/src/__tests__/index.kepler.test.tslibraries/expo-iap/src/__tests__/index.test.tslibraries/expo-iap/src/__tests__/useIAP.test.tsxlibraries/expo-iap/src/__tests__/vega-adapter.test.tslibraries/expo-iap/src/index.kepler.tslibraries/expo-iap/src/index.tslibraries/expo-iap/src/modules/__tests__/ios.test.tslibraries/expo-iap/src/modules/ios.tslibraries/expo-iap/src/useIAP.tslibraries/expo-iap/src/utils/availablePurchases.tslibraries/expo-iap/src/utils/restorePurchases.tslibraries/expo-iap/src/vega-adapter.tslibraries/flutter_inapp_purchase/analysis_options.yamllibraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swiftlibraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swiftlibraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dartlibraries/flutter_inapp_purchase/lib/helpers.dartlibraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swiftlibraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swiftlibraries/flutter_inapp_purchase/test/flutter_inapp_purchase_active_subscriptions_test.dartlibraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dartlibraries/flutter_inapp_purchase/test/ios_methods_test.dartlibraries/godot-iap/EXAMPLES.mdlibraries/godot-iap/Example/iap_manager.gdlibraries/godot-iap/Example/tests/test_envelope_parsing.gdlibraries/godot-iap/Example/tests/test_godot_iap.gdlibraries/godot-iap/addons/godot-iap/android/GodotIap.release.aarlibraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/GodotIaplibraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntimelibraries/godot-iap/addons/godot-iap/godot_iap.gdlibraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.ktlibraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swiftlibraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swiftlibraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.ktlibraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.ktlibraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.ktlibraries/maui-iap/src/OpenIap.Maui/BridgePayloadDecoder.cslibraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cslibraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cslibraries/maui-iap/tests/OpenIap.Maui.Tests/BridgePayloadDecoderTests.cslibraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.ktlibraries/react-native-iap/ios/HybridRnIap.swiftlibraries/react-native-iap/ios/RnIapHelper.swiftlibraries/react-native-iap/src/__tests__/hooks/useIAP.test.tslibraries/react-native-iap/src/__tests__/index.kepler.test.tslibraries/react-native-iap/src/__tests__/index.test.tslibraries/react-native-iap/src/__tests__/utils/type-bridge.test.tslibraries/react-native-iap/src/__tests__/vega-adapter.test.tslibraries/react-native-iap/src/hooks/useIAP.tslibraries/react-native-iap/src/index.kepler.tslibraries/react-native-iap/src/index.tslibraries/react-native-iap/src/utils/available-purchases.tslibraries/react-native-iap/src/utils/type-bridge.tslibraries/react-native-iap/src/vega-adapter.tspackages/apple/Sources/Models/OpenIapSerialization.swiftpackages/apple/Sources/OpenIapModule+ObjC.swiftpackages/apple/Sources/OpenIapModule.swiftpackages/apple/Tests/OpenIapTests.swiftpackages/docs/src/pages/docs/apis/get-active-subscriptions.tsxpackages/docs/src/pages/docs/apis/get-available-purchases.tsxpackages/docs/src/pages/docs/apis/has-active-subscriptions.tsxpackages/docs/src/pages/docs/apis/restore-purchases.tsxpackages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.ktpackages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.ktscripts/audit-non-godot-parity.mjsscripts/audit-purchase-payload-parity.mjs
`HybridRnIap.swift` called `RnIapHelper.encodeRequired` for the strict active-subscription path, but the helper was never defined, so the iOS example failed to build with "type 'RnIapHelper' has no member 'encodeRequired'". Expo, Flutter and Godot helpers all defined it; only React Native was missing. The payload-parity audit did not catch this because it asserted the bridge *calls* the helper and that the helper file contains some strict-serialization markers, but never that the called function is *defined*. It now requires both `static func encodeRequired` and `static func purchasesRequired` in every helper, so a call site can no longer reference a helper that does not exist. Verified by removing the helper again and confirming the audit exits non-zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
libraries/react-native-iap/ios/RnIapHelper.swift (1)
82-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCentralize the encoded-payload validation.
purchasesRequiredduplicates the empty-dictionary check and error construction fromencodeRequired. Keep one validation path so the error code and message cannot diverge.If
OpenIapSerialization.purchaseis purchase-specific, extract a sharedrequireEncoded(_:)helper and use it from both functions.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libraries/react-native-iap/ios/RnIapHelper.swift` around lines 82 - 96, Centralize the empty-encoded-payload validation currently duplicated in purchasesRequired and encodeRequired by extracting a shared requireEncoded(_:) helper, preserving the existing PurchaseError code and message. Update both functions to use this helper, while keeping purchase-specific serialization in OpenIapSerialization.purchase.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@libraries/react-native-iap/ios/RnIapHelper.swift`:
- Around line 82-96: Centralize the empty-encoded-payload validation currently
duplicated in purchasesRequired and encodeRequired by extracting a shared
requireEncoded(_:) helper, preserving the existing PurchaseError code and
message. Update both functions to use this helper, while keeping
purchase-specific serialization in OpenIapSerialization.purchase.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 3ff85927-db77-4449-b958-bfcd9dde8705
📒 Files selected for processing (2)
libraries/react-native-iap/ios/RnIapHelper.swiftscripts/audit-purchase-payload-parity.mjs
🚧 Files skipped from review as they are similar to previous changes (1)
- scripts/audit-purchase-payload-parity.mjs
- godot: emit purchase_error on an iOS restore failure. The non-iOS path already reported failures through _purchase_failure, so a caller listening only to the signal saw Android restore failures but never iOS ones. - flutter: map a FormatException from json.decode to BillingResponseJsonParseError. Malformed JSON previously escaped to the generic catch and surfaced as ServiceError while a non-list payload reported a parse error, splitting one failure class across two codes. - flutter: add positive getAvailablePurchases coverage for a complete Apple and Google payload. The suite only asserted that malformed batches are rejected, so nothing proved a well-formed payload still decodes. - ios helpers: parametrize the generic encodeRequired failure message by type, matching OpenIapSerialization. It claimed "purchase" even for subscription and transaction payloads. purchasesRequired keeps its wording because it really does serialize purchases. - docs: use Types.VoidResult in the Godot restore_purchases signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Horizon billing-compatibility Purchase.orderId is a non-null String and arrives blank on device, so the converter's null-only `orderId ?: token` fallback never fired and every Horizon purchase reached the SDKs with an empty id and transactionId. The strict cross-SDK purchase decoders from #276 correctly rejected the whole batch on a Quest 3 with real store data. Blank-aware fallbacks now cover toPurchase (id, transactionId) and toActiveSubscription (transactionId), pinned by regression tests and by the payload-parity audit's updated canonical source expression. The React Native example manifest also re-enables the HORIZON_APP_ID meta-data, without which the Horizon platform SDK could not initialize at all. Verified on the same Quest 3: initConnection true, 4 available purchases and 2 active subscriptions decode with no errors. Every store in the matrix has now decoded real payloads through the strict pipeline on hardware.
Closes #270, #271, #272.
The bug
Entitlement queries could not distinguish "the user owns nothing" from
"the lookup failed". A transient store or bridge error returned the same
empty list as an authoritative empty result, so an app that revokes
entitlements on an empty list could strip content from a paying user.
Reported against godot-iap, but it was never a godot bug.
packages/appleandpackages/googleare consumed by all six framework SDKs, so a singleswallow-and-continue propagated everywhere.
Three failure shapes
catch { log; continue },resumeIfActive(emptyList()),(purchases ?? []),return []mapNotNull,if x is Dictionaryreturn nullwith no eventApple also emitted purchase updates inside the verification loop, so a
mid-loop failure leaked partial state through events that cannot be retracted.
The fix
Queries are atomic: either every entry decodes or the call fails with a typed
error. Apple publishes only after the whole sequence verifies. Godot gains
failure-aware
*_resultAPIs while the array-returning methods stay forcompatibility. Terminal purchase failures always emit
purchase_error, andAndroid publishes each one exactly once via an
AtomicBooleangate.Required fields match the schema —
id,productId,transactionDate,store,quantity,purchaseStateandisAutoRenewingare all non-null intype.graphql— so the decoders are not relaxed. A bridge omitting them isthe defect.
audit-purchase-payload-parityenforces the contract in CI so oneSDK cannot regress to the lossy idioms.
Migration
get_available_purchases()keeps its signature and still maps failure to[].Code that grants or revokes entitlements must move to the result-bearing API:
This matters most for products that cannot be re-purchased (for example Play
newRegionsConfig: NO_LONGER_AVAILABLE): a wrongly revoked entitlement therehas no user-recoverable path.
Device verification
Real hardware, real store payloads — the key risk was that the stricter
decoders would reject genuine data.
PurchaseError, never a silent nullQuantity: 1presentkeplerBuilds: godot iOS + Android, packages/apple xcframework, packages/google all
flavors, KMP Amazon/Horizon, MAUI Amazon, Flutter FireOS/Horizon, Expo
FireOS/Horizon/Vega, RN FireOS/Horizon/Vega.
Automated: godot 444, react-native-iap 426, flutter 288, expo 82, maui 97,
apple Swift suites, plus
audit:docs,audit:parity,audit:release-state.Blocked, not passed
does not stay resident. No crash log, so this is not a code fault; the matrix
defines Horizon as build-only without a runnable Horizon example.
initConnectionfails on Pixel 2. The godot exportis signed with a different keystore than the Play-entitled example, so this is
a store-entitlement gap, not a regression.
Also in this PR
Two fixes found because the e2e actually ran:
contradicted the normal-Android block six lines above it. Flutter FireOS had
the inverse problem, using the shared layout instead of Flutter's
flutter-apk/output. Both verified by installing from the corrected paths..claude/commands/*.mdworkflows had no frontmatter, so theysurfaced with only their H1 and would not trigger from natural language.
🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
New Features
Documentation