feat: add cross-platform subscriptionBillingIssue event#99
Conversation
Unify StoreKit 2 Message.billingIssue (iOS 18+) and Play Billing isSuspended
(8.1+) into a single SubscriptionBillingIssue event + Purchase payload.
- packages/gql: new IapEvent.SubscriptionBillingIssue + subscriptionBillingIssue
Subscription field; types regenerated for swift/kotlin/ts/dart/gdscript.
- packages/apple: Message.messages listener (iOS 18+, iOS-only, silent no-op
elsewhere); resolves affected subscriptions via currentEntitlements +
RenewalState.inBillingRetryPeriod / inGracePeriod.
- packages/google (Play): isSuspended detection in getAvailablePurchases,
deduped by purchaseToken per session.
- packages/google (Horizon): explicit no-op with warning log; Horizon Billing
Compatibility SDK targets Play Billing 7.0 which lacks isSuspended.
- knowledge/external: storekit2-api.md Message / eligibleWinBackOfferIDs /
Transaction iOS 18.4 / consumable history sections added; iOS 17.4 / 18.2
corrections. google-billing-api.md External Payments (8.3+) section.
horizon-api.md accessToken field correction.
- packages/docs: release notes entry.
Downstream library native bridges (react-native-iap, expo-iap,
flutter_inapp_purchase, godot-iap, kmp-iap) receive synced types; bridge
wiring to expose the new listener will land per-library.
Verified: swift build, gradlew compile{Play,Horizon}DebugKotlin,
tsc rn-iap, tsc expo-iap, flutter analyze, gradlew kmp-iap common compile.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 25 minutes and 18 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughAdds a cross-platform "subscription billing issue" event and listener surface across OpenIAP, wiring StoreKit 2 message-loop (iOS) and Play Billing suspended-subscription detection (Android), Horizon no-op behavior, GraphQL/type updates, SDK bindings for multiple frameworks, docs, examples, and tests. Changes
Sequence Diagram(s)sequenceDiagram
rect rgba(200,230,255,0.5)
participant App as Client App
end
rect rgba(200,255,200,0.5)
participant SDK as Framework SDK (RN/Expo/Flutter/Godot/KMP)
end
rect rgba(255,230,200,0.5)
participant OpenIap as OpenIapModule
end
rect rgba(255,200,200,0.5)
participant Store as Platform Store (StoreKit / Play Billing)
end
App->>SDK: register subscriptionBillingIssue listener
SDK->>OpenIap: addSubscriptionBillingIssueListener(...)
Note right of OpenIap: iOS: start StoreKit Message loop (iOS18+)\nAndroid Play: inspect restored purchases for isSuspended
Store-->>OpenIap: billing-issue message / suspended purchase
OpenIap->>SDK: emit subscriptionBillingIssue payload (deduped)
SDK->>App: deliver Purchase payload to registered callbacks
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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.
Code Review
This pull request implements a cross-platform subscriptionBillingIssue event, unifying StoreKit 2's billing issue messages and Google Play's suspension signals across the monorepo. The implementation includes updates to the GraphQL schema, native modules, framework libraries, and documentation. Feedback highlights the need for thread-safe collections in the Android module, broader platform availability checks for the StoreKit Message API, performance optimizations for product fetching on iOS, and improved event parity on Android by checking suspension states during purchase updates.
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 (2)
libraries/flutter_inapp_purchase/lib/types.dart (1)
5110-5128:⚠️ Potential issue | 🔴 CriticalAdd runtime wiring for the new
subscriptionBillingIssuehandler.The types.dart file declares the
subscriptionBillingIssuefield and handler typedef, but the runtime dispatch influtter_inapp_purchase.dartis incomplete. The handler is not being instantiated in theSubscriptionHandlersconstructor (line 2272). To match the pattern used for other event handlers (userChoiceBillingAndroid,developerProvidedBillingAndroid), you need to:
- Add a
StreamController<Purchase> _subscriptionBillingIssueListenerfield to the class- Expose it as a getter property
- Pass
subscriptionBillingIssue: () async => await _subscriptionBillingIssueListener.stream.first,in theSubscriptionHandlersconstructor instantiation- Wire the listener to receive and dispatch events from the platform channel when the
subscription-billing-issueevent is received🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@libraries/flutter_inapp_purchase/lib/types.dart` around lines 5110 - 5128, The runtime dispatch is missing wiring for the new subscriptionBillingIssue handler declared in types.dart: add a StreamController<Purchase> field (e.g. _subscriptionBillingIssueListener) and a public getter, instantiate SubscriptionHandlers with subscriptionBillingIssue: () async => await _subscriptionBillingIssueListener.stream.first (matching the pattern used for userChoiceBillingAndroid/developerProvidedBillingAndroid), and wire the platform event "subscription-billing-issue" to add incoming Purchase objects into _subscriptionBillingIssueListener so the handler receives events; update flutter_inapp_purchase.dart to create, expose, and dispose the controller alongside the other event controllers.packages/apple/Sources/OpenIapModule.swift (1)
18-34:⚠️ Potential issue | 🟡 MinorCancel the StoreKit message task during normal connection teardown.
endConnection()goes throughcleanupExistingState(), but that path still only stopsupdateListenerTask. Since this module is a singleton, relying ondeinitmeansmessageListenerTaskcan stay parked onStoreKit.Message.messagesuntil the next init or process exit.♻️ Proposed fix
private func cleanupExistingState() async { updateListenerTask?.cancel() updateListenerTask = nil + messageListenerTask?.cancel() + messageListenerTask = nil await state.reset()Also applies to: 69-69
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/apple/Sources/OpenIapModule.swift` around lines 18 - 34, cleanupExistingState()/endConnection() currently only cancels updateListenerTask, leaving messageListenerTask (Task listening on StoreKit.Message.messages) running; update cleanupExistingState() (the endConnection teardown path) to also cancel and nil out messageListenerTask (and mirror what deinit does) so the StoreKit message listener is stopped during normal connection teardown — reference messageListenerTask, cleanupExistingState(), endConnection(), and updateListenerTask to locate the change.
🧹 Nitpick comments (3)
packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt (1)
57-58: Tighten the wording of the event semantics comment.The phrase “previously-healthy subscription” is stronger than the current implementation behavior (first seen suspended token in session). Consider rewording to avoid implying health-transition tracking.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt` around lines 57 - 58, The KDoc for the event in OpenIapListener.kt currently says "previously-healthy subscription" which overstates behavior; update the comment on the event (near class/function OpenIapListener) to state that the event fires once per session when a subscription token is first observed in a suspended state (e.g., payment failed, card expired) rather than implying the system tracks a prior healthy state or health transition.knowledge/external/google-billing-api.md (1)
378-429: Add direct reference links for the new 8.3 API names.This new section introduces several new symbols (
enableDeveloperBillingOption,DeveloperBillingOptionParams,DeveloperProvidedBillingListener,BillingOption.EXTERNAL_PAYMENTS); adding official links (as done in earlier sections) would reduce drift risk over time.Based on learnings: "Update documentation for API changes and reference OpenIAP specification for new features."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@knowledge/external/google-billing-api.md` around lines 378 - 429, Add official reference links for the new Billing Library 8.3 APIs introduced in this section: link enableDeveloperBillingOption (BillingClient.Builder), DeveloperBillingOptionParams, DeveloperProvidedBillingListener, DeveloperProvidedBillingDetails, and BillingOption.EXTERNAL_PAYMENTS to their respective Android developer docs (or OpenIAP equivalents used elsewhere in the doc). Update the examples' inline type mentions to be linked the same way as earlier sections (consistent anchor style), and add a short parenthetical OpenIAP mapping for the AlternativeBilling* surface to match the other API-reference entries.knowledge/_claude-context/context.md (1)
1008-1131: LGTM! Clear IR-based code generation documentation.The architecture description accurately documents the parser → IR → plugin flow and provides helpful reference tables for IR types and plugin features. The schema marker documentation (# => Union, # Future) is particularly useful.
Optional: Fix markdown formatting
Static analysis flagged missing blank lines around the code fence at line 1124 and missing language specification at line 1235. While acceptable for internal docs, adding these would improve markdown compliance:
bun run generate + # Generate specific platform bun run generate:swift-``` +```text GraphQL Schema (src/*.graphql)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@knowledge/_claude-context/context.md` around lines 1008 - 1131, Add proper blank lines around fenced code blocks and include language identifiers on fences lacking them: locate the code fence that contains "GraphQL Schema (src/*.graphql)" and ensure there is an empty line before and after the ```text fence, and add a language spec (e.g., ```text or ```bash) to other fences missing a language label elsewhere in context.md; update the surrounding markdown so each triple-backtick block has a blank line separation and an explicit language tag.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@libraries/react-native-iap/src/types.ts`:
- Around line 1563-1576: Add full listener plumbing for the new
subscriptionBillingIssue event: declare a subscriptionBillingIssueJsListeners
state array, implement a subscriptionBillingIssueListener registration function
that pushes/removes callbacks mirroring the pattern in
userChoiceBillingListenerAndroid and developerProvidedBillingListenerAndroid,
wire a native-to-JS handler that fans out incoming native messages to all
functions in subscriptionBillingIssueJsListeners (ensure event shape matches
SubscriptionArgsMap.subscriptionBillingIssue / Purchase), attach that native
handler where other Android/iOS native listeners are attached in index.ts, and
add cleanup/reset logic in resetListenerState() to clear
subscriptionBillingIssueJsListeners; follow the exact naming and behavior used
by the referenced listeners for consistency.
In `@packages/apple/Sources/OpenIapModule.swift`:
- Around line 1557-1571: The code only checks statusArray.first for billing
issues which can miss other statuses; iterate the full statusArray from
subscription.status and treat the subscription as having a billing issue if any
status in the array has state .inBillingRetryPeriod or .inGracePeriod, i.e.
replace the guard-let latest = statusArray.first / switch latest.state logic
with a loop over statusArray checking each element's state and when a matching
state is found, build the purchase via StoreKitTypesBridge.purchase(from:
transaction, jwsRepresentation: verification.jwsRepresentation), call
emitSubscriptionBillingIssue(purchase), and set emitted = true; keep references
to transaction, verification.jwsRepresentation, emitSubscriptionBillingIssue,
and emitted to ensure behavior remains the same.
In `@packages/docs/public/llms-full.txt`:
- Line 912: Change the wording for the compound modifier in the documentation
entry for enableAutoServiceReconnection() so "Auto reconnect feature (8.0+)" is
hyphenated as "Auto-reconnect feature (8.0+)" to fix the grammar; locate the
string that documents enableAutoServiceReconnection() in
packages/docs/public/llms-full.txt and update the text accordingly.
- Line 883: Change the phrase "Google Play Billing Library compatible wrapper"
to use a hyphenated compound modifier: replace it with "Google Play Billing
Library-compatible wrapper" wherever that exact phrase appears (e.g., the line
containing "2. **Billing Compatibility SDK** - Google Play Billing Library
compatible wrapper").
In `@packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt`:
- Around line 245-247: The notifier is being called with a filtered purchase
list so suspended subscriptions get dropped; update restorePurchases flow so
notifySuspendedSubscriptions runs on the unfiltered Play result (or feed it the
raw purchases) before restorePurchasesHelper/filtering happens. Concretely, call
notifySuspendedSubscriptions(purchasesRaw) prior to any
includeSuspendedAndroid-based filtering in restorePurchases or change
restorePurchasesHelper to return both rawPlayPurchases and filteredPurchases and
pass the rawPlayPurchases into notifySuspendedSubscriptions so
subscriptionBillingIssue events are emitted for all suspended subscriptions.
- Around line 113-115: Replace the non-thread-safe mutableSetOf usages with
concurrent collections and use their atomic operations: change
subscriptionBillingIssueListeners to a thread-safe listener set (e.g.,
CopyOnWriteArraySet or ConcurrentHashMap.newKeySet()) so
addSubscriptionBillingIssueListener/removeSubscriptionBillingIssueListener are
safe from any thread, and change emittedBillingIssueTokens to a concurrent Set
(e.g., ConcurrentHashMap.newKeySet()) and use its add() return value inside
notifySuspendedSubscriptions to atomically test-and-register tokens to preserve
the "once per session" guarantee; update any code that iterates those sets to
rely on the concurrent set semantics and remove manual synchronization.
---
Outside diff comments:
In `@libraries/flutter_inapp_purchase/lib/types.dart`:
- Around line 5110-5128: The runtime dispatch is missing wiring for the new
subscriptionBillingIssue handler declared in types.dart: add a
StreamController<Purchase> field (e.g. _subscriptionBillingIssueListener) and a
public getter, instantiate SubscriptionHandlers with subscriptionBillingIssue:
() async => await _subscriptionBillingIssueListener.stream.first (matching the
pattern used for userChoiceBillingAndroid/developerProvidedBillingAndroid), and
wire the platform event "subscription-billing-issue" to add incoming Purchase
objects into _subscriptionBillingIssueListener so the handler receives events;
update flutter_inapp_purchase.dart to create, expose, and dispose the controller
alongside the other event controllers.
In `@packages/apple/Sources/OpenIapModule.swift`:
- Around line 18-34: cleanupExistingState()/endConnection() currently only
cancels updateListenerTask, leaving messageListenerTask (Task listening on
StoreKit.Message.messages) running; update cleanupExistingState() (the
endConnection teardown path) to also cancel and nil out messageListenerTask (and
mirror what deinit does) so the StoreKit message listener is stopped during
normal connection teardown — reference messageListenerTask,
cleanupExistingState(), endConnection(), and updateListenerTask to locate the
change.
---
Nitpick comments:
In `@knowledge/_claude-context/context.md`:
- Around line 1008-1131: Add proper blank lines around fenced code blocks and
include language identifiers on fences lacking them: locate the code fence that
contains "GraphQL Schema (src/*.graphql)" and ensure there is an empty line
before and after the ```text fence, and add a language spec (e.g., ```text or
```bash) to other fences missing a language label elsewhere in context.md;
update the surrounding markdown so each triple-backtick block has a blank line
separation and an explicit language tag.
In `@knowledge/external/google-billing-api.md`:
- Around line 378-429: Add official reference links for the new Billing Library
8.3 APIs introduced in this section: link enableDeveloperBillingOption
(BillingClient.Builder), DeveloperBillingOptionParams,
DeveloperProvidedBillingListener, DeveloperProvidedBillingDetails, and
BillingOption.EXTERNAL_PAYMENTS to their respective Android developer docs (or
OpenIAP equivalents used elsewhere in the doc). Update the examples' inline type
mentions to be linked the same way as earlier sections (consistent anchor
style), and add a short parenthetical OpenIAP mapping for the
AlternativeBilling* surface to match the other API-reference entries.
In
`@packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt`:
- Around line 57-58: The KDoc for the event in OpenIapListener.kt currently says
"previously-healthy subscription" which overstates behavior; update the comment
on the event (near class/function OpenIapListener) to state that the event fires
once per session when a subscription token is first observed in a suspended
state (e.g., payment failed, card expired) rather than implying the system
tracks a prior healthy state or health transition.
🪄 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
Run ID: 59ba6b72-f72c-48e8-9085-ba7eb3347200
⛔ Files ignored due to path filters (5)
packages/gql/src/generated/Types.ktis excluded by!**/generated/**packages/gql/src/generated/Types.swiftis excluded by!**/generated/**packages/gql/src/generated/types.dartis excluded by!**/generated/**packages/gql/src/generated/types.gdis excluded by!**/generated/**packages/gql/src/generated/types.tsis excluded by!**/generated/**
📒 Files selected for processing (24)
knowledge/_claude-context/context.mdknowledge/external/google-billing-api.mdknowledge/external/horizon-api.mdknowledge/external/storekit2-api.mdlibraries/expo-iap/src/types.tslibraries/flutter_inapp_purchase/lib/types.dartlibraries/godot-iap/addons/godot-iap/types.gdlibraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.ktlibraries/react-native-iap/src/types.tspackages/apple/Sources/Helpers/IapState.swiftpackages/apple/Sources/Models/Types.swiftpackages/apple/Sources/OpenIapModule+ObjC.swiftpackages/apple/Sources/OpenIapModule.swiftpackages/apple/Sources/OpenIapProtocol.swiftpackages/docs/public/llms-full.txtpackages/docs/public/llms.txtpackages/docs/src/pages/docs/updates/releases.tsxpackages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.ktpackages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.ktpackages/google/openiap/src/main/java/dev/hyo/openiap/Types.ktpackages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.ktpackages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.ktpackages/gql/src/event.graphqlpackages/gql/src/type.graphql
…listener Flow CI failure root cause: the regenerated SubscriptionHandlers interface adds `suspend fun subscriptionBillingIssue(): Purchase` and the concrete InAppPurchaseAndroid/InAppPurchaseIOS classes were not implementing it, so the Android compile failed with "Class is not abstract and does not implement abstract member". - common KmpInAppPurchase: new `subscriptionBillingIssueListener: Flow<Purchase>` alongside `purchaseUpdatedListener` and `promotedProductListener`. - android InAppPurchaseAndroid: backing MutableSharedFlow + emission hook in the getAvailablePurchasesHandler (dedup by purchase token per session, only fires for PurchaseAndroid with isSuspendedAndroid == true). - android subscriptionHandlers: wires the new handler. - ios InAppPurchaseIOS: placeholder Flow + throwing override (uses listener-based collection; cinterop bridge to Swift's Message listener lands in a follow-up release). Verified: ./gradlew :library:compileDebugKotlinAndroid -> BUILD SUCCESSFUL. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ios: swift tests fakes now implement subscriptionBillingIssueListener (unblocks CI Test iOS) - ios: message listener gate widened from iOS-only to iOS + Mac Catalyst (StoreKit.Message ships on both; macOS/tvOS/watchOS/visionOS still out because Apple doesn't ship Message there — gemini was partly right) - ios: dispatchBillingIssueMessage batches a single Product.products(for:) call for all currentEntitlements instead of one call per loop iteration - ios: iterate the full subscription.status array rather than only .first — the array is unordered across group members per Apple docs - google(play): switch subscriptionBillingIssueListeners to CopyOnWriteArraySet and emittedBillingIssueTokens to ConcurrentHashMap.newKeySet() so add/remove on the main thread is safe against iteration from Dispatchers.IO - google(play): notify suspended subscriptions from onPurchasesUpdated too, not only from getAvailablePurchases, for parity with iOS push delivery - google(play): always query with includeSuspended=true so the notifier sees suspended purchases even when the caller opted not to include them in the returned list, then filter for the caller - docs(horizon-api.md): hyphenate "Google Play Billing Library-compatible" and "Auto-reconnect feature" compound modifiers Verified locally: - swift build + swift test (87 tests pass) - gradlew :openiap:compilePlayDebugKotlin / compileHorizonDebugKotlin - gradlew :library:compileDebugKotlinAndroid (kmp-iap) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…S + Android) - specs/RnIap.nitro.ts: add/remove SubscriptionBillingIssueListener Nitro methods - src/index.ts: subscriptionBillingIssueListener() public API with the same JS-side pattern as userChoiceBillingListenerAndroid / developerProvidedBillingListenerAndroid - ios/HybridRnIap.swift: bridges OpenIapModule.subscriptionBillingIssueListener through to Nitro callbacks - android/.../HybridRnIap.kt: bridges openIap.addSubscriptionBillingIssueListener (Play flavor) through convertToNitroPurchase to Nitro callbacks. Horizon flavor's OpenIapProtocol implementation is a no-op, so the listener is inert on Horizon builds — consistent with the rest of the stack. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- src/index.ts: add OpenIapEvent.SubscriptionBillingIssue + EventPayloads entry + subscriptionBillingIssueListener() public API - android ExpoIapModule.kt: register EVENT_SUBSCRIPTION_BILLING_ISSUE - android ExpoIapHelper.kt: subscribe to openIap.addSubscriptionBillingIssueListener and relay to the expo emitter - ios ExpoIapModule.swift: declare OpenIapEvent.subscriptionBillingIssue in Events(...) - ios ExpoIapHelper.swift: subscribe to OpenIapModule.shared.subscriptionBillingIssueListener and forward the serialized Purchase payload through sendEvent Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…roid) - lib/flutter_inapp_purchase.dart: add subscriptionBillingIssueListener Stream<Purchase> getter + 'subscription-billing-issue' method-channel case that converts the payload to a Purchase via convertToPurchase - android AndroidInappPurchasePlugin.kt: subscribe to openIap.addSubscriptionBillingIssueListener and forward over the method channel - ios FlutterInappPurchasePlugin.swift: subscribe to OpenIapModule.shared.subscriptionBillingIssueListener, serialize via OpenIapSerialization.purchase, and invokeMethod "subscription-billing-issue" Verified: flutter analyze (no issues). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ndroid + iOS) - addons/godot-iap/godot_iap.gd: new subscription_billing_issue signal + signal connection for both iOS native (Dictionary arg) and Android (JSON string arg parsed into Dictionary) - android GodotIap.kt: register SUBSCRIPTION_BILLING_ISSUE SignalInfo, subscribe OpenIapSubscriptionBillingIssueListener and forward the serialized purchase JSON - ios-gdextension GodotIap.swift: declare @signal subscription_billing_issue, subscribe to openIap.subscriptionBillingIssueListener and emit a VariantDictionary with id, productId, transactionId, transactionDate, store + purchaseJson blob for consumers that need the full shape Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
iosMain InAppPurchaseIOS.kt now subscribes to openIapModule.addSubscriptionBillingIssueListener during setupListeners(), converts the incoming NSDictionary payload via convertAnyToPurchase, and emits onto _subscriptionBillingIssueFlow. endConnection removes the subscription alongside the other listener tokens. The previous throwing stub for the suspend override is replaced with subscriptionBillingIssueListener.first() so either collection path works. commonMain + Android already land the Flow in the earlier commit. Android Flow is driven by openIap.addSubscriptionBillingIssueListener + getAvailablePurchases isSuspended detection inside openiap-google. Verified locally: - ./gradlew :library:compileCommonMainKotlinMetadata -> BUILD SUCCESSFUL - ./gradlew :library:compileDebugKotlinAndroid -> BUILD SUCCESSFUL Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New /docs/features/subscription-billing-issue route + sidebar entry covering the cross-platform event introduced in this PR. Sections: - Platform behavior table (iOS / iPadOS / Mac Catalyst / Android Play / Android Horizon / non-iOS Apple) with min versions and delivery model - Recommended UX (route users to deepLinkToSubscriptions, do not re-grant entitlements on the assumption the subscription is still active) - Per-language usage snippets: react-native-iap/expo-iap, Flutter, Godot, kmp-iap - Deduping behavior on both platforms References Apple (StoreKit.Message, Reason.billingIssue) and Google (Suspended subscriptions) official docs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new cross-platform subscriptionBillingIssue subscription event to OpenIAP, unifying StoreKit 2’s billing-issue messages (iOS 18+/Mac Catalyst 18+) with Google Play Billing’s suspended subscription signal (Billing 8.1+, Play flavor only), and propagates the schema/types + bridge wiring across downstream libraries and docs.
Changes:
- Extend GraphQL schema + regenerate all platform/type mirrors to include
IapEvent.SubscriptionBillingIssueandSubscription.subscriptionBillingIssue: Purchase!. - Implement native emission/listeners on iOS (StoreKit
Message.messagesloop) and Android Play flavor (Purchase.isSuspended), with Horizon flavor as explicit no-op. - Add downstream library bridges (RN/Expo/Flutter/Godot/KMP) and documentation/release notes updates.
Reviewed changes
Copilot reviewed 46 out of 51 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/gql/src/type.graphql | Adds IapEvent.SubscriptionBillingIssue to schema. |
| packages/gql/src/event.graphql | Adds subscriptionBillingIssue: Purchase! subscription field. |
| packages/gql/src/generated/types.ts | Regenerated TS types to include new event + subscription field. |
| packages/gql/src/generated/types.gd | Regenerated GDScript types to include new event mapping. |
| packages/gql/src/generated/types.dart | Regenerated Dart types to include new event + resolver handler. |
| packages/gql/src/generated/Types.swift | Regenerated Swift types to include new event + resolver handler. |
| packages/gql/src/generated/Types.kt | Regenerated Kotlin types to include new event + resolver handler. |
| packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt | Implements Play flavor listener registration + suspended-subscription emission/dedupe. |
| packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt | Adds OpenIapSubscriptionBillingIssueListener interface. |
| packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt | Updates Android main Types mirror with new enum + subscription handler. |
| packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt | Extends protocol with add/remove subscription billing issue listeners. |
| packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt | Adds Horizon explicit no-op add/remove with warning logs. |
| packages/docs/src/pages/docs/updates/releases.tsx | Adds release note entry for the new event. |
| packages/docs/src/pages/docs/index.tsx | Registers new docs route + sidebar entry. |
| packages/docs/src/pages/docs/features/subscription-billing-issue.tsx | New feature doc page describing behavior/UX and library snippets. |
| packages/docs/public/llms.txt | Regenerated LLM quick reference timestamp. |
| packages/docs/public/llms-full.txt | Regenerated full LLM reference content. |
| packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift | Updates fake module to satisfy new protocol listener method. |
| packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift | Updates fake module to satisfy new protocol listener method. |
| packages/apple/Sources/OpenIapProtocol.swift | Adds SubscriptionBillingIssueListener typealias + protocol method. |
| packages/apple/Sources/OpenIapModule.swift | Adds StoreKit Message listener task + emission plumbing for billing-issue. |
| packages/apple/Sources/OpenIapModule+ObjC.swift | Exposes ObjC bridge method for subscription billing issue listener. |
| packages/apple/Sources/Models/Types.swift | Updates Apple Types mirror with new enum + subscription handler. |
| packages/apple/Sources/Helpers/IapState.swift | Stores/snapshots new billing-issue listener list in actor state. |
| libraries/react-native-iap/src/types.ts | Updates RN type mirror with new event + subscription field. |
| libraries/react-native-iap/src/specs/RnIap.nitro.ts | Adds Nitro spec add/remove methods for billing-issue listener. |
| libraries/react-native-iap/src/index.ts | Adds JS API subscriptionBillingIssueListener() and listener state wiring. |
| libraries/react-native-iap/ios/HybridRnIap.swift | Adds iOS Nitro bridge wiring to OpenIapModule subscriptionBillingIssueListener. |
| libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt | Adds Android Nitro bridge wiring via openiap-google listener. |
| libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt | Adds iOS Flow bridge for subscription billing issue + resolver method. |
| libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt | Updates KMP type mirror with new enum + subscription handler. |
| libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt | Adds subscriptionBillingIssueListener: Flow<Purchase> to KMP interface. |
| libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt | Adds Android Flow emission + per-process dedupe in getAvailablePurchases. |
| libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift | Adds iOS GDExtension signal + listener wiring for billing-issue event. |
| libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt | Adds Android signal + listener registration for billing-issue event. |
| libraries/godot-iap/addons/godot-iap/types.gd | Updates Godot addon types mirror with new event mapping. |
| libraries/godot-iap/addons/godot-iap/godot_iap.gd | Adds subscription_billing_issue signal and platform hookups. |
| libraries/flutter_inapp_purchase/lib/types.dart | Updates Flutter Dart types mirror with new enum + resolver handler. |
| libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart | Adds Dart stream + method-channel dispatch for new event. |
| libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift | Adds iOS channel emission for subscription billing issue. |
| libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt | Adds Android channel emission for subscription billing issue. |
| libraries/expo-iap/src/types.ts | Updates Expo type mirror with new event + subscription field. |
| libraries/expo-iap/src/index.ts | Adds JS API + enum/payload typing for subscription billing issue. |
| libraries/expo-iap/ios/ExpoIapModule.swift | Registers new event in Expo module event list. |
| libraries/expo-iap/ios/ExpoIapHelper.swift | Wires iOS OpenIapModule listener to Expo event emission. |
| libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt | Registers new event constant + module Events list. |
| libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt | Wires Android OpenIapModule listener to Expo safeEmitEvent. |
| knowledge/external/storekit2-api.md | Updates StoreKit 2 external reference (Message API, version fixes, new fields). |
| knowledge/external/horizon-api.md | Fixes Horizon reference (accessToken shape, hyphenation). |
| knowledge/external/google-billing-api.md | Adds External Payments Program (8.3+) reference section. |
| knowledge/_claude-context/context.md | Regenerates project context and adds more repo conventions/details. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- rn-iap useIAP: add onSubscriptionBillingIssue callback option with matching listener lifecycle (attach during setup, cleanup with others) - OpenIapStore (Android): expose add/removeSubscriptionBillingIssueListener pass-through so Compose callers don't have to reach the underlying OpenIapProtocol directly - apple Example SubscriptionFlowScreen: banner card + "Fix payment method" button wired to OpenIapModule.shared.deepLinkToSubscriptions, listener registered in setupIapProvider and torn down on screen dispose - google Example SubscriptionFlowScreen: analogous banner on the Compose LazyColumn, registered via DisposableEffect on iapStore, dispatches deepLinkToSubscriptions with sku + package name from BuildConfig Verified locally: - rn-iap: yarn tsc --noEmit -p tsconfig.build.json -> clean - apple: swift build -> clean - google: ./gradlew :example:compilePlayDebugKotlin -> BUILD SUCCESSFUL Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Horizon no-op test (packages/google): - Add Robolectric 4.13 + androidx.test:core 1.5.0 to testImplementation - Enable unitTests.isIncludeAndroidResources for manifest-free test - Add src/testHorizon/java sourceSet - SubscriptionBillingIssueHorizonNoOpTest asserts that add/removeSubscriptionBillingIssueListener on the Horizon OpenIapModule never invokes the callback, guarding against accidental Play-flavor emission logic leaking into Horizon Sandbox E2E guide (knowledge/internal): - Concrete step-by-step for iOS 18 sandbox (billing issue toggle or remove payment method) and Play 8.1+ sandbox (remove payment method / Play Console test suspensions) - Per-library smoke matrix using libraries-versions.jsonc "local" mode - Automated coverage matrix separating what CI enforces from what release QA must run manually against live stores Verified: gradlew :openiap:testHorizonDebugUnitTest -> BUILD SUCCESSFUL Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new cross-platform subscriptionBillingIssue event across the OpenIAP schema and all platform/framework implementations, unifying StoreKit 2’s billing-issue messaging (iOS 18+) with Google Play Billing’s suspended-subscription signal (Billing 8.1+), plus documentation, examples, and a Horizon no-op test.
Changes:
- Extends the GraphQL schema + regenerates types to include
IapEvent.SubscriptionBillingIssueandsubscriptionBillingIssue: Purchase!. - Implements native event emission/listeners on iOS and Android (Play), with explicit Horizon no-op behavior + a Robolectric guard test.
- Wires the event through downstream libraries (RN, Expo, Flutter, Godot, KMP) and adds docs + example-app UI banners.
Reviewed changes
Copilot reviewed 53 out of 58 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/gql/src/type.graphql | Adds IapEvent.SubscriptionBillingIssue enum value. |
| packages/gql/src/event.graphql | Adds subscriptionBillingIssue: Purchase! subscription field. |
| packages/gql/src/generated/types.ts | Regenerated TS types with new event + subscription field. |
| packages/gql/src/generated/types.gd | Regenerated GDScript types/mappings with new event. |
| packages/gql/src/generated/types.dart | Regenerated Dart types with new event + resolver hooks. |
| packages/gql/src/generated/Types.swift | Regenerated Swift types with new event + resolver hooks. |
| packages/gql/src/generated/Types.kt | Regenerated Kotlin types with new event + resolver hooks. |
| packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt | Play-flavor listener registration + isSuspended detection + dedupe. |
| packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt | Exposes add/remove listener passthrough on OpenIapStore. |
| packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt | Adds OpenIapSubscriptionBillingIssueListener interface. |
| packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt | Mirrors generated type updates in Android main Types. |
| packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt | Adds add/remove listener API to protocol. |
| packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt | Horizon explicit no-op listener methods with warnings. |
| packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionBillingIssueHorizonNoOpTest.kt | Robolectric test guaranteeing Horizon remains a no-op. |
| packages/google/openiap/build.gradle.kts | Adds testHorizon sources + Robolectric deps/config. |
| packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt | Example UI banner + listener wiring + deep-link action. |
| packages/apple/Sources/OpenIapProtocol.swift | Adds SubscriptionBillingIssueListener typealias + protocol method. |
| packages/apple/Sources/OpenIapModule.swift | Starts StoreKit Message.messages loop and emits event based on subscription status. |
| packages/apple/Sources/OpenIapModule+ObjC.swift | ObjC bridge: addSubscriptionBillingIssueListener. |
| packages/apple/Sources/Models/Types.swift | Mirrors generated types update in Apple Models. |
| packages/apple/Sources/Helpers/IapState.swift | Adds state storage/snapshot for billing-issue listeners. |
| packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift | Updates protocol fakes to implement new listener method. |
| packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift | Updates protocol fakes to implement new listener method. |
| packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift | Example UI banner + listener token lifecycle management. |
| libraries/react-native-iap/src/types.ts | Updates RN type mirror for new event/subscription field. |
| libraries/react-native-iap/src/specs/RnIap.nitro.ts | Adds Nitro spec methods for add/remove listener. |
| libraries/react-native-iap/src/index.ts | Adds JS subscriptionBillingIssueListener implementation. |
| libraries/react-native-iap/src/hooks/useIAP.ts | Adds onSubscriptionBillingIssue hook callback support. |
| libraries/react-native-iap/ios/HybridRnIap.swift | iOS Nitro bridge forwards event from OpenIapModule. |
| libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt | Android Nitro bridge forwards event from openiap-google. |
| libraries/expo-iap/src/types.ts | Updates Expo type mirror for new event/subscription field. |
| libraries/expo-iap/src/index.ts | Adds OpenIapEvent.SubscriptionBillingIssue + JS listener helper. |
| libraries/expo-iap/ios/ExpoIapModule.swift | Registers new event name for Expo module events. |
| libraries/expo-iap/ios/ExpoIapHelper.swift | Emits subscription-billing-issue via Expo event emitter. |
| libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt | Registers new event constant in Expo Android module. |
| libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt | Attaches native listener and emits event payload to JS. |
| libraries/flutter_inapp_purchase/lib/types.dart | Updates Flutter type mirror with new event/subscription field. |
| libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart | Adds stream + method-channel dispatch for event. |
| libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift | iOS plugin bridges billing-issue listener to Flutter. |
| libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt | Android plugin bridges billing-issue listener to Flutter. |
| libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift | Adds Godot iOS signal + OpenIAP listener wiring. |
| libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt | Adds Godot Android signal + listener registration. |
| libraries/godot-iap/addons/godot-iap/types.gd | Updates Godot type mirror with new event/mappings. |
| libraries/godot-iap/addons/godot-iap/godot_iap.gd | Adds cross-platform signal hookup + handlers. |
| libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt | Updates KMP type mirror with new event/subscription field. |
| libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt | Adds subscriptionBillingIssueListener: Flow<Purchase> to public API. |
| libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt | Emits event in Android KMP implementation + dedupe. |
| libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt | Bridges ObjC listener to Flow on iOS. |
| packages/docs/src/pages/docs/index.tsx | Adds docs route + sidebar entry for feature page. |
| packages/docs/src/pages/docs/features/subscription-billing-issue.tsx | New feature page documenting semantics + usage snippets. |
| packages/docs/src/pages/docs/updates/releases.tsx | Adds release-note entry for the feature. |
| packages/docs/public/llms.txt | Recompiled LLM quick reference output timestamp. |
| packages/docs/public/llms-full.txt | Recompiled LLM full reference content. |
| knowledge/internal/sandbox-subscription-billing-issue.md | Adds internal manual sandbox verification guide. |
| knowledge/external/storekit2-api.md | Updates StoreKit 2 external reference (Message API etc.). |
| knowledge/external/google-billing-api.md | Updates Google Billing external reference (External Payments section). |
| knowledge/external/horizon-api.md | Updates Horizon external reference (wording + accessToken docs). |
| knowledge/_claude-context/context.md | Recompiled project context for tooling/LLM ingestion. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 16
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
libraries/react-native-iap/ios/HybridRnIap.swift (1)
1136-1174:⚠️ Potential issue | 🟠 MajorCleanup misses the new subscription-billing-issue listener state.
cleanupExistingState()does not removesubscriptionBillingIssueSuband does not clearsubscriptionBillingIssueListeners. This can leave stale callbacks/subscriptions alive afterendConnection().🧹 Suggested fix
private func cleanupExistingState() { // Cancel transaction listener if any updateListenerTask?.cancel() updateListenerTask = nil isInitialized = false isInitializing = false // Remove OpenIAP listeners & end connection if let sub = purchaseUpdatedSub { RnIapLog.payload("removeListener", "purchaseUpdated") OpenIapModule.shared.removeListener(sub) } if let sub = purchaseErrorSub { RnIapLog.payload("removeListener", "purchaseError") OpenIapModule.shared.removeListener(sub) } if let sub = promotedProductSub { RnIapLog.payload("removeListener", "promotedProduct") OpenIapModule.shared.removeListener(sub) } + if let sub = subscriptionBillingIssueSub { + RnIapLog.payload("removeListener", "subscriptionBillingIssue") + OpenIapModule.shared.removeListener(sub) + } purchaseUpdatedSub = nil purchaseErrorSub = nil promotedProductSub = nil + subscriptionBillingIssueSub = nil Task { RnIapLog.payload("endConnection", nil) let result = try? await OpenIapModule.shared.endConnection() RnIapLog.result("endConnection", result as Any) } // Clear event listeners, error dedup state, and delivery state (thread-safe) listenerLock.withLock { purchaseUpdatedListeners.removeAll() purchaseErrorListeners.removeAll() promotedProductListeners.removeAll() + subscriptionBillingIssueListeners.removeAll() lastPurchaseErrorKey = nil lastPurchaseErrorTimestamp = 0 deliveredPurchaseEventKeys.removeAll() deliveredPurchaseEventOrder.removeAll() }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@libraries/react-native-iap/ios/HybridRnIap.swift` around lines 1136 - 1174, The cleanupExistingState() function currently misses removing the subscription billing issue listener and clearing its listener state; update cleanupExistingState() to cancel/remove subscriptionBillingIssueSub (call OpenIapModule.shared.removeListener and RnIapLog.payload("removeListener", "subscriptionBillingIssue") if sub exists), set subscriptionBillingIssueSub = nil, and inside listenerLock.withLock also clear subscriptionBillingIssueListeners (and any related state like delivered keys if applicable) so no stale callbacks remain after endConnection(); reference the subscriptionBillingIssueSub and subscriptionBillingIssueListeners symbols when making these changes.
🧹 Nitpick comments (1)
libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt (1)
1689-1723: Move this block into the event-listener section.This new listener API sits below private/helper sections, which breaks the native bridge ordering convention used for
react-native-iap.As per coding guidelines, "libraries/react-native-iap/**/*.{swift,kt}: Organize native implementation classes (Swift/Kotlin) in strict order: Properties and Initialization, Public Cross-platform Methods, Platform-specific Public Methods, Event Listener Methods, Private Helper Methods".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt` around lines 1689 - 1723, The subscription billing-issue listener block (vars and methods subscriptionBillingIssueAttached, addSubscriptionBillingIssueListener, removeSubscriptionBillingIssueListener, and attachSubscriptionBillingIssueIfNeeded which references openIap and convertToNitroPurchase) is placed below private/helper sections; move this entire block into the Event Listener Methods section of the class so it follows the project's ordering convention (Properties/Init, Public Cross-platform Methods, Platform-specific Public Methods, Event Listener Methods, then Private Helpers). Ensure you relocate the declaration of subscriptionBillingIssueAttached and the three methods together, keeping their synchronized blocks and the openIap.addSubscriptionBillingIssueListener usage unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@knowledge/_claude-context/context.md`:
- Around line 1235-1245: The fenced code block in
knowledge/_claude-context/context.md triggers MD040 because it lacks a language;
fix it by adding a language identifier (e.g., change the opening fence from ```
to ```text) for the code block that lists the src/pages/docs/features tree so
the markdown linter passes (addressing MD040 for that fenced code block).
- Line 1233: The markdown contains three identical headings "Directory
Structure" (one shown in the diff) causing MD024 duplicate-heading violations;
rename each duplicate heading to a unique title (e.g., append a qualifier like
"Directory Structure — CLI", "Directory Structure — API", or include a
numeric/section qualifier) and update any internal links/anchors that reference
those headings so anchors remain correct; locate the headings by their exact
text "Directory Structure" in the file and change each to a distinct variant.
- Around line 1123-1130: The fenced GraphQL example starting with the code block
containing "type RequestPurchaseResult" violates MD031; fix it by adding a blank
line immediately before the opening ```graphql fence and a blank line
immediately after the closing ``` fence so there is an empty line both above and
below the fenced block (ensure the block around the "type RequestPurchaseResult"
example is separated by those blank lines).
In `@libraries/expo-iap/src/index.ts`:
- Around line 320-323: The subscriptionBillingIssueListener currently forwards
raw Purchase objects from
emitter.addListener(OpenIapEvent.SubscriptionBillingIssue, listener); update it
to normalize the purchase payload (reuse the same normalizePurchase /
normalizePurchasePayload helper used by purchaseUpdatedListener) before invoking
the consumer callback: register a wrapper listener with emitter.addListener that
receives the raw purchase, runs normalizePurchase(raw) and then calls the
provided listener(normalizedPurchase) so consumers always receive a consistent,
normalized Purchase shape.
In `@libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt`:
- Line 86: The endConnection() method is not removing the subscription
billing-issue listener registered in initConnection():
subscriptionBillingIssueListener is added via
openIap.addSubscriptionBillingIssueListener(subscriptionBillingIssueListener)
but never removed, causing duplicate listeners on reconnect; update
endConnection() to call
openIap.removeSubscriptionBillingIssueListener(subscriptionBillingIssueListener)
alongside the existing removal of purchase update/error listeners so the
subscriptionBillingIssueListener is properly detached before disconnecting.
In
`@libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt`:
- Around line 706-718: The mutableSet emittedBillingIssueTokens used in
notifySuspendedSubscriptions is not thread-safe and can race when
getAvailablePurchasesHandler runs on Dispatchers.IO concurrently; replace
emittedBillingIssueTokens with a concurrent set (e.g.,
ConcurrentHashMap.newKeySet() or Collections.newSetFromMap(ConcurrentHashMap()))
to mirror the cachedProductDetails concurrency pattern, and keep the same
add-and-check logic (emittedBillingIssueTokens.add(token)) in
notifySuspendedSubscriptions so duplicate tokens are still suppressed safely.
In
`@libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt`:
- Around line 107-116: initConnection() does not re-register listeners after a
reconnect, so listeners like subscriptionBillingIssueSubscription (registered
via openIapModule.addSubscriptionBillingIssueListener in setupListeners()) stop
emitting after endConnection()/init cycle; fix by invoking setupListeners() (or
extracting listener registration into a reusable method and calling it) after
the connection succeeds inside initConnection(), ensuring
subscriptionBillingIssueSubscription and the other listener subscriptions are
re-created and the flows (e.g., _subscriptionBillingIssueFlow) will emit again.
In
`@libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt`:
- Line 87: endConnection() currently clears other listener collections but
leaves subscriptionBillingIssueListeners and the flag
subscriptionBillingIssueAttached intact, which allows stale JS callbacks after
reconnect; update endConnection() to clear the subscriptionBillingIssueListeners
list and reset subscriptionBillingIssueAttached to false, and ensure any code
path that removes all listeners (e.g., the teardown/cleanup logic similar to
other listener collections) performs the same reset so that
addSubscriptionBillingIssueListener() will reattach the native OpenIAP callback
on next connect.
In `@libraries/react-native-iap/src/hooks/useIAP.ts`:
- Around line 479-489: The listener for subscription billing issues is only
created when optionsRef.current?.onSubscriptionBillingIssue exists at init, so
it never registers if the callback is added later; change the condition to only
check that subscriptionsRef.current.subscriptionBillingIssue is not already set,
then always call subscriptionBillingIssueListener to assign
subscriptionsRef.current.subscriptionBillingIssue, and inside the listener guard
the call with optionsRef.current?.onSubscriptionBillingIssue before invoking it
(use the existing purchase parameter). This ensures the listener is attached
independently of initial callback presence while still checking for the callback
at invocation time.
In `@libraries/react-native-iap/src/index.ts`:
- Around line 598-613: subscriptionBillingIssueNativeHandler currently calls
convertNitroPurchaseToPurchase without validating the native payload, so a
malformed nitroPurchase can throw and prevent notifications; update
subscriptionBillingIssueNativeHandler to call
validateNitroPurchase(nitroPurchase) first (like purchaseUpdateNativeHandler
does), and if validation fails log the error via RnIapConsole.error and return
early, otherwise proceed to convertNitroPurchaseToPurchase and notify
subscriptionBillingIssueJsListeners; keep the same try/catch around each JS
listener callback.
In `@libraries/react-native-iap/src/specs/RnIap.nitro.ts`:
- Around line 1098-1119: The iOS implementation of
removeSubscriptionBillingIssueListener currently clears all listeners; change
HybridRnIap.removeSubscriptionBillingIssueListener to remove only the provided
listener by using subscriptionBillingIssueListeners.removeAll { $0 === listener
} (inside the existing listenerLock.withLock block) instead of
subscriptionBillingIssueListeners.removeAll(), ensuring behavior matches
Android's removeSubscriptionBillingIssueListener and the NitroPurchase listener
contract.
In `@packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift`:
- Around line 241-245: Ensure you don't register the same listener twice by
checking or tearing down any existing token before assigning a new one: before
calling OpenIapModule.shared.subscriptionBillingIssueListener, either guard that
billingIssueListenerToken is nil (so you only register once) or explicitly
remove/invalidate the previous token (e.g., if let old =
billingIssueListenerToken { old.invalidate() /* or call the module's
remove/unsubscribe API with old */; billingIssueListenerToken = nil }) and then
assign the new token to billingIssueListenerToken; refer to
billingIssueListenerToken and
OpenIapModule.shared.subscriptionBillingIssueListener to locate the code.
In `@packages/apple/Sources/OpenIapModule.swift`:
- Line 18: cleanupExistingState() currently only cancels updateListenerTask and
forgets messageListenerTask, so the StoreKit message loop (messageListenerTask)
keeps draining Message.messages after endConnection(); modify
cleanupExistingState() (and ensure endConnection() calls it) to cancel and nil
out messageListenerTask (call messageListenerTask?.cancel() and set
messageListenerTask = nil) and likewise ensure any similar teardown paths clear
that Task so it stops when state.isInitialized is false.
In `@packages/docs/public/llms-full.txt`:
- Around line 343-374: Add documentation for the new event listener by inserting
a new section alongside purchaseUpdatedListener and purchaseErrorListener that
shows how to import and use subscriptionBillingIssueListener; reference the
symbol subscriptionBillingIssueListener in the example, show a minimal callback
(e.g., logging purchase.productId and prompting the user to update payment info
or deep-link to subscriptions), and show cleanup via subscription.remove();
ensure wording and placement match the existing Event Listeners section and
follow the style used for purchaseUpdatedListener and purchaseErrorListener.
In `@packages/docs/src/pages/docs/features/subscription-billing-issue.tsx`:
- Around line 163-172: The GDScript example uses a raw Dictionary for
deep_link_to_subscriptions which will fail because deep_link_to_subscriptions
expects a Types.DeepLinkOptions instance (or null) and calls .to_dict() on it;
modify the example around the subscription_billing_issue handler to construct
and pass a Types.DeepLinkOptions object (e.g. instantiate Types.DeepLinkOptions,
set skuAndroid and packageNameAndroid) or pass null, and then call
godot_iap.deep_link_to_subscriptions(...) with that object so the method's
.to_dict() call succeeds.
---
Outside diff comments:
In `@libraries/react-native-iap/ios/HybridRnIap.swift`:
- Around line 1136-1174: The cleanupExistingState() function currently misses
removing the subscription billing issue listener and clearing its listener
state; update cleanupExistingState() to cancel/remove
subscriptionBillingIssueSub (call OpenIapModule.shared.removeListener and
RnIapLog.payload("removeListener", "subscriptionBillingIssue") if sub exists),
set subscriptionBillingIssueSub = nil, and inside listenerLock.withLock also
clear subscriptionBillingIssueListeners (and any related state like delivered
keys if applicable) so no stale callbacks remain after endConnection();
reference the subscriptionBillingIssueSub and subscriptionBillingIssueListeners
symbols when making these changes.
---
Nitpick comments:
In
`@libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt`:
- Around line 1689-1723: The subscription billing-issue listener block (vars and
methods subscriptionBillingIssueAttached, addSubscriptionBillingIssueListener,
removeSubscriptionBillingIssueListener, and
attachSubscriptionBillingIssueIfNeeded which references openIap and
convertToNitroPurchase) is placed below private/helper sections; move this
entire block into the Event Listener Methods section of the class so it follows
the project's ordering convention (Properties/Init, Public Cross-platform
Methods, Platform-specific Public Methods, Event Listener Methods, then Private
Helpers). Ensure you relocate the declaration of
subscriptionBillingIssueAttached and the three methods together, keeping their
synchronized blocks and the openIap.addSubscriptionBillingIssueListener usage
unchanged.
🪄 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
Run ID: 114536cb-4e28-468d-9aea-f135e6d942f7
📒 Files selected for processing (32)
knowledge/_claude-context/context.mdknowledge/external/horizon-api.mdlibraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.ktlibraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.ktlibraries/expo-iap/ios/ExpoIapHelper.swiftlibraries/expo-iap/ios/ExpoIapModule.swiftlibraries/expo-iap/src/index.tslibraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.ktlibraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swiftlibraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dartlibraries/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/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.ktlibraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.ktlibraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.ktlibraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.ktlibraries/react-native-iap/ios/HybridRnIap.swiftlibraries/react-native-iap/src/hooks/useIAP.tslibraries/react-native-iap/src/index.tslibraries/react-native-iap/src/specs/RnIap.nitro.tspackages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swiftpackages/apple/Sources/OpenIapModule.swiftpackages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swiftpackages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swiftpackages/docs/public/llms-full.txtpackages/docs/public/llms.txtpackages/docs/src/pages/docs/features/subscription-billing-issue.tsxpackages/docs/src/pages/docs/index.tsxpackages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.ktpackages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.ktpackages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
✅ Files skipped from review due to trivial changes (1)
- packages/docs/public/llms.txt
🚧 Files skipped from review as they are similar to previous changes (1)
- knowledge/external/horizon-api.md
… listener The listener callback receives `Purchase` (the sealed union), not `PurchaseIOS`. Cast via `.asIOS()` before assigning to the `OpenIapPurchase` (aka PurchaseIOS) state. Matches the pattern already used for `onPurchaseSuccess` in this file. Xcode build failure reported: "Cannot assign value of type 'Purchase' to type 'OpenIapPurchase' (aka 'PurchaseIOS')" at SubscriptionFlowScreen.swift:243. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Apple (packages/apple):
- OpenIapModule.startMessageListener docstring now matches Apple's actual
API surface (18.0+ for .billingIssue; not 16+ — that's a different
availability)
- cleanupExistingState cancels messageListenerTask alongside
updateListenerTask so endConnection() stops draining Message.messages
- apple Example SubscriptionFlowScreen guards duplicate listener
registration if setup runs again before teardown
Play flavor (packages/google):
- endConnection now clears emittedBillingIssueTokens and
subscriptionBillingIssueListeners; a fresh initConnection() can
re-emit for previously-seen tokens and late-attached listeners
react-native-iap:
- src/index.ts: subscriptionBillingIssueListener() retries native
attachment every call (previously a listener registered before Nitro
came up stayed inert forever); validates payload via
validateNitroPurchase before converting
- ios HybridRnIap: removeSubscriptionBillingIssueListener pops the last
entry instead of removing all — matches Android semantics
- android HybridRnIap: endConnection resets
subscriptionBillingIssueListeners + subscriptionBillingIssueAttached
so stale JS callbacks don't survive reconnect
- useIAP hook: listener attached unconditionally; late-added
onSubscriptionBillingIssue callback now fires
expo-iap: subscriptionBillingIssueListener wraps the emitter callback
through normalizePurchasePlatform, matching purchaseUpdatedListener
kmp-iap:
- Android emittedBillingIssueTokens switched to
ConcurrentHashMap.newKeySet() for safe add() under Dispatchers.IO
- iOS: setupListeners is now idempotent + called from initConnection()
after a successful reconnect; endConnection nulls the subscription
tokens so flows resume emitting post reconnect
- iOS: stale "follow-up release" comment removed
godot-iap Android: endConnection now calls
removeSubscriptionBillingIssueListener, matching the other listener
teardowns so reconnects don't double-register
Docs:
- feature page SEO "Android 8.1+" → "Play Billing Library 8.1+"
- knowledge/external/storekit2-api.md section header "External Purchase
Support (iOS 18.2+)" → "(iOS 17.4+)" to match the corrected version
table; follow-on custom-link APIs noted as 18.1+
- packages/docs releases.tsx note now says downstream wiring ships with
this PR (not "next per-library release")
- llms.txt / llms-full.txt / context.md recompiled
Verified locally:
- swift build -> clean
- gradlew :openiap:{compilePlayDebugKotlin,compileHorizonDebugKotlin,
testHorizonDebugUnitTest,assembleHorizonDebug},
:example:compilePlayDebugKotlin -> all green
- rn-iap yarn tsc --noEmit -p tsconfig.build.json -> clean
- expo-iap bun run tsc --noEmit -> clean
- flutter analyze -> no issues
- kmp-iap gradlew :library:compileDebugKotlinAndroid -> clean
- docs tsc --noEmit -> clean
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The useIAP hook now attaches the listener unconditionally (fix for review comment #3089615611). Four existing test files mock IAP.instance but did not include the new Nitro method, causing: TypeError: IAP.instance.addSubscriptionBillingIssueListener is not a function in useIAP.test.ts / useIAP.android.test.ts during registerListeners. Added add/removeSubscriptionBillingIssueListener stubs in: - src/__tests__/hooks/useIAP.test.ts - src/__tests__/hooks/useIAP.android.test.ts - src/__tests__/index.test.ts - src/__tests__/platform-detection.test.ts Verified: yarn test -> 269/269 tests pass, 12/12 suites. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- [P1] rn-iap iOS: clear subscriptionBillingIssueSub and listeners in cleanupExistingState() so reconnect re-registers the OpenIAP listener - [P2] openiap-google Play: wire subscriptionBillingIssue into SubscriptionHandlers via new onSubscriptionBillingIssue helper - [P2] openiap-google Horizon: wire handler that throws FeatureNotSupported (Horizon Billing 7.0 lacks isSuspended signal) - [P2] flutter: add subscriptionBillingIssue to subscriptionHandlers getter - [P2] kmp-iap Android: clear emittedBillingIssueTokens on endConnection Tests: - Play/Horizon handler bundle exposure (Robolectric) - Flutter subscriptionHandlers non-null assertion - kmp-iap dedupe set reset via reflection - rn-iap iOS XCTest target + reconnect regression test (Xcode 26.4 env issue blocks local run; CI with Xcode 16.x should pass) Docs: - events.tsx: subscription-billing-issue-event section - home.tsx: expand APIs (6), Events (6), Types (6) cards Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new cross-platform subscriptionBillingIssue event that unifies StoreKit 2 billing-issue messages (iOS 18+ / Mac Catalyst 18+) and Google Play Billing suspended subscriptions (Purchase.isSuspended, Billing 8.1+) into a single listener surface, and wires it through the schema, native SDKs, downstream bridges, examples, docs, and tests.
Changes:
- GraphQL schema + generated types updated with
IapEvent.SubscriptionBillingIssueandsubscriptionBillingIssue: Purchase!. - iOS adds a StoreKit
Message.messagesloop and emits events for subscriptions in billing retry/grace. - Android Play flavor detects suspended purchases (deduped by
purchaseToken), Horizon flavor is explicit no-op/unsupported; downstream libraries + docs updated accordingly.
Reviewed changes
Copilot reviewed 70 out of 75 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/gql/src/type.graphql | Add SubscriptionBillingIssue enum |
| packages/gql/src/event.graphql | Add subscriptionBillingIssue subscription field |
| packages/gql/src/generated/types.ts | Regenerate TS types for event/field |
| packages/gql/src/generated/types.gd | Regenerate GDScript types for event |
| packages/gql/src/generated/types.dart | Regenerate Dart types for event |
| packages/gql/src/generated/Types.swift | Regenerate Swift types for event |
| packages/gql/src/generated/Types.kt | Regenerate Kotlin types for event |
| packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt | Emit event from Play suspended purchases |
| packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt | Horizon handler throws + listener no-op |
| packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt | Add listener methods to protocol |
| packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt | Add listener interface type |
| packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/CommonHelpers.kt | Add suspend helper for event |
| packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt | Expose add/remove passthroughs |
| packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt | Add event enum + handler signature |
| packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionHandlersBillingIssueTest.kt | Play handler presence test |
| packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionHandlersBillingIssueHorizonTest.kt | Horizon handler throws test |
| packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionBillingIssueHorizonNoOpTest.kt | Horizon no-op listener test |
| packages/google/openiap/build.gradle.kts | Add testHorizon src + Robolectric deps |
| packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt | Example billing-issue banner + deep link |
| packages/apple/Sources/OpenIapProtocol.swift | Add Swift listener type + protocol method |
| packages/apple/Sources/OpenIapModule.swift | Start Message listener + emit event |
| packages/apple/Sources/OpenIapModule+ObjC.swift | ObjC bridge for new listener |
| packages/apple/Sources/Helpers/IapState.swift | Store/snapshot new listeners |
| packages/apple/Sources/Models/Types.swift | Mirror generated Types additions |
| packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift | Fake module implements new API |
| packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift | Fake module implements new API |
| packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift | Example banner + listener wiring |
| libraries/react-native-iap/src/specs/RnIap.nitro.ts | Nitro spec add/remove methods |
| libraries/react-native-iap/src/index.ts | JS API subscriptionBillingIssueListener |
| libraries/react-native-iap/src/hooks/useIAP.ts | Hook callback onSubscriptionBillingIssue |
| libraries/react-native-iap/src/types.ts | Update generated TS types mirror |
| libraries/react-native-iap/src/tests/platform-detection.test.ts | Mock new native methods |
| libraries/react-native-iap/src/tests/index.test.ts | Mock new native methods |
| libraries/react-native-iap/src/tests/hooks/useIAP.test.ts | Mock new native methods |
| libraries/react-native-iap/src/tests/hooks/useIAP.android.test.ts | Mock new native methods |
| libraries/react-native-iap/ios/HybridRnIap.swift | iOS bridge wiring + reconnect cleanup |
| libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt | Android bridge wiring + listener list |
| libraries/react-native-iap/example/ios/exampleTests/SubscriptionBillingIssueReconnectTests.swift | iOS reconnect regression test |
| libraries/react-native-iap/example/ios/exampleTests/Info.plist | Add test bundle Info.plist |
| libraries/react-native-iap/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme | Scheme target id update |
| libraries/react-native-iap/example/ios/example.xcodeproj/project.pbxproj | Add exampleTests target/project wiring |
| libraries/react-native-iap/example/ios/add_test_target.rb | Helper script to add test target |
| libraries/react-native-iap/example/ios/Podfile | Add test target + fmt workaround |
| libraries/expo-iap/src/types.ts | Update generated TS types mirror |
| libraries/expo-iap/src/index.ts | Add event enum + listener helper |
| libraries/expo-iap/ios/ExpoIapModule.swift | Register new emitted event |
| libraries/expo-iap/ios/ExpoIapHelper.swift | Wire OpenIap listener to Expo events |
| libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt | Register new emitted event |
| libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt | Wire OpenIap listener to Expo events |
| libraries/flutter_inapp_purchase/lib/types.dart | Update generated Dart types mirror |
| libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart | Add stream + method channel routing |
| libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift | iOS listener to method channel |
| libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt | Android listener to method channel |
| libraries/flutter_inapp_purchase/test/subscription_handlers_test.dart | Flutter handler presence test |
| libraries/godot-iap/addons/godot-iap/types.gd | Update generated GDScript types mirror |
| libraries/godot-iap/addons/godot-iap/godot_iap.gd | Add new signal + handlers |
| libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt | Emit new signal on Android |
| libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift | Emit new signal on iOS |
| libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt | Add Flow to public KMP API |
| libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt | Update generated Kotlin types mirror |
| libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt | Emit Flow + token dedupe |
| libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/SubscriptionBillingIssueDedupResetTest.kt | Dedup reset regression test |
| libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt | iOS flow + reconnect listener fix |
| packages/docs/src/pages/home.tsx | Add quick links for APIs/events/types |
| packages/docs/src/pages/docs/index.tsx | Add route + sidebar entry |
| packages/docs/src/pages/docs/features/subscription-billing-issue.tsx | New feature guide page |
| packages/docs/src/pages/docs/events.tsx | Document new event |
| packages/docs/src/pages/docs/updates/releases.tsx | Add release note entry |
| packages/docs/public/llms.txt | Refresh generated docs index stamp |
| knowledge/internal/sandbox-subscription-billing-issue.md | Add sandbox QA procedure |
| knowledge/external/storekit2-api.md | Update StoreKit2 external reference |
| knowledge/external/google-billing-api.md | Add external payments program docs |
| knowledge/external/horizon-api.md | Update Horizon API notes |
| knowledge/_claude-context/context.md | Refresh generated repo context |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt (1)
666-703:⚠️ Potential issue | 🟠 MajorBilling-issue emission is accidentally gated behind
includeSuspendedAndroid.
notifySuspendedSubscriptions(all)only inspects whateverqueryPurchasesAsyncreturned. SincesetIncludeSuspended(true)is only called when the caller opts in viaoptions?.includeSuspendedAndroid == true, the defaultgetAvailablePurchases()path never retrieves suspended subscriptions. Consequently,subscriptionBillingIssueListener/subscriptionBillingIssue()can wait indefinitely unless the app explicitly passes an Android-only flag.🩹 Always inspect for billing issues, but filter from returned list
val all = mutableListOf<Purchase>() all += query(BillingClient.ProductType.INAPP, includeSuspendedSubs = false) - all += query(BillingClient.ProductType.SUBS, includeSuspendedSubs = includeSuspended) - notifySuspendedSubscriptions(all) + + val subs = query( + BillingClient.ProductType.SUBS, + includeSuspendedSubs = includeSuspended + ) + all += subs + + val subsForBillingIssue = if (includeSuspended) { + subs + } else { + query( + BillingClient.ProductType.SUBS, + includeSuspendedSubs = true + ) + } + notifySuspendedSubscriptions(subsForBillingIssue) all🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt` around lines 666 - 703, The code currently only calls setIncludeSuspended when options?.includeSuspendedAndroid is true, so notifySuspendedSubscriptions inside getAvailablePurchasesHandler can never detect suspended subscriptions unless the caller opted in; change logic to always request suspended subscriptions from the BillingClient when querying SUBS (invoke the reflected setIncludeSuspended(true) inside the query call for BillingClient.ProductType.SUBS regardless of options), then, if options?.includeSuspendedAndroid is not true, filter out purchases with isSuspendedAndroid==true from the returned list before adding to all; keep notifySuspendedSubscriptions(all) working on the full list that includes suspended entries so the subscriptionBillingIssueListener is always triggered.
🧹 Nitpick comments (3)
libraries/flutter_inapp_purchase/test/subscription_handlers_test.dart (1)
13-24: Test validates wiring; consider adding error-path coverage.The smoke test guards against regression in the handler bundle wiring, which is its stated purpose. For comprehensive coverage per coding guidelines, consider adding tests for:
- The error catch block in the
'subscription-billing-issue'handler (verifyingdebugPrintis called and stream doesn't break on malformed JSON)- Actual event emission through
subscriptionBillingIssueListenerThis can be addressed in a follow-up if the native bridge work is deferred.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@libraries/flutter_inapp_purchase/test/subscription_handlers_test.dart` around lines 13 - 24, Add tests covering the error path and actual emission for the subscription handlers: write a test that invokes the 'subscription-billing-issue' handler with malformed JSON and asserts that debugPrint is called and the subscriptionBillingIssue stream does not close or throw (reference the subscription-billing-issue handler and debugPrint), and add a test that emits a well-formed event through the platform handler and verifies subscriptionBillingIssueListener (or subscriptionBillingIssue stream from FlutterInappPurchase.instance.subscriptionHandlers) receives the expected parsed payload; locate logic around subscriptionHandlers, subscriptionBillingIssue, and subscriptionBillingIssueListener to hook the platform handler and simulate both malformed and valid event payloads.libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt (1)
1698-1726: Move these methods into the Event Listener Methods section.
addSubscriptionBillingIssueListener/removeSubscriptionBillingIssueListenerare event listener methods but are currently placed in the billing-program area. Please align placement with the class ordering rule.As per coding guidelines
libraries/react-native-iap/**/*.{swift,kt}: "Organize native implementation classes (Swift/Kotlin) in strict order: Properties and Initialization, Public Cross-platform Methods, Platform-specific Public Methods, Event Listener Methods, Private Helper Methods".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt` around lines 1698 - 1726, The addSubscriptionBillingIssueListener and removeSubscriptionBillingIssueListener methods are in the wrong section—move both methods (and their helper attachSubscriptionBillingIssueIfNeeded and related fields subscriptionBillingIssueAttached and the openIap.addSubscriptionBillingIssueListener block) into the "Event Listener Methods" section of HybridRnIap.kt so the class follows the prescribed ordering (Properties/Init, Public Cross-platform, Platform-specific Public, Event Listener Methods, Private Helper Methods); ensure you keep the synchronized blocks and the attachSubscriptionBillingIssueIfNeeded implementation intact when relocating.libraries/react-native-iap/example/ios/Podfile (1)
90-99: Add a warning when the fmt header patch doesn't match the expected text.Line 97 depends on the exact token
'# define FMT_USE_CONSTEVAL 1'existing in the installed header. If fmt reflows that macro or ships an update with different formatting,pod installsilently does nothing and the Xcode 26 build fails later without clear context. Add a check that warns if the header exists but the expected token is missing.🧩 One way to make drift obvious
if File.exist?(fmt_base) File.chmod(0644, fmt_base) rescue nil # pod checkout is read-only src = File.read(fmt_base) - patched = src.gsub('# define FMT_USE_CONSTEVAL 1', '# define FMT_USE_CONSTEVAL 0 // Xcode 26 workaround') + expected = '# define FMT_USE_CONSTEVAL 1' + unless src.include?(expected) + warn '[Podfile] fmt workaround did not match the installed header; revisit the Xcode 26 patch.' + end + patched = src.gsub(expected, '# define FMT_USE_CONSTEVAL 0 // Xcode 26 workaround') File.write(fmt_base, patched) if patched != src end🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@libraries/react-native-iap/example/ios/Podfile` around lines 90 - 99, The patch silently no-ops if the expected token '# define FMT_USE_CONSTEVAL 1' is absent; update the block around fmt_base/File.read that computes patched (and uses gsub) to detect when File.exist?(fmt_base) is true but the source does not contain the exact token, and emit a clear warning (e.g., via UI.warn or puts) indicating the header was found but the expected macro line wasn't present so the Xcode 26 workaround could not be applied; keep the existing File.chmod, gsub and File.write behavior but add this additional conditional warning when patched == src and src.scan for the token returns none to make drift obvious.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@knowledge/internal/sandbox-subscription-billing-issue.md`:
- Around line 53-57: The markdown blocks with nested code fences are missing
blank lines around the fenced code (triggering MD031); update the sections that
show "Console logs:" and the "logcat" block (the console text blocks showing "🔔
[MessageListener] billingIssue received..." and "D OpenIapModule:
onPurchasesUpdated...") to insert a blank line both immediately before the
opening ```text fence and immediately after the closing ``` fence (also apply
the same fix to the second occurrence around the 91-95 block) so each fenced
code block is separated by an empty line from surrounding paragraph text.
In
`@libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt`:
- Around line 1711-1725: The attachSubscriptionBillingIssueIfNeeded function can
race: check and set of subscriptionBillingIssueAttached happen unsynchronized
allowing double-registration; fix by performing the test-and-set and the call to
openIap.addSubscriptionBillingIssueListener inside a single synchronized block
(use the existing subscriptionBillingIssueListeners as the monitor or introduce
a dedicated lock object) so that subscriptionBillingIssueAttached is read, set
to true, and the listener is registered atomically; keep the existing listener
body (convertToNitroPurchase, snapshot copy, forEach) outside or inside the same
block as appropriate to avoid holding the lock during callbacks.
In `@libraries/react-native-iap/example/ios/example.xcodeproj/project.pbxproj`:
- Around line 42-43: The Foundation.framework PBXFileReference is hard-pinned to
iPhoneOS18.0.sdk; update the PBXFileReference entry for the identifier/name
"7CB695629D08A69F09E048C9" / "Foundation.framework" so it uses an
SDKROOT-relative reference like the JavaScriptCore entry does: set the path to
the SDK-relative System/Library/Frameworks/Foundation.framework and change
sourceTree to SDKROOT (instead of embedding the specific iPhoneOS18.0.sdk in the
path) so the project works across Xcode versions.
In `@libraries/react-native-iap/src/__tests__/index.test.ts`:
- Around line 32-33: Add unit/integration tests in
libraries/react-native-iap/src/__tests__/index.test.ts that exercise the new
subscriptionBillingIssueListener lifecycle: verify that
addSubscriptionBillingIssueListener attaches the native listener (spy on
addSubscriptionBillingIssueListener mock and simulate native emit to ensure the
JS handler is called), verify removeSubscriptionBillingIssueListener removes the
handler so subsequent native emits do not call JS, test that malformed payloads
emitted from native are dropped (no handler invocation and optionally log/error
path), and test reconnect/reset behavior by simulating a native reconnect/reset
sequence and ensuring the listener is re-attached or cleared appropriately;
target the functions addSubscriptionBillingIssueListener,
removeSubscriptionBillingIssueListener and the public
subscriptionBillingIssueListener event path and assert correct
attach/remove/invocation behavior and no-calls on malformed payloads.
In `@libraries/react-native-iap/src/index.ts`:
- Around line 640-646: The JS listener is added to
subscriptionBillingIssueJsListeners before attempting native registration, so if
addSubscriptionBillingIssueListener (called inside
tryAttachSubscriptionBillingIssueNative) throws a non-"Nitro not ready" error
the listener remains in the Set; update subscriptionBillingIssueListener to
remove the listener from subscriptionBillingIssueJsListeners when native
registration fails (catch the thrown error from
tryAttachSubscriptionBillingIssueNative/addSubscriptionBillingIssueListener,
rethrow the error after removing the listener), and preserve the
EventSubscription remove semantics so callers that never received a handle
cannot leak callbacks.
---
Outside diff comments:
In
`@libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt`:
- Around line 666-703: The code currently only calls setIncludeSuspended when
options?.includeSuspendedAndroid is true, so notifySuspendedSubscriptions inside
getAvailablePurchasesHandler can never detect suspended subscriptions unless the
caller opted in; change logic to always request suspended subscriptions from the
BillingClient when querying SUBS (invoke the reflected setIncludeSuspended(true)
inside the query call for BillingClient.ProductType.SUBS regardless of options),
then, if options?.includeSuspendedAndroid is not true, filter out purchases with
isSuspendedAndroid==true from the returned list before adding to all; keep
notifySuspendedSubscriptions(all) working on the full list that includes
suspended entries so the subscriptionBillingIssueListener is always triggered.
---
Nitpick comments:
In `@libraries/flutter_inapp_purchase/test/subscription_handlers_test.dart`:
- Around line 13-24: Add tests covering the error path and actual emission for
the subscription handlers: write a test that invokes the
'subscription-billing-issue' handler with malformed JSON and asserts that
debugPrint is called and the subscriptionBillingIssue stream does not close or
throw (reference the subscription-billing-issue handler and debugPrint), and add
a test that emits a well-formed event through the platform handler and verifies
subscriptionBillingIssueListener (or subscriptionBillingIssue stream from
FlutterInappPurchase.instance.subscriptionHandlers) receives the expected parsed
payload; locate logic around subscriptionHandlers, subscriptionBillingIssue, and
subscriptionBillingIssueListener to hook the platform handler and simulate both
malformed and valid event payloads.
In
`@libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt`:
- Around line 1698-1726: The addSubscriptionBillingIssueListener and
removeSubscriptionBillingIssueListener methods are in the wrong section—move
both methods (and their helper attachSubscriptionBillingIssueIfNeeded and
related fields subscriptionBillingIssueAttached and the
openIap.addSubscriptionBillingIssueListener block) into the "Event Listener
Methods" section of HybridRnIap.kt so the class follows the prescribed ordering
(Properties/Init, Public Cross-platform, Platform-specific Public, Event
Listener Methods, Private Helper Methods); ensure you keep the synchronized
blocks and the attachSubscriptionBillingIssueIfNeeded implementation intact when
relocating.
In `@libraries/react-native-iap/example/ios/Podfile`:
- Around line 90-99: The patch silently no-ops if the expected token '# define
FMT_USE_CONSTEVAL 1' is absent; update the block around fmt_base/File.read that
computes patched (and uses gsub) to detect when File.exist?(fmt_base) is true
but the source does not contain the exact token, and emit a clear warning (e.g.,
via UI.warn or puts) indicating the header was found but the expected macro line
wasn't present so the Xcode 26 workaround could not be applied; keep the
existing File.chmod, gsub and File.write behavior but add this additional
conditional warning when patched == src and src.scan for the token returns none
to make drift obvious.
🪄 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
Run ID: 1f87ca44-2bb0-41f8-b4ef-12131d369a9d
📒 Files selected for processing (39)
knowledge/_claude-context/context.mdknowledge/external/storekit2-api.mdknowledge/internal/sandbox-subscription-billing-issue.mdlibraries/expo-iap/src/index.tslibraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dartlibraries/flutter_inapp_purchase/test/subscription_handlers_test.dartlibraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.ktlibraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.ktlibraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/SubscriptionBillingIssueDedupResetTest.ktlibraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.ktlibraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.ktlibraries/react-native-iap/example/ios/Podfilelibraries/react-native-iap/example/ios/add_test_target.rblibraries/react-native-iap/example/ios/example.xcodeproj/project.pbxprojlibraries/react-native-iap/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcschemelibraries/react-native-iap/example/ios/exampleTests/Info.plistlibraries/react-native-iap/example/ios/exampleTests/SubscriptionBillingIssueReconnectTests.swiftlibraries/react-native-iap/ios/HybridRnIap.swiftlibraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.tslibraries/react-native-iap/src/__tests__/hooks/useIAP.test.tslibraries/react-native-iap/src/__tests__/index.test.tslibraries/react-native-iap/src/__tests__/platform-detection.test.tslibraries/react-native-iap/src/hooks/useIAP.tslibraries/react-native-iap/src/index.tspackages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swiftpackages/apple/Sources/OpenIapModule.swiftpackages/docs/public/llms-full.txtpackages/docs/public/llms.txtpackages/docs/src/pages/docs/events.tsxpackages/docs/src/pages/docs/features/subscription-billing-issue.tsxpackages/docs/src/pages/docs/updates/releases.tsxpackages/docs/src/pages/home.tsxpackages/google/openiap/build.gradle.ktspackages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.ktpackages/google/openiap/src/main/java/dev/hyo/openiap/helpers/CommonHelpers.ktpackages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.ktpackages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionBillingIssueHorizonNoOpTest.ktpackages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionHandlersBillingIssueHorizonTest.ktpackages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionHandlersBillingIssueTest.kt
✅ Files skipped from review due to trivial changes (12)
- libraries/react-native-iap/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme
- libraries/react-native-iap/src/tests/hooks/useIAP.test.ts
- packages/docs/public/llms.txt
- libraries/react-native-iap/src/tests/hooks/useIAP.android.test.ts
- libraries/react-native-iap/src/tests/platform-detection.test.ts
- libraries/react-native-iap/example/ios/exampleTests/Info.plist
- packages/docs/src/pages/home.tsx
- packages/docs/src/pages/docs/features/subscription-billing-issue.tsx
- packages/docs/src/pages/docs/events.tsx
- libraries/expo-iap/src/index.ts
- packages/docs/src/pages/docs/updates/releases.tsx
- packages/docs/public/llms-full.txt
🚧 Files skipped from review as they are similar to previous changes (6)
- libraries/react-native-iap/src/hooks/useIAP.ts
- packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift
- libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt
- libraries/react-native-iap/ios/HybridRnIap.swift
- knowledge/external/storekit2-api.md
- packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
…x testing Log all incoming StoreKit messages (not just billingIssue) and listener lifecycle events so sandbox testers can verify whether the listener is active and what messages arrive during renewal cycles. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- index.ts: clean up JS listener from Set when native attach throws - HybridRnIap.kt: synchronize attach-guard with lock to prevent double-registration of native billing-issue listener - Play OpenIapModule.kt: stop clearing subscriptionBillingIssueListeners on endConnection — only clear dedupe tokens for consistency with purchaseUpdate/purchaseError listener lifecycle - project.pbxproj: use SDKROOT instead of SDK-versioned path for Foundation.framework - sandbox-subscription-billing-issue.md: MD031 blank lines around fences - index.test.ts: behavioral tests for subscriptionBillingIssueListener (attach, remove, cleanup on native failure) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the outdated App Store Connect "Billing Issue toggle" approach with the device-side method: Settings → Developer → Sandbox Account → Manage → disable "Allow Purchases & Renewals" (iOS 16+). Reference: https://developer.apple.com/documentation/storekit/testing-failing-subscription-renewals-and-in-app-purchases Also fix MD031 blank-line warnings and ordered list numbering. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new cross-platform subscriptionBillingIssue event that normalizes StoreKit 2 billing-issue messages (iOS 18+/Mac Catalyst 18+) and Google Play suspended subscriptions (Billing 8.1+) into a single listener surface across the core SDK, schema/types, downstream libraries, examples, and docs.
Changes:
- Extend the GraphQL schema + regenerate all platform type outputs to include
IapEvent.SubscriptionBillingIssueandSubscription.subscriptionBillingIssue. - Implement native emit/listener plumbing on iOS (StoreKit
Message.messages) and Android Play flavor (Purchase.isSuspended+ session dedupe), with Horizon parity APIs as no-op/unsupported. - Wire the event through RN / Expo / Flutter / Godot / KMP bridges + examples + docs and add regression tests.
Reviewed changes
Copilot reviewed 70 out of 75 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/gql/src/type.graphql | Adds new IapEvent.SubscriptionBillingIssue enum value and schema docstring. |
| packages/gql/src/event.graphql | Adds subscriptionBillingIssue: Purchase! subscription field. |
| packages/gql/src/generated/types.ts | Regenerated TS types including new event + subscription field. |
| packages/gql/src/generated/types.gd | Regenerated GDScript types/mappings for new event. |
| packages/gql/src/generated/types.dart | Regenerated Dart types including new event + handler bundle wiring. |
| packages/gql/src/generated/Types.swift | Regenerated Swift types including new event + handler bundle wiring. |
| packages/gql/src/generated/Types.kt | Regenerated Kotlin types including new event + handler bundle wiring. |
| packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt | Play implementation: listener registration, suspended detection, session dedupe, emit hook. |
| packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt | Horizon implementation: handler throws unsupported; add/remove listener are explicit no-ops with warnings. |
| packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt | Adds new listener methods to the public protocol. |
| packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt | Introduces OpenIapSubscriptionBillingIssueListener interface + docs. |
| packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/CommonHelpers.kt | Adds onSubscriptionBillingIssue() suspend helper. |
| packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt | Exposes store-level add/remove passthrough for the new listener. |
| packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt | Regenerated Kotlin types mirror for main package. |
| packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionHandlersBillingIssueTest.kt | Play test: asserts handler bundle wiring exists. |
| packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionHandlersBillingIssueHorizonTest.kt | Horizon test: asserts handler exists and throws FeatureNotSupported. |
| packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionBillingIssueHorizonNoOpTest.kt | Horizon test: listener add/remove are no-ops and never invoke callback. |
| packages/google/openiap/build.gradle.kts | Adds Robolectric + test source set wiring for Horizon JVM tests. |
| packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt | Example UI: banner + listener registration + deep-link action. |
| packages/apple/Sources/OpenIapProtocol.swift | Adds subscriptionBillingIssueListener to the protocol. |
| packages/apple/Sources/OpenIapModule.swift | iOS implementation: StoreKit Message.messages loop + entitlement scan + emit. |
| packages/apple/Sources/OpenIapModule+ObjC.swift | ObjC bridge for addSubscriptionBillingIssueListener. |
| packages/apple/Sources/Helpers/IapState.swift | Tracks subscription billing-issue listeners in actor state. |
| packages/apple/Sources/Models/Types.swift | Regenerated local Swift model types mirror for new event + handler. |
| packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift | Updates test fake to implement new protocol method. |
| packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift | Updates test fake to implement new protocol method. |
| packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift | Example UI: banner + listener lifecycle management. |
| libraries/react-native-iap/src/specs/RnIap.nitro.ts | Adds Nitro spec methods for add/remove subscription billing issue listener. |
| libraries/react-native-iap/src/index.ts | Adds JS API subscriptionBillingIssueListener() and internal listener management. |
| libraries/react-native-iap/src/hooks/useIAP.ts | Adds onSubscriptionBillingIssue hook option + listener wiring/cleanup. |
| libraries/react-native-iap/src/types.ts | Regenerated TS types mirror with new event + subscription field. |
| libraries/react-native-iap/src/tests/platform-detection.test.ts | Updates mocks to include new native methods. |
| libraries/react-native-iap/src/tests/index.test.ts | Adds tests for subscriptionBillingIssueListener. |
| libraries/react-native-iap/src/tests/hooks/useIAP.test.ts | Updates hook mocks to include new native methods. |
| libraries/react-native-iap/src/tests/hooks/useIAP.android.test.ts | Updates Android hook mocks to include new native methods. |
| libraries/react-native-iap/ios/HybridRnIap.swift | iOS Nitro bridge: attach/detach + listener list management. |
| libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt | Android Nitro bridge: listener list + native attach path for billing-issue events. |
| libraries/react-native-iap/example/ios/exampleTests/SubscriptionBillingIssueReconnectTests.swift | Adds iOS regression test for reconnect behavior using reflection. |
| libraries/react-native-iap/example/ios/exampleTests/Info.plist | Adds Info.plist for new XCTest bundle target. |
| libraries/react-native-iap/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme | Scheme tweak to include the new test target. |
| libraries/react-native-iap/example/ios/example.xcodeproj/project.pbxproj | Adds exampleTests target + sources/framework wiring. |
| libraries/react-native-iap/example/ios/add_test_target.rb | One-shot script to create the test target (project maintenance). |
| libraries/react-native-iap/example/ios/Podfile | Adds CocoaPods target for exampleTests. |
| libraries/expo-iap/src/index.ts | Adds OpenIapEvent.SubscriptionBillingIssue + JS listener helper. |
| libraries/expo-iap/src/types.ts | Regenerated TS types mirror with new event + subscription field. |
| libraries/expo-iap/ios/ExpoIapModule.swift | Registers the new event name for Expo module events. |
| libraries/expo-iap/ios/ExpoIapHelper.swift | Wires OpenIAP listener to Expo event emitter. |
| libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt | Registers new Android event name and buffering paths. |
| libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt | Adds native subscription billing-issue listener emission to JS. |
| libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart | Adds Dart stream + method-channel handling + handler bundle wiring. |
| libraries/flutter_inapp_purchase/lib/types.dart | Regenerated Dart types mirror with new event + subscription field. |
| libraries/flutter_inapp_purchase/test/subscription_handlers_test.dart | Test ensuring handler bundle exposes subscriptionBillingIssue. |
| libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift | iOS listener wiring + method-channel emission. |
| libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt | Android listener wiring + method-channel emission. |
| libraries/godot-iap/addons/godot-iap/types.gd | Regenerated GDScript types mirror with new event + mapping. |
| libraries/godot-iap/addons/godot-iap/godot_iap.gd | Adds new signal + platform hookups to propagate event to GDScript. |
| libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt | Android plugin: add/remove listener and emit signal payload. |
| libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift | iOS extension: add signal + listener wiring + payload conversion. |
| libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt | Adds subscriptionBillingIssueListener: Flow<Purchase> to KMP API. |
| libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt | Regenerated common types mirror including new event + handler. |
| libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt | iOS wiring: OpenIAP listener -> shared flow + reconnect handling. |
| libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt | Android wiring: suspended detection + dedupe + flow emission. |
| libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/SubscriptionBillingIssueDedupResetTest.kt | Android unit test guarding dedupe reset on endConnection. |
| packages/docs/src/pages/docs/features/subscription-billing-issue.tsx | Adds a dedicated feature guide page (behavior table, usage snippets, deduping). |
| packages/docs/src/pages/docs/events.tsx | Documents the new event in the events reference page. |
| packages/docs/src/pages/docs/index.tsx | Adds route + sidebar entry for the new feature page. |
| packages/docs/src/pages/docs/updates/releases.tsx | Adds release note entry for the new event. |
| packages/docs/src/pages/home.tsx | Adds home page quick links related to APIs/events/types. |
| packages/docs/public/llms.txt | Regenerated llms.txt timestamp/content header. |
| knowledge/internal/sandbox-subscription-billing-issue.md | Adds internal manual sandbox E2E verification guide. |
| knowledge/external/storekit2-api.md | Updates external StoreKit2 reference (Message API and other drift fixes). |
| knowledge/external/google-billing-api.md | Updates external Google Billing reference (External Payments 8.3+). |
| knowledge/external/horizon-api.md | Updates Horizon reference (hyphenation + accessToken docs). |
| knowledge/_claude-context/context.md | Regenerated repo context snapshot. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- HybridRnIap.kt: track native listener instance and remove on endConnection to prevent duplicate registration on reconnect - Horizon test: wrap handler invocation in withTimeout(5s) to prevent CI hang if regression suspends forever - subscription-billing-issue.tsx: separate react-native-iap and expo-iap import examples for copy-paste correctness - OpenIapListener.kt: update KDoc to match actual semantics — fires once per session per purchaseToken, including already-suspended subscriptions - kmp-iap: always query subs with suspended=true for billing-issue notifier, then filter returned list based on caller preference - HybridRnIap.swift: align removeSubscriptionBillingIssueListener with other remove*Listener methods (removeAll pattern) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@knowledge/internal/sandbox-subscription-billing-issue.md`:
- Around line 69-70: The macOS bullet currently groups all macOS builds into a
silent no-op which conflicts with the earlier note that "Mac Catalyst 18+ is
supported"; update the macOS bullet in the list to exclude Catalyst by renaming
it to "native macOS (non-Catalyst)" or explicitly add a separate exception line
for "Mac Catalyst (Catalyst 18+) — supported", so the bullets read e.g. "native
macOS (non-Catalyst) build — silent no-op by design (StoreKit.Message API is
iOS-only)" and/or add "Mac Catalyst 18+ — supported" to avoid contradiction with
the existing Mac Catalyst statement.
In `@packages/apple/Sources/OpenIapModule.swift`:
- Around line 67-69: Currently initConnection() calls startMessageListener()
unconditionally which begins iterating StoreKit.Message.messages and can
suppress Apple's native billing sheet; change this so the StoreKit message loop
is opt-in: remove the unconditional call from initConnection() and instead start
startMessageListener() only when the app registers a
subscriptionBillingIssueListener (i.e., on the first
subscriptionBillingIssueListener registration callback/registration method), or
implement a fallback inside startMessageListener() that, when no custom handler
is installed, calls message.display(in:) (or DisplayMessageAction) to show the
native sheet; ensure the logic references startMessageListener(),
initConnection(), and the subscriptionBillingIssueListener registration path and
guards on the iOS 18+/Mac Catalyst 18+ behavior.
In `@packages/docs/src/pages/docs/features/subscription-billing-issue.tsx`:
- Around line 206-210: The doc paragraph incorrectly claims a purchase will
"re-emit" after recovery in the same session; clarify that Android's native
layer deduplicates by purchaseToken and only clears that set on
disconnect/reconnect, so update the wording in the paragraph referencing
getAvailablePurchases and purchaseToken to say the event will re-emit only after
a reconnect or app restart (or when the native session resets), not simply after
recovery in the same session.
🪄 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
Run ID: e742fa27-ce08-40eb-86fa-ce046683b958
📒 Files selected for processing (12)
knowledge/internal/sandbox-subscription-billing-issue.mdlibraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.ktlibraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.ktlibraries/react-native-iap/example/ios/example.xcodeproj/project.pbxprojlibraries/react-native-iap/ios/HybridRnIap.swiftlibraries/react-native-iap/src/__tests__/index.test.tslibraries/react-native-iap/src/index.tspackages/apple/Sources/OpenIapModule.swiftpackages/docs/src/pages/docs/features/subscription-billing-issue.tsxpackages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.ktpackages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.ktpackages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionHandlersBillingIssueHorizonTest.kt
✅ Files skipped from review due to trivial changes (2)
- libraries/react-native-iap/src/tests/index.test.ts
- libraries/react-native-iap/example/ios/example.xcodeproj/project.pbxproj
🚧 Files skipped from review as they are similar to previous changes (4)
- packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt
- libraries/react-native-iap/ios/HybridRnIap.swift
- packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionHandlersBillingIssueHorizonTest.kt
- packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
- apple: make Message.messages listener opt-in — only start when subscriptionBillingIssueListener is registered, not on initConnection. Prevents suppression of Apple's native billing-issue sheet for apps that don't use this event. - sandbox doc: exclude Mac Catalyst from silent no-op list (it's supported via iOS 18+ / Mac Catalyst 18+) - feature docs: clarify Android dedupe re-emission only happens after endConnection/app restart, not within the same session Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Unifies StoreKit 2's
Message.Reason.billingIssue(iOS 18+ / Mac Catalyst 18+) and Google Play Billing'sPurchase.isSuspended(Play Billing 8.1+) into a single cross-platform event —subscriptionBillingIssue. Apps now get one listener that fires on either platform when a user's active subscription needs attention due to a payment problem.Every downstream library, both example apps, the react-native-iap
useIAPhook, a feature docs page, and a Horizon no-op unit test land in this PR.What's in this PR
Schema (
packages/gql)IapEvent.SubscriptionBillingIssueenum value intype.graphql.subscriptionBillingIssue: Purchase!field onSubscriptioninevent.graphql.Types.swift,Types.kt,types.ts,types.dart,types.gd, and all 5 downstream library type mirrors.iOS (
packages/apple)subscriptionBillingIssueListeneradded toOpenIapModuleProtocol,OpenIapModule,OpenIapModule+ObjC, andIapState.startMessageListener()spins up aStoreKit.Message.messagesloop on init. Gated to iOS 18+ / Mac Catalyst 18+ (whereMessage.Reason.billingIssueexists). macOS / tvOS / watchOS / visionOS are silent no-ops (Message API not available on those platforms)..billingIssue, batches a singleStoreKit.Product.products(for:)call for all current auto-renewable entitlements, then iterates each subscription's full status array, emitting one event per subscription whose renewal state is.inBillingRetryPeriodor.inGracePeriod.Android Play flavor (
packages/google/.../play)addSubscriptionBillingIssueListener/removeSubscriptionBillingIssueListenerimplemented with thread-safeCopyOnWriteArraySet+ConcurrentHashMap.newKeySet()so add/remove on the main thread is safe against iteration fromDispatchers.IO.getAvailablePurchases(always queriesincludeSuspended=trueso suspended purchases reach the notifier even when the caller opted not to include them in the returned list) and inonPurchasesUpdated(push path, parity with iOS).purchaseToken.Android Horizon flavor (
packages/google/.../horizon) — explicit no-op + automated guaranteeadd/removeare implemented as no-ops withLog.wwarnings. Horizon Billing Compatibility SDK targets Play Billing 7.0 which does not exposeisSuspended. The listener interface exists for API parity but never fires on Horizon.SubscriptionBillingIssueHorizonNoOpTest(Robolectric) asserts the callback is never invoked, guarding against accidental Play-flavor logic leaking into Horizon. Runs on CI via:openiap:testHorizonDebugUnitTest.Downstream library bridges
add/removeSubscriptionBillingIssueListener), regenerated nitrogen bridge, Swift + Kotlin implementations, publicsubscriptionBillingIssueListener()JS API,useIAP({ onSubscriptionBillingIssue })hook callbackOpenIapEvent.SubscriptionBillingIssueenum,EventPayloadsmapping,subscriptionBillingIssueListener()JS API, Kotlin event emission viaExpoIapHelper.setupListeners, Swift event emission viaExpoIapHelper.setupListenerssubscriptionBillingIssueListenerStream<Purchase>onFlutterInappPurchase, Android method-channel forwarding, iOS method-channel forwardingsubscription_billing_issue(purchase)signal, Android AARSignalInfo+ listener registration, iOS GDExtension@Signal+ listener registrationsubscriptionBillingIssueListener: Flow<Purchase>incommonMain, Android backingMutableSharedFlow+ emission insidegetAvailablePurchasesHandler, iOS backing flow + cinterop subscription viaopenIapModule.addSubscriptionBillingIssueListenerExample apps (manual-test ready)
packages/apple/Example—SubscriptionFlowScreenrenders an orange "Subscription needs attention" banner with aFix payment methodbutton that callsOpenIapModule.shared.deepLinkToSubscriptions(nil). Listener registered insetupIapProvider, torn down on screen dispose.packages/google/Example— Compose LazyColumn gains an analogous banner registered viaDisposableEffect(iapStore), dispatchingiapStore.deepLinkToSubscriptionswith the failing purchase's SKU + package name.OpenIapStorenow exposesadd/removeSubscriptionBillingIssueListenerpass-through so Compose callers don't have to reach the underlyingOpenIapProtocol.Documentation
packages/docs/src/pages/docs/features/subscription-billing-issue.tsx— new/docs/features/subscription-billing-issueroute + sidebar entry. Platform-behavior table with min versions, recommended UX, per-language usage snippets (RN/expo, Flutter, Godot, KMP), deduping semantics.packages/docs/src/pages/docs/updates/releases.tsxentry for 2026-04-16.knowledge/internal/sandbox-subscription-billing-issue.md— step-by-step for iOS 18 sandbox (billing issue toggle / remove payment method) and Play 8.1+ sandbox (remove payment method / Play Console test suspensions). Includes a per-library smoke matrix usinglibraries-versions.jsonc "local"mode.knowledge/external/storekit2-api.md(Message API, eligibleWinBackOfferIDs, Transaction iOS 18.4 fields, iOS 17.4 correction),google-billing-api.md(External Payments 8.3+),horizon-api.md(accessToken field + hyphenation).knowledge/_claude-context/context.md+packages/docs/public/llms{,-full}.txtrecompiled.Verification (every target compiled locally before push)
swift build+swift test(87 tests)gradlew :openiap:compilePlayDebugKotlingradlew :openiap:compileHorizonDebugKotlingradlew :openiap:testHorizonDebugUnitTestgradlew :example:compilePlayDebugKotlinyarn specs+yarn tsc --noEmit -p tsconfig.build.jsonbun run tsc --noEmitflutter analyzegradlew :library:compileCommonMainKotlinMetadata compileDebugKotlinAndroidtsc --noEmit+prettier --checkCI is green across all workflows.
Release plan
All five downstream libraries pick this up as a minor version bump (new feature, additive, non-breaking). Version bumps + releases happen per-library after merge through their usual release workflows.
Test plan
SubscriptionBillingIssueHorizonNoOpTest(Robolectric). CI runs:openiap:testHorizonDebugUnitTest.deepLinkToSubscriptionsflow resolves on both platforms (existing API, unchanged; referenced from the new feature page and wired in both example app banners).knowledge/internal/sandbox-subscription-billing-issue.md.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation