Skip to content

fix: preserve purchase query failures across sdks - #276

Merged
hyochan merged 5 commits into
mainfrom
fix/cross-sdk-purchase-safety
Aug 3, 2026
Merged

hyochan merged 5 commits into
mainfrom
fix/cross-sdk-purchase-safety

Conversation

@hyochan

@hyochan hyochan commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

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/apple and
packages/google are consumed by all six framework SDKs, so a single
swallow-and-continue propagated everywhere.

Three failure shapes

Shape Example Why it hurt
Empty list on failure catch { log; continue }, resumeIfActive(emptyList()), (purchases ?? []), return [] failure reads as "owns nothing"
Partial success mapNotNull, if x is Dictionary silently dropped entries produced a plausible but incomplete list — worse than empty, because nobody suspects it
Silence return null with no event caller never learns the request died

Apple 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 *_result APIs while the array-returning methods stay for
compatibility. Terminal purchase failures always emit purchase_error, and
Android publishes each one exactly once via an AtomicBoolean gate.

Required fields match the schema — id, productId, transactionDate,
store, quantity, purchaseState and isAutoRenewing are all non-null in
type.graphql — so the decoders are not relaxed. A bridge omitting them is
the defect. audit-purchase-payload-parity enforces the contract in CI so one
SDK 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:

var result = await iap.get_available_purchases_result()
if not result.success:
    return              # do not revoke on a failed query
if result.purchases.is_empty():
    revoke_premium()    # only now is "owns nothing" authoritative

This matters most for products that cannot be re-purchased (for example Play
newRegionsConfig: NO_LONGER_AVAILABLE): a wrongly revoked entitlement there
has no user-recoverable path.

Device verification

Real hardware, real store payloads — the key risk was that the stricter
decoders would reject genuine data.

Store Device Result
Google Play Pixel 2 (HT79F1A00473) real purchase decoded + refreshed; sandbox purchase surfaced a typed PurchaseError, never a silent null
App Store iPhone 13 mini (35C8EE6D…) 22 StoreKit transactions decoded, 0 failures; all 8 required fields present; sandbox purchase granted (Bulbs 0 → 10)
Amazon Fire tablet (GN43T503515200BA) RN, Expo, Flutter and MAUI each decoded 2 real Amazon transactions; Quantity: 1 present
VegaOS Fire TV (G0733M085512021G) RN and Expo built, installed and confirmed running via kepler

Builds: 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

  • Horizon runtime — Quest 3 installs and launches, then the flat Android app
    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.
  • godot Android billing — initConnection fails on Pixel 2. The godot export
    is 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:

  • The Expo FireOS install path pointed at a directory that does not exist and
    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.
  • The eight .claude/commands/*.md workflows had no frontmatter, so they
    surfaced with only their H1 and would not trigger from natural language.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Malformed, incomplete, or cross-platform purchase data now fails clearly instead of being silently ignored or partially returned.
    • Purchase restoration and subscription checks now consistently propagate synchronization, verification, cancellation, serialization, and store errors.
    • Duplicate purchase-error notifications are suppressed, and parser failures no longer produce partial results.
  • New Features

    • Godot now provides structured result APIs for available purchases and subscription status, including error details.
  • Documentation

    • Updated API guidance and examples to explain failure handling and successful empty results.

hyochan and others added 3 commits August 3, 2026 21:55
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>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8ef35d47-2c86-4818-bac1-03270965cc17

📥 Commits

Reviewing files that changed from the base of the PR and between 460a0bc and 8cc01bd.

📒 Files selected for processing (9)
  • libraries/expo-iap/ios/ExpoIapHelper.swift
  • libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift
  • libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
  • libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift
  • libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart
  • libraries/godot-iap/addons/godot-iap/godot_iap.gd
  • libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift
  • libraries/react-native-iap/ios/RnIapHelper.swift
  • packages/docs/src/pages/docs/apis/restore-purchases.tsx
🚧 Files skipped from review as they are similar to previous changes (8)
  • packages/docs/src/pages/docs/apis/restore-purchases.tsx
  • libraries/react-native-iap/ios/RnIapHelper.swift
  • libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift
  • libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
  • libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift
  • libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift
  • libraries/godot-iap/addons/godot-iap/godot_iap.gd
  • libraries/expo-iap/ios/ExpoIapHelper.swift

📝 Walkthrough

Walkthrough

The 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.

Changes

Purchase validation and serialization

Layer / File(s) Summary
Strict purchase decoding
libraries/expo-iap/..., libraries/flutter_inapp_purchase/..., libraries/kmp-iap/..., libraries/maui-iap/..., libraries/react-native-iap/...
Malformed, incomplete, mixed-store, and partially decodable purchase batches now reject atomically with billing JSON parse errors.
Required native serialization and verification
packages/apple/..., libraries/expo-iap/..., libraries/flutter_inapp_purchase/..., libraries/react-native-iap/..., libraries/godot-iap/...
Serialization and transaction verification failures now propagate instead of producing empty or partial results.
Purchase request error handling
libraries/expo-iap/android/..., libraries/react-native-iap/android/..., packages/google/openiap/...
Cancellation is preserved, request-boundary failures are distinguished, and terminal purchase errors are published at most once.

Godot structured result flow

Layer / File(s) Summary
Structured purchase and subscription queries
libraries/godot-iap/addons/..., libraries/godot-iap/android/..., libraries/godot-iap/ios-gdextension/...
Godot adds failure-aware available-purchase and active-subscription result APIs while retaining compatibility wrappers.
Cancellable iOS operations
libraries/godot-iap/addons/godot-iap/godot_iap.gd, libraries/godot-iap/Example/tests/...
iOS requests use bounded waiters, timeout handling, completion tracking, cancellation, cleanup, and cache eviction.
Restore and entitlement handling
libraries/godot-iap/EXAMPLES.md, libraries/godot-iap/Example/iap_manager.gd, packages/docs/src/pages/docs/apis/...
Restore and entitlement examples check structured success fields before processing purchases or updating state.

Validation and parity coverage

Layer / File(s) Summary
Regression tests
libraries/*/test*, packages/apple/Tests/..., packages/google/openiap/src/testPlay/...
Tests cover malformed payloads, foreign stores, serialization failures, restore failures, cancellation, duplicate errors, and paginated parser failures.
Parity audits
scripts/audit-non-godot-parity.mjs, scripts/audit-purchase-payload-parity.mjs
Audits now check strict decoding, required serialization, restore routing, cancellation propagation, error callbacks, and active-subscription failure contracts.

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
Loading

Possibly related PRs

Suggested labels: 🧪 test

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes Claude command frontmatter and Flutter FireOS APK path changes that are unrelated to issue #270's Godot failure-preservation requirements. Move the workflow and APK path updates to separate PRs, or link issues that define those requirements.
Docstring Coverage ⚠️ Warning Docstring coverage is 21.80% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: preserving purchase query failures across SDKs.
Linked Issues check ✅ Passed The PR satisfies issue #270 by adding structured Godot query results, preserving Android and iOS failures, propagating restore failures, and adding regression tests.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cross-sdk-purchase-safety

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 win

Use the existing accessor helpers for the result purchases.

_clear_pending_purchases() reads purchases from the same get_available_purchases_result() payload, but it normalizes each entry with _purchase_to_dict() and _purchase_product_id() (Lines 91-93). This loop instead accesses purchase.product_id directly. 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

GetActiveSubscriptionsAsync still uses the lenient decoder; it should use the strict decoder like GetAvailablePurchasesAsync and iOS's counterpart.

GetActiveSubscriptionsAsync calls DecodeItems<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 updates GetAvailablePurchasesAsync in the same file, and GetActiveSubscriptionsAsync in OpenIapIOS.cs, to the strict BridgePayloadDecoder path that throws BillingResponseJsonParseError on malformed or missing data. Leaving Android's GetActiveSubscriptionsAsync on 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 and GetAvailablePurchasesAsync.

🐛 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.

hasActiveSubscriptionsInternal now calls invokeOnError(error) and rethrows on failure. This hook delegates to the root-level hasActiveSubscriptions in index.ts (lines 2523-2534), which catches every error internally and always resolves to false:

} 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 at hooks/useIAP.test.ts lines 337-347 only exercises this path because it mocks hasActiveSubscriptions directly, 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 as false ("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 hasActiveSubscriptions in index.ts to 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 win

Log the failure code and message, like the other examples.

The other three pages print result.error and result.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 VoidResult failure envelope carries error and code before 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 win

Formatting-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\t needle at line 1334 with whitespace-tolerant expectMatch patterns, 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 the Transaction.currentEntitlements / try checkVerified(verification) pair with an expectMatch pattern 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 win

Strengthen 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 in OpenIapIOS.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) and put("code", code). GodotIap.kt builds many envelopes, so these match regardless of whether getActiveSubscriptionsResult produces the failure envelope. Scope the assertion to that function body, for example with extractBalancedAfterMarker on fun 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 win

