Skip to content

fix(google): reconcile ambiguous purchase errors - #257

Merged
hyochan merged 6 commits into
mainfrom
fix/google-ambiguous-purchase-recovery
Jul 27, 2026
Merged

hyochan merged 6 commits into
mainfrom
fix/google-ambiguous-purchase-recovery

Conversation

@hyochan

@hyochan hyochan commented Jul 27, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Reconcile transient, ambiguous Play purchase-flow errors with queryPurchasesAsync()
  • Recover only matching SKUs purchased after the current billing flow started
  • Preserve callback/listener ownership and avoid duplicate delivery when an OK callback wins the race
  • Document the coordinated stable patch train with the expected package versions and release links

Why

Google Play can complete an order but report NETWORK_ERROR through onPurchasesUpdated before the app receives the purchase. OpenIAP previously emitted only a purchase error and cleared the pending request, leaving a completed subscription unacknowledged.

Fixes #166

Stable release plan

Release sequentially from main after merge:

  1. openiap-google 2.5.2
  2. react-native-iap 15.6.2
  3. expo-iap 4.7.2
  4. flutter_inapp_purchase 9.6.2
  5. godot-iap 2.6.2
  6. kmp-iap 2.7.2
  7. OpenIap.Maui 1.4.2
  8. Production documentation

The OpenIAP Spec and openiap-apple versions remain unchanged because this fix does not change the public contract.

Test plan

  • Play targeted recovery and race regression tests
  • Play, Horizon, and Amazon compile and unit-test matrix
  • Google Play example APK
  • SDK parity and release-state audits
  • React Native typecheck, 426 library tests, 140 example tests, and Android bridge tests against local OpenIAP
  • Expo typecheck, 381 library tests, 87 plugin tests, 111 example tests, local OpenIAP Android prebuild/compile, and consumer debug APK
  • Physical Pixel 2 Google Play license-tester purchase through Local (IAPKit), including verification, consumption, exact one-order Convex delta, and no duplicate or durable consumable entitlement
  • IAPKit purchase-save idempotency, stats integration, and replay guard: 3 files / 46 tests
  • Release docs Prettier check, build, docs audit, and release-state audit
  • Expo Android unit suite: 15/16 pass; the untouched legacy deep-link test calls unmocked android.util.Log.w (unrelated to this Play recovery change)

Preview

No visual preview is applicable. This changes native purchase reconciliation behavior and release history content; the native behavior is covered by deterministic regression tests and the physical-device receipt vertical.

Summary by CodeRabbit

  • Bug Fixes
    • Improved purchase-update failure recovery by retrying ownership checks for retriable errors and reconciling ambiguous flows against current ownership.
    • Bounded retry attempts and limited recovery to purchases created during the in-flight request to avoid older or duplicate deliveries.
    • Enhanced subscription recovery with base-plan aware handling and purchase-time filtering.
  • Tests
    • Added/expanded coverage for ambiguous/retriable recovery, retry exhaustion, purchase-time filtering, base-plan assertions, and race/deduplication cases.
  • Documentation
    • Updated installation/version references and added a July 28, 2026 Google Play ambiguous purchase recovery patch-train entry.

Query current Play ownership when onPurchasesUpdated reports a transient, ambiguous failure. Only recover same-SKU purchases created after the billing flow starts so existing ownership cannot become a false success.

Add regression coverage for NETWORK_ERROR subscription recovery, retriable response codes, stale ownership filtering, and callback races.

Fixes #166
@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 Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 27c66842-7e96-49f0-a985-eb3c73f98019

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Google Play purchase-flow errors now reconcile recent owned purchases with bounded retries, timestamp filtering, and coordinated callback/listener delivery. Tests cover recovery races and retry outcomes, while release documentation lists updated package versions.

Changes

Purchase recovery

Layer / File(s) Summary
Retryable, time-filtered ownership queries
packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt, packages/google/openiap/src/testPlay/java/dev/hyo/openiap/QueryPurchasesRaceTest.kt
Ownership queries retry eligible Billing failures and exclude purchases older than the active purchase-flow cutoff.
Purchase-flow error reconciliation
packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
A shared helper reconciles ambiguous billing errors, schedules bounded retries, preserves base-plan selection, claims pending callbacks, and delivers recovered purchases.
Recovery and release validation
packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt, packages/docs/src/pages/docs/updates/releases.tsx, packages/docs/public/llms*.txt
Tests cover retry outcomes, filtering, races, duplicate delivery, and plan-aware recovery; release notes and installation references list updated package versions.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BillingClient
  participant OpenIapModule
  participant PurchaseListeners
  participant PendingCallback
  BillingClient->>OpenIapModule: onPurchasesUpdated(error)
  OpenIapModule->>BillingClient: query owned purchases with cutoff and retries
  BillingClient-->>OpenIapModule: recovered purchases or query failure
  OpenIapModule->>PurchaseListeners: deliver recovered purchases
  OpenIapModule->>PendingCallback: complete with recovered or empty results
Loading

Possibly related PRs

Suggested labels: 📖 documentation, 🧪 test

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR addresses Android purchase delivery failures by recovering ambiguous listener errors and matching the issue's reported symptom.
Out of Scope Changes check ✅ Passed The docs and release-note updates are consistent with the PR's stated release documentation work and do not appear unrelated.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: Google purchase error reconciliation for ambiguous billing failures.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/google-ambiguous-purchase-recovery

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.

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

@hyochan hyochan added 🔥 hotfix Hot urgent pr 🛠 bugfix All kinds of bug fixes 🤖 android Related to android labels Jul 27, 2026
@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.

@hyodotdev hyodotdev deleted a comment from coderabbitai Bot Jul 27, 2026
@hyodotdev hyodotdev deleted a comment from coderabbitai Bot Jul 27, 2026
Retry transient ownership-query failures after an ambiguous purchase callback while preserving fatal errors, request matching, and duplicate-delivery guards.
@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 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 (2)
packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt (1)

100-116: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Retries fire immediately with no backoff, and the exception path ignores the retriable check.

Two small asymmetries worth considering:

  • SERVICE_DISCONNECTED / NETWORK_ERROR are re-queried synchronously in the same tick; without a short delay (or a reconnect for SERVICE_DISCONNECTED) the extra attempts will almost always reproduce the same failure and just burn the attempt budget.
  • The response-code path retries only when isRetriablePurchaseQueryResponse is true, but the catch block retries on any exception (including a permanently not-ready client). Bounded by maxAttempts, so no runaway, but the gating is inconsistent.

Since this runs on an async callback path, a delayed retry would need a handler/coroutine; feel free to defer if the current best-effort behavior is intentional.

Also applies to: 132-140

🤖 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/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt`
around lines 100 - 116, The query retry flow in query should apply the same
isRetriablePurchaseQueryResponse gating in its exception path, and schedule
eligible retries with a short asynchronous delay rather than invoking query
immediately. Preserve the maxAttempts bound and return empty results for
non-retriable failures; use the existing callback-compatible Handler or
coroutine mechanism.
packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt (1)

152-152: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Attempt counts are hardcoded to the module's private max-attempts constant.

assertEquals(3, ...) silently encodes AMBIGUOUS_PURCHASE_QUERY_MAX_ATTEMPTS; changing that constant breaks these tests in a way that reads like a regression. Consider deriving the expectation (reflection on the constant, or purchaseResponseCodes.size) or adding a comment tying the two together.

Also applies to: 195-195

🤖 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/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt`
at line 152, The assertions in OnPurchasesUpdatedRecoveryTest that expect 3
query calls hardcode the private retry limit. Derive the expected count from the
existing purchaseResponseCodes size or the AMBIGUOUS_PURCHASE_QUERY_MAX_ATTEMPTS
constant, and apply the same change to both affected assertions so they remain
aligned when the retry limit changes.
🤖 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 `@packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt`:
- Around line 100-116: The query retry flow in query should apply the same
isRetriablePurchaseQueryResponse gating in its exception path, and schedule
eligible retries with a short asynchronous delay rather than invoking query
immediately. Preserve the maxAttempts bound and return empty results for
non-retriable failures; use the existing callback-compatible Handler or
coroutine mechanism.