Unanchored [\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 the requestPurchase function body with extractBalancedAfterMarker before matching catch (e: CancellationException).
  • scripts/audit-non-godot-parity.mjs#L2243-L2324: stop encoding declaration order across getAvailablePurchasesInternal, getActiveSubscriptionsInternal, and hasActiveSubscriptionsInternal. Extract each function body first, then assert invokeOnError(error) and throw error inside 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 win

Add 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 null failure 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 value

Merge the two identical failure builders.

availablePurchasesFailure and activeSubscriptionsFailure have 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 win

The new serialization failures lose their error code in these three methods.

encodeRequired throws a PurchaseError with code billingResponseJsonParseError. The catch blocks for getPendingTransactionsIOS, getAllTransactionsIOS, and showManageSubscriptionsIOS build the failure dictionary inline and set only error, not code. The structured code that getAvailablePurchases and getActiveSubscriptions now preserve is dropped here. Route these three catch blocks through emitAsyncFailure with error.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 win

Pass explicit codes from the argument-validation call sites.

emitAsyncFailure now stamps every failure with a code, and the default is ErrorCode.serviceError. Several call sites report argument-validation problems: fetchProducts for "Invalid arguments" and for the parseProductQueryType failure, and finishTransaction for "Invalid arguments". Those now surface as service-error to GDScript. Pass ErrorCode.developerError.rawValue at 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 value

Remove the unreachable terminal recheck.

Line 206 returns when cache_key is terminal. The condition on line 212 therefore always evaluates to true. Use a plain else.

♻️ 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 value

Extract the duplicated subscription-id JSON encoding.

Both lines build the same platform-dependent ids_json value. 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 null

Also 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 win

Deduplicate the guard, and track removal of this shim.

Two points.

First, both helpers repeat the same guard and throw. purchasesRequired can reuse a single throwing check.

Second, packages/apple/Tests/OpenIapTests.swift (lines 15-26) shows that OpenIapSerialization itself now provides encodeRequired and purchasesRequired with the same billingResponseJsonParseError contract. 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 win

Add a positive assertion that a complete purchase list decodes.

The new tests cover rejection paths and the empty list. No test asserts that decodePurchaseListPayloadIOS returns a decoded purchase for validPurchase(). 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 win

Rename to match the platform-suffix naming convention.

decodeApplePurchases and decodeAndroidPurchases are single-platform functions. The path guideline requires an IOS or Android suffix at the end of the name for single-platform functions, matching the existing convention elsewhere in this file set (for example isProductAndroid, getPendingTransactionsIOS). decodeApplePurchases uses "Apple" instead of "IOS", and decodeAndroidPurchases places "Android" before "Purchases" instead of at the end.

Rename to decodePurchasesIOS and decodePurchasesAndroid (and update the corresponding imports in index.ts and modules/ios.ts).

As per path instructions, libraries/expo-iap/**/*.{ts,tsx,js,jsx}: "Functions that operate on only one platform must use an IOS or Android suffix; cross-platform helpers must use one shared name and branch internally through Platform.select or 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 win

Consider a typed interface instead of as any.

ExpoIapModule as any bypasses type checking for USING_ONSIDE_SDK and restorePurchases. 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8e13e07 and d28a57d.

📒 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.md
  • libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt
  • libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt
  • libraries/expo-iap/ios/ExpoIapHelper.swift
  • libraries/expo-iap/ios/ExpoIapModule.swift
  • libraries/expo-iap/src/__tests__/index.kepler.test.ts
  • libraries/expo-iap/src/__tests__/index.test.ts
  • libraries/expo-iap/src/__tests__/useIAP.test.tsx
  • libraries/expo-iap/src/__tests__/vega-adapter.test.ts
  • libraries/expo-iap/src/index.kepler.ts
  • libraries/expo-iap/src/index.ts
  • libraries/expo-iap/src/modules/__tests__/ios.test.ts
  • libraries/expo-iap/src/modules/ios.ts
  • libraries/expo-iap/src/useIAP.ts
  • libraries/expo-iap/src/utils/availablePurchases.ts
  • libraries/expo-iap/src/utils/restorePurchases.ts
  • libraries/expo-iap/src/vega-adapter.ts
  • libraries/flutter_inapp_purchase/analysis_options.yaml
  • libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift
  • libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift
  • libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
  • libraries/flutter_inapp_purchase/lib/helpers.dart
  • libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift
  • libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift
  • libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_active_subscriptions_test.dart
  • libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart
  • libraries/flutter_inapp_purchase/test/ios_methods_test.dart
  • libraries/godot-iap/EXAMPLES.md
  • libraries/godot-iap/Example/iap_manager.gd
  • libraries/godot-iap/Example/tests/test_envelope_parsing.gd
  • libraries/godot-iap/Example/tests/test_godot_iap.gd
  • libraries/godot-iap/addons/godot-iap/android/GodotIap.release.aar
  • libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/GodotIap
  • libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime
  • libraries/godot-iap/addons/godot-iap/godot_iap.gd
  • libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt
  • libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift
  • libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift
  • libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt
  • libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt
  • libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt
  • libraries/maui-iap/src/OpenIap.Maui/BridgePayloadDecoder.cs
  • libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs
  • libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs
  • libraries/maui-iap/tests/OpenIap.Maui.Tests/BridgePayloadDecoderTests.cs
  • libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
  • libraries/react-native-iap/ios/HybridRnIap.swift
  • libraries/react-native-iap/ios/RnIapHelper.swift
  • libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts
  • libraries/react-native-iap/src/__tests__/index.kepler.test.ts
  • libraries/react-native-iap/src/__tests__/index.test.ts
  • libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts
  • libraries/react-native-iap/src/__tests__/vega-adapter.test.ts
  • libraries/react-native-iap/src/hooks/useIAP.ts
  • libraries/react-native-iap/src/index.kepler.ts
  • libraries/react-native-iap/src/index.ts
  • libraries/react-native-iap/src/utils/available-purchases.ts
  • libraries/react-native-iap/src/utils/type-bridge.ts
  • libraries/react-native-iap/src/vega-adapter.ts
  • packages/apple/Sources/Models/OpenIapSerialization.swift
  • packages/apple/Sources/OpenIapModule+ObjC.swift
  • packages/apple/Sources/OpenIapModule.swift
  • packages/apple/Tests/OpenIapTests.swift
  • packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx
  • packages/docs/src/pages/docs/apis/get-available-purchases.tsx
  • packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx
  • packages/docs/src/pages/docs/apis/restore-purchases.tsx
  • packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
  • packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt
  • scripts/audit-non-godot-parity.mjs
  • scripts/audit-purchase-payload-parity.mjs

Comment thread libraries/expo-iap/ios/ExpoIapHelper.swift
Comment thread libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart Outdated
Comment thread libraries/flutter_inapp_purchase/lib/helpers.dart
Comment thread libraries/godot-iap/addons/godot-iap/godot_iap.gd
Comment thread libraries/godot-iap/addons/godot-iap/godot_iap.gd
Comment thread packages/docs/src/pages/docs/apis/restore-purchases.tsx Outdated
`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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
libraries/react-native-iap/ios/RnIapHelper.swift (1)

82-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Centralize the encoded-payload validation.

purchasesRequired duplicates the empty-dictionary check and error construction from encodeRequired. Keep one validation path so the error code and message cannot diverge.

If OpenIapSerialization.purchase is purchase-specific, extract a shared requireEncoded(_:) 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

📥 Commits

Reviewing files that changed from the base of the PR and between d28a57d and 460a0bc.

📒 Files selected for processing (2)
  • libraries/react-native-iap/ios/RnIapHelper.swift
  • scripts/audit-purchase-payload-parity.mjs
🚧 Files skipped from review as they are similar to previous changes (1)
  • scripts/audit-purchase-payload-parity.mjs

@hyochan hyochan added cross-platform Cross-platform (both Android & iOS) expo-iap expo-iap library flutter-iap godot-iap godot-iap library kmp-iap kmp-iap library react-native-iap react-native-iap library 📖 documentation Improvements or additions to documentation 📱 iOS Related to iOS 🛠 bugfix All kinds of bug fixes 🤖 android Related to android labels Aug 3, 2026
- 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>
@hyochan
hyochan merged commit 791cd44 into main Aug 3, 2026
27 checks passed
@hyochan
hyochan deleted the fix/cross-sdk-purchase-safety branch August 3, 2026 16:39
hyochan added a commit that referenced this pull request Aug 3, 2026
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.
@coderabbitai coderabbitai Bot mentioned this pull request Aug 17, 2026
9 of 10 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🤖 android Related to android 🛠 bugfix All kinds of bug fixes cross-platform Cross-platform (both Android & iOS) 📖 documentation Improvements or additions to documentation expo-iap expo-iap library flutter-iap godot-iap godot-iap library 📱 iOS Related to iOS kmp-iap kmp-iap library react-native-iap react-native-iap library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] godot-iap loses available-purchase and restore failures

1 participant