In
`@packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt`:
- Line 152: The assertions in OnPurchasesUpdatedRecoveryTest that expect 3 query
calls hardcode the private retry limit. Derive the expected count from the
existing purchaseResponseCodes size or the AMBIGUOUS_PURCHASE_QUERY_MAX_ATTEMPTS
constant, and apply the same change to both affected assertions so they remain
aligned when the retry limit changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e5e5469f-4dad-4951-b06a-3d9fad085228

📥 Commits

Reviewing files that changed from the base of the PR and between 2617c90 and 981011d.

📒 Files selected for processing (3)
  • packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
  • packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt
  • packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt

Schedule transient ownership-query retries on the main looper, avoid retrying synchronous API exceptions, and keep retry tests aligned with their response sequences.
@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.

Verify ambiguous reconciliation remains pending until the main-looper retry delay elapses.
@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.

@hyodotdev hyodotdev deleted a comment from coderabbitai Bot Jul 27, 2026
@hyodotdev hyodotdev deleted a comment from coderabbitai Bot Jul 27, 2026
@hyodotdev hyodotdev deleted a comment from coderabbitai Bot Jul 27, 2026
@hyochan

hyochan commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Final live IAPKit receipt E2E

Validated revision bfcc8bd43a47c686aed1cf2cc2e965806ecbd007 with the React Native Martie example on a physical Pixel 2 (Android 11), Google Play, and the real Martie Dev Convex deployment.

  • Built and installed the current React Native Android debug app with the local Google package.
  • Selected Local (IAPKit) and fetched the live Martie catalog (dev.hyo.martie.10bulbs, localized at ₩1,400).
  • Before confirmation, the Google Play sheet explicitly showed Test card, always approves and This is a test order, you will not be charged.
  • The local compiled server received POST /v1/purchase/verify at correlation ID c83399e6-92d2-486e-b2d9-7566401c7935 and returned HTTP 200, store: google, isValid: true, state: PENDING_ACKNOWLEDGMENT.
  • The app reported the matching IAPKit result, finished the consumable successfully, and a refreshed Play ownership read no longer contained 10bulbs.
  • Same-deployment target-SKU data changed by exactly one canonical logical order: rows 2 → 3, distinct order IDs 2 → 3, valid rows 1 → 2.
  • Project purchase counters changed exactly once: total 10 → 11, Google rows 3 → 4, Google orders 3 → 4, valid 9 → 10; invalid remained 1.
  • Durable subscription rows for this consumable remained 0; no duplicate logical order was created.
  • Purchase-save idempotency, stats integration, and replay-guard suites passed: 3 files / 46 tests.
  • Local server, Metro, and run-owned ADB reverse mappings were cleaned up afterward; the worktree remains clean.

This completes the previously approval-gated live Google sandbox receipt vertical.

@hyochan hyochan added the 📖 documentation Improvements or additions to documentation label Jul 27, 2026
@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 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: 1

🤖 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 `@packages/docs/src/pages/docs/updates/releases.tsx`:
- Around line 121-124: Remove or defer the future-dated release entry identified
by id google-play-ambiguous-purchase-recovery-2026-07-28 in the releases list;
do not publish it until July 28, 2026 or later. If retaining it, update the date
and accompanying copy to reflect an already-published release while preserving
the list’s chronological ordering.
🪄 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: e8f361a9-55db-48ad-a1a4-8dc85bbd3bd4

📥 Commits

Reviewing files that changed from the base of the PR and between 981011d and c339eac.

📒 Files selected for processing (4)
  • packages/docs/src/pages/docs/updates/releases.tsx
  • packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
  • packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt
  • packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt
  • packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt

Comment thread packages/docs/src/pages/docs/updates/releases.tsx
@hyodotdev hyodotdev deleted a comment from coderabbitai Bot Jul 27, 2026
@hyodotdev hyodotdev deleted a comment from coderabbitai Bot Jul 27, 2026
@hyochan
hyochan merged commit dbe5993 into main Jul 27, 2026
16 checks passed
@hyochan
hyochan deleted the fix/google-ambiguous-purchase-recovery branch July 27, 2026 18:31
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 📖 documentation Improvements or additions to documentation 🔥 hotfix Hot urgent pr

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android purchases not being reported by success handler or listeners

1 participant